Created
January 27, 2026 15:15
-
-
Save Brayden/ca856c7bf655728e01364fbd10ec4795 to your computer and use it in GitHub Desktop.
Mounted R2 Drive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| bold() { printf "\033[1m%s\033[0m\n" "$*"; } | |
| info() { printf "• %s\n" "$*"; } | |
| warn() { printf "\033[33m! %s\033[0m\n" "$*"; } | |
| err() { printf "\033[31m✗ %s\033[0m\n" "$*" >&2; } | |
| ok() { printf "\033[32m✓ %s\033[0m\n" "$*"; } | |
| require_macos() { | |
| if [[ "$(uname -s)" != "Darwin" ]]; then | |
| err "This installer is for macOS only." | |
| exit 1 | |
| fi | |
| } | |
| prompt() { | |
| local var_name="$1" | |
| local message="$2" | |
| local secret="${3:-false}" | |
| local default="${4:-}" | |
| local value="" | |
| while [[ -z "$value" ]]; do | |
| if [[ -n "$default" ]]; then | |
| if [[ "$secret" == "true" ]]; then | |
| read -r -s -p "$message (default: $default): " value | |
| echo | |
| else | |
| read -r -p "$message (default: $default): " value | |
| fi | |
| value="${value:-$default}" | |
| else | |
| if [[ "$secret" == "true" ]]; then | |
| read -r -s -p "$message: " value | |
| echo | |
| else | |
| read -r -p "$message: " value | |
| fi | |
| fi | |
| done | |
| # shellcheck disable=SC2163 | |
| export "$var_name=$value" | |
| } | |
| open_full_disk_access_settings() { | |
| # This opens the Privacy & Security pane. Apple doesn't provide a perfect deep-link | |
| # to the exact sub-page on all macOS versions, so we open the closest reliable pane. | |
| warn "Opening System Settings → Privacy & Security (so you can enable Full Disk Access)..." | |
| open "x-apple.systempreferences:com.apple.preference.security" | |
| echo | |
| bold "Manual step required (macOS security): Enable Full Disk Access" | |
| cat <<'TXT' | |
| 1) System Settings → Privacy & Security | |
| 2) Scroll to "Full Disk Access" | |
| 3) Enable for: | |
| - Terminal (or iTerm, whichever you used to run this script) | |
| - (Optional) rclone, if it appears | |
| 4) Quit Terminal completely and reopen it | |
| 5) Then re-run this script. | |
| Why: macOS may block processes from using /Volumes mounts, which causes rclone to exit with: | |
| "open /Volumes/...: operation not permitted" | |
| TXT | |
| echo | |
| } | |
| ensure_homebrew() { | |
| if command -v brew >/dev/null 2>&1; then | |
| ok "Homebrew is installed." | |
| return | |
| fi | |
| bold "Homebrew not found." | |
| read -r -p "Install Homebrew now? (y/N): " ans | |
| if [[ "${ans:-}" != "y" && "${ans:-}" != "Y" ]]; then | |
| err "Homebrew is required to install macFUSE automatically. Aborting." | |
| exit 1 | |
| fi | |
| info "Installing Homebrew..." | |
| /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" | |
| if [[ -x /opt/homebrew/bin/brew ]]; then | |
| eval "$(/opt/homebrew/bin/brew shellenv)" | |
| elif [[ -x /usr/local/bin/brew ]]; then | |
| eval "$(/usr/local/bin/brew shellenv)" | |
| fi | |
| ok "Homebrew installed." | |
| } | |
| ensure_macfuse() { | |
| if [[ -d /Library/Filesystems/macfuse.fs ]]; then | |
| ok "macFUSE appears installed." | |
| return | |
| fi | |
| bold "macFUSE is required for rclone mount on macOS." | |
| read -r -p "Install macFUSE now (via Homebrew cask)? (y/N): " ans | |
| if [[ "${ans:-}" != "y" && "${ans:-}" != "Y" ]]; then | |
| err "macFUSE is required. Aborting." | |
| exit 1 | |
| fi | |
| info "Installing macFUSE..." | |
| brew install --cask macfuse | |
| warn "IMPORTANT: macOS may block macFUSE until you approve it." | |
| warn "Go to: System Settings → Privacy & Security → Allow (macFUSE), then restart if prompted." | |
| read -r -p "Press Enter to continue after approving (and rebooting if required)... " _ | |
| } | |
| ensure_rclone_official() { | |
| # Remove brew rclone if present to avoid mount limitations | |
| if command -v brew >/dev/null 2>&1; then | |
| if brew list --formula 2>/dev/null | grep -qx "rclone"; then | |
| warn "Homebrew rclone detected. Uninstalling to avoid mount limitations..." | |
| brew uninstall rclone || true | |
| fi | |
| fi | |
| info "Installing official rclone binary..." | |
| set +e | |
| curl -fsSL https://rclone.org/install.sh | sudo bash | |
| local rc=$? | |
| set -e | |
| # Upstream installer may return non-zero even when rclone is already installed | |
| if [[ $rc -ne 0 ]]; then | |
| warn "rclone installer returned exit code ${rc}. Continuing if rclone is present..." | |
| fi | |
| hash -r | |
| if ! command -v rclone >/dev/null 2>&1; then | |
| err "rclone not found after install attempt." | |
| exit 1 | |
| fi | |
| ok "rclone installed at: $(command -v rclone)" | |
| rclone version | head -n 2 || true | |
| } | |
| write_rclone_config() { | |
| local config_dir="${HOME}/.config/rclone" | |
| local config_file="${config_dir}/rclone.conf" | |
| mkdir -p "$config_dir" | |
| chmod 700 "$config_dir" | |
| if [[ -f "$config_file" ]] && grep -q "^\[${R2_REMOTE}\]$" "$config_file"; then | |
| warn "An rclone remote named '${R2_REMOTE}' already exists in ${config_file}." | |
| read -r -p "Overwrite remote '${R2_REMOTE}' config? (y/N): " ow | |
| if [[ "${ow:-}" != "y" && "${ow:-}" != "Y" ]]; then | |
| info "Keeping existing remote config." | |
| return | |
| fi | |
| perl -0777 -i -pe "s/\\n?\\[\\Q${R2_REMOTE}\\E\\]\\n.*?(?=\\n\\[|\\z)//s" "$config_file" | |
| fi | |
| info "Writing rclone config for remote '${R2_REMOTE}'..." | |
| { | |
| echo "[${R2_REMOTE}]" | |
| echo "type = s3" | |
| echo "provider = Cloudflare" | |
| echo "access_key_id = ${R2_ACCESS_KEY_ID}" | |
| echo "secret_access_key = ${R2_SECRET_ACCESS_KEY}" | |
| echo "endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" | |
| echo "region = auto" | |
| echo "acl = private" | |
| } >> "$config_file" | |
| chmod 600 "$config_file" | |
| ok "rclone config written to ${config_file} (permissions set to 600)." | |
| } | |
| test_remote() { | |
| info "Testing access to bucket '${R2_BUCKET}'..." | |
| rclone lsd "${R2_REMOTE}:" >/dev/null | |
| ok "Remote is reachable." | |
| rclone lsf "${R2_REMOTE}:${R2_BUCKET}" >/dev/null | |
| ok "Bucket '${R2_BUCKET}' is accessible." | |
| } | |
| cleanup_stale_mount() { | |
| if mount | grep -q " on ${MOUNTPOINT} "; then | |
| warn "Something is already mounted at ${MOUNTPOINT}. Attempting to unmount..." | |
| diskutil unmount force "$MOUNTPOINT" >/dev/null 2>&1 || true | |
| fi | |
| pkill -f "rclone mount.*${MOUNTPOINT}" >/dev/null 2>&1 || true | |
| } | |
| reset_mountpoint_dir() { | |
| # Avoid deleting a live mount; ensure it's not mounted first. | |
| cleanup_stale_mount | |
| info "Resetting mountpoint directory at ${MOUNTPOINT} (requires sudo)..." | |
| sudo rm -rf "$MOUNTPOINT" | |
| sudo mkdir -p "$MOUNTPOINT" | |
| sudo chown "$USER":staff "$MOUNTPOINT" | |
| sudo chmod 755 "$MOUNTPOINT" | |
| } | |
| mount_daemon() { | |
| local log_file="$HOME/Library/Logs/r2drive.${R2_REMOTE}.${R2_BUCKET}.log" | |
| mkdir -p "$HOME/Library/Logs" | |
| info "Mounting '${R2_REMOTE}:${R2_BUCKET}' at '${MOUNTPOINT}'..." | |
| info "Logging to: ${log_file}" | |
| rclone mount "${R2_REMOTE}:${R2_BUCKET}" "$MOUNTPOINT" \ | |
| --vfs-cache-mode full \ | |
| --vfs-write-back 10s \ | |
| --vfs-cache-max-size "${VFS_CACHE_MAX_SIZE}" \ | |
| --vfs-cache-max-age "${VFS_CACHE_MAX_AGE}" \ | |
| --dir-cache-time "${DIR_CACHE_TIME}" \ | |
| --noappledouble \ | |
| --noapplexattr \ | |
| --log-level INFO \ | |
| --log-file "$log_file" \ | |
| --daemon | |
| sleep 1 | |
| if mount | grep -q " on ${MOUNTPOINT} "; then | |
| ok "Mounted successfully." | |
| return 0 | |
| fi | |
| warn "Mount did not appear active yet." | |
| warn "Checking log for common macOS permission issues..." | |
| if [[ -f "$log_file" ]] && tail -n 200 "$log_file" | grep -qi "operation not permitted"; then | |
| warn "Detected: operation not permitted when accessing ${MOUNTPOINT}" | |
| open_full_disk_access_settings | |
| exit 1 | |
| fi | |
| warn "Check log: ${log_file}" | |
| warn "For direct errors, run without daemon:" | |
| warn " rclone mount \"${R2_REMOTE}:${R2_BUCKET}\" \"${MOUNTPOINT}\" --vfs-cache-mode full --vfs-write-back 10s --noappledouble --noapplexattr -vv" | |
| exit 1 | |
| } | |
| install_launchagent() { | |
| local plist_dir="${HOME}/Library/LaunchAgents" | |
| local safe_bucket="${R2_BUCKET// /_}" | |
| local plist_path="${plist_dir}/com.cloudflare.r2drive.${R2_REMOTE}.${safe_bucket}.plist" | |
| local rclone_path | |
| rclone_path="$(command -v rclone)" | |
| mkdir -p "$plist_dir" | |
| mkdir -p "$HOME/Library/Logs" | |
| cat > "$plist_path" <<EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>Label</key> | |
| <string>com.cloudflare.r2drive.${R2_REMOTE}.${safe_bucket}</string> | |
| <key>ProgramArguments</key> | |
| <array> | |
| <string>${rclone_path}</string> | |
| <string>mount</string> | |
| <string>${R2_REMOTE}:${R2_BUCKET}</string> | |
| <string>${MOUNTPOINT}</string> | |
| <string>--vfs-cache-mode</string> | |
| <string>full</string> | |
| <string>--vfs-write-back</string> | |
| <string>10s</string> | |
| <string>--vfs-cache-max-size</string> | |
| <string>${VFS_CACHE_MAX_SIZE}</string> | |
| <string>--vfs-cache-max-age</string> | |
| <string>${VFS_CACHE_MAX_AGE}</string> | |
| <string>--dir-cache-time</string> | |
| <string>${DIR_CACHE_TIME}</string> | |
| <string>--noappledouble</string> | |
| <string>--noapplexattr</string> | |
| <string>--log-level</string> | |
| <string>INFO</string> | |
| <string>--log-file</string> | |
| <string>${HOME}/Library/Logs/r2drive.${R2_REMOTE}.${safe_bucket}.log</string> | |
| </array> | |
| <key>RunAtLoad</key> | |
| <true/> | |
| <key>KeepAlive</key> | |
| <true/> | |
| <key>StandardOutPath</key> | |
| <string>${HOME}/Library/Logs/r2drive.${R2_REMOTE}.${safe_bucket}.out.log</string> | |
| <key>StandardErrorPath</key> | |
| <string>${HOME}/Library/Logs/r2drive.${R2_REMOTE}.${safe_bucket}.err.log</string> | |
| </dict> | |
| </plist> | |
| EOF | |
| ok "LaunchAgent created: ${plist_path}" | |
| launchctl unload "$plist_path" >/dev/null 2>&1 || true | |
| launchctl load "$plist_path" | |
| ok "LaunchAgent loaded. It will auto-mount on login." | |
| } | |
| main() { | |
| require_macos | |
| bold "Cloudflare R2 Drive Installer (macOS) — rclone + macFUSE" | |
| echo | |
| ensure_homebrew | |
| ensure_macfuse | |
| ensure_rclone_official | |
| echo | |
| bold "R2 Configuration" | |
| info "Create credentials in Cloudflare Dashboard → R2 → Manage R2 API Tokens." | |
| echo | |
| prompt R2_ACCOUNT_ID "Cloudflare Account ID" | |
| prompt R2_ACCESS_KEY_ID "R2 Access Key ID" | |
| prompt R2_SECRET_ACCESS_KEY "R2 Secret Access Key" true | |
| prompt R2_BUCKET "R2 Bucket name (e.g. mount-drive)" | |
| prompt R2_REMOTE "Name this rclone remote" false "r2" | |
| echo | |
| bold "Mount Options" | |
| prompt MOUNT_NAME "Volume name under /Volumes (shows in Finder → Go → Computer)" false "Cloudflare Drive" | |
| MOUNTPOINT="/Volumes/${MOUNT_NAME}" | |
| prompt VFS_CACHE_MAX_SIZE "VFS cache max size (e.g. 5G, 20G)" false "10G" | |
| prompt VFS_CACHE_MAX_AGE "VFS cache max age (e.g. 1h, 24h)" false "1h" | |
| prompt DIR_CACHE_TIME "Directory cache time (e.g. 5m, 1h)" false "5m" | |
| echo | |
| write_rclone_config | |
| test_remote | |
| # Make mountpoint stable + owned, every run | |
| reset_mountpoint_dir | |
| # Mount with Finder-stability flags | |
| mount_daemon | |
| echo | |
| read -r -p "Install auto-mount on login (LaunchAgent)? (y/N): " auto | |
| if [[ "${auto:-}" == "y" || "${auto:-}" == "Y" ]]; then | |
| install_launchagent | |
| else | |
| info "Skipping auto-mount. To mount later:" | |
| echo " rclone mount ${R2_REMOTE}:${R2_BUCKET} \"${MOUNTPOINT}\" --vfs-cache-mode full --vfs-write-back 10s --noappledouble --noapplexattr --daemon" | |
| fi | |
| echo | |
| ok "Done!" | |
| info "Open Finder → Go → Computer → ${MOUNT_NAME}" | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment