From d95fbfc293432f0e42d5e3d8f0f527f7bc1ec3ef Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 12:07:52 +0200 Subject: [PATCH 01/18] Add Google Drive backing layer for Time Machine Time Machine writes to a locally attached sparsebundle whose bands live on Google Drive behind an rclone mount with a 100 GB write-back cache. No network filesystem sits in the write path, so a dropped link stalls the drain instead of interrupting the backup. Bands are 64 MB rather than the 8 MB default. Drive allows roughly two operations per file per second and caps a drive at 400,000 files; for a 200 GB backup that is the difference between ~25,000 and ~3,200 files. The cost is that any change dirties a full 64 MB. Band size is fixed at image creation and cannot be changed later without starting over. Three rclone flags carry weight: --drive-use-trash=false keeps deleted bands from counting against quota, --drive-stop-on-upload-limit halts rather than spinning on 403 once the 750 GB daily cap is hit, and --drive-chunk-size matches the band size. The mount script excludes the cache directory from Time Machine; without that Time Machine would back up its own backup. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- gdrive/README.md | 87 +++++++++++++++++++ gdrive/attach-image.sh | 34 ++++++++ gdrive/cm-env.sh | 31 +++++++ ....cloudmachine.gdrive-attach.plist.template | 39 +++++++++ ...enacode.cloudmachine.gdrive.plist.template | 41 +++++++++ gdrive/create-image.sh | 36 ++++++++ gdrive/mount-drive.sh | 38 ++++++++ gdrive/status.sh | 38 ++++++++ 8 files changed, 344 insertions(+) create mode 100644 gdrive/README.md create mode 100755 gdrive/attach-image.sh create mode 100755 gdrive/cm-env.sh create mode 100644 gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template create mode 100644 gdrive/com.renacode.cloudmachine.gdrive.plist.template create mode 100755 gdrive/create-image.sh create mode 100755 gdrive/mount-drive.sh create mode 100755 gdrive/status.sh diff --git a/gdrive/README.md b/gdrive/README.md new file mode 100644 index 0000000..ea6ea41 --- /dev/null +++ b/gdrive/README.md @@ -0,0 +1,87 @@ +# CloudMachine / warstwa Google Drive + +Time Machine na Google Drive, bez dysku zewnetrznego i bez sprzetu w domu. + +``` +Time Machine + -> /Volumes/CloudMachine obraz podpiety przez hdiutil; TM widzi zwykly APFS + -> ~/.cloudmachine/drive rclone mount na FUSE-T + -> ~/.cloudmachine/cache bufor zapisu 100 GB + -> gdrive:CloudMachine/... Google Drive +``` + +Sens ukladu: **w sciezce zapisu nie ma sieciowego systemu plikow.** Time Machine +pisze do lokalnie podpietego obrazu i nie wie, ze pasma leza w chmurze. Odpada +SMB, a z nim najczestsza przyczyna psucia sie backupow sieciowych - uszkodzenie +sparsebundle przy zerwaniu polaczenia. Zapis konczy sie w buforze, wysylka idzie +w tle, wiec utrata lacza wstrzymuje drenaz zamiast przerywac backup. + +## Dwie decyzje, ktore trzymaja to przy zyciu + +**Pasma po 64 MB zamiast domyslnych 8 MB.** Google Drive przepuszcza okolo +dwoch operacji na plik na sekunde i ma limit 400 000 plikow. Dla 200 GB backupu +to roznica miedzy ~25 000 a ~3 200 plikow. Cena: kazda zmiana brudzi cale 64 MB +i tyle trzeba wyslac od nowa. Ustawiane wylacznie przy tworzeniu obrazu - +pozniej nie da sie tego zmienic bez zaczynania od zera. + +**Wlasny client_id OAuth.** Wspoldzielony client_id rclone jest wycofywany i +przestanie dzialac w trakcie 2026 roku. Niezaleznie od tego Google limituje +tempo per client_id, wiec na wspoldzielonym konkurujesz ze wszystkimi +uzytkownikami rclone naraz. + +## Instalacja + +Wymagania: `rclone`, FUSE-T (`brew install macos-fuse-t/homebrew-cask/fuse-t`), +skonfigurowany remote `gdrive` z wlasnym client_id. + +```sh +chmod +x gdrive/*.sh + +./gdrive/mount-drive.sh & # albo od razu przez launchd +./gdrive/create-image.sh # raz, na starcie +./gdrive/attach-image.sh + +sudo tmutil setdestination /Volumes/CloudMachine +``` + +Agenty launchd - podmien `__REPO__` i `__HOME__` na sciezki absolutne: + +```sh +for t in gdrive gdrive-attach; do + sed -e "s|__REPO__|$PWD|g" -e "s|__HOME__|$HOME|g" \ + "gdrive/com.renacode.cloudmachine.$t.plist.template" \ + > ~/Library/LaunchAgents/com.renacode.cloudmachine.$t.plist + launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.renacode.cloudmachine.$t.plist +done +``` + +## Pierwszy backup + +Ten Mac ma wlaczone usypianie, a spiacy Mac nie wysyla nic do Drive'a. Na czas +pierwszego pelnego transferu: + +```sh +caffeinate -dims & +``` + +## Podglad + +```sh +./gdrive/status.sh +``` + +Liczba, ktora trzeba obserwowac, to `uploadsQueued`. Jesli rosnie i nie wraca do +zera miedzy backupami, wysylka nie nadaza za zapisem i bufor sie zapelnia. + +## Czego jeszcze nie sprawdzono + +- Czy `hdiutil attach` na obrazie lezacym na wolumenie FUSE-T dziala stabilnie + pod obciazeniem. FUSE-T montuje przez NFS albo FSKit, wiec obraz lezy + technicznie na wolumenie sieciowym. +- Czy `tmutil setdestination` na macOS 26.6 przyjmie tak podpiety obraz. +- Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych. Zalozenie + projektu, do potwierdzenia testem: odciac siec w trakcie backupu, przywrocic, + odczekac na pelny drenaz, `hdiutil verify` na obrazie. Powtorzyc kilka razy. +- Limit 750 GB/dobe. Pierwszy backup (~200 GB) sie miesci, ale przyrosty + nalezy zmierzyc. `--drive-stop-on-upload-limit` zatrzymuje rclone zamiast + pozwalac mu kreci sie w 403. diff --git a/gdrive/attach-image.sh b/gdrive/attach-image.sh new file mode 100755 index 0000000..3dfc3c8 --- /dev/null +++ b/gdrive/attach-image.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Podpina obraz jako /Volumes/CloudMachine. Uruchamiane przy kazdym starcie, +# po tym jak mount-drive.sh postawi montowanie. + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +# Time Machine nie moze zobaczyc celu, zanim bufor bedzie gotowy - inaczej +# uzna, ze dysk backupu zniknal. +for _ in $(seq 1 60); do + cm_is_mounted && break + sleep 2 +done + +if ! cm_is_mounted; then + echo "Drive nie wstal w $CM_MOUNT po 120 s - przerywam" >&2 + exit 1 +fi + +if cm_is_attached; then + echo "Juz podpiety: $CM_TARGET" + exit 0 +fi + +if [ ! -d "$CM_IMAGE" ]; then + echo "Brak obrazu $CM_IMAGE - najpierw create-image.sh" >&2 + exit 1 +fi + +hdiutil attach "$CM_IMAGE" \ + -nobrowse \ + -mountpoint "$CM_TARGET" + +echo "Podpiete: $CM_TARGET" diff --git a/gdrive/cm-env.sh b/gdrive/cm-env.sh new file mode 100755 index 0000000..a689be5 --- /dev/null +++ b/gdrive/cm-env.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Wspolna konfiguracja dla warstwy Google Drive. +# Kazda wartosc da sie nadpisac zmienna srodowiskowa przed uruchomieniem skryptu. + +CM_REMOTE="${CM_REMOTE:-gdrive}" # nazwa remote'a w rclone.conf +CM_REMOTE_PATH="${CM_REMOTE_PATH:-CloudMachine/mac-studio}" +CM_ROOT="${CM_ROOT:-$HOME/.cloudmachine}" +CM_MOUNT="$CM_ROOT/drive" # tu widac zawartosc Drive'a +CM_CACHE="$CM_ROOT/cache" # bufor zapisu +CM_LOG="$CM_ROOT/rclone.log" +CM_CACHE_SIZE="${CM_CACHE_SIZE:-100G}" + +CM_IMAGE_NAME="${CM_IMAGE_NAME:-mac-studio}" +CM_IMAGE_SIZE="${CM_IMAGE_SIZE:-1500g}" # rozmiar deklarowany, obraz jest rzadki +CM_VOLNAME="${CM_VOLNAME:-CloudMachine}" +CM_IMAGE="$CM_MOUNT/$CM_IMAGE_NAME.sparsebundle" +CM_TARGET="/Volumes/$CM_VOLNAME" + +# 64 MB na pasmo, podane w sektorach po 512 B. +# Powod: Google Drive przepuszcza okolo 2 operacji na plik na sekunde i ma limit +# 400 000 plikow. Domyslne pasma 8 MB daloby ~25 000 plikow na 200 GB backupu, +# 64 MB schodzi do ~3 200. Cena: kazda zmiana w pasmie to wyslanie calych 64 MB. +CM_BAND_SECTORS="${CM_BAND_SECTORS:-131072}" + +cm_is_mounted() { + /sbin/mount | grep -q " on $CM_MOUNT " +} + +cm_is_attached() { + [ -d "$CM_TARGET" ] && /sbin/mount | grep -q " on $CM_TARGET " +} diff --git a/gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template b/gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template new file mode 100644 index 0000000..0d0a2e8 --- /dev/null +++ b/gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template @@ -0,0 +1,39 @@ + + + + + + Label + com.renacode.cloudmachine.gdrive-attach + + ProgramArguments + + __REPO__/gdrive/attach-image.sh + + + RunAtLoad + + + + StartInterval + 900 + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + StandardOutPath + __HOME__/.cloudmachine/launchd-attach.out.log + StandardErrorPath + __HOME__/.cloudmachine/launchd-attach.err.log + + diff --git a/gdrive/com.renacode.cloudmachine.gdrive.plist.template b/gdrive/com.renacode.cloudmachine.gdrive.plist.template new file mode 100644 index 0000000..910d842 --- /dev/null +++ b/gdrive/com.renacode.cloudmachine.gdrive.plist.template @@ -0,0 +1,41 @@ + + + + + + Label + com.renacode.cloudmachine.gdrive + + ProgramArguments + + __REPO__/gdrive/mount-drive.sh + + + RunAtLoad + + + KeepAlive + + + ThrottleInterval + 30 + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + StandardOutPath + __HOME__/.cloudmachine/launchd-mount.out.log + StandardErrorPath + __HOME__/.cloudmachine/launchd-mount.err.log + + diff --git a/gdrive/create-image.sh b/gdrive/create-image.sh new file mode 100755 index 0000000..ff770c9 --- /dev/null +++ b/gdrive/create-image.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Tworzy obraz sparsebundle na zamontowanym Drive. Uruchamiane raz. +# +# Time Machine dostaje do reki zwykly wolumen APFS i nie wie, ze pasma obrazu +# leza w chmurze. Dzieki temu w sciezce zapisu nie ma sieciowego systemu plikow - +# odpada cala klasa awarii, ktore trapia Time Machine po SMB. + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +if ! cm_is_mounted; then + echo "Drive nie jest zamontowany w $CM_MOUNT - najpierw mount-drive.sh" >&2 + exit 1 +fi + +if [ -d "$CM_IMAGE" ]; then + echo "Obraz juz istnieje: $CM_IMAGE" >&2 + echo "Usun go recznie, jesli chcesz zaczac od zera - to kasuje caly backup." >&2 + exit 1 +fi + +echo "Tworze $CM_IMAGE" +echo " rozmiar deklarowany: $CM_IMAGE_SIZE (rzadki - zajmuje tyle, ile zapisane)" +echo " pasmo: $((CM_BAND_SECTORS * 512 / 1024 / 1024)) MB" + +hdiutil create \ + -type SPARSEBUNDLE \ + -size "$CM_IMAGE_SIZE" \ + -fs "Case-sensitive APFS" \ + -volname "$CM_VOLNAME" \ + -imagekey "sparse-band-size=$CM_BAND_SECTORS" \ + "$CM_IMAGE" + +echo +echo "Gotowe. Nastepny krok: attach-image.sh, potem" +echo " sudo tmutil setdestination $CM_TARGET" diff --git a/gdrive/mount-drive.sh b/gdrive/mount-drive.sh new file mode 100755 index 0000000..7a590ac --- /dev/null +++ b/gdrive/mount-drive.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Montuje Google Drive jako wolumen z buforem zapisu na dysku lokalnym. +# Zapis konczy sie w momencie trafienia do bufora; wysylka do Drive'a idzie w tle, +# wiec zerwanie lacza nie przerywa Time Machine, tylko wstrzymuje drenaz. +# +# Proces zostaje na pierwszym planie - zarzadza nim launchd (KeepAlive). + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +mkdir -p "$CM_MOUNT" "$CM_CACHE" + +if cm_is_mounted; then + echo "Juz zamontowane: $CM_MOUNT" + exit 0 +fi + +# Bufor trzyma kopie danych backupu. Gdyby Time Machine go objal, backupowalby +# wlasny backup i rosl bez konca. +tmutil addexclusion "$CM_ROOT" 2>/dev/null || true + +exec rclone mount "${CM_REMOTE}:${CM_REMOTE_PATH}" "$CM_MOUNT" \ + --vfs-cache-mode full \ + --vfs-cache-max-size "$CM_CACHE_SIZE" \ + --vfs-cache-max-age 9999h \ + --vfs-write-back 30s \ + --vfs-cache-poll-interval 1m \ + --cache-dir "$CM_CACHE" \ + --dir-cache-time 5m \ + --attr-timeout 5m \ + --transfers 8 \ + --drive-chunk-size 64M \ + --drive-use-trash=false \ + --drive-stop-on-upload-limit \ + --volname "$CM_REMOTE" \ + --rc --rc-addr 127.0.0.1:5572 --rc-no-auth \ + --log-file "$CM_LOG" \ + --log-level INFO diff --git a/gdrive/status.sh b/gdrive/status.sh new file mode 100755 index 0000000..863e4af --- /dev/null +++ b/gdrive/status.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Stan warstwy Drive: czy bufor stoi, ile w nim zalega, co robi Time Machine. +# Kluczowa liczba to "uploads in progress/queued" - jesli rosnie i nie spada, +# wysylka nie nadaza za zapisem i bufor sie zapelnia. + +source "$(dirname "$0")/cm-env.sh" + +printf '%-22s ' "Montowanie Drive:" +cm_is_mounted && echo "OK ($CM_MOUNT)" || echo "BRAK" + +printf '%-22s ' "Obraz podpiety:" +cm_is_attached && echo "OK ($CM_TARGET)" || echo "BRAK" + +printf '%-22s ' "Bufor na dysku:" +if [ -d "$CM_CACHE" ]; then + echo "$(du -shx "$CM_CACHE" 2>/dev/null | awk '{print $1}') z limitu $CM_CACHE_SIZE" +else + echo "brak katalogu" +fi + +echo +echo "--- kolejka wysylki (rclone vfs/stats) ---" +rclone rc --url 127.0.0.1:5572 --no-auth vfs/stats 2>/dev/null \ + | grep -E '"(uploadsInProgress|uploadsQueued|files|erroredFiles|bytesUsed)"' \ + || echo "(rclone rc nieosiagalne - bufor nie dziala?)" + +echo +echo "--- Time Machine ---" +tmutil destinationinfo 2>/dev/null | grep -E "Name|Kind|Mount" || echo "(brak celu)" +tmutil status 2>/dev/null | grep -E "Running|Percent|BytesWritten|Phase" || true + +echo +echo "--- ostatnie bledy w logu rclone ---" +if [ -f "$CM_LOG" ]; then + grep -iE "error|failed|403|quota" "$CM_LOG" | tail -5 || echo "(brak)" +else + echo "(brak logu)" +fi From 95fb6d8a0b39529402851350d3bd2425f8422849 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 12:28:41 +0200 Subject: [PATCH 02/18] Size bands by measurement and survive loss of the cloud layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Band size drops from 64 MB to 32 MB. The original 64 MB was justified on the assumption that larger bands cost nothing; measurement says otherwise. Under the append pattern Time Machine actually produces, upload volume grows monotonically with band size — 768 MB at 64 MB bands versus 384 MB at 8 MB for the same 300 MB of real change. 32 MB is the smallest band at which the first upload stops being bound by Drive's per-file rate (6,400 files, ~0.9 h) and becomes bound by link bandwidth (~1.3 h). Below it the first backup drags; above it every increment pays more transfer for a benefit that is already gone. Under a scattered rewrite, by contrast, band size is irrelevant — nearly every band is dirtied whatever its size, so the whole image goes over the wire. That is the worst case, not the one this workload hits. poc-pullplug.sh pulls the backing store out from under the image mid-write, standing in for a dead rclone process or a crashed FUSE-T. Three rounds, no unrecoverable loss: the image passed fsck_apfs every time. Getting that answer took fixing two traps the test itself fell into, both now handled in the scripts: Stale devices. A forced detach can leave the image's device behind. Reattaching then yields a dead handle where fsck_apfs reports "failed to read container superblock" with an all-zero UUID — indistinguishable from a destroyed backup unless you check from a clean state. The first version of this test declared total loss three times running; the data was intact. cm_purge_stale_devices clears them before attaching. Orphaned mountpoints. An unclean detach leaves /Volumes/ behind, and it blocks reattachment with "no mountable file systems". The directory belongs to the user but sits in root-owned /Volumes, so rmdir is refused — a launchd agent running as the user cannot recover on its own and the backup stalls until someone intervenes. attach-image.sh now detects this and prints the exact command to run. Also adds install-rclone.sh, because the Homebrew rclone is built without mount support and refuses outright, and verify-image.sh, because sparsebundles carry no checksum and hdiutil verify cannot check them — fsck_apfs on the attached device is the right tool. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- gdrive/README.md | 133 ++++++++++++++++++++++++++++++------ gdrive/attach-image.sh | 22 ++++++ gdrive/cm-env.sh | 60 ++++++++++++++-- gdrive/install-rclone.sh | 47 +++++++++++++ gdrive/mount-drive.sh | 7 +- gdrive/poc-amplification.sh | 91 ++++++++++++++++++++++++ gdrive/poc-local.sh | 87 +++++++++++++++++++++++ gdrive/poc-pullplug.sh | 106 ++++++++++++++++++++++++++++ gdrive/setup-remote.sh | 54 +++++++++++++++ gdrive/status.sh | 2 +- gdrive/verify-image.sh | 53 ++++++++++++++ 11 files changed, 633 insertions(+), 29 deletions(-) create mode 100755 gdrive/install-rclone.sh create mode 100755 gdrive/poc-amplification.sh create mode 100755 gdrive/poc-local.sh create mode 100755 gdrive/poc-pullplug.sh create mode 100755 gdrive/setup-remote.sh create mode 100755 gdrive/verify-image.sh diff --git a/gdrive/README.md b/gdrive/README.md index ea6ea41..0ac17f6 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -16,31 +16,101 @@ SMB, a z nim najczestsza przyczyna psucia sie backupow sieciowych - uszkodzenie sparsebundle przy zerwaniu polaczenia. Zapis konczy sie w buforze, wysylka idzie w tle, wiec utrata lacza wstrzymuje drenaz zamiast przerywac backup. -## Dwie decyzje, ktore trzymaja to przy zyciu +## Dwie pulapki, ktore kosztuja dzien szukania -**Pasma po 64 MB zamiast domyslnych 8 MB.** Google Drive przepuszcza okolo -dwoch operacji na plik na sekunde i ma limit 400 000 plikow. Dla 200 GB backupu -to roznica miedzy ~25 000 a ~3 200 plikow. Cena: kazda zmiana brudzi cale 64 MB -i tyle trzeba wyslac od nowa. Ustawiane wylacznie przy tworzeniu obrazu - -pozniej nie da sie tego zmienic bez zaczynania od zera. +**rclone z Homebrew nie umie montowac.** Odmawia wprost: +`rclone mount is not supported on MacOS when rclone is installed via Homebrew`. +Potrzebna jest oficjalna binarka z rclone.org - stawia ja `install-rclone.sh` +obok instalacji brew, we wlasnym katalogu, bez ruszania systemowego rclone. -**Wlasny client_id OAuth.** Wspoldzielony client_id rclone jest wycofywany i -przestanie dzialac w trakcie 2026 roku. Niezaleznie od tego Google limituje -tempo per client_id, wiec na wspoldzielonym konkurujesz ze wszystkimi -uzytkownikami rclone naraz. +**Sparsebundle nie ma sumy kontrolnej,** wiec `hdiutil verify` na nim nie dziala +(`has no checksum`). Do sprawdzania spojnosci sluzy `fsck_apfs` na podpietym +urzadzeniu - patrz `verify-image.sh`. -## Instalacja +## Rozmiar pasma + +Ustawiany wylacznie przy tworzeniu obrazu; pozniej nie da sie go zmienic bez +zaczynania backupu od zera. Dwie sily ciagna w przeciwne strony: + +- Google Drive przepuszcza okolo **dwoch operacji na plik na sekunde** i ma + limit **400 000 plikow**. To premiuje duze pasma. +- Kazda zmiana brudzi **cale** pasmo i tyle trzeba wyslac od nowa. Przy limicie + **750 GB uploadu na dobe** to premiuje male pasma. + +Zmierzone lokalnie (`poc-amplification.sh`, obraz 3 GB, zmiana 300 MB): + +| Pasmo | Pasm na 3 GB | Rozrzucona zmiana | Dopisanie (jak TM) | Plikow na 200 GB | +|-------|--------------|-------------------|--------------------|------------------| +| 8 MB | 381 | 2712 MB | 384 MB | 25 600 | +| 16 MB | 193 | 3040 MB | 480 MB | 12 800 | +| 32 MB | 99 | 3072 MB | **672 MB** | **6 400** | +| 64 MB | 52 | 3136 MB | 768 MB | 3 200 | + +Dwa wnioski, oba wbrew pierwotnemu zalozeniu. + +Przy zmianie **rozrzuconej** po calym wolumenie rozmiar pasma nie ma znaczenia - +brudzi sie prawie kazde pasmo i wysyla sie w praktyce caly obraz. Ale to +najgorszy przypadek, nie ten, ktory nas dotyczy. + +Przy **dopisywaniu**, czyli tym, co faktycznie robi Time Machine, transfer rosnie +monotonicznie z rozmiarem pasma: 64 MB kosztuje dokladnie dwa razy tyle co 8 MB. +Czyli duze pasma nie sa darmowe, jak zakladalem. + +Stad **32 MB**, nie 64: to najmniejsze pasmo, przy ktorym pierwsza wysylka +przestaje byc ograniczona tempem operacji Drive'a (6 400 plikow, ~0,9 h przy +~2 operacjach na sekunde) i zaczyna byc ograniczona pasmem lacza (~1,3 h przy +332 Mb/s). Ponizej tego progu wydluzasz pierwszy backup, powyzej - placisz +wiekszym transferem przy kazdym przyroscie za korzysc, ktorej juz nie ma. + +## Co przetrwa smierc warstwy chmurowej + +`poc-pullplug.sh` odpina zastepnik montowania w trakcie zapisu do obrazu, czyli +symuluje padniecie procesu rclone albo wysypanie sie FUSE-T. Trzy rundy, +**zero nieodwracalnych strat** - obraz za kazdym razem przeszedl `fsck_apfs`. + +Uwaga: zerwanie samego lacza to co innego i jest lagodniejsze. Przy +`--vfs-cache-mode full` zapis idzie do bufora na dysku, wiec brak sieci +wstrzymuje tylko wysylke; montowanie stoi i Time Machine niczego nie zauwaza. + +Test ujawnil natomiast dwie pulapki w obsludze, obie juz zalatane w skryptach: -Wymagania: `rclone`, FUSE-T (`brew install macos-fuse-t/homebrew-cask/fuse-t`), -skonfigurowany remote `gdrive` z wlasnym client_id. +**Zombie urzadzenia.** Po wymuszonym odpieciu urzadzenie obrazu potrafi zostac +w systemie. Podpiecie zwraca wtedy martwy uchwyt, na ktorym `fsck_apfs` melduje +`failed to read container superblock` z UUID z samych zer. Wyglada to jak +skasowany backup, a jest tylko nieczytelnym urzadzeniem - pierwsza wersja tego +testu na tej podstawie trzy razy z rzedu orzekla utrate danych, ktore byly cale. +`cm_purge_stale_devices` w `cm-env.sh` sprzata je przed podpieciem. + +**Osierocony punkt montowania.** Po nieczystym odpieciu katalog +`/Volumes/CloudMachine` zostaje i blokuje ponowne podpiecie komunikatem +`no mountable file systems`. Nalezy do uzytkownika, ale lezy w `/Volumes` +nalezacym do roota, wiec `rmdir` odmawia - **agent launchd dzialajacy jako +uzytkownik nie posprzata po sobie sam** i backup stoi do reczne interwencji. +`attach-image.sh` wykrywa to i wypisuje dokladne polecenie do uruchomienia. +Obejsciem docelowym byloby montowanie poza `/Volumes` (patrz `CM_TARGET`), ale +nie wiadomo, czy `tmutil setdestination` przyjmie taki cel. + +**Wlasny client_id OAuth** to osobna koniecznosc: wspoldzielony client_id rclone +jest wycofywany i przestanie dzialac w trakcie 2026 roku, a Google limituje +tempo per client_id - na wspoldzielonym konkurujesz ze wszystkimi uzytkownikami +rclone naraz. + +## Instalacja ```sh chmod +x gdrive/*.sh +./gdrive/install-rclone.sh # binarka z obsluga mount +brew install macos-fuse-t/homebrew-cask/fuse-t # FUSE bez kexta +rclone config # remote "gdrive", wlasny client_id +``` + +Konfiguracja rclone z tokenami OAuth trafia do `~/.config/rclone/rclone.conf`, +poza repozytorium. To repo jest publiczne - nic z sekretami tu nie wchodzi. -./gdrive/mount-drive.sh & # albo od razu przez launchd +```sh +./gdrive/mount-drive.sh & ./gdrive/create-image.sh # raz, na starcie ./gdrive/attach-image.sh - sudo tmutil setdestination /Volumes/CloudMachine ``` @@ -73,15 +143,36 @@ caffeinate -dims & Liczba, ktora trzeba obserwowac, to `uploadsQueued`. Jesli rosnie i nie wraca do zera miedzy backupami, wysylka nie nadaza za zapisem i bufor sie zapelnia. +## Skrypty testowe + +`poc-local.sh` - przepuszcza caly lancuch bez Drive'a i bez FUSE-T, podstawiajac +lokalny obraz w miejsce montowania rclone. Dowodzi, ze obraz powstaje, podpina +sie, przyjmuje dane i przezywa odpiecie z ponownym podpieciem. + +`poc-amplification.sh` - mierzy, ile megabajtow trzeba wyslac na kazdy megabajt +faktycznej zmiany. `BAND_MB` wybiera rozmiar pasma, `WORKLOAD` scenariusz +(`scatter` - najgorszy przypadek, `append` - wzorzec Time Machine). + +`poc-pullplug.sh` - wyrywa warstwe chmurowa spod obrazu w trakcie zapisu +i sprawdza, czy backup da sie odzyskac. + +`verify-image.sh` - sprawdza spojnosc obrazu przez `fsck_apfs`. + ## Czego jeszcze nie sprawdzono - Czy `hdiutil attach` na obrazie lezacym na wolumenie FUSE-T dziala stabilnie pod obciazeniem. FUSE-T montuje przez NFS albo FSKit, wiec obraz lezy technicznie na wolumenie sieciowym. - Czy `tmutil setdestination` na macOS 26.6 przyjmie tak podpiety obraz. -- Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych. Zalozenie - projektu, do potwierdzenia testem: odciac siec w trakcie backupu, przywrocic, - odczekac na pelny drenaz, `hdiutil verify` na obrazie. Powtorzyc kilka razy. -- Limit 750 GB/dobe. Pierwszy backup (~200 GB) sie miesci, ale przyrosty - nalezy zmierzyc. `--drive-stop-on-upload-limit` zatrzymuje rclone zamiast - pozwalac mu kreci sie w 403. +- Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych. Na tym stoi + cala obietnica nieprzerywalnosci. Test: odciac siec w trakcie backupu, + przywrocic, odczekac na pelny drenaz, `verify-image.sh`. Powtorzyc kilka razy. + (`poc-pullplug.sh` pokrywa mocniejszy przypadek - smierc calej warstwy - ale + nie ten konkretny, bo wymaga dzialajacego rclone.) +- Czy `tmutil setdestination` przyjmie cel spoza `/Volumes`. Od tego zalezy, + czy da sie usunac koniecznosc recznej interwencji po nieczystym odpieciu. +- Rzeczywiste wzmocnienie zapisu przy prawdziwych backupach Time Machine, + a nie przy symulacji. +- Czy `--rc-no-auth` na 127.0.0.1:5572 jest akceptowalne. Interfejs slucha tylko + na petli zwrotnej, ale kazdy lokalny proces moze przez niego sterowac + montowaniem. Do rozwazenia `--rc-user`/`--rc-pass`. diff --git a/gdrive/attach-image.sh b/gdrive/attach-image.sh index 3dfc3c8..bba655d 100755 --- a/gdrive/attach-image.sh +++ b/gdrive/attach-image.sh @@ -27,6 +27,28 @@ if [ ! -d "$CM_IMAGE" ]; then exit 1 fi +# Zombie po poprzednim, nieczystym odpieciu blokuje ponowne podpiecie +# komunikatem "no mountable file systems". Zaobserwowane w poc-pullplug.sh. +if [ -n "$(cm_devices_for_image "$CM_IMAGE")" ]; then + echo "Usuwam zawieszone urzadzenia po poprzednim podpieciu" + cm_purge_stale_devices "$CM_IMAGE" + sleep 2 +fi + +# Osierocony punkt montowania po nieczystym odpieciu blokuje podpiecie. +# Jesli lezy w /Volumes, usuniecie wymaga roota - dlatego mowimy dokladnie, co +# uruchomic, zamiast ponawiac w nieskonczonosc. +if [ -d "$CM_TARGET" ] && ! cm_is_attached; then + if rmdir "$CM_TARGET" 2>/dev/null; then + echo "Usunieto osierocony punkt montowania $CM_TARGET" + else + echo "Osierocony punkt montowania blokuje podpiecie: $CM_TARGET" >&2 + echo "Usun go i uruchom ponownie:" >&2 + echo " sudo rmdir '$CM_TARGET'" >&2 + exit 1 + fi +fi + hdiutil attach "$CM_IMAGE" \ -nobrowse \ -mountpoint "$CM_TARGET" diff --git a/gdrive/cm-env.sh b/gdrive/cm-env.sh index a689be5..cb21773 100755 --- a/gdrive/cm-env.sh +++ b/gdrive/cm-env.sh @@ -2,6 +2,11 @@ # Wspolna konfiguracja dla warstwy Google Drive. # Kazda wartosc da sie nadpisac zmienna srodowiskowa przed uruchomieniem skryptu. +# rclone z Homebrew jest zbudowany BEZ obslugi mount i odmawia z komunikatem +# "rclone mount is not supported on MacOS when rclone is installed via Homebrew". +# Potrzebna jest oficjalna binarka z rclone.org - patrz install-rclone.sh. +CM_RCLONE="${CM_RCLONE:-$HOME/.cloudmachine/bin/rclone}" + CM_REMOTE="${CM_REMOTE:-gdrive}" # nazwa remote'a w rclone.conf CM_REMOTE_PATH="${CM_REMOTE_PATH:-CloudMachine/mac-studio}" CM_ROOT="${CM_ROOT:-$HOME/.cloudmachine}" @@ -14,13 +19,36 @@ CM_IMAGE_NAME="${CM_IMAGE_NAME:-mac-studio}" CM_IMAGE_SIZE="${CM_IMAGE_SIZE:-1500g}" # rozmiar deklarowany, obraz jest rzadki CM_VOLNAME="${CM_VOLNAME:-CloudMachine}" CM_IMAGE="$CM_MOUNT/$CM_IMAGE_NAME.sparsebundle" -CM_TARGET="/Volumes/$CM_VOLNAME" +# Punkt montowania celu. Domyslnie /Volumes, bo to sciezka, ktora Time Machine +# na pewno przyjmuje. +# +# Cena tego wyboru, zmierzona w poc-pullplug.sh: po nieczystym odpieciu katalog +# /Volumes/ zostaje jako osierocony i blokuje ponowne podpiecie +# komunikatem "no mountable file systems". Nalezy do uzytkownika, ale leży +# w /Volumes nalezacym do roota, wiec rmdir odmawia - agent launchd dzialajacy +# jako uzytkownik nie posprzata po sobie sam. +# +# Alternatywa: CM_TARGET="$CM_ROOT/target" - katalog w calosci nasz, wiec +# samonaprawialny. NIEPRZETESTOWANE: nie wiadomo, czy tmutil setdestination +# przyjmie cel spoza /Volumes. Do sprawdzenia, gdy bedzie dostep do sudo. +CM_TARGET="${CM_TARGET:-/Volumes/$CM_VOLNAME}" -# 64 MB na pasmo, podane w sektorach po 512 B. -# Powod: Google Drive przepuszcza okolo 2 operacji na plik na sekunde i ma limit -# 400 000 plikow. Domyslne pasma 8 MB daloby ~25 000 plikow na 200 GB backupu, -# 64 MB schodzi do ~3 200. Cena: kazda zmiana w pasmie to wyslanie calych 64 MB. -CM_BAND_SECTORS="${CM_BAND_SECTORS:-131072}" +# 32 MB na pasmo, podane w sektorach po 512 B. Wybrane pomiarem, nie z palca - +# patrz tabela w README i skrypt poc-amplification.sh. +# +# Dwie sily ciagna w przeciwne strony. Google Drive przepuszcza okolo 2 operacji +# na plik na sekunde, wiec male pasma wydluzaja pierwsza wysylke: 200 GB przy +# 8 MB to ~25 600 plikow i ~3,6 h samych operacji, podczas gdy lacze zrobiloby +# to w ~1,3 h. Z drugiej strony kazda zmiana brudzi cale pasmo, wiec duze pasma +# mnoza transfer przy kazdym przyroscie - zmierzone 768 MB przy 64 MB wobec +# 384 MB przy 8 MB na te same 300 MB realnej zmiany. +# +# 32 MB to punkt, w ktorym pierwsza wysylka przestaje byc ograniczona tempem +# operacji (6 400 plikow, ~0,9 h) i zaczyna byc ograniczona pasmem lacza. +# Powyzej tego progu placi sie wiekszym transferem za korzysc, ktorej juz nie ma. +# +# Zmiana dziala tylko przy tworzeniu obrazu - pozniej wymaga backupu od zera. +CM_BAND_SECTORS="${CM_BAND_SECTORS:-65536}" cm_is_mounted() { /sbin/mount | grep -q " on $CM_MOUNT " @@ -29,3 +57,23 @@ cm_is_mounted() { cm_is_attached() { [ -d "$CM_TARGET" ] && /sbin/mount | grep -q " on $CM_TARGET " } + +# Zwraca urzadzenia /dev/diskN podpiete pod wskazany obraz. +# +# Po wymuszonym odpieciu urzadzenie potrafi zostac w systemie jako zombie. +# Ponowne podpiecie obrazu konczy sie wtedy bledem "no mountable file systems", +# albo - gorzej - zwraca martwy uchwyt, na ktorym fsck_apfs melduje +# "failed to read container superblock" i wyglada to jak utrata backupu. +cm_devices_for_image() { + hdiutil info | awk -v img="$1" ' + /^image-path/ { sub(/^image-path[ \t]*:[ \t]*/, ""); path = $0; next } + /^\/dev\/disk[0-9]+[ \t]/ { if (path == img) print $1 } + ' | sort -u +} + +cm_purge_stale_devices() { + local img="$1" d + for d in $(cm_devices_for_image "$img"); do + hdiutil detach "$d" -force -quiet 2>/dev/null || true + done +} diff --git a/gdrive/install-rclone.sh b/gdrive/install-rclone.sh new file mode 100755 index 0000000..4586cab --- /dev/null +++ b/gdrive/install-rclone.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Instaluje oficjalna binarke rclone obok tej z Homebrew. +# +# Powod: rclone z Homebrew jest zbudowany bez obslugi FUSE i przy probie +# montowania odmawia wprost: +# "rclone mount is not supported on MacOS when rclone is installed via Homebrew" +# Binarka z rclone.org dochodzi do prawdziwego montowania. +# +# Nie ruszamy instalacji Homebrew - ta binarka siedzi we wlasnym katalogu +# i uzywaja jej tylko skrypty CloudMachine, przez CM_RCLONE. + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +DEST="$(dirname "$CM_RCLONE")" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +VER="$(curl -fsSL https://downloads.rclone.org/version.txt | awk '{print $2}')" +case "$(uname -m)" in + arm64) ARCH="osx-arm64" ;; + x86_64) ARCH="osx-amd64" ;; + *) echo "Nieznana architektura: $(uname -m)" >&2; exit 1 ;; +esac +ZIP="rclone-${VER}-${ARCH}.zip" + +echo "Pobieram $ZIP" +curl -fsSL -o "$TMP/$ZIP" "https://downloads.rclone.org/${VER}/${ZIP}" +curl -fsSL -o "$TMP/SHA256SUMS" "https://downloads.rclone.org/${VER}/SHA256SUMS" + +EXPECT="$(grep " $ZIP\$\|$ZIP\$" "$TMP/SHA256SUMS" | awk '{print $1}' | head -1)" +ACTUAL="$(shasum -a 256 "$TMP/$ZIP" | awk '{print $1}')" +if [ -z "$EXPECT" ] || [ "$EXPECT" != "$ACTUAL" ]; then + echo "Suma SHA256 sie nie zgadza - nie instaluje." >&2 + echo " oczekiwana: ${EXPECT:-brak wpisu}" >&2 + echo " policzona : $ACTUAL" >&2 + exit 1 +fi +echo "Suma SHA256 zgodna." + +mkdir -p "$DEST" +unzip -oq "$TMP/$ZIP" -d "$TMP" +cp "$TMP/rclone-${VER}-${ARCH}/rclone" "$CM_RCLONE" +chmod +x "$CM_RCLONE" + +echo "Zainstalowane: $CM_RCLONE" +"$CM_RCLONE" version | head -2 diff --git a/gdrive/mount-drive.sh b/gdrive/mount-drive.sh index 7a590ac..bb2afd4 100755 --- a/gdrive/mount-drive.sh +++ b/gdrive/mount-drive.sh @@ -15,11 +15,16 @@ if cm_is_mounted; then exit 0 fi +if [ ! -x "$CM_RCLONE" ]; then + echo "Brak rclone z obsluga mount w $CM_RCLONE - uruchom install-rclone.sh" >&2 + exit 1 +fi + # Bufor trzyma kopie danych backupu. Gdyby Time Machine go objal, backupowalby # wlasny backup i rosl bez konca. tmutil addexclusion "$CM_ROOT" 2>/dev/null || true -exec rclone mount "${CM_REMOTE}:${CM_REMOTE_PATH}" "$CM_MOUNT" \ +exec "$CM_RCLONE" mount "${CM_REMOTE}:${CM_REMOTE_PATH}" "$CM_MOUNT" \ --vfs-cache-mode full \ --vfs-cache-max-size "$CM_CACHE_SIZE" \ --vfs-cache-max-age 9999h \ diff --git a/gdrive/poc-amplification.sh b/gdrive/poc-amplification.sh new file mode 100755 index 0000000..480e66d --- /dev/null +++ b/gdrive/poc-amplification.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Mierzy wzmocnienie zapisu: ile megabajtow trzeba wyslac do Drive'a, +# zeby utrwalic jeden megabajt faktycznej zmiany. +# +# To jest liczba, ktora decyduje o rozmiarze pasma. Duze pasma oszczedzaja +# operacje na plikach (Drive przepuszcza ~2/s i ma limit 400 000 plikow), ale +# kazda drobna zmiana kaze wyslac cale pasmo od nowa. Jesli wzmocnienie okaze +# sie wysokie, 64 MB jest bledem i trzeba zejsc nizej. +# +# Uruchamiane dla kazdego rozmiaru pasma osobno; wynik to tabela do porownania. + +set -euo pipefail + +POC_ROOT="${POC_ROOT:-/tmp/cm-amp}" +BAND_MB="${BAND_MB:-64}" +SEED_FILES="${SEED_FILES:-3000}" # plikow w pierwszym zapisie +FILE_KB="${FILE_KB:-64}" # rozmiar pojedynczego pliku +TOUCH_FILES="${TOUCH_FILES:-300}" # ile plikow zmieniamy w drugim przebiegu +WORKLOAD="${WORKLOAD:-scatter}" # scatter = przepisanie rozrzuconych plikow + # append = dopisanie nowych, jak robi TM + +BAND_SECTORS=$((BAND_MB * 1024 * 1024 / 512)) +ROOT="$POC_ROOT/b$BAND_MB" +OUTER="$ROOT/stand-in.sparseimage" +MOUNT="$ROOT/drive" +IMAGE="$MOUNT/amp.sparsebundle" +TARGET="/Volumes/AmpPOC$BAND_MB" + +cleanup() { + hdiutil detach "$TARGET" -quiet 2>/dev/null || true + hdiutil detach "$MOUNT" -quiet 2>/dev/null || true +} +trap cleanup EXIT + +[ "${1:-}" = "clean" ] && { cleanup; rm -rf "$POC_ROOT"; echo "Posprzatane."; exit 0; } + +cleanup; rm -rf "$ROOT"; mkdir -p "$MOUNT" + +hdiutil create -type SPARSE -size 40g -fs APFS -volname AmpStandIn "$OUTER" >/dev/null +hdiutil attach "$OUTER" -mountpoint "$MOUNT" -nobrowse >/dev/null +hdiutil create -type SPARSEBUNDLE -size 30g -fs "Case-sensitive APFS" \ + -volname "AmpPOC$BAND_MB" -imagekey "sparse-band-size=$BAND_SECTORS" "$IMAGE" >/dev/null +hdiutil attach "$IMAGE" -nobrowse -mountpoint "$TARGET" >/dev/null + +# Pierwszy zapis - odpowiednik pelnego backupu. +mkdir -p "$TARGET/dane" +for i in $(seq 1 "$SEED_FILES"); do + dd if=/dev/urandom of="$TARGET/dane/plik-$i.bin" bs=1k count="$FILE_KB" 2>/dev/null +done +sync; hdiutil detach "$TARGET" -quiet + +SEED_BANDS=$(ls "$IMAGE/bands" | wc -l | tr -d ' ') +SEED_MB=$(du -sk "$IMAGE" | awk '{print int($1/1024)}') + +# Znacznik czasu, wzgledem ktorego liczymy zmienione pasma. +MARK="$ROOT/mark"; touch "$MARK"; sleep 1 + +# Drugi przebieg - odpowiednik backupu przyrostowego. Zmieniamy rozrzucone +# pliki, zeby trafic w mozliwie wiele roznych pasm; to najgorszy realny +# przypadek, nie sredni. +hdiutil attach "$IMAGE" -nobrowse -mountpoint "$TARGET" >/dev/null +CHANGED_KB=0 +if [ "$WORKLOAD" = "append" ]; then + # Time Machine nie przepisuje istniejacych danych w miejscu - kazdy backup + # dokłada nowe pliki. Zapis jest wtedy skupiony, nie rozrzucony. + mkdir -p "$TARGET/dane/przyrost" + for i in $(seq 1 "$TOUCH_FILES"); do + dd if=/dev/urandom of="$TARGET/dane/przyrost/nowy-$i.bin" bs=1k count="$FILE_KB" 2>/dev/null + CHANGED_KB=$((CHANGED_KB + FILE_KB)) + done +else + STEP=$((SEED_FILES / TOUCH_FILES)) + for i in $(seq 1 "$STEP" "$SEED_FILES"); do + dd if=/dev/urandom of="$TARGET/dane/plik-$i.bin" bs=1k count="$FILE_KB" conv=notrunc 2>/dev/null + CHANGED_KB=$((CHANGED_KB + FILE_KB)) + done +fi +sync; hdiutil detach "$TARGET" -quiet + +DIRTY=$(find "$IMAGE/bands" -type f -newer "$MARK" | wc -l | tr -d ' ') +UPLOAD_MB=$((DIRTY * BAND_MB)) +CHANGED_MB=$((CHANGED_KB / 1024)) +[ "$CHANGED_MB" -eq 0 ] && CHANGED_MB=1 + +printf '%s\n' "---" +printf 'pasmo : %s MB (scenariusz: %s)\n' "$BAND_MB" "$WORKLOAD" +printf 'po pelnym zapisie : %s pasm, %s MB\n' "$SEED_BANDS" "$SEED_MB" +printf 'zmieniono realnie : %s MB w %s plikach\n' "$CHANGED_MB" "$TOUCH_FILES" +printf 'pobrudzonych pasm : %s\n' "$DIRTY" +printf 'do wyslania : %s MB\n' "$UPLOAD_MB" +printf 'WZMOCNIENIE : %sx\n' "$((UPLOAD_MB / CHANGED_MB))" diff --git a/gdrive/poc-local.sh b/gdrive/poc-local.sh new file mode 100755 index 0000000..f6cfc08 --- /dev/null +++ b/gdrive/poc-local.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# POC bez Google Drive i bez FUSE-T. +# +# W miejsce montowania rclone podstawia zwykly obraz dyskowy przypiety pod +# ta sama sciezka. Dzieki temu create-image.sh i attach-image.sh widza prawdziwy +# punkt montowania i nie trzeba ich modyfikowac na czas testu. +# +# Co to dowodzi: ze obraz z pasmami 64 MB powstaje, podpina sie, przyjmuje dane, +# rozklada je na pasma zgodnie z zalozeniem i przezywa odpiecie z ponownym +# podpieciem. Czego NIE dowodzi: zachowania FUSE-T, limitow Drive'a ani tego, +# czy Time Machine przyjmie taki cel - to wymaga sudo i konta Google. + +set -euo pipefail + +POC_ROOT="${POC_ROOT:-/tmp/cm-poc}" +OUTER="$POC_ROOT/stand-in-drive.sparseimage" +DATA_MB="${DATA_MB:-4096}" + +export CM_ROOT="$POC_ROOT/home" +export CM_VOLNAME="${CM_VOLNAME:-CloudMachinePOC}" +export CM_IMAGE_NAME="${CM_IMAGE_NAME:-poc}" +export CM_IMAGE_SIZE="${CM_IMAGE_SIZE:-200g}" + +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/cm-env.sh" + +step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } + +cleanup() { + hdiutil detach "$CM_TARGET" -quiet 2>/dev/null || true + hdiutil detach "$CM_MOUNT" -quiet 2>/dev/null || true +} + +case "${1:-run}" in + clean) + cleanup + rm -rf "$POC_ROOT" + echo "Posprzatane." + exit 0 + ;; +esac + +step "Przygotowanie piaskownicy" +cleanup +rm -rf "$POC_ROOT" +mkdir -p "$CM_MOUNT" "$CM_CACHE" +hdiutil create -type SPARSE -size 60g -fs APFS -volname StandInDrive "$OUTER" >/dev/null +hdiutil attach "$OUTER" -mountpoint "$CM_MOUNT" -nobrowse >/dev/null +cm_is_mounted && echo "Stand-in zamontowany pod $CM_MOUNT" + +step "create-image.sh" +"$HERE/create-image.sh" + +step "Weryfikacja rozmiaru pasma" +BAND=$(plutil -extract band-size raw "$CM_IMAGE/Info.plist") +echo "band-size = $BAND B = $((BAND / 1024 / 1024)) MB" +[ "$BAND" -eq $((CM_BAND_SECTORS * 512)) ] || { echo "BLAD: nie zgadza sie z CM_BAND_SECTORS"; exit 1; } + +step "attach-image.sh" +"$HERE/attach-image.sh" +df -h "$CM_TARGET" | tail -1 + +step "Zapis ${DATA_MB} MB danych" +time dd if=/dev/urandom of="$CM_TARGET/dane.bin" bs=1m count="$DATA_MB" 2>&1 | tail -2 +sync + +step "Rozklad na pasma" +N=$(ls "$CM_IMAGE/bands" | wc -l | tr -d ' ') +TOTAL=$(du -sk "$CM_IMAGE" | awk '{print $1}') +echo "pasm: $N" +echo "obraz zajmuje: $((TOTAL / 1024)) MB na ${DATA_MB} MB danych" +echo "rozmiary pasm (unikalne):" +ls -l "$CM_IMAGE/bands" | awk 'NR>1{print $5}' | sort -n | uniq -c | tail -3 + +step "Odpiecie i ponowne podpiecie" +hdiutil detach "$CM_TARGET" -quiet +"$HERE/attach-image.sh" +SUM_BEFORE=$(ls -l "$CM_TARGET/dane.bin" | awk '{print $5}') +echo "plik po ponownym podpieciu: $SUM_BEFORE B" +[ "$SUM_BEFORE" -eq $((DATA_MB * 1024 * 1024)) ] || { echo "BLAD: rozmiar sie nie zgadza"; exit 1; } + +step "hdiutil verify" +hdiutil detach "$CM_TARGET" -quiet +hdiutil verify "$CM_IMAGE" 2>&1 | tail -3 + +step "Gotowe" +echo "Piaskownica: $POC_ROOT (sprzatanie: $0 clean)" diff --git a/gdrive/poc-pullplug.sh b/gdrive/poc-pullplug.sh new file mode 100755 index 0000000..952cea4 --- /dev/null +++ b/gdrive/poc-pullplug.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Symuluje smierc warstwy chmurowej w trakcie zapisu. +# +# Najgrozniejszy scenariusz tej architektury: Time Machine pisze do podpietego +# obrazu, a pod spodem znika montowanie rclone - bo padl proces, bo FUSE-T sie +# wysypal, bo system uspil dysk. Obraz traci swoje pasma w srodku zapisu. +# +# Test odpina zewnetrzny obraz (zastepnik montowania rclone) w trakcie zapisu +# do wewnetrznego, potem podpina wszystko z powrotem i sprawdza fsck_apfs. +# +# Interesuje nas nie to, czy zapis przezyje - nie przezyje - tylko czy obraz +# da sie pozniej naprawic, czy jest do wyrzucenia. Roznica miedzy "backup +# przerwany, wznowi sie" a "backup stracony, zaczynamy od zera". + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +POC_ROOT="${POC_ROOT:-/tmp/cm-plug}" +BAND_MB="${BAND_MB:-64}" +ROUNDS="${ROUNDS:-3}" + +BAND_SECTORS=$((BAND_MB * 1024 * 1024 / 512)) +OUTER="$POC_ROOT/stand-in.sparseimage" +MOUNT="$POC_ROOT/drive" +IMAGE="$MOUNT/plug.sparsebundle" +TARGET="/Volumes/PlugPOC" +FSCK=/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs + +detach_all() { + hdiutil detach "$TARGET" -force -quiet 2>/dev/null || true + hdiutil detach "$MOUNT" -force -quiet 2>/dev/null || true +} +trap detach_all EXIT + +[ "${1:-}" = "clean" ] && { detach_all; rm -rf "$POC_ROOT"; echo "Posprzatane."; exit 0; } + +detach_all; rm -rf "$POC_ROOT"; mkdir -p "$MOUNT" + +echo "Przygotowanie" +hdiutil create -type SPARSE -size 20g -fs APFS -volname PlugStandIn "$OUTER" >/dev/null +hdiutil attach "$OUTER" -mountpoint "$MOUNT" -nobrowse >/dev/null +hdiutil create -type SPARSEBUNDLE -size 10g -fs "Case-sensitive APFS" \ + -volname PlugPOC -imagekey "sparse-band-size=$BAND_SECTORS" "$IMAGE" >/dev/null + +FAILED=0 +for r in $(seq 1 "$ROUNDS"); do + echo + echo "--- runda $r ---" + hdiutil attach "$IMAGE" -nobrowse -mountpoint "$TARGET" >/dev/null + + # Zapis w tle, zeby wyrwac podloge w jego trakcie. + ( dd if=/dev/urandom of="$TARGET/obciazenie-$r.bin" bs=1m count=1500 2>/dev/null ) & + WRITER=$! + sleep 3 + + echo "Wyrywam podloge (odpinam zastepnik montowania)" + hdiutil detach "$MOUNT" -force -quiet 2>/dev/null || true + wait "$WRITER" 2>/dev/null || echo " zapis przerwany, zgodnie z oczekiwaniem" + hdiutil detach "$TARGET" -force -quiet 2>/dev/null || true + + echo "Przywracam warstwe i sprawdzam obraz" + # Nie sprawdzamy obrazu w miejscu. Po wymuszonym odpieciu urzadzenie potrafi + # zostac w systemie jako zombie; podpiecie zwraca wtedy martwy uchwyt, a + # fsck_apfs melduje "failed to read container superblock" z UUID z samych zer. + # Wyglada to jak nieodwracalne uszkodzenie, a jest tylko nieczytelnym + # urzadzeniem - wczesniejsza wersja tego testu na tej podstawie trzy razy + # z rzedu orzekla utrate backupu, ktory byl caly. + # + # Kopia pod swieza sciezka jest odporna na ten artefakt: nowy plik, nowe + # urzadzenie, zaden stary uchwyt nie ma z nim zwiazku. + hdiutil detach "$TARGET" -force -quiet 2>/dev/null || true + cm_purge_stale_devices "$IMAGE" + sleep 2 + hdiutil attach "$OUTER" -mountpoint "$MOUNT" -nobrowse >/dev/null + sleep 1 + + COPY="$POC_ROOT/kontrola-$r.sparsebundle" + rm -rf "$COPY" + cp -R "$IMAGE" "$COPY" + + DEV=$(hdiutil attach "$COPY" -nomount 2>/dev/null | awk '/41504653/ {print $1; exit}') + if [ -z "$DEV" ]; then + echo " WYNIK: obrazu nie da sie nawet podpiac - stracony" + FAILED=$((FAILED + 1)) + break + fi + if "$FSCK" -n "$DEV" >"$POC_ROOT/fsck-$r.log" 2>&1; then + echo " WYNIK: spojny" + else + echo " WYNIK: niespojny - probuje naprawic" + if "$FSCK" -y "$DEV" >>"$POC_ROOT/fsck-$r.log" 2>&1; then + echo " naprawa udana - backup do uratowania" + else + echo " naprawa nieudana - backup stracony (log: $POC_ROOT/fsck-$r.log)" + FAILED=$((FAILED + 1)) + fi + fi + hdiutil detach "$DEV" -force -quiet 2>/dev/null || true + rm -rf "$COPY" +done + +echo +echo "===================================" +echo "Rund: $ROUNDS nieodwracalnych strat: $FAILED" +[ "$FAILED" -eq 0 ] && echo "Obraz przezyl kazde wyrwanie podlogi." \ + || echo "UWAGA: architektura gubi backup przy utracie warstwy chmurowej." diff --git a/gdrive/setup-remote.sh b/gdrive/setup-remote.sh new file mode 100755 index 0000000..5b04091 --- /dev/null +++ b/gdrive/setup-remote.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Zaklada remote rclone wskazujacy na Google Drive. +# +# Klucze podaje sie przez zmienne srodowiskowe, nie jako argumenty - argumenty +# ladują w historii powloki i w `ps` widocznym dla innych procesow: +# +# read -s -r CM_CLIENT_SECRET; export CM_CLIENT_SECRET +# export CM_CLIENT_ID=...apps.googleusercontent.com +# ./gdrive/setup-remote.sh +# +# Klucze trafiaja wylacznie do ~/.config/rclone/rclone.conf. To repo jest +# publiczne - nic z sekretami tu nie wchodzi. +# +# Skad wziac klucze: console.developers.google.com -> nowy projekt -> +# wlacz "Google Drive API" -> ekran zgody OAuth -> dane logowania -> +# "OAuth 2.0", typ aplikacji "Desktop". +# +# Po co wlasne: wspoldzielony client_id rclone jest wycofywany i przestanie +# dzialac w trakcie 2026 roku, a Google limituje tempo per client_id - na +# wspoldzielonym konkurujesz ze wszystkimi uzytkownikami rclone naraz. + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +if [ ! -x "$CM_RCLONE" ]; then + echo "Brak rclone w $CM_RCLONE - uruchom install-rclone.sh" >&2 + exit 1 +fi + +: "${CM_CLIENT_ID:?Ustaw CM_CLIENT_ID (z konsoli Google)}" +: "${CM_CLIENT_SECRET:?Ustaw CM_CLIENT_SECRET (z konsoli Google)}" + +if "$CM_RCLONE" listremotes | grep -qx "${CM_REMOTE}:"; then + echo "Remote '${CM_REMOTE}' juz istnieje. Usun go recznie, jesli chcesz zalozyc od nowa:" >&2 + echo " $CM_RCLONE config delete ${CM_REMOTE}" >&2 + exit 1 +fi + +echo "Zakladam remote '${CM_REMOTE}'. Otworzy sie przegladarka do zalogowania." +"$CM_RCLONE" config create "$CM_REMOTE" drive \ + client_id="$CM_CLIENT_ID" \ + client_secret="$CM_CLIENT_SECRET" \ + scope=drive + +echo +echo "Sprawdzam dostep i wolne miejsce:" +"$CM_RCLONE" about "${CM_REMOTE}:" + +echo +echo "Tworze katalog docelowy ${CM_REMOTE}:${CM_REMOTE_PATH}" +"$CM_RCLONE" mkdir "${CM_REMOTE}:${CM_REMOTE_PATH}" + +echo +echo "Gotowe. Nastepny krok: mount-drive.sh" diff --git a/gdrive/status.sh b/gdrive/status.sh index 863e4af..1623212 100755 --- a/gdrive/status.sh +++ b/gdrive/status.sh @@ -20,7 +20,7 @@ fi echo echo "--- kolejka wysylki (rclone vfs/stats) ---" -rclone rc --url 127.0.0.1:5572 --no-auth vfs/stats 2>/dev/null \ +"$CM_RCLONE" rc --url 127.0.0.1:5572 --no-auth vfs/stats 2>/dev/null \ | grep -E '"(uploadsInProgress|uploadsQueued|files|erroredFiles|bytesUsed)"' \ || echo "(rclone rc nieosiagalne - bufor nie dziala?)" diff --git a/gdrive/verify-image.sh b/gdrive/verify-image.sh new file mode 100755 index 0000000..b22e4a5 --- /dev/null +++ b/gdrive/verify-image.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Sprawdza spojnosc obrazu backupu. +# +# Uwaga: na sparsebundle NIE dziala `hdiutil verify` - taki obraz nie ma sumy +# kontrolnej i narzedzie konczy komunikatem "has no checksum". Trzeba podpiac +# urzadzenie bez montowania i puscic na nim fsck_apfs. +# +# To jest test rozstrzygajacy dla calej architektury: po zerwaniu lacza +# w trakcie backupu i pelnym drenazu bufora obraz musi byc spojny. Jesli nie +# jest, asynchroniczna wysylka gubi dane i uklad trzeba zmienic. + +set -euo pipefail +source "$(dirname "$0")/cm-env.sh" + +IMAGE="${1:-$CM_IMAGE}" +FSCK=/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs + +if [ ! -d "$IMAGE" ]; then + echo "Brak obrazu: $IMAGE" >&2 + exit 1 +fi + +if cm_is_attached; then + echo "Obraz jest podpiety pod $CM_TARGET - odepnij go najpierw:" >&2 + echo " hdiutil detach $CM_TARGET" >&2 + exit 1 +fi + +echo "Podpinam bez montowania: $IMAGE" +ATTACH_OUT="$(hdiutil attach "$IMAGE" -nomount)" +DEV="$(echo "$ATTACH_OUT" | awk '/41504653/ {print $1; exit}')" + +if [ -z "$DEV" ]; then + echo "Nie znalazlem urzadzenia APFS w wyniku hdiutil:" >&2 + echo "$ATTACH_OUT" >&2 + hdiutil detach "$(echo "$ATTACH_OUT" | awk 'NR==1{print $1}')" -quiet 2>/dev/null || true + exit 1 +fi + +cleanup() { hdiutil detach "$DEV" -quiet 2>/dev/null || true; } +trap cleanup EXIT + +echo "Urzadzenie: $DEV" +echo + +if "$FSCK" -n "$DEV"; then + echo + echo "SPOJNY" +else + echo + echo "NIESPOJNY - fsck_apfs zglosil problemy" >&2 + exit 1 +fi From 8c2796a2ab04b3794bbd37965cb452bfb8346e06 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 12:34:53 +0200 Subject: [PATCH 03/18] Guard the buffer agent against restart loops; test the launchd chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffer agent runs under KeepAlive, so any startup failure repeats every 30 seconds forever. mount-drive.sh now checks for FUSE before launching rclone — without it rclone exits immediately with "cgofuse: cannot find FUSE" and the log grows without bound. This project has already lost 3.3 GiB to a runaway log once, so the rclone log is also trimmed at startup past 100 MB. poc-launchd.sh substitutes a stub for rclone and exercises what the cloud layer does not affect: that the plists load, that the attach agent waits for the buffer instead of racing it, and that KeepAlive brings the buffer back after a kill -9. All three pass. It uses its own poc-* labels so it cannot disturb the production agents, and tears itself down. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- gdrive/README.md | 14 ++++++ gdrive/mount-drive.sh | 16 +++++++ gdrive/poc-launchd.sh | 108 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100755 gdrive/poc-launchd.sh diff --git a/gdrive/README.md b/gdrive/README.md index 0ac17f6..1d05d14 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -134,6 +134,15 @@ pierwszego pelnego transferu: caffeinate -dims & ``` +## Zabezpieczenia przed zapetleniem + +Agent bufora ma `KeepAlive`, wiec kazdy blad startowy powtarzalby sie co 30 s +w nieskonczonosc. `mount-drive.sh` sprawdza dlatego przed uruchomieniem, czy +FUSE w ogole jest - bez tego rclone konczylby natychmiast bledem `cgofuse: +cannot find FUSE`, a log rosl bez konca. Ten projekt ma juz za soba incydent +logu na 3,3 GiB, wiec log rclone jest dodatkowo przycinany przy starcie po +przekroczeniu 100 MB. + ## Podglad ```sh @@ -156,6 +165,11 @@ faktycznej zmiany. `BAND_MB` wybiera rozmiar pasma, `WORKLOAD` scenariusz `poc-pullplug.sh` - wyrywa warstwe chmurowa spod obrazu w trakcie zapisu i sprawdza, czy backup da sie odzyskac. +`poc-launchd.sh` - laduje agenty z zaslepka w miejsce rclone i sprawdza, czy +bufor wstaje, czy agent podpinajacy czeka na niego zamiast wyscigowac sie z nim +i czy KeepAlive podnosi bufor po padnieciu. Uzywa wlasnych etykiet `poc-*`, +wiec nie koliduje z produkcyjnymi agentami, i sprzata po sobie. + `verify-image.sh` - sprawdza spojnosc obrazu przez `fsck_apfs`. ## Czego jeszcze nie sprawdzono diff --git a/gdrive/mount-drive.sh b/gdrive/mount-drive.sh index bb2afd4..d103109 100755 --- a/gdrive/mount-drive.sh +++ b/gdrive/mount-drive.sh @@ -20,6 +20,22 @@ if [ ! -x "$CM_RCLONE" ]; then exit 1 fi +# Bez FUSE rclone konczy sie natychmiast bledem "cgofuse: cannot find FUSE". +# Agent launchd ma KeepAlive, wiec probowalby w kolko co 30 s i zalewal log. +# Ten projekt ma juz za soba incydent logu na 3,3 GiB - lepiej stanac od razu. +if ! ls /usr/local/lib/libfuse-t.dylib /usr/local/lib/libfuse.2.dylib \ + /Library/Filesystems/fuse-t.fs /usr/local/lib/libfuse.dylib >/dev/null 2>&1; then + echo "Nie znalazlem FUSE. Zainstaluj:" >&2 + echo " brew install macos-fuse-t/homebrew-cask/fuse-t" >&2 + exit 1 +fi + +# rclone nie rotuje wlasnego logu. Przy dzialaniu ciaglym trzeba go przyciac +# samemu, bo inaczej rosnie bez konca. +if [ -f "$CM_LOG" ] && [ "$(stat -f%z "$CM_LOG")" -gt 104857600 ]; then + mv -f "$CM_LOG" "$CM_LOG.1" +fi + # Bufor trzyma kopie danych backupu. Gdyby Time Machine go objal, backupowalby # wlasny backup i rosl bez konca. tmutil addexclusion "$CM_ROOT" 2>/dev/null || true diff --git a/gdrive/poc-launchd.sh b/gdrive/poc-launchd.sh new file mode 100755 index 0000000..a39a77b --- /dev/null +++ b/gdrive/poc-launchd.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# Testuje orkiestracje launchd bez Google Drive i bez FUSE-T. +# +# W miejsce rclone podstawia zaslepke, ktora podpina lokalny obraz pod ta sama +# sciezke i zostaje na pierwszym planie - tak samo zachowuje sie prawdziwy +# `rclone mount`. Dzieki temu da sie sprawdzic to, co w tej ukladance jest +# niezalezne od chmury: +# - czy plisty w ogole sie laduja, +# - czy agent podpinajacy poprawnie czeka na bufor zamiast wyscigowac sie z nim, +# - czy KeepAlive podnosi bufor po padnieciu. +# +# Etykiety sa wlasne (poc-*), zeby nie kolidowaly z produkcyjnymi agentami. + +set -euo pipefail + +POC_ROOT="${POC_ROOT:-/tmp/cm-launchd}" +LABEL_MOUNT="com.renacode.cloudmachine.poc-mount" +LABEL_ATTACH="com.renacode.cloudmachine.poc-attach" +LA="$HOME/Library/LaunchAgents" +HERE="$(cd "$(dirname "$0")" && pwd)" +GUI="gui/$(id -u)" + +export CM_ROOT="$POC_ROOT/home" +export CM_VOLNAME="CloudMachineLaunchdPOC" +export CM_IMAGE_NAME="poc" +export CM_IMAGE_SIZE="20g" +source "$HERE/cm-env.sh" + +teardown() { + launchctl bootout "$GUI/$LABEL_ATTACH" 2>/dev/null || true + launchctl bootout "$GUI/$LABEL_MOUNT" 2>/dev/null || true + sleep 1 + rm -f "$LA/$LABEL_MOUNT.plist" "$LA/$LABEL_ATTACH.plist" + hdiutil detach "$CM_TARGET" -force -quiet 2>/dev/null || true + hdiutil detach "$CM_MOUNT" -force -quiet 2>/dev/null || true +} + +[ "${1:-}" = "clean" ] && { teardown; rm -rf "$POC_ROOT"; echo "Posprzatane."; exit 0; } +trap teardown EXIT + +step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } + +step "Piaskownica" +teardown; rm -rf "$POC_ROOT"; mkdir -p "$CM_MOUNT" "$CM_CACHE" +hdiutil create -type SPARSE -size 30g -fs APFS -volname LaunchdStandIn \ + "$POC_ROOT/stand-in.sparseimage" >/dev/null + +# Zaslepka rclone: podpina zastepnik i zostaje na pierwszym planie. +cat > "$POC_ROOT/stub-mount.sh" </dev/null || true +hdiutil attach "$POC_ROOT/stand-in.sparseimage" -mountpoint "\$CM_MOUNT" -nobrowse >/dev/null +echo "zaslepka: podpieta \$(date +%T)" +while cm_is_mounted; do sleep 2; done +echo "zaslepka: montowanie zniknelo, koncze" +STUB +chmod +x "$POC_ROOT/stub-mount.sh" +echo "gotowa" + +step "Generuje i laduje agenty" +sed -e "s|__REPO__/gdrive/mount-drive.sh|$POC_ROOT/stub-mount.sh|" \ + -e "s|__HOME__|$POC_ROOT|g" \ + -e "s|$LABEL_MOUNT|$LABEL_MOUNT|" \ + -e "s|com.renacode.cloudmachine.gdrive|$LABEL_MOUNT|" \ + "$HERE/com.renacode.cloudmachine.gdrive.plist.template" > "$LA/$LABEL_MOUNT.plist" +sed -e "s|__REPO__|$HERE/..|" -e "s|__HOME__|$POC_ROOT|g" \ + -e "s|com.renacode.cloudmachine.gdrive-attach|$LABEL_ATTACH|" \ + "$HERE/com.renacode.cloudmachine.gdrive-attach.plist.template" > "$LA/$LABEL_ATTACH.plist" + +# Agent podpinajacy musi dostac te sama piaskownice. +/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:CM_ROOT string $CM_ROOT" \ + -c "Add :EnvironmentVariables:CM_VOLNAME string $CM_VOLNAME" \ + -c "Add :EnvironmentVariables:CM_IMAGE_NAME string $CM_IMAGE_NAME" \ + "$LA/$LABEL_ATTACH.plist" >/dev/null + +plutil -lint "$LA/$LABEL_MOUNT.plist" && plutil -lint "$LA/$LABEL_ATTACH.plist" +mkdir -p "$POC_ROOT/.cloudmachine" + +launchctl bootstrap "$GUI" "$LA/$LABEL_MOUNT.plist" +echo "zaladowany: $LABEL_MOUNT" + +step "Czy bufor wstal" +for _ in $(seq 1 15); do cm_is_mounted && break; sleep 1; done +cm_is_mounted && echo "TAK - $CM_MOUNT" || { echo "NIE"; exit 1; } + +step "Tworze obraz i laduje agenta podpinajacego" +"$HERE/create-image.sh" >/dev/null +launchctl bootstrap "$GUI" "$LA/$LABEL_ATTACH.plist" +for _ in $(seq 1 20); do cm_is_attached && break; sleep 1; done +cm_is_attached && echo "podpiete: $CM_TARGET" || { echo "NIE podpiete"; cat "$POC_ROOT/.cloudmachine/launchd-attach.err.log" 2>/dev/null; exit 1; } + +step "KeepAlive: zabijam bufor i patrze, czy wroci" +PID=$(launchctl print "$GUI/$LABEL_MOUNT" 2>/dev/null | awk '/pid = / {print $3}') +echo "pid zaslepki: ${PID:-brak}" +hdiutil detach "$CM_TARGET" -force -quiet 2>/dev/null || true +[ -n "${PID:-}" ] && kill -9 "$PID" 2>/dev/null || true +hdiutil detach "$CM_MOUNT" -force -quiet 2>/dev/null || true +echo "czekam na restart (ThrottleInterval 30 s)..." +OK=0 +for _ in $(seq 1 50); do cm_is_mounted && { OK=1; break; }; sleep 2; done +[ "$OK" = 1 ] && echo "WROCIL - KeepAlive dziala" || echo "NIE WROCIL" + +step "Wynik" +[ "$OK" = 1 ] && echo "Orkiestracja launchd dziala." || { echo "Orkiestracja zawodzi."; exit 1; } From a9a467513449aaf62542c7f44637626569e2e4a5 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 12:58:10 +0200 Subject: [PATCH 04/18] Ride out FUSE-T's intermittent RPC failures; keep OAuth secrets out of sight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image creation and attachment now wait for rclone's upload queue to drain and retry up to five times. FUSE-T mounts over NFS, and hdiutil against such a volume is intermittently rejected with "RPC version wrong". The failure does not depend on image size or content — one sweep failed at 100 GB and 400 GB while succeeding at 600 GB, 1000 GB and 1500 GB — but on timing: five of five attempts succeed with an idle queue, and it turns random while rclone is uploading or deleting. Creating the production image proved the point, failing once and succeeding on the retry. The image must be created in place on the mounted Drive. Creating it locally and moving it produces a bundle hdiutil will not open afterwards ("CBSDBackingStore::newProbe stat() failed") even though every file and band is present and readable. Neither alternative FUSE-T backend helps: fskit does not mount at all, smb mounts but hdiutil create fails with "Is a directory". Two secret leaks closed in setup-remote.sh. Passing client_secret= on rclone's command line exposed it in ps for the entire time the browser consent was pending — minutes, not milliseconds; it now goes through RCLONE_DRIVE_CLIENT_SECRET. And rclone prints the finished remote config, refresh token included, to stdout on success; that output is now discarded, since a refresh token grants lasting access to the whole Drive and has no business in a terminal, a log, or a session transcript. Errors still surface — they go to stderr. Also fixes the FUSE preflight added in the previous commit, which used `ls` on several candidate paths and so failed whenever ANY of them was missing rather than all — it refused to start on a correctly installed FUSE-T. And status.sh passed --no-auth to `rclone rc`, which is a server-side flag the client does not accept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- gdrive/README.md | 29 +++++++++++++++++++--- gdrive/attach-image.sh | 13 +++++++--- gdrive/cm-env.sh | 38 +++++++++++++++++++++++++++++ gdrive/create-image.sh | 24 ++++++++++++------ gdrive/mount-drive.sh | 11 +++++++-- gdrive/setup-remote.sh | 55 +++++++++++++++++++++++++++++++++--------- gdrive/status.sh | 2 +- 7 files changed, 144 insertions(+), 28 deletions(-) diff --git a/gdrive/README.md b/gdrive/README.md index 1d05d14..ebb0c85 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -134,6 +134,29 @@ pierwszego pelnego transferu: caffeinate -dims & ``` +## Kaprysy montowania FUSE-T + +FUSE-T montuje przez NFS (`fuse-t:/gdrive ... (nfs)`), a `hdiutil` na takim +wolumenie bywa odrzucany bledem **`RPC version wrong`**. Zmierzone zachowanie: + +- Blad nie zalezy od rozmiaru obrazu ani od danych. Przy jednym przebiegu padly + proby dla 100 GB i 400 GB, a przeszly dla 600 GB, 1000 GB i 1500 GB. +- Zalezy od **chwili**: przy pustej kolejce wysylki 5 prob na 5 udanych, przy + rclone zajetym wysylka lub kasowaniem - losowo. + +Dlatego `create-image.sh` i `attach-image.sh` czekaja na cisze (`cm_wait_quiet`, +czyta `vfs/stats` przez interfejs rc) i ponawiaja do pieciu razy. Przy tworzeniu +produkcyjnego obrazu pierwsza proba padla, druga przeszla - mechanizm nie jest +teoretyczny. + +**Obraz trzeba tworzyc na miejscu, na zamontowanym Drive.** Utworzenie go +lokalnie i przeniesienie przez `mv` daje obraz, ktorego `hdiutil` pozniej nie +otwiera (`CBSDBackingStore::newProbe stat() failed`), mimo ze wszystkie pliki +i pasma sa na swoim miejscu i daja sie czytac. + +Dwa inne backendy FUSE-T nie pomagaja: `backend=fskit` w ogole sie nie montuje, +`backend=smb` montuje sie, ale `hdiutil create` konczy sie `Is a directory`. + ## Zabezpieczenia przed zapetleniem Agent bufora ma `KeepAlive`, wiec kazdy blad startowy powtarzalby sie co 30 s @@ -174,10 +197,10 @@ wiec nie koliduje z produkcyjnymi agentami, i sprzata po sobie. ## Czego jeszcze nie sprawdzono -- Czy `hdiutil attach` na obrazie lezacym na wolumenie FUSE-T dziala stabilnie - pod obciazeniem. FUSE-T montuje przez NFS albo FSKit, wiec obraz lezy - technicznie na wolumenie sieciowym. - Czy `tmutil setdestination` na macOS 26.6 przyjmie tak podpiety obraz. +- Jak zachowa sie montowanie pod obciazeniem prawdziwego backupu. Pojedyncze + operacje dzialaja (zapis 50 MB przy 267 MB/s), ale kilkugodzinny pierwszy + transfer to inna skala. - Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych. Na tym stoi cala obietnica nieprzerywalnosci. Test: odciac siec w trakcie backupu, przywrocic, odczekac na pelny drenaz, `verify-image.sh`. Powtorzyc kilka razy. diff --git a/gdrive/attach-image.sh b/gdrive/attach-image.sh index bba655d..78176be 100755 --- a/gdrive/attach-image.sh +++ b/gdrive/attach-image.sh @@ -49,8 +49,15 @@ if [ -d "$CM_TARGET" ] && ! cm_is_attached; then fi fi -hdiutil attach "$CM_IMAGE" \ - -nobrowse \ - -mountpoint "$CM_TARGET" +cm_wait_quiet 120 || true + +cm_attach() { + hdiutil attach "$CM_IMAGE" -nobrowse -mountpoint "$CM_TARGET" +} + +if ! cm_retry 5 cm_attach; then + echo "Nie udalo sie podpiac obrazu po 5 probach." >&2 + exit 1 +fi echo "Podpiete: $CM_TARGET" diff --git a/gdrive/cm-env.sh b/gdrive/cm-env.sh index cb21773..05461f3 100755 --- a/gdrive/cm-env.sh +++ b/gdrive/cm-env.sh @@ -77,3 +77,41 @@ cm_purge_stale_devices() { hdiutil detach "$d" -force -quiet 2>/dev/null || true done } + +CM_RC_URL="${CM_RC_URL:-127.0.0.1:5572}" + +# Czy rclone skonczyl wysylke. Pusta kolejka to warunek, pod ktorym operacje +# hdiutil na tym montowaniu sa stabilne - patrz nizej. +cm_vfs_quiet() { + local out + out="$("$CM_RCLONE" rc --url "$CM_RC_URL" vfs/stats 2>/dev/null)" || return 1 + local busy + busy="$(printf '%s' "$out" | awk -F: '/uploadsInProgress|uploadsQueued/ {gsub(/[^0-9]/,"",$2); s+=$2} END {print s+0}')" + [ "$busy" = "0" ] +} + +# Czeka, az wysylka ucichnie. Bez tego hdiutil bywa odrzucany. +cm_wait_quiet() { + local limit="${1:-120}" i=0 + while [ "$i" -lt "$limit" ]; do + cm_vfs_quiet && return 0 + i=$((i + 1)); sleep 1 + done + return 1 +} + +# Ponawia polecenie. Operacje hdiutil na montowaniu FUSE-T zawodza przejsciowo +# bledem "RPC version wrong", gdy rclone akurat wysyla albo kasuje dane. +# Zmierzone: przy pustej kolejce 5 prob na 5 udanych, przy zajetej - losowo. +# To nie jest blad zalezny od rozmiaru ani od danych, tylko od chwili. +cm_retry() { + local tries="$1"; shift + local i=1 + while :; do + if "$@"; then return 0; fi + [ "$i" -ge "$tries" ] && return 1 + echo " proba $i nieudana, ponawiam za 5 s..." >&2 + i=$((i + 1)); sleep 5 + cm_wait_quiet 60 || true + done +} diff --git a/gdrive/create-image.sh b/gdrive/create-image.sh index ff770c9..3e4854e 100755 --- a/gdrive/create-image.sh +++ b/gdrive/create-image.sh @@ -23,13 +23,23 @@ echo "Tworze $CM_IMAGE" echo " rozmiar deklarowany: $CM_IMAGE_SIZE (rzadki - zajmuje tyle, ile zapisane)" echo " pasmo: $((CM_BAND_SECTORS * 512 / 1024 / 1024)) MB" -hdiutil create \ - -type SPARSEBUNDLE \ - -size "$CM_IMAGE_SIZE" \ - -fs "Case-sensitive APFS" \ - -volname "$CM_VOLNAME" \ - -imagekey "sparse-band-size=$CM_BAND_SECTORS" \ - "$CM_IMAGE" +echo "Czekam, az wysylka ucichnie..." +cm_wait_quiet 180 || echo " (kolejka nadal zajeta, probuje mimo to)" + +cm_create() { + hdiutil create \ + -type SPARSEBUNDLE \ + -size "$CM_IMAGE_SIZE" \ + -fs "Case-sensitive APFS" \ + -volname "$CM_VOLNAME" \ + -imagekey "sparse-band-size=$CM_BAND_SECTORS" \ + "$CM_IMAGE" +} + +if ! cm_retry 5 cm_create; then + echo "Nie udalo sie utworzyc obrazu po 5 probach." >&2 + exit 1 +fi echo echo "Gotowe. Nastepny krok: attach-image.sh, potem" diff --git a/gdrive/mount-drive.sh b/gdrive/mount-drive.sh index d103109..4e3aadb 100755 --- a/gdrive/mount-drive.sh +++ b/gdrive/mount-drive.sh @@ -23,8 +23,15 @@ fi # Bez FUSE rclone konczy sie natychmiast bledem "cgofuse: cannot find FUSE". # Agent launchd ma KeepAlive, wiec probowalby w kolko co 30 s i zalewal log. # Ten projekt ma juz za soba incydent logu na 3,3 GiB - lepiej stanac od razu. -if ! ls /usr/local/lib/libfuse-t.dylib /usr/local/lib/libfuse.2.dylib \ - /Library/Filesystems/fuse-t.fs /usr/local/lib/libfuse.dylib >/dev/null 2>&1; then +# Uwaga na pulapke: `ls a b c` zwraca blad, gdy BRAKUJE KTOREJKOLWIEK sciezki, +# a nie gdy brakuje wszystkich. Pierwsza wersja tej kontroli odmawiala startu +# przy poprawnie zainstalowanym FUSE-T. Sprawdzamy wiec po kolei. +cm_have_fuse=0 +for lib in /usr/local/lib/libfuse-t.dylib /usr/local/lib/libfuse.2.dylib \ + /usr/local/lib/libfuse.dylib /Library/Filesystems/fuse-t.fs; do + [ -e "$lib" ] && { cm_have_fuse=1; break; } +done +if [ "$cm_have_fuse" -eq 0 ]; then echo "Nie znalazlem FUSE. Zainstaluj:" >&2 echo " brew install macos-fuse-t/homebrew-cask/fuse-t" >&2 exit 1 diff --git a/gdrive/setup-remote.sh b/gdrive/setup-remote.sh index 5b04091..cef6b6f 100755 --- a/gdrive/setup-remote.sh +++ b/gdrive/setup-remote.sh @@ -1,15 +1,20 @@ #!/bin/bash # Zaklada remote rclone wskazujacy na Google Drive. # -# Klucze podaje sie przez zmienne srodowiskowe, nie jako argumenty - argumenty -# ladują w historii powloki i w `ps` widocznym dla innych procesow: +# Klucze OAuth siedza w Keychainie macOS pod usluga "cloudmachine-gdrive", nie +# w pliku i nie w zmiennych srodowiskowych. Keychain jest zaszyfrowany na dysku +# i otwiera sie razem z logowaniem, wiec skrypt bierze je sam: # -# read -s -r CM_CLIENT_SECRET; export CM_CLIENT_SECRET -# export CM_CLIENT_ID=...apps.googleusercontent.com # ./gdrive/setup-remote.sh # -# Klucze trafiaja wylacznie do ~/.config/rclone/rclone.conf. To repo jest -# publiczne - nic z sekretami tu nie wchodzi. +# Zalozenie kluczy w Keychainie (raz), bez podawania ich w argumentach: +# security add-generic-password -a client_id -s cloudmachine-gdrive -w -U +# security add-generic-password -a client_secret -s cloudmachine-gdrive -w -U +# Bez -w z wartoscia `security` zapyta interaktywnie i nic nie trafi do +# historii powloki ani do `ps`. +# +# Zmienne CM_CLIENT_ID / CM_CLIENT_SECRET nadal dzialaja i maja pierwszenstwo, +# gdyby Keychain byl niedostepny. # # Skad wziac klucze: console.developers.google.com -> nowy projekt -> # wlacz "Google Drive API" -> ekran zgody OAuth -> dane logowania -> @@ -18,6 +23,8 @@ # Po co wlasne: wspoldzielony client_id rclone jest wycofywany i przestanie # dzialac w trakcie 2026 roku, a Google limituje tempo per client_id - na # wspoldzielonym konkurujesz ze wszystkimi uzytkownikami rclone naraz. +# +# To repo jest publiczne - nic z sekretami tu nie wchodzi. set -euo pipefail source "$(dirname "$0")/cm-env.sh" @@ -27,8 +34,24 @@ if [ ! -x "$CM_RCLONE" ]; then exit 1 fi -: "${CM_CLIENT_ID:?Ustaw CM_CLIENT_ID (z konsoli Google)}" -: "${CM_CLIENT_SECRET:?Ustaw CM_CLIENT_SECRET (z konsoli Google)}" +CM_KEYCHAIN_SERVICE="${CM_KEYCHAIN_SERVICE:-cloudmachine-gdrive}" + +from_keychain() { + security find-generic-password -a "$1" -s "$CM_KEYCHAIN_SERVICE" -w 2>/dev/null +} + +CM_CLIENT_ID="${CM_CLIENT_ID:-$(from_keychain client_id)}" +CM_CLIENT_SECRET="${CM_CLIENT_SECRET:-$(from_keychain client_secret)}" + +if [ -z "$CM_CLIENT_ID" ] || [ -z "$CM_CLIENT_SECRET" ]; then + echo "Brak kluczy OAuth w Keychainie (usluga: $CM_KEYCHAIN_SERVICE)." >&2 + echo "Zaloz je - security zapyta o wartosc, nic nie trafi do historii:" >&2 + echo " security add-generic-password -a client_id -s $CM_KEYCHAIN_SERVICE -w -U" >&2 + echo " security add-generic-password -a client_secret -s $CM_KEYCHAIN_SERVICE -w -U" >&2 + exit 1 +fi + +echo "Klucze OAuth: z Keychaina (${CM_CLIENT_ID%%-*}...)" if "$CM_RCLONE" listremotes | grep -qx "${CM_REMOTE}:"; then echo "Remote '${CM_REMOTE}' juz istnieje. Usun go recznie, jesli chcesz zalozyc od nowa:" >&2 @@ -37,10 +60,18 @@ if "$CM_RCLONE" listremotes | grep -qx "${CM_REMOTE}:"; then fi echo "Zakladam remote '${CM_REMOTE}'. Otworzy sie przegladarka do zalogowania." -"$CM_RCLONE" config create "$CM_REMOTE" drive \ - client_id="$CM_CLIENT_ID" \ - client_secret="$CM_CLIENT_SECRET" \ - scope=drive + +# Klucze przez srodowisko, nie przez argumenty: `rclone config create` z +# client_secret= w linii polecen wystawia sekret w `ps` na caly czas czekania +# na zatwierdzenie w przegladarce - czyli na minuty. +# +# Wyjscie idzie do /dev/null, bo rclone wypisuje na koniec gotowa sekcje +# konfiguracji razem z tokenem odswiezajacym. Token daje trwaly dostep do +# calego Dysku, wiec nie ma powodu, zeby ladowal w terminalu, logach czy +# historii sesji. Bledy nadal widac - one ida na stderr. +RCLONE_DRIVE_CLIENT_ID="$CM_CLIENT_ID" \ +RCLONE_DRIVE_CLIENT_SECRET="$CM_CLIENT_SECRET" \ +"$CM_RCLONE" config create "$CM_REMOTE" drive scope=drive >/dev/null echo echo "Sprawdzam dostep i wolne miejsce:" diff --git a/gdrive/status.sh b/gdrive/status.sh index 1623212..ff9a130 100755 --- a/gdrive/status.sh +++ b/gdrive/status.sh @@ -20,7 +20,7 @@ fi echo echo "--- kolejka wysylki (rclone vfs/stats) ---" -"$CM_RCLONE" rc --url 127.0.0.1:5572 --no-auth vfs/stats 2>/dev/null \ +"$CM_RCLONE" rc --url 127.0.0.1:5572 vfs/stats 2>/dev/null \ | grep -E '"(uploadsInProgress|uploadsQueued|files|erroredFiles|bytesUsed)"' \ || echo "(rclone rc nieosiagalne - bufor nie dziala?)" From 9d52530dfb5531e90d433267112c9b8ffa595ed5 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 13:03:08 +0200 Subject: [PATCH 05/18] Keep the buffer from eating the disk during a large first backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time Machine writes to the attached image at SSD speed while rclone uploads at link speed; the difference accumulates in the buffer. --vfs-cache-max-size is a soft limit — rclone only evicts what it has already uploaded, so when the whole cache is queued it keeps growing and can fill the disk. Measured at the start of the first backup: the buffer grew about 32 MB/s net, against 425 GB free and a source Time Machine estimates at 1.5 TB. buffer-guard.sh stops Time Machine above a threshold and restarts it once the queue has drained, trading backup speed for finishing at all. It also watches for Google Drive's daily upload ceiling: past it rclone exits by design, and the KeepAlive agent relaunching it achieves nothing until the quota renews, so the guard says so plainly instead of letting the system spin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- gdrive/README.md | 13 ++++++++ gdrive/buffer-guard.sh | 76 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100755 gdrive/buffer-guard.sh diff --git a/gdrive/README.md b/gdrive/README.md index ebb0c85..1218c5d 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -127,6 +127,19 @@ done ## Pierwszy backup +`buffer-guard.sh` pilnuje, zeby bufor nie zjadl dysku. Time Machine pisze do +obrazu z predkoscia SSD, rclone wysyla z predkoscia lacza - roznica zostaje +w buforze. Limit `--vfs-cache-max-size` jest **miekki**: rclone usuwa z bufora +tylko dane juz wyslane, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej. +Dozorca wstrzymuje Time Machine powyzej progu i wznawia, gdy wysylka nadgoni. +Pilnuje tez dobowego limitu Google Drive - po jego przekroczeniu rclone konczy +prace i nie ma sensu go podnosic, dopoki limit sie nie odnowi. + +```sh +./gdrive/buffer-guard.sh # progi: CM_BUFFER_HIGH, CM_BUFFER_LOW, CM_FREE_MIN +``` + + Ten Mac ma wlaczone usypianie, a spiacy Mac nie wysyla nic do Drive'a. Na czas pierwszego pelnego transferu: diff --git a/gdrive/buffer-guard.sh b/gdrive/buffer-guard.sh new file mode 100755 index 0000000..dcd78ce --- /dev/null +++ b/gdrive/buffer-guard.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Pilnuje, zeby bufor nie zjadl dysku. +# +# Time Machine pisze do podpietego obrazu z predkoscia SSD, a rclone wysyla +# z predkoscia lacza - u nas ~41 MB/s. Roznica ladu­je w buforze. Limit +# --vfs-cache-max-size jest miekki: rclone usuwa z bufora tylko to, co juz +# wyslal, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej i moze zapelnic +# dysk. Przy pierwszym backupie liczonym w terabajtach to nie teoria. +# +# Dozorca wstrzymuje Time Machine, gdy bufor urosnie ponad prog, i wznawia, +# gdy wysylka go opcni. Backup staje sie wtedy wolniejszy, ale konczy sie +# zamiast wysypac maszyne. +# +# Osobno pilnuje dobowego limitu Google Drive (750 GB). Po jego przekroczeniu +# rclone konczy prace (--drive-stop-on-upload-limit), a agent launchd probuje +# go podniesc - bez sensu, dopoki limit sie nie odnowi. Dozorca to wykrywa +# i mowi wprost, zamiast pozwolic systemowi kreci sie w kolko. + +set -uo pipefail +source "$(dirname "$0")/cm-env.sh" + +HIGH_GB="${CM_BUFFER_HIGH:-150}" # powyzej tego wstrzymujemy Time Machine +LOW_GB="${CM_BUFFER_LOW:-40}" # ponizej tego wznawiamy +INTERVAL="${CM_GUARD_INTERVAL:-30}" +FREE_MIN_GB="${CM_FREE_MIN:-80}" # ponizej tego wstrzymujemy niezaleznie od bufora + +cache_gb() { du -sk "$CM_CACHE" 2>/dev/null | awk '{printf "%d", $1/1048576}'; } +free_gb() { df -k /System/Volumes/Data | awk 'NR==2 {printf "%d", $4/1048576}'; } +tm_running() { tmutil status 2>/dev/null | grep -q "Running = 1"; } +tm_percent() { tmutil status 2>/dev/null | awk -F'= ' '/"_raw_Percent"/ {gsub(/[";]/,"",$2); printf "%.1f", $2*100}'; } + +quota_hit() { + [ -f "$CM_LOG" ] || return 1 + tail -200 "$CM_LOG" 2>/dev/null | grep -qiE "quota|userRateLimitExceeded|storageQuotaExceeded|upload limit" +} + +paused=0 +echo "dozorca: prog $HIGH_GB GB / wznowienie $LOW_GB GB / min. wolnego $FREE_MIN_GB GB" + +while :; do + c=$(cache_gb); f=$(free_gb); p=$(tm_percent) + [ -z "$p" ] && p="?" + + if quota_hit; then + echo "LIMIT GOOGLE: dobowy limit uploadu wyczerpany - wysylka wstrzymana do odnowienia" + if [ "$paused" -eq 0 ] && tm_running; then + tmutil stopbackup 2>/dev/null; paused=1 + echo " wstrzymano Time Machine, zeby bufor nie rosl bez odbioru" + fi + sleep 300; continue + fi + + if [ "$paused" -eq 0 ]; then + if [ "$c" -ge "$HIGH_GB" ] || [ "$f" -le "$FREE_MIN_GB" ]; then + tmutil stopbackup 2>/dev/null + paused=1 + echo "PAUZA: bufor ${c} GB, wolne ${f} GB, postep ${p}% - czekam na wysylke" + fi + else + if [ "$c" -le "$LOW_GB" ]; then + tmutil startbackup 2>/dev/null + paused=0 + echo "WZNOWIENIE: bufor ${c} GB, wolne ${f} GB, postep ${p}%" + fi + fi + + if [ "$paused" -eq 0 ] && ! tm_running; then + echo "KONIEC: Time Machine zakonczyl. Bufor ${c} GB, postep ${p}%" + echo "Czekam na oproznienie bufora..." + while ! cm_vfs_quiet; do sleep 30; done + echo "Bufor opróżniony - wszystko na Google Drive." + exit 0 + fi + + sleep "$INTERVAL" +done From 7dc50ee47f3a02235ac2337785b3d3c061161868 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 13:33:09 +0200 Subject: [PATCH 06/18] Move the Google Drive layer into the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell scripts under gdrive/ carried everything the Drive backing layer knows, which made the app a spectator to its own job. The knowledge now lives in CloudMachineCore, and the agent binary exposes it: install-rclone, mount-drive, create-image, attach-image, verify-image, buffer-guard, drive-status. launchd and the GUI can call the binary instead of a shell. CMTooling exists because ProcessRunner.runRclone resolves `env rclone`, which finds the Homebrew build — the one compiled without FUSE that refuses to mount outright. Anything touching a mount must go through the managed binary instead. Two bugs the port surfaced, both of the silent kind: parseDevices filtered partitions with `!device.contains("s")`, and "disk" contains an "s", so it discarded every device. Stale-device cleanup would have been a no-op — and that cleanup is what stops a dead handle from being mistaken for a destroyed backup. The test now pins it. Free space came from volumeAvailableCapacityForImportantUsage, which counts space macOS could reclaim from snapshots: it reported 1202 GB where df said 427 GB. The buffer guard exists to pause before the disk fills, so it has to read what is free now, not what might be freed. It uses statfs, and a test holds it to df's number. The comments carry the measurements behind each choice — band size, retry on FUSE-T's intermittent RPC failures, in-place image creation, the soft cache limit — so the reasoning survives where the code is, not only in gdrive/README. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineAgent/CloudMachineAgent.swift | 8 + .../CloudMachineAgent/DriveCommands.swift | 190 +++++++++++ .../CloudMachineCore/BackupImageService.swift | 298 ++++++++++++++++++ .../CloudMachineCore/BufferGuardService.swift | 137 ++++++++ .../Sources/CloudMachineCore/CMTooling.swift | 88 ++++++ .../CloudMachineCore/DriveBufferService.swift | 206 ++++++++++++ .../CloudMachineCore/RcloneInstaller.swift | 137 ++++++++ .../DriveLayerTests.swift | 125 ++++++++ 8 files changed, 1189 insertions(+) create mode 100644 mac-app/Sources/CloudMachineAgent/DriveCommands.swift create mode 100644 mac-app/Sources/CloudMachineCore/BackupImageService.swift create mode 100644 mac-app/Sources/CloudMachineCore/BufferGuardService.swift create mode 100644 mac-app/Sources/CloudMachineCore/CMTooling.swift create mode 100644 mac-app/Sources/CloudMachineCore/DriveBufferService.swift create mode 100644 mac-app/Sources/CloudMachineCore/RcloneInstaller.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index ac31f00..793713e 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -19,6 +19,14 @@ struct CloudMachineAgent: AsyncParsableCommand { BuildApp.self, MakeDmg.self, SetupSigningCert.self, + // Warstwa Google Drive - zastapila skrypty z gdrive/. + InstallRclone.self, + MountDrive.self, + CreateImage.self, + AttachImage.self, + VerifyImage.self, + BufferGuard.self, + DriveStatus.self, ] ) } diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift new file mode 100644 index 0000000..2f84dd6 --- /dev/null +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -0,0 +1,190 @@ +import ArgumentParser +import CloudMachineCore +import Foundation + +/// Podkomendy warstwy Google Drive. Zastepuja skrypty z `gdrive/` - launchd +/// i GUI wolaja odtad wylacznie te binarke, nie powloke. + +// MARK: - Bufor + +struct MountDrive: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mount-drive", + abstract: "Montuje Google Drive z buforem zapisu. Zostaje na pierwszym planie (dla launchd).") + + func run() async throws { + if DriveBufferService.isMounted { + print("Juz zamontowane: \(DriveBufferService.mountPoint.path)") + return + } + + // Bez FUSE rclone konczy natychmiast bledem "cgofuse: cannot find FUSE". + // Agent ma KeepAlive, wiec probowalby w kolko co 30 s i zalewal log - + // lepiej stanac od razu i powiedziec, czego brakuje. + let readiness = CMTooling.checkReadiness() + guard readiness.ready else { + for (what, how) in zip(readiness.missing, readiness.remedies) { + FileHandle.standardError.write(Data("Brakuje: \(what)\n \(how)\n".utf8)) + } + throw ExitCode(1) + } + + await DriveBufferService.excludeBufferFromTimeMachine() + let args = try DriveBufferService.prepare() + + // Podmieniamy sie na rclone zamiast go nadzorowac: launchd ma pilnowac + // procesu, ktory faktycznie trzyma montowanie, a nie posrednika. + let rclone = CMTooling.managedRclonePath.path + var argv: [UnsafeMutablePointer?] = ([rclone] + args).map { strdup($0) } + argv.append(nil) + execv(rclone, &argv) + + FileHandle.standardError.write(Data("Nie udalo sie uruchomic \(rclone)\n".utf8)) + throw ExitCode(1) + } +} + +// MARK: - Obraz backupu + +struct CreateImage: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create-image", + abstract: "Tworzy obraz backupu na Google Drive. Jednorazowo.") + + @Option(name: .long, help: "Rozmiar deklarowany w GB (obraz jest rzadki).") + var sizeGB: Int = 4000 + + func run() async throws { + let result = await BackupImageService.create(sizeGB: sizeGB) + print(result.message) + if result.succeeded { + print("Nastepny krok: cloudmachine-agent attach-image, potem") + print(" sudo tmutil setdestination \(BackupImageService.targetPath.path)") + } else { + throw ExitCode(1) + } + } +} + +struct AttachImage: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "attach-image", + abstract: "Podpina obraz backupu jako cel Time Machine.") + + func run() async throws { + // Time Machine nie moze zobaczyc celu, zanim bufor bedzie gotowy - inaczej + // uzna, ze dysk backupu zniknal. + for _ in 0..<60 { + if DriveBufferService.isMounted { break } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + let result = await BackupImageService.attach() + print(result.message) + if !result.succeeded { throw ExitCode(1) } + } +} + +struct VerifyImage: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "verify-image", + abstract: "Sprawdza spojnosc obrazu przez fsck_apfs (hdiutil verify na sparsebundle nie dziala).") + + func run() async throws { + let result = await BackupImageService.verify() + print(result.message) + if !result.succeeded { throw ExitCode(1) } + } +} + +// MARK: - Dozorca bufora + +struct BufferGuard: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "buffer-guard", + abstract: "Wstrzymuje Time Machine, gdy bufor rosnie szybciej, niz idzie wysylka.") + + @Option(name: .long, help: "Powyzej tylu GB bufora wstrzymujemy Time Machine.") + var highGB: Int = 150 + + @Option(name: .long, help: "Ponizej tylu GB bufora wznawiamy.") + var lowGB: Int = 40 + + @Option(name: .long, help: "Ponizej tylu GB wolnych na dysku wstrzymujemy niezaleznie od bufora.") + var minFreeGB: Int = 80 + + @Option(name: .long, help: "Co ile sekund sprawdzac.") + var interval: Int = 30 + + func run() async throws { + let guardService = BufferGuardService( + thresholds: .init(highGB: highGB, lowGB: lowGB, minFreeGB: minFreeGB)) + CMLogger.log( + "Dozorca bufora: prog \(highGB) GB / wznowienie \(lowGB) GB / min. wolnego \(minFreeGB) GB") + + while true { + let snapshot = await guardService.step() + if snapshot.state == .finished { + CMLogger.log("Czekam na oproznienie bufora...") + while !(await DriveBufferService.queueStats()?.isQuiet ?? false) { + try? await Task.sleep(nanoseconds: 30_000_000_000) + } + CMLogger.log("Bufor oprozniony - wszystko na Google Drive.") + return + } + try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + } + } +} + +// MARK: - Stan + +struct DriveStatus: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "drive-status", + abstract: "Stan bufora, kolejki wysylki i Time Machine.") + + func run() async throws { + let readiness = CMTooling.checkReadiness() + print("Narzedzia: \(readiness.ready ? "OK" : "brakuje: " + readiness.missing.joined(separator: ", "))") + print("Montowanie Drive: \(DriveBufferService.isMounted ? "OK" : "BRAK")") + print("Obraz podpiety: \(BackupImageService.isAttached ? "OK (\(BackupImageService.targetPath.path))" : "BRAK")") + print("Bufor: \(BufferGuardService.bufferGB()) GB z \(DriveBufferService.cacheSize)") + print("Wolne na dysku: \(BufferGuardService.freeGB()) GB") + + if let stats = await DriveBufferService.queueStats() { + print("Kolejka wysylki: \(stats.uploadsInProgress) w toku, \(stats.uploadsQueued) w kolejce, \(stats.erroredFiles) bledow") + } else { + print("Kolejka wysylki: (interfejs rc nieosiagalny)") + } + + if DriveBufferService.hitDailyQuota() { + print("UWAGA: dobowy limit uploadu Google Drive wyczerpany") + } + + if let mountPoint = await TimeMachineStatus.currentDestinationMountPoint() { + print("Cel Time Machine: \(mountPoint)") + } else { + print("Cel Time Machine: brak") + } + if await TimeMachineStatus.isRunning(), let progress = await TimeMachineStatus.currentProgress() { + let percent = (progress.percent ?? 0) * 100 + print("Backup: trwa, \(String(format: "%.1f", percent))% (\(progress.phase ?? "?"))") + } else { + print("Backup: nie trwa") + } + } +} + +// MARK: - Instalacja rclone + +struct InstallRclone: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "install-rclone", + abstract: "Pobiera oficjalna binarke rclone (ta z Homebrew nie umie montowac).") + + func run() async throws { + let result = await RcloneInstaller.install() + print(result.message) + if !result.succeeded { throw ExitCode(1) } + } +} diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift new file mode 100644 index 0000000..6e59a5b --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -0,0 +1,298 @@ +import Foundation + +/// Obraz backupu lezacy na Google Drive - port `gdrive/create-image.sh`, +/// `attach-image.sh` i `verify-image.sh`. +/// +/// Time Machine dostaje do reki podpiety, zwykly wolumen APFS i nie wie, ze +/// pasma obrazu leza w chmurze. Dzieki temu w sciezce zapisu nie ma sieciowego +/// systemu plikow - odpada SMB i cala klasa awarii, ktore trapia backupy +/// sieciowe. +public enum BackupImageService { + + // MARK: - Sciezki + + public static let imageName = "mac-studio" + public static let volumeName = "CloudMachine" + + public static var imagePath: URL { + DriveBufferService.mountPoint.appendingPathComponent("\(imageName).sparsebundle") + } + + /// Punkt montowania celu. + /// + /// `/Volumes` jest sciezka, ktora Time Machine na pewno przyjmuje - i to jest + /// jedyny powod, dla ktorego tu siedzi. Cena: po nieczystym odpieciu katalog + /// `/Volumes/` zostaje jako osierocony i blokuje ponowne podpiecie. + /// Nalezy do uzytkownika, ale lezy w `/Volumes` nalezacym do roota, wiec + /// `rmdir` odmawia - agent dzialajacy jako uzytkownik nie posprzata po sobie + /// sam. Alternatywa (katalog w calosci nasz, samonaprawialny) jest + /// nieprzetestowana: nie wiadomo, czy `tmutil setdestination` przyjmie cel + /// spoza `/Volumes`. + public static var targetPath: URL { + URL(fileURLWithPath: "/Volumes/\(volumeName)") + } + + /// 32 MB na pasmo, w sektorach po 512 B. Wybrane pomiarem - patrz + /// `gdrive/poc-amplification.sh` i tabela w `gdrive/README.md`. + /// + /// Dwie sily ciagna w przeciwne strony. Google Drive przepuszcza okolo dwoch + /// operacji na plik na sekunde, wiec male pasma wydluzaja pierwsza wysylke. + /// Ale kazda zmiana brudzi cale pasmo, wiec duze pasma mnoza transfer przy + /// kazdym przyroscie - zmierzone 768 MB przy 64 MB wobec 384 MB przy 8 MB na + /// te same 300 MB realnej zmiany. 32 MB to punkt, w ktorym pierwsza wysylka + /// przestaje byc ograniczona tempem operacji, a zaczyna pasmem lacza. + /// + /// Dziala tylko przy tworzeniu obrazu - pozniej wymaga backupu od zera. + public static let bandSectors = 65536 + + private static let fsckPath = + "/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs" + + // MARK: - Stan + + public static var exists: Bool { + var isDir: ObjCBool = false + let ok = FileManager.default.fileExists(atPath: imagePath.path, isDirectory: &isDir) + return ok && isDir.boolValue + } + + public static var isAttached: Bool { + guard let out = try? mountTable() else { return false } + return out.contains(" on \(targetPath.path) ") + } + + private static func mountTable() throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/sbin/mount") + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: - Zawieszone urzadzenia + + /// Urzadzenia `/dev/diskN` podpiete pod wskazany obraz. + /// + /// Po wymuszonym odpieciu urzadzenie potrafi zostac w systemie jako zombie. + /// Ponowne podpiecie konczy sie wtedy bledem "no mountable file systems", + /// albo - gorzej - zwraca martwy uchwyt, na ktorym `fsck_apfs` melduje + /// "failed to read container superblock" z UUID z samych zer. Wyglada to jak + /// skasowany backup, a jest tylko nieczytelnym urzadzeniem: wczesniejsza + /// wersja testu wyrywania podlogi trzy razy z rzedu orzekla na tej podstawie + /// utrate danych, ktore byly cale. + public static func devicesForImage(_ image: URL = imagePath) async -> [String] { + guard let result = try? await ProcessRunner.run("/usr/bin/hdiutil", ["info"], timeout: 60), + result.succeeded + else { return [] } + return parseDevices(hdiutilInfo: result.stdout, imagePath: image.path) + } + + /// Czysta wersja parsera - `hdiutil info` grupuje wpisy w bloki, gdzie po + /// linii `image-path` naleza wszystkie kolejne linie `/dev/diskN`. + public static func parseDevices(hdiutilInfo: String, imagePath: String) -> [String] { + var devices: [String] = [] + var currentImage: String? + for line in hdiutilInfo.components(separatedBy: .newlines) { + if line.hasPrefix("image-path") { + currentImage = + line + .drop(while: { $0 != ":" }) + .dropFirst() + .trimmingCharacters(in: .whitespaces) + continue + } + guard line.hasPrefix("/dev/disk"), currentImage == imagePath else { continue } + let device = String(line.prefix(while: { !$0.isWhitespace })) + // Interesuje nas urzadzenie nadrzedne (/dev/disk7), nie partycja + // (/dev/disk7s1) - odpiecie nadrzednego zabiera ze soba partycje. + // + // UWAGA: kuszace `!device.contains("s")` jest BLEDNE, bo "disk" tez + // zawiera "s" i odrzuca wszystko. Sprawdzamy, czy po prefiksie zostaly + // same cyfry. + let suffix = device.dropFirst("/dev/disk".count) + guard !suffix.isEmpty, suffix.allSatisfy(\.isNumber) else { continue } + if !devices.contains(device) { + devices.append(device) + } + } + return devices + } + + public static func purgeStaleDevices(_ image: URL = imagePath) async { + for device in await devicesForImage(image) { + _ = try? await ProcessRunner.run( + "/usr/bin/hdiutil", ["detach", device, "-force", "-quiet"], timeout: 60) + } + } + + // MARK: - Tworzenie + + /// Tworzy obraz NA MIEJSCU, na zamontowanym Drive. + /// + /// Utworzenie go lokalnie i przeniesienie daje obraz, ktorego `hdiutil` + /// pozniej nie otwiera ("CBSDBackingStore::newProbe stat() failed"), mimo ze + /// wszystkie pliki i pasma sa na swoim miejscu i daja sie czytac. + public static func create(sizeGB: Int) async -> CMActionResult { + guard DriveBufferService.isMounted else { + return CMActionResult( + succeeded: false, message: "Drive nie jest zamontowany - najpierw uruchom bufor.") + } + guard !exists else { + return CMActionResult( + succeeded: false, + message: "Obraz juz istnieje. Usuniecie go kasuje caly backup - zrob to swiadomie.") + } + + await DriveBufferService.waitUntilQuiet(timeout: 180) + + let args = [ + "create", "-type", "SPARSEBUNDLE", + "-size", "\(sizeGB)g", + "-fs", "Case-sensitive APFS", + "-volname", volumeName, + "-imagekey", "sparse-band-size=\(bandSectors)", + imagePath.path, + ] + + let result = await retryingFlakyMount(attempts: 5) { + try? await ProcessRunner.run("/usr/bin/hdiutil", args, timeout: 600) + } + + guard result?.succeeded == true else { + return CMActionResult( + succeeded: false, + message: "Nie udalo sie utworzyc obrazu: \(result?.stderr ?? "nieznany blad")") + } + return CMActionResult( + succeeded: true, + message: "Utworzono obraz \(sizeGB) GB, pasmo \(bandSectors * 512 / 1024 / 1024) MB.") + } + + // MARK: - Podpinanie + + public static func attach() async -> CMActionResult { + guard DriveBufferService.isMounted else { + return CMActionResult(succeeded: false, message: "Drive nie jest zamontowany.") + } + guard exists else { + return CMActionResult(succeeded: false, message: "Brak obrazu - najpierw go utworz.") + } + if isAttached { + return CMActionResult(succeeded: true, message: "Juz podpiete: \(targetPath.path)") + } + + await purgeStaleDevices() + + // Osierocony punkt montowania blokuje podpiecie. Jesli lezy w /Volumes, + // usuniecie wymaga roota - mowimy wiec dokladnie, co uruchomic, zamiast + // ponawiac bez konca. + if FileManager.default.fileExists(atPath: targetPath.path) { + do { + try FileManager.default.removeItem(at: targetPath) + } catch { + return CMActionResult( + succeeded: false, + message: """ + Osierocony punkt montowania blokuje podpiecie: \(targetPath.path) + Usun go i sprobuj ponownie: sudo rmdir '\(targetPath.path)' + """) + } + } + + await DriveBufferService.waitUntilQuiet(timeout: 120) + + let result = await retryingFlakyMount(attempts: 5) { + try? await ProcessRunner.run( + "/usr/bin/hdiutil", + ["attach", imagePath.path, "-nobrowse", "-mountpoint", targetPath.path], + timeout: 300) + } + + guard result?.succeeded == true else { + return CMActionResult( + succeeded: false, + message: "Nie udalo sie podpiac obrazu: \(result?.stderr ?? "nieznany blad")") + } + return CMActionResult(succeeded: true, message: "Podpiete: \(targetPath.path)") + } + + public static func detach(force: Bool = false) async -> CMActionResult { + var args = ["detach", targetPath.path, "-quiet"] + if force { args.append("-force") } + let result = try? await ProcessRunner.run("/usr/bin/hdiutil", args, timeout: 120) + return CMActionResult( + succeeded: result?.succeeded == true, + message: result?.succeeded == true ? "Odpiete." : "Nie udalo sie odpiac.") + } + + // MARK: - Weryfikacja + + /// Sprawdza spojnosc obrazu. + /// + /// UWAGA: `hdiutil verify` na sparsebundle NIE dziala - taki obraz nie ma + /// sumy kontrolnej i narzedzie konczy komunikatem "has no checksum". + /// Trzeba podpiac urzadzenie bez montowania i puscic na nim `fsck_apfs`. + public static func verify() async -> CMActionResult { + guard exists else { + return CMActionResult(succeeded: false, message: "Brak obrazu.") + } + if isAttached { + return CMActionResult( + succeeded: false, + message: "Obraz jest podpiety - odepnij go przed sprawdzeniem.") + } + + guard + let attachResult = try? await ProcessRunner.run( + "/usr/bin/hdiutil", ["attach", imagePath.path, "-nomount"], timeout: 300), + attachResult.succeeded, + let device = attachResult.stdout + .components(separatedBy: .newlines) + .first(where: { $0.contains("41504653") })? + .prefix(while: { !$0.isWhitespace }) + else { + return CMActionResult(succeeded: false, message: "Nie znalazlem urzadzenia APFS w obrazie.") + } + + defer { + Task { + _ = try? await ProcessRunner.run( + "/usr/bin/hdiutil", ["detach", String(device), "-force", "-quiet"], timeout: 60) + } + } + + let fsck = try? await ProcessRunner.run(fsckPath, ["-n", String(device)], timeout: 3600) + return CMActionResult( + succeeded: fsck?.succeeded == true, + message: fsck?.succeeded == true + ? "Obraz spojny." : "Obraz NIESPOJNY: \(fsck?.stdout.suffix(500) ?? "")") + } + + // MARK: - Ponawianie + + /// Ponawia operacje `hdiutil` na montowaniu FUSE-T. + /// + /// FUSE-T montuje przez NFS, a `hdiutil` na takim wolumenie bywa odrzucany + /// bledem "RPC version wrong". Zmierzone: blad nie zalezy od rozmiaru obrazu + /// ani od danych (jeden przebieg padl dla 100 GB i 400 GB, a przeszedl dla + /// 600, 1000 i 1500 GB), tylko od chwili - przy pustej kolejce wysylki + /// 5 prob na 5 udanych, przy rclone zajetym losowo. Przy tworzeniu obrazu + /// produkcyjnego pierwsza proba padla, druga przeszla. + private static func retryingFlakyMount( + attempts: Int, _ operation: () async -> ProcessResult? + ) async -> ProcessResult? { + var last: ProcessResult? + for attempt in 1...attempts { + last = await operation() + if last?.succeeded == true { return last } + guard attempt < attempts else { break } + CMLogger.log("hdiutil: proba \(attempt) nieudana, ponawiam") + try? await Task.sleep(nanoseconds: 5_000_000_000) + await DriveBufferService.waitUntilQuiet(timeout: 60) + } + return last + } +} diff --git a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift new file mode 100644 index 0000000..1b6a640 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Pilnuje, zeby bufor nie zjadl dysku - port `gdrive/buffer-guard.sh`. +/// +/// Time Machine pisze do podpietego obrazu z predkoscia SSD (zmierzone +/// 267 MB/s), a rclone wysyla z predkoscia lacza (~41 MB/s przy 332 Mb/s +/// uploadu). Roznica laduje w buforze. +/// +/// `--vfs-cache-max-size` jest limitem MIEKKIM: rclone usuwa z bufora tylko +/// dane juz wyslane, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej +/// i moze zapelnic dysk. Przy pierwszym backupie liczonym w terabajtach to nie +/// jest teoria - zmierzony przyrost netto na starcie wynosil 32 MB/s. +/// +/// Dozorca wstrzymuje Time Machine powyzej progu i wznawia, gdy wysylka +/// nadgoni. Backup staje sie wolniejszy, ale konczy sie zamiast wysypac +/// maszyne. +public actor BufferGuardService { + + public struct Thresholds: Sendable { + /// Powyzej tego rozmiaru bufora wstrzymujemy Time Machine. + public var highGB: Int + /// Ponizej tego wznawiamy. + public var lowGB: Int + /// Ponizej tylu GB wolnych na dysku wstrzymujemy niezaleznie od bufora. + public var minFreeGB: Int + + public init(highGB: Int = 150, lowGB: Int = 40, minFreeGB: Int = 80) { + self.highGB = highGB + self.lowGB = lowGB + self.minFreeGB = minFreeGB + } + } + + public enum State: String, Sendable { + case running + case pausedForBuffer + case pausedForQuota + case finished + } + + public struct Snapshot: Sendable { + public var state: State + public var bufferGB: Int + public var freeGB: Int + public var backupRunning: Bool + public var percent: Double + } + + private let thresholds: Thresholds + private var state: State = .running + + public init(thresholds: Thresholds = Thresholds()) { + self.thresholds = thresholds + } + + // MARK: - Pomiary + + public static func bufferGB() -> Int { + Int(DriveBufferService.cacheSizeBytes() / 1_073_741_824) + } + + /// Wolne miejsce liczone tak, jak liczy je `df` - czyli PESYMISTYCZNIE. + /// + /// Kusi, zeby uzyc `volumeAvailableCapacityForImportantUsageKey`, ale to + /// miara optymistyczna: wlicza miejsce zajete przez lokalne migawki, ktore + /// system dopiero MOGLBY zwolnic. Na tej maszynie pokazywala 1202 GB, gdy + /// `df` mowilo 427 GB. Dozorca ma wstrzymywac backup, zanim dysk sie zapelni, + /// wiec musi patrzec na miejsce faktycznie dostepne teraz, a nie na obietnice. + public static func freeGB() -> Int { + var stats = statfs() + guard statfs("/System/Volumes/Data", &stats) == 0 else { return 0 } + let available = UInt64(stats.f_bavail) * UInt64(stats.f_bsize) + return Int(available / 1_073_741_824) + } + + // MARK: - Jeden krok + + /// Wykonuje jeden krok nadzoru i zwraca stan. Wydzielone z petli, zeby dalo + /// sie sprawdzic decyzje testem bez czekania w czasie rzeczywistym. + @discardableResult + public func step() async -> Snapshot { + let buffer = Self.bufferGB() + let free = Self.freeGB() + let running = await TimeMachineStatus.isRunning() + let percent = (await TimeMachineStatus.currentProgress())?.percent ?? 0 + + // Limit dobowy ma pierwszenstwo: dopoki sie nie odnowi, wysylka nie ruszy, + // wiec pozwolenie Time Machine na dalsza prace tylko napompuje bufor. + if DriveBufferService.hitDailyQuota() { + if state != .pausedForQuota { + CMLogger.log("Dobowy limit uploadu Google Drive wyczerpany - wstrzymuje Time Machine") + await stopBackup() + state = .pausedForQuota + } + return Snapshot( + state: state, bufferGB: buffer, freeGB: free, backupRunning: running, percent: percent) + } + + switch state { + case .running: + if buffer >= thresholds.highGB || free <= thresholds.minFreeGB { + CMLogger.log( + "PAUZA: bufor \(buffer) GB, wolne \(free) GB - czekam na wysylke") + await stopBackup() + state = .pausedForBuffer + } else if !running { + CMLogger.log("Time Machine zakonczyl. Bufor \(buffer) GB") + state = .finished + } + + case .pausedForBuffer, .pausedForQuota: + if buffer <= thresholds.lowGB { + CMLogger.log("WZNOWIENIE: bufor \(buffer) GB, wolne \(free) GB") + await startBackup() + state = .running + } + + case .finished: + break + } + + return Snapshot( + state: state, bufferGB: buffer, freeGB: free, backupRunning: running, percent: percent) + } + + public func currentState() -> State { state } + + // MARK: - Sterowanie Time Machine + + private func stopBackup() async { + _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["stopbackup"], timeout: 120) + } + + private func startBackup() async { + _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["startbackup"], timeout: 120) + } +} diff --git a/mac-app/Sources/CloudMachineCore/CMTooling.swift b/mac-app/Sources/CloudMachineCore/CMTooling.swift new file mode 100644 index 0000000..0385f6c --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/CMTooling.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Rozwiazuje zewnetrzne narzedzia potrzebne warstwie Google Drive i sprawdza, +/// czy w ogole nadaja sie do uzycia. +/// +/// Istnieje, bo `ProcessRunner.runRclone` wola `/usr/bin/env rclone`, a to +/// trafia w rclone z Homebrew - zbudowane BEZ obslugi FUSE. Przy probie +/// montowania odmawia wprost: +/// +/// rclone mount is not supported on MacOS when rclone is installed via Homebrew +/// +/// Potrzebna jest oficjalna binarka z rclone.org. Trzymamy ja we wlasnym +/// katalogu, zeby nie kolidowac z instalacja Homebrew, z ktorej korzystaja +/// pozostale, niemontujace sciezki kodu. +public enum CMTooling { + + // MARK: - rclone + + /// Katalog na narzedzia zarzadzane przez CloudMachine. + public static var toolsDir: URL { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cloudmachine/bin") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Oficjalna binarka rclone z obsluga montowania. + public static var managedRclonePath: URL { + toolsDir.appendingPathComponent("rclone") + } + + public static var hasManagedRclone: Bool { + FileManager.default.isExecutableFile(atPath: managedRclonePath.path) + } + + /// Uruchamia rclone, ktore NA PEWNO umie montowac. Kazda sciezka kodu + /// dotykajaca montowania musi isc tedy, nie przez `ProcessRunner.runRclone`. + public static func runRclone(_ args: [String], timeout: TimeInterval? = nil) async throws + -> ProcessResult + { + try await ProcessRunner.run(managedRclonePath.path, args, timeout: timeout) + } + + // MARK: - FUSE + + /// Sciezki, pod ktorymi moze siedziec FUSE. Wystarczy jedna. + private static let fuseCandidates = [ + "/usr/local/lib/libfuse-t.dylib", + "/usr/local/lib/libfuse.2.dylib", + "/usr/local/lib/libfuse.dylib", + "/Library/Filesystems/fuse-t.fs", + ] + + /// Czy FUSE jest zainstalowane. + /// + /// UWAGA na pulapke, ktora juz raz zadzialala: pierwsza wersja tej kontroli + /// w bashu robila `ls a b c` i sprawdzala kod wyjscia. `ls` zwraca blad, gdy + /// brakuje KTOREJKOLWIEK ze sciezek, a nie gdy brakuje wszystkich - wiec + /// odmawiala startu przy poprawnie zainstalowanym FUSE-T. Sprawdzamy po kolei. + public static var hasFuse: Bool { + fuseCandidates.contains { FileManager.default.fileExists(atPath: $0) } + } + + // MARK: - Diagnostyka gotowosci + + public struct Readiness { + public var ready: Bool { missing.isEmpty } + /// Czego brakuje, w kolejnosci, w jakiej trzeba to naprawic. + public var missing: [String] + /// Polecenia, ktore to naprawiaja - gotowe do pokazania uzytkownikowi. + public var remedies: [String] + } + + public static func checkReadiness() -> Readiness { + var missing: [String] = [] + var remedies: [String] = [] + + if !hasManagedRclone { + missing.append("rclone z obsluga montowania") + remedies.append("cloudmachine-agent install-rclone") + } + if !hasFuse { + missing.append("FUSE") + remedies.append("brew install macos-fuse-t/homebrew-cask/fuse-t") + } + return Readiness(missing: missing, remedies: remedies) + } +} diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift new file mode 100644 index 0000000..a642b98 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -0,0 +1,206 @@ +import Foundation + +/// Bufor miedzy Time Machine a Google Drive - port `gdrive/mount-drive.sh`. +/// +/// Montuje Drive jako wolumen z lokalnym cache zapisu. Zapis konczy sie +/// w momencie trafienia do cache, wysylka idzie w tle - dlatego zerwanie lacza +/// wstrzymuje drenaz zamiast przerywac backup. +/// +/// Proces rclone zostaje na pierwszym planie; cyklem zycia zarzadza launchd +/// (KeepAlive). +public enum DriveBufferService { + + // MARK: - Sciezki i ustawienia + + public static var root: URL { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cloudmachine") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + public static var mountPoint: URL { root.appendingPathComponent("drive") } + public static var cacheDir: URL { root.appendingPathComponent("cache") } + public static var logFile: URL { root.appendingPathComponent("rclone.log") } + + public static let remoteName = "gdrive" + public static let remotePath = "CloudMachine/mac-studio" + public static let cacheSize = "100G" + + /// Adres interfejsu sterujacego rclone. Slucha tylko na petli zwrotnej, ale + /// kazdy lokalny proces moze przez niego sterowac montowaniem - jesli kiedys + /// uznamy to za zbyt luzne, trzeba dolozyc `--rc-user`/`--rc-pass`. + public static let rcAddress = "127.0.0.1:5572" + + /// Powyzej tego rozmiaru log rclone jest przycinany przy starcie. rclone nie + /// rotuje wlasnego logu, a ten projekt stracil juz raz 3.3 GiB na logu, + /// ktory rosl bez ograniczen. + private static let logSizeLimit: UInt64 = 100 * 1024 * 1024 + + // MARK: - Stan + + public static var isMounted: Bool { + // `mount` jest zrodlem prawdy - samo istnienie katalogu nic nie znaczy, + // bo punkt montowania zostaje na dysku po odmontowaniu. + guard let out = try? shellMountTable() else { return false } + return out.contains(" on \(mountPoint.path) ") + } + + private static func shellMountTable() throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/sbin/mount") + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: - Uruchomienie + + /// Argumenty `rclone mount`. Wydzielone, zeby dalo sie je sprawdzic testem + /// bez uruchamiania czegokolwiek. + public static func mountArguments() -> [String] { + [ + "mount", "\(remoteName):\(remotePath)", mountPoint.path, + "--vfs-cache-mode", "full", + "--vfs-cache-max-size", cacheSize, + // Cache nie moze wyrzucac danych, ktore czekaja na wyslanie - stad + // wysoki wiek. Rozmiarem rzadzi --vfs-cache-max-size. + "--vfs-cache-max-age", "9999h", + "--vfs-write-back", "30s", + "--vfs-cache-poll-interval", "1m", + "--cache-dir", cacheDir.path, + "--dir-cache-time", "5m", + "--attr-timeout", "5m", + "--transfers", "8", + // Rozmiar kawalka dopasowany do rozmiaru pasma obrazu. + "--drive-chunk-size", "32M", + // Bez tego skasowane pasma ida do kosza Dysku i dalej licza sie + // do limitu pojemnosci. + "--drive-use-trash=false", + // Po przekroczeniu dobowego limitu 750 GB rclone ma stanac, a nie + // kreci sie w 403 do konca swiata. + "--drive-stop-on-upload-limit", + "--volname", remoteName, + "--rc", "--rc-addr", rcAddress, "--rc-no-auth", + "--log-file", logFile.path, + "--log-level", "INFO", + ] + } + + /// Przygotowuje otoczenie i oddaje argumenty do uruchomienia. Nie uruchamia + /// rclone sam - robi to `cloudmachine-agent mount-drive`, ktore musi zostac + /// na pierwszym planie pod launchd. + public static func prepare() throws -> [String] { + try FileManager.default.createDirectory(at: mountPoint, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + rotateLogIfLarge() + return mountArguments() + } + + private static func rotateLogIfLarge() { + guard + let attrs = try? FileManager.default.attributesOfItem(atPath: logFile.path), + let size = attrs[.size] as? UInt64, size > logSizeLimit + else { return } + let rotated = logFile.appendingPathExtension("1") + try? FileManager.default.removeItem(at: rotated) + try? FileManager.default.moveItem(at: logFile, to: rotated) + } + + /// Wyklucza katalog bufora z Time Machine. Bufor trzyma kopie danych + /// backupu - gdyby Time Machine go objal, backupowalby wlasny backup i rosl + /// bez konca. Wykluczenie zapisuje sie jako xattr na katalogu, wiec ginie + /// razem z nim; dlatego odnawiamy je przy kazdym starcie, a nie raz w setupie. + @discardableResult + public static func excludeBufferFromTimeMachine() async -> Bool { + let result = try? await ProcessRunner.run( + "/usr/bin/tmutil", ["addexclusion", root.path], timeout: 30) + return result?.succeeded == true + } + + // MARK: - Kolejka wysylki + + public struct QueueStats { + public var uploadsInProgress: Int + public var uploadsQueued: Int + public var files: Int + public var erroredFiles: Int + public var isQuiet: Bool { uploadsInProgress == 0 && uploadsQueued == 0 } + } + + /// Odczytuje stan kolejki przez interfejs sterujacy rclone. + /// + /// UWAGA: `--rc-no-auth` to flaga SERWERA. Klient `rclone rc` jej nie + /// przyjmuje i konczy sie bledem "unknown flag" - kosztowalo to juz jedno + /// ciche zepsucie podgladu stanu. + public static func queueStats() async -> QueueStats? { + guard + let result = try? await CMTooling.runRclone( + ["rc", "--url", rcAddress, "vfs/stats"], timeout: 30), + result.succeeded, + let data = result.stdout.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + func number(_ key: String, in dict: [String: Any]) -> Int { + if let v = dict[key] as? Int { return v } + if let v = dict[key] as? NSNumber { return v.intValue } + return 0 + } + + // vfs/stats zwraca liczniki zagniezdzone w sekcji "diskCache". + let disk = (json["diskCache"] as? [String: Any]) ?? json + return QueueStats( + uploadsInProgress: number("uploadsInProgress", in: disk), + uploadsQueued: number("uploadsQueued", in: disk), + files: number("files", in: disk), + erroredFiles: number("erroredFiles", in: disk) + ) + } + + /// Czeka, az wysylka ucichnie. Operacje `hdiutil` na montowaniu FUSE-T sa + /// stabilne tylko przy pustej kolejce - patrz `BackupImageService`. + @discardableResult + public static func waitUntilQuiet(timeout: TimeInterval = 180) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let stats = await queueStats(), stats.isQuiet { return true } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + return false + } + + /// Rozmiar bufora na dysku w bajtach. + public static func cacheSizeBytes() -> UInt64 { + guard + let enumerator = FileManager.default.enumerator( + at: cacheDir, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], + options: [.skipsHiddenFiles]) + else { return 0 } + var total: UInt64 = 0 + for case let url as URL in enumerator { + let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + total += UInt64(values?.totalFileAllocatedSize ?? 0) + } + return total + } + + /// Czy rclone zatrzymal sie na dobowym limicie Google Drive. Po jego + /// przekroczeniu rclone konczy prace z zalozenia, a agent launchd probuje go + /// podnosic - bez sensu, dopoki limit sie nie odnowi. + public static func hitDailyQuota() -> Bool { + guard let handle = try? FileHandle(forReadingFrom: logFile) else { return false } + defer { try? handle.close() } + let size = (try? handle.seekToEnd()) ?? 0 + let window: UInt64 = 64 * 1024 + try? handle.seek(toOffset: size > window ? size - window : 0) + guard let data = try? handle.readToEnd(), + let text = String(data: data, encoding: .utf8)?.lowercased() + else { return false } + return text.contains("storagequotaexceeded") || text.contains("upload limit") + || text.contains("userratelimitexceeded") + } +} diff --git a/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift b/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift new file mode 100644 index 0000000..2ad93d8 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Pobiera oficjalna binarke rclone - port `gdrive/install-rclone.sh`. +/// +/// Istnieje, bo rclone z Homebrew jest zbudowane bez obslugi FUSE i przy +/// probie montowania odmawia wprost: +/// +/// rclone mount is not supported on MacOS when rclone is installed via Homebrew +/// +/// Instalujemy obok, we wlasnym katalogu (`CMTooling.managedRclonePath`), nie +/// ruszajac instalacji Homebrew - pozostale, niemontujace sciezki kodu moga +/// z niej dalej korzystac. +public enum RcloneInstaller { + + private static let versionURL = "https://downloads.rclone.org/version.txt" + + public static func install() async -> CMActionResult { + let workDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cloudmachine-rclone-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workDir) } + + do { + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + } catch { + return CMActionResult( + succeeded: false, message: "Nie moge utworzyc katalogu roboczego: \(error.localizedDescription)") + } + + guard let version = await latestVersion() else { + return CMActionResult(succeeded: false, message: "Nie udalo sie odczytac numeru wersji rclone.") + } + + let arch = currentArch() + let zipName = "rclone-\(version)-\(arch).zip" + let zipURL = "https://downloads.rclone.org/\(version)/\(zipName)" + let sumsURL = "https://downloads.rclone.org/\(version)/SHA256SUMS" + let zipPath = workDir.appendingPathComponent(zipName) + let sumsPath = workDir.appendingPathComponent("SHA256SUMS") + + guard await download(zipURL, to: zipPath), await download(sumsURL, to: sumsPath) else { + return CMActionResult(succeeded: false, message: "Nie udalo sie pobrac \(zipName).") + } + + // Weryfikacja sumy nie jest ozdoba: sciagamy wykonywalna binarke, ktora + // bedzie miala dostep do calego Dysku Google. + guard let expected = expectedChecksum(sumsFile: sumsPath, zipName: zipName) else { + return CMActionResult(succeeded: false, message: "Brak wpisu dla \(zipName) w SHA256SUMS.") + } + guard let actual = await checksum(of: zipPath) else { + return CMActionResult(succeeded: false, message: "Nie udalo sie policzyc sumy kontrolnej.") + } + guard expected == actual else { + return CMActionResult( + succeeded: false, + message: "Suma SHA256 sie nie zgadza - NIE instaluje.\n oczekiwana: \(expected)\n policzona : \(actual)") + } + + guard + let unzip = try? await ProcessRunner.run( + "/usr/bin/unzip", ["-oq", zipPath.path, "-d", workDir.path], timeout: 300), + unzip.succeeded + else { + return CMActionResult(succeeded: false, message: "Nie udalo sie rozpakowac archiwum.") + } + + let extracted = workDir + .appendingPathComponent("rclone-\(version)-\(arch)") + .appendingPathComponent("rclone") + let destination = CMTooling.managedRclonePath + try? FileManager.default.removeItem(at: destination) + do { + try FileManager.default.copyItem(at: extracted, to: destination) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: destination.path) + } catch { + return CMActionResult( + succeeded: false, message: "Nie udalo sie zainstalowac binarki: \(error.localizedDescription)") + } + + return CMActionResult( + succeeded: true, message: "Zainstalowano rclone \(version) w \(destination.path) (suma SHA256 zgodna).") + } + + // MARK: - Szczegoly + + static func currentArch() -> String { + #if arch(arm64) + return "osx-arm64" + #else + return "osx-amd64" + #endif + } + + /// Wyciaga oczekiwana sume dla danego archiwum z pliku SHA256SUMS. + /// Czysta funkcja - testowalna bez sieci. + public static func expectedChecksum(sumsContent: String, zipName: String) -> String? { + for line in sumsContent.components(separatedBy: .newlines) { + let parts = line.split(separator: " ", omittingEmptySubsequences: true) + guard parts.count >= 2 else { continue } + let name = parts.last.map(String.init)?.trimmingCharacters(in: CharacterSet(charactersIn: "*")) + if name == zipName { return String(parts[0]) } + } + return nil + } + + private static func expectedChecksum(sumsFile: URL, zipName: String) -> String? { + guard let content = try? String(contentsOf: sumsFile, encoding: .utf8) else { return nil } + return expectedChecksum(sumsContent: content, zipName: zipName) + } + + private static func latestVersion() async -> String? { + guard + let result = try? await ProcessRunner.run( + "/usr/bin/curl", ["-fsSL", versionURL], timeout: 60), + result.succeeded + else { return nil } + // Format: "rclone v1.75.1" + return result.stdout.split(separator: " ").last.map { + String($0).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private static func download(_ url: String, to path: URL) async -> Bool { + let result = try? await ProcessRunner.run( + "/usr/bin/curl", ["-fsSL", "-o", path.path, url], timeout: 600) + return result?.succeeded == true + } + + private static func checksum(of path: URL) async -> String? { + guard + let result = try? await ProcessRunner.run( + "/usr/bin/shasum", ["-a", "256", path.path], timeout: 300), + result.succeeded + else { return nil } + return result.stdout.split(separator: " ").first.map(String.init) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift new file mode 100644 index 0000000..5c22a1d --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift @@ -0,0 +1,125 @@ +import XCTest + +@testable import CloudMachineCore + +/// Testy warstwy Google Drive. Pokrywaja parsowanie i decyzje - czyli te +/// miejsca, gdzie bledy byly ciche i kosztowne, a nie widac ich po tym, ze +/// "backup sie robi". +final class DriveLayerTests: XCTestCase { + + // MARK: - Parsowanie hdiutil info + + /// `hdiutil info` grupuje wpisy w bloki: po linii `image-path` naleza + /// wszystkie kolejne linie `/dev/diskN`, az do nastepnego `image-path`. + private let hdiutilInfo = """ + framework : 595.100.2 + driver : 595.100.2 + ================================================ + image-path : /Users/x/.cloudmachine/drive/other.sparsebundle + image-alias : /Users/x/.cloudmachine/drive/other.sparsebundle + shadow-path : + /dev/disk4\tGUID_partition_scheme\t + /dev/disk4s1\t41504653-0000-11AA-AA11-00306543ECAC\t/Volumes/Other + ================================================ + image-path : /Users/x/.cloudmachine/drive/mac-studio.sparsebundle + image-alias : /Users/x/.cloudmachine/drive/mac-studio.sparsebundle + shadow-path : + /dev/disk7\tEF57347C-0000-11AA-AA11-00306543ECAC\t + /dev/disk7s1\t41504653-0000-11AA-AA11-00306543ECAC\t/Volumes/CloudMachine + """ + + func testParseDevicesFindsOnlyMatchingImage() { + let devices = BackupImageService.parseDevices( + hdiutilInfo: hdiutilInfo, + imagePath: "/Users/x/.cloudmachine/drive/mac-studio.sparsebundle") + XCTAssertEqual(devices, ["/dev/disk7"]) + } + + func testParseDevicesIgnoresOtherImages() { + let devices = BackupImageService.parseDevices( + hdiutilInfo: hdiutilInfo, + imagePath: "/Users/x/.cloudmachine/drive/other.sparsebundle") + XCTAssertEqual(devices, ["/dev/disk4"]) + } + + /// Regresja: gdy obraz nie jest podpiety, nie wolno zwrocic cudzych + /// urzadzen - odpiecie ich zabiloby czyjs wolumen. + func testParseDevicesReturnsNothingForUnknownImage() { + let devices = BackupImageService.parseDevices( + hdiutilInfo: hdiutilInfo, imagePath: "/Users/x/nieistniejacy.sparsebundle") + XCTAssertTrue(devices.isEmpty) + } + + func testParseDevicesHandlesEmptyInput() { + XCTAssertTrue(BackupImageService.parseDevices(hdiutilInfo: "", imagePath: "/x").isEmpty) + } + + // MARK: - Suma kontrolna rclone + + private let sums = """ + 3a1f0000000000000000000000000000000000000000000000000000000000aa rclone-v1.75.1-osx-amd64.zip + c61d7a371c62bcbbe882c3423aa4b8bf63485c248dd0f692997b8f0c3f6d0c6f rclone-v1.75.1-osx-arm64.zip + 9b2c0000000000000000000000000000000000000000000000000000000000bb rclone-v1.75.1-linux-amd64.zip + """ + + func testExpectedChecksumPicksTheRightArchive() { + XCTAssertEqual( + RcloneInstaller.expectedChecksum(sumsContent: sums, zipName: "rclone-v1.75.1-osx-arm64.zip"), + "c61d7a371c62bcbbe882c3423aa4b8bf63485c248dd0f692997b8f0c3f6d0c6f") + } + + /// Brak wpisu MUSI dac nil, a nie dowolna inna sume - inaczej instalator + /// porownalby archiwum z suma innego pliku i albo odrzucil poprawne + /// pobranie, albo (gorzej) przepuscil niepoprawne. + func testExpectedChecksumReturnsNilWhenArchiveMissing() { + XCTAssertNil( + RcloneInstaller.expectedChecksum(sumsContent: sums, zipName: "rclone-v9.9.9-osx-arm64.zip")) + } + + // MARK: - Argumenty montowania + + func testMountArgumentsCarryTheNonObviousFlags() { + let args = DriveBufferService.mountArguments() + + // Bez tego skasowane pasma ida do kosza Dysku i dalej licza sie do limitu. + XCTAssertTrue(args.contains("--drive-use-trash=false")) + + // Po przekroczeniu dobowego limitu 750 GB rclone ma stanac, a nie kreci + // sie w 403. + XCTAssertTrue(args.contains("--drive-stop-on-upload-limit")) + + // Bez pelnego cache zapis nie jest buforowany, czyli cala obietnica + // nieprzerywalnosci znika. + XCTAssertTrue(args.contains("--vfs-cache-mode")) + XCTAssertEqual(args[(args.firstIndex(of: "--vfs-cache-mode")! + 1)], "full") + + // Interfejs rc jest jedynym zrodlem stanu kolejki - bez niego dozorca + // bufora jest slepy. + XCTAssertTrue(args.contains("--rc")) + } + + /// Rozmiar pasma zostal wybrany pomiarem (patrz gdrive/README.md). Zmiana + /// dziala tylko przy tworzeniu obrazu, wiec nie wolno jej przeoczyc. + func testBandSizeIs32MB() { + XCTAssertEqual(BackupImageService.bandSectors * 512, 32 * 1024 * 1024) + } + + // MARK: - Progi dozorcy + + func testGuardThresholdsAreOrdered() { + let t = BufferGuardService.Thresholds() + XCTAssertLessThan( + t.lowGB, t.highGB, + "Prog wznowienia musi byc nizszy niz prog pauzy, inaczej dozorca wpadnie w oscylacje.") + } + + /// Wolne miejsce musi byc liczone pesymistycznie, jak `df`. Miara + /// "important usage" wliczala miejsce zajete przez migawki i pokazywala + /// 1202 GB tam, gdzie `df` mowilo 427 GB - dozorca spoznilby sie z pauza. + func testFreeSpaceMatchesStatfs() { + var stats = statfs() + XCTAssertEqual(statfs("/System/Volumes/Data", &stats), 0) + let expected = Int(UInt64(stats.f_bavail) * UInt64(stats.f_bsize) / 1_073_741_824) + XCTAssertEqual(BufferGuardService.freeGB(), expected) + } +} From b158702aca9fa6a3fd9469430e388797ebbca90a Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 13:38:37 +0200 Subject: [PATCH 07/18] Retire the shell layer; the app now owns every process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchd no longer points at scripts. Three agents run the bundled binary: gdrive-buffer (mount-drive, KeepAlive), gdrive-attach (attach-image, every 15 minutes to recover from a dropped mount) and buffer-guard. Verified live — the buffer comes up, the image attaches and the guard reports its thresholds, all from /Applications/CloudMachine.app. Two templates are removed rather than ported, because the architecture they served no longer exists. archive-watchdog copied completed backups from a local Time Machine volume to Drive; there is no local volume now, the backup goes straight to Drive. verify-watchdog is worse than obsolete — it runs `tmutil verifychecksums` over the whole backup, which against a Drive-backed volume means pulling every byte back down. Installing it would have been a trap. buffer-guard no longer exits when a backup finishes. Under KeepAlive that would mean an immediate relaunch, and with Time Machine idle a restart loop bounded only by ThrottleInterval. It goes idle instead and picks up the next backup. What stays here are the measurement harnesses — they measure hdiutil and FUSE-T behaviour, which no unit test reaches — plus the README, which keeps the numbers behind each decision. poc-common.sh replaces cm-env.sh so the harnesses no longer depend on the production scripts that are gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- gdrive/README.md | 259 ++++++------------ gdrive/attach-image.sh | 63 ----- gdrive/buffer-guard.sh | 76 ----- gdrive/cm-env.sh | 117 -------- ....cloudmachine.gdrive-attach.plist.template | 39 --- ...enacode.cloudmachine.gdrive.plist.template | 41 --- gdrive/create-image.sh | 46 ---- gdrive/install-rclone.sh | 47 ---- gdrive/mount-drive.sh | 66 ----- gdrive/poc-common.sh | 29 ++ gdrive/poc-launchd.sh | 108 -------- gdrive/poc-local.sh | 87 ------ gdrive/poc-pullplug.sh | 2 +- gdrive/setup-remote.sh | 85 ------ gdrive/status.sh | 38 --- gdrive/verify-image.sh | 53 ---- ...oudmachine.archive-watchdog.plist.template | 28 -- ...e.cloudmachine.buffer-guard.plist.template | 28 ++ ....cloudmachine.gdrive-attach.plist.template | 29 ++ ....cloudmachine.gdrive-buffer.plist.template | 32 +++ ...loudmachine.verify-watchdog.plist.template | 28 -- .../CloudMachineAgent/DriveCommands.swift | 11 +- .../CloudMachineCore/BufferGuardService.swift | 36 ++- 23 files changed, 235 insertions(+), 1113 deletions(-) delete mode 100755 gdrive/attach-image.sh delete mode 100755 gdrive/buffer-guard.sh delete mode 100755 gdrive/cm-env.sh delete mode 100644 gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template delete mode 100644 gdrive/com.renacode.cloudmachine.gdrive.plist.template delete mode 100755 gdrive/create-image.sh delete mode 100755 gdrive/install-rclone.sh delete mode 100755 gdrive/mount-drive.sh create mode 100755 gdrive/poc-common.sh delete mode 100755 gdrive/poc-launchd.sh delete mode 100755 gdrive/poc-local.sh delete mode 100755 gdrive/setup-remote.sh delete mode 100755 gdrive/status.sh delete mode 100755 gdrive/verify-image.sh delete mode 100644 launchd/com.renacode.cloudmachine.archive-watchdog.plist.template create mode 100644 launchd/com.renacode.cloudmachine.buffer-guard.plist.template create mode 100644 launchd/com.renacode.cloudmachine.gdrive-attach.plist.template create mode 100644 launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template delete mode 100644 launchd/com.renacode.cloudmachine.verify-watchdog.plist.template diff --git a/gdrive/README.md b/gdrive/README.md index 1218c5d..1f0a635 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -1,6 +1,7 @@ -# CloudMachine / warstwa Google Drive +# Warstwa Google Drive - pomiary i wnioski -Time Machine na Google Drive, bez dysku zewnetrznego i bez sprzetu w domu. +Dzialajacy system siedzi w aplikacji, nie tutaj. Ten katalog trzyma harnessy +pomiarowe i uzasadnienia decyzji, ktore z tych pomiarow wyszly. ``` Time Machine @@ -12,32 +13,38 @@ Time Machine Sens ukladu: **w sciezce zapisu nie ma sieciowego systemu plikow.** Time Machine pisze do lokalnie podpietego obrazu i nie wie, ze pasma leza w chmurze. Odpada -SMB, a z nim najczestsza przyczyna psucia sie backupow sieciowych - uszkodzenie -sparsebundle przy zerwaniu polaczenia. Zapis konczy sie w buforze, wysylka idzie -w tle, wiec utrata lacza wstrzymuje drenaz zamiast przerywac backup. +SMB, a z nim najczestsza przyczyna psucia sie backupow sieciowych. -## Dwie pulapki, ktore kosztuja dzien szukania +## Gdzie co jest -**rclone z Homebrew nie umie montowac.** Odmawia wprost: -`rclone mount is not supported on MacOS when rclone is installed via Homebrew`. -Potrzebna jest oficjalna binarka z rclone.org - stawia ja `install-rclone.sh` -obok instalacji brew, we wlasnym katalogu, bez ruszania systemowego rclone. +| Co | Gdzie | +|---|---| +| Bufor (montowanie, cache, kolejka) | `CloudMachineCore/DriveBufferService` | +| Obraz (tworzenie, podpinanie, spojnosc) | `CloudMachineCore/BackupImageService` | +| Dozorca bufora | `CloudMachineCore/BufferGuardService` | +| Instalacja rclone z obsluga montowania | `CloudMachineCore/RcloneInstaller` | +| Rozwiazywanie narzedzi, kontrola FUSE | `CloudMachineCore/CMTooling` | +| Podkomendy | `CloudMachineAgent/DriveCommands` | +| Agenty launchd | `launchd/*.plist.template` | -**Sparsebundle nie ma sumy kontrolnej,** wiec `hdiutil verify` na nim nie dziala -(`has no checksum`). Do sprawdzania spojnosci sluzy `fsck_apfs` na podpietym -urzadzeniu - patrz `verify-image.sh`. +```sh +cloudmachine-agent install-rclone # oficjalna binarka (ta z Homebrew nie umie montowac) +cloudmachine-agent configure-remote # OAuth, klucze z Keychaina +cloudmachine-agent create-image --size-gb 4000 +cloudmachine-agent attach-image +cloudmachine-agent drive-status +sudo tmutil setdestination /Volumes/CloudMachine +``` ## Rozmiar pasma Ustawiany wylacznie przy tworzeniu obrazu; pozniej nie da sie go zmienic bez -zaczynania backupu od zera. Dwie sily ciagna w przeciwne strony: +zaczynania backupu od zera. Dwie sily ciagna w przeciwne strony: Google Drive +przepuszcza okolo **dwoch operacji na plik na sekunde** i ma limit **400 000 +plikow**, co premiuje duze pasma - ale kazda zmiana brudzi **cale** pasmo, co +przy dobowym limicie **750 GB** premiuje male. -- Google Drive przepuszcza okolo **dwoch operacji na plik na sekunde** i ma - limit **400 000 plikow**. To premiuje duze pasma. -- Kazda zmiana brudzi **cale** pasmo i tyle trzeba wyslac od nowa. Przy limicie - **750 GB uploadu na dobe** to premiuje male pasma. - -Zmierzone lokalnie (`poc-amplification.sh`, obraz 3 GB, zmiana 300 MB): +Zmierzone (`poc-amplification.sh`, obraz 3 GB, zmiana 300 MB): | Pasmo | Pasm na 3 GB | Rozrzucona zmiana | Dopisanie (jak TM) | Plikow na 200 GB | |-------|--------------|-------------------|--------------------|------------------| @@ -46,183 +53,91 @@ Zmierzone lokalnie (`poc-amplification.sh`, obraz 3 GB, zmiana 300 MB): | 32 MB | 99 | 3072 MB | **672 MB** | **6 400** | | 64 MB | 52 | 3136 MB | 768 MB | 3 200 | -Dwa wnioski, oba wbrew pierwotnemu zalozeniu. - Przy zmianie **rozrzuconej** po calym wolumenie rozmiar pasma nie ma znaczenia - -brudzi sie prawie kazde pasmo i wysyla sie w praktyce caly obraz. Ale to +brudzi sie prawie kazde pasmo i wysyla sie w praktyce caly obraz. To jednak najgorszy przypadek, nie ten, ktory nas dotyczy. -Przy **dopisywaniu**, czyli tym, co faktycznie robi Time Machine, transfer rosnie -monotonicznie z rozmiarem pasma: 64 MB kosztuje dokladnie dwa razy tyle co 8 MB. -Czyli duze pasma nie sa darmowe, jak zakladalem. +Przy **dopisywaniu**, czyli tym, co faktycznie robi Time Machine, transfer +rosnie monotonicznie z rozmiarem pasma: 64 MB kosztuje dokladnie dwa razy tyle +co 8 MB. Duze pasma nie sa darmowe. + +Stad **32 MB**: najmniejsze pasmo, przy ktorym pierwsza wysylka przestaje byc +ograniczona tempem operacji Drive'a (6 400 plikow, ~0,9 h) i zaczyna byc +ograniczona pasmem lacza (~1,3 h przy 332 Mb/s). + +## Kaprysy montowania FUSE-T + +FUSE-T montuje przez NFS, a `hdiutil` na takim wolumenie bywa odrzucany bledem +**`RPC version wrong`**. Zmierzone: blad nie zalezy od rozmiaru obrazu ani od +danych (jeden przebieg padl dla 100 GB i 400 GB, a przeszedl dla 600, 1000 +i 1500 GB), tylko od **chwili** - przy pustej kolejce wysylki 5 prob na 5 +udanych, przy rclone zajetym losowo. Stad czekanie na cisze i ponawianie +w `BackupImageService`; przy tworzeniu produkcyjnego obrazu pierwsza proba padla, +druga przeszla. + +**Obraz trzeba tworzyc na miejscu, na zamontowanym Drive.** Utworzenie go +lokalnie i przeniesienie daje obraz, ktorego `hdiutil` pozniej nie otwiera +(`CBSDBackingStore::newProbe stat() failed`), mimo ze wszystkie pliki i pasma sa +na swoim miejscu i daja sie czytac. -Stad **32 MB**, nie 64: to najmniejsze pasmo, przy ktorym pierwsza wysylka -przestaje byc ograniczona tempem operacji Drive'a (6 400 plikow, ~0,9 h przy -~2 operacjach na sekunde) i zaczyna byc ograniczona pasmem lacza (~1,3 h przy -332 Mb/s). Ponizej tego progu wydluzasz pierwszy backup, powyzej - placisz -wiekszym transferem przy kazdym przyroscie za korzysc, ktorej juz nie ma. +Pozostale backendy FUSE-T nie pomagaja: `backend=fskit` w ogole sie nie montuje, +`backend=smb` montuje sie, ale `hdiutil create` konczy sie `Is a directory`. ## Co przetrwa smierc warstwy chmurowej -`poc-pullplug.sh` odpina zastepnik montowania w trakcie zapisu do obrazu, czyli -symuluje padniecie procesu rclone albo wysypanie sie FUSE-T. Trzy rundy, -**zero nieodwracalnych strat** - obraz za kazdym razem przeszedl `fsck_apfs`. +`poc-pullplug.sh` odpina zastepnik montowania w trakcie zapisu, czyli symuluje +padniecie procesu rclone albo wysypanie sie FUSE-T. Trzy rundy, **zero +nieodwracalnych strat** - obraz za kazdym razem przeszedl `fsck_apfs`. -Uwaga: zerwanie samego lacza to co innego i jest lagodniejsze. Przy -`--vfs-cache-mode full` zapis idzie do bufora na dysku, wiec brak sieci -wstrzymuje tylko wysylke; montowanie stoi i Time Machine niczego nie zauwaza. +Zerwanie samego lacza jest lagodniejsze: przy `--vfs-cache-mode full` zapis idzie +do bufora, montowanie stoi i Time Machine niczego nie zauwaza. -Test ujawnil natomiast dwie pulapki w obsludze, obie juz zalatane w skryptach: +Dwie pulapki, ktore ten test ujawnil, obie zalatane w `BackupImageService`: **Zombie urzadzenia.** Po wymuszonym odpieciu urzadzenie obrazu potrafi zostac w systemie. Podpiecie zwraca wtedy martwy uchwyt, na ktorym `fsck_apfs` melduje `failed to read container superblock` z UUID z samych zer. Wyglada to jak skasowany backup, a jest tylko nieczytelnym urzadzeniem - pierwsza wersja tego testu na tej podstawie trzy razy z rzedu orzekla utrate danych, ktore byly cale. -`cm_purge_stale_devices` w `cm-env.sh` sprzata je przed podpieciem. **Osierocony punkt montowania.** Po nieczystym odpieciu katalog `/Volumes/CloudMachine` zostaje i blokuje ponowne podpiecie komunikatem `no mountable file systems`. Nalezy do uzytkownika, ale lezy w `/Volumes` -nalezacym do roota, wiec `rmdir` odmawia - **agent launchd dzialajacy jako -uzytkownik nie posprzata po sobie sam** i backup stoi do reczne interwencji. -`attach-image.sh` wykrywa to i wypisuje dokladne polecenie do uruchomienia. -Obejsciem docelowym byloby montowanie poza `/Volumes` (patrz `CM_TARGET`), ale -nie wiadomo, czy `tmutil setdestination` przyjmie taki cel. - -**Wlasny client_id OAuth** to osobna koniecznosc: wspoldzielony client_id rclone -jest wycofywany i przestanie dzialac w trakcie 2026 roku, a Google limituje -tempo per client_id - na wspoldzielonym konkurujesz ze wszystkimi uzytkownikami -rclone naraz. - -## Instalacja - -```sh -chmod +x gdrive/*.sh -./gdrive/install-rclone.sh # binarka z obsluga mount -brew install macos-fuse-t/homebrew-cask/fuse-t # FUSE bez kexta -rclone config # remote "gdrive", wlasny client_id -``` - -Konfiguracja rclone z tokenami OAuth trafia do `~/.config/rclone/rclone.conf`, -poza repozytorium. To repo jest publiczne - nic z sekretami tu nie wchodzi. - -```sh -./gdrive/mount-drive.sh & -./gdrive/create-image.sh # raz, na starcie -./gdrive/attach-image.sh -sudo tmutil setdestination /Volumes/CloudMachine -``` +nalezacym do roota, wiec `rmdir` odmawia - **agent dzialajacy jako uzytkownik +nie posprzata po sobie sam**. Aplikacja wykrywa to i podaje dokladne polecenie. -Agenty launchd - podmien `__REPO__` i `__HOME__` na sciezki absolutne: +## Bufor jest limitem miekkim -```sh -for t in gdrive gdrive-attach; do - sed -e "s|__REPO__|$PWD|g" -e "s|__HOME__|$HOME|g" \ - "gdrive/com.renacode.cloudmachine.$t.plist.template" \ - > ~/Library/LaunchAgents/com.renacode.cloudmachine.$t.plist - launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.renacode.cloudmachine.$t.plist -done -``` - -## Pierwszy backup - -`buffer-guard.sh` pilnuje, zeby bufor nie zjadl dysku. Time Machine pisze do -obrazu z predkoscia SSD, rclone wysyla z predkoscia lacza - roznica zostaje -w buforze. Limit `--vfs-cache-max-size` jest **miekki**: rclone usuwa z bufora -tylko dane juz wyslane, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej. -Dozorca wstrzymuje Time Machine powyzej progu i wznawia, gdy wysylka nadgoni. -Pilnuje tez dobowego limitu Google Drive - po jego przekroczeniu rclone konczy -prace i nie ma sensu go podnosic, dopoki limit sie nie odnowi. - -```sh -./gdrive/buffer-guard.sh # progi: CM_BUFFER_HIGH, CM_BUFFER_LOW, CM_FREE_MIN -``` - - -Ten Mac ma wlaczone usypianie, a spiacy Mac nie wysyla nic do Drive'a. Na czas -pierwszego pelnego transferu: - -```sh -caffeinate -dims & -``` - -## Kaprysy montowania FUSE-T - -FUSE-T montuje przez NFS (`fuse-t:/gdrive ... (nfs)`), a `hdiutil` na takim -wolumenie bywa odrzucany bledem **`RPC version wrong`**. Zmierzone zachowanie: - -- Blad nie zalezy od rozmiaru obrazu ani od danych. Przy jednym przebiegu padly - proby dla 100 GB i 400 GB, a przeszly dla 600 GB, 1000 GB i 1500 GB. -- Zalezy od **chwili**: przy pustej kolejce wysylki 5 prob na 5 udanych, przy - rclone zajetym wysylka lub kasowaniem - losowo. - -Dlatego `create-image.sh` i `attach-image.sh` czekaja na cisze (`cm_wait_quiet`, -czyta `vfs/stats` przez interfejs rc) i ponawiaja do pieciu razy. Przy tworzeniu -produkcyjnego obrazu pierwsza proba padla, druga przeszla - mechanizm nie jest -teoretyczny. - -**Obraz trzeba tworzyc na miejscu, na zamontowanym Drive.** Utworzenie go -lokalnie i przeniesienie przez `mv` daje obraz, ktorego `hdiutil` pozniej nie -otwiera (`CBSDBackingStore::newProbe stat() failed`), mimo ze wszystkie pliki -i pasma sa na swoim miejscu i daja sie czytac. - -Dwa inne backendy FUSE-T nie pomagaja: `backend=fskit` w ogole sie nie montuje, -`backend=smb` montuje sie, ale `hdiutil create` konczy sie `Is a directory`. +`--vfs-cache-max-size` nie jest granica twarda: rclone usuwa z bufora tylko dane +juz wyslane, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej i moze +zapelnic dysk. Time Machine pisze do obrazu z predkoscia SSD (zmierzone +267 MB/s), rclone wysyla z predkoscia lacza (~41 MB/s) - na starcie pierwszego +backupu bufor rosl netto o 32 MB/s. -## Zabezpieczenia przed zapetleniem +Dlatego `BufferGuardService` wstrzymuje Time Machine powyzej progu i wznawia, +gdy wysylka nadgoni. Pilnuje tez dobowego limitu Drive'a: po jego przekroczeniu +rclone konczy prace z zalozenia i podnoszenie go nic nie da, dopoki limit sie +nie odnowi. -Agent bufora ma `KeepAlive`, wiec kazdy blad startowy powtarzalby sie co 30 s -w nieskonczonosc. `mount-drive.sh` sprawdza dlatego przed uruchomieniem, czy -FUSE w ogole jest - bez tego rclone konczylby natychmiast bledem `cgofuse: -cannot find FUSE`, a log rosl bez konca. Ten projekt ma juz za soba incydent -logu na 3,3 GiB, wiec log rclone jest dodatkowo przycinany przy starcie po -przekroczeniu 100 MB. +## Harnessy pomiarowe -## Podglad +Nie sa czescia dzialajacego systemu - uruchamia sie je recznie, gdy trzeba cos +zmierzyc albo potwierdzic regresje. Mierza zachowanie `hdiutil` i FUSE-T, czyli +rzeczy, ktorych testem jednostkowym sie nie zmierzy. ```sh -./gdrive/status.sh +BAND_MB=32 WORKLOAD=append ./poc-amplification.sh # wzmocnienie zapisu +BAND_MB=32 ROUNDS=3 ./poc-pullplug.sh # smierc warstwy chmurowej ``` -Liczba, ktora trzeba obserwowac, to `uploadsQueued`. Jesli rosnie i nie wraca do -zera miedzy backupami, wysylka nie nadaza za zapisem i bufor sie zapelnia. - -## Skrypty testowe - -`poc-local.sh` - przepuszcza caly lancuch bez Drive'a i bez FUSE-T, podstawiajac -lokalny obraz w miejsce montowania rclone. Dowodzi, ze obraz powstaje, podpina -sie, przyjmuje dane i przezywa odpiecie z ponownym podpieciem. - -`poc-amplification.sh` - mierzy, ile megabajtow trzeba wyslac na kazdy megabajt -faktycznej zmiany. `BAND_MB` wybiera rozmiar pasma, `WORKLOAD` scenariusz -(`scatter` - najgorszy przypadek, `append` - wzorzec Time Machine). - -`poc-pullplug.sh` - wyrywa warstwe chmurowa spod obrazu w trakcie zapisu -i sprawdza, czy backup da sie odzyskac. - -`poc-launchd.sh` - laduje agenty z zaslepka w miejsce rclone i sprawdza, czy -bufor wstaje, czy agent podpinajacy czeka na niego zamiast wyscigowac sie z nim -i czy KeepAlive podnosi bufor po padnieciu. Uzywa wlasnych etykiet `poc-*`, -wiec nie koliduje z produkcyjnymi agentami, i sprzata po sobie. - -`verify-image.sh` - sprawdza spojnosc obrazu przez `fsck_apfs`. - -## Czego jeszcze nie sprawdzono - -- Czy `tmutil setdestination` na macOS 26.6 przyjmie tak podpiety obraz. -- Jak zachowa sie montowanie pod obciazeniem prawdziwego backupu. Pojedyncze - operacje dzialaja (zapis 50 MB przy 267 MB/s), ale kilkugodzinny pierwszy - transfer to inna skala. -- Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych. Na tym stoi - cala obietnica nieprzerywalnosci. Test: odciac siec w trakcie backupu, - przywrocic, odczekac na pelny drenaz, `verify-image.sh`. Powtorzyc kilka razy. - (`poc-pullplug.sh` pokrywa mocniejszy przypadek - smierc calej warstwy - ale - nie ten konkretny, bo wymaga dzialajacego rclone.) -- Czy `tmutil setdestination` przyjmie cel spoza `/Volumes`. Od tego zalezy, - czy da sie usunac koniecznosc recznej interwencji po nieczystym odpieciu. -- Rzeczywiste wzmocnienie zapisu przy prawdziwych backupach Time Machine, - a nie przy symulacji. -- Czy `--rc-no-auth` na 127.0.0.1:5572 jest akceptowalne. Interfejs slucha tylko - na petli zwrotnej, ale kazdy lokalny proces moze przez niego sterowac - montowaniem. Do rozwazenia `--rc-user`/`--rc-pass`. +## Czego nadal nie wiadomo + +- Jak montowanie zachowa sie pod obciazeniem pelnego, wielogodzinnego backupu. + Pojedyncze operacje dzialaja (zapis 50 MB przy 267 MB/s), ale to inna skala. +- Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych. + `poc-pullplug.sh` pokrywa mocniejszy przypadek - smierc calej warstwy - ale + nie ten konkretny, bo wymaga dzialajacego rclone. +- Czy `tmutil setdestination` przyjmie cel spoza `/Volumes`. Od tego zalezy, czy + da sie usunac koniecznosc recznej interwencji po nieczystym odpieciu. +- Czy `--rc-no-auth` na petli zwrotnej jest akceptowalne. Kazdy lokalny proces + moze przez ten interfejs sterowac montowaniem. diff --git a/gdrive/attach-image.sh b/gdrive/attach-image.sh deleted file mode 100755 index 78176be..0000000 --- a/gdrive/attach-image.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# Podpina obraz jako /Volumes/CloudMachine. Uruchamiane przy kazdym starcie, -# po tym jak mount-drive.sh postawi montowanie. - -set -euo pipefail -source "$(dirname "$0")/cm-env.sh" - -# Time Machine nie moze zobaczyc celu, zanim bufor bedzie gotowy - inaczej -# uzna, ze dysk backupu zniknal. -for _ in $(seq 1 60); do - cm_is_mounted && break - sleep 2 -done - -if ! cm_is_mounted; then - echo "Drive nie wstal w $CM_MOUNT po 120 s - przerywam" >&2 - exit 1 -fi - -if cm_is_attached; then - echo "Juz podpiety: $CM_TARGET" - exit 0 -fi - -if [ ! -d "$CM_IMAGE" ]; then - echo "Brak obrazu $CM_IMAGE - najpierw create-image.sh" >&2 - exit 1 -fi - -# Zombie po poprzednim, nieczystym odpieciu blokuje ponowne podpiecie -# komunikatem "no mountable file systems". Zaobserwowane w poc-pullplug.sh. -if [ -n "$(cm_devices_for_image "$CM_IMAGE")" ]; then - echo "Usuwam zawieszone urzadzenia po poprzednim podpieciu" - cm_purge_stale_devices "$CM_IMAGE" - sleep 2 -fi - -# Osierocony punkt montowania po nieczystym odpieciu blokuje podpiecie. -# Jesli lezy w /Volumes, usuniecie wymaga roota - dlatego mowimy dokladnie, co -# uruchomic, zamiast ponawiac w nieskonczonosc. -if [ -d "$CM_TARGET" ] && ! cm_is_attached; then - if rmdir "$CM_TARGET" 2>/dev/null; then - echo "Usunieto osierocony punkt montowania $CM_TARGET" - else - echo "Osierocony punkt montowania blokuje podpiecie: $CM_TARGET" >&2 - echo "Usun go i uruchom ponownie:" >&2 - echo " sudo rmdir '$CM_TARGET'" >&2 - exit 1 - fi -fi - -cm_wait_quiet 120 || true - -cm_attach() { - hdiutil attach "$CM_IMAGE" -nobrowse -mountpoint "$CM_TARGET" -} - -if ! cm_retry 5 cm_attach; then - echo "Nie udalo sie podpiac obrazu po 5 probach." >&2 - exit 1 -fi - -echo "Podpiete: $CM_TARGET" diff --git a/gdrive/buffer-guard.sh b/gdrive/buffer-guard.sh deleted file mode 100755 index dcd78ce..0000000 --- a/gdrive/buffer-guard.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# Pilnuje, zeby bufor nie zjadl dysku. -# -# Time Machine pisze do podpietego obrazu z predkoscia SSD, a rclone wysyla -# z predkoscia lacza - u nas ~41 MB/s. Roznica ladu­je w buforze. Limit -# --vfs-cache-max-size jest miekki: rclone usuwa z bufora tylko to, co juz -# wyslal, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej i moze zapelnic -# dysk. Przy pierwszym backupie liczonym w terabajtach to nie teoria. -# -# Dozorca wstrzymuje Time Machine, gdy bufor urosnie ponad prog, i wznawia, -# gdy wysylka go opcni. Backup staje sie wtedy wolniejszy, ale konczy sie -# zamiast wysypac maszyne. -# -# Osobno pilnuje dobowego limitu Google Drive (750 GB). Po jego przekroczeniu -# rclone konczy prace (--drive-stop-on-upload-limit), a agent launchd probuje -# go podniesc - bez sensu, dopoki limit sie nie odnowi. Dozorca to wykrywa -# i mowi wprost, zamiast pozwolic systemowi kreci sie w kolko. - -set -uo pipefail -source "$(dirname "$0")/cm-env.sh" - -HIGH_GB="${CM_BUFFER_HIGH:-150}" # powyzej tego wstrzymujemy Time Machine -LOW_GB="${CM_BUFFER_LOW:-40}" # ponizej tego wznawiamy -INTERVAL="${CM_GUARD_INTERVAL:-30}" -FREE_MIN_GB="${CM_FREE_MIN:-80}" # ponizej tego wstrzymujemy niezaleznie od bufora - -cache_gb() { du -sk "$CM_CACHE" 2>/dev/null | awk '{printf "%d", $1/1048576}'; } -free_gb() { df -k /System/Volumes/Data | awk 'NR==2 {printf "%d", $4/1048576}'; } -tm_running() { tmutil status 2>/dev/null | grep -q "Running = 1"; } -tm_percent() { tmutil status 2>/dev/null | awk -F'= ' '/"_raw_Percent"/ {gsub(/[";]/,"",$2); printf "%.1f", $2*100}'; } - -quota_hit() { - [ -f "$CM_LOG" ] || return 1 - tail -200 "$CM_LOG" 2>/dev/null | grep -qiE "quota|userRateLimitExceeded|storageQuotaExceeded|upload limit" -} - -paused=0 -echo "dozorca: prog $HIGH_GB GB / wznowienie $LOW_GB GB / min. wolnego $FREE_MIN_GB GB" - -while :; do - c=$(cache_gb); f=$(free_gb); p=$(tm_percent) - [ -z "$p" ] && p="?" - - if quota_hit; then - echo "LIMIT GOOGLE: dobowy limit uploadu wyczerpany - wysylka wstrzymana do odnowienia" - if [ "$paused" -eq 0 ] && tm_running; then - tmutil stopbackup 2>/dev/null; paused=1 - echo " wstrzymano Time Machine, zeby bufor nie rosl bez odbioru" - fi - sleep 300; continue - fi - - if [ "$paused" -eq 0 ]; then - if [ "$c" -ge "$HIGH_GB" ] || [ "$f" -le "$FREE_MIN_GB" ]; then - tmutil stopbackup 2>/dev/null - paused=1 - echo "PAUZA: bufor ${c} GB, wolne ${f} GB, postep ${p}% - czekam na wysylke" - fi - else - if [ "$c" -le "$LOW_GB" ]; then - tmutil startbackup 2>/dev/null - paused=0 - echo "WZNOWIENIE: bufor ${c} GB, wolne ${f} GB, postep ${p}%" - fi - fi - - if [ "$paused" -eq 0 ] && ! tm_running; then - echo "KONIEC: Time Machine zakonczyl. Bufor ${c} GB, postep ${p}%" - echo "Czekam na oproznienie bufora..." - while ! cm_vfs_quiet; do sleep 30; done - echo "Bufor opróżniony - wszystko na Google Drive." - exit 0 - fi - - sleep "$INTERVAL" -done diff --git a/gdrive/cm-env.sh b/gdrive/cm-env.sh deleted file mode 100755 index 05461f3..0000000 --- a/gdrive/cm-env.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash -# Wspolna konfiguracja dla warstwy Google Drive. -# Kazda wartosc da sie nadpisac zmienna srodowiskowa przed uruchomieniem skryptu. - -# rclone z Homebrew jest zbudowany BEZ obslugi mount i odmawia z komunikatem -# "rclone mount is not supported on MacOS when rclone is installed via Homebrew". -# Potrzebna jest oficjalna binarka z rclone.org - patrz install-rclone.sh. -CM_RCLONE="${CM_RCLONE:-$HOME/.cloudmachine/bin/rclone}" - -CM_REMOTE="${CM_REMOTE:-gdrive}" # nazwa remote'a w rclone.conf -CM_REMOTE_PATH="${CM_REMOTE_PATH:-CloudMachine/mac-studio}" -CM_ROOT="${CM_ROOT:-$HOME/.cloudmachine}" -CM_MOUNT="$CM_ROOT/drive" # tu widac zawartosc Drive'a -CM_CACHE="$CM_ROOT/cache" # bufor zapisu -CM_LOG="$CM_ROOT/rclone.log" -CM_CACHE_SIZE="${CM_CACHE_SIZE:-100G}" - -CM_IMAGE_NAME="${CM_IMAGE_NAME:-mac-studio}" -CM_IMAGE_SIZE="${CM_IMAGE_SIZE:-1500g}" # rozmiar deklarowany, obraz jest rzadki -CM_VOLNAME="${CM_VOLNAME:-CloudMachine}" -CM_IMAGE="$CM_MOUNT/$CM_IMAGE_NAME.sparsebundle" -# Punkt montowania celu. Domyslnie /Volumes, bo to sciezka, ktora Time Machine -# na pewno przyjmuje. -# -# Cena tego wyboru, zmierzona w poc-pullplug.sh: po nieczystym odpieciu katalog -# /Volumes/ zostaje jako osierocony i blokuje ponowne podpiecie -# komunikatem "no mountable file systems". Nalezy do uzytkownika, ale leży -# w /Volumes nalezacym do roota, wiec rmdir odmawia - agent launchd dzialajacy -# jako uzytkownik nie posprzata po sobie sam. -# -# Alternatywa: CM_TARGET="$CM_ROOT/target" - katalog w calosci nasz, wiec -# samonaprawialny. NIEPRZETESTOWANE: nie wiadomo, czy tmutil setdestination -# przyjmie cel spoza /Volumes. Do sprawdzenia, gdy bedzie dostep do sudo. -CM_TARGET="${CM_TARGET:-/Volumes/$CM_VOLNAME}" - -# 32 MB na pasmo, podane w sektorach po 512 B. Wybrane pomiarem, nie z palca - -# patrz tabela w README i skrypt poc-amplification.sh. -# -# Dwie sily ciagna w przeciwne strony. Google Drive przepuszcza okolo 2 operacji -# na plik na sekunde, wiec male pasma wydluzaja pierwsza wysylke: 200 GB przy -# 8 MB to ~25 600 plikow i ~3,6 h samych operacji, podczas gdy lacze zrobiloby -# to w ~1,3 h. Z drugiej strony kazda zmiana brudzi cale pasmo, wiec duze pasma -# mnoza transfer przy kazdym przyroscie - zmierzone 768 MB przy 64 MB wobec -# 384 MB przy 8 MB na te same 300 MB realnej zmiany. -# -# 32 MB to punkt, w ktorym pierwsza wysylka przestaje byc ograniczona tempem -# operacji (6 400 plikow, ~0,9 h) i zaczyna byc ograniczona pasmem lacza. -# Powyzej tego progu placi sie wiekszym transferem za korzysc, ktorej juz nie ma. -# -# Zmiana dziala tylko przy tworzeniu obrazu - pozniej wymaga backupu od zera. -CM_BAND_SECTORS="${CM_BAND_SECTORS:-65536}" - -cm_is_mounted() { - /sbin/mount | grep -q " on $CM_MOUNT " -} - -cm_is_attached() { - [ -d "$CM_TARGET" ] && /sbin/mount | grep -q " on $CM_TARGET " -} - -# Zwraca urzadzenia /dev/diskN podpiete pod wskazany obraz. -# -# Po wymuszonym odpieciu urzadzenie potrafi zostac w systemie jako zombie. -# Ponowne podpiecie obrazu konczy sie wtedy bledem "no mountable file systems", -# albo - gorzej - zwraca martwy uchwyt, na ktorym fsck_apfs melduje -# "failed to read container superblock" i wyglada to jak utrata backupu. -cm_devices_for_image() { - hdiutil info | awk -v img="$1" ' - /^image-path/ { sub(/^image-path[ \t]*:[ \t]*/, ""); path = $0; next } - /^\/dev\/disk[0-9]+[ \t]/ { if (path == img) print $1 } - ' | sort -u -} - -cm_purge_stale_devices() { - local img="$1" d - for d in $(cm_devices_for_image "$img"); do - hdiutil detach "$d" -force -quiet 2>/dev/null || true - done -} - -CM_RC_URL="${CM_RC_URL:-127.0.0.1:5572}" - -# Czy rclone skonczyl wysylke. Pusta kolejka to warunek, pod ktorym operacje -# hdiutil na tym montowaniu sa stabilne - patrz nizej. -cm_vfs_quiet() { - local out - out="$("$CM_RCLONE" rc --url "$CM_RC_URL" vfs/stats 2>/dev/null)" || return 1 - local busy - busy="$(printf '%s' "$out" | awk -F: '/uploadsInProgress|uploadsQueued/ {gsub(/[^0-9]/,"",$2); s+=$2} END {print s+0}')" - [ "$busy" = "0" ] -} - -# Czeka, az wysylka ucichnie. Bez tego hdiutil bywa odrzucany. -cm_wait_quiet() { - local limit="${1:-120}" i=0 - while [ "$i" -lt "$limit" ]; do - cm_vfs_quiet && return 0 - i=$((i + 1)); sleep 1 - done - return 1 -} - -# Ponawia polecenie. Operacje hdiutil na montowaniu FUSE-T zawodza przejsciowo -# bledem "RPC version wrong", gdy rclone akurat wysyla albo kasuje dane. -# Zmierzone: przy pustej kolejce 5 prob na 5 udanych, przy zajetej - losowo. -# To nie jest blad zalezny od rozmiaru ani od danych, tylko od chwili. -cm_retry() { - local tries="$1"; shift - local i=1 - while :; do - if "$@"; then return 0; fi - [ "$i" -ge "$tries" ] && return 1 - echo " proba $i nieudana, ponawiam za 5 s..." >&2 - i=$((i + 1)); sleep 5 - cm_wait_quiet 60 || true - done -} diff --git a/gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template b/gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template deleted file mode 100644 index 0d0a2e8..0000000 --- a/gdrive/com.renacode.cloudmachine.gdrive-attach.plist.template +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - Label - com.renacode.cloudmachine.gdrive-attach - - ProgramArguments - - __REPO__/gdrive/attach-image.sh - - - RunAtLoad - - - - StartInterval - 900 - - EnvironmentVariables - - PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - - - StandardOutPath - __HOME__/.cloudmachine/launchd-attach.out.log - StandardErrorPath - __HOME__/.cloudmachine/launchd-attach.err.log - - diff --git a/gdrive/com.renacode.cloudmachine.gdrive.plist.template b/gdrive/com.renacode.cloudmachine.gdrive.plist.template deleted file mode 100644 index 910d842..0000000 --- a/gdrive/com.renacode.cloudmachine.gdrive.plist.template +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - Label - com.renacode.cloudmachine.gdrive - - ProgramArguments - - __REPO__/gdrive/mount-drive.sh - - - RunAtLoad - - - KeepAlive - - - ThrottleInterval - 30 - - EnvironmentVariables - - PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - - - StandardOutPath - __HOME__/.cloudmachine/launchd-mount.out.log - StandardErrorPath - __HOME__/.cloudmachine/launchd-mount.err.log - - diff --git a/gdrive/create-image.sh b/gdrive/create-image.sh deleted file mode 100755 index 3e4854e..0000000 --- a/gdrive/create-image.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Tworzy obraz sparsebundle na zamontowanym Drive. Uruchamiane raz. -# -# Time Machine dostaje do reki zwykly wolumen APFS i nie wie, ze pasma obrazu -# leza w chmurze. Dzieki temu w sciezce zapisu nie ma sieciowego systemu plikow - -# odpada cala klasa awarii, ktore trapia Time Machine po SMB. - -set -euo pipefail -source "$(dirname "$0")/cm-env.sh" - -if ! cm_is_mounted; then - echo "Drive nie jest zamontowany w $CM_MOUNT - najpierw mount-drive.sh" >&2 - exit 1 -fi - -if [ -d "$CM_IMAGE" ]; then - echo "Obraz juz istnieje: $CM_IMAGE" >&2 - echo "Usun go recznie, jesli chcesz zaczac od zera - to kasuje caly backup." >&2 - exit 1 -fi - -echo "Tworze $CM_IMAGE" -echo " rozmiar deklarowany: $CM_IMAGE_SIZE (rzadki - zajmuje tyle, ile zapisane)" -echo " pasmo: $((CM_BAND_SECTORS * 512 / 1024 / 1024)) MB" - -echo "Czekam, az wysylka ucichnie..." -cm_wait_quiet 180 || echo " (kolejka nadal zajeta, probuje mimo to)" - -cm_create() { - hdiutil create \ - -type SPARSEBUNDLE \ - -size "$CM_IMAGE_SIZE" \ - -fs "Case-sensitive APFS" \ - -volname "$CM_VOLNAME" \ - -imagekey "sparse-band-size=$CM_BAND_SECTORS" \ - "$CM_IMAGE" -} - -if ! cm_retry 5 cm_create; then - echo "Nie udalo sie utworzyc obrazu po 5 probach." >&2 - exit 1 -fi - -echo -echo "Gotowe. Nastepny krok: attach-image.sh, potem" -echo " sudo tmutil setdestination $CM_TARGET" diff --git a/gdrive/install-rclone.sh b/gdrive/install-rclone.sh deleted file mode 100755 index 4586cab..0000000 --- a/gdrive/install-rclone.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# Instaluje oficjalna binarke rclone obok tej z Homebrew. -# -# Powod: rclone z Homebrew jest zbudowany bez obslugi FUSE i przy probie -# montowania odmawia wprost: -# "rclone mount is not supported on MacOS when rclone is installed via Homebrew" -# Binarka z rclone.org dochodzi do prawdziwego montowania. -# -# Nie ruszamy instalacji Homebrew - ta binarka siedzi we wlasnym katalogu -# i uzywaja jej tylko skrypty CloudMachine, przez CM_RCLONE. - -set -euo pipefail -source "$(dirname "$0")/cm-env.sh" - -DEST="$(dirname "$CM_RCLONE")" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -VER="$(curl -fsSL https://downloads.rclone.org/version.txt | awk '{print $2}')" -case "$(uname -m)" in - arm64) ARCH="osx-arm64" ;; - x86_64) ARCH="osx-amd64" ;; - *) echo "Nieznana architektura: $(uname -m)" >&2; exit 1 ;; -esac -ZIP="rclone-${VER}-${ARCH}.zip" - -echo "Pobieram $ZIP" -curl -fsSL -o "$TMP/$ZIP" "https://downloads.rclone.org/${VER}/${ZIP}" -curl -fsSL -o "$TMP/SHA256SUMS" "https://downloads.rclone.org/${VER}/SHA256SUMS" - -EXPECT="$(grep " $ZIP\$\|$ZIP\$" "$TMP/SHA256SUMS" | awk '{print $1}' | head -1)" -ACTUAL="$(shasum -a 256 "$TMP/$ZIP" | awk '{print $1}')" -if [ -z "$EXPECT" ] || [ "$EXPECT" != "$ACTUAL" ]; then - echo "Suma SHA256 sie nie zgadza - nie instaluje." >&2 - echo " oczekiwana: ${EXPECT:-brak wpisu}" >&2 - echo " policzona : $ACTUAL" >&2 - exit 1 -fi -echo "Suma SHA256 zgodna." - -mkdir -p "$DEST" -unzip -oq "$TMP/$ZIP" -d "$TMP" -cp "$TMP/rclone-${VER}-${ARCH}/rclone" "$CM_RCLONE" -chmod +x "$CM_RCLONE" - -echo "Zainstalowane: $CM_RCLONE" -"$CM_RCLONE" version | head -2 diff --git a/gdrive/mount-drive.sh b/gdrive/mount-drive.sh deleted file mode 100755 index 4e3aadb..0000000 --- a/gdrive/mount-drive.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# Montuje Google Drive jako wolumen z buforem zapisu na dysku lokalnym. -# Zapis konczy sie w momencie trafienia do bufora; wysylka do Drive'a idzie w tle, -# wiec zerwanie lacza nie przerywa Time Machine, tylko wstrzymuje drenaz. -# -# Proces zostaje na pierwszym planie - zarzadza nim launchd (KeepAlive). - -set -euo pipefail -source "$(dirname "$0")/cm-env.sh" - -mkdir -p "$CM_MOUNT" "$CM_CACHE" - -if cm_is_mounted; then - echo "Juz zamontowane: $CM_MOUNT" - exit 0 -fi - -if [ ! -x "$CM_RCLONE" ]; then - echo "Brak rclone z obsluga mount w $CM_RCLONE - uruchom install-rclone.sh" >&2 - exit 1 -fi - -# Bez FUSE rclone konczy sie natychmiast bledem "cgofuse: cannot find FUSE". -# Agent launchd ma KeepAlive, wiec probowalby w kolko co 30 s i zalewal log. -# Ten projekt ma juz za soba incydent logu na 3,3 GiB - lepiej stanac od razu. -# Uwaga na pulapke: `ls a b c` zwraca blad, gdy BRAKUJE KTOREJKOLWIEK sciezki, -# a nie gdy brakuje wszystkich. Pierwsza wersja tej kontroli odmawiala startu -# przy poprawnie zainstalowanym FUSE-T. Sprawdzamy wiec po kolei. -cm_have_fuse=0 -for lib in /usr/local/lib/libfuse-t.dylib /usr/local/lib/libfuse.2.dylib \ - /usr/local/lib/libfuse.dylib /Library/Filesystems/fuse-t.fs; do - [ -e "$lib" ] && { cm_have_fuse=1; break; } -done -if [ "$cm_have_fuse" -eq 0 ]; then - echo "Nie znalazlem FUSE. Zainstaluj:" >&2 - echo " brew install macos-fuse-t/homebrew-cask/fuse-t" >&2 - exit 1 -fi - -# rclone nie rotuje wlasnego logu. Przy dzialaniu ciaglym trzeba go przyciac -# samemu, bo inaczej rosnie bez konca. -if [ -f "$CM_LOG" ] && [ "$(stat -f%z "$CM_LOG")" -gt 104857600 ]; then - mv -f "$CM_LOG" "$CM_LOG.1" -fi - -# Bufor trzyma kopie danych backupu. Gdyby Time Machine go objal, backupowalby -# wlasny backup i rosl bez konca. -tmutil addexclusion "$CM_ROOT" 2>/dev/null || true - -exec "$CM_RCLONE" mount "${CM_REMOTE}:${CM_REMOTE_PATH}" "$CM_MOUNT" \ - --vfs-cache-mode full \ - --vfs-cache-max-size "$CM_CACHE_SIZE" \ - --vfs-cache-max-age 9999h \ - --vfs-write-back 30s \ - --vfs-cache-poll-interval 1m \ - --cache-dir "$CM_CACHE" \ - --dir-cache-time 5m \ - --attr-timeout 5m \ - --transfers 8 \ - --drive-chunk-size 64M \ - --drive-use-trash=false \ - --drive-stop-on-upload-limit \ - --volname "$CM_REMOTE" \ - --rc --rc-addr 127.0.0.1:5572 --rc-no-auth \ - --log-file "$CM_LOG" \ - --log-level INFO diff --git a/gdrive/poc-common.sh b/gdrive/poc-common.sh new file mode 100755 index 0000000..5416ad6 --- /dev/null +++ b/gdrive/poc-common.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Wspolne czesci harnessow pomiarowych. +# +# Same harnessy mierza zachowanie hdiutil i FUSE-T, a nie nasz kod - dlatego +# zostaly, gdy reszta skryptow trafila do aplikacji (CloudMachineCore). +# Nie sa czescia dzialajacego systemu; uruchamia sie je recznie, gdy trzeba +# cos zmierzyc albo potwierdzic regresje. + +CM_AGENT="${CM_AGENT:-/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent}" + +# Urzadzenia /dev/diskN podpiete pod wskazany obraz. +# +# Po wymuszonym odpieciu urzadzenie potrafi zostac w systemie jako zombie. +# Podpiecie zwraca wtedy martwy uchwyt, na ktorym fsck_apfs melduje +# "failed to read container superblock" z UUID z samych zer - wyglada to jak +# skasowany backup, a jest tylko nieczytelnym urzadzeniem. +cm_devices_for_image() { + hdiutil info | awk -v img="$1" ' + /^image-path/ { sub(/^image-path[ \t]*:[ \t]*/, ""); path = $0; next } + /^\/dev\/disk[0-9]+[ \t]/ { if (path == img) print $1 } + ' | sort -u +} + +cm_purge_stale_devices() { + local d + for d in $(cm_devices_for_image "$1"); do + hdiutil detach "$d" -force -quiet 2>/dev/null || true + done +} diff --git a/gdrive/poc-launchd.sh b/gdrive/poc-launchd.sh deleted file mode 100755 index a39a77b..0000000 --- a/gdrive/poc-launchd.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/bin/bash -# Testuje orkiestracje launchd bez Google Drive i bez FUSE-T. -# -# W miejsce rclone podstawia zaslepke, ktora podpina lokalny obraz pod ta sama -# sciezke i zostaje na pierwszym planie - tak samo zachowuje sie prawdziwy -# `rclone mount`. Dzieki temu da sie sprawdzic to, co w tej ukladance jest -# niezalezne od chmury: -# - czy plisty w ogole sie laduja, -# - czy agent podpinajacy poprawnie czeka na bufor zamiast wyscigowac sie z nim, -# - czy KeepAlive podnosi bufor po padnieciu. -# -# Etykiety sa wlasne (poc-*), zeby nie kolidowaly z produkcyjnymi agentami. - -set -euo pipefail - -POC_ROOT="${POC_ROOT:-/tmp/cm-launchd}" -LABEL_MOUNT="com.renacode.cloudmachine.poc-mount" -LABEL_ATTACH="com.renacode.cloudmachine.poc-attach" -LA="$HOME/Library/LaunchAgents" -HERE="$(cd "$(dirname "$0")" && pwd)" -GUI="gui/$(id -u)" - -export CM_ROOT="$POC_ROOT/home" -export CM_VOLNAME="CloudMachineLaunchdPOC" -export CM_IMAGE_NAME="poc" -export CM_IMAGE_SIZE="20g" -source "$HERE/cm-env.sh" - -teardown() { - launchctl bootout "$GUI/$LABEL_ATTACH" 2>/dev/null || true - launchctl bootout "$GUI/$LABEL_MOUNT" 2>/dev/null || true - sleep 1 - rm -f "$LA/$LABEL_MOUNT.plist" "$LA/$LABEL_ATTACH.plist" - hdiutil detach "$CM_TARGET" -force -quiet 2>/dev/null || true - hdiutil detach "$CM_MOUNT" -force -quiet 2>/dev/null || true -} - -[ "${1:-}" = "clean" ] && { teardown; rm -rf "$POC_ROOT"; echo "Posprzatane."; exit 0; } -trap teardown EXIT - -step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } - -step "Piaskownica" -teardown; rm -rf "$POC_ROOT"; mkdir -p "$CM_MOUNT" "$CM_CACHE" -hdiutil create -type SPARSE -size 30g -fs APFS -volname LaunchdStandIn \ - "$POC_ROOT/stand-in.sparseimage" >/dev/null - -# Zaslepka rclone: podpina zastepnik i zostaje na pierwszym planie. -cat > "$POC_ROOT/stub-mount.sh" </dev/null || true -hdiutil attach "$POC_ROOT/stand-in.sparseimage" -mountpoint "\$CM_MOUNT" -nobrowse >/dev/null -echo "zaslepka: podpieta \$(date +%T)" -while cm_is_mounted; do sleep 2; done -echo "zaslepka: montowanie zniknelo, koncze" -STUB -chmod +x "$POC_ROOT/stub-mount.sh" -echo "gotowa" - -step "Generuje i laduje agenty" -sed -e "s|__REPO__/gdrive/mount-drive.sh|$POC_ROOT/stub-mount.sh|" \ - -e "s|__HOME__|$POC_ROOT|g" \ - -e "s|$LABEL_MOUNT|$LABEL_MOUNT|" \ - -e "s|com.renacode.cloudmachine.gdrive|$LABEL_MOUNT|" \ - "$HERE/com.renacode.cloudmachine.gdrive.plist.template" > "$LA/$LABEL_MOUNT.plist" -sed -e "s|__REPO__|$HERE/..|" -e "s|__HOME__|$POC_ROOT|g" \ - -e "s|com.renacode.cloudmachine.gdrive-attach|$LABEL_ATTACH|" \ - "$HERE/com.renacode.cloudmachine.gdrive-attach.plist.template" > "$LA/$LABEL_ATTACH.plist" - -# Agent podpinajacy musi dostac te sama piaskownice. -/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:CM_ROOT string $CM_ROOT" \ - -c "Add :EnvironmentVariables:CM_VOLNAME string $CM_VOLNAME" \ - -c "Add :EnvironmentVariables:CM_IMAGE_NAME string $CM_IMAGE_NAME" \ - "$LA/$LABEL_ATTACH.plist" >/dev/null - -plutil -lint "$LA/$LABEL_MOUNT.plist" && plutil -lint "$LA/$LABEL_ATTACH.plist" -mkdir -p "$POC_ROOT/.cloudmachine" - -launchctl bootstrap "$GUI" "$LA/$LABEL_MOUNT.plist" -echo "zaladowany: $LABEL_MOUNT" - -step "Czy bufor wstal" -for _ in $(seq 1 15); do cm_is_mounted && break; sleep 1; done -cm_is_mounted && echo "TAK - $CM_MOUNT" || { echo "NIE"; exit 1; } - -step "Tworze obraz i laduje agenta podpinajacego" -"$HERE/create-image.sh" >/dev/null -launchctl bootstrap "$GUI" "$LA/$LABEL_ATTACH.plist" -for _ in $(seq 1 20); do cm_is_attached && break; sleep 1; done -cm_is_attached && echo "podpiete: $CM_TARGET" || { echo "NIE podpiete"; cat "$POC_ROOT/.cloudmachine/launchd-attach.err.log" 2>/dev/null; exit 1; } - -step "KeepAlive: zabijam bufor i patrze, czy wroci" -PID=$(launchctl print "$GUI/$LABEL_MOUNT" 2>/dev/null | awk '/pid = / {print $3}') -echo "pid zaslepki: ${PID:-brak}" -hdiutil detach "$CM_TARGET" -force -quiet 2>/dev/null || true -[ -n "${PID:-}" ] && kill -9 "$PID" 2>/dev/null || true -hdiutil detach "$CM_MOUNT" -force -quiet 2>/dev/null || true -echo "czekam na restart (ThrottleInterval 30 s)..." -OK=0 -for _ in $(seq 1 50); do cm_is_mounted && { OK=1; break; }; sleep 2; done -[ "$OK" = 1 ] && echo "WROCIL - KeepAlive dziala" || echo "NIE WROCIL" - -step "Wynik" -[ "$OK" = 1 ] && echo "Orkiestracja launchd dziala." || { echo "Orkiestracja zawodzi."; exit 1; } diff --git a/gdrive/poc-local.sh b/gdrive/poc-local.sh deleted file mode 100755 index f6cfc08..0000000 --- a/gdrive/poc-local.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash -# POC bez Google Drive i bez FUSE-T. -# -# W miejsce montowania rclone podstawia zwykly obraz dyskowy przypiety pod -# ta sama sciezka. Dzieki temu create-image.sh i attach-image.sh widza prawdziwy -# punkt montowania i nie trzeba ich modyfikowac na czas testu. -# -# Co to dowodzi: ze obraz z pasmami 64 MB powstaje, podpina sie, przyjmuje dane, -# rozklada je na pasma zgodnie z zalozeniem i przezywa odpiecie z ponownym -# podpieciem. Czego NIE dowodzi: zachowania FUSE-T, limitow Drive'a ani tego, -# czy Time Machine przyjmie taki cel - to wymaga sudo i konta Google. - -set -euo pipefail - -POC_ROOT="${POC_ROOT:-/tmp/cm-poc}" -OUTER="$POC_ROOT/stand-in-drive.sparseimage" -DATA_MB="${DATA_MB:-4096}" - -export CM_ROOT="$POC_ROOT/home" -export CM_VOLNAME="${CM_VOLNAME:-CloudMachinePOC}" -export CM_IMAGE_NAME="${CM_IMAGE_NAME:-poc}" -export CM_IMAGE_SIZE="${CM_IMAGE_SIZE:-200g}" - -HERE="$(cd "$(dirname "$0")" && pwd)" -source "$HERE/cm-env.sh" - -step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } - -cleanup() { - hdiutil detach "$CM_TARGET" -quiet 2>/dev/null || true - hdiutil detach "$CM_MOUNT" -quiet 2>/dev/null || true -} - -case "${1:-run}" in - clean) - cleanup - rm -rf "$POC_ROOT" - echo "Posprzatane." - exit 0 - ;; -esac - -step "Przygotowanie piaskownicy" -cleanup -rm -rf "$POC_ROOT" -mkdir -p "$CM_MOUNT" "$CM_CACHE" -hdiutil create -type SPARSE -size 60g -fs APFS -volname StandInDrive "$OUTER" >/dev/null -hdiutil attach "$OUTER" -mountpoint "$CM_MOUNT" -nobrowse >/dev/null -cm_is_mounted && echo "Stand-in zamontowany pod $CM_MOUNT" - -step "create-image.sh" -"$HERE/create-image.sh" - -step "Weryfikacja rozmiaru pasma" -BAND=$(plutil -extract band-size raw "$CM_IMAGE/Info.plist") -echo "band-size = $BAND B = $((BAND / 1024 / 1024)) MB" -[ "$BAND" -eq $((CM_BAND_SECTORS * 512)) ] || { echo "BLAD: nie zgadza sie z CM_BAND_SECTORS"; exit 1; } - -step "attach-image.sh" -"$HERE/attach-image.sh" -df -h "$CM_TARGET" | tail -1 - -step "Zapis ${DATA_MB} MB danych" -time dd if=/dev/urandom of="$CM_TARGET/dane.bin" bs=1m count="$DATA_MB" 2>&1 | tail -2 -sync - -step "Rozklad na pasma" -N=$(ls "$CM_IMAGE/bands" | wc -l | tr -d ' ') -TOTAL=$(du -sk "$CM_IMAGE" | awk '{print $1}') -echo "pasm: $N" -echo "obraz zajmuje: $((TOTAL / 1024)) MB na ${DATA_MB} MB danych" -echo "rozmiary pasm (unikalne):" -ls -l "$CM_IMAGE/bands" | awk 'NR>1{print $5}' | sort -n | uniq -c | tail -3 - -step "Odpiecie i ponowne podpiecie" -hdiutil detach "$CM_TARGET" -quiet -"$HERE/attach-image.sh" -SUM_BEFORE=$(ls -l "$CM_TARGET/dane.bin" | awk '{print $5}') -echo "plik po ponownym podpieciu: $SUM_BEFORE B" -[ "$SUM_BEFORE" -eq $((DATA_MB * 1024 * 1024)) ] || { echo "BLAD: rozmiar sie nie zgadza"; exit 1; } - -step "hdiutil verify" -hdiutil detach "$CM_TARGET" -quiet -hdiutil verify "$CM_IMAGE" 2>&1 | tail -3 - -step "Gotowe" -echo "Piaskownica: $POC_ROOT (sprzatanie: $0 clean)" diff --git a/gdrive/poc-pullplug.sh b/gdrive/poc-pullplug.sh index 952cea4..a308352 100755 --- a/gdrive/poc-pullplug.sh +++ b/gdrive/poc-pullplug.sh @@ -13,7 +13,7 @@ # przerwany, wznowi sie" a "backup stracony, zaczynamy od zera". set -euo pipefail -source "$(dirname "$0")/cm-env.sh" +source "$(dirname "$0")/poc-common.sh" POC_ROOT="${POC_ROOT:-/tmp/cm-plug}" BAND_MB="${BAND_MB:-64}" diff --git a/gdrive/setup-remote.sh b/gdrive/setup-remote.sh deleted file mode 100755 index cef6b6f..0000000 --- a/gdrive/setup-remote.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/bash -# Zaklada remote rclone wskazujacy na Google Drive. -# -# Klucze OAuth siedza w Keychainie macOS pod usluga "cloudmachine-gdrive", nie -# w pliku i nie w zmiennych srodowiskowych. Keychain jest zaszyfrowany na dysku -# i otwiera sie razem z logowaniem, wiec skrypt bierze je sam: -# -# ./gdrive/setup-remote.sh -# -# Zalozenie kluczy w Keychainie (raz), bez podawania ich w argumentach: -# security add-generic-password -a client_id -s cloudmachine-gdrive -w -U -# security add-generic-password -a client_secret -s cloudmachine-gdrive -w -U -# Bez -w z wartoscia `security` zapyta interaktywnie i nic nie trafi do -# historii powloki ani do `ps`. -# -# Zmienne CM_CLIENT_ID / CM_CLIENT_SECRET nadal dzialaja i maja pierwszenstwo, -# gdyby Keychain byl niedostepny. -# -# Skad wziac klucze: console.developers.google.com -> nowy projekt -> -# wlacz "Google Drive API" -> ekran zgody OAuth -> dane logowania -> -# "OAuth 2.0", typ aplikacji "Desktop". -# -# Po co wlasne: wspoldzielony client_id rclone jest wycofywany i przestanie -# dzialac w trakcie 2026 roku, a Google limituje tempo per client_id - na -# wspoldzielonym konkurujesz ze wszystkimi uzytkownikami rclone naraz. -# -# To repo jest publiczne - nic z sekretami tu nie wchodzi. - -set -euo pipefail -source "$(dirname "$0")/cm-env.sh" - -if [ ! -x "$CM_RCLONE" ]; then - echo "Brak rclone w $CM_RCLONE - uruchom install-rclone.sh" >&2 - exit 1 -fi - -CM_KEYCHAIN_SERVICE="${CM_KEYCHAIN_SERVICE:-cloudmachine-gdrive}" - -from_keychain() { - security find-generic-password -a "$1" -s "$CM_KEYCHAIN_SERVICE" -w 2>/dev/null -} - -CM_CLIENT_ID="${CM_CLIENT_ID:-$(from_keychain client_id)}" -CM_CLIENT_SECRET="${CM_CLIENT_SECRET:-$(from_keychain client_secret)}" - -if [ -z "$CM_CLIENT_ID" ] || [ -z "$CM_CLIENT_SECRET" ]; then - echo "Brak kluczy OAuth w Keychainie (usluga: $CM_KEYCHAIN_SERVICE)." >&2 - echo "Zaloz je - security zapyta o wartosc, nic nie trafi do historii:" >&2 - echo " security add-generic-password -a client_id -s $CM_KEYCHAIN_SERVICE -w -U" >&2 - echo " security add-generic-password -a client_secret -s $CM_KEYCHAIN_SERVICE -w -U" >&2 - exit 1 -fi - -echo "Klucze OAuth: z Keychaina (${CM_CLIENT_ID%%-*}...)" - -if "$CM_RCLONE" listremotes | grep -qx "${CM_REMOTE}:"; then - echo "Remote '${CM_REMOTE}' juz istnieje. Usun go recznie, jesli chcesz zalozyc od nowa:" >&2 - echo " $CM_RCLONE config delete ${CM_REMOTE}" >&2 - exit 1 -fi - -echo "Zakladam remote '${CM_REMOTE}'. Otworzy sie przegladarka do zalogowania." - -# Klucze przez srodowisko, nie przez argumenty: `rclone config create` z -# client_secret= w linii polecen wystawia sekret w `ps` na caly czas czekania -# na zatwierdzenie w przegladarce - czyli na minuty. -# -# Wyjscie idzie do /dev/null, bo rclone wypisuje na koniec gotowa sekcje -# konfiguracji razem z tokenem odswiezajacym. Token daje trwaly dostep do -# calego Dysku, wiec nie ma powodu, zeby ladowal w terminalu, logach czy -# historii sesji. Bledy nadal widac - one ida na stderr. -RCLONE_DRIVE_CLIENT_ID="$CM_CLIENT_ID" \ -RCLONE_DRIVE_CLIENT_SECRET="$CM_CLIENT_SECRET" \ -"$CM_RCLONE" config create "$CM_REMOTE" drive scope=drive >/dev/null - -echo -echo "Sprawdzam dostep i wolne miejsce:" -"$CM_RCLONE" about "${CM_REMOTE}:" - -echo -echo "Tworze katalog docelowy ${CM_REMOTE}:${CM_REMOTE_PATH}" -"$CM_RCLONE" mkdir "${CM_REMOTE}:${CM_REMOTE_PATH}" - -echo -echo "Gotowe. Nastepny krok: mount-drive.sh" diff --git a/gdrive/status.sh b/gdrive/status.sh deleted file mode 100755 index ff9a130..0000000 --- a/gdrive/status.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# Stan warstwy Drive: czy bufor stoi, ile w nim zalega, co robi Time Machine. -# Kluczowa liczba to "uploads in progress/queued" - jesli rosnie i nie spada, -# wysylka nie nadaza za zapisem i bufor sie zapelnia. - -source "$(dirname "$0")/cm-env.sh" - -printf '%-22s ' "Montowanie Drive:" -cm_is_mounted && echo "OK ($CM_MOUNT)" || echo "BRAK" - -printf '%-22s ' "Obraz podpiety:" -cm_is_attached && echo "OK ($CM_TARGET)" || echo "BRAK" - -printf '%-22s ' "Bufor na dysku:" -if [ -d "$CM_CACHE" ]; then - echo "$(du -shx "$CM_CACHE" 2>/dev/null | awk '{print $1}') z limitu $CM_CACHE_SIZE" -else - echo "brak katalogu" -fi - -echo -echo "--- kolejka wysylki (rclone vfs/stats) ---" -"$CM_RCLONE" rc --url 127.0.0.1:5572 vfs/stats 2>/dev/null \ - | grep -E '"(uploadsInProgress|uploadsQueued|files|erroredFiles|bytesUsed)"' \ - || echo "(rclone rc nieosiagalne - bufor nie dziala?)" - -echo -echo "--- Time Machine ---" -tmutil destinationinfo 2>/dev/null | grep -E "Name|Kind|Mount" || echo "(brak celu)" -tmutil status 2>/dev/null | grep -E "Running|Percent|BytesWritten|Phase" || true - -echo -echo "--- ostatnie bledy w logu rclone ---" -if [ -f "$CM_LOG" ]; then - grep -iE "error|failed|403|quota" "$CM_LOG" | tail -5 || echo "(brak)" -else - echo "(brak logu)" -fi diff --git a/gdrive/verify-image.sh b/gdrive/verify-image.sh deleted file mode 100755 index b22e4a5..0000000 --- a/gdrive/verify-image.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -# Sprawdza spojnosc obrazu backupu. -# -# Uwaga: na sparsebundle NIE dziala `hdiutil verify` - taki obraz nie ma sumy -# kontrolnej i narzedzie konczy komunikatem "has no checksum". Trzeba podpiac -# urzadzenie bez montowania i puscic na nim fsck_apfs. -# -# To jest test rozstrzygajacy dla calej architektury: po zerwaniu lacza -# w trakcie backupu i pelnym drenazu bufora obraz musi byc spojny. Jesli nie -# jest, asynchroniczna wysylka gubi dane i uklad trzeba zmienic. - -set -euo pipefail -source "$(dirname "$0")/cm-env.sh" - -IMAGE="${1:-$CM_IMAGE}" -FSCK=/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs - -if [ ! -d "$IMAGE" ]; then - echo "Brak obrazu: $IMAGE" >&2 - exit 1 -fi - -if cm_is_attached; then - echo "Obraz jest podpiety pod $CM_TARGET - odepnij go najpierw:" >&2 - echo " hdiutil detach $CM_TARGET" >&2 - exit 1 -fi - -echo "Podpinam bez montowania: $IMAGE" -ATTACH_OUT="$(hdiutil attach "$IMAGE" -nomount)" -DEV="$(echo "$ATTACH_OUT" | awk '/41504653/ {print $1; exit}')" - -if [ -z "$DEV" ]; then - echo "Nie znalazlem urzadzenia APFS w wyniku hdiutil:" >&2 - echo "$ATTACH_OUT" >&2 - hdiutil detach "$(echo "$ATTACH_OUT" | awk 'NR==1{print $1}')" -quiet 2>/dev/null || true - exit 1 -fi - -cleanup() { hdiutil detach "$DEV" -quiet 2>/dev/null || true; } -trap cleanup EXIT - -echo "Urzadzenie: $DEV" -echo - -if "$FSCK" -n "$DEV"; then - echo - echo "SPOJNY" -else - echo - echo "NIESPOJNY - fsck_apfs zglosil problemy" >&2 - exit 1 -fi diff --git a/launchd/com.renacode.cloudmachine.archive-watchdog.plist.template b/launchd/com.renacode.cloudmachine.archive-watchdog.plist.template deleted file mode 100644 index a837ab2..0000000 --- a/launchd/com.renacode.cloudmachine.archive-watchdog.plist.template +++ /dev/null @@ -1,28 +0,0 @@ - - - - - Label - com.renacode.cloudmachine.archive-watchdog - ProgramArguments - - __CM_AGENT_BIN__ - archive-watchdog - - RunAtLoad - - - StartInterval - 3600 - StandardOutPath - __CM_LOG_DIR__/launchd-archive-watchdog.out.log - StandardErrorPath - __CM_LOG_DIR__/launchd-archive-watchdog.err.log - - diff --git a/launchd/com.renacode.cloudmachine.buffer-guard.plist.template b/launchd/com.renacode.cloudmachine.buffer-guard.plist.template new file mode 100644 index 0000000..36e6feb --- /dev/null +++ b/launchd/com.renacode.cloudmachine.buffer-guard.plist.template @@ -0,0 +1,28 @@ + + + + + Label + com.renacode.cloudmachine.buffer-guard + ProgramArguments + + __CM_AGENT_BIN__ + buffer-guard + + RunAtLoad + + + KeepAlive + + ThrottleInterval + 60 + StandardOutPath + __CM_LOG_DIR__/launchd-buffer-guard.out.log + StandardErrorPath + __CM_LOG_DIR__/launchd-buffer-guard.err.log + + diff --git a/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template b/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template new file mode 100644 index 0000000..97370b2 --- /dev/null +++ b/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template @@ -0,0 +1,29 @@ + + + + + Label + com.renacode.cloudmachine.gdrive-attach + ProgramArguments + + __CM_AGENT_BIN__ + attach-image + + RunAtLoad + + + StartInterval + 900 + StandardOutPath + __CM_LOG_DIR__/launchd-gdrive-attach.out.log + StandardErrorPath + __CM_LOG_DIR__/launchd-gdrive-attach.err.log + + diff --git a/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template b/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template new file mode 100644 index 0000000..1a39f8f --- /dev/null +++ b/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template @@ -0,0 +1,32 @@ + + + + + Label + com.renacode.cloudmachine.gdrive-buffer + ProgramArguments + + __CM_AGENT_BIN__ + mount-drive + + RunAtLoad + + + KeepAlive + + ThrottleInterval + 30 + StandardOutPath + __CM_LOG_DIR__/launchd-gdrive-buffer.out.log + StandardErrorPath + __CM_LOG_DIR__/launchd-gdrive-buffer.err.log + + diff --git a/launchd/com.renacode.cloudmachine.verify-watchdog.plist.template b/launchd/com.renacode.cloudmachine.verify-watchdog.plist.template deleted file mode 100644 index 001c89e..0000000 --- a/launchd/com.renacode.cloudmachine.verify-watchdog.plist.template +++ /dev/null @@ -1,28 +0,0 @@ - - - - - Label - com.renacode.cloudmachine.verify-watchdog - ProgramArguments - - __CM_AGENT_BIN__ - verify-watchdog - - RunAtLoad - - - StartInterval - 86400 - StandardOutPath - __CM_LOG_DIR__/launchd-verify-watchdog.out.log - StandardErrorPath - __CM_LOG_DIR__/launchd-verify-watchdog.err.log - - diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 2f84dd6..182ef8d 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -121,16 +121,9 @@ struct BufferGuard: AsyncParsableCommand { CMLogger.log( "Dozorca bufora: prog \(highGB) GB / wznowienie \(lowGB) GB / min. wolnego \(minFreeGB) GB") + // Bez konca: dozorca ma przezyc kazdy backup, nie tylko pierwszy. while true { - let snapshot = await guardService.step() - if snapshot.state == .finished { - CMLogger.log("Czekam na oproznienie bufora...") - while !(await DriveBufferService.queueStats()?.isQuiet ?? false) { - try? await Task.sleep(nanoseconds: 30_000_000_000) - } - CMLogger.log("Bufor oprozniony - wszystko na Google Drive.") - return - } + await guardService.step() try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) } } diff --git a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift index 1b6a640..fa93132 100644 --- a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift +++ b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift @@ -32,10 +32,17 @@ public actor BufferGuardService { } public enum State: String, Sendable { + /// Nadzorujemy trwajacy backup. case running case pausedForBuffer case pausedForQuota - case finished + /// Backup nie trwa - czuwamy do nastepnego. + /// + /// Dozorca NIE konczy pracy po skonczonym backupie. Dziala pod launchd + /// z KeepAlive, wiec wyjscie oznaczaloby natychmiastowy restart, a przy + /// niedzialajacym Time Machine - ciasna petle restartow ograniczana tylko + /// przez ThrottleInterval. + case idle } public struct Snapshot: Sendable { @@ -47,7 +54,10 @@ public actor BufferGuardService { } private let thresholds: Thresholds - private var state: State = .running + private var state: State = .idle + /// Czy od ostatniego przejscia w czuwanie widzielismy dzialajacy backup - + /// zeby zameldowac zakonczenie raz, a nie przy kazdym tyknieciu. + private var sawBackupRunning = false public init(thresholds: Thresholds = Thresholds()) { self.thresholds = thresholds @@ -97,26 +107,34 @@ public actor BufferGuardService { } switch state { + case .idle: + if running { + CMLogger.log("Backup ruszyl - nadzoruje bufor") + sawBackupRunning = true + state = .running + } + case .running: if buffer >= thresholds.highGB || free <= thresholds.minFreeGB { - CMLogger.log( - "PAUZA: bufor \(buffer) GB, wolne \(free) GB - czekam na wysylke") + CMLogger.log("PAUZA: bufor \(buffer) GB, wolne \(free) GB - czekam na wysylke") await stopBackup() state = .pausedForBuffer } else if !running { - CMLogger.log("Time Machine zakonczyl. Bufor \(buffer) GB") - state = .finished + if sawBackupRunning { + CMLogger.log("Time Machine zakonczyl. Bufor \(buffer) GB") + sawBackupRunning = false + } + state = .idle } case .pausedForBuffer, .pausedForQuota: + // Wznawiamy dopiero, gdy wysylka faktycznie nadgonila - inaczej + // wpadlibysmy w oscylacje start/stop przy progu. if buffer <= thresholds.lowGB { CMLogger.log("WZNOWIENIE: bufor \(buffer) GB, wolne \(free) GB") await startBackup() state = .running } - - case .finished: - break } return Snapshot( From 2e7606ced055572eaad0d687fe4d383597f4f685 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 13:44:59 +0200 Subject: [PATCH 08/18] Delete the old architecture and rebuild the UI around the real one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven services went: archive and verify watchdogs, BackupVerifier, CloudArchiveService, LocalBackupService, NetworkShareService, CooldownGate. They served a design with a local Time Machine volume archived to Drive and a disk shared over SMB. Neither exists now — the backup writes straight through the buffer to Drive. The UI described that dead design too, so it is rewritten rather than patched. The dashboard answers one question on open — are my files safe — and shows detail only when the answer is no. The number it leads with is the upload queue, not the buffer size: until the queue returns to zero between backups, the data is still only on this Mac. Steps that genuinely need a terminal (OAuth, `tmutil setdestination`) are shown as copyable commands instead of buttons that would fail. The controller holds no backup logic. Everything it knows about Drive, the image and the buffer comes from CloudMachineCore, so the CLI and the GUI cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineAgent/CloudMachineAgent.swift | 4 - .../CloudMachineAgent/SetupCommands.swift | 33 +- .../CloudMachineAgent/WatchdogCommands.swift | 25 - .../CloudMachineApp/Models/AppStatus.swift | 94 +-- .../Services/CloudMachineController.swift | 543 ++++------------- .../CloudMachineApp/Views/DashboardView.swift | 304 +++++----- .../Views/MenuBarContentView.swift | 178 ++---- .../Views/SetupWizardView.swift | 249 -------- .../CloudMachineApp/Views/StatusView.swift | 551 ------------------ .../ArchiveWatchdogService.swift | 50 -- .../CloudMachineCore/BackupVerifier.swift | 102 ---- .../CloudArchiveService.swift | 285 --------- .../CloudMachineCore/CooldownGate.swift | 28 - .../CloudMachineCore/LocalBackupService.swift | 71 --- .../NetworkShareService.swift | 108 ---- .../VerifyWatchdogService.swift | 132 ----- .../CooldownGateTests.swift | 47 -- .../NetworkShareServiceTests.swift | 20 - 18 files changed, 383 insertions(+), 2441 deletions(-) delete mode 100644 mac-app/Sources/CloudMachineAgent/WatchdogCommands.swift delete mode 100644 mac-app/Sources/CloudMachineApp/Views/SetupWizardView.swift delete mode 100644 mac-app/Sources/CloudMachineApp/Views/StatusView.swift delete mode 100644 mac-app/Sources/CloudMachineCore/ArchiveWatchdogService.swift delete mode 100644 mac-app/Sources/CloudMachineCore/BackupVerifier.swift delete mode 100644 mac-app/Sources/CloudMachineCore/CloudArchiveService.swift delete mode 100644 mac-app/Sources/CloudMachineCore/CooldownGate.swift delete mode 100644 mac-app/Sources/CloudMachineCore/LocalBackupService.swift delete mode 100644 mac-app/Sources/CloudMachineCore/NetworkShareService.swift delete mode 100644 mac-app/Sources/CloudMachineCore/VerifyWatchdogService.swift delete mode 100644 mac-app/Tests/CloudMachineAppTests/CooldownGateTests.swift delete mode 100644 mac-app/Tests/CloudMachineAppTests/NetworkShareServiceTests.swift diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index 793713e..5363636 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -9,10 +9,6 @@ struct CloudMachineAgent: AsyncParsableCommand { abstract: "CloudMachine - weryfikacja, konfiguracja Google Drive i instalacja. Wolane przez launchd na harmonogramie, albo recznie z Terminala.", subcommands: [ - VerifyWatchdog.self, - VerifyBackup.self, - ArchiveWatchdog.self, - ArchiveNow.self, InstallLaunchd.self, ConfigureRemote.self, InstallDependencies.self, diff --git a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift index 1b2e443..dbe1e47 100644 --- a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift @@ -2,39 +2,10 @@ import ArgumentParser import CloudMachineCore import Foundation -struct VerifyBackup: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "verify-backup", - abstract: - "Pelna weryfikacja spojnosci najnowszego backupu (destinationinfo, listbackups, verifychecksums)." - ) - - func run() async throws { - let result = await BackupVerifier.runFullCheck() - print(result.message) - if !result.succeeded { throw ExitCode.failure } - } -} - -struct ArchiveNow: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "archive-now", - abstract: - "Recznie kopiuje wszystkie jeszcze nie zarchiwizowane lokalne backupy na Google Drive." - ) - - func run() async throws { - let (config, key) = await CLIContext.load() - let result = await CloudArchiveService.archivePending(config: config, machineKey: key) - print(result.message) - if !result.succeeded { throw ExitCode.failure } - } -} - struct InstallLaunchd: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install-launchd", - abstract: "Generuje i instaluje agentow launchd (verify-watchdog, archive-watchdog)." + abstract: "Generuje i instaluje agentow launchd (bufor Drive, podpiecie obrazu, dozorca bufora)." ) func run() async throws { @@ -61,7 +32,7 @@ struct ConfigureRemote: AsyncParsableCommand { struct InstallDependencies: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install-dependencies", - abstract: "Instaluje rclone przez Homebrew (zaklada, ze Homebrew jest juz obecny).") + abstract: "Instaluje rclone przez Homebrew - UWAGA: ta wersja NIE umie montowac, patrz install-rclone.") func run() async throws { let result = await DependencyInstaller.installRclone() diff --git a/mac-app/Sources/CloudMachineAgent/WatchdogCommands.swift b/mac-app/Sources/CloudMachineAgent/WatchdogCommands.swift deleted file mode 100644 index 153895c..0000000 --- a/mac-app/Sources/CloudMachineAgent/WatchdogCommands.swift +++ /dev/null @@ -1,25 +0,0 @@ -import ArgumentParser -import CloudMachineCore - -struct VerifyWatchdog: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "verify-watchdog", - abstract: - "Automatyczna, cykliczna weryfikacja sum kontrolnych najnowszego backupu (raz na 7 dni).") - - func run() async throws { - await VerifyWatchdogService.run() - } -} - -struct ArchiveWatchdog: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "archive-watchdog", - abstract: - "Automatycznie kopiuje ukonczone lokalne backupy na Google Drive (co kilka godzin).") - - func run() async throws { - let (config, key) = await CLIContext.load() - await ArchiveWatchdogService.run(config: config, machineKey: key) - } -} diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 641fba0..cebc0a8 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -1,33 +1,38 @@ import CloudMachineCore import Foundation +/// Gotowosc narzedzi, bez ktorych nic nie ruszy. enum DependencyState: Equatable { case unknown case checking - case missing([String]) + /// Czego brakuje i czym to naprawic - pary (brak, polecenie). + case missing([String], [String]) case ready } enum TimeMachineState: Equatable { case unknown + /// Time Machine nie wskazuje na nasz obraz - backupu realnie nie ma. case notRegistered - case registered + case registered(mountPoint: String) } -/// Stan lokalnego woluminu backupu (architektura local-first - patrz README). -/// W przeciwienstwie do legacy `MountState`, nie ma tu pojec "montowania" -/// sieciowego - wolumin albo istnieje na dysku, albo nie. -struct LocalVolumeStatus: Equatable { - var exists: Bool = false - var mountPoint: String? = nil - var totalGB: Double? = nil - var usedGB: Double? = nil - var freeContainerGB: Double? = nil +/// Stan bufora miedzy Time Machine a Google Drive. +struct BufferStatus: Equatable { + var mounted: Bool = false + var imageAttached: Bool = false + var sizeGB: Int = 0 + var freeDiskGB: Int = 0 + /// Ile plikow czeka na wyslanie. Ta liczba jest wazniejsza od rozmiaru + /// bufora: jesli rosnie i nie wraca do zera miedzy backupami, wysylka nie + /// nadaza za zapisem. + var uploadsQueued: Int = 0 + var uploadsInProgress: Int = 0 + var erroredFiles: Int = 0 + /// Dobowy limit uploadu Google Drive wyczerpany - do odnowienia trzeba czekac. + var dailyQuotaHit: Bool = false - var usedFraction: Double { - guard let total = totalGB, total > 0, let used = usedGB else { return 0 } - return min(used / total, 1.0) - } + var draining: Bool { uploadsInProgress > 0 || uploadsQueued > 0 } } struct LastRunResult: Equatable { @@ -36,29 +41,9 @@ struct LastRunResult: Equatable { var date: Date } -/// Stan kroku "Udostepnij dysk w sieci" w Kreatorze - `candidateDisks` to -/// dyski pod `/Volumes` do wyboru (patrz `NetworkShareService`), -/// `fileSharingEnabled` odzwierciedla glowny przelacznik File Sharing w -/// System Settings, niezaleznie od tego, czy jakikolwiek konkretny udzial -/// juz istnieje. -struct NetworkShareStatus: Equatable { - var candidateDisks: [DiskCandidate] = [] - var fileSharingEnabled: Bool = false -} - -/// Stan warstwy archiwizacji w chmurze (patrz `CloudArchiveService` - -/// kopiuje ukonczone lokalne backupy na Google Drive w tle). -struct CloudArchiveStatusInfo: Equatable { - var lastArchivedBackup: String? = nil - var lastArchivedDate: Date? = nil - var archivedCount: Int = 0 - var pendingCount: Int = 0 -} - -/// Zywy postep aktualnie trwajacego backupu Time Machine (z `tmutil status`), -/// odswiezany cyklicznie przez `refreshAll()` - `nil`, gdy nic sie akurat -/// nie kopiuje. `transferRateMBs` jest liczone lokalnie (delta bajtow miedzy -/// dwoma odswiezeniami), bo `tmutil` samo w sobie tego nie podaje. +/// Zywy postep trwajacego backupu (`tmutil status`). `nil`, gdy nic sie nie +/// kopiuje. `transferRateMBs` liczymy sami z roznicy bajtow miedzy +/// odswiezeniami - `tmutil` tego nie podaje. struct BackupProgressInfo: Equatable { var phase: String? var percent: Double? @@ -74,20 +59,35 @@ struct BackupProgressInfo: Equatable { final class AppStatus: ObservableObject { @Published var dependencyState: DependencyState = .unknown @Published var remoteConfigured: Bool = false - @Published var localVolume = LocalVolumeStatus() + @Published var buffer = BufferStatus() @Published var timeMachineState: TimeMachineState = .unknown - @Published var lastVerify: LastRunResult? - @Published var lastArchive: LastRunResult? - @Published var lastNetworkShare: LastRunResult? - @Published var cloudArchive = CloudArchiveStatusInfo() - @Published var networkShare = NetworkShareStatus() @Published var backupProgress: BackupProgressInfo? + @Published var lastAction: LastRunResult? @Published var hasFullDiskAccess: Bool = false - @Published var currentMachineKey: String = "" @Published var isBusy: Bool = false @Published var busyLabel: String = "" @Published var logTail: String = "" - /// Ostatni blad do pokazania uzytkownikowi w banerze (Status + menu bar) - - /// bez tego bledy z akcji byly widoczne tylko w logu. @Published var errorMessage: String? + + /// Jednozdaniowa odpowiedz na pytanie "czy moje dane sa bezpieczne". + var headline: String { + if case .missing(let what, _) = dependencyState { + return "Brakuje: \(what.joined(separator: ", "))" + } + if !remoteConfigured { return "Google Drive niepolaczony" } + if !buffer.mounted { return "Bufor nie dziala" } + if !buffer.imageAttached { return "Obraz backupu niepodpiety" } + if case .notRegistered = timeMachineState { return "Time Machine nie wskazuje na CloudMachine" } + if buffer.dailyQuotaHit { return "Dobowy limit Google Drive wyczerpany" } + if backupProgress != nil { return "Backup w toku" } + if buffer.draining { return "Wysylanie na Google Drive" } + return "Gotowe" + } + + var healthy: Bool { + guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached, + case .registered = timeMachineState, !buffer.dailyQuotaHit + else { return false } + return true + } } diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 8f43967..02a5287 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -1,476 +1,187 @@ -import AppKit import CloudMachineCore import Foundation +import SwiftUI +/// Laczy interfejs z serwisami w `CloudMachineCore`. Sam nie zawiera logiki +/// backupu - to, co wie o Google Drive, obrazie i buforze, siedzi w serwisach, +/// zeby CLI i GUI robily dokladnie to samo. @MainActor final class CloudMachineController: ObservableObject { - @Published var config: MachinesConfig - @Published var status = AppStatus() + let status = AppStatus() - /// Chroni przed lawina nakladajacych sie subprocessow - `MenuBarContentView` - /// odpala `refreshAll()` w `.task` przy KAZDYM otwarciu popupu paska menu, - /// wiec bez tego szybkie, powtarzane klikanie ikonki multiplikowaloby te - /// wywolania bez sensu. - private var lastRefreshAllAt: Date? - private let refreshAllMinInterval: TimeInterval = 5 + private var refreshTask: Task? + private var lastBytesDone: Double? + private var lastBytesSampledAt: Date? - /// Ostatnia znana probka (bajty, znacznik czasu) do liczenia predkosci - /// transferu miedzy dwoma odswiezeniami - `tmutil` nie podaje tego sam. - private var lastProgressSample: (bytes: Double, date: Date)? + // MARK: - Cykl odswiezania - init() { - let (loaded, corruption) = ConfigStore.loadOrInitialize() - config = loaded - if let corruption { - status.errorMessage = - "Plik konfiguracyjny (machines.json) jest uszkodzony i nie zostal wczytany - pokazuje pusta konfiguracje, ZEBY NIE NADPISAC oryginalu (kopia zapasowa zapisana obok). Blad: \(corruption.localizedDescription)" - } - Task { - status.currentMachineKey = await MachineIdentity.currentKey() - } - } - - // MARK: - Config - - func saveConfig() { - do { - try ConfigStore.save(config) - } catch { - fail("Nie udalo sie zapisac konfiguracji: \(error.localizedDescription)") + func startAutoRefresh(interval: TimeInterval = 10) { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshAll() + try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + } } } - func currentMachineKey() async -> String { - await MachineIdentity.currentKey() - } - - // MARK: - Logging i bledy widoczne w UI - - /// Wszystkie akcje wywolywane z UI powinny zaczynac od `clearError()`, a przy - /// niepowodzeniu wolac `fail(...)` - inaczej blad trafia tylko do pliku logu - /// i uzytkownik widzi tylko "kliknalem i nic sie nie stalo". - func clearError() { - status.errorMessage = nil + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil } - func fail(_ message: String) { - status.errorMessage = message - appendLog("BLAD: \(message)") - } - - func appendLog(_ line: String) { - let stamp = ISO8601DateFormatter().string(from: Date()) - status.logTail += "[\(stamp)] \(line)\n" - if status.logTail.count > 20_000 { - status.logTail = String(status.logTail.suffix(20_000)) - } - } - - func refreshLogTail() { - guard let data = try? String(contentsOf: CMPaths.combinedLogFile, encoding: .utf8) else { - return - } - status.logTail = String(data.suffix(20_000)) + func refreshAll() async { + await refreshDependencies() + await refreshBuffer() + await refreshTimeMachine() + await refreshProgress() + refreshLogTail() } - // MARK: - Zaleznosci (rclone) - potrzebne dla warstwy archiwizacji w chmurze + // MARK: - Poszczegolne odczyty - func checkDependencies() async { - status.dependencyState = .checking - let missing = await DependencyInstaller.missingTools() - status.dependencyState = missing.isEmpty ? .ready : .missing(missing) + private func refreshDependencies() async { + let readiness = CMTooling.checkReadiness() + status.dependencyState = + readiness.ready ? .ready : .missing(readiness.missing, readiness.remedies) + status.remoteConfigured = await RemoteConfigurer.isConfigured(remoteName: DriveBufferService.remoteName) + status.hasFullDiskAccess = FileManager.default.isReadableFile( + atPath: NSHomeDirectory() + "/Library/Application Support/com.apple.TCC") } - private func homebrewPrefix() async -> String { - let arch = (try? await ProcessRunner.run("/usr/bin/uname", ["-m"]))? - .stdout.trimmingCharacters(in: .whitespacesAndNewlines) - return arch == "arm64" ? "/opt/homebrew" : "/usr/local" - } - - func installDependencies() async { - guard !status.isBusy else { return } - clearError() - status.isBusy = true - defer { status.isBusy = false } - - if DependencyInstaller.resolvedBrewPath() == nil { - let prefix = await homebrewPrefix() - status.busyLabel = - "Homebrew nie jest zainstalowany - przygotowuje \(prefix) (autoryzacja administratora)..." - do { - _ = try await Shell.runPrivileged( - "mkdir -p '\(prefix)' && chown -R \(NSUserName()):admin '\(prefix)'") - } catch { - fail( - "Nie udalo sie przygotowac katalogu \(prefix) dla Homebrew: \(error.localizedDescription)" - ) - await checkDependencies() - return - } - - status.busyLabel = "Pobieram i instaluje Homebrew (moze potrwac kilka minut)..." - let installCommand = - "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" - guard - let installResult = try? await ProcessRunner.run( - "/bin/bash", ["-c", installCommand], timeout: 900) - else { - fail("Instalacja Homebrew nie powiodla sie (proces nie odpowiedzial).") - await checkDependencies() - return - } - appendLog(installResult.stdout) - if DependencyInstaller.resolvedBrewPath() == nil { - fail( - "Instalacja Homebrew nie powiodla sie: \(installResult.stderr.isEmpty ? installResult.stdout : installResult.stderr)" - ) - await checkDependencies() - return - } - appendLog("Homebrew zainstalowany pomyslnie w \(prefix).") - } - - status.busyLabel = "Instaluje rclone przez Homebrew..." - let result = await DependencyInstaller.installRclone() - appendLog(result.message) - if !result.succeeded { - fail(result.message) + private func refreshBuffer() async { + var buffer = BufferStatus() + buffer.mounted = DriveBufferService.isMounted + buffer.imageAttached = BackupImageService.isAttached + buffer.sizeGB = BufferGuardService.bufferGB() + buffer.freeDiskGB = BufferGuardService.freeGB() + buffer.dailyQuotaHit = DriveBufferService.hitDailyQuota() + if let stats = await DriveBufferService.queueStats() { + buffer.uploadsQueued = stats.uploadsQueued + buffer.uploadsInProgress = stats.uploadsInProgress + buffer.erroredFiles = stats.erroredFiles } - await checkDependencies() + status.buffer = buffer } - // MARK: - Polaczenie z Google Drive (dla warstwy archiwizacji w chmurze) - - func remoteConfigured() async -> Bool { - await RemoteConfigurer.isConfigured(remoteName: config.remoteName) - } - - func connectGoogleDrive() async { - guard !status.isBusy else { return } - clearError() - guard status.dependencyState == .ready else { - fail("Najpierw zainstaluj zaleznosci - rclone nie jest jeszcze dostepne.") + private func refreshTimeMachine() async { + guard let mountPoint = await TimeMachineStatus.currentDestinationMountPoint() else { + status.timeMachineState = .notRegistered return } - status.isBusy = true - status.busyLabel = "Czekam na logowanie do Google w przegladarce..." - defer { status.isBusy = false } - - let key = await currentMachineKey() - let result = await RemoteConfigurer.connect(config: config, machineKey: key) - if result.succeeded { - appendLog(result.message) - status.remoteConfigured = true - } else { - fail(result.message) - } - } - - // MARK: - Lokalny wolumin backupu - - /// CZYTA tylko aktualny stan lokalnego celu Time Machine - tworzenie - /// woluminu i rejestrowanie go jako cel TM to teraz krok wykonywany przez - /// uzytkownika samodzielnie (System Settings -> Time Machine), nie logika - /// tej apki (patrz `LocalBackupService`). - func refreshLocalVolume() async { - let volumeStatus = await LocalBackupService.currentStatus() - status.localVolume = LocalVolumeStatus( - exists: volumeStatus.exists, - mountPoint: volumeStatus.mountPoint, - totalGB: volumeStatus.totalGB, - usedGB: volumeStatus.usedGB, - freeContainerGB: volumeStatus.freeContainerGB - ) - status.timeMachineState = volumeStatus.destinationID != nil ? .registered : .notRegistered + // Cel moze istniec, ale wskazywac gdzie indziej - wtedy backupu na Drive + // nie ma, mimo ze Time Machine wyglada na skonfigurowany. + status.timeMachineState = + mountPoint == BackupImageService.targetPath.path + ? .registered(mountPoint: mountPoint) : .notRegistered } - // MARK: - Time Machine - - /// Odswieza zywy postep aktualnie trwajacego backupu (procent, bajty, - /// pozostaly czas) - `nil`, gdy nic sie akurat nie kopiuje. Predkosc - /// transferu liczymy sami z delty bajtow wzgledem poprzedniego odswiezenia, - /// bo `tmutil status` samo w sobie tego nie podaje. - func refreshBackupProgress() async { - guard let progress = await TimeMachineStatus.currentProgress() else { + private func refreshProgress() async { + guard await TimeMachineStatus.isRunning(), + let progress = await TimeMachineStatus.currentProgress() + else { status.backupProgress = nil - lastProgressSample = nil + lastBytesDone = nil + lastBytesSampledAt = nil return } - var transferRateMBs: Double? - if let bytes = progress.bytes { - if let last = lastProgressSample { - let elapsed = Date().timeIntervalSince(last.date) - let deltaBytes = bytes - last.bytes - if elapsed > 1, deltaBytes >= 0 { - transferRateMBs = (deltaBytes / elapsed) / 1_048_576 - } + var info = BackupProgressInfo( + phase: progress.phase, percent: progress.percent, bytesDone: progress.bytes, + bytesTotal: progress.totalBytes, filesDone: progress.files, filesTotal: progress.totalFiles, + timeRemainingSeconds: progress.timeRemainingSeconds) + + // tmutil nie podaje tempa - liczymy je z roznicy miedzy odczytami. + if let bytes = progress.bytes, let previous = lastBytesDone, let at = lastBytesSampledAt { + let seconds = Date().timeIntervalSince(at) + if seconds > 0, bytes >= previous { + info.transferRateMBs = (bytes - previous) / seconds / 1_048_576 } - lastProgressSample = (bytes: bytes, date: Date()) } - - status.backupProgress = BackupProgressInfo( - phase: progress.phase, - percent: progress.percent, - bytesDone: progress.bytes, - bytesTotal: progress.totalBytes, - filesDone: progress.files, - filesTotal: progress.totalFiles, - timeRemainingSeconds: progress.timeRemainingSeconds, - transferRateMBs: transferRateMBs - ) - } - - // MARK: - Udostepnienie sieciowe (SMB) - - func refreshNetworkShare() async { - status.networkShare = NetworkShareStatus( - candidateDisks: await NetworkShareService.candidateDisks(), - fileSharingEnabled: await NetworkShareService.isFileSharingEnabled() - ) + lastBytesDone = progress.bytes + lastBytesSampledAt = Date() + status.backupProgress = info } - /// Wlacza File Sharing (jesli jeszcze wylaczony) i dodaje `disk.mountPoint` - /// jako zwykly udzial SMB o nazwie `shareName`, bez dostepu goscia (host - /// backupu wymaga zalogowania sie kontem uzytkownika tego Maca). Oznaczenie - /// TEGO udzialu jako cel "Time Machine backup destination" jest swiadomie - /// POZOSTAWIONE uzytkownikowi jako ostatni, reczny krok w System Settings - /// -> General -> Sharing - patrz doc-comment `NetworkShareService`. - func shareDiskOverNetwork(disk: DiskCandidate, shareName: String) async { - guard !status.isBusy else { return } - guard !shareName.trimmingCharacters(in: .whitespaces).isEmpty else { - fail("Podaj nazwe udzialu sieciowego.") - return - } - clearError() - status.isBusy = true - status.busyLabel = "Udostepniam \(disk.name) w sieci..." - defer { status.isBusy = false } - - let path = shellSingleQuoteEscaped(disk.mountPoint) - let name = shellSingleQuoteEscaped(shareName) - let enablePrefix = - status.networkShare.fileSharingEnabled - ? "" - : "launchctl enable system/com.apple.smbd && launchctl kickstart -k system/com.apple.smbd && " - let command = "\(enablePrefix)/usr/sbin/sharing -a '\(path)' -S '\(name)' -s 001 -g 000" - - do { - let output = try await Shell.runPrivileged(command) - let message = - output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? "Udostepniono \"\(disk.name)\" w sieci jako \"\(shareName)\"." - : output - status.lastNetworkShare = LastRunResult(succeeded: true, message: message, date: Date()) - appendLog("Udostepniono \(disk.mountPoint) w sieci jako \"\(shareName)\".") - } catch { - status.lastNetworkShare = LastRunResult( - succeeded: false, message: error.localizedDescription, date: Date()) - fail("Nie udalo sie udostepnic dysku w sieci: \(error.localizedDescription)") + func refreshLogTail() { + guard let handle = try? FileHandle(forReadingFrom: CMPaths.combinedLogFile) else { return } + defer { try? handle.close() } + let size = (try? handle.seekToEnd()) ?? 0 + let window: UInt64 = 16 * 1024 + try? handle.seek(toOffset: size > window ? size - window : 0) + if let data = try? handle.readToEnd(), let text = String(data: data, encoding: .utf8) { + status.logTail = text } - await refreshNetworkShare() } - // MARK: - Weryfikacja - - func verifyNow() async { - guard !status.isBusy else { return } - clearError() - status.isBusy = true - status.busyLabel = "Weryfikuje spojnosc backupu (moze potrwac dlugo)..." - defer { status.isBusy = false } - guard let mountPoint = status.localVolume.mountPoint else { - fail("Wolumin lokalny nie jest jeszcze utworzony.") - return - } - - guard let latest = await BackupVerifier.latestBackupPath(mountPoint: mountPoint) else { - status.lastVerify = LastRunResult( - succeeded: false, message: "Brak backupow do zweryfikowania.", date: Date()) - fail("Brak backupow Time Machine do zweryfikowania pod \(mountPoint).") - return - } + // MARK: - Akcje - do { - let result = try await runTmutilPrivileged(["verifychecksums", latest]) - if result.succeeded { - status.lastVerify = LastRunResult( - succeeded: true, message: "Sumy kontrolne OK dla \(latest)", date: Date()) - appendLog("Weryfikacja OK: \(latest)") - } else { - let message = result.stderr.isEmpty ? result.stdout : result.stderr - status.lastVerify = LastRunResult(succeeded: false, message: message, date: Date()) - fail("Weryfikacja nieudana: \(message)") - } - } catch { - status.lastVerify = LastRunResult( - succeeded: false, message: "Blad weryfikacji: \(error.localizedDescription)", date: Date()) - fail("Weryfikacja nieudana: \(error.localizedDescription)") - } + func installRclone() async { + await run("Instaluje rclone") { await RcloneInstaller.install() } } - // MARK: - Archiwizacja w chmurze - - func refreshCloudArchive() async { - let key = await currentMachineKey() - let archiveStatus = await CloudArchiveService.currentStatus(config: config, machineKey: key) - status.cloudArchive = CloudArchiveStatusInfo( - lastArchivedBackup: archiveStatus.lastArchivedBackup, - lastArchivedDate: archiveStatus.lastArchivedDate, - archivedCount: archiveStatus.archivedCount, - pendingCount: archiveStatus.pendingCount - ) + /// Polaczenie z Google Drive robi sie z terminala, nie z GUI: OAuth otwiera + /// przegladarke i czeka na zatwierdzenie, a klucze czytamy z Keychaina. + var connectDriveCommand: String { + "\(CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent") configure-remote" } - func archiveNow() async { - guard !status.isBusy else { return } - clearError() - status.isBusy = true - status.busyLabel = "Archiwizuje ukonczone backupy na Google Drive (moze potrwac dlugo)..." - defer { status.isBusy = false } - - let key = await currentMachineKey() - let result = await CloudArchiveService.archivePending(config: config, machineKey: key) - status.lastArchive = LastRunResult( - succeeded: result.succeeded, message: result.message, date: Date()) - if result.succeeded { - appendLog(result.message) - } else { - fail(result.message) - } - await refreshCloudArchive() + func createImage(sizeGB: Int) async { + await run("Tworze obraz backupu") { await BackupImageService.create(sizeGB: sizeGB) } } - /// Uruchamia uprzywilejowana podkomende `tmutil` przez `sudo` z regula - /// NOPASSWD, NIE przez AppleScript `do shell script ... with administrator - /// privileges` - `do shell script` z podniesionymi uprawnieniami uruchamia - /// polecenie w oddzielnym procesie autoryzacyjnym, ktorego macOS NIE - /// przypisuje poprawnie tej aplikacji do celow TCC / Pelnego dostepu do - /// dysku. `sudo` wywolane przez zwykly `Process` jest natomiast - /// BEZPOSREDNIM potomkiem tej aplikacji, wiec TCC poprawnie go rozpoznaje. - private func runTmutilPrivileged(_ args: [String]) async throws -> ProcessResult { - if let result = try? await ProcessRunner.run( - "/usr/bin/sudo", ["-n", "/usr/bin/tmutil"] + args, timeout: 300) - { - if result.succeeded || !result.isSudoAuthFailure { - return result - } - } - try await ensureTmutilSudoersRule() - return try await ProcessRunner.run( - "/usr/bin/sudo", ["-n", "/usr/bin/tmutil"] + args, timeout: 300) + func attachImage() async { + await run("Podpinam obraz") { await BackupImageService.attach() } } - /// Bezpiecznie osadza `value` wewnatrz pojedynczego `echo '...'` w - /// `command` w `ensureTmutilSudoersRule()` ponizej: zamyka biezacy - /// cudzyslow, wstawia znak `'` jako osobny, zescape'owany literal (`\'`), - /// po czym otwiera cudzyslow na nowo. Konieczne, bo `volumeMountPoint`/ - /// `user` pochodza w koncu z nazwy dysku/konta, ktora uzytkownik moze - /// dowolnie zmienic (np. na "Mac Studio's Backup") - bez tego escape'owania - /// apostrof w tej nazwie wyrywalby sie z cudzyslowu w komendzie wykonywanej - /// przez `do shell script ... with administrator privileges` (patrz - /// `Shell.runPrivileged`, ktory escape'uje TYLKO `\` i `"` na potrzeby - /// samego AppleScriptu, nie pojedynczy cudzyslow uzywany tutaj przez - /// powloke) - realne ryzyko wykonania dowolnej komendy jako root z tresci - /// nazwy dysku. - private func shellSingleQuoteEscaped(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "'\\''") + func verifyImage() async { + await run("Sprawdzam spojnosc obrazu") { await BackupImageService.verify() } } - /// Dopisuje wpis sudoers (NOPASSWD) dla `tmutil verifychecksums`, zawezony - /// do lokalnego wolumnu CloudMachine (glob na sciezce) - jedyna - /// uprzywilejowana podkomenda `tmutil`, ktorej ta apka jeszcze sama uzywa - /// (`verifyNow()`/`VerifyWatchdogService`). Rejestrowanie celu TM - /// (`setdestination`) i uruchamianie/zatrzymywanie backupu - /// (`startbackup`/`stopbackup`/`removedestination`) to teraz krok - /// wykonywany przez uzytkownika samodzielnie w Ustawieniach systemowych, - /// wiec te reguly zostaly usuniete - mniej uprawnien nadanych bez hasla, - /// tym lepiej. (Wykluczenia folderow z backupu sa tak samo celowo POZA - /// CloudMachine - uzytkownik zarzadza nimi wprost w Ustawieniach - /// systemowych -> Time Machine.) - /// - /// WAZNE: sciezka glob musi odzwierciedlac RZECZYWISTY wolumin, nie - /// zakladana z gory nazwe - `LocalBackupService.currentStatus()` juz - /// prawidlowo wykrywa rzeczywisty zarejestrowany cel Time Machine (albo, - /// jesli jeszcze nic nie zarejestrowano, zgaduje po domyslnej nazwie na - /// czas pierwszej konfiguracji). Wczesniej ta funkcja uzywala WYLACZNIE - /// twardo zakodowanej `LocalBackupService.defaultVolumeName` - dzialalo to - /// tylko dopoki uzytkownik nie nazwal/nie zmienil nazwy woluminu inaczej - /// (zaobserwowane na zywo: po recznej zmianie nazwy na "TimeMachine" ta - /// reguła nie pasowalaby juz do prawdziwej sciezki, cicho psujac - /// bezobslugowe wywolania tmutil). - private func ensureTmutilSudoersRule() async throws { - let user = shellSingleQuoteEscaped(NSUserName()) - let volumeMountPoint = shellSingleQuoteEscaped( - await LocalBackupService.currentStatus().mountPoint - ?? "/Volumes/\(LocalBackupService.defaultVolumeName)") - let cmGlob = "\(volumeMountPoint)*" - let rules = [ - "\(user) ALL=(root) NOPASSWD: /usr/bin/tmutil verifychecksums \(cmGlob)*" - ] - let tmpPath = "/etc/sudoers.d/.cloudmachine.tmp.$$" - let writeBody = rules.map { "echo '\($0)'" }.joined(separator: "; ") - let command = """ - { \(writeBody); } > \(tmpPath) && chmod 440 \(tmpPath) && visudo -c -f \(tmpPath) \ - && mv -f \(tmpPath) /etc/sudoers.d/cloudmachine || { rm -f \(tmpPath); exit 1; } - """ - _ = try await Shell.runPrivileged(command) - appendLog( - "Skonfigurowano uprawnienia sudoers dla tmutil, zawezone do lokalnego wolumnu CloudMachine." - ) + func installAgents() async { + await run("Instaluje agentow launchd") { await LaunchdInstaller.install() } } - /// macOS nie ma publicznego API do wprost odpytania "czy ta appka ma Pelny - /// dostep do dysku" (TCC.db jest prywatne) - jedyny wiarygodny sposob to - /// probne odczytanie pliku, do ktorego dostep BEZ FDA jest zawsze - /// zablokowany przez sandboxing systemowy. - func checkFullDiskAccess() { - let home = FileManager.default.homeDirectoryForCurrentUser - let bookmarksPath = home.appendingPathComponent("Library/Safari/Bookmarks.plist") - - do { - _ = try Data(contentsOf: bookmarksPath) - status.hasFullDiskAccess = true - } catch { - let migrationPath = URL( - fileURLWithPath: "/Library/SystemMigration/History/MigrationHistory.plist") - do { - _ = try Data(contentsOf: migrationPath) - status.hasFullDiskAccess = true - } catch { - status.hasFullDiskAccess = false - } + func startBackup() async { + await run("Uruchamiam backup") { + let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["startbackup"], timeout: 60) + return CMActionResult( + succeeded: result?.succeeded == true, + message: result?.succeeded == true ? "Backup uruchomiony." : "Nie udalo sie uruchomic backupu.") } } - func openFullDiskAccessSettings() { - if let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") - { - NSWorkspace.shared.open(url) + func stopBackup() async { + await run("Wstrzymuje backup") { + let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["stopbackup"], timeout: 60) + return CMActionResult( + succeeded: result?.succeeded == true, + message: result?.succeeded == true ? "Backup wstrzymany." : "Nie udalo sie wstrzymac backupu.") } } - // MARK: - Odswiezenie calosci + /// Polecenie, ktore uzytkownik musi wkleic sam - `tmutil setdestination` + /// wymaga roota, a aplikacja nie ma reguly sudoers. + var setDestinationCommand: String { + "sudo tmutil setdestination '\(BackupImageService.targetPath.path)'" + } - /// `force: false` (domyslne) pomija odswiezenie, jesli ostatnie wywolanie - /// bylo mniej niz `refreshAllMinInterval` temu. Przyciski "Odswiez" - /// klikniete recznie przez uzytkownika powinny uzywac `force: true`. - func refreshAll(force: Bool = false) async { - if !force, let last = lastRefreshAllAt, Date().timeIntervalSince(last) < refreshAllMinInterval { - return + // MARK: - Wspolna obsluga akcji + + private func run(_ label: String, _ action: () async -> CMActionResult) async { + status.isBusy = true + status.busyLabel = label + status.errorMessage = nil + defer { + status.isBusy = false + status.busyLabel = "" } - lastRefreshAllAt = Date() - let key = await currentMachineKey() - status.currentMachineKey = key - await checkDependencies() - status.remoteConfigured = await remoteConfigured() - await refreshLocalVolume() - await refreshBackupProgress() - await refreshCloudArchive() - await refreshNetworkShare() - checkFullDiskAccess() - refreshLogTail() + let result = await action() + status.lastAction = LastRunResult( + succeeded: result.succeeded, message: result.message, date: Date()) + if !result.succeeded { status.errorMessage = result.message } + CMLogger.log("[gui] \(label): \(result.message)") + await refreshAll() } } diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 0a271cc..f6ab817 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -1,174 +1,206 @@ import CloudMachineCore import SwiftUI +/// Glowne okno. Ma odpowiadac na jedno pytanie od razu po otwarciu: czy moje +/// dane sa bezpieczne. Szczegoly sa nizej, dla tych chwil, gdy odpowiedz brzmi +/// "nie". struct DashboardView: View { @EnvironmentObject private var controller: CloudMachineController - @State private var selectedTab: Tab = .status - - /// W przeciwienstwie do `MenuBarContentView` (ktorego `.task` odpala sie - /// na nowo za KAZDYM razem, gdy uzytkownik otwiera popup paska menu - patrz - /// komentarz przy `refreshAllMinInterval` w `CloudMachineController`), to - /// okno raz otwarte zyje dlugo i NIGDY samo z siebie nie odswiezaloby - /// statusu ponownie - `.task` ponizej odpala sie tylko RAZ, przy pierwszym - /// pojawieniu sie okna. Bez tego timera stan (np. "Montowanie...") moze - /// pozostac zamrozony na godziny w oknie otwartym akurat w trakcie - /// przejsciowego problemu, mimo ze system pod spodem dawno juz wrocil do - /// zdrowia - zaobserwowane realnie na zywo. - private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect() - - enum Tab: Hashable { - case status - case wizard - case logs - } var body: some View { - NavigationSplitView { - List(selection: $selectedTab) { - Section("Narzędzia".localized) { - NavigationLink(value: Tab.status) { - Label("Status".localized, systemImage: "gauge.with.needle") - } - NavigationLink(value: Tab.wizard) { - Label("Kreator".localized, systemImage: "wand.and.stars") - } - NavigationLink(value: Tab.logs) { - Label("Logi systemowe".localized, systemImage: "doc.text.magnifyingglass") - } - } - } - .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 250) - .safeAreaInset(edge: .bottom) { - SidebarStatusFooter() - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(.thinMaterial) - } - } detail: { - Group { - switch selectedTab { - case .status: - StatusView() - case .wizard: - SetupWizardView() - case .logs: - LogsView() - } - } - .navigationTitle(navigationTitle(for: selectedTab)) - .toolbar { - ToolbarItem(placement: .navigation) { - if controller.status.isBusy { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text(controller.status.busyLabel) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + if let error = controller.status.errorMessage { errorBanner(error) } + if !setupSteps.isEmpty { setupCard } + bufferCard + if let progress = controller.status.backupProgress { progressCard(progress) } + actions + logCard } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) } .task { - await controller.refreshAll() - } - .onReceive(refreshTimer) { _ in - Task { await controller.refreshAll() } + controller.startAutoRefresh() } + .onDisappear { controller.stopAutoRefresh() } } - private func navigationTitle(for tab: Tab) -> String { - switch tab { - case .status: return "Stan systemu".localized - case .wizard: return "Kreator konfiguracji".localized - case .logs: return "Logi konsoli".localized + // MARK: - Naglowek + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: controller.status.healthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 32)) + .foregroundStyle(controller.status.healthy ? .green : .orange) + VStack(alignment: .leading, spacing: 2) { + Text(controller.status.headline).font(.title2).bold() + Text("Time Machine na Google Drive").font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + if controller.status.isBusy { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(controller.status.busyLabel).font(.caption).foregroundStyle(.secondary) + } + } } } -} -struct SidebarStatusFooter: View { - @EnvironmentObject private var controller: CloudMachineController - @State private var isPulse = false + private func errorBanner(_ message: String) -> some View { + Text(message) + .font(.callout) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + .textSelection(.enabled) + } - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Circle() - .fill(statusColor) - .frame(width: 8, height: 8) - .scaleEffect(isPulse ? 1.25 : 0.8) - .opacity(isPulse ? 1.0 : 0.5) - .onAppear { - withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { - isPulse = true + // MARK: - Konfiguracja + + /// Kroki, ktorych brakuje. Pusta lista znaczy, ze wszystko jest na miejscu - + /// wtedy karta w ogole sie nie pokazuje. + private var setupSteps: [(String, String?)] { + var steps: [(String, String?)] = [] + if case .missing(let what, let how) = controller.status.dependencyState { + for (miss, remedy) in zip(what, how) { steps.append(("Brakuje: \(miss)", remedy)) } + } + if !controller.status.remoteConfigured { + steps.append(("Google Drive niepolaczony", controller.connectDriveCommand)) + } + if case .notRegistered = controller.status.timeMachineState, + controller.status.buffer.imageAttached + { + steps.append(("Time Machine nie wskazuje na CloudMachine", controller.setDestinationCommand)) + } + return steps + } + + private var setupCard: some View { + card("Do zrobienia") { + ForEach(Array(setupSteps.enumerated()), id: \.offset) { _, step in + VStack(alignment: .leading, spacing: 4) { + Text(step.0).font(.callout) + if let command = step.1 { + HStack { + Text(command) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Spacer() + Button("Kopiuj") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + .controlSize(.small) } + .padding(8) + .background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) } - - VStack(alignment: .leading, spacing: 2) { - Text(statusText) - .font(.caption2.bold()) - Text(subText) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - .lineLimit(1) } - Spacer() } - - Text("CloudMachine \(appVersionText)") - .font(.system(size: 9)) - .foregroundStyle(.secondary.opacity(0.7)) } } - /// "1.0.0 (build 42)" - `CFBundleShortVersionString`/`CFBundleVersion` z - /// Info.plist (podstawiane przez `build-app` z pliku `VERSION` i licznika - /// commitow - patrz `BuildAppCommand.swift`). Puste w `swift run`/testach, - /// gdzie nie ma prawdziwego bundla `.app` z wypelnionym Info.plist. - private var appVersionText: String { - let info = Bundle.main.infoDictionary - let short = info?["CFBundleShortVersionString"] as? String - let build = info?["CFBundleVersion"] as? String - switch (short, build) { - case (.some(let s), .some(let b)) where !s.isEmpty && !b.isEmpty: return "\(s) (build \(b))" - case (.some(let s), _) where !s.isEmpty: return s - default: return "dev" + // MARK: - Bufor + + private var bufferCard: some View { + card("Bufor i wysylka") { + row("Montowanie Drive", controller.status.buffer.mounted ? "dziala" : "brak", + ok: controller.status.buffer.mounted) + row("Obraz backupu", controller.status.buffer.imageAttached ? "podpiety" : "niepodpiety", + ok: controller.status.buffer.imageAttached) + row("Bufor na dysku", "\(controller.status.buffer.sizeGB) GB", ok: true) + row("Wolne na dysku", "\(controller.status.buffer.freeDiskGB) GB", + ok: controller.status.buffer.freeDiskGB > 80) + + // Ta liczba jest wazniejsza od rozmiaru bufora: jesli rosnie i nie wraca + // do zera miedzy backupami, wysylka nie nadaza za zapisem. + row( + "Czeka na wyslanie", + controller.status.buffer.draining + ? "\(controller.status.buffer.uploadsInProgress) w toku, \(controller.status.buffer.uploadsQueued) w kolejce" + : "nic", + ok: controller.status.buffer.erroredFiles == 0) + + if controller.status.buffer.erroredFiles > 0 { + row("Bledy wysylki", "\(controller.status.buffer.erroredFiles)", ok: false) + } + if controller.status.buffer.dailyQuotaHit { + row("Limit Google Drive", "dobowy limit wyczerpany", ok: false) + } } } - private var statusColor: Color { - if controller.status.errorMessage != nil { - return .red - } - if !controller.status.localVolume.exists { - return .secondary + // MARK: - Postep + + private func progressCard(_ progress: BackupProgressInfo) -> some View { + card("Backup w toku") { + if let percent = progress.percent { + ProgressView(value: min(max(percent, 0), 1)) + Text(String(format: "%.1f%%", percent * 100)).font(.caption).foregroundStyle(.secondary) + } + if let done = progress.filesDone, let total = progress.filesTotal, total > 0 { + row("Pliki", "\(done) z \(total)", ok: true) + } + if let rate = progress.transferRateMBs { + row("Tempo", String(format: "%.1f MB/s", rate), ok: true) + } + if let phase = progress.phase { + row("Faza", phase, ok: true) + } } - return controller.status.timeMachineState == .registered ? .green : .orange } - private var statusText: String { - if controller.status.errorMessage != nil { - return "Problem z konfiguracją".localized - } - if !controller.status.localVolume.exists { - return "Brak lokalnego woluminu".localized + // MARK: - Akcje + + private var actions: some View { + HStack(spacing: 10) { + if controller.status.backupProgress == nil { + Button("Zrob backup teraz") { Task { await controller.startBackup() } } + .disabled(!controller.status.healthy || controller.status.isBusy) + } else { + Button("Wstrzymaj backup") { Task { await controller.stopBackup() } } + } + Button("Sprawdz spojnosc obrazu") { Task { await controller.verifyImage() } } + .disabled(controller.status.buffer.imageAttached || controller.status.isBusy) + Button("Odswiez") { Task { await controller.refreshAll() } } + Spacer() } - return controller.status.timeMachineState == .registered - ? "Backup lokalny gotowy".localized : "Wolumin nie zarejestrowany".localized } - private var subText: String { - if let err = controller.status.errorMessage { - return err + // MARK: - Log + + private var logCard: some View { + card("Ostatnie zdarzenia") { + ScrollView { + Text(controller.status.logTail.isEmpty ? "(pusto)" : controller.status.logTail) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(height: 140) } - guard controller.status.localVolume.exists else { - return "Zarejestruj dysk w Ustawieniach systemowych".localized + } + + // MARK: - Elementy wspolne + + private func card(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + content() } - if let used = controller.status.localVolume.usedGB { - return String(format: "%.1f GB na dysku lokalnym", used) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + + private func row(_ label: String, _ value: String, ok: Bool) -> some View { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + Text(value).foregroundStyle(ok ? Color.primary : Color.red) } - return "Wolumin lokalny istnieje".localized + .font(.callout) } } diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index ab2674c..de72735 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -1,161 +1,61 @@ -import AppKit -import CloudMachineCore import SwiftUI +/// Zawartosc ikony w pasku menu. Ma sie czytac jednym spojrzeniem - stan +/// i jedna liczba, ktora naprawde cos znaczy. Reszta jest w oknie glownym. struct MenuBarContentView: View { @EnvironmentObject private var controller: CloudMachineController @Environment(\.openWindow) private var openWindow var body: some View { - VStack(alignment: .leading, spacing: 12) { - - // Nagłówek panelu - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("CloudMachine") - .font(.headline.bold()) - Text("Time Machine Cloud Utility") - .font(.system(size: 9)) - .foregroundStyle(.secondary) - } - Spacer() - - // Pigułka statusu montowania - HStack(spacing: 4) { - Circle() - .fill(mountColor) - .frame(width: 6, height: 6) - Text(mountLabel) - .font(.caption2.bold()) - .foregroundStyle(mountColor) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(mountColor.opacity(0.1)) - .clipShape(Capsule()) - } - .padding(.bottom, 2) - - // Alert o błędzie - if let error = controller.status.errorMessage { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - .font(.caption) - Text(error) - .font(.system(size: 10)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.red.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: controller.status.healthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(controller.status.healthy ? .green : .orange) + Text(controller.status.headline).bold() } - // Sekcja wykorzystania lokalnego woluminu backupu - if let total = controller.status.localVolume.totalGB, - let used = controller.status.localVolume.usedGB - { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Lokalny wolumin".localized) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text(String(format: "%.1f GB / %.0f GB", used, total)) - .font(.caption.bold()) - .foregroundStyle(.primary) - } - - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.secondary.opacity(0.12)) - .frame(height: 6) - - Capsule() - .fill( - LinearGradient( - colors: controller.status.localVolume.usedFraction >= 0.9 - ? [.orange, .red] : [.accentColor, .blue], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame( - width: geo.size.width * CGFloat(controller.status.localVolume.usedFraction), - height: 6) - } - } - .frame(height: 6) - } - .padding(.vertical, 4) - } + Divider() - // Pasek aktualnej predkosci wysylania - jedyna czesc "postepu - // backupu" ktora ma sens w waskim popupie paska menu (jedna linia, - // bez wykresow), reszta (procent/pliki/ETA) zostaje w pelnym Panelu - // sterowania nizej. Widoczny tylko gdy backup faktycznie kopiuje. - if let progress = controller.status.backupProgress, let rate = progress.transferRateMBs, - rate > 0 - { + if let progress = controller.status.backupProgress, let percent = progress.percent { HStack { - Label("Wysyłanie".localized, systemImage: "arrow.up.circle") - .font(.caption) - .foregroundStyle(.secondary) + Text("Backup") Spacer() - Text(String(format: "%.1f MB/s", rate)) - .font(.caption.bold()) - .foregroundStyle(.primary) + Text(String(format: "%.1f%%", percent * 100)).monospacedDigit() } + .font(.callout) } - Divider() + // Kolejka wysylki mowi wiecej niz rozmiar bufora: dopoki nie wraca do + // zera miedzy backupami, dane sa jeszcze tylko lokalnie. + HStack { + Text("Czeka na wyslanie") + Spacer() + Text(controller.status.buffer.draining ? "\(controller.status.buffer.uploadsQueued)" : "nic") + .monospacedDigit() + .foregroundStyle(controller.status.buffer.draining ? .orange : .secondary) + } + .font(.callout) - // Montowanie i backup przenieione do pelnego Panelu sterowania (karta - // Status) - tam jest tez miejsce na info o postepie/predkosci, ktorego - // w waskim popupie paska menu nie dalo sie sensownie pokazac. + HStack { + Text("Bufor") + Spacer() + Text("\(controller.status.buffer.sizeGB) GB").monospacedDigit().foregroundStyle(.secondary) + } + .font(.callout) - // Menu sterowania / preferencji - HStack(spacing: 8) { - Button(action: { - openWindow(id: "dashboard") - NSApp.activate(ignoringOtherApps: true) - }) { - Label("Panel sterowania".localized, systemImage: "gauge") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.small) + Divider() - Button(action: { - NSApp.terminate(nil) - }) { - Label("Zakończ".localized, systemImage: "power") - } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(.red) + if controller.status.backupProgress == nil { + Button("Zrob backup teraz") { Task { await controller.startBackup() } } + .disabled(!controller.status.healthy) + } else { + Button("Wstrzymaj backup") { Task { await controller.stopBackup() } } } + Button("Otworz CloudMachine") { openWindow(id: "dashboard") } + Button("Zakoncz") { NSApplication.shared.terminate(nil) } } - .padding(12) - .frame(width: 270) - .task { - await controller.refreshAll() - } - } - - // MARK: - Pomocnicze zmienne statusu - - private var mountColor: Color { - guard controller.status.localVolume.exists else { return .secondary } - return controller.status.timeMachineState == .registered ? .green : .orange - } - - private var mountLabel: String { - guard controller.status.localVolume.exists else { return "Brak woluminu".localized } - return controller.status.timeMachineState == .registered - ? "Gotowy".localized : "Niezarejestrowany".localized + .padding(14) + .frame(width: 260) + .task { controller.startAutoRefresh(interval: 15) } } } diff --git a/mac-app/Sources/CloudMachineApp/Views/SetupWizardView.swift b/mac-app/Sources/CloudMachineApp/Views/SetupWizardView.swift deleted file mode 100644 index 942f1d8..0000000 --- a/mac-app/Sources/CloudMachineApp/Views/SetupWizardView.swift +++ /dev/null @@ -1,249 +0,0 @@ -import CloudMachineCore -import SwiftUI - -struct SetupWizardView: View { - @EnvironmentObject private var controller: CloudMachineController - - @State private var selectedDiskID: String? - @State private var shareName: String = "" - - private var selectedDisk: DiskCandidate? { - controller.status.networkShare.candidateDisks.first { $0.id == selectedDiskID } - } - - private func diskLabel(_ disk: DiskCandidate) -> String { - let kind = disk.isInternal ? "wewnętrzny" : "zewnętrzny" - if let totalGB = disk.totalGB { - return String(format: "%@ (%.0f GB, %@)", disk.name, totalGB, kind) - } - return "\(disk.name) (\(kind))" - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - // Nagłówek kreatora - VStack(alignment: .leading, spacing: 6) { - Text("Kreator konfiguracji CloudMachine") - .font(.title2.bold()) - Text( - "Przejdź przez poniższe kroki kolejno od góry do dołu. Kolejne kroki zostaną odblokowane automatycznie po ukończeniu poprzednich." - ) - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(.bottom, 4) - - if !controller.status.hasFullDiskAccess { - FullDiskAccessAlertBanner(action: { - controller.openFullDiskAccessSettings() - }) - } - - if let errorMessage = controller.status.errorMessage { - StatusAlertBanner(message: errorMessage) - } - - // KROK 1: Instalacja Zależności - let step1Done = controller.status.dependencyState == .ready - WizardStepCard( - number: 1, - title: "Zainstaluj wymagane narzędzia", - status: step1Done ? .completed : .active, - description: - "Aplikacja wymaga narzędzia 'rclone'. Kliknij poniżej, aby zainstalować je automatycznie za pomocą Homebrew." - ) { - Button(action: { - Task { await controller.installDependencies() } - }) { - Label( - step1Done ? "Zależności gotowe (Sprawdź ponownie)" : "Zainstaluj narzędzia", - systemImage: "square.and.arrow.down") - } - .buttonStyle(.bordered) - .tint(step1Done ? .green : .accentColor) - } - - // KROK 2: Połączenie z Google Drive - let step2Allowed = step1Done - let step2Done = controller.status.remoteConfigured - WizardStepCard( - number: 2, - title: "Połącz konto Google Drive", - status: step2Done ? .completed : (step2Allowed ? .active : .locked), - description: - "Kliknij przycisk, aby autoryzować rclone. Otworzy się przeglądarka internetowa, w której musisz zalogować się do swojego Google Drive." - ) { - Button(action: { - Task { await controller.connectGoogleDrive() } - }) { - Label( - step2Done ? "Połączono z Google Drive" : "Autoryzuj Google Drive", systemImage: "link" - ) - } - .buttonStyle(.bordered) - .tint(step2Done ? .green : .accentColor) - .disabled(!step2Allowed) - } - - // KROK 3: Udostepnienie lokalnego dysku w sieci (SMB) - let step3Done = controller.status.lastNetworkShare?.succeeded == true - WizardStepCard( - number: 3, - title: "Wybierz dysk i udostępnij go w sieci", - status: step3Done ? .completed : .active, - description: - "Wybierz lokalny dysk (np. podłączony dysk zewnętrzny) i udostępnij go przez SMB (File Sharing), żeby inny Mac w sieci lokalnej mógł go użyć jako własny cel Time Machine. Ten krok TYLKO włącza File Sharing i tworzy zwykły udział sieciowy - rejestrację jako cel Time Machine (dla tego i innych Maców) rób ręcznie w Ustawieniach systemowych -> Time Machine / Udostępnianie." - ) { - VStack(alignment: .leading, spacing: 10) { - if controller.status.networkShare.candidateDisks.isEmpty { - HStack(spacing: 12) { - Text( - "Nie znaleziono żadnego dysku pod /Volumes poza dyskiem systemowym. Podłącz dysk zewnętrzny i odśwież." - ) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button(action: { - Task { await controller.refreshNetworkShare() } - }) { - Label("Odśwież", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - } - } else { - Picker("Dysk:", selection: $selectedDiskID) { - Text("Wybierz dysk...").tag(String?.none) - ForEach(controller.status.networkShare.candidateDisks) { disk in - Text(diskLabel(disk)).tag(String?.some(disk.id)) - } - } - .pickerStyle(.menu) - .frame(maxWidth: 380) - - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Nazwa udziału sieciowego:") - .font(.caption.bold()) - .foregroundStyle(.secondary) - TextField("np. Network-MacBook", text: $shareName) - .textFieldStyle(.roundedBorder) - .frame(width: 220) - } - - Button(action: { - guard let disk = selectedDisk else { return } - Task { await controller.shareDiskOverNetwork(disk: disk, shareName: shareName) } - }) { - Label("Udostępnij w sieci", systemImage: "wifi") - } - .buttonStyle(.borderedProminent) - .disabled( - selectedDisk == nil || shareName.trimmingCharacters(in: .whitespaces).isEmpty) - } - - if !controller.status.networkShare.fileSharingEnabled { - Text("File Sharing jest obecnie wyłączony - zostanie włączony automatycznie.") - .font(.caption2) - .foregroundStyle(.secondary) - } - - if let result = controller.status.lastNetworkShare { - Text(result.message) - .font(.caption2) - .foregroundStyle(result.succeeded ? .green : .red) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - } - .padding() - } - .disabled(controller.status.isBusy) - } -} - -// MARK: - Komponent Karty Kroku Kreatora - -enum StepStatus { - case locked - case active - case completed - - var iconName: String { - switch self { - case .locked: return "lock.fill" - case .active: return "play.circle.fill" - case .completed: return "checkmark.circle.fill" - } - } - - var color: Color { - switch self { - case .locked: return .secondary - case .active: return .accentColor - case .completed: return .green - } - } -} - -struct WizardStepCard: View { - var number: Int - var title: String - var status: StepStatus - var description: String - var content: () -> Content - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - ZStack { - Circle() - .fill(status.color.opacity(0.12)) - .frame(width: 28, height: 28) - - Image(systemName: status.iconName) - .font(.body.bold()) - .foregroundStyle(status.color) - } - - Text("Krok \(number): \(title)") - .font(.headline) - .foregroundStyle(status == .locked ? .secondary : .primary) - - Spacer() - } - - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.leading, 38) - - if status != .locked { - VStack(alignment: .leading) { - content() - } - .padding(.leading, 38) - .padding(.top, 4) - } - } - .padding(16) - .background( - Color(nsColor: .windowBackgroundColor) - .opacity(status == .locked ? 0.5 : 1.0) - ) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(status == .locked ? 0 : 0.03), radius: 3, x: 0, y: 1) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke( - status == .active - ? Color.accentColor.opacity(0.5) - : (status == .completed ? Color.green.opacity(0.3) : Color.secondary.opacity(0.1)), - lineWidth: status == .active ? 1.5 : 1.0 - ) - ) - } -} diff --git a/mac-app/Sources/CloudMachineApp/Views/StatusView.swift b/mac-app/Sources/CloudMachineApp/Views/StatusView.swift deleted file mode 100644 index 4315858..0000000 --- a/mac-app/Sources/CloudMachineApp/Views/StatusView.swift +++ /dev/null @@ -1,551 +0,0 @@ -import SwiftUI - -struct StatusView: View { - @EnvironmentObject private var controller: CloudMachineController - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - if !controller.status.hasFullDiskAccess { - FullDiskAccessAlertBanner(action: { - controller.openFullDiskAccessSettings() - }) - } - - if let errorMessage = controller.status.errorMessage { - StatusAlertBanner(message: errorMessage) - } - - // Siatka głównych kart statusu - LazyVGrid( - columns: [GridItem(.flexible(), spacing: 16), GridItem(.flexible(), spacing: 16)], - spacing: 16 - ) { - // Karta 1: Stan Usług i Narzędzi - StatusCard(title: "Status usług i narzędzi", systemImage: "checklist") { - VStack(spacing: 12) { - StatusRow(title: "Zależności (rclone)", status: dependencyStatus) - StatusRow(title: "Konto Google Drive", status: driveConfigStatus) - StatusRow(title: "Lokalny wolumin backupu", status: localVolumeStatus) - StatusRow(title: "Time Machine", status: tmStatus) - StatusRow(title: "Archiwizacja w chmurze", status: archiveStatus) - } - } - - // Karta 2: Zużycie lokalnego woluminu backupu - StatusCard(title: "Wykorzystanie dysku lokalnego", systemImage: "chart.bar.fill") { - VStack(alignment: .leading, spacing: 14) { - if let total = controller.status.localVolume.totalGB, - let used = controller.status.localVolume.usedGB - { - QuotaProgressGauge( - usedGB: used, - limitGB: Int(total), - fraction: controller.status.localVolume.usedFraction, - isNearLimit: controller.status.localVolume.usedFraction >= 0.9 - ) - - if let free = controller.status.localVolume.freeContainerGB { - HStack { - Image(systemName: "internaldrive") - Text(String(format: "Realnie wolne miejsce na dysku: %.1f GB", free)) - } - .font(.caption2) - .foregroundStyle(.secondary) - } - } else { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.circle") - .font(.title2) - .foregroundStyle(.secondary) - Text("Brak lokalnego woluminu") - .font(.headline) - Text("Zarejestruj dysk jako cel Time Machine w Ustawieniach systemowych.") - .font(.caption) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - } - } - } - - // Karta 2b: Zywy postep aktualnie trwajacego backupu (widoczna tylko - // gdy Time Machine cos faktycznie kopiuje) - procent, dane, predkosc. - if let progress = controller.status.backupProgress { - BackupProgressCard(progress: progress) - } - - // Karta 2c: Archiwizacja w chmurze - drugi poziom architektury - // (patrz README): kopiuje ukonczone lokalne backupy na Google Drive. - StatusCard(title: "Archiwizacja w chmurze", systemImage: "icloud.and.arrow.up") { - VStack(alignment: .leading, spacing: 10) { - if !controller.status.remoteConfigured { - Text("Połącz konto Google Drive w zakładce Kreator, żeby włączyć archiwizację.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - HStack { - Label( - "\(controller.status.cloudArchive.archivedCount) zarchiwizowanych", - systemImage: "checkmark.icloud") - Spacer() - Label( - "\(controller.status.cloudArchive.pendingCount) oczekujących", - systemImage: "arrow.up.circle") - } - .font(.caption) - .foregroundStyle(.secondary) - - if let lastBackup = controller.status.cloudArchive.lastArchivedBackup { - Text("Ostatnio zarchiwizowany: \(lastBackup)") - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.head) - } - } - } - } - - // Karta 3: Ostatnie operacje (na całą szerokość) - StatusCard( - title: "Historia ostatnich operacji (ta sesja aplikacji)", - systemImage: "clock.arrow.circlepath" - ) { - VStack(spacing: 12) { - OperationResultRow( - title: "Weryfikacja spójności (checksums)", - result: controller.status.lastVerify, - placeholder: "Weryfikacja nie była jeszcze przeprowadzana." - ) - Divider() - OperationResultRow( - title: "Archiwizacja na Google Drive", - result: controller.status.lastArchive, - placeholder: "Archiwizacja nie była jeszcze przeprowadzana." - ) - Divider() - OperationResultRow( - title: "Udostępnienie dysku w sieci", - result: controller.status.lastNetworkShare, - placeholder: "Udostępnianie sieciowe nie było jeszcze przeprowadzane." - ) - } - } - - // Dolna sekcja akcji (Panel sterowania) - VStack(spacing: 12) { - HStack(spacing: 12) { - Button(action: { - Task { await controller.refreshAll(force: true) } - }) { - Label("Odśwież status", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - - Button(action: { - Task { await controller.verifyNow() } - }) { - Label("Weryfikuj sumy kontrolne", systemImage: "checkmark.shield") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - .disabled(!controller.status.localVolume.exists) - - Button(action: { - Task { await controller.archiveNow() } - }) { - Label("Archiwizuj teraz", systemImage: "icloud.and.arrow.up") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - .disabled(!controller.status.remoteConfigured || !controller.status.localVolume.exists) - } - } - .disabled(controller.status.isBusy) - .padding(.top, 8) - } - .padding() - } - } - - // MARK: - Mapowanie stanów na badges - - private var dependencyStatus: StatusType { - switch controller.status.dependencyState { - case .ready: return .active("Gotowe") - case .checking: return .warning("Sprawdzanie...") - case .missing: return .error("Brak narzędzi") - case .unknown: return .inactive("Nieznany") - } - } - - private var driveConfigStatus: StatusType { - controller.status.remoteConfigured ? .active("Połączone") : .inactive("Nieskonfigurowane") - } - - private var localVolumeStatus: StatusType { - controller.status.localVolume.exists ? .active("Utworzony") : .inactive("Nie istnieje") - } - - private var tmStatus: StatusType { - switch controller.status.timeMachineState { - case .registered: return .active("Zarejestrowany") - case .notRegistered: return .inactive("Niezarejestrowany") - case .unknown: return .inactive("Nieznany") - } - } - - private var archiveStatus: StatusType { - guard controller.status.remoteConfigured else { return .inactive("Nieskonfigurowane") } - let pending = controller.status.cloudArchive.pendingCount - return pending > 0 ? .warning("\(pending) oczekuje") : .active("Aktualne") - } -} - -// MARK: - Komponenty pomocnicze UI - -enum StatusType { - case active(String) - case warning(String) - case error(String) - case inactive(String) - - var text: String { - switch self { - case .active(let t), .warning(let t), .error(let t), .inactive(let t): return t - } - } - - var color: Color { - switch self { - case .active: return .green - case .warning: return .orange - case .error: return .red - case .inactive: return .gray - } - } -} - -struct StatusRow: View { - var title: String - var status: StatusType - - var body: some View { - HStack { - Text(title) - .foregroundStyle(.primary) - .font(.body) - Spacer() - Text(status.text) - .font(.caption.bold()) - .foregroundStyle(status.color) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(status.color.opacity(0.12)) - .clipShape(Capsule()) - .overlay( - Capsule() - .stroke(status.color.opacity(0.3), lineWidth: 1) - ) - } - } -} - -struct StatusCard: View { - var title: String - var systemImage: String - var content: () -> Content - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 8) { - Image(systemName: systemImage) - .font(.headline) - .foregroundStyle(Color.accentColor) - Text(title) - .font(.headline) - .foregroundStyle(.primary) - } - Divider() - content() - } - .padding(16) - .background(Color(nsColor: .windowBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 2) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.secondary.opacity(0.15), lineWidth: 1) - ) - } -} - -struct QuotaProgressGauge: View { - var usedGB: Double - var limitGB: Int - var fraction: Double - var isNearLimit: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Zużycie przestrzeni:") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - Text(String(format: "%.1f%%", fraction * 100)) - .font(.headline.bold()) - .foregroundStyle(isNearLimit ? .orange : .accentColor) - } - - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.secondary.opacity(0.12)) - .frame(height: 12) - - Capsule() - .fill( - LinearGradient( - colors: isNearLimit ? [.orange, .red] : [.accentColor, .blue], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: geo.size.width * CGFloat(fraction), height: 12) - } - } - .frame(height: 12) - - HStack { - Text(String(format: "%.1f GB użyte", usedGB)) - .font(.subheadline.bold()) - Spacer() - Text("Limit: \(limitGB) GB") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.top, 4) - } - } -} - -/// Karta z zywym postepem trwajacego backupu Time Machine - pokazuje sie -/// tylko wtedy, gdy `tmutil status` faktycznie raportuje aktywne kopiowanie -/// (patrz `CloudMachineController.refreshBackupProgress`). -struct BackupProgressCard: View { - var progress: BackupProgressInfo - - var body: some View { - StatusCard(title: "Postęp backupu", systemImage: "clock.arrow.2.circlepath") { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(phaseLabel) - .font(.subheadline.bold()) - Spacer() - if let percent = progress.percent { - Text(String(format: "%.1f%%", percent * 100)) - .font(.headline.bold()) - .foregroundStyle(Color.accentColor) - } - } - - if let percent = progress.percent { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.secondary.opacity(0.12)) - .frame(height: 10) - Capsule() - .fill( - LinearGradient( - colors: [.accentColor, .blue], startPoint: .leading, endPoint: .trailing) - ) - .frame(width: geo.size.width * CGFloat(min(percent, 1.0)), height: 10) - } - } - .frame(height: 10) - } - - HStack { - if let bytesDone = progress.bytesDone, let bytesTotal = progress.bytesTotal, - bytesTotal > 0 - { - Label( - "\(formatGB(bytesDone)) / \(formatGB(bytesTotal))", systemImage: "internaldrive" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if let rate = progress.transferRateMBs, rate > 0 { - Label(String(format: "%.1f MB/s", rate), systemImage: "speedometer") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - HStack { - if let filesDone = progress.filesDone, let filesTotal = progress.filesTotal { - Label( - "\(filesDone.formatted()) / \(filesTotal.formatted()) plików", - systemImage: "doc.on.doc" - ) - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - if let remaining = progress.timeRemainingSeconds, remaining > 0 { - Label("Pozostało: \(formatDuration(remaining))", systemImage: "hourglass") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - } - } - - private var phaseLabel: String { - switch progress.phase { - case "Copying": return "Kopiowanie danych" - case "ThinningPreBackup", "ThinningPostBackup": return "Zwalnianie miejsca (thinning)" - case "FindingChanges": return "Wyszukiwanie zmian" - case "MountingBackupVolume": return "Montowanie wolumenu backupu" - case "Mounting": return "Montowanie" - case "CleaningUp": return "Porządkowanie" - case .some(let other): return other - case nil: return "Backup w toku" - } - } - - private func formatGB(_ bytes: Double) -> String { - String(format: "%.1f GB", bytes / 1_073_741_824) - } - - private func formatDuration(_ seconds: Double) -> String { - let totalMinutes = Int(seconds / 60) - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - if hours > 0 { - return "\(hours) godz. \(minutes) min" - } - return "\(minutes) min" - } -} - -struct OperationResultRow: View { - var title: String - var result: LastRunResult? - var placeholder: String - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.subheadline.bold()) - - if let result { - HStack(alignment: .top, spacing: 8) { - Image( - systemName: result.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" - ) - .foregroundStyle(result.succeeded ? .green : .red) - .font(.title3) - - VStack(alignment: .leading, spacing: 2) { - Text(result.message) - .font(.body) - .fixedSize(horizontal: false, vertical: true) - - Text(result.date.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } else { - Text(placeholder) - .font(.callout) - .foregroundStyle(.secondary) - .italic() - .padding(.vertical, 2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -struct StatusAlertBanner: View { - var message: String - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.octagon.fill") - .font(.title2) - .foregroundStyle(.red) - VStack(alignment: .leading, spacing: 4) { - Text("Problem z działaniem") - .font(.headline) - .foregroundStyle(.red) - Text(message) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(14) - .background(Color.red.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.red.opacity(0.25), lineWidth: 1) - ) - } -} - -struct FullDiskAccessAlertBanner: View { - var action: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 14) { - Image(systemName: "lock.shield.fill") - .font(.largeTitle) - .foregroundStyle(.orange) - - VStack(alignment: .leading, spacing: 6) { - Text("Wymagany Pełny dostęp do dysku (Full Disk Access)") - .font(.headline) - .foregroundStyle(.primary) - Text( - "System macOS wymaga tego uprawnienia, aby aplikacja mogła sterować kopiami zapasowymi Time Machine. Kliknij poniższy przycisk, przejdź do Ustawień systemowych i dodaj aplikację CloudMachine oraz Terminal do listy." - ) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button(action: action) { - Label("Otwórz Ustawienia systemowe", systemImage: "gearshape") - } - .buttonStyle(.borderedProminent) - .tint(.orange) - .padding(.top, 4) - } - Spacer() - } - .padding(16) - .background(Color.orange.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.orange.opacity(0.3), lineWidth: 1) - ) - } -} diff --git a/mac-app/Sources/CloudMachineCore/ArchiveWatchdogService.swift b/mac-app/Sources/CloudMachineCore/ArchiveWatchdogService.swift deleted file mode 100644 index f80c2f0..0000000 --- a/mac-app/Sources/CloudMachineCore/ArchiveWatchdogService.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -/// Automatyzuje `CloudArchiveService.archivePending` - odpalane periodycznie -/// przez launchd (patrz `com.renacode.cloudmachine.archive-watchdog.plist.template`), -/// ale rzeczywista archiwizacja odpala sie co najwyzej raz na `intervalHours` -/// godzin, zeby nie kolidowac z toczacym sie backupem lokalnym ani nie -/// wysycac lacza w kolko bez potrzeby. -public enum ArchiveWatchdogService { - public static var intervalHours: Int { - if let env = ProcessInfo.processInfo.environment["CM_ARCHIVE_INTERVAL_HOURS"], - let value = Int(env) - { - return value - } - return 6 - } - - private static var stateFile: URL { - CMPaths.logDir.appendingPathComponent(".archive-watchdog-last-run") - } - - /// Blokada TUTAJ ma inna nazwe niz ta w `CloudArchiveService.archivePending` - /// (ktora chroni sam transfer przed rownoleglymi wywolaniami z GUI/CLI/ - /// watchdoga) - ta pilnuje wylacznie, zeby dwa nakladajace sie odpalenia - /// TEGO watchdoga (np. launchd + reczne uruchomienie w tej samej chwili) - /// nie sciegaly sie o plik stanu cooldownu. Gdyby uzyc tej samej nazwy, - /// wewnetrzna blokada `archivePending` zawsze zawodzilaby (juz trzymana - /// przez ten sam proces) i watchdog nigdy nie archiwizowalby niczego. - public static func run(config: MachinesConfig, machineKey: String) async { - await withCMLock("archive-watchdog-schedule") { - await runLocked(config: config, machineKey: machineKey) - } - } - - private static func runLocked(config: MachinesConfig, machineKey: String) async { - let lastEpoch = CooldownGate.parseStateFile(stateFile) - guard - !CooldownGate.isWithinCooldown(lastEpoch: lastEpoch, cooldown: Double(intervalHours * 3600)) - else { return } - - CooldownGate.writeStateFile(stateFile) - - let result = await CloudArchiveService.archivePending(config: config, machineKey: machineKey) - if result.succeeded { - CMLogger.log("[archive-watchdog] \(result.message)") - } else { - CMLogger.log("[archive-watchdog] BLAD: \(result.message)") - } - } -} diff --git a/mac-app/Sources/CloudMachineCore/BackupVerifier.swift b/mac-app/Sources/CloudMachineCore/BackupVerifier.swift deleted file mode 100644 index dcad159..0000000 --- a/mac-app/Sources/CloudMachineCore/BackupVerifier.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -/// GLOWNE narzedzie do oceny, czy backup Time Machine na lokalnym wolumenie -/// APFS jest spojny. Wspoldzielone miedzy CLI (`verify-backup`, pelny raport -/// diagnostyczny) i GUI (przycisk "Weryfikuj sumy kontrolne" w Statusie, -/// ktory dodatkowo uzywa wlasnego mechanizmu autoryzacji AppleScript zamiast -/// `sudo -n` - stad `latestBackupPath` jest wydzielone jako osobny, -/// przywilej-agnostyczny krok, a `verifychecksums` samo GUI woła po swojemu). -public enum BackupVerifier { - /// Najnowszy backup na danym wolumenie, albo `nil` jesli brak. - public static func latestBackupPath(mountPoint: String) async -> String? { - guard - let result = try? await ProcessRunner.run( - "/usr/bin/tmutil", ["listbackups", "-d", mountPoint]) - else { - return nil - } - return result.stdout.split(separator: "\n").last.map(String.init) - } - - /// Pelna sekwencja diagnostyczna (uzywana przez CLI): destinationinfo, - /// listbackups, verifychecksums (przez `sudo -n`, wymaga skonfigurowanej - /// reguly sudoers). - /// - /// WAZNE: nie ma tu kroku porownania z Google Drive - w tej architekturze - /// lokalny wolumin APFS JEST zrodlem prawdy, a archiwizacja do chmury - /// (`CloudArchiveService`, dzialajaca produkcyjnie) ma wlasny, osobny stan - /// (`archived-backups.json`) i wlasny watchdog (`archive-watchdog`) - - /// swiadomie POZA tym sprawdzeniem integralnosci lokalnego backupu, zeby - /// nie mieszac "czy lokalny backup jest spojny" z "czy zdazyl juz trafic - /// do chmury". - public static func runFullCheck() async -> CMActionResult { - let volume = await LocalBackupService.currentStatus() - guard let mountPoint = volume.mountPoint, volume.exists else { - return CMActionResult( - succeeded: false, message: "Lokalny wolumin backupu jeszcze nie istnieje.") - } - - CMLogger.log("=== 1/3: tmutil destinationinfo ===") - if let info = try? await ProcessRunner.run("/usr/bin/tmutil", ["destinationinfo"]) { - print(info.stdout) - } - - CMLogger.log("=== 2/3: lista backupow (tmutil listbackups) ===") - if let list = try? await ProcessRunner.run( - "/usr/bin/tmutil", ["listbackups", "-d", mountPoint]) - { - print(list.stdout) - } - - guard let latestBackup = await latestBackupPath(mountPoint: mountPoint) else { - return CMActionResult( - succeeded: false, - message: - "Brak backupu do zweryfikowania. Odpal najpierw: sudo tmutil startbackup --auto --block") - } - - CMLogger.log( - "=== 3/3: weryfikacja sum kontrolnych najnowszego backupu: \(latestBackup) (moze potrwac dlugo) ===" - ) - // Bez sztywnego limitu czasu - weryfikacja duzego backupu moze legalnie - // trwac dlugo, timeout tutaj zabijalby prawidlowo dzialajacy proces. - let verifyResult = try? await ProcessRunner.runTmutilUnattended( - ["verifychecksums", latestBackup]) - guard let verifyResult else { - return CMActionResult( - succeeded: false, - message: "verifychecksums nie odpowiedzialo (blad uruchomienia procesu).") - } - // WAZNE: brak reguly sudoers NOPASSWD dla 'tmutil verifychecksums' NIE - // jest tym samym co uszkodzony backup - to zwykly blad konfiguracji. - // Wczesniej oba przypadki zwracaly ten sam, alarmujacy komunikat - // sugerujacy uszkodzenie/niewiarygodnosc backupu. - guard !verifyResult.isSudoAuthFailure else { - return CMActionResult( - succeeded: false, - message: - "verifychecksums wymaga hasla sudo - to NIE oznacza uszkodzenia backupu, tylko brak reguly sudoers. Dopisz regule recznie w /etc/sudoers.d/cloudmachine (patrz README)." - ) - } - guard verifyResult.succeeded else { - // Niezerowy kod wyjscia NIE zawsze oznacza uszkodzone sumy kontrolne - - // moze to byc tez czysto operacyjny problem (dysk zniknal w trakcie - // weryfikacji: odlaczony dysk zewnetrzny, wybudzenie ze snu). Sprawdzamy - // wiec, czy wolumin nadal istnieje, zeby nie strasze uzytkownika - // falszywym "uszkodzony backup" za zwykly zanik polaczenia z dyskiem. - guard await LocalBackupService.currentStatus().exists else { - return CMActionResult( - succeeded: false, - message: - "verifychecksums nie powiodlo sie, ale wolumin lokalny zniknal w trakcie weryfikacji (dysk odlaczony/uspiony?) - to problem operacyjny, nie potwierdzenie uszkodzonych danych." - ) - } - return CMActionResult( - succeeded: false, message: "verifychecksums zglosilo problem z backupem.") - } - CMLogger.log("OK: sumy kontrolne sie zgadzaja.") - - return CMActionResult( - succeeded: true, message: "Wszystkie testy przeszly. Backup wyglada na spojny.") - } -} diff --git a/mac-app/Sources/CloudMachineCore/CloudArchiveService.swift b/mac-app/Sources/CloudMachineCore/CloudArchiveService.swift deleted file mode 100644 index 8386c50..0000000 --- a/mac-app/Sources/CloudMachineCore/CloudArchiveService.swift +++ /dev/null @@ -1,285 +0,0 @@ -import Foundation - -/// Warstwa archiwizacji w chmurze - drugi z dwoch poziomow architektury -/// (patrz README): lokalny wolumin APFS (`LocalBackupService`) jest szybkim, -/// w pelni trwalym celem Time Machine; ten serwis periodycznie kopiuje -/// ukonczone, "zimne" backupy z lokalnego woluminu na Google Drive przez -/// rclone, dajac retencje wykraczajaca poza lokalny limit. -/// -/// Bezpieczenstwo spojnosci: `tmutil listbackups` pokazuje WYLACZNIE w pelni -/// ukonczone backupy (te w trakcie kopiowania widnieja jako `.inProgress` i -/// nie pojawiaja sie na tej liscie), a dodatkowo NIE archiwizujemy, gdy Time -/// Machine aktywnie pisze (`TimeMachineStatus.isRunning()`) - te dwa -/// zabezpieczenia razem gwarantuja, ze zawartosc backupu jest w pelni -/// statyczna podczas kopiowania przez rclone. -/// -/// WAZNE - kazdy lokalny backup na wolumenie APFS jest w rzeczywistosci -/// snapshotem APFS (`com.apple.TimeMachine..backup`), NIE zwyklym, -/// zawsze dostepnym katalogiem - potwierdzone na zywo: `tmutil listbackups` -/// zwraca sciezke pod `/Volumes/.timemachine//...`, ale zwykle -/// `stat`/`rclone` na tej sciezce dostaja "No such file or directory", bo nic -/// nie zamontowalo tego konkretnego snapshotu do globalnej przestrzeni nazw -/// systemu plikow (`diskutil apfs listSnapshots` pokazuje snapshoty, ale -/// `mount` nie pokazuje ich jako zamontowane, dopoki cos jawnie tego nie -/// zrobi - Finder/Migration Assistant robia to pod maska, generyczne -/// narzedzia jak `rclone` - nie). Dlatego kazda archiwizacja NAJPIERW montuje -/// wlasciwy snapshot tymczasowo (`mount_apfs -s`, bez potrzeby sudo - to -/// wolumin wlasny uzytkownika), kopiuje z tego mountu, i odmontowuje zaraz -/// po (patrz `mountSnapshot`/`unmountSnapshot`). -public enum CloudArchiveService { - public struct ArchiveStatus: Equatable { - public var lastArchivedBackup: String? - public var lastArchivedDate: Date? - public var archivedCount: Int - public var pendingCount: Int - } - - private static var stateFile: URL { - CMPaths.appSupportDir.appendingPathComponent("archived-backups.json") - } - - /// Nazwy (ostatni segment sciezki, np. "2026-07-28-160440.backup") backupow - /// juz skopiowanych do chmury. - static func loadArchivedNames() -> Set { - guard let data = try? Data(contentsOf: stateFile), - let names = try? JSONDecoder().decode([String].self, from: data) - else { return [] } - return Set(names) - } - - private static func saveArchivedNames(_ names: Set) { - guard let data = try? JSONEncoder().encode(Array(names).sorted()) else { return } - try? data.write(to: stateFile, options: .atomic) - } - - private static func archiveStateModifiedDate() -> Date? { - (try? FileManager.default.attributesOfItem(atPath: stateFile.path))?[.modificationDate] - as? Date - } - - /// Konwertuje limit predkosci wysylania z `MachinesConfig.bwLimitMbps` - /// (Mbps - megabity/s, jak u dostawcow internetu) na argumenty `rclone - /// --bwlimit`, ktore oczekuje megaBAJTOW/s (`8 Mbps = 1 MB/s`). `0` = bez - /// limitu, wiec bez tego argumentu w ogole. - static func bwLimitArgs(forMbps mbps: Int) -> [String] { - guard mbps > 0 else { return [] } - let megabytesPerSecond = Double(mbps) / 8 - return ["--bwlimit", String(format: "%.2fM", megabytesPerSecond)] - } - - /// Katalog, pod ktorym tymczasowo montujemy dokladnie JEDEN snapshot na - /// raz do kopiowania - staly, nie unikalny per-proba, bo `archivePending` - /// i tak trzyma wylaczna blokade `withCMLock` przez caly czas dzialania - /// (patrz nizej), wiec nie ma ryzyka dwoch rownoleglych montowan tutaj. - private static var snapshotMountDir: URL { - CMPaths.appSupportDir.appendingPathComponent("archive-snapshot-mount") - } - - /// Sprawdza, czy sciezka backupu zwrocona przez `tmutil listbackups` jest - /// JUZ bezposrednio czytelna bez wlasnego mountowania - na niektorych - /// wersjach/stanach macOS system SAM montuje niedawne lokalne snapshoty TM - /// pod ta sama sciezka (potwierdzone na zywo: `com.apple.TimeMachine. - /// ` bylo juz zamontowane przez system, wiec nasz wlasny - /// `mount_apfs -s` dostawal "Resource busy" - podwojne mountowanie tego - /// samego snapshotu, NIE dowod ze zostal usuniety - a kod mylnie - /// interpretowal kazde niepowodzenie mountowania jako "TM go juz skasowal" - /// i trwale blokowal archiwizacje). - private static func isDirectlyAccessible(path: String) -> Bool { - (try? FileManager.default.contentsOfDirectory(atPath: path))?.isEmpty == false - } - - /// Montuje snapshot APFS danego backupu (`com.apple.TimeMachine.`) - /// pod `snapshotMountDir`, zeby jego zawartosc byla widoczna dla zwyklych - /// operacji na plikach (patrz uzasadnienie w komentarzu na gorze pliku). - /// Zwraca sciezke do faktycznej zawartosci backupu wewnatrz mountu, albo - /// `nil` przy niepowodzeniu (np. snapshot juz zostal usuniety przez - /// automatyczne przycinanie Time Machine miedzy `tmutil listbackups` a tym - /// wywolaniem - lokalny wolumin moze byc pod duza presja miejsca). - private static func mountSnapshot(backupName: String, volumeMountPoint: String) async - -> String? - { - let mountDir = snapshotMountDir - try? FileManager.default.removeItem(at: mountDir) - guard - (try? FileManager.default.createDirectory( - at: mountDir, withIntermediateDirectories: true)) != nil - else { return nil } - - let snapshotName = "com.apple.TimeMachine.\(backupName)" - let result = try? await ProcessRunner.run( - "/sbin/mount_apfs", ["-s", snapshotName, volumeMountPoint, mountDir.path], timeout: 30) - guard result?.succeeded == true else { - try? FileManager.default.removeItem(at: mountDir) - return nil - } - return mountDir.appendingPathComponent(backupName).path - } - - /// Odmontowuje i sprzata katalog zamontowany przez `mountSnapshot` - - /// wywolywane BEZWARUNKOWO po probie kopiowania (sukces czy porazka), zeby - /// nie zostawic osieroconego mountu snapshotu na kolejny przebieg. - private static func unmountSnapshot() async { - let mountDir = snapshotMountDir - _ = try? await ProcessRunner.run("/usr/sbin/diskutil", ["unmount", mountDir.path], timeout: 30) - try? FileManager.default.removeItem(at: mountDir) - } - - /// Pelne sciezki lokalnych backupow (posortowane od najstarszego przez - /// `tmutil`), albo `[]` przy bledzie/braku backupow. - public static func localBackups(mountPoint: String) async -> [String] { - guard - let result = try? await ProcessRunner.run( - "/usr/bin/tmutil", ["listbackups", "-d", mountPoint], timeout: 60), - result.succeeded - else { return [] } - return result.stdout.split(separator: "\n").map(String.init).filter { !$0.isEmpty } - } - - public static func currentStatus(config: MachinesConfig, machineKey: String) async - -> ArchiveStatus - { - let volume = await LocalBackupService.currentStatus() - let archived = loadArchivedNames() - guard let mountPoint = volume.mountPoint, volume.exists else { - return ArchiveStatus( - lastArchivedBackup: nil, lastArchivedDate: nil, archivedCount: archived.count, - pendingCount: 0) - } - let backups = await localBackups(mountPoint: mountPoint) - let pendingCount = backups.reduce(0) { count, path in - archived.contains(URL(fileURLWithPath: path).lastPathComponent) ? count : count + 1 - } - return ArchiveStatus( - lastArchivedBackup: archived.sorted().last, - lastArchivedDate: archiveStateModifiedDate(), - archivedCount: archived.count, - pendingCount: pendingCount - ) - } - - /// Kopiuje wszystkie jeszcze nie zarchiwizowane backupy do Google Drive, w - /// kolejnosci od najstarszego. PRZERYWA (nie pomija) przy pierwszym - /// bledzie rclone zamiast probowac kolejny - kazdy nastepny backup jest - /// hardlinkowany do poprzedniego, wiec pomijanie zerwałoby ciaglosc - /// historii po stronie chmury bez wyraznego ostrzezenia. - /// - /// Blokada `withCMLock` jest TUTAJ (nie tylko w `ArchiveWatchdogService`), - /// zeby chronic przed KAZDYM wywolujacym rownolegle - GUI ("Archiwizuj - /// teraz"), `cloudmachine-agent archive-now` i sam watchdog moga wystartowac - /// w tej samej chwili z osobnych procesow; bez wspolnej blokady dwa - /// rownlegle przebiegi scigalyby sie o niezablokowany odczyt-modyfikacje- - /// zapis `archived-backups.json`, mogac zgubic wpisy albo odpalic - /// zdublowane, wielogodzinne kopiowanie tego samego backupu. - @discardableResult - public static func archivePending(config: MachinesConfig, machineKey: String) async - -> CMActionResult - { - guard - let result = await withCMLock( - "archive-watchdog", - { - await archivePendingLocked(config: config, machineKey: machineKey) - }) - else { - return CMActionResult( - succeeded: false, - message: "Inna archiwizacja juz trwa (z innego procesu) - pomijam ten przebieg.") - } - return result - } - - private static func archivePendingLocked(config: MachinesConfig, machineKey: String) async - -> CMActionResult - { - let volume = await LocalBackupService.currentStatus() - guard let mountPoint = volume.mountPoint, volume.exists else { - return CMActionResult( - succeeded: false, message: "Lokalny wolumin backupu jeszcze nie istnieje.") - } - guard !(await TimeMachineStatus.isRunning()) else { - return CMActionResult( - succeeded: false, - message: "Time Machine aktywnie zapisuje - pomijam archiwizacje do nastepnego przebiegu.") - } - - let backups = await localBackups(mountPoint: mountPoint) - guard !backups.isEmpty else { - return CMActionResult(succeeded: true, message: "Brak lokalnych backupow do zarchiwizowania.") - } - - var archived = loadArchivedNames() - let remotePath = config.remotePath(forMachineKey: machineKey) - var copiedCount = 0 - let bwLimitArgs = bwLimitArgs(forMbps: config.bwLimitMbps) - // Najnowszy JUZ zarchiwizowany backup (z tego albo poprzedniego przebiegu) - // - przekazywany do rclone jako `--copy-dest`, zeby pliki niezmienione - // wzgledem niego trafialy do nowego folderu przez SZYBKA kopie po stronie - // Google Drive (bez ponownego wysylania tych samych bajtow z Maca). - // Bez tego kazdy kolejny backup przesylalby caly swoj rozmiar OD NOWA, - // mimo ze wiekszosc plikow jest identyczna z poprzednim (lokalnie - // zaoszczedzone dzieki hardlinkom, ktorych `rclone` po prostu nie widzi). - var previousArchivedName = archived.sorted().last - - for backupPath in backups { - let name = URL(fileURLWithPath: backupPath).lastPathComponent - guard !archived.contains(name) else { continue } - - // Niektore wersje/stany macOS montuja niedawne lokalne snapshoty TM - // pod ta sama sciezka SAME - wtedy wlasny mount jest niepotrzebny i - // koliduje ("Resource busy"), patrz komentarz przy `isDirectlyAccessible`. - let usedCustomMount: Bool - let sourcePath: String - if isDirectlyAccessible(path: backupPath) { - usedCustomMount = false - sourcePath = backupPath - } else if let mounted = await mountSnapshot(backupName: name, volumeMountPoint: mountPoint) { - usedCustomMount = true - sourcePath = mounted - } else { - return CMActionResult( - succeeded: false, - message: - "Nie udalo sie zamontowac snapshotu \(name) do archiwizacji (mogl zostac usuniety" - + " przez Time Machine w miedzyczasie) - przerywam, zeby nie zerwac kolejnosci historii." - ) - } - - var copyArgs = ["copy", sourcePath, "\(remotePath)/\(name)"] + bwLimitArgs - if let previousArchivedName { - copyArgs += ["--copy-dest", "\(remotePath)/\(previousArchivedName)"] - } - CMLogger.log("[cloud-archive] Kopiuje \(name) do \(remotePath)...") - // Bez sztywnego limitu czasu - rozmiar backupu jest nieprzewidywalny, - // a przerwanie kopiowania w polowie (SIGKILL) zostawia niespojny stan - // (osierocony czesciowy folder na Drive, a przy pechowym timingu - - // niedomontowany lokalny wolumin, patrz incydent z 2026-07-29). - // ProcessRunner i tak czeka na realne zakonczenie procesu. - let result = try? await ProcessRunner.runRclone(copyArgs) - if usedCustomMount { - await unmountSnapshot() - } - - guard result?.succeeded == true else { - let detail = result?.stderr.isEmpty == false ? result!.stderr : (result?.stdout ?? "") - return CMActionResult( - succeeded: false, - message: - "Archiwizacja \(name) nie powiodla sie - przerywam, zeby nie zerwac kolejnosci historii." - + " \(detail)" - ) - } - archived.insert(name) - saveArchivedNames(archived) - previousArchivedName = name - copiedCount += 1 - CMLogger.log("[cloud-archive] OK: \(name) zarchiwizowany.") - } - - guard copiedCount > 0 else { - return CMActionResult( - succeeded: true, message: "Wszystko juz zarchiwizowane, nic do zrobienia.") - } - return CMActionResult( - succeeded: true, message: "Zarchiwizowano \(copiedCount) backup(ow) do Google Drive.") - } -} diff --git a/mac-app/Sources/CloudMachineCore/CooldownGate.swift b/mac-app/Sources/CloudMachineCore/CooldownGate.swift deleted file mode 100644 index 9bb91e3..0000000 --- a/mac-app/Sources/CloudMachineCore/CooldownGate.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -/// Czysta logika "czy minal juz cooldown od ostatniej proby" - wydzielona z -/// `BackupWatchdogService`/`VerifyWatchdogService` (obie mialy identyczna, -/// zduplikowana logike zmieszana z odczytem pliku stanu), zeby dalo sie ja -/// przetestowac bez dotykania systemu plikow. -public enum CooldownGate { - /// `true`, jesli od `lastEpoch` (unix timestamp z pliku stanu, `nil` gdy - /// nigdy jeszcze nie probowano) minelo mniej niz `cooldown` sekund - czyli - /// NALEZY POMINAC ten przebieg. - public static func isWithinCooldown( - lastEpoch: Double?, now: Date = Date(), cooldown: TimeInterval - ) -> Bool { - guard let lastEpoch else { return false } - return now.timeIntervalSince1970 - lastEpoch < cooldown - } - - /// Parsuje zawartosc pliku stanu (tekstowy unix timestamp) - `nil`, jesli - /// plik nie istnieje albo zawiera smieci. - public static func parseStateFile(_ url: URL) -> Double? { - guard let raw = try? String(contentsOf: url, encoding: .utf8) else { return nil } - return Double(raw.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - public static func writeStateFile(_ url: URL, now: Date = Date()) { - try? "\(Int(now.timeIntervalSince1970))".write(to: url, atomically: true, encoding: .utf8) - } -} diff --git a/mac-app/Sources/CloudMachineCore/LocalBackupService.swift b/mac-app/Sources/CloudMachineCore/LocalBackupService.swift deleted file mode 100644 index b650717..0000000 --- a/mac-app/Sources/CloudMachineCore/LocalBackupService.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation - -/// Odczytuje stan lokalnego celu Time Machine w architekturze "local-first": -/// prawdziwy lokalny wolumin APFS jako cel backupu (bez montowania -/// sieciowego/sparsebundle - patrz legacy `MountService`/`UnmountService` -/// usuniete razem z ta zmiana). Tworzenie woluminu i rejestrowanie go jako -/// cel Time Machine NIE jest juz zadaniem tej apki - uzytkownik robi to sam -/// (System Settings -> Time Machine), ta warstwa tylko CZYTA, gdziekolwiek -/// wskazuje aktualnie zarejestrowany cel, zeby archiwizacja do chmury -/// (`CloudArchiveService`) i weryfikacja sum kontrolnych (`BackupVerifier`/ -/// `VerifyWatchdogService`) mialy skad kopiowac/co sprawdzac. -public enum LocalBackupService { - public static let defaultVolumeName = "CloudMachine-Local" - - public struct VolumeStatus: Equatable { - public var exists: Bool - public var mountPoint: String? - public var totalGB: Double? - public var usedGB: Double? - public var freeContainerGB: Double? - public var destinationID: String? - } - - /// Sprawdza, czy wolumin o danej nazwie juz istnieje w podanym kontenerze. - public static func currentStatus(volumeName: String = defaultVolumeName) async -> VolumeStatus { - // Najpierw pytamy Time Machine o RZECZYWISTY zarejestrowany mount point - - // dziala niezaleznie od tego, jak nazwany jest wolumin. Zgadywanie po - // `volumeName` to tylko fallback na czas konfiguracji, zanim jakikolwiek - // cel zostanie zarejestrowany (patrz komentarz w TimeMachineStatus). - let mountPoint = - await TimeMachineStatus.currentDestinationMountPoint() ?? "/Volumes/\(volumeName)" - guard - let result = try? await ProcessRunner.run( - "/usr/sbin/diskutil", ["info", "-plist", mountPoint], timeout: 15), - result.succeeded, - let data = result.stdout.data(using: .utf8), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) - as? [String: Any] - else { - return VolumeStatus( - exists: false, mountPoint: nil, totalGB: nil, usedGB: nil, freeContainerGB: nil, - destinationID: nil) - } - // WAZNE: `diskutil info -plist` NIE ma klucza "VolumeUsedSpace" (ani - // "ContainerTotalFreeSpace") dla wolumenu APFS - potwierdzone na zywo: - // te odczyty ZAWSZE zwracaly `nil`, przez co StatusView (ktore wymaga - // OBU `totalGB` i `usedGB` naraz do pokazania cokolwiek) zawsze - // pokazywalo "Brak lokalnego woluminu", mimo ze wolumin istnial i - // dzialal. Faktyczne wykorzystanie TEJ konkretnej objetosci to - // "CapacityInUse", a realnie wolne miejsce w kontenerze to - // "APFSContainerFree" - `TotalSize`/`Size` to pojemnosc CALEGO - // kontenera (dzielona ze wszystkimi wolumenami), nie limit tego - // wolumenu, wiec dla "total" wolimy limit TM z `tmutil destinationinfo` - // (Quota), jesli jest dostepny - duzo bardziej znaczacy dla uzytkownika - // niz surowa pojemnosc calego dysku. - let usedBytes = plist["CapacityInUse"] as? Double - let freeBytes = plist["APFSContainerFree"] as? Double - let quotaGB = await TimeMachineStatus.destinationQuotaGB(forMountPointContaining: mountPoint) - let totalGB = quotaGB ?? (plist["TotalSize"] as? Double).map { $0 / 1_073_741_824 } - let destinationID = await TimeMachineStatus.destinationID( - forMountPointContaining: mountPoint) - return VolumeStatus( - exists: true, - mountPoint: mountPoint, - totalGB: totalGB, - usedGB: usedBytes.map { $0 / 1_073_741_824 }, - freeContainerGB: freeBytes.map { $0 / 1_073_741_824 }, - destinationID: destinationID - ) - } -} diff --git a/mac-app/Sources/CloudMachineCore/NetworkShareService.swift b/mac-app/Sources/CloudMachineCore/NetworkShareService.swift deleted file mode 100644 index a7ccc24..0000000 --- a/mac-app/Sources/CloudMachineCore/NetworkShareService.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation - -/// Dysk lokalny (wolumin pod `/Volumes`) nadajacy sie do wybrania jako -/// zrodlo udostepnienia sieciowego w Kreatorze - patrz `NetworkShareService`. -public struct DiskCandidate: Equatable, Identifiable, Sendable { - public var id: String { mountPoint } - public var name: String - public var mountPoint: String - public var totalGB: Double? - public var isInternal: Bool - - public init(name: String, mountPoint: String, totalGB: Double?, isInternal: Bool) { - self.name = name - self.mountPoint = mountPoint - self.totalGB = totalGB - self.isInternal = isInternal - } -} - -/// Udostepnianie lokalnego dysku w sieci lokalnej przez SMB (File Sharing), -/// zeby inny Mac (np. MacBook) mogl uzyc go jako wlasny, niezalezny cel -/// Time Machine przez siec. CloudMachine tworzy TYLKO zwykly udzial SMB -/// (`sharing` - publiczne, udokumentowane narzedzie Apple, patrz `man -/// sharing`); zaznaczenie konkretnego udzialu jako "Share as a Time Machine -/// backup destination" w System Settings -> General -> Sharing jest -/// swiadomie POZOSTAWIONE uzytkownikowi jako ostatni, reczny krok (patrz -/// `CloudMachineController.shareDiskOverNetwork`) - ten mechanizm jest -/// wewnetrzny/nieudokumentowany przez Apple, wiec automatyzowanie go -/// byloby krucha zaleznoscia miedzy wersjami macOS. Z tego samego powodu ta -/// warstwa NIE probuje tez wylistowac juz istniejacych udzialow (`sharing -l -/// -f json`) - jego dokladny format wyjscia nie jest udokumentowany, wiec -/// polegamy zamiast tego na surowym komunikacie samego `sharing` po kazdej -/// probie dodania udzialu (patrz `shareDiskOverNetwork`). -public enum NetworkShareService { - /// Identyfikator urzadzenia dysku rozruchowego (np. "disk3s5") - dyski - /// pod `/Volumes` pasujace do tego ID sa wykluczane z `candidateDisks()`. - /// To jest zabezpieczenie, nie tylko podpowiedz UI: `sharing -a` - /// udostepnialoby caly dysk systemowy w sieci, gdyby uzytkownik przez - /// pomylke go wybral. - static func bootVolumeDeviceIdentifier() async -> String? { - guard - let result = try? await ProcessRunner.run( - "/usr/sbin/diskutil", ["info", "-plist", "/"], timeout: 15), - result.succeeded, - let data = result.stdout.data(using: .utf8), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) - as? [String: Any] - else { return nil } - return plist["DeviceIdentifier"] as? String - } - - /// Woluminy zamontowane pod `/Volumes` - dysk rozruchowy jest zawsze - /// wykluczony (patrz `bootVolumeDeviceIdentifier`). - public static func candidateDisks() async -> [DiskCandidate] { - guard - let entries = try? FileManager.default.contentsOfDirectory(atPath: "/Volumes") - else { return [] } - let bootID = await bootVolumeDeviceIdentifier() - - var results: [DiskCandidate] = [] - for name in entries.sorted() { - let mountPoint = "/Volumes/\(name)" - guard - let result = try? await ProcessRunner.run( - "/usr/sbin/diskutil", ["info", "-plist", mountPoint], timeout: 15), - result.succeeded, - let data = result.stdout.data(using: .utf8), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) - as? [String: Any] - else { continue } - - let deviceID = plist["DeviceIdentifier"] as? String - if let bootID, let deviceID, deviceID == bootID { continue } - - let totalBytes = plist["TotalSize"] as? Double - results.append( - DiskCandidate( - name: name, - mountPoint: mountPoint, - totalGB: totalBytes.map { $0 / 1_073_741_824 }, - isInternal: (plist["Internal"] as? Bool) ?? true - )) - } - return results - } - - /// Czy usluga File Sharing (`com.apple.smbd`) jest wlaczona - odpowiednik - /// glownego przelacznika w System Settings -> General -> Sharing -> File - /// Sharing. Sprawdzane przez `launchctl print-disabled system`, bo - /// wlaczenie/wylaczenie tej uslugi to dokladnie `launchctl enable/disable - /// system/com.apple.smbd` (potwierdzone na zywo na tym Maku). - public static func isFileSharingEnabled() async -> Bool { - guard - let result = try? await ProcessRunner.run( - "/bin/launchctl", ["print-disabled", "system"], timeout: 15) - else { return false } - return isFileSharingEnabled(printDisabledOutput: result.stdout) - } - - static func isFileSharingEnabled(printDisabledOutput: String) -> Bool { - for line in printDisabledOutput.split(separator: "\n") where line.contains("com.apple.smbd") { - return line.contains("=> enabled") - } - // Brak wpisu na liscie "print-disabled" oznacza, ze usluga nigdy nie - // zostala jawnie wylaczona - domyslny stan to wlaczona. - return true - } -} diff --git a/mac-app/Sources/CloudMachineCore/VerifyWatchdogService.swift b/mac-app/Sources/CloudMachineCore/VerifyWatchdogService.swift deleted file mode 100644 index 9aab980..0000000 --- a/mac-app/Sources/CloudMachineCore/VerifyWatchdogService.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation - -/// Automatyzuje to, co README opisuje jako "wysoce zalecane, rob to -/// regularnie": weryfikacje sum kontrolnych najnowszego backupu. Realna -/// weryfikacja odpala sie co najwyzej raz na `intervalDays` dni (domyslnie 7) - -/// `tmutil verifychecksums` na duzym backupie moze trwac dlugo i mocno -/// obciazyc I/O. W architekturze lokalnego woluminu APFS nie ma juz -/// zawieszajacych sie montowan NFS/sparsebundle do pilnowania (patrz legacy -/// `MountHealth`/`RuntimeState` usuniete razem z ta zmiana) - jedyna ochrona -/// potrzebna tutaj to nie startowac drugiej weryfikacji rownolegle z inna. -public enum VerifyWatchdogService { - public static var intervalDays: Int { - if let env = ProcessInfo.processInfo.environment["CM_VERIFY_INTERVAL_DAYS"], - let value = Int(env) - { - return value - } - return 7 - } - - private static var stateFile: URL { - CMPaths.logDir.appendingPathComponent(".verify-watchdog-last-run") - } - - public static func run() async { - await withCMLock("verify-watchdog") { - await runLocked() - } - } - - private static func runLocked() async { - let volume = await LocalBackupService.currentStatus() - guard let mountPoint = volume.mountPoint, volume.exists else { - // EdgeTriggeredLog zamiast zwyklego CMLogger.log - bez tego kazdy - // codzienny cykl watchdoga (RunAtLoad+StartInterval, patrz plist) na - // Maku bez jeszcze utworzonego woluminu zapisywalby identyczna linie - // w kolko w nieskonczonosc, zasypujac wspolny log. - EdgeTriggeredLog.log( - marker: CMPaths.logDir.appendingPathComponent(".verify-watchdog-no-volume"), - active: true, - "[verify-watchdog] Brak lokalnego woluminu backupu, pomijam ten przebieg." - ) - return - } - EdgeTriggeredLog.log( - marker: CMPaths.logDir.appendingPathComponent(".verify-watchdog-no-volume"), active: false, - "") - - // Nie przeszkadzamy aktywnemu backupowi. EdgeTriggeredLog (jak przy - // braku woluminu wyzej) - bez tego watchdog na Maku z bardzo czestymi - // backupami mogl(by) milczec bezterminowo, trafiajac za kazdym razem w - // trakcie aktywnego zapisu, bez zadnego sladu w logu. - if await TimeMachineStatus.isRunning() { - EdgeTriggeredLog.log( - marker: CMPaths.logDir.appendingPathComponent(".verify-watchdog-tm-running"), - active: true, - "[verify-watchdog] Time Machine aktywnie zapisuje, pomijam ten przebieg." - ) - return - } - EdgeTriggeredLog.log( - marker: CMPaths.logDir.appendingPathComponent(".verify-watchdog-tm-running"), active: false, - "") - - let lastEpoch = CooldownGate.parseStateFile(stateFile) - guard - !CooldownGate.isWithinCooldown(lastEpoch: lastEpoch, cooldown: Double(intervalDays * 86400)) - else { return } - - guard - let listResult = try? await ProcessRunner.run( - "/usr/bin/tmutil", ["listbackups", "-d", mountPoint], timeout: 60), - let latestBackup = listResult.stdout.split(separator: "\n").last - else { - CMLogger.log("[verify-watchdog] Brak backupow do zweryfikowania, pomijam ten przebieg.") - return - } - - // Zapisujemy PRZED faktyczna weryfikacja (nie po) - dluga weryfikacja - // (potencjalnie godziny) nie powinna sama siebie wywolywac ponownie w - // kolko, jesli nastepny cykl watchdoga trafi w trakcie jej trwania. - CooldownGate.writeStateFile(stateFile) - - CMLogger.log( - "[verify-watchdog] Weryfikuje sumy kontrolne najnowszego backupu: \(latestBackup) (moze potrwac dlugo)." - ) - // Bez sztywnego limitu czasu - jak w komentarzu wyzej, weryfikacja moze - // legalnie trwac godziny przy duzym backupie; timeout tutaj tylko - // zabijalby prawidlowo dzialajacy proces w polowie. - let result = try? await ProcessRunner.runTmutilUnattended( - ["verifychecksums", String(latestBackup)]) - if result?.succeeded == true { - CMLogger.log("[verify-watchdog] OK: sumy kontrolne sie zgadzaja.") - } else if result?.isSudoAuthFailure == true { - // Brak reguly sudoers to problem konfiguracji, NIE dowod na - // uszkodzony backup - nie strasz uzytkownika notyfikacja sugerujaca - // realna niespojnosc danych za cos, co jest tylko brakiem uprawnien. - CMLogger.log( - "[verify-watchdog] BLAD: brak reguly sudoers dla 'tmutil verifychecksums' - dopisz regule recznie w /etc/sudoers.d/cloudmachine (patrz README), zeby weryfikacja mogla dzialac bez nadzoru." - ) - } else if result == nil { - // WAZNE: `result == nil` oznacza, ze proces w ogole nie zwrocil - // wyniku (blad uruchomienia - brak timeoutu od kiedy usunieto sztywny - // limit, patrz komentarz przy wywolaniu wyzej) - to problem - // OPERACYJNY, NIE dowod na uszkodzone sumy kontrolne. - CMLogger.log( - "[verify-watchdog] BLAD: verifychecksums nie zakonczylo sie poprawnie (blad uruchomienia) - to problem operacyjny, nie potwierdzenie uszkodzonych danych. Sprobuje ponownie przy nastepnym cyklu." - ) - } else if !(await LocalBackupService.currentStatus().exists) { - // Wolumin zniknal w trakcie/po weryfikacji (odlaczony dysk zewnetrzny, - // wybudzenie ze snu, chwilowy problem polaczenia) - `verifychecksums` - // zwraca wtedy tez niezerowy kod wyjscia, nieodrozniajacy sie od - // prawdziwego uszkodzenia sum kontrolnych po samym exit code. Bez tego - // sprawdzenia kazdy taki, czysto operacyjny zanik dysku (bardziej - // prawdopodobny na dysku zewnetrznym niz na wewnetrznej partycji) - // wysylalby uzytkownikowi falszywa notyfikacje o uszkodzonym backupie. - CMLogger.log( - "[verify-watchdog] BLAD: wolumin lokalny zniknal podczas weryfikacji (dysk odlaczony/uspiony?) - to problem operacyjny, nie potwierdzenie uszkodzonych danych. Sprobuje ponownie przy nastepnym cyklu." - ) - } else { - CMLogger.log( - "[verify-watchdog] UWAGA: verifychecksums wykryto realny problem z sumami kontrolnymi backupu." - ) - _ = try? await ProcessRunner.run( - "/usr/bin/osascript", - [ - "-e", - "display notification \"Weryfikacja sum kontrolnych najnowszego backupu wykazala problem - sprawdz Logi.\" with title \"CloudMachine\"", - ]) - } - } -} diff --git a/mac-app/Tests/CloudMachineAppTests/CooldownGateTests.swift b/mac-app/Tests/CloudMachineAppTests/CooldownGateTests.swift deleted file mode 100644 index 81550cd..0000000 --- a/mac-app/Tests/CloudMachineAppTests/CooldownGateTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -import XCTest - -@testable import CloudMachineCore - -final class CooldownGateTests: XCTestCase { - func testIsWithinCooldown_neverAttempted_isFalse() { - XCTAssertFalse(CooldownGate.isWithinCooldown(lastEpoch: nil, cooldown: 180)) - } - - func testIsWithinCooldown_recentAttempt_isTrue() { - let now = Date(timeIntervalSince1970: 1_000_000) - let lastEpoch = now.timeIntervalSince1970 - 60 // 60s temu - XCTAssertTrue(CooldownGate.isWithinCooldown(lastEpoch: lastEpoch, now: now, cooldown: 180)) - } - - func testIsWithinCooldown_exactlyAtThreshold_isFalse() { - // Granica jest wylaczna (`<`, nie `<=`) - dokladnie w momencie uplywu - // cooldownu juz WOLNO probowac ponownie, zgodnie z oryginalnym bash - // (`[ $((now - last)) -lt "$COOLDOWN" ]`). - let now = Date(timeIntervalSince1970: 1_000_000) - let lastEpoch = now.timeIntervalSince1970 - 180 - XCTAssertFalse(CooldownGate.isWithinCooldown(lastEpoch: lastEpoch, now: now, cooldown: 180)) - } - - func testIsWithinCooldown_oldAttempt_isFalse() { - let now = Date(timeIntervalSince1970: 1_000_000) - let lastEpoch = now.timeIntervalSince1970 - 3600 - XCTAssertFalse(CooldownGate.isWithinCooldown(lastEpoch: lastEpoch, now: now, cooldown: 180)) - } - - func testParseStateFile_missingFile_returnsNil() { - let missing = URL(fileURLWithPath: "/tmp/cloudmachine-cooldown-test-\(UUID().uuidString)") - XCTAssertNil(CooldownGate.parseStateFile(missing)) - } - - func testWriteAndParseStateFile_roundTrips() throws { - let file = FileManager.default.temporaryDirectory - .appendingPathComponent("cloudmachine-cooldown-test-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: file) } - - let now = Date(timeIntervalSince1970: 1_700_000_000) - CooldownGate.writeStateFile(file, now: now) - - let parsed = try XCTUnwrap(CooldownGate.parseStateFile(file)) - XCTAssertEqual(parsed, now.timeIntervalSince1970, accuracy: 0.001) - } -} diff --git a/mac-app/Tests/CloudMachineAppTests/NetworkShareServiceTests.swift b/mac-app/Tests/CloudMachineAppTests/NetworkShareServiceTests.swift deleted file mode 100644 index 97fb85d..0000000 --- a/mac-app/Tests/CloudMachineAppTests/NetworkShareServiceTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import XCTest - -@testable import CloudMachineCore - -final class NetworkShareServiceTests: XCTestCase { - func testIsFileSharingEnabled_explicitlyEnabled() { - let output = "disabled services = {\n\t\"com.apple.smbd\" => enabled\n}" - XCTAssertTrue(NetworkShareService.isFileSharingEnabled(printDisabledOutput: output)) - } - - func testIsFileSharingEnabled_explicitlyDisabled() { - let output = "disabled services = {\n\t\"com.apple.smbd\" => disabled\n}" - XCTAssertFalse(NetworkShareService.isFileSharingEnabled(printDisabledOutput: output)) - } - - func testIsFileSharingEnabled_noEntry_defaultsToEnabled() { - let output = "disabled services = {\n\t\"com.apple.someotherd\" => disabled\n}" - XCTAssertTrue(NetworkShareService.isFileSharingEnabled(printDisabledOutput: output)) - } -} From 8ec44028fdc67604d7243866704e7c45fbb2294b Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 13:48:52 +0200 Subject: [PATCH 09/18] Pull FUSE-T inside CloudMachine so there is no second app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official installer leaves /Applications/fuse-t.app in place. That app hosts the FSKit extension — a backend this project does not use, since the mount goes over NFS — so it sits there doing nothing. Exactly two files are needed, and both link only against system libraries: libfuse-t.dylib, which rclone dlopens, and go-nfsv4, which actually holds the mount. install-fuse downloads the official package, extracts those two into ~/.cloudmachine/fuse and points /usr/local/lib/libfuse-t.dylib at our copy. The server path is passed through FUSE_NFSSRV_PATH so it can live anywhere; the library cannot, because rclone hardcodes absolute paths and dlopen on an absolute path ignores DYLD_*. A symlink covers that, and needs no root: /usr/local/lib belongs to the user and the admin group. Verified — the mount now runs from ~/.cloudmachine/fuse/go-nfsv4. The package is downloaded rather than committed. The binaries are 21 MB, and FUSE-T is not open source: the binary distribution is free for non-commercial use provided the copyright notice is kept, which is why LICENSE.rtf is copied next to them. Bundling with commercial software would need a licence from its authors. mount-drive restores the symlink before checking anything, because FUSE-T's own uninstaller wipes everything under its path — including our link. Tested by deleting the link: the agent recreated it and mounted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineAgent/CloudMachineAgent.swift | 1 + .../CloudMachineAgent/DriveCommands.swift | 24 +++ .../Sources/CloudMachineCore/CMTooling.swift | 17 +- .../CloudMachineCore/FuseInstaller.swift | 184 ++++++++++++++++++ 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 mac-app/Sources/CloudMachineCore/FuseInstaller.swift diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index 5363636..14f87ef 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -17,6 +17,7 @@ struct CloudMachineAgent: AsyncParsableCommand { SetupSigningCert.self, // Warstwa Google Drive - zastapila skrypty z gdrive/. InstallRclone.self, + InstallFuse.self, MountDrive.self, CreateImage.self, AttachImage.self, diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 182ef8d..3510dfa 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -18,6 +18,10 @@ struct MountDrive: AsyncParsableCommand { return } + // Deinstalator FUSE-T kasuje cala zawartosc /usr/local/lib pod swoja + // sciezka, w tym nasze dowiazanie - odtwarzamy je, zanim cokolwiek sprawdzimy. + FuseInstaller.ensureSystemLink() + // Bez FUSE rclone konczy natychmiast bledem "cgofuse: cannot find FUSE". // Agent ma KeepAlive, wiec probowalby w kolko co 30 s i zalewal log - // lepiej stanac od razu i powiedziec, czego brakuje. @@ -32,6 +36,12 @@ struct MountDrive: AsyncParsableCommand { await DriveBufferService.excludeBufferFromTimeMachine() let args = try DriveBufferService.prepare() + // Nasza kopia serwera NFS, jesli jest - wtedy osobna instalacja FUSE-T + // w systemie nie jest potrzebna. + if FileManager.default.isExecutableFile(atPath: CMTooling.bundledNfsServer.path) { + setenv("FUSE_NFSSRV_PATH", CMTooling.bundledNfsServer.path, 1) + } + // Podmieniamy sie na rclone zamiast go nadzorowac: launchd ma pilnowac // procesu, ktory faktycznie trzyma montowanie, a nie posrednika. let rclone = CMTooling.managedRclonePath.path @@ -168,6 +178,20 @@ struct DriveStatus: AsyncParsableCommand { } } +// MARK: - Instalacja FUSE + +struct InstallFuse: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "install-fuse", + abstract: "Wciaga FUSE-T do CloudMachine, zeby nie bylo osobnej aplikacji w systemie.") + + func run() async throws { + let result = await FuseInstaller.install() + print(result.message) + if !result.succeeded { throw ExitCode(1) } + } +} + // MARK: - Instalacja rclone struct InstallRclone: AsyncParsableCommand { diff --git a/mac-app/Sources/CloudMachineCore/CMTooling.swift b/mac-app/Sources/CloudMachineCore/CMTooling.swift index 0385f6c..e197e7e 100644 --- a/mac-app/Sources/CloudMachineCore/CMTooling.swift +++ b/mac-app/Sources/CloudMachineCore/CMTooling.swift @@ -43,6 +43,21 @@ public enum CMTooling { // MARK: - FUSE + /// Nasza kopia FUSE-T - zeby nie trzymac w systemie osobnej aplikacji. + /// Patrz `FuseInstaller`. + public static var bundledFuseDir: URL { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cloudmachine/fuse") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + public static var bundledFuseLib: URL { bundledFuseDir.appendingPathComponent("libfuse-t.dylib") } + + /// Serwer NFS, ktory faktycznie trzyma montowanie. Jego sciezke da sie + /// wskazac zmienna `FUSE_NFSSRV_PATH`, wiec moze lezec u nas. + public static var bundledNfsServer: URL { bundledFuseDir.appendingPathComponent("go-nfsv4") } + /// Sciezki, pod ktorymi moze siedziec FUSE. Wystarczy jedna. private static let fuseCandidates = [ "/usr/local/lib/libfuse-t.dylib", @@ -81,7 +96,7 @@ public enum CMTooling { } if !hasFuse { missing.append("FUSE") - remedies.append("brew install macos-fuse-t/homebrew-cask/fuse-t") + remedies.append("cloudmachine-agent install-fuse") } return Readiness(missing: missing, remedies: remedies) } diff --git a/mac-app/Sources/CloudMachineCore/FuseInstaller.swift b/mac-app/Sources/CloudMachineCore/FuseInstaller.swift new file mode 100644 index 0000000..f814497 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/FuseInstaller.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Wciaga FUSE-T do CloudMachine, zeby nie bylo osobnej aplikacji w systemie. +/// +/// Oficjalny instalator FUSE-T stawia `/Applications/fuse-t.app`, biblioteki +/// w `/usr/local/lib` i serwer w `/Library/Application Support/fuse-t`. Ta +/// aplikacja jest hostem rozszerzenia FSKit (`FskitSrvModule.appex`) - backendu, +/// ktorego u nas i tak nie uzywamy, bo montujemy przez NFS. Zostaje wiec w +/// systemie ikona i pakiet, ktore nic nie robia. +/// +/// Potrzebne sa dokladnie dwa pliki, oba zalezne wylacznie od bibliotek +/// systemowych: +/// - `libfuse-t.dylib` - rclone otwiera ja przez dlopen, +/// - `go-nfsv4` - serwer NFS, ktory faktycznie trzyma montowanie. +/// +/// Serwer wskazujemy zmienna `FUSE_NFSSRV_PATH`, wiec moze lezec gdziekolwiek. +/// Biblioteka nie: rclone ma zaszyte sciezki bezwzgledne +/// (`/usr/local/lib/libfuse-t.dylib`, `/usr/local/lib/libfuse.2.dylib`), +/// a `dlopen` na sciezce bezwzglednej ignoruje `DYLD_*`. Dlatego zostawiamy tam +/// DOWIAZANIE do naszej kopii - `/usr/local/lib` nalezy do uzytkownika +/// z grupy admin, wiec nie trzeba do tego roota. +/// +/// LICENCJA: FUSE-T nie jest oprogramowaniem otwartym. Binarna dystrybucja jest +/// darmowa do uzytku niekomercyjnego pod warunkiem zachowania noty +/// o prawach autorskich - dlatego kopiujemy `LICENSE.rtf` obok binariow. +/// Bundlowanie z oprogramowaniem komercyjnym wymaga osobnej licencji od +/// autorow FUSE-T. +public enum FuseInstaller { + + private static let releasesAPI = + "https://api.github.com/repos/macos-fuse-t/fuse-t/releases/latest" + + /// Sciezka, pod ktora rclone szuka biblioteki. Nie da sie jej zmienic. + static let systemLibLink = "/usr/local/lib/libfuse-t.dylib" + + public static var isInstalled: Bool { + FileManager.default.isExecutableFile(atPath: CMTooling.bundledNfsServer.path) + && FileManager.default.fileExists(atPath: systemLibLink) + } + + /// Odtwarza dowiazanie w `/usr/local/lib`, jesli zniknelo. + /// + /// Potrzebne, bo deinstalator FUSE-T kasuje wszystko pod ta sciezka - razem + /// z naszym dowiazaniem. Bez tego usuniecie osobnej aplikacji fuse-t + /// zabieraloby ze soba montowanie, mimo ze nasza kopia biblioteki lezy + /// nietknieta na swoim miejscu. + @discardableResult + public static func ensureSystemLink() -> Bool { + guard FileManager.default.fileExists(atPath: CMTooling.bundledFuseLib.path) else { + return false + } + if let target = try? FileManager.default.destinationOfSymbolicLink(atPath: systemLibLink), + target == CMTooling.bundledFuseLib.path + { + return true + } + do { + try linkSystemLibrary() + CMLogger.log("Odtworzono dowiazanie \(systemLibLink) do kopii w CloudMachine") + return true + } catch { + return false + } + } + + public static func install() async -> CMActionResult { + let workDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cloudmachine-fuse-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workDir) } + try? FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + + guard let (version, pkgURL) = await latestPackage() else { + return CMActionResult(succeeded: false, message: "Nie udalo sie ustalic wersji FUSE-T.") + } + + let pkgPath = workDir.appendingPathComponent("fuse-t.pkg") + guard + let download = try? await ProcessRunner.run( + "/usr/bin/curl", ["-fsSL", "-o", pkgPath.path, pkgURL], timeout: 600), + download.succeeded + else { + return CMActionResult(succeeded: false, message: "Nie udalo sie pobrac pakietu FUSE-T.") + } + + let expanded = workDir.appendingPathComponent("expanded") + guard + let expand = try? await ProcessRunner.run( + "/usr/sbin/pkgutil", ["--expand-full", pkgPath.path, expanded.path], timeout: 300), + expand.succeeded + else { + return CMActionResult(succeeded: false, message: "Nie udalo sie rozpakowac pakietu FUSE-T.") + } + + guard + let dylib = findFile(under: expanded, matching: { $0.hasPrefix("libfuse-t") && $0.hasSuffix(".dylib") }), + let server = findFile(under: expanded, matching: { $0.hasPrefix("go-nfsv4") }) + else { + return CMActionResult( + succeeded: false, message: "W pakiecie FUSE-T nie ma spodziewanych plikow.") + } + + let destination = CMTooling.bundledFuseDir + try? FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + + do { + try copy(dylib, to: CMTooling.bundledFuseLib) + try copy(server, to: CMTooling.bundledNfsServer) + // Licencja wymaga zachowania noty o prawach autorskich przy redystrybucji. + if let license = findFile(under: expanded, matching: { $0 == "LICENSE.rtf" }) { + try? copy(license, to: destination.appendingPathComponent("LICENSE.rtf")) + } + try linkSystemLibrary() + } catch { + return CMActionResult( + succeeded: false, message: "Instalacja FUSE-T nie powiodla sie: \(error.localizedDescription)") + } + + return CMActionResult( + succeeded: true, + message: """ + Zainstalowano FUSE-T \(version) wewnatrz CloudMachine (\(destination.path)). + Osobna aplikacja fuse-t nie jest juz potrzebna - mozesz ja usunac: + sudo "/Library/Application Support/fuse-t/uninstall.sh" + """) + } + + // MARK: - Szczegoly + + /// Podmienia `/usr/local/lib/libfuse-t.dylib` na dowiazanie do naszej kopii. + /// + /// Nie wymaga roota: `/usr/local/lib` nalezy do uzytkownika i grupy admin. + /// Jesli lezy tam prawdziwy plik z oficjalnego instalatora, usuwamy go - + /// nasza kopia jest bit w bit taka sama, bo pochodzi z tego samego pakietu. + private static func linkSystemLibrary() throws { + let fm = FileManager.default + let linkURL = URL(fileURLWithPath: systemLibLink) + try? fm.createDirectory( + at: linkURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if fm.fileExists(atPath: systemLibLink) || isSymlink(systemLibLink) { + try fm.removeItem(at: linkURL) + } + try fm.createSymbolicLink(at: linkURL, withDestinationURL: CMTooling.bundledFuseLib) + } + + private static func isSymlink(_ path: String) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: path)[.type] as? FileAttributeType) + == FileAttributeType.typeSymbolicLink + } + + private static func copy(_ source: URL, to destination: URL) throws { + try? FileManager.default.removeItem(at: destination) + try FileManager.default.copyItem(at: source, to: destination) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: destination.path) + } + + private static func findFile(under root: URL, matching: (String) -> Bool) -> URL? { + guard let enumerator = FileManager.default.enumerator(atPath: root.path) else { return nil } + for case let relative as String in enumerator { + let name = (relative as NSString).lastPathComponent + if matching(name) { return root.appendingPathComponent(relative) } + } + return nil + } + + /// Wersja i adres pakietu z ostatniego wydania na GitHubie. + static func latestPackage() async -> (version: String, url: String)? { + guard + let result = try? await ProcessRunner.run( + "/usr/bin/curl", ["-fsSL", releasesAPI], timeout: 60), + result.succeeded, + let data = result.stdout.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = json["tag_name"] as? String, + let assets = json["assets"] as? [[String: Any]] + else { return nil } + + let pkg = assets.first { + ($0["name"] as? String)?.hasSuffix(".pkg") == true + } + guard let url = pkg?["browser_download_url"] as? String else { return nil } + return (tag, url) + } +} From 6b993c2ec466be750fa2b6b5c66533046152660b Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 14:03:02 +0200 Subject: [PATCH 10/18] Drain the queue on detach, or the volume comes back empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detaching the image is itself a write: APFS flushes metadata into bands, and --vfs-write-back defers sending them by thirty seconds. Losing the buffer in that window does not cost "the last few changes" — it costs the volume's root directory. Observed live: 367 MiB of bands already on Drive and the reattached image completely empty, because three metadata bands were killed while still queued. detach() now waits out the write-back delay and then for the queue to go quiet, and says plainly when it could not: Odpiete, ale wysylka NIE zakonczyla sie w czasie - nie kasuj bufora. Proven on a fresh image, since the first attempt at this test had corrupted the old one and anything measured on it was meaningless: write 200 MB, detach with drain, SIGKILL rclone, wipe the local cache to zero, let the agents recover, read back from Drive. Byte-identical. hasFuse now counts our own copy. The FUSE-T uninstaller removes the symlink in /usr/local/lib along with the separate app, and without this the status claimed FUSE was missing while the mount was plainly working. checkReadiness repairs the link rather than waiting for the next launch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineAgent/CloudMachineAgent.swift | 1 + .../CloudMachineAgent/DriveCommands.swift | 15 ++++++++ .../CloudMachineCore/BackupImageService.swift | 35 +++++++++++++++++-- .../Sources/CloudMachineCore/CMTooling.swift | 12 ++++++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index 14f87ef..81a4dc9 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -21,6 +21,7 @@ struct CloudMachineAgent: AsyncParsableCommand { MountDrive.self, CreateImage.self, AttachImage.self, + DetachImage.self, VerifyImage.self, BufferGuard.self, DriveStatus.self, diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 3510dfa..9157f15 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -94,6 +94,21 @@ struct AttachImage: AsyncParsableCommand { } } +struct DetachImage: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "detach-image", + abstract: "Odpina obraz i czeka, az wszystko doleci na Google Drive.") + + @Flag(name: .long, help: "Nie czekaj na wysylke - RYZYKOWNE, patrz BackupImageService.detach.") + var noWait = false + + func run() async throws { + let result = await BackupImageService.detach(waitForUpload: !noWait) + print(result.message) + if !result.succeeded { throw ExitCode(1) } + } +} + struct VerifyImage: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "verify-image", diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index 6e59a5b..b4468fc 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -219,13 +219,42 @@ public enum BackupImageService { return CMActionResult(succeeded: true, message: "Podpiete: \(targetPath.path)") } - public static func detach(force: Bool = false) async -> CMActionResult { + /// Odpina obraz i CZEKA, az wszystko doleci na Google Drive. + /// + /// Czekanie nie jest ostroznoscia na zapas. Samo odpiecie zapisuje metadane + /// APFS do pasm, a `--vfs-write-back` odklada ich wyslanie o kilkadziesiat + /// sekund. Utrata bufora w tym oknie nie kosztuje "ostatnich zmian" - zabiera + /// katalog glowny wolumenu. Zaobserwowane na zywo: 367 MiB pasm lezalo juz na + /// Dysku, a obraz po ponownym podpieciu byl pusty, bo trzy pasma z metadanymi + /// zostaly zabite w kolejce. + /// + /// Dlatego kazda sciezka wygaszania - odpiecie, zatrzymanie bufora, + /// wylaczenie Maca - musi przepuscic drenaz do konca. + public static func detach(force: Bool = false, waitForUpload: Bool = true) async -> CMActionResult + { var args = ["detach", targetPath.path, "-quiet"] if force { args.append("-force") } let result = try? await ProcessRunner.run("/usr/bin/hdiutil", args, timeout: 120) + guard result?.succeeded == true else { + return CMActionResult(succeeded: false, message: "Nie udalo sie odpiac.") + } + + guard waitForUpload else { + return CMActionResult( + succeeded: true, message: "Odpiete (bez czekania na wysylke - dane moga byc tylko lokalnie).") + } + + // Zapisy z odpiecia trafiaja do kolejki dopiero po `--vfs-write-back`, + // wiec najpierw dajemy im szanse tam trafic, a dopiero potem czekamy + // na cisze. Bez tej przerwy kolejka wygladalaby na pusta, bo jeszcze + // by sie nie zdazyla zapelnic. + try? await Task.sleep(nanoseconds: 35_000_000_000) + let drained = await DriveBufferService.waitUntilQuiet(timeout: 600) return CMActionResult( - succeeded: result?.succeeded == true, - message: result?.succeeded == true ? "Odpiete." : "Nie udalo sie odpiac.") + succeeded: drained, + message: drained + ? "Odpiete, wszystko wyslane na Google Drive." + : "Odpiete, ale wysylka NIE zakonczyla sie w czasie - nie kasuj bufora.") } // MARK: - Weryfikacja diff --git a/mac-app/Sources/CloudMachineCore/CMTooling.swift b/mac-app/Sources/CloudMachineCore/CMTooling.swift index e197e7e..adcef92 100644 --- a/mac-app/Sources/CloudMachineCore/CMTooling.swift +++ b/mac-app/Sources/CloudMachineCore/CMTooling.swift @@ -73,7 +73,13 @@ public enum CMTooling { /// brakuje KTOREJKOLWIEK ze sciezek, a nie gdy brakuje wszystkich - wiec /// odmawiala startu przy poprawnie zainstalowanym FUSE-T. Sprawdzamy po kolei. public static var hasFuse: Bool { - fuseCandidates.contains { FileManager.default.fileExists(atPath: $0) } + // Wlasna kopia liczy sie tak samo jak instalacja systemowa: dowiazanie + // w /usr/local/lib potrafimy odtworzyc sami (FuseInstaller.ensureSystemLink), + // wiec jego chwilowy brak nie znaczy, ze FUSE nie ma. Deinstalator FUSE-T + // kasuje to dowiazanie przy usuwaniu osobnej aplikacji - bez tego warunku + // status melduje wtedy brak FUSE, mimo ze montowanie dziala. + if FileManager.default.fileExists(atPath: bundledFuseLib.path) { return true } + return fuseCandidates.contains { FileManager.default.fileExists(atPath: $0) } } // MARK: - Diagnostyka gotowosci @@ -87,6 +93,10 @@ public enum CMTooling { } public static func checkReadiness() -> Readiness { + // Sprawdzenie jest tez okazja do naprawy - dowiazanie bywa kasowane przez + // deinstalator FUSE-T i nie ma powodu czekac z tym do nastepnego startu. + FuseInstaller.ensureSystemLink() + var missing: [String] = [] var remedies: [String] = [] From bc179eb4b0aeff23ae37c0d1628c77cdd527fcf9 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 14:08:47 +0200 Subject: [PATCH 11/18] Keep the log out of the dashboard The dashboard carried a tail of the log, duplicating the Logs tab that exists for exactly that. It also meant reading the file on every refresh cycle so that nobody would look at it. The tab reads it itself when visible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../Services/CloudMachineController.swift | 5 ++++- .../CloudMachineApp/Views/DashboardView.swift | 15 --------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 02a5287..2753d57 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -35,7 +35,10 @@ final class CloudMachineController: ObservableObject { await refreshBuffer() await refreshTimeMachine() await refreshProgress() - refreshLogTail() + // Logu NIE czytamy w cyklu odswiezania - jest od tego osobna zakladka, + // ktora wola refreshLogTail() sama, gdy jest widoczna. Czytanie pliku co + // kilka sekund w tle tylko po to, zeby nikt na to nie patrzyl, jest + // marnotrawstwem. } // MARK: - Poszczegolne odczyty diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index f6ab817..c534930 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -16,7 +16,6 @@ struct DashboardView: View { bufferCard if let progress = controller.status.backupProgress { progressCard(progress) } actions - logCard } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) @@ -169,20 +168,6 @@ struct DashboardView: View { } } - // MARK: - Log - - private var logCard: some View { - card("Ostatnie zdarzenia") { - ScrollView { - Text(controller.status.logTail.isEmpty ? "(pusto)" : controller.status.logTail) - .font(.system(.caption, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - .frame(height: 140) - } - } - // MARK: - Elementy wspolne private func card(_ title: String, @ViewBuilder content: () -> Content) -> some View { From f44991a253c0fe1b2e44db63b528156d707b141a Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 14:39:54 +0200 Subject: [PATCH 12/18] Add a safe way to reboot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shutdown is where this architecture is fragile, not startup. All three agents carry RunAtLoad and the two that must never stay down carry KeepAlive, so after a reboot the buffer mounts, the image attaches and the guard resumes on their own; Time Machine finds the destination again because the volume UUID does not change. Powering off is the dangerous half. It kills rclone, and if bands are still queued the image can come back empty — the same failure that detach() now guards against. macOS gives an agent seconds to exit, which is not enough for a full queue. prepare-shutdown stops the backup first so nothing new is written, then detaches and waits for the queue to drain. It refuses to report success while data is still local, because the whole point is to know before pulling the plug rather than after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineAgent/CloudMachineAgent.swift | 1 + .../CloudMachineAgent/DriveCommands.swift | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index 81a4dc9..a7537f2 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -24,6 +24,7 @@ struct CloudMachineAgent: AsyncParsableCommand { DetachImage.self, VerifyImage.self, BufferGuard.self, + PrepareShutdown.self, DriveStatus.self, ] ) diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 9157f15..f3b3067 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -154,6 +154,39 @@ struct BufferGuard: AsyncParsableCommand { } } +// MARK: - Bezpieczne wygaszenie + +struct PrepareShutdown: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prepare-shutdown", + abstract: "Przygotowuje do restartu: wstrzymuje backup, odpina obraz i czeka na wysylke.") + + func run() async throws { + // Kolejnosc nie jest dowolna. Najpierw Time Machine przestaje dokladac + // nowych zapisow, dopiero potem odpinamy obraz - inaczej odpiecie + // walczyloby z trwajacym backupem. + if await TimeMachineStatus.isRunning() { + print("Wstrzymuje backup...") + _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["stopbackup"], timeout: 120) + try? await Task.sleep(nanoseconds: 3_000_000_000) + } + + print("Odpinam obraz i czekam na wysylke...") + let result = await BackupImageService.detach() + print(result.message) + + guard result.succeeded else { + print("") + print("NIE RESTARTUJ jeszcze - w buforze sa dane, ktore nie doleciely na Dysk.") + print("Sprawdz stan: cloudmachine-agent drive-status") + throw ExitCode(1) + } + + print("") + print("Mozna restartowac. Po starcie agenty podniosa bufor i podepna obraz same.") + } +} + // MARK: - Stan struct DriveStatus: AsyncParsableCommand { From e0e146e4bbd2e8e7fe5e6a6a56edb54a414de288 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 14:59:29 +0200 Subject: [PATCH 13/18] Stop mistaking Drive's throttle for its daily cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard halted a live backup after 109 GiB of the 750 GB daily allowance, reporting the quota exhausted. It was not: the error was 403 userRateLimitExceeded, a transient throttle rclone retries by itself ("will retry in 1m0s"). The trap is that rclone announces it as "Received upload limit error", which no string match can tell apart from the real daily cap. So detection now reads rclone's behaviour instead of its wording. The real cap is fatal to rclone under --drive-stop-on-upload-limit, so the process exits and the mount disappears; while the mount stands, rclone is coping and there is nothing to pause. Only storageQuotaExceeded and teamDriveFileLimitExceeded still count, and only within the last thirty minutes — without a time window a single entry would linger in the log and hold the backup in a pause-resume-pause cycle forever. The parser is now pure so the case that caused this has a test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineCore/DriveBufferService.swift | 54 ++++++++++++++++--- .../DriveLayerTests.swift | 46 ++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index a642b98..fb0ba62 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -188,19 +188,59 @@ public enum DriveBufferService { return total } - /// Czy rclone zatrzymal sie na dobowym limicie Google Drive. Po jego - /// przekroczeniu rclone konczy prace z zalozenia, a agent launchd probuje go - /// podnosic - bez sensu, dopoki limit sie nie odnowi. + /// Czy rclone stanal na dobowym limicie Google Drive (750 GB/dobe). + /// + /// Rozpoznajemy to po ZACHOWANIU rclone, nie po tresci bledu. Powod jest + /// konkretny: `403 userRateLimitExceeded` to chwilowe dlawienie tempa, ktore + /// rclone ponawia sam ("will retry in 1m0s"), ale opisuje je komunikatem + /// "Received upload limit error" - nie do odroznienia po samym tekscie od + /// limitu dobowego. Pierwsza wersja tej funkcji lapala wlasnie to i + /// wstrzymala backup po 109 GiB wyslanych, czyli przy siodmej czesci limitu. + /// + /// Prawdziwy limit dobowy jest dla rclone fatalny (`--drive-stop-on-upload-limit`), + /// wiec proces konczy prace i montowanie znika. Dopoki montowanie stoi, + /// rclone sobie radzi i nie ma czego wstrzymywac. public static func hitDailyQuota() -> Bool { + if isMounted { return false } + return recentLogMentionsUploadLimit() + } + + private static func recentLogMentionsUploadLimit(within minutes: Int = 30) -> Bool { guard let handle = try? FileHandle(forReadingFrom: logFile) else { return false } defer { try? handle.close() } let size = (try? handle.seekToEnd()) ?? 0 - let window: UInt64 = 64 * 1024 + let window: UInt64 = 256 * 1024 try? handle.seek(toOffset: size > window ? size - window : 0) guard let data = try? handle.readToEnd(), - let text = String(data: data, encoding: .utf8)?.lowercased() + let text = String(data: data, encoding: .utf8) else { return false } - return text.contains("storagequotaexceeded") || text.contains("upload limit") - || text.contains("userratelimitexceeded") + return logMentionsUploadLimit(text, now: Date(), within: minutes) + } + + /// Szuka sladu limitu tylko w swiezych wpisach. Bez ograniczenia czasowego + /// raz zapalony alarm nigdy by nie zgasl, bo wpis zostaje w logu na zawsze - + /// backup wpadlby w cykl pauza-wznowienie-pauza. + /// + /// Czysta wersja, zeby dalo sie ja sprawdzic testem bez pliku i bez zegara. + public static func logMentionsUploadLimit(_ text: String, now: Date, within minutes: Int) -> Bool + { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + formatter.timeZone = TimeZone.current + let cutoff = now.addingTimeInterval(-Double(minutes) * 60) + + for line in text.components(separatedBy: .newlines).reversed() { + guard line.count > 19, let stamp = formatter.date(from: String(line.prefix(19))) else { + continue + } + if stamp < cutoff { return false } + let lower = line.lowercased() + // userRateLimitExceeded celowo POMINIETE - to zwykla przepustnica, ktora + // rclone ponawia sam. Lapanie jej wstrzymalo backup przy 109 GiB z 750 GB. + if lower.contains("storagequotaexceeded") || lower.contains("teamdrivefilelimitexceeded") { + return true + } + } + return false } } diff --git a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift index 5c22a1d..eb02669 100644 --- a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift @@ -123,3 +123,49 @@ final class DriveLayerTests: XCTestCase { XCTAssertEqual(BufferGuardService.freeGB(), expected) } } + +/// Wykrywanie dobowego limitu Google Drive. Osobna klasa, bo to pojedyncza +/// pomylka, ktora zatrzymala prawdziwy backup - zasluguje na wlasne miejsce. +final class DailyQuotaDetectionTests: XCTestCase { + private let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy/MM/dd HH:mm:ss" + return f + }() + + private func line(_ minutesAgo: Int, _ message: String, now: Date) -> String { + let stamp = formatter.string(from: now.addingTimeInterval(-Double(minutesAgo) * 60)) + return "\(stamp) ERROR : \(message)" + } + + /// TO jest ten blad. rclone opisuje chwilowa przepustnice komunikatem + /// "Received upload limit error", nie do odroznienia po tekscie od limitu + /// dobowego - i sam ja ponawia. Zlapanie tego wstrzymalo backup po wyslaniu + /// 109 GiB z 750 GB dozwolonych na dobe. + func testTransientRateLimitIsNotTheDailyQuota() { + let now = Date() + let log = [ + line(2, "Received upload limit error: googleapi: Error 403: User rate limit exceeded., userRateLimitExceeded", now: now), + line(2, "bands/cf9: vfs cache: failed to upload try #1, will retry in 1m0s", now: now), + ].joined(separator: "\n") + XCTAssertFalse(DriveBufferService.logMentionsUploadLimit(log, now: now, within: 30)) + } + + func testRealQuotaErrorIsDetected() { + let now = Date() + let log = line(1, "googleapi: Error 403: The user has exceeded their Drive storage quota, storageQuotaExceeded", now: now) + XCTAssertTrue(DriveBufferService.logMentionsUploadLimit(log, now: now, within: 30)) + } + + /// Bez okna czasowego raz zapalony alarm nigdy by nie zgasl - wpis zostaje + /// w logu, wiec backup wpadlby w cykl pauza-wznowienie-pauza. + func testOldQuotaErrorIsIgnored() { + let now = Date() + let log = line(120, "googleapi: Error 403: storageQuotaExceeded", now: now) + XCTAssertFalse(DriveBufferService.logMentionsUploadLimit(log, now: now, within: 30)) + } + + func testEmptyLogIsNotAQuotaError() { + XCTAssertFalse(DriveBufferService.logMentionsUploadLimit("", now: Date(), within: 30)) + } +} From 51ed15e094cd269081cd18f7afe6566dbbd967d3 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 16:37:34 +0200 Subject: [PATCH 14/18] Stop the UI from freezing and calling it a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the dashboard window called stopAutoRefresh, which killed refreshing for the whole app — the controller is shared with the menu bar. Everything then kept showing whatever was true when the window closed. Reported as "the buffer is down and Drive is disconnected" while both were plainly working; the state was simply from minutes earlier. A frozen view and a broken system look identical, so the dashboard now shows when the state was last read. The user should not have to open a log to tell the difference. isConfigured asked the Homebrew rclone while everything else runs the managed binary. Both read the same config today, so the answers agree — but the status must describe what we actually use, not a second installation that may not always be there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../Sources/CloudMachineApp/Models/AppStatus.swift | 4 ++++ .../Services/CloudMachineController.swift | 1 + .../CloudMachineApp/Views/DashboardView.swift | 13 +++++++++---- .../Sources/CloudMachineCore/RemoteConfigurer.swift | 10 +++++++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index cebc0a8..5c51ff9 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -68,6 +68,10 @@ final class AppStatus: ObservableObject { @Published var busyLabel: String = "" @Published var logTail: String = "" @Published var errorMessage: String? + /// Kiedy ostatnio udalo sie odczytac stan. Pokazywane w interfejsie, bo + /// zamrozony widok wyglada dokladnie jak awaria - a to dwie rozne rzeczy + /// i uzytkownik musi je odroznic bez zagladania do logow. + @Published var lastRefresh: Date? /// Jednozdaniowa odpowiedz na pytanie "czy moje dane sa bezpieczne". var headline: String { diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 2753d57..e64d4b0 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -35,6 +35,7 @@ final class CloudMachineController: ObservableObject { await refreshBuffer() await refreshTimeMachine() await refreshProgress() + status.lastRefresh = Date() // Logu NIE czytamy w cyklu odswiezania - jest od tego osobna zakladka, // ktora wola refreshLogTail() sama, gdy jest widoczna. Czytanie pliku co // kilka sekund w tle tylko po to, zeby nikt na to nie patrzyl, jest diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index c534930..0ac2670 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -20,10 +20,11 @@ struct DashboardView: View { .padding(24) .frame(maxWidth: .infinity, alignment: .leading) } - .task { - controller.startAutoRefresh() - } - .onDisappear { controller.stopAutoRefresh() } + .task { controller.startAutoRefresh() } + // Celowo BEZ onDisappear: kontroler jest wspolny dla okna i paska menu, + // wiec zatrzymanie odswiezania przy zamknieciu okna zamrazalo takze + // pasek menu - pokazywal wtedy stan sprzed zamkniecia, wygladajacy jak + // awaria, mimo ze wszystko dzialalo. } // MARK: - Naglowek @@ -38,6 +39,10 @@ struct DashboardView: View { Text("Time Machine na Google Drive").font(.subheadline).foregroundStyle(.secondary) } Spacer() + if let at = controller.status.lastRefresh { + Text("odswiezono \(at.formatted(date: .omitted, time: .standard))") + .font(.caption).foregroundStyle(.secondary) + } if controller.status.isBusy { HStack(spacing: 8) { ProgressView().controlSize(.small) diff --git a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift index f83ad4c..313aba2 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift @@ -6,8 +6,16 @@ import Foundation /// interaktywnego kreatora `rclone config`. CLI i GUI uzywaja teraz /// DOKLADNIE tej samej sciezki logowania. public enum RemoteConfigurer { + /// Czy remote istnieje w konfiguracji rclone. + /// + /// Pyta binarke zarzadzana przez CloudMachine, nie te z Homebrew. Obie czytaja + /// ten sam plik konfiguracyjny, ale reszta systemu chodzi na naszej - a stan + /// pokazywany uzytkownikowi musi opisywac to, czego uzywamy naprawde, nie + /// przypadkowa druga instalacje, ktorej moze kiedys nie byc. public static func isConfigured(remoteName: String) async -> Bool { - guard let result = try? await ProcessRunner.runRclone(["listremotes"]) else { return false } + guard let result = try? await CMTooling.runRclone(["listremotes"], timeout: 30) else { + return false + } return result.stdout.contains("\(remoteName):") } From d4685359972e68d0d7794e1005969e39b59ed431 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 11 Sep 2026 17:17:23 +0200 Subject: [PATCH 15/18] Audit fixes: stop measuring what rclone already reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffer size came from walking the cache directory — 6504 stat calls, every ten seconds while the dashboard is open, on the same disk the backup writes to. rclone publishes the number in vfs/stats as bytesUsed. It also publishes outOfSpace, meaning it has nothing left to evict because nothing has been uploaded yet; that is a harder fact than any threshold we could pick, and the guard now pauses on it. The directory walk stays as a fallback for when the control interface is unreachable. Guard thresholds derive from the cache size instead of being written by hand. 150 GB was right only by coincidence for a 100 GB buffer: --vfs-cache-max-size is a soft limit and the buffer sits at that limit in normal operation — measured at exactly 100 GiB throughout the first upload — so a threshold at or below it would pause the backup permanently. Changing one number without the other would have produced a threshold that either never fires or never stops firing. The relationship now has tests, which the extracted step() was supposed to have from the start and did not. verify() detached through `defer { Task { ... } }`, returning before the detach happened. Callers attach again immediately, so the attach raced a pending detach of the same device. It waits now. detach() unmounts browsed backup snapshots first. Reading a backup — in Finder or with plain ls under a path from tmutil listbackups — mounts its snapshot read-only, and that mount holds the image device busy. hdiutil then refuses with a message that never mentions what is blocking it. This blocked the consistency check until it was traced by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- .../CloudMachineApp/Models/AppStatus.swift | 2 + .../Services/CloudMachineController.swift | 8 +++- .../CloudMachineCore/BackupImageService.swift | 36 ++++++++++++---- .../CloudMachineCore/BufferGuardService.swift | 33 ++++++++++++--- .../CloudMachineCore/DriveBufferService.swift | 27 ++++++++++-- .../DriveLayerTests.swift | 41 +++++++++++++++++++ 6 files changed, 128 insertions(+), 19 deletions(-) diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 5c51ff9..929b1ce 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -31,6 +31,8 @@ struct BufferStatus: Equatable { var erroredFiles: Int = 0 /// Dobowy limit uploadu Google Drive wyczerpany - do odnowienia trzeba czekac. var dailyQuotaHit: Bool = false + /// rclone nie ma gdzie odlozyc danych - bufor pelny samymi niewyslanymi. + var outOfSpace: Bool = false var draining: Bool { uploadsInProgress > 0 || uploadsQueued > 0 } } diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index e64d4b0..56e1b8c 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -54,16 +54,20 @@ final class CloudMachineController: ObservableObject { } private func refreshBuffer() async { + let stats = await DriveBufferService.queueStats() var buffer = BufferStatus() buffer.mounted = DriveBufferService.isMounted buffer.imageAttached = BackupImageService.isAttached - buffer.sizeGB = BufferGuardService.bufferGB() + // Rozmiar bufora bierzemy od rclone; wlasny obchod katalogu to 6504 + // wywolania stat co 10 sekund na dysku, na ktory leci backup. + buffer.sizeGB = BufferGuardService.bufferGB(stats: stats) buffer.freeDiskGB = BufferGuardService.freeGB() buffer.dailyQuotaHit = DriveBufferService.hitDailyQuota() - if let stats = await DriveBufferService.queueStats() { + if let stats { buffer.uploadsQueued = stats.uploadsQueued buffer.uploadsInProgress = stats.uploadsInProgress buffer.erroredFiles = stats.erroredFiles + buffer.outOfSpace = stats.outOfSpace } status.buffer = buffer } diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index b4468fc..512d412 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -232,6 +232,8 @@ public enum BackupImageService { /// wylaczenie Maca - musi przepuscic drenaz do konca. public static func detach(force: Bool = false, waitForUpload: Bool = true) async -> CMActionResult { + await unmountBrowsedSnapshots() + var args = ["detach", targetPath.path, "-quiet"] if force { args.append("-force") } let result = try? await ProcessRunner.run("/usr/bin/hdiutil", args, timeout: 120) @@ -257,6 +259,25 @@ public enum BackupImageService { : "Odpiete, ale wysylka NIE zakonczyla sie w czasie - nie kasuj bufora.") } + /// Odmontowuje migawki backupu podpiete pod `/Volumes/.timemachine/`. + /// + /// Przegladanie backupu - w Finderze albo zwyklym `ls` po sciezce z + /// `tmutil listbackups` - montuje jego migawke tylko do odczytu. Takie + /// montowanie trzyma urzadzenie obrazu zajete i `hdiutil detach` odmawia, + /// a komunikat nie mowi ani slowa o tym, co go blokuje. + public static func unmountBrowsedSnapshots() async { + guard let table = try? mountTable() else { return } + for line in table.components(separatedBy: .newlines) { + guard line.contains("/Volumes/.timemachine/"), + let range = line.range(of: " on "), + let end = line.range(of: " (", range: range.upperBound.. Int { - Int(DriveBufferService.cacheSizeBytes() / 1_073_741_824) + /// Rozmiar bufora wg rclone, z obchodem katalogu tylko jako awaryjnym + /// zapasem - patrz `DriveBufferService.cacheSizeBytesByWalk`. + public static func bufferGB(stats: DriveBufferService.QueueStats? = nil) -> Int { + if let bytes = stats?.bytesUsed, bytes > 0 { return Int(bytes / 1_073_741_824) } + return Int(DriveBufferService.cacheSizeBytesByWalk() / 1_073_741_824) } /// Wolne miejsce liczone tak, jak liczy je `df` - czyli PESYMISTYCZNIE. @@ -89,7 +106,8 @@ public actor BufferGuardService { /// sie sprawdzic decyzje testem bez czekania w czasie rzeczywistym. @discardableResult public func step() async -> Snapshot { - let buffer = Self.bufferGB() + let stats = await DriveBufferService.queueStats() + let buffer = Self.bufferGB(stats: stats) let free = Self.freeGB() let running = await TimeMachineStatus.isRunning() let percent = (await TimeMachineStatus.currentProgress())?.percent ?? 0 @@ -115,8 +133,11 @@ public actor BufferGuardService { } case .running: - if buffer >= thresholds.highGB || free <= thresholds.minFreeGB { - CMLogger.log("PAUZA: bufor \(buffer) GB, wolne \(free) GB - czekam na wysylke") + // outOfSpace pochodzi od rclone i znaczy "nie mam juz gdzie odlozyc + // danych" - to twardszy fakt niz jakikolwiek nasz prog. + if stats?.outOfSpace == true || buffer >= thresholds.highGB || free <= thresholds.minFreeGB { + let why = stats?.outOfSpace == true ? "rclone zglasza brak miejsca w buforze" : "prog" + CMLogger.log("PAUZA (\(why)): bufor \(buffer) GB, wolne \(free) GB - czekam na wysylke") await stopBackup() state = .pausedForBuffer } else if !running { diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index fb0ba62..43073a5 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -25,7 +25,11 @@ public enum DriveBufferService { public static let remoteName = "gdrive" public static let remotePath = "CloudMachine/mac-studio" - public static let cacheSize = "100G" + /// Rozmiar bufora. Trzymany jako liczba, bo progi dozorcy sa z niego + /// wyliczane - inaczej zmiana jednego bez drugiego daje progi, ktore nigdy + /// nie zadzialaja albo dzialaja natychmiast. + public static let cacheSizeGB = 100 + public static var cacheSize: String { "\(cacheSizeGB)G" } /// Adres interfejsu sterujacego rclone. Slucha tylko na petli zwrotnej, ale /// kazdy lokalny proces moze przez niego sterowac montowaniem - jesli kiedys @@ -128,6 +132,14 @@ public enum DriveBufferService { public var uploadsQueued: Int public var files: Int public var erroredFiles: Int + /// Rozmiar bufora wg samego rclone. Liczenie go wlasnym obchodem katalogu + /// oznaczalo 6504 wywolania stat przy kazdym odswiezeniu interfejsu, co + /// 10 sekund, na tym samym dysku, na ktory leci backup. + public var bytesUsed: UInt64 + /// rclone nie ma juz gdzie odlozyc danych - nie zdazyl wyslac tego, co + /// trzyma, wiec nie ma czego usunac. Mocniejszy sygnal niz jakikolwiek + /// prog, bo pochodzi od tego, kto naprawde wie. + public var outOfSpace: Bool public var isQuiet: Bool { uploadsInProgress == 0 && uploadsQueued == 0 } } @@ -157,7 +169,9 @@ public enum DriveBufferService { uploadsInProgress: number("uploadsInProgress", in: disk), uploadsQueued: number("uploadsQueued", in: disk), files: number("files", in: disk), - erroredFiles: number("erroredFiles", in: disk) + erroredFiles: number("erroredFiles", in: disk), + bytesUsed: UInt64(max(0, number("bytesUsed", in: disk))), + outOfSpace: (disk["outOfSpace"] as? Bool) ?? false ) } @@ -173,8 +187,13 @@ public enum DriveBufferService { return false } - /// Rozmiar bufora na dysku w bajtach. - public static func cacheSizeBytes() -> UInt64 { + /// Rozmiar bufora w bajtach - z obchodu katalogu. + /// + /// Uzywane TYLKO awaryjnie, gdy interfejs sterujacy rclone nie odpowiada. + /// Normalnie liczbe podaje samo rclone (`QueueStats.bytesUsed`): obchod + /// katalogu to 6504 wywolania stat, a przy odswiezaniu co 10 sekund + /// niepotrzebne obciazenie dysku, na ktory akurat leci backup. + public static func cacheSizeBytesByWalk() -> UInt64 { guard let enumerator = FileManager.default.enumerator( at: cacheDir, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], diff --git a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift index eb02669..b9f9f1f 100644 --- a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift @@ -169,3 +169,44 @@ final class DailyQuotaDetectionTests: XCTestCase { XCTAssertFalse(DriveBufferService.logMentionsUploadLimit("", now: Date(), within: 30)) } } + +/// Decyzje dozorcy bufora. Komentarz przy `step()` obiecywal, ze wydzielenie +/// go z petli sluzy testowaniu - a testu nie bylo. Tu jest. +final class BufferGuardThresholdTests: XCTestCase { + + /// Prog pauzy MUSI lezec powyzej rozmiaru bufora. `--vfs-cache-max-size` to + /// granica miekka i bufor normalnie stoi przy limicie (zmierzone: rowno + /// 100 GiB przez cala pierwsza wysylke). Prog rowny albo nizszy oznaczalby + /// wstrzymywanie backupu bez przerwy. + func testPauseThresholdSitsAboveTheCacheSize() { + let t = BufferGuardService.Thresholds() + XCTAssertGreaterThan( + t.highGB, DriveBufferService.cacheSizeGB, + "Prog pauzy ponizej rozmiaru bufora zatrzymywalby backup non stop.") + } + + /// Prog wznowienia musi byc wyraznie nizszy od progu pauzy, inaczej dozorca + /// oscylowalby miedzy start i stop przy kazdym tyknieciu. + func testResumeThresholdLeavesHysteresis() { + let t = BufferGuardService.Thresholds() + XCTAssertLessThan(t.lowGB, t.highGB) + XCTAssertLessThanOrEqual( + t.lowGB, t.highGB / 2, + "Zbyt waski odstep progow daje cykl pauza-wznowienie-pauza.") + } + + /// Progi wyliczaja sie z rozmiaru bufora. Wpisane z palca dzialaly tylko + /// przypadkiem, dla jednej konkretnej wartosci. + func testThresholdsFollowTheCacheSize() { + let t = BufferGuardService.Thresholds() + XCTAssertEqual(t.highGB, DriveBufferService.cacheSizeGB * 3 / 2) + XCTAssertEqual(t.lowGB, DriveBufferService.cacheSizeGB * 2 / 5) + } + + func testExplicitThresholdsAreRespected() { + let t = BufferGuardService.Thresholds(highGB: 10, lowGB: 2, minFreeGB: 5) + XCTAssertEqual(t.highGB, 10) + XCTAssertEqual(t.lowGB, 2) + XCTAssertEqual(t.minFreeGB, 5) + } +} From 3102bf78a8babe119b121b010817f797fa5389b9 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Sat, 12 Sep 2026 06:00:38 +0200 Subject: [PATCH 16/18] Rewrite the README for the architecture that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README still described the two-tier design: Time Machine to a real local disk, a background job archiving completed backups to Drive, and that disk optionally shared over SMB for a second Mac. None of it is true any more — the backup writes straight through the buffer to Drive, and the setup it documented would not work if followed. The new text leads with why the write path has no network filesystem in it, since that is the decision the whole design rests on, and carries the measured costs rather than estimates: 210 GiB full, ~370 MB per increment, ~9 GB a day against Google's 750 GB ceiling. The reasoning behind band size, the bundled rclone and FUSE-T, and the buffer guard is stated where someone deciding whether to trust this will look for it, including FUSE-T's non-commercial licence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- README.md | 285 +++++++++++++++++++++++++++--------------------------- 1 file changed, 142 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 143976b..a7c05a9 100644 --- a/README.md +++ b/README.md @@ -4,201 +4,200 @@ [![Platform: macOS 14+](https://img.shields.io/badge/Platform-macOS%2014%2B-blue.svg)](#) [![CI](https://github.com/RenaCode/CloudMachine/actions/workflows/ci.yml/badge.svg)](https://github.com/RenaCode/CloudMachine/actions/workflows/ci.yml) -Native Time Machine backup for a Mac, with automatic offsite archiving to Google Drive. +Native Time Machine, backed by Google Drive. No external disk, no NAS, nothing +plugged in at home. -CloudMachine runs on a **two-tier architecture**: Time Machine backs up to a real local disk (fully durable, native macOS backup with full version history and Migration Assistant support), and a background job archives completed backups to Google Drive for offsite retention. That same local disk can optionally be shared over the network (SMB), so a second Mac can use it as its own, independent Time Machine target. +Time Machine writes to a disk image that macOS mounts like any other volume. The +image's contents live on Google Drive, behind a local write buffer. Backups land +in the buffer at SSD speed and drain to the cloud in the background, so a dropped +connection stalls the upload instead of interrupting the backup. -CloudMachine does not create or register the local Time Machine volume, or start/stop backups, itself — that's a deliberate choice, kept fully in your hands via the normal macOS tools (Disk Utility, *System Settings -> Time Machine*), so you're free to point it at any disk, internal or external. CloudMachine's job is what's hard to do by hand: cloud archiving, checksum verification, and (optionally) turning that disk into a network share for a second Mac. - ---- - -## 📋 Requirements +``` +Time Machine + -> /Volumes/CloudMachine attached sparsebundle; Time Machine sees plain APFS + -> ~/.cloudmachine/drive rclone mount over FUSE-T + -> ~/.cloudmachine/cache 100 GB write buffer + -> gdrive:CloudMachine/... Google Drive +``` -* macOS 14 (Sonoma) or newer, with administrator privileges. -* A Google account with free space (e.g., Google One or Workspace) and the Google Drive API enabled. -* **Tools**: Homebrew and `rclone`. The GUI application (and `cloudmachine-agent install-dependencies`) install these automatically if needed. +**No network filesystem sits in the write path.** That is the whole point. +Time Machine over SMB to a NAS or a cloud share is the common approach and the +fragile one — a sparsebundle written directly over a network link corrupts when +the link drops. Here Time Machine talks to a locally attached image and never +knows the cloud exists. --- -## 🚀 Setup - -### 1. Create and register the local backup volume (manual, once) - -Do this first, with the normal macOS tools — CloudMachine only reads whatever is currently registered as the Time Machine destination, it doesn't create or register it: - -* **Disk Utility (GUI)**: format the disk — an internal partition, or a whole external drive (e.g. an SSD) — as APFS, then *System Settings -> Time Machine -> Add Backup Disk* and pick it. -* **Terminal**, for an internal partition sized with a quota (a ceiling, not a guarantee — actual usable space is still capped by real free space in the container): - ```bash - diskutil apfs list # find your boot container's disk identifier, e.g. disk3 - diskutil apfs addVolume disk3 APFS "CloudMachine-Local" -quota 300G - sudo tmutil setdestination -a /Volumes/CloudMachine-Local - ``` - For a whole external disk instead, erase it directly (⚠️ **destroys any existing data on that disk**): - ```bash - diskutil list external physical # find the external disk's identifier, e.g. disk4 - diskutil eraseDisk APFS "CloudMachine-Local" disk4 - sudo tmutil setdestination -a /Volumes/CloudMachine-Local - ``` - -Want to exclude folders from the backup (VM disk images, container/Docker data, anything already synced elsewhere like iCloud Drive)? Use *System Settings -> Time Machine -> Options*. +## What it costs in practice -Start a backup with `sudo tmutil startbackup --auto` (or just wait for Time Machine's own hourly schedule). If it fails with `BACKUP_FAILED_TARGETVOL_DISK_FULL`, that's expected on a tightly-sized volume — Time Machine cleans up and retries automatically. +Measured on a Mac Studio, 332 Mbit/s uplink, ~600 GB of live data: -### 2. Let the GUI Setup Wizard handle the rest +| | | +|---|---| +| Full backup | 210 GiB, about two hours | +| Incremental backup | ~370 MB, a few minutes | +| Daily upload | ~9 GB — 1.2% of Google's 750 GB/day ceiling | +| Google Drive used | 214 GiB | -Full Disk Access (see below) is required for step 3 below (checksum verification). - -1. **Install dependencies**: installs `rclone` via Homebrew. -2. **Connect Google Drive**: `cloudmachine-agent configure-remote` (opens a browser for OAuth login). Cloud archiving to Google Drive starts working automatically once connected. -3. **Pick a disk and share it over the network** (optional): lists local disks under `/Volumes` (excluding the boot disk), turns on File Sharing if it's off, and creates a plain SMB share via the public `sharing` tool. This is meant for a *second* Mac (e.g. a MacBook) to use the same physical disk as its own, independent network Time Machine target. CloudMachine deliberately stops at creating the SMB share — check "Share as a Time Machine backup destination" for it yourself in *System Settings -> General -> Sharing* (that specific mechanism isn't public API, so automating it would be a fragile dependency on the exact macOS version). - - Equivalent manual commands for this step, if you prefer the terminal: - ```bash - sudo launchctl enable system/com.apple.smbd - sudo launchctl kickstart -k system/com.apple.smbd - sudo sharing -a /Volumes/YourDisk -S "ShareName" -s 001 -g 000 - ``` +The daily cap only matters for the first backup, and only if the source is +larger than 750 GB. --- -## 🔒 Full Disk Access Requirements +## Requirements -> [!IMPORTANT] -> For security reasons, macOS requires **Full Disk Access (FDA)** permissions for processes reading/verifying Time Machine backups. Without this permission, the OS blocks the internal mechanisms of `tmutil`, resulting in errors like `Resource busy` or similar Full Disk Access errors. -> -> Add the **CloudMachine.app** (and **Terminal**, if you use the CLI) in *System Settings -> Privacy & Security -> Full Disk Access*. -> -> If you build the app yourself from source: the default ad-hoc signature generates a new identifier with **every** rebuild, so macOS revokes previously granted Full Disk Access after each build. Run `cloudmachine-agent setup-signing-cert` once (creates a local, self-signed code-signing certificate) so the app's identity — and thus the granted permission — persists across rebuilds. `build-app` uses this certificate automatically once it exists. +- macOS 14 (Sonoma) or newer. Administrator rights for two commands, listed below. +- A Google account with room to spare. +- Nothing else. CloudMachine installs its own `rclone` and its own copy of FUSE-T. --- -## ☁️ Cloud Archiving - -`CloudArchiveService` periodically copies completed, "cold" backups from the local volume up to Google Drive via `rclone`. Safety comes from two guarantees: `tmutil listbackups` only ever lists fully-finished backups (an in-progress one is invisible to it until done), and the archiver additionally refuses to run while Time Machine is actively writing. Each backup is copied at most once (tracked in a local state file) and in chronological order, since later backups are hardlinked to earlier ones — a failed copy stops the run rather than skipping ahead, so cloud-side history never gets holes. - -It runs automatically via the `archive-watchdog` background agent (checks hourly, real work gated to once per `CM_ARCHIVE_INTERVAL_HOURS` — 6 by default), or on demand from the GUI ("Archiwizuj teraz") or `cloudmachine-agent archive-now`. The cloud side can retain far more history than fits in the local quota, at the cost of not being instantly browsable from Time Machine's UI for very old snapshots (they'd need to be pulled back down first). - -Optional upload speed limit: set `bwlimit_mbps` (Mbps, like an ISP plan) in `~/Library/Application Support/CloudMachine/machines.json`. +## Setup ---- - -## 🩺 Background Automation - -Two agents run via `launchctl`, installed together with `cloudmachine-agent install-launchd`: +```sh +cloudmachine-agent install-rclone # official binary — the Homebrew build cannot mount +cloudmachine-agent install-fuse # FUSE-T, inside CloudMachine, no separate app +cloudmachine-agent configure-remote # Google OAuth in the browser +cloudmachine-agent create-image --size-gb 4000 +cloudmachine-agent attach-image +cloudmachine-agent install-launchd # agents that keep it running +``` -* **Verify Watchdog** (`com.renacode.cloudmachine.verify-watchdog`): performs a checksum validation of the latest backup once every 7 days when Time Machine is idle, notifying you via system notifications if any integrity issue is detected. -* **Archive Watchdog** (`com.renacode.cloudmachine.archive-watchdog`): copies completed backups to Google Drive on the schedule described above. +Two steps need `sudo`, because they change system-wide settings: -Time Machine's own native retry/thinning behavior handles backup scheduling and local space management — no separate watchdog needed for that. +```sh +sudo tmutil setdestination /Volumes/CloudMachine +sudo tmutil enable # hourly backups; skip if you prefer manual +``` ---- +### Your own Google OAuth credentials -## 🔑 Custom Google client_id (Recommended) +`configure-remote` reads `client_id` and `client_secret` from the macOS Keychain +under the service `cloudmachine-gdrive`. Create them at +[console.developers.google.com](https://console.developers.google.com/): new +project, enable the Google Drive API, consent screen, credentials, OAuth 2.0 of +type *Desktop*. Then: -By default, `rclone` logs in using a **shared application ID (client_id)** used by all `rclone` users worldwide. This means the global request-per-second limit for the Google Drive API is shared, which under heavy load (large initial backup, many small band files) leads to throttling and slowdowns. Creating your own private `client_id` is a free, one-time setup in the Google Cloud Console that gives you your own dedicated quota. +```sh +security add-generic-password -a client_id -s cloudmachine-gdrive -w -U +security add-generic-password -a client_secret -s cloudmachine-gdrive -w -U +``` -1. Go to [console.cloud.google.com](https://console.cloud.google.com) and log in with the Google account used for Drive. -2. Create a new project (project selector at the top → *New Project*). -3. Go to **APIs & Services → Library** → search for **Google Drive API** → click **Enable**. -4. Go to **APIs & Services → OAuth consent screen** → User Type: **External** → Create. Fill in the app name and contact email. In the **Test users** section, add **exactly the Gmail address** you use to log into CloudMachine. -5. Go to **APIs & Services → Credentials** → **+ Create Credentials → OAuth client ID** → Application type: **Desktop app** → Create. -6. Copy the displayed **Client ID** and **Client secret**. -7. Update your existing remote configuration and log in again (this will open a browser window): - ```bash - rclone config update gdrive-cloudmachine client_id "YOUR_CLIENT_ID" client_secret "YOUR_CLIENT_SECRET" - ``` - Data already synchronized on Google Drive remains untouched — only the authentication method changes. +Without `-w `, `security` prompts — the secret stays out of your shell +history and out of `ps`. -If you encounter the Google error **"Access blocked: project has not configured OAuth consent screen"** or similar, make sure you added your email as a Test User in Step 4. +Your own credentials are not optional polish: rclone's shared `client_id` is +being retired during 2026, and Google rate-limits per `client_id`, so on the +shared one you compete with every other rclone user. --- -## 🧪 Test Plan (Do this before trusting this solution) +## Running it -1. **Exclude Large Directories**: Temporarily exclude large folders (e.g., Downloads, heavy project directories) in Time Machine settings (*System Settings -> Time Machine -> Options*) so that the first test backup completes quickly. -2. **Start the First Backup Manually**: - ```bash - sudo tmutil startbackup --auto --block - ``` -3. **Verify Integrity**: - ```bash - cloudmachine-agent verify-backup - ``` -4. **Test Incremental Backups**: Run the backup process manually 2–3 times. Monitor progress with `tmutil status` and history with `tmutil listbackups -d /Volumes/CloudMachine-Local`. -5. **Test Cloud Archiving**: `cloudmachine-agent archive-now`, then check the Google Drive folder for the copied backup. -6. **Enable Full Backups**: If the verifications completed without errors, remove the temporary exclusions and let Time Machine secure the entire drive. +```sh +cloudmachine-agent drive-status +``` -If `cloudmachine-agent verify-backup` reports a checksum error at any point, **stop the backup immediately** and inspect the logs. +``` +Narzedzia: OK +Montowanie Drive: OK +Obraz podpiety: OK (/Volumes/CloudMachine) +Bufor: 103 GB z 100G +Wolne na dysku: 288 GB +Kolejka wysylki: 0 w toku, 0 w kolejce, 0 bledow +Cel Time Machine: /Volumes/CloudMachine +Backup: nie trwa +``` ---- +The number that matters is the upload queue. Until it returns to zero between +backups, part of the backup is still only on this Mac. -## 🔒 Passwordless `tmutil verifychecksums` (sudoers) +Three launchd agents keep it alive, all running the binary inside the app: -`verifychecksums` is the only privileged `tmutil` subcommand CloudMachine itself still calls (registering the destination and starting/stopping backups are now manual steps — see Setup above). The GUI configures this automatically the first time verification is needed (GUI "Verify checksums" button, or the verify-watchdog) — one administrator password prompt, then it writes a `sudoers` rule with `NOPASSWD`, scoped to your local backup volume's actual path. +| Agent | Job | +|---|---| +| `gdrive-buffer` | holds the rclone mount; `KeepAlive` | +| `gdrive-attach` | attaches the image, retries every 15 minutes | +| `buffer-guard` | pauses Time Machine when the buffer outgrows the uplink | -Without the GUI (command-line installation), add this rule manually: +### Before rebooting -```bash -sudo visudo -f /etc/sudoers.d/cloudmachine +```sh +cloudmachine-agent prepare-shutdown ``` -Paste the following (replace `YOUR_USERNAME` with the output of `whoami`, and `/Volumes/CloudMachine-Local` with your actual local backup volume's path): +Powering off is where this design is fragile. Detaching the image is itself a +write — APFS flushes metadata into bands, and the upload of those bands is +deferred. Killing rclone inside that window does not cost "the last few +changes", it costs the volume's root directory. `prepare-shutdown` stops the +backup, detaches, waits for the queue to drain, and refuses to report success +while anything is still local. -``` -YOUR_USERNAME ALL=(root) NOPASSWD: /usr/bin/tmutil verifychecksums /Volumes/CloudMachine-Local** -``` +Sleep is safe and needs nothing: the buffer survives, uploads resume on wake, +and Power Nap wakes the Mac for scheduled backups. --- -## 📊 Monitoring +## Why the pieces are what they are + +**32 MB bands.** Google Drive allows roughly two operations per file per second +and caps a drive at 400,000 files, which favours large bands. But every change +dirties a whole band, which favours small ones. Measured under Time Machine's +actual write pattern, 64 MB bands cost exactly twice the transfer of 8 MB bands. +32 MB is the smallest band at which the first upload stops being bound by +Drive's per-file rate and becomes bound by the link. The size is fixed when the +image is created and cannot be changed afterwards. + +**Its own rclone.** The Homebrew build is compiled without FUSE and refuses to +mount outright. CloudMachine installs the official binary beside it, verified by +SHA256. + +**Its own FUSE-T.** The official installer leaves an app in `/Applications` that +only hosts an FSKit backend this project does not use. CloudMachine keeps the two +files it actually needs in `~/.cloudmachine/fuse` and symlinks the library where +rclone looks for it — no root required, since `/usr/local/lib` belongs to the +user. FUSE-T is not open source: its binary distribution is free for +non-commercial use provided the copyright notice is kept, which is why +`LICENSE.rtf` is copied alongside. Bundling it with commercial software needs a +licence from its authors. + +**A buffer guard.** `--vfs-cache-max-size` is a soft limit — rclone only evicts +what it has already uploaded, so when everything is queued the buffer keeps +growing and can fill the disk. Time Machine writes at SSD speed, rclone uploads +at link speed, and the difference accumulates. The guard pauses Time Machine +above a threshold and resumes when the queue catches up, trading speed for +finishing at all. -```bash -tail -f ~/Library/Logs/CloudMachine/*.log -launchctl list | grep renacode.cloudmachine -tmutil destinationinfo -``` +--- -The GUI's Status tab shows the same information visually: dependency/connection state, local volume usage, live backup progress, and cloud archive status (archived/pending counts, last archived backup). +## Restoring ---- +Nothing special: this is a normal Time Machine backup. Enter Time Machine from +the menu bar to browse versions, or restore a whole system through Migration +Assistant. That is the reason for the disk-image approach — file-level cloud +backup tools cannot feed Migration Assistant. -## 🧹 Uninstallation +Check the image itself with: -```bash -for plist in ~/Library/LaunchAgents/com.renacode.cloudmachine.*.plist; do - launchctl unload "$plist" -done -rm ~/Library/LaunchAgents/com.renacode.cloudmachine.*.plist -sudo tmutil removedestination +```sh +cloudmachine-agent verify-image ``` -To also reclaim the disk space, delete the local backup volume (this destroys all local backup history — make sure Google Drive has what you need first, via `cloudmachine-agent archive-now`): -```bash -diskutil apfs deleteVolume /Volumes/CloudMachine-Local -``` +`hdiutil verify` does not work on a sparsebundle: such an image carries no +checksum and the tool reports `has no checksum`. The right tool is `fsck_apfs` +on the attached device, which is what `verify-image` runs. --- -## 🛠️ Building the Application from Source - -The build tools (`build-app`, `make-dmg`, `setup-signing-cert`) are subcommands of `cloudmachine-agent` — the entire build system is written in Swift, without bash scripts. Running `swift run` automatically compiles `cloudmachine-agent` (in debug mode) on first use. - -```bash -cd mac-app -swift run cloudmachine-agent setup-signing-cert # ONCE - creates a local cert so FDA survives rebuilds -swift run cloudmachine-agent build-app # Compiles the Release version, builds the .app bundle (GUI + CLI agent) -swift run cloudmachine-agent make-dmg # Packs build/CloudMachine.app into build/CloudMachine-.dmg -``` - -`build-app` compiles two binaries from the same Swift Package (`mac-app/`): `CloudMachine.app` (GUI) and `cloudmachine-agent` (CLI, called by launchd and the command line). Both share the same local-volume, archiving, verification, and setup logic (`CloudMachineCore`), ensuring identical behavior. To build the CLI agent alone without packaging `.app`: `cd mac-app && swift build -c release --product cloudmachine-agent`. The version shown in the GUI sidebar comes from `mac-app/VERSION`. - -### CI and Releases +## Measurement harnesses -* Every push or pull request triggers [CI](.github/workflows/ci.yml): runs `swift format lint`, `swift build`, and `swift test` for the entire package. -* Pushing a tag in the format `vX.Y.Z` triggers [Release DMG](.github/workflows/release.yml): builds the app (ad-hoc signing, no Apple Developer account required) and publishes `CloudMachine-.dmg` as a GitHub Release asset. +`gdrive/` holds the scripts used to measure the behaviour behind these +decisions — write amplification per band size, and what survives the cloud layer +dying mid-write. They are not part of the running system; see +[gdrive/README.md](gdrive/README.md) for the numbers. --- -## 📄 License +## Licence -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +MIT. FUSE-T and rclone keep their own licences; see above. From 4760cc4c2d62df43f2701a6a3c47b0db25bda983 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Sat, 12 Sep 2026 06:32:55 +0200 Subject: [PATCH 17/18] Answer "is it safe to reboot" instead of demanding a ritual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running prepare-shutdown before every restart is the wrong deal to offer. The risk is not constant: it exists only while the buffer holds data that has not been uploaded. macOS gives agents seconds to exit, which is plenty for an empty queue and useless for a full one — and the queue returns to zero within minutes of each hourly backup, so most of the time a plain reboot is simply fine. drive-status now says which case you are in. An unreachable control interface counts as unsafe: with a question like this, silence cannot mean yes. install-launchd detaches the image before reloading the agents. Reloading them while the image is attached pulls the mount out from under it, and detaching is itself a write that still has to reach Drive. I made exactly this mistake three times in two days, including twice after writing the warning about it, so it is now the code's job rather than mine to remember. Adds a login agent that opens the app through `open` — launching the binary directly would put a menu-bar app outside the Aqua session with nothing to draw on. No KeepAlive: `open` returns immediately, so closing the window stays closed instead of being an invitation to relaunch. Backups do not depend on the app; it is the view, not the engine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- ...m.renacode.cloudmachine.app.plist.template | 32 +++++++++++++++++++ .../CloudMachineAgent/DriveCommands.swift | 3 ++ .../CloudMachineCore/BackupImageService.swift | 22 +++++++++++++ .../CloudMachineCore/LaunchdInstaller.swift | 19 +++++++++++ 4 files changed, 76 insertions(+) create mode 100644 launchd/com.renacode.cloudmachine.app.plist.template diff --git a/launchd/com.renacode.cloudmachine.app.plist.template b/launchd/com.renacode.cloudmachine.app.plist.template new file mode 100644 index 0000000..a8a7705 --- /dev/null +++ b/launchd/com.renacode.cloudmachine.app.plist.template @@ -0,0 +1,32 @@ + + + + + Label + com.renacode.cloudmachine.app + ProgramArguments + + /usr/bin/open + -a + /Applications/CloudMachine.app + + RunAtLoad + + + StandardOutPath + __CM_LOG_DIR__/launchd-app.out.log + StandardErrorPath + __CM_LOG_DIR__/launchd-app.err.log + + diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index f3b3067..a94c93f 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -208,6 +208,9 @@ struct DriveStatus: AsyncParsableCommand { print("Kolejka wysylki: (interfejs rc nieosiagalny)") } + let safe = await BackupImageService.safeToRebootNow() + print("Restart bez pytania: \(safe ? "TAK - kolejka pusta" : "NIE - najpierw prepare-shutdown")") + if DriveBufferService.hitDailyQuota() { print("UWAGA: dobowy limit uploadu Google Drive wyczerpany") } diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index 512d412..8959b0d 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -322,6 +322,28 @@ public enum BackupImageService { ? "Obraz spojny." : "Obraz NIESPOJNY: \(fsck?.stdout.suffix(500) ?? "")") } + // MARK: - Gotowosc do restartu + + /// Czy mozna bezpiecznie wylaczyc Maca bez `prepare-shutdown`. + /// + /// Ryzyko przy wylaczaniu nie jest stale - istnieje tylko wtedy, gdy w + /// buforze czekaja dane jeszcze niewyslane. macOS daje agentom kilkanascie + /// sekund na zamkniecie, co przy pustej kolejce wystarcza z zapasem, a przy + /// pelnej nie wystarcza wcale. + /// + /// Zmierzone: kolejka wraca do zera w ciagu kilku minut po kazdym backupie + /// godzinowym, wiec przez wieksza czesc doby restart jest po prostu + /// bezpieczny. Zamiast kazac uzytkownikowi pamietac o poleceniu przed kazdym + /// restartem, mowimy mu, kiedy naprawde jest potrzebne. + public static func safeToRebootNow() async -> Bool { + guard let stats = await DriveBufferService.queueStats() else { + // Bez odczytu ze stanu kolejki nie mamy podstaw twierdzic, ze jest + // bezpiecznie - a przy takim pytaniu milczenie musi znaczyc "nie". + return false + } + return stats.isQuiet + } + // MARK: - Ponawianie /// Ponawia operacje `hdiutil` na montowaniu FUSE-T. diff --git a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift index be9a74b..b7cd239 100644 --- a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift @@ -12,6 +12,25 @@ public enum LaunchdInstaller { } public static func install() async -> CMActionResult { + // Instalacja przeladowuje agentow, w tym ten trzymajacy montowanie. Zrobienie + // tego przy podpietym obrazie wyrywa mu podloge w trakcie - a odpiecie jest + // zapisem, ktory musi jeszcze doleciec na Dysk. Popelnilem ten blad trzy razy + // z rzedu, wiec nie polegamy juz na pamietaniu o nim. + if BackupImageService.isAttached { + CMLogger.log("Instalacja agentow: najpierw odpinam obraz i czekam na wysylke") + let detached = await BackupImageService.detach() + CMLogger.log("Instalacja agentow: \(detached.message)") + if !detached.succeeded { + return CMActionResult( + succeeded: false, + message: """ + Nie odpieto obrazu przed przeladowaniem agentow - przerywam, zeby nie \ + stracic danych czekajacych w buforze. + \(detached.message) + """) + } + } + guard let templatesDir = CMPaths.launchdTemplatesDir else { return CMActionResult( succeeded: false, message: "Nie znaleziono katalogu launchd/ z szablonami.") From e6df7ce0631837692a5126b64484fa3296ca2069 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Sat, 12 Sep 2026 06:48:38 +0200 Subject: [PATCH 18/18] Put cloudmachine-agent on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command in the README and in conversation was written as `cloudmachine-agent ...`, but the binary lives inside the app bundle, so all of them end in "command not found". The user hit this on the one command that matters most — prepare-shutdown, right before a restart. install-launchd now symlinks it into /usr/local/bin, which needs no root since that directory belongs to the user. The README says where the binary is for anyone who has not run the installer yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LWh4XwG2PhLEo4tBeTVumv --- README.md | 7 ++++ .../Sources/CloudMachineCore/CMTooling.swift | 34 +++++++++++++++++++ .../CloudMachineCore/LaunchdInstaller.swift | 4 +++ 3 files changed, 45 insertions(+) diff --git a/README.md b/README.md index a7c05a9..ada1048 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ larger than 750 GB. ## Setup +`cloudmachine-agent` lives inside the app bundle. `install-launchd` symlinks it +into `/usr/local/bin`; until then, call it by its full path: + +```sh +/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help +``` + ```sh cloudmachine-agent install-rclone # official binary — the Homebrew build cannot mount cloudmachine-agent install-fuse # FUSE-T, inside CloudMachine, no separate app diff --git a/mac-app/Sources/CloudMachineCore/CMTooling.swift b/mac-app/Sources/CloudMachineCore/CMTooling.swift index adcef92..30ffe23 100644 --- a/mac-app/Sources/CloudMachineCore/CMTooling.swift +++ b/mac-app/Sources/CloudMachineCore/CMTooling.swift @@ -41,6 +41,40 @@ public enum CMTooling { try await ProcessRunner.run(managedRclonePath.path, args, timeout: timeout) } + // MARK: - Dostepnosc z terminala + + /// Sciezka, pod ktora `cloudmachine-agent` ma byc widoczny w PATH. + public static let commandLinkPath = "/usr/local/bin/cloudmachine-agent" + + /// Zaklada dowiazanie do binarki agenta w PATH. + /// + /// Bez tego kazde polecenie z dokumentacji - `prepare-shutdown`, + /// `drive-status` - konczy sie "command not found", bo binarka siedzi + /// w bundlu aplikacji. Roota nie trzeba: `/usr/local/bin` nalezy do + /// uzytkownika i grupy admin. + @discardableResult + public static func linkCommandIntoPath() -> Bool { + guard let agent = CMPaths.agentBinaryPath else { return false } + let fm = FileManager.default + let link = URL(fileURLWithPath: commandLinkPath) + + if let existing = try? fm.destinationOfSymbolicLink(atPath: commandLinkPath), + existing == agent.path + { + return true + } + try? fm.createDirectory( + at: link.deletingLastPathComponent(), withIntermediateDirectories: true) + try? fm.removeItem(at: link) + do { + try fm.createSymbolicLink(at: link, withDestinationURL: agent) + CMLogger.log("Dodano \(commandLinkPath) -> \(agent.path)") + return true + } catch { + return false + } + } + // MARK: - FUSE /// Nasza kopia FUSE-T - zeby nie trzymac w systemie osobnej aplikacji. diff --git a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift index b7cd239..d7c3dc1 100644 --- a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift @@ -12,6 +12,10 @@ public enum LaunchdInstaller { } public static func install() async -> CMActionResult { + // Zeby polecenia z dokumentacji dzialaly z terminala, a nie konczyly sie + // "command not found" - binarka siedzi w bundlu aplikacji. + CMTooling.linkCommandIntoPath() + // Instalacja przeladowuje agentow, w tym ten trzymajacy montowanie. Zrobienie // tego przy podpietym obrazie wyrywa mu podloge w trakcie - a odpiecie jest // zapisem, ktory musi jeszcze doleciec na Dysk. Popelnilem ten blad trzy razy