diff --git a/README.md b/README.md index 143976b..ada1048 100644 --- a/README.md +++ b/README.md @@ -4,201 +4,207 @@ [![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. +## Setup -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). +`cloudmachine-agent` lives inside the app bundle. `install-launchd` symlinks it +into `/usr/local/bin`; until then, call it by its full path: -Optional upload speed limit: set `bwlimit_mbps` (Mbps, like an ISP plan) in `~/Library/Application Support/CloudMachine/machines.json`. - ---- +```sh +/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help +``` -## 🩺 Background Automation +```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 +``` -Two agents run via `launchctl`, installed together with `cloudmachine-agent install-launchd`: +Two steps need `sudo`, because they change system-wide settings: -* **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. +```sh +sudo tmutil setdestination /Volumes/CloudMachine +sudo tmutil enable # hourly backups; skip if you prefer manual +``` -Time Machine's own native retry/thinning behavior handles backup scheduling and local space management β€” no separate watchdog needed for that. +### Your own Google OAuth credentials ---- +`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: -## πŸ”‘ Custom Google client_id (Recommended) - -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. diff --git a/gdrive/README.md b/gdrive/README.md new file mode 100644 index 0000000..1f0a635 --- /dev/null +++ b/gdrive/README.md @@ -0,0 +1,143 @@ +# Warstwa Google Drive - pomiary i wnioski + +Dzialajacy system siedzi w aplikacji, nie tutaj. Ten katalog trzyma harnessy +pomiarowe i uzasadnienia decyzji, ktore z tych pomiarow wyszly. + +``` +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. + +## Gdzie co jest + +| 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` | + +```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: 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. + +Zmierzone (`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 | + +Przy zmianie **rozrzuconej** po calym wolumenie rozmiar pasma nie ma znaczenia - +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. 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. + +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, czyli symuluje +padniecie procesu rclone albo wysypanie sie FUSE-T. Trzy rundy, **zero +nieodwracalnych strat** - obraz za kazdym razem przeszedl `fsck_apfs`. + +Zerwanie samego lacza jest lagodniejsze: przy `--vfs-cache-mode full` zapis idzie +do bufora, montowanie stoi i Time Machine niczego nie zauwaza. + +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. + +**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 dzialajacy jako uzytkownik +nie posprzata po sobie sam**. Aplikacja wykrywa to i podaje dokladne polecenie. + +## Bufor jest limitem miekkim + +`--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. + +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. + +## Harnessy pomiarowe + +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 +BAND_MB=32 WORKLOAD=append ./poc-amplification.sh # wzmocnienie zapisu +BAND_MB=32 ROUNDS=3 ./poc-pullplug.sh # smierc warstwy chmurowej +``` + +## 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/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-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-pullplug.sh b/gdrive/poc-pullplug.sh new file mode 100755 index 0000000..a308352 --- /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")/poc-common.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/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/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/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index ac31f00..a7537f2 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -9,16 +9,23 @@ 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, BuildApp.self, MakeDmg.self, SetupSigningCert.self, + // Warstwa Google Drive - zastapila skrypty z gdrive/. + InstallRclone.self, + InstallFuse.self, + MountDrive.self, + CreateImage.self, + AttachImage.self, + 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 new file mode 100644 index 0000000..a94c93f --- /dev/null +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -0,0 +1,258 @@ +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 + } + + // 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. + 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() + + // 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 + 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 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", + 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") + + // Bez konca: dozorca ma przezyc kazdy backup, nie tylko pierwszy. + while true { + await guardService.step() + try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + } + } +} + +// 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 { + 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)") + } + + 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") + } + + 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 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 { + 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/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..929b1ce 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -1,33 +1,40 @@ 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 + /// rclone nie ma gdzie odlozyc danych - bufor pelny samymi niewyslanymi. + var outOfSpace: 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 +43,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 +61,39 @@ 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? + /// 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 { + 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..56e1b8c 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -1,476 +1,195 @@ -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() + 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) + } } } - // MARK: - Config - - func saveConfig() { - do { - try ConfigStore.save(config) - } catch { - fail("Nie udalo sie zapisac konfiguracji: \(error.localizedDescription)") - } + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil } - func currentMachineKey() async -> String { - await MachineIdentity.currentKey() + func refreshAll() async { + await refreshDependencies() + 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 + // marnotrawstwem. } - // 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 - } + // MARK: - Poszczegolne odczyty - func fail(_ message: String) { - status.errorMessage = message - appendLog("BLAD: \(message)") + 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") } - 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)) + private func refreshBuffer() async { + let stats = await DriveBufferService.queueStats() + var buffer = BufferStatus() + buffer.mounted = DriveBufferService.isMounted + buffer.imageAttached = BackupImageService.isAttached + // 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 { + buffer.uploadsQueued = stats.uploadsQueued + buffer.uploadsInProgress = stats.uploadsInProgress + buffer.erroredFiles = stats.erroredFiles + buffer.outOfSpace = stats.outOfSpace } + status.buffer = buffer } - func refreshLogTail() { - guard let data = try? String(contentsOf: CMPaths.combinedLogFile, encoding: .utf8) else { + private func refreshTimeMachine() async { + guard let mountPoint = await TimeMachineStatus.currentDestinationMountPoint() else { + status.timeMachineState = .notRegistered return } - status.logTail = String(data.suffix(20_000)) - } - - // MARK: - Zaleznosci (rclone) - potrzebne dla warstwy archiwizacji w chmurze - - func checkDependencies() async { - status.dependencyState = .checking - let missing = await DependencyInstaller.missingTools() - status.dependencyState = missing.isEmpty ? .ready : .missing(missing) - } - - 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) - } - await checkDependencies() - } - - // 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.") - 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 - ) + lastBytesDone = progress.bytes + lastBytesSampledAt = Date() + status.backupProgress = info } - // MARK: - Udostepnienie sieciowe (SMB) - - func refreshNetworkShare() async { - status.networkShare = NetworkShareStatus( - candidateDisks: await NetworkShareService.candidateDisks(), - fileSharingEnabled: await NetworkShareService.isFileSharingEnabled() - ) - } - - /// 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..0ac2670 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -1,174 +1,196 @@ 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") - } - } + 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 } - .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 250) - .safeAreaInset(edge: .bottom) { - SidebarStatusFooter() - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(.thinMaterial) + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .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 + + 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) } - } detail: { - Group { - switch selectedTab { - case .status: - StatusView() - case .wizard: - SetupWizardView() - case .logs: - LogsView() - } + Spacer() + if let at = controller.status.lastRefresh { + Text("odswiezono \(at.formatted(date: .omitted, time: .standard))") + .font(.caption).foregroundStyle(.secondary) } - .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) - } - } + if controller.status.isBusy { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(controller.status.busyLabel).font(.caption).foregroundStyle(.secondary) } } } - .task { - await controller.refreshAll() - } - .onReceive(refreshTimer) { _ in - Task { await controller.refreshAll() } - } } - 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 - } + 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) } -} -struct SidebarStatusFooter: View { - @EnvironmentObject private var controller: CloudMachineController - @State private var isPulse = false + // MARK: - Konfiguracja - 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 + /// 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 - } - 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/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift new file mode 100644 index 0000000..8959b0d --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -0,0 +1,371 @@ +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)") + } + + /// 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 + { + 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) + 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: drained, + message: drained + ? "Odpiete, wszystko wyslane na Google Drive." + : "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.. 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.") + } + + let fsck = try? await ProcessRunner.run(fsckPath, ["-n", String(device)], timeout: 3600) + + // Odpinamy Z CZEKANIEM, nie przez `defer { Task { ... } }`. Tamta wersja + // wracala z funkcji, zanim odpiecie sie wydarzylo - a wolajacy zwykle od + // razu podpina obraz z powrotem, wiec podpiecie scigalo sie z zaleglym + // odpieciem tego samego urzadzenia. + _ = try? await ProcessRunner.run( + "/usr/bin/hdiutil", ["detach", String(device), "-force", "-quiet"], timeout: 120) + + return CMActionResult( + succeeded: fsck?.succeeded == true, + message: fsck?.succeeded == true + ? "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. + /// + /// 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/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/BufferGuardService.swift b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift new file mode 100644 index 0000000..0f86bd5 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift @@ -0,0 +1,176 @@ +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 + + /// Progi wyliczane z rozmiaru bufora, nie wpisane z palca. + /// + /// `--vfs-cache-max-size` jest granica MIEKKA: rclone usuwa tylko to, co + /// juz wyslal, wiec przy pelnej kolejce bufor rosnie ponad limit. Prog + /// pauzy musi wiec lezec POWYZEJ rozmiaru bufora - inaczej dozorca + /// wstrzymywalby backup bez przerwy, bo bufor normalnie stoi przy limicie + /// (zmierzone: rowno 100 GiB przez cala pierwsza wysylke). + /// + /// Wpisanie 150 na sztywno dzialalo tylko przypadkiem, dla bufora 100 GB - + /// po zmianie rozmiaru bufora prog bylby albo absurdalny, albo martwy. + public init( + highGB: Int = DriveBufferService.cacheSizeGB * 3 / 2, + lowGB: Int = DriveBufferService.cacheSizeGB * 2 / 5, + minFreeGB: Int = 80 + ) { + self.highGB = highGB + self.lowGB = lowGB + self.minFreeGB = minFreeGB + } + } + + public enum State: String, Sendable { + /// Nadzorujemy trwajacy backup. + case running + case pausedForBuffer + case pausedForQuota + /// 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 { + 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 = .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 + } + + // MARK: - Pomiary + + /// 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. + /// + /// 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 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 + + // 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 .idle: + if running { + CMLogger.log("Backup ruszyl - nadzoruje bufor") + sawBackupRunning = true + state = .running + } + + case .running: + // 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 { + 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 + } + } + + 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..30ffe23 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/CMTooling.swift @@ -0,0 +1,147 @@ +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: - 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. + /// 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", + "/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 { + // 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 + + 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 { + // 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] = [] + + if !hasManagedRclone { + missing.append("rclone z obsluga montowania") + remedies.append("cloudmachine-agent install-rclone") + } + if !hasFuse { + missing.append("FUSE") + remedies.append("cloudmachine-agent install-fuse") + } + return Readiness(missing: missing, remedies: remedies) + } +} 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/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift new file mode 100644 index 0000000..43073a5 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -0,0 +1,265 @@ +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" + /// 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 + /// 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 + /// 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 } + } + + /// 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), + bytesUsed: UInt64(max(0, number("bytesUsed", in: disk))), + outOfSpace: (disk["outOfSpace"] as? Bool) ?? false + ) + } + + /// 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 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], + 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 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 = 256 * 1024 + try? handle.seek(toOffset: size > window ? size - window : 0) + guard let data = try? handle.readToEnd(), + let text = String(data: data, encoding: .utf8) + else { return false } + 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/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) + } +} diff --git a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift index be9a74b..d7c3dc1 100644 --- a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift @@ -12,6 +12,29 @@ 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 + // 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.") 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/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/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):") } 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/DriveLayerTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift new file mode 100644 index 0000000..b9f9f1f --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift @@ -0,0 +1,212 @@ +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) + } +} + +/// 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)) + } +} + +/// 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) + } +} 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)) - } -}