Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ when needed; writes remain protected in the local VFS cache until uploaded.

The supported target is Linux Mint 22.x with Cinnamon, Nemo and a normal
graphical login. Arch Linux and Ubuntu are the first portability targets. The
Arch Linux `0.8.0-1` package candidate has passed its clean Cinnamon/X11
desktop gate and is attached to the `v0.8.0` GitHub Release for configured
real-world review; it is not yet a generally supported Arch target. See the
Arch Linux `0.8.1-1` package candidate preserves the package baseline whose
`0.8.0-1` build passed its clean Cinnamon/X11 desktop gate. It is attached to
the `v0.8.1` GitHub Release for configured real-world review; Arch is not yet a
generally supported target. See the
[distribution portability plan](docs/PORTABILITY.md) and the privacy-safe
[real desktop release gates](docs/DESKTOP_GATES.md).

Expand All @@ -96,12 +97,12 @@ cd proton-drive-linux
pdrive-ui
```

Arch reviewers can download the `proton-drive-linux-0.8.0-1-any.pkg.tar.zst`
asset from the [`v0.8.0` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.0),
Arch reviewers can download the `proton-drive-linux-0.8.1-1-any.pkg.tar.zst`
asset from the [`v0.8.1` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.1),
verify the SHA-256 published in its release notes and install it with:

```bash
sudo pacman -U ./proton-drive-linux-0.8.0-1-any.pkg.tar.zst
sudo pacman -U ./proton-drive-linux-0.8.1-1-any.pkg.tar.zst
pdrive-ui
```

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.8.0
0.8.1
26 changes: 22 additions & 4 deletions bin/pdrive-state
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ from typing import Any


SCHEMA_VERSION = 1
TOOL_VERSION = "0.8.0"
TOOL_VERSION = "0.8.1"
RECENT_TRANSFER_WINDOW_SECONDS = 24 * 60 * 60
RECENT_TRANSFER_LIMIT = 24
MOUNT_LOG_TAIL_BYTES = 512 * 1024
Expand Down Expand Up @@ -295,14 +295,24 @@ def rc_call(
connection.close()


def collect_rc(*, include_vfs: bool) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
def collect_rc(
*,
include_vfs: bool,
include_backend: bool,
) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
requests = [
("core/stats", None),
("core/transferred", None),
("backend/command", {"command": "data-bandwidth", "fs": "proton:"}),
]
if include_vfs:
requests.extend([("vfs/queue", None), ("vfs/stats", None)])
# backend/command resolves its fs argument through rclone's backend cache.
# Before the mount has finished registering proton:, every poll can create
# another login-capable Proton backend instance. Keep startup and terminal
# authentication polling strictly local; query the live backend only after
# systemd and the FUSE mount both prove that the managed instance is ready.
if include_backend:
requests.append(("backend/command", {"command": "data-bandwidth", "fs": "proton:"}))
payloads: dict[str, dict[str, Any]] = {}
errors: dict[str, str] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=len(requests)) as pool:
Expand Down Expand Up @@ -1345,7 +1355,15 @@ def build_state(include_capacity: bool = False) -> dict[str, Any]:
# vfs/* returns an error and writes an ERROR line to rclone's own log while
# the RC socket is up but the mount has not registered its VFS yet. Avoid
# manufacturing user-visible issues during this normal startup window.
rc_payloads, rc_errors = collect_rc(include_vfs=mount["ready"])
authentication_blocked = authentication.get("status") in {
"rate-limited",
"reauthorization-required",
}
runtime_ready = bool(service.get("active") and mount["ready"] and not authentication_blocked)
rc_payloads, rc_errors = collect_rc(
include_vfs=mount["ready"],
include_backend=runtime_ready,
)
mount_log = recent_mount_log()
stats = rc_payloads.get("core/stats", {})
transferred = rc_payloads.get("core/transferred", {}).get("transferred", [])
Expand Down
89 changes: 85 additions & 4 deletions bin/pdrive-ui
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ PLATFORM_ADAPTER = load_platform_adapter()


APP_ID = "io.github.claudiuschuster.PDriveControl"
VERSION = "0.8.0"
VERSION = "0.8.1"
REFRESH_INTERVAL_SECONDS = 2
REFRESH_INTERVAL_OPTIONS = (1, 2, 5, 10)
GRAPH_WINDOW_SECONDS = 5 * 60
Expand Down Expand Up @@ -1234,6 +1234,16 @@ window.pdrive-window {
padding: 1px 3px;
}
.status-card { border-radius: 16px; padding: 13px 20px; }
.status-action-banner .status-card { transition: 120ms ease-in-out; }
.status-action-banner:hover .status-card,
.status-action-banner:focus .status-card {
border-color: #ef7084;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
}
.status-action-banner:active .status-card {
background: #3f1a22;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.32);
}
.page.overview-page { padding-top: 18px; }
.status-ready { background: #12382f; border: 1px solid #23876e; }
.status-working { background: #153746; border: 1px solid #2e91b5; }
Expand Down Expand Up @@ -4173,7 +4183,68 @@ class PDriveWindow(Gtk.ApplicationWindow):
right.pack_start(self.updated_label, False, False, 0)
self.status_box.pack_end(right, False, False, 0)
self.status_frame = card(self.status_box, "status-card", "status-unknown")
return self.status_frame
self.status_banner = Gtk.EventBox()
self.status_banner.set_visible_window(False)
self.status_banner.set_above_child(True)
self.status_banner.set_can_focus(False)
self.status_banner.add_events(
Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.ENTER_NOTIFY_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
| Gdk.EventMask.FOCUS_CHANGE_MASK
)
self.status_banner.connect("button-release-event", self.on_status_banner_release)
self.status_banner.connect("key-release-event", self.on_status_banner_key_release)
self.status_banner.connect("enter-notify-event", self.on_status_banner_enter)
self.status_banner.connect("leave-notify-event", self.on_status_banner_leave)
self.status_banner.add(self.status_frame)
self.status_banner_reauthorizes = False
return self.status_banner

def set_status_banner_reauthorization(self, enabled: bool) -> None:
self.status_banner_reauthorizes = enabled
self.status_banner.set_can_focus(enabled)
self.status_banner.set_tooltip_text(translate("Reauthorize Proton account …") if enabled else None)
context = self.status_banner.get_style_context()
if enabled:
context.add_class("status-action-banner")
else:
context.remove_class("status-action-banner")
event_window = self.status_banner.get_window()
if event_window is not None:
event_window.set_cursor(None)

def on_status_banner_release(self, _widget: Gtk.Widget, event: Gdk.EventButton) -> bool:
if not self.status_banner_reauthorizes or event.button != 1:
return False
self.on_reauthorize(None)
return True

def on_status_banner_key_release(self, _widget: Gtk.Widget, event: Gdk.EventKey) -> bool:
if not self.status_banner_reauthorizes:
return False
if event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space):
return False
self.on_reauthorize(None)
return True

def on_status_banner_enter(self, _widget: Gtk.Widget, _event: Gdk.EventCrossing) -> bool:
if not self.status_banner_reauthorizes:
return False
event_window = self.status_banner.get_window()
if event_window is not None:
display = event_window.get_display()
cursor = Gdk.Cursor.new_from_name(display, "pointer")
if cursor is None:
cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.HAND2)
event_window.set_cursor(cursor)
return False

def on_status_banner_leave(self, _widget: Gtk.Widget, _event: Gdk.EventCrossing) -> bool:
event_window = self.status_banner.get_window()
if event_window is not None:
event_window.set_cursor(None)
return False

def graph_with_axes(self, graph: SpeedGraph) -> Gtk.Widget:
overlay = Gtk.Overlay()
Expand Down Expand Up @@ -4984,8 +5055,18 @@ class PDriveWindow(Gtk.ApplicationWindow):
else:
status_title = STATUS_META.get(live_status, STATUS_META["unknown"])[1]
self.update_status(live_status, translate(status_title), live_summary)
self.status_action.set_visible(reauthorization_required)
self.reauthorize_menu_button.set_visible(reauthorization_required)
if reauthorization_required:
# Both controls use no-show-all while inactive. Showing only the
# parent would leave the custom menu row's icon and label hidden.
for control in (self.status_action, self.reauthorize_menu_button):
child = control.get_child()
if child is not None:
child.show_all()
control.show()
else:
self.status_action.hide()
self.reauthorize_menu_button.hide()
self.set_status_banner_reauthorization(reauthorization_required)
issues = state.get("issues", {})
application = self.get_application()
if (
Expand Down
27 changes: 15 additions & 12 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,11 +687,12 @@ the current service start, writes mode-0600
`~/.local/state/rclone/pdrive-auth-state.json`, cancels the pending
`Restart=on-failure` retry and sends one notification according to the Control
Center notification preference. It never records a username, password, TOTP,
API URL or session token. The Overview then shows **Reauthorization required**
with a dedicated button; only while this state is active, the same action is
available under **hamburger menu → Reauthorize Proton account …**. Both actions
disappear again after successful reauthorization. A rate-limit state keeps the
action unavailable until its saved cooldown expires.
API URL or session token. The Overview then shows **Reauthorization required**;
the complete red banner and its dedicated button open the guided action by
pointer or keyboard. Only while this state is active, the same action is
available under **hamburger menu → Reauthorize Proton account …**. These actions
disappear again after successful reauthorization. A rate-limit state keeps them
unavailable until its saved cooldown expires.

The native dialog uses the account already present in the encrypted
configuration and requests only the current account password and optional fresh
Expand All @@ -703,13 +704,15 @@ the old configuration and the stopped retry guard intact. A successful login
backs up and atomically replaces the encrypted configuration, clears the
one-time code, starts `/pdrive` and changes the authentication state to ready.

Only concrete HTTP 429 evidence in the private isolated-login log activates the
rate-limit state; generic login rejection and HTTP 422 do not. PDrive stores a
credential-free `retry_after` timestamp with a conservative one-hour cooldown,
shows it in the Overview, turns the dialog into a Close-only explanation and
blocks the hamburger-menu and direct-service-start bypasses. When the timestamp
expires, the normalized state automatically offers reauthorization again. A
successful isolated login clears either terminal state immediately.
Only concrete HTTP 429 evidence from Proton's authentication endpoint in the
current service-start log or the private isolated-login log activates the
rate-limit state; metadata 429 responses, generic login rejection and HTTP 422
do not. PDrive stores a credential-free `retry_after` timestamp with a
conservative one-hour cooldown, shows it in the Overview, turns the dialog into
a Close-only explanation and blocks the hamburger-menu and direct-service-start
bypasses. When the timestamp expires, the normalized state automatically offers
reauthorization again. A successful isolated login clears either terminal state
immediately.

The equivalent terminal fallback remains:

Expand Down
13 changes: 7 additions & 6 deletions docs/QUICK_START.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ upload. Detailed maintenance belongs in [Operations](OPERATIONS.md), not here.

The supported target is Linux Mint 22.x with Cinnamon, Nemo and a graphical
login. Arch Linux and Ubuntu are active portability targets. The Arch Linux
`0.8.0-1` candidate has passed its clean Cinnamon/X11 desktop gate and is ready
for configured real-world review, but is not yet a generally supported release
target; see [Distribution portability](PORTABILITY.md).
`0.8.1-1` candidate preserves the package baseline whose `0.8.0-1` build passed
its clean Cinnamon/X11 desktop gate. It is ready for configured real-world
review, but Arch is not yet a generally supported release target; see
[Distribution portability](PORTABILITY.md).

```bash
git clone https://github.com/oss-singularity/proton-drive-linux.git
Expand All @@ -24,12 +25,12 @@ pdrive-ui
```

For the reviewed Arch candidate, download
`proton-drive-linux-0.8.0-1-any.pkg.tar.zst` from the
[`v0.8.0` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.0),
`proton-drive-linux-0.8.1-1-any.pkg.tar.zst` from the
[`v0.8.1` release](https://github.com/oss-singularity/proton-drive-linux/releases/tag/v0.8.1),
verify its published SHA-256 and replace the source-install commands above with:

```bash
sudo pacman -U ./proton-drive-linux-0.8.0-1-any.pkg.tar.zst
sudo pacman -U ./proton-drive-linux-0.8.1-1-any.pkg.tar.zst
pdrive-ui
```

Expand Down
28 changes: 21 additions & 7 deletions libexec/pdrive-auth-failure-guard
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ notify_reauthorization_required() {
}

after_service_exit() {
local log_offset current_size new_log terminal_state_active=false
local log_offset current_size new_log current_status=''
local fresh_two_factor=false login_rate_limited=false

[[ -r "${attempt_file}" && -f "${mount_log}" ]] || return 0
log_offset="$(awk -F= '$1 == "log_offset" && $2 ~ /^[0-9]+$/ { print $2; exit }' "${attempt_file}")"
Expand All @@ -157,18 +158,31 @@ after_service_exit() {
(( current_size >= log_offset )) || return 0

new_log="$(tail -c "+$((log_offset + 1))" -- "${mount_log}" 2>/dev/null || true)"
if ! grep -Fq \
if grep -Fq \
"this account requires a 2FA code. Can be provided with --protondrive-2fa=000000" \
<<< "${new_log}"; then
fresh_two_factor=true
fi
# Only an HTTP 429 from Proton's authentication endpoint is a login
# cooldown. Metadata and file-operation 429 responses remain ordinary
# runtime evidence and must not suppress a later healthy service start.
if grep -Eqi \
'(^|[^0-9])429([^0-9]|$)[^[:cntrl:]]*POST https://[^[:space:]]+/auth(/|[[:space:]:])' \
<<< "${new_log}"; then
login_rate_limited=true
fi
if [[ "${fresh_two_factor}" == false && "${login_rate_limited}" == false ]]; then
return 0
fi

if [[ -r "${auth_state_file}" ]] \
&& grep -Eq '"status": "(rate-limited|reauthorization-required)"' \
"${auth_state_file}"; then
terminal_state_active=true
if [[ -r "${auth_state_file}" ]] && command -v jq >/dev/null 2>&1; then
current_status="$(jq -r '.status // empty' "${auth_state_file}" 2>/dev/null || true)"
fi
if [[ "${terminal_state_active}" == false ]]; then
if [[ "${login_rate_limited}" == true && "${current_status}" != 'rate-limited' ]]; then
mark_rate_limited
elif [[ "${fresh_two_factor}" == true \
&& "${current_status}" != 'rate-limited' \
&& "${current_status}" != 'reauthorization-required' ]]; then
write_auth_state 'reauthorization-required' 'two-factor-required' true
notify_reauthorization_required
fi
Expand Down
34 changes: 34 additions & 0 deletions tests/test-auth-failure-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,40 @@ grep -qF 'PDrive needs reauthorization' "${events}"
run_guard --mark-healthy
run_guard --start-allowed

ready_state="$(sha256sum "${state_dir}/pdrive-auth-state.json")"
service_stop_count="$(grep -c '^systemctl:' "${events}")"
run_guard --begin-start
printf '%s\n' \
"2026/08/27 02:23:17 ERROR : proton drive root link ID 'redacted': 429 GET https://drive-api.proton.me/core/v4/users: Too many requests (Status=429)" \
>> "${mount_log}"
run_guard --after-service-exit
[[ "$(sha256sum "${state_dir}/pdrive-auth-state.json")" == "${ready_state}" ]]
[[ "$(grep -c '^systemctl:' "${events}")" == "${service_stop_count}" ]]

rate_limit_started="$(date +%s)"
run_guard --begin-start
printf '%s\n' \
"2026/08/27 02:23:18 ERROR : proton drive root link ID 'redacted': 429 GET https://drive-api.proton.me/core/v4/users: Too many requests (Status=429)" \
"2026/08/27 02:23:19 ERROR : rc: backend command: this account requires a 2FA code. Can be provided with --protondrive-2fa=000000" \
"2026/08/27 02:23:20 CRITICAL: Failed to create file system: 429 POST https://drive-api.proton.me/auth/v4: Too many requests (Code=2011, Status=429)" \
>> "${mount_log}"
notification_count="$(grep -c '^notify-send:' "${events}")"
run_guard --after-service-exit
jq -e '
.status == "rate-limited"
and .reason == "login-rate-limited"
and .restart_suppressed == true
and (.retry_after | type == "string")
' "${state_dir}/pdrive-auth-state.json" >/dev/null
retry_epoch="$(date --date="$(jq -r .retry_after "${state_dir}/pdrive-auth-state.json")" +%s)"
(( retry_epoch >= rate_limit_started + 119 ))
(( retry_epoch <= rate_limit_started + 121 ))
[[ "$(grep -c '^notify-send:' "${events}")" == "${notification_count}" ]]
rate_limited_state="$(sha256sum "${state_dir}/pdrive-auth-state.json")"
run_guard --after-service-exit
[[ "$(sha256sum "${state_dir}/pdrive-auth-state.json")" == "${rate_limited_state}" ]]
run_guard --mark-healthy

rate_limit_started="$(date +%s)"
run_guard --mark-rate-limited
jq -e '
Expand Down
Loading