diff --git a/AGENTS.md b/AGENTS.md index 43ccb8f..9683b97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,12 @@ project-specific; CI remains the authority for mechanical formatting rules. - Preserve rclone bandwidth semantics: `0`/`off` means unlimited. A UI near-pause must use the documented low nonzero rate and must never be presented as a native pause. +- Apply upload and download limits only to Proton bulk file payloads through + the backend `data-bandwidth` runtime command. Never apply rclone's global + transport limiter to the managed mount because it also throttles metadata + requests. Never pass the backend limits as mount options: option changes alter + the VFS cache fingerprint. Mount first, then apply saved limits through the + owner-only RC socket so an existing Dirty queue remains in the same namespace. - Use “PDrive” for this project and local tooling; use “Proton Drive” or “Proton cloud” for Proton’s service and web destination. - An issue counter must lead to reviewable evidence before acknowledgment: @@ -123,9 +129,11 @@ project-specific; CI remains the authority for mechanical formatting rules. pause, two separated idle probes and the same strict generation/namespace/ queue validation. Persist at most six bridge-unwedge restarts per exact cache generation with a 30-minute gap; never reset that budget automatically. -- New installations are temporarily pinned to the tested official fixed rclone - 1.76 beta. The updater must hold it rather than downgrade to an older stable - release, then return to stable automatically once stable 1.76 or newer exists. +- New installations use the checksum-pinned OSS Singularity rclone build whose + public source contains the tested retry, bridge-worker and backend file-data + 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. - 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. diff --git a/README.md b/README.md index 7de2f24..830ad8d 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Keyring and provides a native GTK control center for the details that matter. - Active transfers, upload queue with a smoothed remaining-time estimate, service diagnostics, bounded health history and reviewable issue evidence. - Clear Proton cloud used/total/free, local free-space and VFS-cache values. -- Fine-grained bandwidth plus guarded upload-slot, cache-retention, - metadata-refresh and restart controls. +- Independent upload/download file-data limits, guided connection tuning and + guarded upload-slot, cache-retention, metadata-refresh and restart controls. - Conservative health monitoring with desktop notifications. - Guided first setup, native account reauthorization, encrypted credentials and signed rclone updates. @@ -85,9 +85,12 @@ pdrive-ui The installer may request `sudo` once to create the owner-only `/pdrive` directory. On first launch, the setup wizard checks prerequisites, can install -missing Debian/Ubuntu/Mint packages through Polkit, prepares a current -Proton-capable rclone and guides you through username, password and optional -2FA. Credentials never appear in command arguments or environment variables. +missing Debian/Ubuntu/Mint packages through Polkit, prepares PDrive's verified +Proton-capable rclone, offers automatic or manual connection headroom and then +guides you through username, password and optional 2FA. Automatic tuning uses a +bounded Cloudflare speed test and reserves capacity for browsing and other +applications. Credentials never appear in command arguments or environment +variables. If Proton later requires a fresh login, PDrive stops automatic service retries, shows one actionable notification and offers reauthorization directly in the @@ -99,6 +102,13 @@ UI or manual service start can create another premature login attempt. Existing configuration, credentials, cache and state are preserved when the installer is run again. +

+ PDrive first-run wizard with automatic, manual and unlimited connection policies +

+ +

Approachable automatic tuning, with independent expert controls when wanted.

+ ## Start using PDrive Complete the first-run wizard, select **Open PDrive folder**, and work in @@ -127,12 +137,14 @@ git pull --ff-only ./install.sh ``` -New installations temporarily use a pinned official rclone 1.76 beta containing -the upstream fix for [rclone #9722](https://github.com/rclone/rclone/issues/9722). -The weekly updater holds that tested build until stable rclone 1.76 or newer is -available, then follows stable releases again. It never restarts an active -mount. The optional official Proton Drive CLI is a separate client and is not -required by this project. +PDrive installs a pinned, checksum-verified rclone build published from its +public source fork. It contains the upstream fix for +[rclone #9722](https://github.com/rclone/rclone/issues/9722), a source-pinned API +bridge fix and a Proton file-data limiter that leaves metadata browsing outside +bulk transfer limits. The weekly updater stays on that reviewed PDrive build +until this project publishes a replacement and never restarts an active mount. +The optional official Proton Drive CLI is a separate client and is not required +by this project. ## Uninstall diff --git a/bin/pdrive-bwlimit b/bin/pdrive-bwlimit index ca5051e..1f49491 100755 --- a/bin/pdrive-bwlimit +++ b/bin/pdrive-bwlimit @@ -4,10 +4,10 @@ set -uo pipefail umask 077 -readonly config_file="${HOME}/.config/pdrive-bwlimit.conf" -readonly state_dir="${HOME}/.local/state/rclone" -readonly rc_socket="${state_dir}/pdrive-rc.sock" -readonly rclone_bin="${HOME}/.local/libexec/rclone-bin" +readonly config_file="${PDRIVE_BWLIMIT_CONFIG:-${HOME}/.config/pdrive-bwlimit.conf}" +readonly state_dir="${PDRIVE_RCLONE_STATE_DIR:-${HOME}/.local/state/rclone}" +readonly rc_socket="${PDRIVE_RC_SOCKET:-${state_dir}/pdrive-rc.sock}" +readonly rclone_bin="${PDRIVE_RCLONE_BIN:-${HOME}/.local/libexec/rclone-bin}" usage() { printf '%s\n' \ @@ -186,15 +186,48 @@ rc_call() { timeout --signal=TERM 10s "${rclone_bin}" rc --unix-socket "${rc_socket}" "$@" } +rc_endpoint_available() { + [[ -S "${rc_socket}" || "${PDRIVE_BWLIMIT_TEST_SOCKET_READY:-}" == '1' ]] +} + query_live_rate() { - local response + local response upload download - [[ -S "${rc_socket}" ]] || return 1 - response="$(rc_call core/bwlimit 2>/dev/null)" || return 1 - live_rate="$(jq -r '.rate // empty' <<< "${response}" 2>/dev/null || true)" + rc_endpoint_available || return 1 + response="$(rc_call backend/command \ + 'command=data-bandwidth' 'fs=proton:' 2>/dev/null)" || return 1 + upload="$(jq -r '.result.upload // empty' <<< "${response}" 2>/dev/null || true)" + download="$(jq -r '.result.download // empty' <<< "${response}" 2>/dev/null || true)" + rate_is_safe "${upload}" || return 1 + rate_is_safe "${download}" || return 1 + if [[ "${upload}" == 'off' && "${download}" == 'off' ]]; then + live_rate='off' + else + live_rate="${upload}:${download}" + fi rate_is_safe "${live_rate}" } +apply_live_rate() { + local rate="${1:-off}" + local upload='off' download='off' options response + + if [[ "${rate}" == *:* ]]; then + upload="${rate%%:*}" + download="${rate#*:}" + elif [[ "${rate}" != 'off' ]]; then + upload="${rate}" + download="${rate}" + fi + options="$(jq -cn --arg upload "${upload}" --arg download "${download}" \ + '{upload: $upload, download: $download}')" || return 1 + response="$(rc_call backend/command \ + 'command=data-bandwidth' 'fs=proton:' "opt=${options}" 2>/dev/null)" || return 1 + applied_upload="$(jq -r '.result.upload // empty' <<< "${response}" 2>/dev/null || true)" + applied_download="$(jq -r '.result.download // empty' <<< "${response}" 2>/dev/null || true)" + rate_is_safe "${applied_upload}" && rate_is_safe "${applied_download}" +} + mkdir -p -- "${state_dir}" exec 9>"${state_dir}/pdrive-bwlimit.lock" if ! flock -w 15 9; then @@ -211,7 +244,7 @@ case "${1:-status}" in usage exit 0 ;; - status) + status|--status) if (( $# > 1 )); then printf 'Too many arguments (help: pdrive-bwlimit --help).\n' >&2 exit 2 @@ -236,6 +269,26 @@ case "${1:-status}" in ' Runtime control becomes active at the next service start.' fi ;; + --apply-startup) + if (( $# != 1 )); then + printf 'Too many arguments for startup application.\n' >&2 + exit 2 + fi + load_configured_rate || { + printf 'Error: %s.\n' "${config_warning}" >&2 + exit 78 + } + for _attempt in {1..40}; do + if rc_endpoint_available && apply_live_rate "${configured_rate}"; then + printf 'Active: %s\n' "$(describe_rate "${configured_rate}")" + printf 'Startup: saved bulk file-data limits applied; Proton API traffic remains outside them.\n' + exit 0 + fi + sleep 0.25 + done + printf 'Error: the running Proton backend did not accept its saved file-data limits.\n' >&2 + exit 1 + ;; *) if (( $# != 1 )); then printf 'Provide exactly one limit (help: pdrive-bwlimit --help).\n' >&2 @@ -249,14 +302,18 @@ case "${1:-status}" in fi if query_live_rate; then - if ! response="$(rc_call core/bwlimit "rate=${canonical_rate}" 2>/dev/null)"; then + if ! apply_live_rate "${canonical_rate}"; then printf 'Saved: %s\n' "$(describe_rate "${canonical_rate}")" printf '%s\n' \ 'Error: the running rclone process rejected the change.' \ 'The saved value applies at the next service start at the latest.' >&2 exit 1 fi - applied_rate="$(jq -r '.rate // empty' <<< "${response}" 2>/dev/null || true)" + if [[ "${applied_upload}" == 'off' && "${applied_download}" == 'off' ]]; then + applied_rate='off' + else + applied_rate="${applied_upload}:${applied_download}" + fi if ! rate_is_safe "${applied_rate}"; then printf 'Error: rclone returned an unexpected runtime response.\n' >&2 exit 70 diff --git a/bin/pdrive-network-tune b/bin/pdrive-network-tune new file mode 100755 index 0000000..4a30314 --- /dev/null +++ b/bin/pdrive-network-tune @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail +umask 077 + +readonly provider_url="${PDRIVE_TUNE_PROVIDER_URL:-https://speed.cloudflare.com}" +readonly curl_bin="${PDRIVE_CURL_BIN:-$(command -v curl || true)}" +readonly result_file="${HOME}/.config/pdrive-network-tune.json" +readonly bulk_percent=60 +readonly reserve_percent=40 +readonly warmup_bytes=1000000 +readonly download_bytes=25000000 +readonly upload_bytes=10000000 +readonly approximate_total_bytes=72000000 + +usage() { + printf '%s\n' \ + 'Usage: pdrive-network-tune [status|measure [--json]]' \ + '' \ + 'status: show the last locally stored recommendation; never uses the network.' \ + 'measure: explicitly run a bounded connection test against speed.cloudflare.com.' \ + ' About 72 MB are transferred and only local measurements are stored.' \ + ' The recommendation reserves 40% for responsive browsing and other traffic.' \ + '--json: emit the new measurement as JSON for the setup wizard.' +} + +case "${1:-status}" in + -h|--help|help) + (( $# == 1 )) || { + printf 'Too many arguments.\n' >&2 + exit 2 + } + usage + exit 0 + ;; +esac + +valid_result_file() { + [[ -r "${result_file}" ]] \ + && jq -e ' + .schema == 1 + and (.measured_at | type == "string") + and (.upload.recommended_mib_per_second | type == "number") + and (.download.recommended_mib_per_second | type == "number") + and .bulk_percent == 60 + and .reserve_percent == 40 + ' "${result_file}" >/dev/null 2>&1 +} + +show_status() { + if ! valid_result_file; then + printf 'No valid local network-tune result is available yet.\n' + printf 'Run pdrive-network-tune measure after reviewing pdrive-network-tune --help.\n' + return 1 + fi + jq -r ' + "Measured: \(.measured_at)", + "Provider: \(.provider)", + "Upload: \(.upload.measured_mib_per_second) MiB/s measured; \(.upload.recommended_mib_per_second) MiB/s bulk limit", + "Download: \(.download.measured_mib_per_second) MiB/s measured; \(.download.recommended_mib_per_second) MiB/s bulk limit", + "Reserved: \(.reserve_percent)% for browsing and other traffic" + ' "${result_file}" +} + +numeric_speed() { + [[ "${1:-}" =~ ^[0-9]+([.][0-9]+)?$ ]] && awk -v value="$1" 'BEGIN { exit !(value > 0) }' +} + +download_sample() { + local bytes="$1" + local speed + + speed="$("${curl_bin}" \ + --silent --show-error --fail \ + --connect-timeout 10 --max-time 45 \ + --output /dev/null --write-out '%{speed_download}' \ + "${provider_url}/__down?bytes=${bytes}")" + numeric_speed "${speed}" || { + printf 'The download measurement returned an invalid result.\n' >&2 + return 1 + } + printf '%s\n' "${speed}" +} + +upload_sample() { + local bytes="$1" + local speed + + speed="$(head -c "${bytes}" /dev/zero | "${curl_bin}" \ + --silent --show-error --fail \ + --connect-timeout 10 --max-time 45 \ + --output /dev/null --write-out '%{speed_upload}' \ + --header 'Content-Type: application/octet-stream' \ + --data-binary @- \ + "${provider_url}/__up?bytes=${bytes}")" + numeric_speed "${speed}" || { + printf 'The upload measurement returned an invalid result.\n' >&2 + return 1 + } + printf '%s\n' "${speed}" +} + +lower_speed() { + awk -v first="$1" -v second="$2" 'BEGIN { print first < second ? first : second }' +} + +to_mib() { + awk -v bytes_per_second="$1" 'BEGIN { printf "%.2f", bytes_per_second / 1048576 }' +} + +recommend_mib() { + awk -v bytes_per_second="$1" -v percent="${bulk_percent}" ' + BEGIN { + recommended = bytes_per_second / 1048576 * percent / 100 + if (recommended < 0.02) recommended = 0.02 + printf "%.2f", recommended + } + ' +} + +measure() { + local output_json="$1" + local download_first download_second upload_first upload_second + local download_speed upload_speed download_mib upload_mib + local download_recommended upload_recommended measured_at + local result_dir result_tmp + + [[ -n "${curl_bin}" && -x "${curl_bin}" ]] || { + printf 'curl is required for the connection measurement.\n' >&2 + return 69 + } + command -v jq >/dev/null 2>&1 || { + printf 'jq is required for the connection measurement.\n' >&2 + return 69 + } + + # Small warmups establish TLS and route state; only the two bounded full + # samples per direction feed the conservative recommendation. + download_sample "${warmup_bytes}" >/dev/null + download_first="$(download_sample "${download_bytes}")" + download_second="$(download_sample "${download_bytes}")" + upload_sample "${warmup_bytes}" >/dev/null + upload_first="$(upload_sample "${upload_bytes}")" + upload_second="$(upload_sample "${upload_bytes}")" + + download_speed="$(lower_speed "${download_first}" "${download_second}")" + upload_speed="$(lower_speed "${upload_first}" "${upload_second}")" + download_mib="$(to_mib "${download_speed}")" + upload_mib="$(to_mib "${upload_speed}")" + download_recommended="$(recommend_mib "${download_speed}")" + upload_recommended="$(recommend_mib "${upload_speed}")" + measured_at="$(date --utc +'%Y-%m-%dT%H:%M:%SZ')" + + result_dir="${result_file%/*}" + mkdir -p -- "${result_dir}" + result_tmp="$(mktemp "${result_dir}/pdrive-network-tune.json.XXXXXX")" + if ! jq -n \ + --arg measured_at "${measured_at}" \ + --arg provider "${provider_url}" \ + --argjson transferred_bytes "${approximate_total_bytes}" \ + --argjson bulk_percent "${bulk_percent}" \ + --argjson reserve_percent "${reserve_percent}" \ + --argjson upload_measured "${upload_mib}" \ + --argjson upload_recommended "${upload_recommended}" \ + --argjson download_measured "${download_mib}" \ + --argjson download_recommended "${download_recommended}" \ + '{ + schema: 1, + measured_at: $measured_at, + provider: $provider, + approximate_transferred_bytes: $transferred_bytes, + bulk_percent: $bulk_percent, + reserve_percent: $reserve_percent, + upload: { + measured_mib_per_second: $upload_measured, + recommended_mib_per_second: $upload_recommended + }, + download: { + measured_mib_per_second: $download_measured, + recommended_mib_per_second: $download_recommended + } + }' > "${result_tmp}"; then + rm -f -- "${result_tmp}" + return 1 + fi + chmod 0600 "${result_tmp}" + mv -f -- "${result_tmp}" "${result_file}" + + if [[ "${output_json}" == true ]]; then + cat -- "${result_file}" + else + show_status + fi +} + +case "${1:-status}" in + status) + (( $# == 1 )) || { + printf 'Too many arguments (help: pdrive-network-tune --help).\n' >&2 + exit 2 + } + show_status + ;; + measure) + case "${2:-}" in + '') measure false ;; + --json) + (( $# == 2 )) || { + printf 'Too many arguments (help: pdrive-network-tune --help).\n' >&2 + exit 2 + } + measure true + ;; + *) + printf 'Unknown argument: %s\n' "${2}" >&2 + exit 2 + ;; + esac + ;; + *) + printf 'Unknown action: %s\n' "${1}" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/bin/pdrive-prerequisites b/bin/pdrive-prerequisites index 43bba40..cfed300 100755 --- a/bin/pdrive-prerequisites +++ b/bin/pdrive-prerequisites @@ -5,9 +5,12 @@ set -euo pipefail umask 077 readonly target_rclone="${PDRIVE_REAL_RCLONE:-${HOME}/.local/libexec/rclone-bin}" -readonly safe_rclone_beta='v1.76.0-beta.10204.660144d31' readonly minimum_safe_rclone='v1.76.0' readonly minimum_safe_beta_build=10204 +readonly pdrive_rclone_release='pdrive-v1.76.0-beta.10204.1' +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:-77a1abfddd9b9badcdae866319ace9575db680486704d79d8d4f9c8b04c6c41e}" +readonly curl_bin="${PDRIVE_CURL_BIN:-$(command -v curl || true)}" usage() { printf '%s\n' \ @@ -15,26 +18,14 @@ usage() { '' \ 'Without an option or with --help, this command only prints help.' \ '--check verify the user-local rclone and Proton backend' \ - '--install-rclone bootstrap the pinned official upload-safe rclone beta' \ - ' atomically from an installed distribution rclone;' \ - ' the updater returns to stable when 1.76 is released' \ + '--install-rclone download and verify the pinned PDrive rclone build' \ + ' atomically from the OSS Singularity release;' \ + ' its source and checksum are published with the asset' \ '' \ 'System packages and /pdrive are intentionally prepared by trusted' \ 'system executables through Polkit, not by this user-writable helper.' } -find_bootstrap_rclone() { - local candidate - for candidate in "${PDRIVE_BOOTSTRAP_RCLONE:-}" /usr/bin/rclone /usr/local/bin/rclone; do - [[ -n "${candidate}" ]] || continue - if [[ -x "${candidate}" && "${candidate}" != "${target_rclone}" ]]; then - printf '%s\n' "${candidate}" - return 0 - fi - done - return 1 -} - rclone_upload_retry_safe() { local candidate="$1" first_line version base beta_suffix beta_build newest first_line="$("${candidate}" version 2>/dev/null | head -n 1)" || return 1 @@ -54,7 +45,20 @@ rclone_upload_retry_safe() { check_rclone() { [[ -x "${target_rclone}" ]] || return 1 rclone_upload_retry_safe "${target_rclone}" \ - && "${target_rclone}" help backend protondrive >/dev/null 2>&1 + && rclone_provides_data_limiter "${target_rclone}" +} + +rclone_provides_data_limiter() { + local candidate="$1" + + "${candidate}" help backend protondrive >/dev/null 2>&1 || return 1 + "${candidate}" backend help protondrive 2>/dev/null \ + | grep -q '^### data-bandwidth$' +} + +installed_release_matches() { + [[ -x "${target_rclone}" ]] || return 1 + [[ "$(sha256sum "${target_rclone}" | cut -d ' ' -f 1)" == "${pdrive_rclone_sha256}" ]] } case "${1:-}" in @@ -74,29 +78,38 @@ case "${1:-}" in ;; --install-rclone) (( $# == 1 )) || { usage >&2; exit 2; } - if check_rclone; then - printf 'Already ready: %s\n' "${target_rclone}" + if installed_release_matches && check_rclone; then + printf 'Already current: %s provides %s.\n' "${target_rclone}" "${pdrive_rclone_release}" exit 0 fi - bootstrap_rclone="$(find_bootstrap_rclone || true)" - if [[ -z "${bootstrap_rclone}" ]]; then - printf 'No distribution rclone was found in /usr/bin or /usr/local/bin.\n' >&2 + if [[ "$(uname -m)" != 'x86_64' ]]; then + printf 'The pinned PDrive rclone build currently supports x86-64 Linux only.\n' >&2 + exit 69 + fi + if [[ -z "${curl_bin}" || ! -x "${curl_bin}" ]]; then + printf 'curl is required to download the pinned PDrive rclone build.\n' >&2 exit 69 fi mkdir -p -- "${target_rclone%/*}" temporary_rclone="$(mktemp "${target_rclone%/*}/.rclone-bin.XXXXXX")" cleanup() { rm -f -- "${temporary_rclone:-}"; } trap cleanup EXIT - install -m 0755 "${bootstrap_rclone}" "${temporary_rclone}" - "${temporary_rclone}" selfupdate --beta --version "${safe_rclone_beta}" + "${curl_bin}" --fail --location --silent --show-error \ + --connect-timeout 15 --max-time 300 \ + --output "${temporary_rclone}" "${pdrive_rclone_url}" + if [[ "$(sha256sum "${temporary_rclone}" | cut -d ' ' -f 1)" != "${pdrive_rclone_sha256}" ]]; then + printf 'The downloaded PDrive rclone checksum did not match.\n' >&2 + exit 70 + fi + chmod 0755 "${temporary_rclone}" if ! rclone_upload_retry_safe "${temporary_rclone}" \ - || ! "${temporary_rclone}" help backend protondrive >/dev/null 2>&1; then - printf 'The downloaded rclone is not an upload-safe Proton Drive build.\n' >&2 + || ! rclone_provides_data_limiter "${temporary_rclone}"; then + printf 'The downloaded rclone is not the required PDrive Proton build.\n' >&2 exit 70 fi mv -f -- "${temporary_rclone}" "${target_rclone}" trap - EXIT - printf 'Installed the pinned upload-safe rclone beta at %s.\n' "${target_rclone}" + printf 'Installed verified %s at %s.\n' "${pdrive_rclone_release}" "${target_rclone}" ;; -h|--help) (( $# == 1 )) || { usage >&2; exit 2; } diff --git a/bin/pdrive-state b/bin/pdrive-state index 4ac0662..a141bad 100755 --- a/bin/pdrive-state +++ b/bin/pdrive-state @@ -232,15 +232,17 @@ class UnixHTTPConnection(http.client.HTTPConnection): self.sock = connection -def rc_call_cli(endpoint: str) -> tuple[str, dict[str, Any] | None, str]: +def rc_call_cli( + endpoint: str, + params: dict[str, str] | None = None, +) -> tuple[str, dict[str, Any] | None, str]: if not RCLONE_BIN.is_file() or not os.access(RCLONE_BIN, os.X_OK): return endpoint, None, f"rclone is missing: {RCLONE_BIN}" if not RC_SOCKET.exists(): return endpoint, None, f"RC socket is missing: {RC_SOCKET}" - code, stdout, stderr = run( - [str(RCLONE_BIN), "rc", "--unix-socket", str(RC_SOCKET), endpoint], - timeout=4.0, - ) + command = [str(RCLONE_BIN), "rc", "--unix-socket", str(RC_SOCKET), endpoint] + command.extend(f"{key}={value}" for key, value in (params or {}).items()) + code, stdout, stderr = run(command, timeout=4.0) if code != 0: return endpoint, None, stderr.strip() or f"RC call exited with {code}" try: @@ -252,12 +254,15 @@ def rc_call_cli(endpoint: str) -> tuple[str, dict[str, Any] | None, str]: return endpoint, payload, "" -def rc_call(endpoint: str) -> tuple[str, dict[str, Any] | None, str]: +def rc_call( + endpoint: str, + params: dict[str, str] | None = None, +) -> tuple[str, dict[str, Any] | None, str]: # The CLI fallback exists for isolated fixture tests and unusual debugging. # Production polling talks HTTP directly over the already owner-only Unix # socket. This avoids starting five large Go processes on every UI refresh. if os.environ.get("PDRIVE_RC_TRANSPORT") == "cli": - return rc_call_cli(endpoint) + return rc_call_cli(endpoint, params) if not RC_SOCKET.exists(): return endpoint, None, f"RC socket is missing: {RC_SOCKET}" connection = UnixHTTPConnection(RC_SOCKET, timeout=3.0) @@ -265,7 +270,7 @@ def rc_call(endpoint: str) -> tuple[str, dict[str, Any] | None, str]: connection.request( "POST", f"/{endpoint}", - body=b"{}", + body=json.dumps(params or {}).encode("utf-8"), headers={"Content-Type": "application/json", "Accept": "application/json"}, ) response = connection.getresponse() @@ -288,13 +293,19 @@ def rc_call(endpoint: str) -> tuple[str, dict[str, Any] | None, str]: def collect_rc(*, include_vfs: bool) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: - endpoints = ["core/stats", "core/transferred", "core/bwlimit"] + requests = [ + ("core/stats", None), + ("core/transferred", None), + ("backend/command", {"command": "data-bandwidth", "fs": "proton:"}), + ] if include_vfs: - endpoints.extend(["vfs/queue", "vfs/stats"]) + requests.extend([("vfs/queue", None), ("vfs/stats", None)]) payloads: dict[str, dict[str, Any]] = {} errors: dict[str, str] = {} - with concurrent.futures.ThreadPoolExecutor(max_workers=len(endpoints)) as pool: - for endpoint, payload, error in pool.map(rc_call, endpoints): + with concurrent.futures.ThreadPoolExecutor(max_workers=len(requests)) as pool: + futures = [pool.submit(rc_call, endpoint, params) for endpoint, params in requests] + for future in futures: + endpoint, payload, error = future.result() if payload is not None: payloads[endpoint] = payload else: @@ -1145,7 +1156,18 @@ def build_state(include_capacity: bool = False) -> dict[str, Any]: vfs = rc_payloads.get("vfs/stats", {}) disk_cache = vfs.get("diskCache", {}) if isinstance(vfs.get("diskCache"), dict) else {} vfs_options = vfs.get("opt", {}) if isinstance(vfs.get("opt"), dict) else {} - bandwidth = rc_payloads.get("core/bwlimit", {}) + bandwidth_payload = rc_payloads.get("backend/command", {}) + bandwidth = bandwidth_payload.get("result", {}) + if not isinstance(bandwidth, dict): + bandwidth = {} + bandwidth_upload = str(bandwidth.get("upload") or "") + bandwidth_download = str(bandwidth.get("download") or "") + if bandwidth_upload == "off" and bandwidth_download == "off": + live_bandwidth = "off" + elif bandwidth_upload and bandwidth_download: + live_bandwidth = f"{bandwidth_upload}:{bandwidth_download}" + else: + live_bandwidth = "unavailable" components = [] if not service["available"]: @@ -1327,9 +1349,10 @@ def build_state(include_capacity: bool = False) -> dict[str, Any]: }, "bandwidth": { "configured": configured_bwlimit, - "live": str(bandwidth.get("rate") or "unavailable"), - "bytes_per_second": number(bandwidth.get("bytesPerSecond"), -1), - "upload_bytes_per_second": number(bandwidth.get("bytesPerSecondTx"), -1), + "live": live_bandwidth, + "bytes_per_second": -1, + "upload_bytes_per_second": number(bandwidth.get("uploadBytesPerSecond"), -1), + "download_bytes_per_second": number(bandwidth.get("downloadBytesPerSecond"), -1), }, "configuration": { "transfers": configured_transfers, diff --git a/bin/pdrive-ui b/bin/pdrive-ui index a67e7f0..cac6390 100755 --- a/bin/pdrive-ui +++ b/bin/pdrive-ui @@ -72,7 +72,7 @@ ABOUT_CREDITS = ( ) DEFAULT_WINDOW_WIDTH = 820 DEFAULT_WINDOW_HEIGHT = 824 -SETUP_WINDOW_HEIGHT = 720 +SETUP_WINDOW_HEIGHT = 780 MIN_WINDOW_WIDTH = 640 MIN_WINDOW_HEIGHT = 480 MAX_WINDOW_DIMENSION = 16384 @@ -111,7 +111,6 @@ NOTIFICATION_POLICIES = { SUPPORTED_LANGUAGES = {"en": "English", "de": "Deutsch"} CURRENT_LANGUAGE = "en" SYSTEM_PACKAGES = ( - "rclone", "fuse3", "libfuse3-3", "libsecret-tools", @@ -431,11 +430,14 @@ GERMAN_TRANSLATIONS = { "Bandwidth limit": "Bandbreitenlimit", "Apply": "Übernehmen", "Upload limit in MiB/s": "Uploadlimit in MiB/s", + "Download limit in MiB/s": "Downloadlimit in MiB/s", "Near pause": "Nahezu pausiert", "Unlimited": "Unbegrenzt", "Near pause throttles uploads to 0.02 MiB/s; rclone has no true VFS pause. Full right removes the limit. Changes apply live without interrupting the active transfer.": "Nahezu pausiert drosselt Uploads auf 0,02 MiB/s; rclone besitzt keine echte VFS-Pause. Ganz rechts wird das Limit entfernt. Änderungen gelten live, ohne den aktiven Transfer zu unterbrechen.", + "Near pause throttles that direction to 0.02 MiB/s; rclone has no true VFS pause. Full right removes its limit. Changes apply live without interrupting active transfers.": "Nahezu pausiert drosselt diese Richtung auf 0,02 MiB/s; rclone besitzt keine echte VFS-Pause. Ganz rechts wird das jeweilige Limit entfernt. Änderungen gelten live, ohne aktive Transfers zu unterbrechen.", "The slider controls uploads up to 100 MiB/s. Use pdrive-bwlimit for separate upload/download values or higher limits.": "Der Regler steuert Uploads bis 100 MiB/s. Verwende pdrive-bwlimit für getrennte Upload-/Downloadwerte oder höhere Limits.", "The logarithmic slider gives low everyday limits more precision and controls uploads up to 100 MiB/s. Use pdrive-bwlimit for separate upload/download values or higher limits.": "Der logarithmische Regler gibt niedrigen Alltagslimits mehr Präzision und steuert Uploads bis 100 MiB/s. Verwende pdrive-bwlimit für getrennte Upload-/Downloadwerte oder höhere Limits.", + "The logarithmic sliders give low everyday limits more precision. Leaving connection headroom can keep browsing and calls responsive; use pdrive-bwlimit for values above 100 MiB/s.": "Die logarithmischen Regler geben niedrigen Alltagslimits mehr Präzision. Freie Leitungsreserve kann Browsing und Anrufe reaktionsschnell halten; verwende pdrive-bwlimit für Werte über 100 MiB/s.", "Parallel file uploads": "Parallele Datei-Uploads", "The saved value takes effect after the next controlled service start.": "Der gespeicherte Wert gilt nach dem nächsten kontrollierten Dienststart.", "Keep clean local cache files for": "Saubere lokale Cachedateien aufbewahren für", @@ -492,6 +494,23 @@ GERMAN_TRANSLATIONS = { "The package manager may ask for your administrator password.": "Der Paketmanager fragt möglicherweise nach deinem Administratorpasswort.", "Preparation failed": "Vorbereitung fehlgeschlagen", "The prerequisites could not be prepared automatically. You can retry or use the manual instructions.": "Die Voraussetzungen konnten nicht automatisch vorbereitet werden. Du kannst es erneut versuchen oder die manuelle Anleitung verwenden.", + "Connection headroom": "Leitungsreserve", + "Choose how much of the connection PDrive bulk file transfers may use. Proton metadata requests remain outside rclone’s file-data limiter and can use the reserved capacity.": "Wähle, wie viel der Verbindung PDrive für große Dateiübertragungen verwenden darf. Proton-Metadatenanfragen bleiben außerhalb von rclones Dateidaten-Limiter und können die reservierte Kapazität nutzen.", + "Auto-tune (recommended)": "Auto-Tune (empfohlen)", + "Measure this connection against Cloudflare, then reserve 40% for responsive Nemo browsing, calls and other traffic. The bounded test transfers about 72 MB. Cloudflare receives the test requests; PDrive stores only the resulting rates locally.": "Miss diese Verbindung gegen Cloudflare und reserviere anschließend 40 % für reaktionsschnelles Browsing in Nemo, Anrufe und anderen Traffic. Der begrenzte Test überträgt etwa 72 MB. Cloudflare empfängt die Testanfragen; PDrive speichert nur die ermittelten Raten lokal.", + "Manual limits": "Manuelle Limits", + "Set separate upload and download ceilings. Leave meaningful headroom for other applications. Even a near-pause bulk limit keeps Proton metadata browsing responsive.": "Lege getrennte Obergrenzen für Upload und Download fest. Lass eine sinnvolle Reserve für andere Anwendungen. Selbst ein Datei-Limit nahe der Pause hält das Browsing von Proton-Metadaten reaktionsschnell.", + "Unlimited bulk transfers": "Unbegrenzte Dateiübertragungen", + "Use the entire available connection. Nemo and other applications may become less responsive while large transfers are active.": "Nutze die gesamte verfügbare Verbindung. Nemo und andere Anwendungen können während großer Übertragungen langsamer reagieren.", + "Configure connection": "Verbindung konfigurieren", + "Measuring upload and download …": "Upload und Download werden gemessen …", + "Applying bandwidth policy …": "Bandbreitenregel wird angewendet …", + "Auto-tune measured {upload_measured} MiB/s upload and {download_measured} MiB/s download. Bulk transfers are limited to {upload_limit} / {download_limit} MiB/s; 40% remains reserved.": "Auto-Tune maß {upload_measured} MiB/s Upload und {download_measured} MiB/s Download. Dateiübertragungen sind auf {upload_limit} / {download_limit} MiB/s begrenzt; 40 % bleiben reserviert.", + "Manual bulk limits saved: {upload_limit} MiB/s upload and {download_limit} MiB/s download.": "Manuelle Datei-Limits gespeichert: {upload_limit} MiB/s Upload und {download_limit} MiB/s Download.", + "Bulk transfers remain unlimited. No connection headroom is reserved.": "Dateiübertragungen bleiben unbegrenzt. Es wird keine Leitungsreserve reserviert.", + "Continue to Proton account": "Weiter zum Proton-Konto", + "Connection measurement failed": "Verbindungsmessung fehlgeschlagen", + "The connection policy could not be prepared. Retry auto-tune or choose a manual or unlimited policy.": "Die Verbindungsregel konnte nicht vorbereitet werden. Wiederhole Auto-Tune oder wähle eine manuelle beziehungsweise unbegrenzte Regel.", "Proton account": "Proton-Konto", "Username or email": "Benutzername oder E-Mail", "Password": "Passwort", @@ -906,8 +925,19 @@ def verified_single_transfer_metrics( } -def bandwidth_slider_position(rate: str) -> float: - component = rate.split(":", 1)[0].strip() +def bandwidth_rate_components(rate: str) -> tuple[str, str]: + normalized = rate.strip() + if normalized.lower() in {"", "off", "0"}: + return "off", "off" + if ":" in normalized: + upload, download = normalized.split(":", 1) + return upload.strip(), download.strip() + return normalized, normalized + + +def bandwidth_slider_position(rate: str, direction: str = "upload") -> float: + upload, download = bandwidth_rate_components(rate) + component = download if direction == "download" else upload if component.lower() in {"", "off", "0"}: return BANDWIDTH_SLIDER_UNLIMITED match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)([bkmgtp]?)(?:i?b?)?", component, re.IGNORECASE) @@ -950,7 +980,7 @@ def bandwidth_slider_label(position: float) -> str: return f"{rate:.{digits}f} MiB/s" -def bandwidth_slider_command(position: float) -> str: +def bandwidth_slider_component(position: float) -> str: if position >= BANDWIDTH_SLIDER_UNLIMITED - 0.05: return "off" if position <= 0.05: @@ -960,6 +990,16 @@ def bandwidth_slider_command(position: float) -> str: return f"{rate:.{digits}f}".rstrip("0").rstrip(".") +def bandwidth_slider_command(position: float, download_position: float | None = None) -> str: + upload = bandwidth_slider_component(position) + if download_position is None: + return upload + download = bandwidth_slider_component(download_position) + if upload == "off" and download == "off": + return "off" + return f"{upload}:{download}" + + def autostart_contents() -> str: return "\n".join( ( @@ -1419,6 +1459,31 @@ def add_css(widget: Gtk.Widget, *classes: str) -> Gtk.Widget: return widget +def create_bandwidth_scale(initial_position: float) -> Gtk.Scale: + adjustment = Gtk.Adjustment( + value=initial_position, + lower=0, + upper=BANDWIDTH_SLIDER_UNLIMITED, + step_increment=0.1, + page_increment=1, + page_size=0, + ) + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adjustment) + scale.set_digits(1) + scale.set_value_pos(Gtk.PositionType.TOP) + scale.set_hexpand(True) + scale.connect("format-value", lambda _scale, value: bandwidth_slider_label(value)) + scale.add_mark(0, Gtk.PositionType.BOTTOM, "⏸ ≈0") + for rate in (0.1, 0.5, 1, 4, 10, 25, 50): + scale.add_mark( + bandwidth_slider_position(str(rate)), + Gtk.PositionType.BOTTOM, + f"{rate:g}", + ) + scale.add_mark(BANDWIDTH_SLIDER_UNLIMITED, Gtk.PositionType.BOTTOM, translate("Unlimited")) + return scale + + def set_pointer_on_hover(widget: Gtk.Widget) -> None: """Give an ordinary GTK control an explicit link-style pointer cursor.""" @@ -2248,12 +2313,15 @@ class SetupWizard(Gtk.Box): self.window = window self.preparing = False self.connecting = False + self.bandwidth_preparing = False + self.bandwidth_policy_ready = False self.readiness: dict[str, Any] = {} self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) self.stack.set_transition_duration(220) self.pack_start(self.stack, True, True, 0) self.build_readiness_page() + self.build_bandwidth_page() self.build_account_page() self.build_progress_page() self.build_success_page() @@ -2263,6 +2331,7 @@ class SetupWizard(Gtk.Box): def page_box(step: str, title: str, subtitle: str) -> Gtk.Box: page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=OVERVIEW_GUTTER) add_css(page, "setup-page") + page.set_margin_bottom(12) page.pack_start(label(step.upper(), "setup-step"), False, False, 0) page.pack_start(label(title, "setup-hero"), False, False, 0) lead = label(subtitle, "setup-lead") @@ -2277,9 +2346,18 @@ class SetupWizard(Gtk.Box): button.set_tooltip_text(translate(text)) return button + def add_page(self, page: Gtk.Widget, name: str) -> None: + """Keep every setup step reachable on short displays and long translations.""" + + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroller.set_shadow_type(Gtk.ShadowType.NONE) + scroller.add(page) + self.stack.add_named(scroller, name) + def build_readiness_page(self) -> None: page = self.page_box( - "Step 1 of 2", + "Step 1 of 3", "Welcome to PDrive", "This guided setup checks the local tools, prepares a private mount and connects your Proton account.", ) @@ -2347,14 +2425,101 @@ class SetupWizard(Gtk.Box): check_button.connect("clicked", lambda _button: self.refresh_readiness()) actions.pack_start(check_button, False, False, 0) self.continue_button = self.action_button("Continue", primary=True) - self.continue_button.connect("clicked", lambda _button: self.stack.set_visible_child_name("account")) + self.continue_button.connect("clicked", lambda _button: self.stack.set_visible_child_name("bandwidth")) actions.pack_end(self.continue_button, False, False, 0) page.pack_end(actions, False, False, 0) - self.stack.add_named(page, "readiness") + self.add_page(page, "readiness") + + def build_bandwidth_page(self) -> None: + page = self.page_box( + "Step 2 of 3", + "Connection headroom", + "Choose how much of the connection PDrive bulk file transfers may use. Proton metadata requests remain outside rclone’s file-data limiter and can use the reserved capacity.", + ) + + self.bandwidth_auto = Gtk.RadioButton.new_with_label_from_widget(None, translate("Auto-tune (recommended)")) + self.bandwidth_manual = Gtk.RadioButton.new_with_label_from_widget( + self.bandwidth_auto, translate("Manual limits") + ) + self.bandwidth_unlimited = Gtk.RadioButton.new_with_label_from_widget( + self.bandwidth_auto, translate("Unlimited bulk transfers") + ) + for radio in (self.bandwidth_auto, self.bandwidth_manual, self.bandwidth_unlimited): + set_pointer_on_hover(radio) + radio.connect("toggled", self.on_bandwidth_mode_changed) + + auto_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + auto_box.pack_start(self.bandwidth_auto, False, False, 0) + auto_note = label( + "Measure this connection against Cloudflare, then reserve 40% for responsive Nemo browsing, calls and other traffic. The bounded test transfers about 72 MB. Cloudflare receives the test requests; PDrive stores only the resulting rates locally.", + "section-subtitle", + ) + auto_note.set_line_wrap(True) + auto_note.set_margin_start(24) + auto_box.pack_start(auto_note, False, False, 0) + page.pack_start(card(auto_box), False, False, 0) + + manual_outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + manual_outer.pack_start(self.bandwidth_manual, False, False, 0) + manual_note = label( + "Set separate upload and download ceilings. Leave meaningful headroom for other applications. Even a near-pause bulk limit keeps Proton metadata browsing responsive.", + "section-subtitle", + ) + manual_note.set_line_wrap(True) + manual_note.set_margin_start(24) + manual_outer.pack_start(manual_note, False, False, 0) + self.setup_manual_scales = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + self.setup_manual_scales.set_margin_start(24) + self.setup_manual_scales.pack_start(label("Upload limit in MiB/s"), False, False, 0) + self.setup_upload_scale = create_bandwidth_scale(bandwidth_slider_position("4")) + self.setup_manual_scales.pack_start(self.setup_upload_scale, False, False, 0) + self.setup_manual_scales.pack_start(label("Download limit in MiB/s"), False, False, 0) + self.setup_download_scale = create_bandwidth_scale(bandwidth_slider_position("20")) + self.setup_manual_scales.pack_start(self.setup_download_scale, False, False, 0) + manual_outer.pack_start(self.setup_manual_scales, False, False, 0) + page.pack_start(card(manual_outer), False, False, 0) + + unlimited_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + unlimited_box.pack_start(self.bandwidth_unlimited, False, False, 0) + unlimited_note = label( + "Use the entire available connection. Nemo and other applications may become less responsive while large transfers are active.", + "section-subtitle", + ) + unlimited_note.set_line_wrap(True) + unlimited_note.set_margin_start(24) + unlimited_box.pack_start(unlimited_note, False, False, 0) + page.pack_start(card(unlimited_box), False, False, 0) + + self.bandwidth_progress = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=9) + self.bandwidth_progress.set_no_show_all(True) + self.bandwidth_spinner = Gtk.Spinner() + self.bandwidth_progress.pack_start(self.bandwidth_spinner, False, False, 0) + self.bandwidth_progress_label = label("Measuring upload and download …", "section-subtitle") + self.bandwidth_progress.pack_start(self.bandwidth_progress_label, False, False, 0) + page.pack_start(self.bandwidth_progress, False, False, 0) + self.bandwidth_progress.hide() + + self.bandwidth_result = label("", "setup-ready") + self.bandwidth_result.set_no_show_all(True) + self.bandwidth_result.set_line_wrap(True) + page.pack_start(self.bandwidth_result, False, False, 0) + self.bandwidth_result.hide() + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=9) + self.bandwidth_back_button = self.action_button("Back") + self.bandwidth_back_button.connect("clicked", lambda _button: self.stack.set_visible_child_name("readiness")) + actions.pack_start(self.bandwidth_back_button, False, False, 0) + self.bandwidth_continue_button = self.action_button("Configure connection", primary=True) + self.bandwidth_continue_button.connect("clicked", self.on_bandwidth_continue) + actions.pack_end(self.bandwidth_continue_button, False, False, 0) + page.pack_end(actions, False, False, 0) + self.add_page(page, "bandwidth") + self.bandwidth_auto.set_active(True) + self.on_bandwidth_mode_changed(self.bandwidth_auto) def build_account_page(self) -> None: page = self.page_box( - "Step 2 of 2", + "Step 3 of 3", "Proton account", "Credentials are sent through a private anonymous pipe, never through process arguments, environment variables or logs.", ) @@ -2403,13 +2568,13 @@ class SetupWizard(Gtk.Box): self.account_error.hide() actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=9) back_button = self.action_button("Back") - back_button.connect("clicked", lambda _button: self.stack.set_visible_child_name("readiness")) + back_button.connect("clicked", lambda _button: self.stack.set_visible_child_name("bandwidth")) actions.pack_start(back_button, False, False, 0) connect_button = self.action_button("Connect securely", primary=True) connect_button.connect("clicked", self.on_connect) actions.pack_end(connect_button, False, False, 0) page.pack_end(actions, False, False, 0) - self.stack.add_named(page, "account") + self.add_page(page, "account") def build_progress_page(self) -> None: page = self.page_box( @@ -2422,7 +2587,7 @@ class SetupWizard(Gtk.Box): spinner.set_halign(Gtk.Align.CENTER) spinner.start() page.pack_start(spinner, True, True, 20) - self.stack.add_named(page, "progress") + self.add_page(page, "progress") def build_success_page(self) -> None: page = self.page_box( @@ -2443,7 +2608,7 @@ class SetupWizard(Gtk.Box): open_button.set_halign(Gtk.Align.END) open_button.connect("clicked", lambda _button: self.window.enter_dashboard()) page.pack_end(open_button, False, False, 0) - self.stack.add_named(page, "success") + self.add_page(page, "success") def refresh_readiness(self) -> None: self.readiness = setup_readiness() @@ -2578,6 +2743,136 @@ class SetupWizard(Gtk.Box): ) return GLib.SOURCE_REMOVE + def on_bandwidth_mode_changed(self, button: Gtk.RadioButton) -> None: + if not button.get_active(): + return + manual = self.bandwidth_manual.get_active() + self.setup_manual_scales.set_visible(manual) + self.bandwidth_policy_ready = False + self.bandwidth_result.hide() + self.bandwidth_continue_button.set_label(translate("Configure connection")) + self.bandwidth_continue_button.set_tooltip_text(translate("Configure connection")) + + def on_bandwidth_continue(self, _button: Gtk.Button) -> None: + if self.bandwidth_preparing: + return + if self.bandwidth_policy_ready: + self.stack.set_visible_child_name("account") + return + + if self.bandwidth_auto.get_active(): + mode = "auto" + command = "" + progress = "Measuring upload and download …" + elif self.bandwidth_manual.get_active(): + mode = "manual" + command = bandwidth_slider_command( + self.setup_upload_scale.get_value(), + self.setup_download_scale.get_value(), + ) + progress = "Applying bandwidth policy …" + else: + mode = "unlimited" + command = "off" + progress = "Applying bandwidth policy …" + + self.bandwidth_preparing = True + self.bandwidth_result.hide() + self.bandwidth_progress_label.set_text(translate(progress)) + self.bandwidth_progress.show_all() + self.bandwidth_spinner.start() + self.bandwidth_continue_button.set_sensitive(False) + self.bandwidth_back_button.set_sensitive(False) + for radio in (self.bandwidth_auto, self.bandwidth_manual, self.bandwidth_unlimited): + radio.set_sensitive(False) + threading.Thread( + target=self.bandwidth_worker, + args=(mode, command), + daemon=True, + ).start() + + def bandwidth_worker(self, mode: str, command: str) -> None: + success = False + detail = "The connection policy could not be prepared. Retry auto-tune or choose a manual or unlimited policy." + try: + bandwidth_command = resolve_command("PDRIVE_BWLIMIT_BIN", "pdrive-bwlimit") + if mode == "auto": + tune_command = resolve_command("PDRIVE_NETWORK_TUNE_BIN", "pdrive-network-tune") + measured = subprocess.run( + [tune_command, "measure", "--json"], + check=True, + capture_output=True, + text=True, + timeout=240, + ) + payload = json.loads(measured.stdout) + upload_measured = float(payload["upload"]["measured_mib_per_second"]) + download_measured = float(payload["download"]["measured_mib_per_second"]) + upload_limit = float(payload["upload"]["recommended_mib_per_second"]) + download_limit = float(payload["download"]["recommended_mib_per_second"]) + if min(upload_measured, download_measured, upload_limit, download_limit) <= 0: + raise ValueError("invalid network measurement") + command = f"{upload_limit:.2f}:{download_limit:.2f}" + detail = translate( + "Auto-tune measured {upload_measured} MiB/s upload and {download_measured} MiB/s download. Bulk transfers are limited to {upload_limit} / {download_limit} MiB/s; 40% remains reserved." + ).format( + upload_measured=f"{upload_measured:.2f}", + download_measured=f"{download_measured:.2f}", + upload_limit=f"{upload_limit:.2f}", + download_limit=f"{download_limit:.2f}", + ) + elif mode == "manual": + upload_limit, download_limit = command.split(":", 1) + detail = translate( + "Manual bulk limits saved: {upload_limit} MiB/s upload and {download_limit} MiB/s download." + ).format(upload_limit=upload_limit, download_limit=download_limit) + else: + detail = translate("Bulk transfers remain unlimited. No connection headroom is reserved.") + + subprocess.run( + [bandwidth_command, command], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + success = True + except ( + KeyError, + OSError, + ValueError, + json.JSONDecodeError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): + pass + GLib.idle_add(self.bandwidth_finished, success, detail) + + def bandwidth_finished(self, success: bool, detail: str) -> bool: + self.bandwidth_preparing = False + self.bandwidth_spinner.stop() + self.bandwidth_progress.hide() + self.bandwidth_continue_button.set_sensitive(True) + self.bandwidth_back_button.set_sensitive(True) + for radio in (self.bandwidth_auto, self.bandwidth_manual, self.bandwidth_unlimited): + radio.set_sensitive(True) + + context = self.bandwidth_result.get_style_context() + context.remove_class("setup-ready") + context.remove_class("setup-error") + if success: + self.bandwidth_policy_ready = True + context.add_class("setup-ready") + self.bandwidth_continue_button.set_label(translate("Continue to Proton account")) + self.bandwidth_continue_button.set_tooltip_text(translate("Continue to Proton account")) + else: + self.bandwidth_policy_ready = False + context.add_class("setup-error") + detail = translate(detail) + self.bandwidth_result.set_text(detail) + self.bandwidth_result.show() + return GLib.SOURCE_REMOVE + def on_show_passwords(self, button: Gtk.CheckButton) -> None: visible = button.get_active() self.password_entry.set_visibility(visible) @@ -5201,52 +5496,45 @@ class PDriveWindow(Gtk.ApplicationWindow): content.set_margin_bottom(15) content.set_margin_start(15) content.set_margin_end(15) - content.pack_start(label("Upload limit in MiB/s", "section-title"), False, False, 0) current = str(self.current_state.get("bandwidth", {}).get("configured", "off")) - initial_position = bandwidth_slider_position(current) - adjustment = Gtk.Adjustment( - value=initial_position, - lower=0, - upper=BANDWIDTH_SLIDER_UNLIMITED, - step_increment=0.1, - page_increment=1, - page_size=0, - ) - scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adjustment) - scale.set_digits(1) - scale.set_value_pos(Gtk.PositionType.TOP) - scale.set_hexpand(True) - scale.connect("format-value", lambda _scale, value: bandwidth_slider_label(value)) - scale.add_mark(0, Gtk.PositionType.BOTTOM, "⏸ ≈0") - for rate in (0.1, 0.5, 1, 4, 10, 25, 50, 100): - scale.add_mark( - bandwidth_slider_position(str(rate)), - Gtk.PositionType.BOTTOM, - f"{rate:g}", - ) - scale.add_mark(BANDWIDTH_SLIDER_UNLIMITED, Gtk.PositionType.BOTTOM, translate("Unlimited")) - content.pack_start(scale, False, False, 0) + initial_upload = bandwidth_slider_position(current, "upload") + initial_download = bandwidth_slider_position(current, "download") + + def add_scale(title: str, initial_position: float) -> Gtk.Scale: + content.pack_start(label(title, "section-title"), False, False, 0) + scale = create_bandwidth_scale(initial_position) + content.pack_start(scale, False, False, 0) + return scale + + upload_scale = add_scale("Upload limit in MiB/s", initial_upload) + download_scale = add_scale("Download limit in MiB/s", initial_download) help_label = label( - "Near pause throttles uploads to 0.02 MiB/s; rclone has no true VFS pause. Full right removes the limit. Changes apply live without interrupting the active transfer.", + "Near pause throttles that direction to 0.02 MiB/s; rclone has no true VFS pause. Full right removes its limit. Changes apply live without interrupting active transfers.", "section-subtitle", ) help_label.set_line_wrap(True) content.pack_start(help_label, False, False, 0) advanced_label = label( - "The logarithmic slider gives low everyday limits more precision and controls uploads up to 100 MiB/s. Use pdrive-bwlimit for separate upload/download values or higher limits.", + "The logarithmic sliders give low everyday limits more precision. Leaving connection headroom can keep browsing and calls responsive; use pdrive-bwlimit for values above 100 MiB/s.", "section-subtitle", ) advanced_label.set_line_wrap(True) content.pack_start(advanced_label, False, False, 0) apply_button = dialog.get_widget_for_response(Gtk.ResponseType.OK) apply_button.set_sensitive(False) - scale.connect( - "value-changed", - lambda current_scale: apply_button.set_sensitive(abs(current_scale.get_value() - initial_position) >= 0.05), - ) + + def update_apply(_scale: Gtk.Scale) -> None: + changed = ( + abs(upload_scale.get_value() - initial_upload) >= 0.05 + or abs(download_scale.get_value() - initial_download) >= 0.05 + ) + apply_button.set_sensitive(changed) + + upload_scale.connect("value-changed", update_apply) + download_scale.connect("value-changed", update_apply) dialog.show_all() response = dialog.run() - value = bandwidth_slider_command(scale.get_value()) + value = bandwidth_slider_command(upload_scale.get_value(), download_scale.get_value()) dialog.destroy() if response == Gtk.ResponseType.OK: self.run_helper(["pdrive-bwlimit", value], "Bandwidth limit") diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0971631..6dd6d6d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -45,6 +45,14 @@ responses but excludes browsers and unrelated rclone mounts. Proton capacity may require a backend request and is therefore sampled only at startup, every 15 minutes and on explicit refresh. +Bandwidth mutation is backend-specific by design. `pdrive-bwlimit` calls the +Proton backend's `data-bandwidth` runtime command; the backend wraps shared +aggregate upload and download file readers while metadata HTTP requests bypass +the limit. Never replace this with rclone's global `core/bwlimit` on the live +mount. Saved limits are applied only after mount startup so backend option +values cannot alter the VFS cache fingerprint and select a different Dirty +queue namespace. + ## Security model - The rclone configuration is encrypted with a random password stored in the @@ -93,6 +101,24 @@ Run the full local suite before every commit: make verify ``` +### Pinned rclone dependency + +The installed binary comes from the public +[`oss-singularity/rclone`](https://github.com/oss-singularity/rclone) release +`pdrive-v1.76.0-beta.10204.1`. Its branch pins the exact +[`oss-singularity/Proton-API-Bridge`](https://github.com/oss-singularity/Proton-API-Bridge) +commit used to build the asset; the bridge worker-drain correction is proposed +upstream in [Proton-API-Bridge PR #8](https://github.com/rclone/Proton-API-Bridge/pull/8). +The Linux x86-64 asset is statically linked and its checksum is embedded in +`pdrive-prerequisites` and published beside the release. + +Treat the release branch, annotated tag, source replace and checksum as one +review unit. Updating only the executable or only the embedded digest is not a +valid dependency upgrade. The binary intentionally retains its exact upstream +rclone version string so VFS cache identity and minimum-version guards remain +stable; detect the PDrive extension through `backend help protondrive` and the +`data-bandwidth` command. + `make help` lists action-free developer entry points. `make check-units` runs the focused systemd invariants and static verification, while `make check-display` exercises both GTK suites on the current desktop. The diff --git a/docs/EVERYDAY_USE.md b/docs/EVERYDAY_USE.md index 3bb47f2..fe0c1c9 100644 --- a/docs/EVERYDAY_USE.md +++ b/docs/EVERYDAY_USE.md @@ -49,14 +49,15 @@ web client before removing the independent local copy. ## Bandwidth and responsiveness Open **Bandwidth limit** from the Configuration card or hamburger menu. The -left side of the limit controls upload; download remains unlimited unless an -explicit terminal pair is used. Full right is **Unlimited**. The far-left -position is a documented low nonzero rate, not a true pause. - -Very small upload limits can also make uncached Proton directory requests feel -slow. Increase the limit temporarily when browsing becomes impractical. See -[Troubleshooting](TROUBLESHOOTING.md) for measured diagnosis before changing -recovery settings. +dialog has separate logarithmic Upload and Download controls. Full right is +**Unlimited**. The far-left Upload position is a documented low nonzero rate, +not a true pause. + +PDrive applies these limits only to bulk file payloads. Directory listings, +login and other small Proton API requests stay outside them, so a deliberate +near-pause does not make uncached Nemo navigation wait behind a large upload. +The first-run wizard can estimate both limits automatically and reserves 40% +of the conservative measured connection for browsing and other applications. ## Cache and external clients diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b585097..e42660c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -41,7 +41,7 @@ candidate login succeeds. | `~/.local/share/applications/io.github.claudiuschuster.PDriveControl.desktop` | Cinnamon menu entry | | `~/.local/share/icons/hicolor/scalable/apps/io.github.claudiuschuster.PDriveControl.svg` | Scalable UI icon | | `~/.local/bin/rclone` | Adds the Keyring-backed `--password-command` | -| `~/.local/libexec/rclone-bin` | Signed stable rclone executable | +| `~/.local/libexec/rclone-bin` | Checksum-verified PDrive rclone executable | | `~/.local/libexec/rclone-proton-*` | Mount and guarded unmount implementation | | `~/.config/rclone/rclone.conf` | Encrypted rclone configuration | | `~/.config/pdrive-*.conf` | Strict single-purpose helper settings | @@ -64,6 +64,23 @@ executables `/usr/bin/apt-get` and `/usr/bin/install`; project scripts always remain unprivileged. An expandable section provides equivalent manual commands for advanced users. +The next page configures connection headroom before Proton authentication: + +- **Auto-tune (recommended)** downloads and uploads bounded Cloudflare test + payloads totaling about 72 MB, chooses conservative samples and assigns 60% + to bulk PDrive file data. The remaining 40% is left to Nemo metadata, + interactive traffic and other applications. +- **Set manually** exposes separate logarithmic Upload and Download sliders. + Use measured sustained throughput rather than an ISP's headline bit rate; + leaving roughly 30–40% unused normally keeps the desktop responsive. +- **Unlimited** removes both file-data limits. It maximizes throughput but a + saturated connection can still affect unrelated applications. + +The selected pair is saved atomically and applied live when a mount already +exists. Limits affect Proton file payloads only; login, directory listing and +other backend metadata stay outside them. The test result is stored locally in +mode-0600 JSON and contains rates and timestamps, never credentials. + The account page transports username, password and the optional current 2FA code as three NUL-delimited values over an anonymous stdin pipe. They never appear in process arguments, environment variables or logs, and the password @@ -101,12 +118,14 @@ pdrive-prerequisites --install-rclone pdrive-prerequisites --help ``` -`--install-rclone` copies an installed distribution rclone into a private -temporary file, updates that copy from rclone's stable channel, verifies the -`protondrive` backend and only then atomically installs it as -`~/.local/libexec/rclone-bin`. It never installs system packages or edits -`/pdrive` itself; those privileged operations remain visible Polkit steps in the -wizard. +`--check` requires both the upload-safe 1.76 baseline and PDrive's Proton +`data-bandwidth` backend command. `--install-rclone` downloads the pinned x86-64 +Linux asset from the OSS Singularity rclone release, verifies its published +SHA-256 checksum, version and backend feature in a private temporary file, and +only then atomically installs it as `~/.local/libexec/rclone-bin`. It never +installs system packages or edits `/pdrive` itself; those privileged operations +remain visible Polkit steps in the wizard. Reinstalling the expected asset is a +safe repair when the executable was replaced by an incompatible rclone build. ## Status and diagnosis @@ -345,13 +364,13 @@ only its own marked autostart file. A manual menu launch remains visible. These dialogs delegate to the same strict `pdrive-*` helpers available in a terminal. The UI never edits rclone's encrypted configuration directly. -| Control and location | Range and default | When it takes effect and what it changes | -| ------------------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| **Bandwidth** — Configuration or menu | `0.02`–`100 MiB/s`; default **Unlimited** | Applies to the live process without restarting or closing the active transfer. Full right removes the limit. | -| **Upload slots** — Configuration or menu | **1–8**; default **4** | Saves parallel file-upload count for the next controlled service start; it does not restart rclone. | -| **Metadata cache** — Configuration or menu | Disabled / Enabled; default **Disabled** | Saves the exclusive Proton metadata mode for the next controlled service start. Enable only for one active writer. | -| **Cooldown** — Configuration or menu | **1–168 hours**; default **12 hours** | Changes the watchdog's automatic-recovery policy immediately; it does not restart rclone or clear an active cooldown. | -| **Cache retention** — Transfers or menu | **1–8760 hours**; default **24 hours** | Saves clean read-cache age for the next controlled service start. It never expires Dirty upload data. | +| Control and location | Range and default | When it takes effect and what it changes | +| ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Bandwidth** — Configuration or menu | Upload and Download: `0.02`–`100 MiB/s`; default **Unlimited** | Applies independent bulk file-data limits live without restarting or closing a transfer. Metadata/API requests stay outside them; full right removes that direction's limit. | +| **Upload slots** — Configuration or menu | **1–8**; default **4** | Saves parallel file-upload count for the next controlled service start; it does not restart rclone. | +| **Metadata cache** — Configuration or menu | Disabled / Enabled; default **Disabled** | Saves the exclusive Proton metadata mode for the next controlled service start. Enable only for one active writer. | +| **Cooldown** — Configuration or menu | **1–168 hours**; default **12 hours** | Changes the watchdog's automatic-recovery policy immediately; it does not restart rclone or clear an active cooldown. | +| **Cache retention** — Transfers or menu | **1–8760 hours**; default **24 hours** | Saves clean read-cache age for the next controlled service start. It never expires Dirty upload data. | Each dialog states whether a change is live or saved for the next service start. The Transfers cache section also shows running and saved retention when @@ -418,19 +437,25 @@ upload and download. Every unitless numeric component receives the rclone `M` suffix before validation, so `4:1` becomes `4M:1M`. Values are bytes per second, not bits per second. -The Control Center exposes the upload value as a slider. Its far-right endpoint -is **Unlimited (`off`/`0`)**, because rclone normalizes a zero bandwidth limit -to unlimited throughput. Its far-left **⏸ ≈0** endpoint applies `0.02 MiB/s`: -this is an intentional near-zero throttle, not a native VFS pause. The value is -high enough to remain above the watchdog's conservative TCP activity threshold -during its 20-second probe. The active HTTP transfer remains open and resumes -normal throughput as soon as the slider is moved or the limit is removed. -Advanced asymmetric or higher values remain available through -`pdrive-bwlimit`. - -The helper first asks rclone's loopback parser to canonicalize the value, writes -one mode-0600 config atomically, then updates the live process through the -owner-only Unix socket. A live transfer is not restarted. +The Control Center exposes separate logarithmic sliders for both directions. +Each far-right endpoint is **Unlimited (`off`/`0`)**, because rclone normalizes +a zero bandwidth limit to unlimited throughput. Upload's far-left **⏸ ≈0** +endpoint applies `0.02 MiB/s`: this is an intentional near-zero throttle, not a +native VFS pause. The active upload remains open and resumes normal throughput +as soon as the slider is moved or its limit is removed. + +The helper uses a short-lived loopback rclone only to canonicalize rate syntax, +writes one mode-0600 config atomically, then calls the running Proton backend's +`data-bandwidth` command through the owner-only Unix socket. Its shared upload +and download token buckets wrap only file payload readers. Directory listings, +authentication and other Proton API metadata bypass those buckets. + +At service start, the mount is created first and `ExecStartPost` applies the +saved pair. The limits are deliberately not passed as backend command-line +options: backend options participate in rclone's VFS cache fingerprint, and a +different fingerprint could temporarily hide an existing Dirty upload queue. +Runtime application preserves the original namespace and never restarts the +transfer. ## Transfer concurrency @@ -807,15 +832,15 @@ systemctl --user status rclone-selfupdate.timer proton-drive-update.timer The rclone timer runs ten minutes after boot when due and every Sunday at 04:00, with up to two hours of randomized delay. `Persistent=true` catches up after the -computer was off. New installations currently bootstrap the pinned official -`v1.76.0-beta.10204.660144d31` build because it contains the fix for the Proton -retry corruption tracked by [rclone #9722](https://github.com/rclone/rclone/issues/9722). -The updater checks stable releases without downgrading that beta. As soon as -stable rclone 1.76 or newer exists, it returns the installation to the stable -channel and follows stable releases normally. rclone self-update verifies the -official release hash and available signature metadata. A newly installed -binary is intentionally left for the next natural mount start; the updater -never restarts an active transfer. +computer was off. New installations currently download the pinned +`pdrive-v1.76.0-beta.10204.1` x86-64 asset from the public OSS Singularity +rclone release. `pdrive-prerequisites` verifies the embedded SHA-256 digest, +minimum upload-safe version and required Proton backend command before atomic +installation. The updater invokes that same verifier and does not follow +rclone's official stable channel, because an otherwise newer binary may lack +PDrive's source-reviewed file-data limiter and bridge-worker fix. A newly +installed binary is intentionally left for the next natural mount start; the +updater never restarts an active transfer. The optional official Proton Drive CLI timer runs five minutes after boot when due and daily with up to four hours of randomized delay. Its updater accepts diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index d6fb3df..812fcba 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -30,9 +30,21 @@ On first launch, PDrive Control Center guides you through these steps: 1. Check the required Mint, GTK, FUSE and Keyring components. 2. Install missing packages through the system Polkit prompt when needed. -3. Prepare the tested Proton-capable rclone build. -4. Enter your Proton username, password and optional fresh six-digit 2FA code. -5. Wait until the wizard confirms that `/pdrive` is mounted. +3. Prepare the checksum-verified PDrive rclone build. +4. Choose **Auto-tune**, **Set manually** or **Unlimited** for file transfers. +5. Enter your Proton username, password and optional fresh six-digit 2FA code. +6. Wait until the wizard confirms that `/pdrive` is mounted. + +Auto-tune runs one disclosed, bounded Cloudflare test of about 72 MB, then +assigns 60% of conservative measured upload and download rates to bulk file +data. The remaining 40% stays available to Nemo metadata requests and other +applications. Manual mode offers separate logarithmic upload and download +controls; Unlimited leaves both directions open. These choices affect file +payloads only, not Proton login, directory listing or other API metadata. + +PDrive setup wizard showing separate manual upload and download controls Credentials travel through private anonymous pipes. They never appear in process arguments, environment variables or logs. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5648ba5..1e505d4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -150,7 +150,7 @@ No traffic is normal when: - rclone is listing or decrypting many small metadata objects; - an upload is in bounded API retry/backoff; - another process still holds a write-open file; -- the bandwidth limit is unexpectedly low. +- the file-data bandwidth limit is unexpectedly low. Check: @@ -225,6 +225,21 @@ this helper deliberately treats every unitless component as MiB/s: The left side is upload and the right side is download. The units are bytes per second. A stale value can be corrected live without restarting the mount. +PDrive's limiter applies only to bulk file payloads, so even a near-paused +upload must not hold a small directory-listing request behind it. + +If uncached Nemo navigation still takes roughly as long as the global byte rate +would need for a tiny request, verify the managed build: + +```bash +pdrive-prerequisites --check +``` + +An incompatible or official rclone replacement does not provide the required +backend command. Repair it with `pdrive-prerequisites --install-rclone`, then +perform one controlled service restart after checking that the queue and Dirty +cache are preserved. Do not add rclone's global `--bwlimit` to the mount: that +transport-level limiter also throttles Proton metadata writes. ## Nemo is slow or shows stale folders @@ -380,8 +395,11 @@ that finalization path can be followed by 422 and 404 responses. The retry could reuse an already consumed stream, so blindly repeating the operation is unsafe. The upstream correction is tracked in [rclone #9722](https://github.com/rclone/rclone/issues/9722). New toolkit -installations use a pinned official fixed beta until stable rclone 1.76 or newer -is available; the weekly updater then returns to stable automatically. +installations use a pinned, checksum-verified PDrive rclone build based on the +fixed beta. Its source also pins the API bridge worker-drain correction and adds +the file-data-only limiter. The weekly updater keeps this reviewed build until +PDrive publishes a replacement instead of replacing it with an incompatible +official binary. The guarded helper does not treat an ordinary 100% transfer as stalled. It requires a terminal backend error in the completion window, a fixed rclone, diff --git a/docs/assets/pdrive-setup-wizard.png b/docs/assets/pdrive-setup-wizard.png new file mode 100644 index 0000000..bb1adb5 Binary files /dev/null and b/docs/assets/pdrive-setup-wizard.png differ diff --git a/install.sh b/install.sh index ad8bedd..c82d18f 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,6 @@ set -euo pipefail -readonly safe_rclone_beta='v1.76.0-beta.10204.660144d31' readonly minimum_safe_rclone='v1.76.0' readonly minimum_safe_beta_build=10204 umask 022 @@ -27,8 +26,8 @@ usage() { 'Usage: ./install.sh [--with-proton-cli-updater]' \ '' \ 'Installs or updates the user-local helpers, systemd units and docs.' \ - 'Bootstraps the pinned official upload-safe rclone beta when the local' \ - 'binary is missing or older than the fixed 1.76 baseline. Configuration,' \ + 'Installs the pinned, source-published PDrive rclone build when the local' \ + 'binary is missing or lacks the required Proton data limiter. Configuration,' \ 'cache, logs and a running mount are never overwritten or restarted.' \ '' \ '--with-proton-cli-updater also enable the optional official Proton' \ @@ -75,8 +74,10 @@ rclone_upload_retry_safe() { rclone_ready() { local candidate="$1" - rclone_upload_retry_safe "${candidate}" \ - && "${candidate}" help backend protondrive >/dev/null 2>&1 + rclone_upload_retry_safe "${candidate}" || return 1 + "${candidate}" help backend protondrive >/dev/null 2>&1 || return 1 + "${candidate}" backend help protondrive 2>/dev/null \ + | grep -q '^### data-bandwidth$' } missing_commands=() @@ -106,17 +107,6 @@ if ! python3 -c \ exit 69 fi -bootstrap_rclone='' -if [[ -x "${real_rclone}" ]]; then - bootstrap_rclone="${real_rclone}" -elif command -v rclone >/dev/null 2>&1; then - bootstrap_rclone="$(command -v rclone)" -fi -if [[ -z "${bootstrap_rclone}" || ! -x "${bootstrap_rclone}" ]]; then - printf 'No bootstrap rclone found. Install the distribution rclone package first.\n' >&2 - exit 69 -fi - if [[ -L "${mount_dir}" ]]; then printf 'Refusing symlink mountpoint: %s\n' "${mount_dir}" >&2 exit 73 @@ -145,17 +135,8 @@ mkdir -p -- "${bin_dir}" "${libexec_dir}" "${unit_dir}" "${doc_dir}" \ "${applications_dir}" "${icons_dir}" "${config_dir}" if ! rclone_ready "${real_rclone}"; then - temporary_rclone="$(mktemp "${libexec_dir}/.rclone-bin.XXXXXX")" - cleanup_rclone() { rm -f -- "${temporary_rclone:-}"; } - trap cleanup_rclone EXIT - install -m 0755 "${bootstrap_rclone}" "${temporary_rclone}" - "${temporary_rclone}" selfupdate --beta --version "${safe_rclone_beta}" - if ! rclone_ready "${temporary_rclone}"; then - printf 'The downloaded rclone is not an upload-safe Proton Drive build.\n' >&2 - exit 70 - fi - mv -f -- "${temporary_rclone}" "${real_rclone}" - trap - EXIT + PDRIVE_REAL_RCLONE="${real_rclone}" \ + "${project_dir}/bin/pdrive-prerequisites" --install-rclone fi for source_file in "${project_dir}"/bin/*; do @@ -193,6 +174,9 @@ install -m 0644 \ install -m 0644 \ "${project_dir}/docs/assets/pdrive-auth-cooldown.png" \ "${doc_assets_dir}/pdrive-auth-cooldown.png" +install -m 0644 \ + "${project_dir}/docs/assets/pdrive-setup-wizard.png" \ + "${doc_assets_dir}/pdrive-setup-wizard.png" install -m 0644 \ "${project_dir}/share/icons/hicolor/scalable/apps/io.github.claudiuschuster.PDriveControl.svg" \ "${doc_icon_dir}/io.github.claudiuschuster.PDriveControl.svg" diff --git a/libexec/rclone-proton-mount b/libexec/rclone-proton-mount index 48dd75f..7172bbc 100755 --- a/libexec/rclone-proton-mount +++ b/libexec/rclone-proton-mount @@ -104,10 +104,11 @@ if [[ -e "${transfers_config}" ]]; then fi fi -# The helper writes a canonical single runtime bandwidth setting. Never -# source this file: accepting exactly one validated value keeps manual damage -# from becoming shell code or silently changing unrelated mount arguments. -bwlimit='off' +# Validate the saved file-data limits before starting. ExecStartPost applies +# them through the Proton backend after the RC socket is ready. They must not +# be passed as backend flags here: doing that would change rclone's VFS cache +# fingerprint and hide an existing pending-upload namespace from the new +# process. Never source this file. if [[ -e "${bwlimit_config}" ]]; then if [[ ! -r "${bwlimit_config}" ]]; then echo "Bandwidth configuration is unreadable: ${bwlimit_config}" >&2 @@ -221,7 +222,6 @@ exec "${rclone_bin}" mount proton: "${mount_dir}" \ --protondrive-enable-caching="${proton_metadata_cache}" \ --protondrive-replace-existing-draft="${replace_existing_draft}" \ --transfers="${transfers}" \ - --bwlimit="${bwlimit}" \ --low-level-retries=5 \ --retries=2 \ --umask=077 \ diff --git a/libexec/rclone-selfupdate b/libexec/rclone-selfupdate index 32fc8c8..d733204 100755 --- a/libexec/rclone-selfupdate +++ b/libexec/rclone-selfupdate @@ -4,20 +4,9 @@ set -euo pipefail readonly rclone_bin="${HOME}/.local/libexec/rclone-bin" +readonly prerequisite_bin="${PDRIVE_PREREQUISITES_BIN:-${HOME}/.local/bin/pdrive-prerequisites}" before="$(${rclone_bin} version | head -n 1)" -current_version="${before#rclone }" -if [[ "${current_version}" == *-beta.* ]]; then - beta_base="${current_version%%-beta.*}" - stable_check="$(${rclone_bin} selfupdate --stable --check)" - stable_version="$(sed -nE 's/.*install rclone version (v[^ ]+).*/\1/p' <<< "${stable_check}" | head -n 1)" - if [[ -z "${stable_version}" \ - || "$(printf '%s\n%s\n' "${beta_base}" "${stable_version}" | sort -V | tail -n 1)" != "${stable_version}" ]]; then - printf 'Keeping %s until a stable rclone release reaches %s.\n' \ - "${before}" "${beta_base}" - exit 0 - fi -fi -"${rclone_bin}" selfupdate --stable +"${prerequisite_bin}" --install-rclone after="$(${rclone_bin} version | head -n 1)" if [[ "${before}" != "${after}" ]]; then diff --git a/systemd/user/rclone-proton-drive.service b/systemd/user/rclone-proton-drive.service index a427327..36f7d48 100644 --- a/systemd/user/rclone-proton-drive.service +++ b/systemd/user/rclone-proton-drive.service @@ -11,6 +11,7 @@ UMask=0077 ExecCondition=%h/.local/libexec/pdrive-auth-failure-guard --start-allowed ExecStartPre=%h/.local/libexec/pdrive-auth-failure-guard --begin-start ExecStart=%h/.local/libexec/rclone-proton-mount +ExecStartPost=%h/.local/bin/pdrive-bwlimit --apply-startup ExecStartPost=%h/.local/libexec/pdrive-auth-failure-guard --mark-healthy ExecStop=-%h/.local/libexec/rclone-proton-unmount ExecStopPost=%h/.local/libexec/pdrive-auth-failure-guard --after-service-exit diff --git a/systemd/user/rclone-selfupdate.service b/systemd/user/rclone-selfupdate.service index aace92c..47b7788 100644 --- a/systemd/user/rclone-selfupdate.service +++ b/systemd/user/rclone-selfupdate.service @@ -1,6 +1,6 @@ [Unit] -Description=Update rclone from its signed official release channel -Documentation=file:%h/.local/share/doc/proton-drive-linux/OPERATIONS.md https://rclone.org/commands/rclone_selfupdate/ +Description=Verify and refresh the pinned PDrive rclone build +Documentation=file:%h/.local/share/doc/proton-drive-linux/OPERATIONS.md https://github.com/oss-singularity/rclone/releases After=network-online.target Wants=network-online.target ConditionPathExists=%h/.local/libexec/rclone-bin diff --git a/tests/check.sh b/tests/check.sh index af2a17f..7688a37 100755 --- a/tests/check.sh +++ b/tests/check.sh @@ -130,6 +130,7 @@ manual_assets=( 'docs/assets/pdrive-control-center.png' 'docs/assets/pdrive-transfers.png' 'docs/assets/pdrive-auth-cooldown.png' + 'docs/assets/pdrive-setup-wizard.png' ) for manual_path in "${manual_files[@]}" "${manual_assets[@]}"; do if [[ ! -s "${project_dir}/${manual_path}" ]]; then @@ -150,6 +151,8 @@ done "${project_dir}/tests/test-systemd.sh" "${project_dir}/tests/test-updaters.sh" "${project_dir}/tests/test-cache-age.sh" +"${project_dir}/tests/test-network-tune.sh" +"${project_dir}/tests/test-bwlimit.sh" "${project_dir}/tests/test-prerequisites.sh" "${project_dir}/tests/test-setup.sh" "${project_dir}/tests/test-reauth.sh" diff --git a/tests/test-bwlimit.sh b/tests/test-bwlimit.sh new file mode 100755 index 0000000..0b79c22 --- /dev/null +++ b/tests/test-bwlimit.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(mktemp -d /tmp/proton-drive-linux-bwlimit.XXXXXX)" +cleanup() { + rm -rf -- "${test_root}" +} +trap cleanup EXIT + +test_home="${test_root}/home" +state_dir="${test_root}/state" +socket_path="${state_dir}/pdrive-rc.sock" +fake_rclone="${test_root}/rclone" +runtime_state="${test_root}/runtime" +command_log="${test_root}/commands.log" +mkdir -p "${test_home}" "${state_dir}" +printf '%s\n' 'off:off' > "${runtime_state}" + +cat > "${fake_rclone}" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +printf '%q ' "$@" >> "${PDRIVE_TEST_COMMAND_LOG}" +printf '\n' >> "${PDRIVE_TEST_COMMAND_LOG}" + +if [[ "${1:-}" == rc && "${2:-}" == --loopback && "${3:-}" == core/bwlimit ]]; then + rate="${4#rate=}" + printf '{"rate":"%s"}\n' "${rate}" + exit 0 +fi + +if [[ "${1:-}" == rc && "${2:-}" == --unix-socket ]]; then + shift 3 + [[ "${1:-}" == backend/command ]] + shift + options='' + for argument in "$@"; do + case "${argument}" in + command=data-bandwidth|fs=proton:) ;; + opt=*) options="${argument#opt=}" ;; + *) printf 'unexpected argument: %s\n' "${argument}" >&2; exit 2 ;; + esac + done + if [[ -n "${options}" ]]; then + upload="$(jq -r .upload <<< "${options}")" + download="$(jq -r .download <<< "${options}")" + printf '%s:%s\n' "${upload}" "${download}" > "${PDRIVE_TEST_RUNTIME_STATE}" + fi + IFS=: read -r upload download < "${PDRIVE_TEST_RUNTIME_STATE}" + jq -cn --arg upload "${upload}" --arg download "${download}" \ + '{result: {upload: $upload, download: $download}}' + exit 0 +fi + +exit 2 +SH +chmod 0755 "${fake_rclone}" + +run_helper() { + HOME="${test_home}" \ + PDRIVE_RCLONE_STATE_DIR="${state_dir}" \ + PDRIVE_RC_SOCKET="${socket_path}" \ + PDRIVE_RCLONE_BIN="${fake_rclone}" \ + PDRIVE_BWLIMIT_TEST_SOCKET_READY=1 \ + PDRIVE_TEST_RUNTIME_STATE="${runtime_state}" \ + PDRIVE_TEST_COMMAND_LOG="${command_log}" \ + "${project_dir}/bin/pdrive-bwlimit" "$@" +} + +run_helper 4.2 >/dev/null +grep -q '^bwlimit=4.2M:off$' "${test_home}/.config/pdrive-bwlimit.conf" +grep -q '^4.2M:off$' "${runtime_state}" + +run_helper 1:0.5 >/dev/null +grep -q '^bwlimit=1M:0.5M$' "${test_home}/.config/pdrive-bwlimit.conf" +grep -q '^1M:0.5M$' "${runtime_state}" + +status_output="$(run_helper --status)" +grep -qF 'Saved: upload 1MB/s, download 0.5MB/s' <<< "${status_output}" +grep -qF 'Running rclone: upload 1MB/s, download 0.5MB/s' <<< "${status_output}" + +printf '%s\n' 'bwlimit=800K:off' > "${test_home}/.config/pdrive-bwlimit.conf" +printf '%s\n' 'off:off' > "${runtime_state}" +run_helper --apply-startup >/dev/null +grep -q '^800K:off$' "${runtime_state}" + +run_helper off >/dev/null +grep -q '^bwlimit=off$' "${test_home}/.config/pdrive-bwlimit.conf" +grep -q '^off:off$' "${runtime_state}" + +if grep -q 'core/bwlimit.*--unix-socket\|--unix-socket.*core/bwlimit' "${command_log}"; then + printf 'The live mount was changed through the global rclone limiter.\n' >&2 + exit 1 +fi +grep -q 'backend/command.*command=data-bandwidth.*fs=proton:' "${command_log}" + +printf 'Backend file-data bandwidth checks passed.\n' diff --git a/tests/test-help.sh b/tests/test-help.sh index b41788a..f8f07f4 100755 --- a/tests/test-help.sh +++ b/tests/test-help.sh @@ -18,7 +18,7 @@ snapshot() { } before="$(snapshot)" -for helper in pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-reauth \ +for helper in pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-network-tune pdrive-reauth \ pdrive-prerequisites pdrive-recovery pdrive-refresh pdrive-setup pdrive-state pdrive-transfers \ pdrive-ui pdrive-watch; do helper_help="$(HOME="${test_home}" "${project_dir}/bin/${helper}" --help 2>&1)" diff --git a/tests/test-network-tune.sh b/tests/test-network-tune.sh new file mode 100755 index 0000000..959c9fe --- /dev/null +++ b/tests/test-network-tune.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(mktemp -d /tmp/proton-drive-linux-network-tune.XXXXXX)" +cleanup() { rm -rf -- "${test_root}"; } +trap cleanup EXIT + +test_home="${test_root}/home" +fake_curl="${test_root}/curl" +mkdir -p -- "${test_home}" + +cat > "${fake_curl}" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +direction=download +for argument in "$@"; do + if [[ "${argument}" == *'/__up?'* ]]; then + direction=upload + fi +done +if [[ "${direction}" == upload ]]; then + dd of=/dev/null status=none + printf '8388608' +else + printf '20971520' +fi +EOF +chmod 0755 "${fake_curl}" + +if HOME="${test_home}" PDRIVE_CURL_BIN="${fake_curl}" \ + "${project_dir}/bin/pdrive-network-tune" status >/dev/null 2>&1; then + printf 'Network-tune status unexpectedly succeeded without a measurement.\n' >&2 + exit 1 +fi + +result="$({ + HOME="${test_home}" \ + PDRIVE_CURL_BIN="${fake_curl}" \ + PDRIVE_TUNE_PROVIDER_URL='https://speed.example.test' \ + "${project_dir}/bin/pdrive-network-tune" measure --json +})" + +jq -e ' + .schema == 1 + and .provider == "https://speed.example.test" + and .approximate_transferred_bytes == 72000000 + and .bulk_percent == 60 + and .reserve_percent == 40 + and .upload.measured_mib_per_second == 8 + and .upload.recommended_mib_per_second == 4.8 + and .download.measured_mib_per_second == 20 + and .download.recommended_mib_per_second == 12 +' <<< "${result}" >/dev/null + +result_file="${test_home}/.config/pdrive-network-tune.json" +[[ "$(stat -c %a -- "${result_file}")" == 600 ]] +cmp -s <(jq -S . <<< "${result}") <(jq -S . "${result_file}") + +status_output="$(HOME="${test_home}" "${project_dir}/bin/pdrive-network-tune" status)" +grep -qF '4.80 MiB/s bulk limit' <<< "${status_output}" +grep -qF '12.00 MiB/s bulk limit' <<< "${status_output}" +grep -qF '40% for browsing and other traffic' <<< "${status_output}" + +help_output="$(HOME="${test_home}" "${project_dir}/bin/pdrive-network-tune" --help)" +grep -qF 'About 72 MB' <<< "${help_output}" + +printf 'PDrive network auto-tune checks passed.\n' diff --git a/tests/test-prerequisites.sh b/tests/test-prerequisites.sh index ca28a16..7cb7227 100755 --- a/tests/test-prerequisites.sh +++ b/tests/test-prerequisites.sh @@ -9,7 +9,8 @@ cleanup() { rm -rf -- "${test_root}"; } trap cleanup EXIT test_home="${test_root}/home" -bootstrap="${test_root}/bootstrap-rclone" +download="${test_root}/rclone-pdrive-download" +fake_curl="${test_root}/curl" target="${test_home}/.local/libexec/rclone-bin" mkdir -p -- "${test_home}" @@ -18,12 +19,25 @@ mkdir -p -- "${test_home}" printf '%s\n' \ '#!/usr/bin/env bash' \ 'case "${1:-}" in' \ - ' selfupdate) [[ "${2:-}" == --beta && "${3:-}" == --version && "${4:-}" == v1.76.0-beta.10204.660144d31 ]] ;;' \ ' version) printf "rclone v1.76.0-beta.10204.660144d31\n" ;;' \ ' help) [[ "${2:-}" == backend && "${3:-}" == protondrive ]] ;;' \ + ' backend) [[ "${2:-}" == help && "${3:-}" == protondrive ]] && printf "### data-bandwidth\n" ;;' \ ' *) exit 2 ;;' \ - 'esac' > "${bootstrap}" -chmod 0755 "${bootstrap}" + 'esac' > "${download}" +chmod 0755 "${download}" +download_sha="$(sha256sum "${download}" | cut -d ' ' -f 1)" + +# Preserve the expansions for the fake downloader rather than this test process. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'output=""' \ + 'while (( $# )); do' \ + ' case "$1" in --output) output="$2"; shift 2 ;; *) shift ;; esac' \ + 'done' \ + 'cp -- "${PDRIVE_TEST_RCLONE_DOWNLOAD}" "${output}"' \ + > "${fake_curl}" +chmod 0755 "${fake_curl}" if HOME="${test_home}" PDRIVE_REAL_RCLONE="${target}" \ "${project_dir}/bin/pdrive-prerequisites" --check >/dev/null 2>&1; then @@ -33,9 +47,12 @@ fi HOME="${test_home}" \ PDRIVE_REAL_RCLONE="${target}" \ - PDRIVE_BOOTSTRAP_RCLONE="${bootstrap}" \ + PDRIVE_CURL_BIN="${fake_curl}" \ + PDRIVE_RCLONE_URL='https://example.test/rclone-pdrive-linux-amd64' \ + PDRIVE_RCLONE_SHA256="${download_sha}" \ + PDRIVE_TEST_RCLONE_DOWNLOAD="${download}" \ "${project_dir}/bin/pdrive-prerequisites" --install-rclone >/dev/null -HOME="${test_home}" PDRIVE_REAL_RCLONE="${target}" \ +HOME="${test_home}" PDRIVE_REAL_RCLONE="${target}" PDRIVE_RCLONE_SHA256="${download_sha}" \ "${project_dir}/bin/pdrive-prerequisites" --check >/dev/null [[ -x "${target}" ]] @@ -47,6 +64,7 @@ printf '%s\n' \ 'case "${1:-}" in' \ ' version) printf "rclone v1.75.0\n" ;;' \ ' help) exit 0 ;;' \ + ' backend) printf "### data-bandwidth\n" ;;' \ ' *) exit 2 ;;' \ 'esac' > "${unsafe_target}" chmod 0755 "${unsafe_target}" @@ -56,4 +74,17 @@ if HOME="${test_home}" PDRIVE_REAL_RCLONE="${unsafe_target}" \ exit 1 fi +before_sha="$(sha256sum "${target}")" +if HOME="${test_home}" \ + PDRIVE_REAL_RCLONE="${target}" \ + PDRIVE_CURL_BIN="${fake_curl}" \ + PDRIVE_RCLONE_URL='https://example.test/rclone-pdrive-linux-amd64' \ + PDRIVE_RCLONE_SHA256="$(printf '0%.0s' {1..64})" \ + PDRIVE_TEST_RCLONE_DOWNLOAD="${download}" \ + "${project_dir}/bin/pdrive-prerequisites" --install-rclone >/dev/null 2>&1; then + printf 'A PDrive rclone download with an invalid checksum was accepted.\n' >&2 + exit 1 +fi +[[ "$(sha256sum "${target}")" == "${before_sha}" ]] + printf 'PDrive prerequisite bootstrap checks passed.\n' diff --git a/tests/test-setup-wizard-ui.sh b/tests/test-setup-wizard-ui.sh index 5210227..3a17c3a 100755 --- a/tests/test-setup-wizard-ui.sh +++ b/tests/test-setup-wizard-ui.sh @@ -23,6 +23,9 @@ mount_dir="${test_root}/mount" config_file="${test_home}/.config/rclone/rclone.conf" fake_setup="${test_root}/pdrive-setup" fake_rclone="${test_root}/rclone-bin" +fake_network_tune="${test_root}/pdrive-network-tune" +fake_bwlimit="${test_root}/pdrive-bwlimit" +bandwidth_log="${test_root}/bandwidth.log" mkdir -p -- "${test_home}" "${mount_dir}" # Preserve the expansions for the fake process rather than this test process. @@ -32,6 +35,7 @@ printf '%s\n' \ 'case "${1:-}" in' \ ' version) printf "rclone v1.76.0-beta.10204.660144d31\n" ;;' \ ' help) [[ "${2:-}" == backend && "${3:-}" == protondrive ]] ;;' \ + ' backend) [[ "${2:-}" == help && "${3:-}" == protondrive ]] && printf "### data-bandwidth\n" ;;' \ ' *) exit 2 ;;' \ 'esac' > "${fake_rclone}" chmod 0755 "${fake_rclone}" @@ -54,6 +58,19 @@ printf '%s\n' \ 'printf "[proton]\\ntype = protondrive\\n" > "${PDRIVE_RCLONE_CONFIG}"' > "${fake_setup}" chmod 0755 "${fake_setup}" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + '[[ "$*" == "measure --json" ]]' \ + 'printf '\''{"upload":{"measured_mib_per_second":8,"recommended_mib_per_second":4.8},"download":{"measured_mib_per_second":20,"recommended_mib_per_second":12}}\n'\''' \ + > "${fake_network_tune}" +# Preserve the expansion for the fake helper process. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\n" "${1:-}" >> "${PDRIVE_TEST_BANDWIDTH_LOG}"' \ + > "${fake_bwlimit}" +chmod 0755 "${fake_network_tune}" "${fake_bwlimit}" + HOME="${test_home}" \ XDG_CONFIG_HOME="${test_home}/.config" \ XDG_DATA_HOME="${test_home}/.local/share" \ @@ -61,6 +78,9 @@ HOME="${test_home}" \ PDRIVE_RCLONE_CONFIG="${config_file}" \ PDRIVE_REAL_RCLONE="${fake_rclone}" \ PDRIVE_SETUP_BIN="${fake_setup}" \ + PDRIVE_NETWORK_TUNE_BIN="${fake_network_tune}" \ + PDRIVE_BWLIMIT_BIN="${fake_bwlimit}" \ + PDRIVE_TEST_BANDWIDTH_LOG="${bandwidth_log}" \ PDRIVE_UI_NON_UNIQUE=1 \ PYTHONDONTWRITEBYTECODE=1 \ "${runner[@]}" python3 - "${project_dir}/bin/pdrive-ui" <<'PY' @@ -93,6 +113,7 @@ rclone_fixture.write_text( "case \"${1:-}\" in\n" " version) printf 'rclone v1.75.0\\n' ;;\n" " help) exit 0 ;;\n" + " backend) printf '### data-bandwidth\\n' ;;\n" " *) exit 2 ;;\n" "esac\n", encoding="utf-8", @@ -115,7 +136,24 @@ rclone_fixture.write_text(safe_rclone, encoding="utf-8") rclone_fixture.chmod(0o755) wizard.refresh_readiness() assert wizard.readiness["ready"] + +def drain_until(predicate): + deadline = time.monotonic() + 5 + while not predicate() and time.monotonic() < deadline: + while module.Gtk.events_pending(): + module.Gtk.main_iteration_do(False) + time.sleep(0.01) + assert predicate() + wizard.continue_button.emit("clicked") +assert wizard.stack.get_visible_child_name() == "bandwidth" +assert wizard.bandwidth_auto.get_active() +wizard.bandwidth_continue_button.emit("clicked") +drain_until(lambda: wizard.bandwidth_policy_ready and not wizard.bandwidth_preparing) +assert "4.80 / 12.00 MiB/s" in wizard.bandwidth_result.get_text() +bandwidth_log = pathlib.Path(module.os.environ["PDRIVE_TEST_BANDWIDTH_LOG"]) +assert bandwidth_log.read_text(encoding="utf-8").splitlines() == ["4.80:12.00"] +wizard.bandwidth_continue_button.emit("clicked") assert wizard.stack.get_visible_child_name() == "account" def submit(): @@ -128,14 +166,6 @@ def submit(): assert wizard.password_confirm_entry.get_text() == "" assert wizard.two_factor_entry.get_text() == "" -def drain_until(predicate): - deadline = time.monotonic() + 5 - while not predicate() and time.monotonic() < deadline: - while module.Gtk.events_pending(): - module.Gtk.main_iteration_do(False) - time.sleep(0.01) - assert predicate() - module.os.environ["PDRIVE_TEST_SETUP_FAIL"] = "1" submit() drain_until(lambda: wizard.stack.get_visible_child_name() == "account" and not wizard.connecting) diff --git a/tests/test-state.sh b/tests/test-state.sh index 994ab8c..44ae8d6 100755 --- a/tests/test-state.sh +++ b/tests/test-state.sh @@ -51,7 +51,10 @@ printf '%s\n' \ # shellcheck disable=SC2016 printf '%s\n' \ '#!/usr/bin/env bash' \ - 'endpoint="${@: -1}"' \ + 'endpoint=""' \ + 'for argument in "$@"; do' \ + ' case "${argument}" in core/*|vfs/*|backend/*) endpoint="${argument}" ;; esac' \ + 'done' \ 'if [[ "${PDRIVE_TEST_NO_VFS:-}" == 1 && "${endpoint}" == vfs/* ]]; then exit 99; fi' \ 'case "${endpoint}" in' \ ' core/stats)' \ @@ -65,7 +68,7 @@ printf '%s\n' \ ' core/transferred) printf "%s\\n" '\''{"transferred":[{"name":"done.txt","size":12,"bytes":12,"completedAt":"2026-08-24T10:00:00+00:00","srcFs":"/tmp/vfs/proton-test","dstFs":"proton-test:"},{"name":"Projects/demo.qcow2","size":1048576,"bytes":1048576,"completedAt":"2026-08-24T10:04:00+00:00","srcFs":"proton-test:","dstFs":"/tmp/vfs/proton-test"},{"name":"missing-direction.txt","size":24,"bytes":24,"completedAt":"2026-08-24T10:05:00+00:00"},{"name":"ambiguous-direction.txt","size":48,"bytes":48,"completedAt":"2026-08-24T10:06:00+00:00","srcFs":"proton-test:source","dstFs":"proton-test:destination"}]} '\'' ;;' \ ' vfs/queue) printf "%s\\n" '\''{"queue":[{"name":"demo/file.iso","size":2097152,"tries":2,"uploading":true}]} '\'' ;;' \ ' vfs/stats) printf "%s\\n" '\''{"diskCache":{"bytesUsed":3145728,"files":2,"uploadsQueued":1,"uploadsInProgress":1,"erroredFiles":0,"outOfSpace":false},"opt":{"CacheMaxAge":86400000000000}}'\'' ;;' \ - ' core/bwlimit) printf "%s\\n" '\''{"rate":"4M:off","bytesPerSecondTx":4194304}'\'' ;;' \ + ' backend/command) printf "%s\\n" '\''{"result":{"upload":"4M","download":"2M","uploadBytesPerSecond":4194304,"downloadBytesPerSecond":2097152}}'\'' ;;' \ ' *) exit 2 ;;' \ 'esac' > "${fake_bin}/rclone-bin" chmod 0755 "${fake_bin}/systemctl" "${fake_bin}/ss" \ @@ -131,7 +134,7 @@ EOF printf '%s\n' \ '2026-08-24T10:00:00+00:00 status=ready reason=mounted service=active/running pid=4242 mount=ready dns=ok tcp=established progress=yes success=1 queued=1 errors=7 notices=3 vfs_queue=1 vfs_queue_bytes=2097152 vfs_uploading=1 vfs_failed=0' \ | tr ' ' '\t' > "${state_dir}/pdrive-watch-history.log" -printf '%s\n' 'bwlimit=4M:off' > "${config_dir}/pdrive-bwlimit.conf" +printf '%s\n' 'bwlimit=4M:2M' > "${config_dir}/pdrive-bwlimit.conf" printf '%s\n' 'cache_max_age_hours=24' > "${config_dir}/pdrive-cache.conf" printf '%s\n' 'transfers=4' > "${config_dir}/pdrive-transfers.conf" printf '%s\n' 'proton_metadata_cache=true' > "${config_dir}/pdrive-recovery.conf" @@ -178,7 +181,10 @@ jq -e ' and .vfs.cache_state == "pending" and .vfs.clean_files == 1 and .vfs.pending_files == 1 - and .bandwidth.live == "4M:off" + and .bandwidth.configured == "4M:2M" + and .bandwidth.live == "4M:2M" + and .bandwidth.upload_bytes_per_second == 4194304 + and .bandwidth.download_bytes_per_second == 2097152 and .configuration.metadata_cache == true and .configuration.cache_max_age_seconds == 86400 and .configuration.running_cache_max_age_seconds == 86400 diff --git a/tests/test-systemd.sh b/tests/test-systemd.sh index 67c8f9b..113222b 100755 --- a/tests/test-systemd.sh +++ b/tests/test-systemd.sh @@ -60,6 +60,7 @@ grep -qFx 'TimeoutStartSec=infinity' "${mount_unit}" grep -qFx 'RestartSec=1h' "${mount_unit}" grep -qFx 'ExecCondition=%h/.local/libexec/pdrive-auth-failure-guard --start-allowed' "${mount_unit}" grep -qFx 'ExecStartPre=%h/.local/libexec/pdrive-auth-failure-guard --begin-start' "${mount_unit}" +grep -qFx 'ExecStartPost=%h/.local/bin/pdrive-bwlimit --apply-startup' "${mount_unit}" grep -qFx 'ExecStartPost=%h/.local/libexec/pdrive-auth-failure-guard --mark-healthy' "${mount_unit}" grep -qFx 'ExecStopPost=%h/.local/libexec/pdrive-auth-failure-guard --after-service-exit' "${mount_unit}" [[ -x "${project_dir}/libexec/pdrive-auth-failure-guard" ]] @@ -93,7 +94,7 @@ if command -v systemd-analyze >/dev/null 2>&1; then set -e verify_output="$(grep -Ev \ -e '^Failed to (bind private socket|connect to system bus): Operation not permitted$' \ - -e '^(pdrive-draft-recovery|pdrive-watch|proton-drive-update|rclone-proton-drive|rclone-selfupdate)\.service: Command /[^ ]+/\.local/(bin|libexec)/(pdrive-auth-failure-guard|pdrive-draft-recovery-auto|pdrive-watch|proton-drive-update|rclone-proton-mount|rclone-selfupdate)( --auto| --after-service-exit| --begin-start| --mark-healthy| --start-allowed)? is not executable: No such file or directory$' \ + -e '^(pdrive-draft-recovery|pdrive-watch|proton-drive-update|rclone-proton-drive|rclone-selfupdate)\.service: Command /[^ ]+/\.local/(bin|libexec)/(pdrive-auth-failure-guard|pdrive-bwlimit|pdrive-draft-recovery-auto|pdrive-watch|proton-drive-update|rclone-proton-mount|rclone-selfupdate)( --apply-startup| --auto| --after-service-exit| --begin-start| --mark-healthy| --start-allowed)? is not executable: No such file or directory$' \ <<< "${verify_output}" || true)" if (( verify_status != 0 )) && [[ -n "${verify_output}" ]]; then printf '%s\n' "${verify_output}" >&2 diff --git a/tests/test-ui-preferences.sh b/tests/test-ui-preferences.sh index f09c86d..7c9c68f 100755 --- a/tests/test-ui-preferences.sh +++ b/tests/test-ui-preferences.sh @@ -45,6 +45,10 @@ assert module.translate("GitHub project") == "GitHub-Projekt" assert module.translate("License") == "Lizenz" assert module.translate("Quick start") == "Schnellstart" assert module.translate("Everyday use") == "Tägliche Nutzung" +assert module.translate("Download limit in MiB/s") == "Downloadlimit in MiB/s" +assert module.translate( + "The logarithmic sliders give low everyday limits more precision. Leaving connection headroom can keep browsing and calls responsive; use pdrive-bwlimit for values above 100 MiB/s." +).startswith("Die logarithmischen Regler") assert module.translate( "Fabian Schneider — comic relief, lively development chats and plenty of screenshots" ).startswith("Fabian Schneider — Quatschkomödie") @@ -307,11 +311,23 @@ assert identity_tracker.upload_eta_samples == 1 assert module.bandwidth_slider_position("off") == module.BANDWIDTH_SLIDER_UNLIMITED assert module.bandwidth_slider_position("0") == module.BANDWIDTH_SLIDER_UNLIMITED assert 60 < module.bandwidth_slider_position("4.200Mi:off") < 70 +assert module.bandwidth_slider_position("4.200Mi:off", "download") == module.BANDWIDTH_SLIDER_UNLIMITED +assert abs(module.bandwidth_slider_rate(module.bandwidth_slider_position("4.200Mi:1Mi", "download")) - 1) < 0.001 +assert abs(module.bandwidth_slider_rate(module.bandwidth_slider_position("4.200Mi", "download")) - 4.2) < 0.001 assert 40 < module.bandwidth_slider_position("800K:off") < 50 assert abs(module.bandwidth_slider_rate(module.bandwidth_slider_position("4.2")) - 4.2) < 0.001 assert module.bandwidth_slider_command(0) == "0.02" assert module.bandwidth_slider_command(module.bandwidth_slider_position("4.2")) == "4.2" assert module.bandwidth_slider_command(module.BANDWIDTH_SLIDER_UNLIMITED) == "off" +assert module.bandwidth_slider_command( + module.bandwidth_slider_position("4.2"), module.BANDWIDTH_SLIDER_UNLIMITED +) == "4.2:off" +assert module.bandwidth_slider_command( + module.BANDWIDTH_SLIDER_UNLIMITED, module.bandwidth_slider_position("2") +) == "off:2" +assert module.bandwidth_slider_command( + module.BANDWIDTH_SLIDER_UNLIMITED, module.BANDWIDTH_SLIDER_UNLIMITED +) == "off" assert "≈0" in module.bandwidth_slider_label(0) assert "4.2 MiB/s" in module.bandwidth_slider_label(module.bandwidth_slider_position("4.2")) assert "off/0" in module.bandwidth_slider_label(module.BANDWIDTH_SLIDER_UNLIMITED) diff --git a/tests/test-ui-widgets.sh b/tests/test-ui-widgets.sh index 082dba9..80651e3 100755 --- a/tests/test-ui-widgets.sh +++ b/tests/test-ui-widgets.sh @@ -742,6 +742,7 @@ for newcomer_guidance in ( assert " "${rclone_state}" # shellcheck disable=SC2016 printf '%s\n' \ '#!/usr/bin/env bash' \ - 'printf "%s\n" "$*" >> "${PDRIVE_TEST_RCLONE_LOG}"' \ - 'case "${1:-}" in' \ - ' version) cat "${PDRIVE_TEST_RCLONE_STATE}" ;;' \ - ' selfupdate) printf "rclone v1.0.1\n" > "${PDRIVE_TEST_RCLONE_STATE}" ;;' \ - ' *) exit 2 ;;' \ - 'esac' > "${rclone_home}/.local/libexec/rclone-bin" + '[[ "${1:-}" == version ]]' \ + 'cat "${PDRIVE_TEST_RCLONE_STATE}"' > "${rclone_home}/.local/libexec/rclone-bin" chmod 0755 "${rclone_home}/.local/libexec/rclone-bin" -HOME="${rclone_home}" \ - PDRIVE_TEST_RCLONE_LOG="${rclone_home}/rclone.log" \ - PDRIVE_TEST_RCLONE_STATE="${rclone_state}" \ - "${project_dir}/libexec/rclone-selfupdate" > "${rclone_home}/stdout" -grep -qFx 'selfupdate --stable' "${rclone_home}/rclone.log" -grep -qF 'Updated rclone v1.0.0 to rclone v1.0.1.' "${rclone_home}/stdout" -printf 'rclone v1.76.0-beta.10204.660144d31\n' > "${rclone_state}" +# The expansions belong to the generated prerequisite fixture. # shellcheck disable=SC2016 printf '%s\n' \ '#!/usr/bin/env bash' \ 'printf "%s\n" "$*" >> "${PDRIVE_TEST_RCLONE_LOG}"' \ - 'case "${1:-}" in' \ - ' version) cat "${PDRIVE_TEST_RCLONE_STATE}" ;;' \ - ' selfupdate)' \ - ' if [[ "${2:-}" == --stable && "${3:-}" == --check ]]; then' \ - ' printf "Without --check this would install rclone version v1.75.0 at test-bin\n"' \ - ' else' \ - ' printf "Unexpected beta transition\n" >&2; exit 2' \ - ' fi' \ - ' ;;' \ - ' *) exit 2 ;;' \ - 'esac' > "${rclone_home}/.local/libexec/rclone-bin" -chmod 0755 "${rclone_home}/.local/libexec/rclone-bin" -: > "${rclone_home}/rclone.log" + '[[ "$*" == --install-rclone ]]' \ + 'printf "rclone v1.76.0-beta.10204.660144d31\n" > "${PDRIVE_TEST_RCLONE_STATE}"' \ + > "${rclone_home}/pdrive-prerequisites" +chmod 0755 "${rclone_home}/pdrive-prerequisites" HOME="${rclone_home}" \ PDRIVE_TEST_RCLONE_LOG="${rclone_home}/rclone.log" \ PDRIVE_TEST_RCLONE_STATE="${rclone_state}" \ + PDRIVE_PREREQUISITES_BIN="${rclone_home}/pdrive-prerequisites" \ "${project_dir}/libexec/rclone-selfupdate" > "${rclone_home}/stdout" -grep -qFx 'selfupdate --stable --check' "${rclone_home}/rclone.log" -grep -qF 'Keeping rclone v1.76.0-beta.10204.660144d31 until a stable rclone release reaches v1.76.0.' \ +grep -qFx -- '--install-rclone' "${rclone_home}/rclone.log" +grep -qF 'Updated rclone v1.0.0 to rclone v1.76.0-beta.10204.660144d31.' \ "${rclone_home}/stdout" -if grep -qFx 'selfupdate --stable' "${rclone_home}/rclone.log"; then - printf 'The updater downgraded the upload-safe beta to an older stable release.\n' >&2 - exit 1 -fi printf 'PDrive updater integrity checks passed.\n' diff --git a/uninstall.sh b/uninstall.sh index b89c36b..06d0e13 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -95,7 +95,7 @@ systemctl --user disable --now \ pdrive-watch.timer pdrive-draft-recovery.timer rclone-proton-drive.service \ rclone-selfupdate.timer proton-drive-update.timer >/dev/null 2>&1 || true -for file_name in pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-reauth \ +for file_name in pdrive-bwlimit pdrive-cache-age pdrive-doctor pdrive-draft-recovery pdrive-network-tune pdrive-reauth \ pdrive-recovery pdrive-refresh pdrive-setup pdrive-state pdrive-transfers \ pdrive-ui pdrive-watch rclone; do rm -f -- "${bin_dir}/${file_name}" @@ -126,6 +126,7 @@ if [[ -d "${doc_dir}" ]]; then "${doc_dir}/docs/assets/pdrive-transfers.png" \ "${doc_dir}/docs/assets/pdrive-history.png" \ "${doc_dir}/docs/assets/pdrive-auth-cooldown.png" \ + "${doc_dir}/docs/assets/pdrive-setup-wizard.png" \ "${doc_dir}/share/icons/hicolor/scalable/apps/io.github.claudiuschuster.PDriveControl.svg" rmdir "${doc_dir}/docs/assets" "${doc_dir}/docs" 2>/dev/null || true rmdir "${doc_dir}/share/icons/hicolor/scalable/apps" \