diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b842fa6..1db4249 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,23 @@ name: Release -# Tag vX.Y.Z -> uniwersalny CloudMachine.app (Apple Silicon + Intel) podpisany -# STALYM certyfikatem, .dmg w GitHub Release i zaktualizowany cask w +# Tag vX.Y.Z -> universal CloudMachine.app (Apple Silicon + Intel) signed with +# a STABLE certificate, a .dmg in the GitHub Release and an updated cask in # RenaCode/homebrew-tap (`brew install --cask renacode/tap/cloudmachine`). # -# Pull request zmieniajacy budowanie albo cask przechodzi ten sam potok na -# sucho: podpis ad-hoc, bez wydania i bez tapu, artefakty do pobrania z runu. +# A pull request that changes the build or the cask goes through the same +# pipeline as a dry run: ad-hoc signature, no release and no tap, artifacts +# downloadable from the run. # -# Sekrety (tylko dla tagu): -# CM_SIGNING_P12_BASE64, CM_SIGNING_P12_PASSWORD - certyfikat self-signed -# "CloudMachine Release Signing" (patrz packaging/README.md). Bez niego -# wydanie sie NIE buduje: podpis ad-hoc zmienia tozsamosc appki przy -# kazdym wydaniu i macOS cofa Pelny dostep do dysku po kazdym upgradzie. -# HOMEBREW_TAP_TOKEN - token z prawem zapisu do RenaCode/homebrew-tap. +# Secrets (tag only): +# CM_SIGNING_P12_BASE64, CM_SIGNING_P12_PASSWORD - the self-signed +# "CloudMachine Release Signing" certificate (see packaging/README.md). +# Without it the release does NOT build: an ad-hoc signature changes the +# app's identity on every release and macOS revokes Full Disk Access after +# every upgrade. +# HOMEBREW_TAP_TOKEN - token with write access to RenaCode/homebrew-tap. # -# Nadal brak Developer ID i notaryzacji - Gatekeeper nie zna wydawcy; cask -# zdejmuje kwarantanne w postflight. +# Still no Developer ID and no notarization - Gatekeeper does not know the +# publisher; the cask removes the quarantine attribute in postflight. on: push: @@ -49,7 +51,7 @@ jobs: run: | version="$(tr -d '[:space:]' < mac-app/VERSION)" if [ "$IS_RELEASE" = "true" ] && [ "${GITHUB_REF_NAME}" != "v${version}" ]; then - echo "::error::Tag ${GITHUB_REF_NAME} != v${version} z mac-app/VERSION. Podbij VERSION albo popraw tag." + echo "::error::Tag ${GITHUB_REF_NAME} != v${version} from mac-app/VERSION. Bump VERSION or fix the tag." exit 1 fi echo "version=${version}" >> "$GITHUB_OUTPUT" @@ -65,7 +67,7 @@ jobs: P12_PASSWORD: ${{ secrets.CM_SIGNING_P12_PASSWORD }} run: | if [ -z "$P12_BASE64" ] || [ -z "$P12_PASSWORD" ]; then - echo "::error::Brak sekretow CM_SIGNING_P12_*. Wydanie podpisane ad-hoc cofaloby Pelny dostep do dysku po kazdym upgradzie - przerywam. Patrz packaging/README.md." + echo "::error::CM_SIGNING_P12_* secrets are missing. An ad-hoc signed release would revoke Full Disk Access after every upgrade - aborting. See packaging/README.md." exit 1 fi keychain="$RUNNER_TEMP/signing.keychain-db" @@ -80,8 +82,8 @@ jobs: security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$P12_PASSWORD" \ -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" - # Dopisujemy do listy wyszukiwania, bo `build-app` szuka certyfikatu - # przez `security find-certificate -c` bez wskazania keychaina. + # Add it to the search list, because `build-app` looks for the + # certificate with `security find-certificate -c` without naming a keychain. security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') sudo security add-trusted-cert -d -r trustRoot -p codeSign \ -k /Library/Keychains/System.keychain "$RUNNER_TEMP/cert.pem" @@ -98,16 +100,16 @@ jobs: archs="$(lipo -archs "build/CloudMachine.app/Contents/MacOS/$bin")" echo "$bin: $archs" case "$archs" in *arm64*x86_64*|*x86_64*arm64*) ;; *) - echo "::error::$bin nie jest uniwersalny ($archs)"; exit 1 ;; + echo "::error::$bin is not universal ($archs)"; exit 1 ;; esac done codesign --verify --deep --strict build/CloudMachine.app signature="$(codesign -dv --verbose=2 build/CloudMachine.app 2>&1)" echo "$signature" - # `build-app` po cichu spada do ad-hoc, gdy nie znajdzie certyfikatu - - # tu to musi byc blad, nie ostrzezenie. + # `build-app` silently falls back to ad-hoc when it cannot find the + # certificate - here that has to be an error, not a warning. if [ "$IS_RELEASE" = "true" ] && ! grep -qF "Authority=$CM_SIGNING_CERT_NAME" <<<"$signature"; then - echo "::error::Wydanie nie jest podpisane certyfikatem '$CM_SIGNING_CERT_NAME'." + echo "::error::The release is not signed with the '$CM_SIGNING_CERT_NAME' certificate." exit 1 fi @@ -124,13 +126,13 @@ jobs: sed -e "s/__VERSION__/${version}/" -e "s/__SHA256__/${sha256}/" \ packaging/homebrew/cloudmachine.rb.in > mac-app/build/cloudmachine.rb if grep -q '__[A-Z0-9]*__' mac-app/build/cloudmachine.rb; then - echo "::error::W casku zostal niewypelniony znacznik."; exit 1 + echo "::error::An unfilled placeholder is left in the cask."; exit 1 fi echo "${sha256} CloudMachine-${version}.dmg" > "mac-app/build/CloudMachine-${version}.dmg.sha256" echo "dmg=${dmg}" >> "$GITHUB_OUTPUT" - # Reguly dla caskow `brew style` stosuje tylko do plikow w Casks/ tapu - - # na luznym pliku sprawdza go jak zwykly Ruby i przepuszcza bledy caska. + # `brew style` applies the cask rules only to files in a tap's Casks/ - + # on a loose file it checks it as plain Ruby and lets cask errors through. - name: brew style + audit env: HOMEBREW_NO_AUTO_UPDATE: "1" @@ -167,7 +169,7 @@ jobs: TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | if [ -z "$TAP_TOKEN" ]; then - echo "::error::Wydanie opublikowane, ale brak HOMEBREW_TAP_TOKEN - cask w tapie NIE zostal zaktualizowany. Zawartosc do recznego wstawienia:" + echo "::error::Release published, but HOMEBREW_TAP_TOKEN is missing - the cask in the tap was NOT updated. Contents to insert manually:" cat mac-app/build/cloudmachine.rb exit 1 fi @@ -190,7 +192,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Casks/cloudmachine.rb if git diff --cached --quiet; then - echo "Cask bez zmian."; exit 0 + echo "Cask unchanged."; exit 0 fi git commit -m "cloudmachine ${{ steps.version.outputs.version }}" git push diff --git a/.gitignore b/.gitignore index 3637c4b..830f63c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,34 @@ -# Konfiguracja z danymi osobistymi (nazwy maszyn, limity) - kazdy uzytkownik ma wlasna +# Configuration with personal data (machine names, limits) - every user has their own config/machines.json -# Logi +# Logs *.log logs/ -# rclone config (zawiera tokeny OAuth) - nigdy nie commitowac +# rclone config (contains OAuth tokens) - never commit rclone.conf *.conf -# Wygenerowane pliki launchd (zawieraja lokalne sciezki uzytkownika) +# Generated launchd files (contain the user's local paths) launchd/*.plist !launchd/*.plist.template # OS .DS_Store -# Robocze/testowe sparsebundle uzywane recznie do eksperymentow z hdiutil/rclone - -# to sa binarne pliki testowe, nie kod projektu. +# Scratch/test sparsebundles used manually for hdiutil/rclone experiments - +# these are binary test files, not project code. scratch/ -# Globalny ~/.gitignore_global na tym koncie ignoruje katalogi "scripts" - -# to jest kluczowy kod tego projektu, wiec jawnie go odignorowujemy. +# The global ~/.gitignore_global on this account ignores "scripts" directories - +# that is key code for this project, so we explicitly un-ignore it. !scripts/ !scripts/*.sh -# Lokalny stan narzedzi Claude / claude-flow / ruflo. -# To sa artefakty konkretnej maszyny i sesji (liczniki edycji, polityki, -# przyjeta konfiguracja) - nie opisuja projektu i nie naleza do repozytorium. -# Definicje agentow (.claude/agents/) i CLAUDE.md zostaja SLEDZONE celowo. +# Local state of the Claude / claude-flow / ruflo tools. +# These are artifacts of a specific machine and session (edit counters, policies, +# adopted configuration) - they do not describe the project and do not belong in +# the repository. Agent definitions (.claude/agents/) and CLAUDE.md stay TRACKED on purpose. .claude-flow/ .claude/proven-config.json .claude/.proven-config-version diff --git a/README.md b/README.md index 0ade0e8..292291f 100644 --- a/README.md +++ b/README.md @@ -107,174 +107,140 @@ else on the account. `operations/about` through the rclone rc gives real bytes. - macOS 14 (Sonoma) or newer. Administrator rights for two commands, listed below. - A Google account with room to spare. -- Homebrew, to install a release — or Xcode / a Swift 5.9+ toolchain, to build - the app yourself (see below). +- [Homebrew](https://brew.sh). - Nothing else at runtime. CloudMachine installs its own `rclone` and its own copy of FUSE-T. +The menu-bar app, CLI output and notifications follow the system language: +Polish when Polish is the first preferred language, English otherwise. +`CM_LANGUAGE=en` or `CM_LANGUAGE=pl` overrides it. Logs are always English, +so they read the same whoever sends them to you. + ### Current limitations -Worth knowing before you start, because none of them announce themselves: - -- **One Mac per Google account.** The Drive folder is a constant, - `gdrive:CloudMachine/mac-studio`, whatever the Mac is called, so a second Mac - on the same account would share the folder and the image with the first. - `config/machines.example.json` describes several machines with per-machine - `limit_gb` budgets, but no code enforces those budgets — what is checked is - the real free space on Drive, reported by rclone. -- **The interface speaks Polish.** The menu-bar app, the CLI output and the log - lines are in Polish; there is no language switch yet. The examples below quote - that output verbatim. -- **Not notarised.** Releases are signed with a self-signed certificate (local - builds ad hoc or with a local one, see below), not with an Apple Developer ID. - The Homebrew cask clears the quarantine flag; a DMG downloaded by hand gets - Gatekeeper's "unidentified developer" warning. +- **Not notarised.** Releases are signed with a self-signed certificate, not + with an Apple Developer ID. The Homebrew cask clears the quarantine flag; a + DMG downloaded by hand gets Gatekeeper's "unidentified developer" warning. +- **Per-machine budgets are not enforced.** `config/machines.example.json` + describes `limit_gb` per Mac, but nothing acts on it — what is checked is the + real free space on Drive, reported by rclone. Several Macs on one account + share that space. --- -## Installing +## Getting started ```sh brew install --cask renacode/tap/cloudmachine +open -a CloudMachine ``` -The cask installs a universal (Apple Silicon + Intel) `CloudMachine.app` into -`/Applications`. `brew upgrade` does not restart the Google Drive mount, and -neither `brew uninstall` nor `--zap` touches the launchd agents or the upload -buffer in `~/.cloudmachine`, which may hold backups that have not reached -Google Drive yet. Run `cloudmachine-agent prepare-shutdown` before -uninstalling. Releases, signing and the cask are described in -[`packaging/README.md`](packaging/README.md). - -### Building the app - -```sh -cd mac-app -swift run cloudmachine-agent setup-signing-cert # optional, once per Mac -swift run cloudmachine-agent build-app # -> mac-app/build/CloudMachine.app -rm -rf /Applications/CloudMachine.app # never copy over a live bundle -cp -R build/CloudMachine.app /Applications/ -``` - -Removing the installed copy first is not optional. `cp -R` onto an existing -bundle overwrites its files in place; macOS still holds the old signature for -them and kills every agent started from the bundle -(`last exit reason = OS_REASON_CODESIGNING`), while `codesign --verify` keeps -passing. Removed and copied anew, the files get new identities. The mount -survives this: the rclone process that holds it lives outside the bundle. - -`build-app` puts the menu-bar app and `cloudmachine-agent` side by side in -`Contents/MacOS/`, with the launchd templates as resources, so the installed app -does not need the repository next to it. It must live in `/Applications`: the -launchd agent that starts the app opens `/Applications/CloudMachine.app`. - -`setup-signing-cert` creates a local, self-signed code-signing certificate in the -login keychain. Without it every build is signed ad hoc with a new identity, and -macOS revokes permissions such as Full Disk Access after each rebuild. With it, -`build-app` signs with that certificate automatically. - -`swift run cloudmachine-agent make-dmg` packs the built app into -`mac-app/build/CloudMachine-.dmg`; `build-app --universal` builds for -both architectures, as releases do. Releases are built by -`.github/workflows/release.yml` from a `vX.Y.Z` tag. The version -comes from `mac-app/VERSION`, and `cloudmachine-agent version` prints it -together with the build number and the commit the binary was built from. - ---- - -## Setup - -`cloudmachine-agent` lives inside the app bundle. `install-launchd` symlinks it -into `/usr/local/bin`; until then, call it by its full path: - -```sh -/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help -``` - -```sh -cloudmachine-agent install-rclone # official binary — the Homebrew build cannot mount -cloudmachine-agent install-fuse # FUSE-T, inside CloudMachine, no separate app -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 steps need `sudo`, because they change system-wide settings: - -```sh -sudo tmutil setdestination /Volumes/CloudMachine -sudo tmutil enable # hourly backups; skip if you prefer manual -``` +CloudMachine lives in the menu bar. Its window opens on a **Required Setup +Steps** card listing what is left to do on this Mac, in order, each with a +button — or, for the two steps an app cannot do, a command to copy: + +1. **Install rclone** and **Install FUSE-T.** CloudMachine downloads its own + copies; nothing else is installed system-wide. +2. **Connect Google Drive** — a command to run in Terminal. It opens Google's + sign-in in the browser and waits for your approval. Enter your own OAuth + credentials first (see [below](#your-own-google-oauth-credentials)). +3. **Grant Full Disk Access** — opens the right pane of System Settings. + Without it CloudMachine cannot read when Time Machine last *finished* a + backup, which is the one check that matters. +4. **Install agents** — the launchd agents that keep the Drive mounted, attach + the image and watch the backup. +5. **Create image**, then **Attach image** — the backup image on Google Drive. + The size is a ceiling, not an allocation: the image is sparse, and Drive + only holds what has been written. +6. **Point Time Machine at CloudMachine** — a `sudo tmutil setdestination` + command to copy, because only an administrator can change it. + +When the card disappears, setup is done. Turn on automatic backups in System +Settings → General → Time Machine, or run `sudo tmutil enable`. + +### Several Macs, one Google account + +Install CloudMachine on each Mac and go through the same steps; they can all +use the same Google account and the same OAuth credentials. Each Mac backs up +into its own folder, `gdrive:CloudMachine/`, holding +`.sparsebundle`, so their backups never mix. + +The folder name is chosen once, at **Connect Google Drive**, from the computer +name. To pick it yourself, add `--folder NAME` to the command the card gives +you, e.g. `… configure-remote --folder office-imac`. It cannot be changed +afterwards, because a new name is a new, empty backup; CloudMachine refuses +rather than orphan the old one. Installations set up before per-Mac folders +keep `mac-studio`, which is where their backup already is. + +### Upgrading and uninstalling + +`brew upgrade` replaces the app without restarting the Google Drive mount; the +agents pick up the new version on their next run. Neither `brew uninstall` nor +`--zap` touches the launchd agents or the upload buffer in `~/.cloudmachine`, +which may hold backups that have not reached Google Drive yet. Run +`cloudmachine-agent prepare-shutdown` before uninstalling. + +Setting up without the app is described in [docs/setup-cli.md](docs/setup-cli.md); +building from source, releases and the measurement harnesses in +[docs/building.md](docs/building.md). ### 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 +Do this before **Connect Google Drive**. Create the credentials 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: - -```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 -``` - -Without `-w `, `security` prompts — the secret stays out of your shell -history and out of `ps`. - -The app window can do the same thing: the *Poświadczenia Google Drive -(OAuth 2.0)* card, folded away at the bottom since it is a once-ever step. It writes through the -`security` tool rather than the Keychain API on purpose — an entry created by -`SecItemAdd` gets an ACL limited to the program that made it, and reading it -from a different binary raises an authorisation dialog. The launchd agent has -nobody to show that dialog to, so it would read nothing and quietly fall back to -the shared `client_id`. - -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. If the Keychain entries are -missing, `configure-remote` still works — it falls back to the shared -`client_id` and says so in the log rather than pretending otherwise. - -The remote is created with scope `drive.file`, which grants access only to files -this application itself created. Full `drive` scope would hand out read, write -and **delete** over the entire Google account, which is far more than a folder -of disk-image bands needs — especially with `--drive-use-trash=false`, where a +type *Desktop*. Paste the client ID and secret into the **Google Drive +Credentials (OAuth 2.0)** card at the bottom of the app window; it stores them +in the macOS Keychain. + +They 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. + +The connection uses scope `drive.file`, which grants access only to files this +application itself created. Full `drive` scope would hand out read, write and +**delete** over the entire Google account, which is far more than a folder of +disk-image bands needs — especially with `--drive-use-trash=false`, where a delete has no bin to recover from. -`configure-remote` refuses to touch a remote that already exists. Overwriting it -replaces the token and the scope, and credentials scoped `drive.file` cannot see -files created by the previous credentials — the backup stays intact but becomes -unreachable, which amounts to the same thing. Back up `~/.config/rclone/rclone.conf` -first and pass `--replace-existing` if you really mean it. - --- ## Running it +Day to day, the menu-bar icon is the whole interface. Its menu shows whether a +backup is running and what is still waiting to upload, with **Back up now** and +**Stop backup**. The window answers one question — is the backup reaching Google +Drive, and if not, why: the time of the last *completed* backup, the state of +the local buffer and the upload, and a red line naming the problem when there is +one. Problems also arrive as macOS notifications, so nothing depends on someone +opening the window. + +Everything the window shows is also available in Terminal, for scripts and for +checking over SSH: + ```sh cloudmachine-agent drive-status ``` ``` -Narzedzia: OK -Montowanie Drive: OK -Obraz podpiety: OK (/Volumes/CloudMachine) -Cache na dysku: 103 GB z 100G -Do wyslania: ~14 GB (462 pozycji) -Wolne na dysku: 288 GB -Kolejka wysylki: 0 w toku, 0 w kolejce, 0 bledow -Restart bez pytania: TAK - kolejka pusta -Wysylka: Wszystko wysłane na Google Drive -Cel Time Machine: /Volumes/CloudMachine -Backup: nie trwa +Tools: OK +Drive mount: OK +Drive folder: gdrive:CloudMachine/mac-studio +Image attached: OK (/Volumes/CloudMachine) +Cache on disk: 103 GB of 100G +To upload: ~14 GB (462 items) +Free on disk: 288 GB +Upload queue: 0 in progress, 0 queued, 0 errors +Restart without asking: YES - queue empty +Upload: Everything uploaded to Google Drive +TM destination: /Volumes/CloudMachine +Backup: not running ``` 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. -The `Wysylka:` line is the same verdict the app window shows, computed in one +The `Upload:` line is the same verdict the app window shows, computed in one place so the two can never disagree. When it is not nominal it prints a second line saying why, and whether it clears on its own. @@ -282,7 +248,7 @@ It has three kinds of answer, not two. Besides "fine" and "broken" there is **"unknown"** — printed when rclone does not answer the question about its queue. That third state exists because of a specific lie: the queue read used to time out, the caller substituted zeros for the missing numbers, and both the -CLI and the app then announced *Wszystko wysłane na Google Drive* while 386 +CLI and the app then announced *Everything uploaded to Google Drive* while 386 bands sat unsent. A verdict computed from numbers nobody measured is worse than no verdict, so now it says so. @@ -316,9 +282,9 @@ cloudmachine-agent backup-health ``` ``` -Ostatnia udana kopia: 2026-09-12 18:25 -Ostatnia proba: 2026-09-12 18:02 -Cykl backupu: OK +Last successful backup: 2026-09-12 18:25 +Last attempt: 2026-09-12 18:02 +Backup cycle: OK ``` It reads the date of the last **completed** backup — `SnapshotDates` in @@ -360,7 +326,7 @@ hours old. ```sh # Did anything fail today? -grep "^\[$(date +%Y-%m-%d)" ~/Library/Logs/CloudMachine/cloudmachine.log | grep -i awaria +grep "^\[$(date +%Y-%m-%d)" ~/Library/Logs/CloudMachine/cloudmachine.log | grep -i "backup failure" # Is the upload actually moving, or only erroring? Successes vs refusals per minute. grep "^$(date +%Y/%m/%d)" ~/.cloudmachine/rclone.log \ @@ -392,8 +358,8 @@ to report. Every run therefore drops its date into `drive-status` and the app window show it: ``` -Czujka backupu: 2026-09-25 22:04 (12 min temu) -Czujka backupu: 2026-09-22 03:10 (3 dni temu) - CZUJKA MOZE NIE CHODZIC +Backup watchdog: 2026-09-25 22:04 (12 min ago) +Backup watchdog: 2026-09-22 03:10 (3 days ago) - THE WATCHDOG MAY NOT BE RUNNING ``` The second line means nobody has been asking whether the backup works — not @@ -574,16 +540,6 @@ do it when you can leave the Mac alone. --- -## Measurement harnesses - -The measurements behind these decisions — write amplification per band size, and -what survives the cloud layer dying mid-write — are written up in -[gdrive/README.md](gdrive/README.md). The shell harnesses that produced them are -gone; that work now lives as subcommands of `cloudmachine-agent`, which is why -`gdrive/` holds nothing but the write-up. - ---- - ## Licence MIT. FUSE-T and rclone keep their own licences; see above. diff --git a/config/machines.example.json b/config/machines.example.json index 1730827..c43be41 100644 --- a/config/machines.example.json +++ b/config/machines.example.json @@ -1,18 +1,18 @@ { - "_comment": "Skopiuj ten plik jako machines.json i dostosuj. Suma limit_gb obu Macow powinna zostac ponizej realnej pojemnosci dysku Google (zostaw margines bezpieczenstwa, np. 10-15%, na narzuty rclone/Google i przypadkowy wzrost miedzy sprawdzeniami watchdoga).", + "_comment": "Copy this file to machines.json and adjust it. The sum of limit_gb for both Macs should stay below the real capacity of the Google drive (leave a safety margin, e.g. 10-15%, for rclone/Google overhead and accidental growth between watchdog checks).", "drive_total_gb": 5000, "safety_margin_percent": 10, "remote_name": "gdrive-cloudmachine", "remote_root_folder": "CloudMachine", - "_bwlimit_comment": "Limit predkosci wysylania w Mbps (megabity/s, jak u dostawcow internetu) - to ustawienie jest per-Mac, wiec kazda maszyna moze miec inna wartosc w swoim lokalnym machines.json. 0 = bez limitu.", + "_bwlimit_comment": "Upload speed limit in Mbps (megabits/s, as internet providers quote it) - this setting is per-Mac, so each machine can have a different value in its local machines.json. 0 = no limit.", "bwlimit_mbps": 0, "machines": { - "macbook-pro-marcin": { - "display_name": "MacBook Pro Marcin", + "macbook-pro": { + "display_name": "MacBook Pro", "limit_gb": 3000 }, - "imac-domowy": { - "display_name": "iMac domowy", + "imac-home": { + "display_name": "iMac (home)", "limit_gb": 1500 } } diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..cd97832 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,74 @@ +# Building CloudMachine from source + +Most people install with Homebrew (see the [README](../README.md)). This page is +for working on CloudMachine itself: building, running a local build, releases +and the measurement harnesses. + +## Requirements + +- macOS 14 (Sonoma) or newer +- Xcode, or a Swift 5.9+ toolchain +- `swift-format` for the lint CI runs: `brew install swift-format` + +## Build and install a local copy + +```sh +cd mac-app +swift run cloudmachine-agent setup-signing-cert # optional, once per Mac +swift run cloudmachine-agent build-app # -> mac-app/build/CloudMachine.app +rm -rf /Applications/CloudMachine.app # never copy over a live bundle +cp -R build/CloudMachine.app /Applications/ +``` + +Removing the installed copy first is not optional. `cp -R` onto an existing +bundle overwrites its files in place; macOS still holds the old signature for +them and kills every agent started from the bundle +(`last exit reason = OS_REASON_CODESIGNING`), while `codesign --verify` keeps +passing. Removed and copied anew, the files get new identities. The mount +survives this: the rclone process that holds it lives outside the bundle. + +`build-app` puts the menu-bar app and `cloudmachine-agent` side by side in +`Contents/MacOS/`, with the launchd templates as resources, so the installed app +does not need the repository next to it. It must live in `/Applications`: the +launchd agent that starts the app opens `/Applications/CloudMachine.app`. + +`setup-signing-cert` creates a local, self-signed code-signing certificate in the +login keychain. Without it every build is signed ad hoc with a new identity, and +macOS revokes permissions such as Full Disk Access after each rebuild. With it, +`build-app` signs with that certificate automatically. A Homebrew release is +signed with a different certificate, so switching between a local build and a +release needs Full Disk Access granted once more. + +`swift run cloudmachine-agent make-dmg` packs the built app into +`mac-app/build/CloudMachine-.dmg`; `build-app --universal` builds for +both architectures, as releases do. The version comes from `mac-app/VERSION`, +and `cloudmachine-agent version` prints it together with the build number and +the commit the binary was built from. + +## Tests and lint + +```sh +cd mac-app +swift test +swift format lint --strict --recursive Sources Tests +``` + +`L10nTests` fail on any Polish left outside the Polish translation tables, and +on any `L10n.tr` key without a Polish entry. User-facing text goes through +`L10n.tr("English text")` with a Polish entry in +`Sources/CloudMachineCore/L10nPolish+*.swift`; logs stay English. + +## Releases + +Releases are built by `.github/workflows/release.yml` from a `vX.Y.Z` tag and +published to Homebrew. The procedure and the one-time setup (signing +certificate, tap token) are in [`packaging/README.md`](../packaging/README.md). + +## Measurement harnesses + +The measurements behind the design — write amplification per band size, and +what survives the cloud layer dying mid-write — are written up in +[`gdrive/README.md`](../gdrive/README.md). The harnesses that produced them are +the separate `cloudmachine-poc` executable (`swift run cloudmachine-poc --help`). +It is deliberately not part of the app bundle: every harness creates and +deletes disk images. diff --git a/docs/setup-cli.md b/docs/setup-cli.md new file mode 100644 index 0000000..2840b0d --- /dev/null +++ b/docs/setup-cli.md @@ -0,0 +1,72 @@ +# Setting up CloudMachine from Terminal + +The menu-bar app walks through setup with a button for each step (see +[Getting started](../README.md#getting-started)). This page is the same +setup without the app — for a Mac you reach over SSH, for scripting, or to see +exactly what each button does. + +## The agent + +`cloudmachine-agent` lives inside the app bundle. `install-launchd` links it +into `/usr/local/bin`; until then call it by its full path: + +```sh +/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help +``` + +## Steps + +Run them in this order. `configure-remote` comes before anything that mounts, +because it chooses this Mac's folder on Google Drive, and `create-image` needs +the mount that `install-launchd` starts. + +```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; --folder NAME to choose this Mac's folder +cloudmachine-agent install-launchd # agents that mount Drive and keep it running +cloudmachine-agent create-image --size-gb 4000 # needs the mount from the step above +cloudmachine-agent attach-image +``` + +Two steps need `sudo`, because they change system-wide settings: + +```sh +sudo tmutil setdestination /Volumes/CloudMachine +sudo tmutil enable # hourly backups; skip if you prefer manual +``` + +Full Disk Access cannot be granted from Terminal: add CloudMachine in System +Settings → Privacy & Security → Full Disk Access. + +## Google OAuth credentials from Terminal + +`configure-remote` reads `client_id` and `client_secret` from the macOS Keychain +under the service `cloudmachine-gdrive`. Instead of the app's credentials card: + +```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 +``` + +Without `-w `, `security` prompts — the secret stays out of your shell +history and out of `ps`. + +The app's card writes through the same `security` tool rather than the Keychain +API on purpose — an entry created by `SecItemAdd` gets an ACL limited to the +program that made it, and reading it from a different binary raises an +authorisation dialog. The launchd agent has nobody to show that dialog to, so it +would read nothing and quietly fall back to the shared `client_id`. + +If the Keychain entries are missing, `configure-remote` still works — it falls +back to rclone's shared `client_id` and says so in the log rather than +pretending otherwise. + +## Replacing the connection + +`configure-remote` refuses to touch a remote that already exists. Overwriting it +replaces the token and the scope, and credentials scoped `drive.file` cannot see +files created by the previous credentials — the backup stays intact but becomes +unreachable, which amounts to the same thing. Back up `~/.config/rclone/rclone.conf` +first and pass `--replace-existing` if you really mean it. The Drive folder of +this Mac does not change with it. diff --git a/gdrive/README.md b/gdrive/README.md index ecb9680..900bbe5 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -1,154 +1,161 @@ -# Warstwa Google Drive - pomiary i wnioski +# Google Drive layer - measurements and conclusions -Dzialajacy system siedzi w aplikacji, nie tutaj. Ten katalog trzyma harnessy -pomiarowe i uzasadnienia decyzji, ktore z tych pomiarow wyszly. +The working system lives in the app, not here. This directory holds the +measurement harnesses and the reasoning behind the decisions that came out of +those measurements. ``` 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 + -> /Volumes/CloudMachine image attached by hdiutil; TM sees plain APFS + -> ~/.cloudmachine/drive rclone mount on FUSE-T + -> ~/.cloudmachine/cache 100 GB write buffer -> 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. +The point of this layout: **there is no network file system in the write path.** +Time Machine writes to a locally attached image and does not know that the bands +live in the cloud. SMB drops out, and with it the most common cause of corrupted +network backups. -## Gdzie co jest +## Where things are -| Co | Gdzie | +| What | Where | |---|---| -| 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` | +| Buffer (mounting, cache, queue) | `CloudMachineCore/DriveBufferService` | +| Image (creating, attaching, consistency) | `CloudMachineCore/BackupImageService` | +| Buffer guard | `CloudMachineCore/BufferGuardService` | +| Installing rclone with mount support | `CloudMachineCore/RcloneInstaller` | +| Tool resolution, FUSE check | `CloudMachineCore/CMTooling` | +| Subcommands | `CloudMachineAgent/DriveCommands` | +| launchd agents | `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 install-rclone # official binary (the Homebrew one cannot mount) +cloudmachine-agent configure-remote # OAuth, keys from the Keychain cloudmachine-agent create-image --size-gb 4000 cloudmachine-agent attach-image cloudmachine-agent drive-status sudo tmutil setdestination /Volumes/CloudMachine ``` -## Rozmiar pasma +## Band size -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. +Set only when the image is created; it cannot be changed later without starting +the backup from scratch. Two forces pull in opposite directions: Google Drive +allows roughly **two operations per file per second** and has a limit of +**400,000 files**, which favours large bands - but every change dirties the +**whole** band, which, with the daily limit of **750 GB**, favours small ones. -Zmierzone (`cloudmachine-poc amplification`, obraz 3 GB, zmiana 300 MB): +Measured (`cloudmachine-poc amplification`, 3 GB image, 300 MB change): -| 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 | +| Band | Bands per 3 GB | Scattered change | Append (like TM) | Files per 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. +With a change **scattered** across the whole volume, band size does not matter - +almost every band gets dirtied and in practice the whole image is uploaded. That +is the worst case, though, not the one that applies to us. -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. +With **appending**, which is what Time Machine actually does, transfer grows +monotonically with band size: 64 MB costs exactly twice as much as 8 MB. Large +bands are not free. -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). +Hence **32 MB**: the smallest band at which the initial upload stops being +limited by Drive's operation rate (6,400 files, ~0.9 h) and starts being limited +by link bandwidth (~1.3 h at 332 Mb/s). -## Kaprysy montowania FUSE-T +## FUSE-T mount quirks -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. +FUSE-T mounts via NFS, and `hdiutil` on such a volume is sometimes rejected with +the error **`RPC version wrong`**. Measured: the error does not depend on image +size or on the data (one run failed for 100 GB and 400 GB and passed for 600, +1000 and 1500 GB), only on the **moment** - with an empty upload queue 5 out of +5 attempts succeeded, with rclone busy it was random. Hence waiting for quiet and +retrying in `BackupImageService`; when the production image was created, the +first attempt failed and the second one succeeded. -**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. +**The image has to be created in place, on the mounted Drive.** Creating it +locally and moving it produces an image that `hdiutil` later will not open +(`CBSDBackingStore::newProbe stat() failed`), even though all the files and bands +are in place and readable. -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`. +The other FUSE-T backends do not help: `backend=fskit` does not mount at all, +`backend=smb` mounts, but `hdiutil create` ends with `Is a directory`. -## Co przetrwa smierc warstwy chmurowej +## What survives the death of the cloud layer -`cloudmachine-poc pullplug` 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`. +`cloudmachine-poc pullplug` detaches a stand-in for the mount in the middle of a +write, i.e. it simulates the rclone process dying or FUSE-T crashing. Three +rounds, **zero irreversible losses** - the image passed `fsck_apfs` every time. -Zerwanie samego lacza jest lagodniejsze: przy `--vfs-cache-mode full` zapis idzie -do bufora, montowanie stoi i Time Machine niczego nie zauwaza. +Losing just the network link is milder: with `--vfs-cache-mode full` writes go +to the buffer, the mount stays up and Time Machine notices nothing. -Dwie pulapki, ktore ten test ujawnil, obie zalatane w `BackupImageService`: +Two traps this test uncovered, both patched in `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. +**Zombie devices.** After a forced detach the image's device can remain in the +system. Attaching then returns a dead handle, on which `fsck_apfs` reports +`failed to read container superblock` with an all-zero UUID. It looks like a +deleted backup, but it is only an unreadable device - on that basis the first +version of this test declared, three times in a row, the loss of data that was +intact. -**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. +**Orphaned mount point.** After an unclean detach the `/Volumes/CloudMachine` +directory remains and blocks re-attaching with the message +`no mountable file systems`. It belongs to the user, but it sits in `/Volumes`, +which belongs to root, so `rmdir` refuses - **an agent running as the user +cannot clean up after itself**. The app detects this and gives the exact +command. -## Bufor jest limitem miekkim +## The buffer is a soft limit -`--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. +`--vfs-cache-max-size` is not a hard limit: rclone evicts from the buffer only +data that has already been uploaded, so when everything is waiting in the queue, +the buffer keeps growing and can fill the disk. Time Machine writes to the image +at SSD speed (measured 267 MB/s), rclone uploads at link speed (~41 MB/s) - at +the start of the first backup the buffer grew by a net 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. +That is why `BufferGuardService` pauses Time Machine above a threshold and +resumes it once uploading catches up. It also watches Drive's daily limit: once +it is exceeded, rclone stops working by design and bringing it back up achieves +nothing until the limit resets. -## Harnessy pomiarowe +## Measurement harnesses -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. +They are not part of the running system - they are run by hand when something +needs to be measured or a regression confirmed. They measure the behaviour of +`hdiutil` and FUSE-T, i.e. things a unit test cannot measure. -Siedza w osobnej binarce `cloudmachine-poc`, ktorej `build-app` NIE wklada do -`CloudMachine.app` - nie trafiaja wiec na maszyny uzytkownikow, a mimo to sa -budowane i sprawdzane przez CI razem z reszta kodu. Kazdy z nich tworzy i -kasuje obrazy dyskow, dlatego celowo nie sa podkomendami `cloudmachine-agent`: -nie ma jak odpalic ich przez pomylke na produkcji. +They live in a separate binary, `cloudmachine-poc`, which `build-app` does NOT +put into `CloudMachine.app` - so they never reach users' machines, yet they are +still built and checked by CI along with the rest of the code. Each of them +creates and deletes disk images, which is why they are deliberately not +subcommands of `cloudmachine-agent`: there is no way to launch them by mistake +on production. ```sh cd mac-app swift run cloudmachine-poc amplification --band-mb 32 --workload append swift run cloudmachine-poc pullplug --band-mb 32 --rounds 3 -# po przerwanym przebiegu zostaja podpiete obrazy - sprzatanie: +# after an interrupted run, attached images are left behind - cleanup: swift run cloudmachine-poc amplification --clean swift run cloudmachine-poc pullplug --clean ``` -## 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. - `cloudmachine-poc pullplug` 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. +## What is still unknown + +- How the mount will behave under the load of a full, multi-hour backup. + Individual operations work (a 50 MB write at 267 MB/s), but that is a + different scale. +- Whether rclone never evicts data that has not been uploaded yet from the + buffer. `cloudmachine-poc pullplug` covers a stronger case - the death of the + whole layer - but not this specific one, because it requires a running rclone. +- Whether `tmutil setdestination` accepts a destination outside `/Volumes`. That + determines whether the need for manual intervention after an unclean detach + can be removed. +- Whether `--rc-no-auth` on the loopback interface is acceptable. Any local + process can control the mount through that interface. diff --git a/launchd/com.renacode.cloudmachine.app.plist.template b/launchd/com.renacode.cloudmachine.app.plist.template index a8a7705..384c445 100644 --- a/launchd/com.renacode.cloudmachine.app.plist.template +++ b/launchd/com.renacode.cloudmachine.app.plist.template @@ -13,16 +13,16 @@ RunAtLoad StandardOutPath __CM_LOG_DIR__/launchd-app.out.log diff --git a/launchd/com.renacode.cloudmachine.backup-health.plist.template b/launchd/com.renacode.cloudmachine.backup-health.plist.template index c97b905..6ace24c 100644 --- a/launchd/com.renacode.cloudmachine.backup-health.plist.template +++ b/launchd/com.renacode.cloudmachine.backup-health.plist.template @@ -12,19 +12,19 @@ RunAtLoad StartInterval 1800 diff --git a/launchd/com.renacode.cloudmachine.buffer-guard.plist.template b/launchd/com.renacode.cloudmachine.buffer-guard.plist.template index 36e6feb..03590ed 100644 --- a/launchd/com.renacode.cloudmachine.buffer-guard.plist.template +++ b/launchd/com.renacode.cloudmachine.buffer-guard.plist.template @@ -12,9 +12,9 @@ RunAtLoad KeepAlive diff --git a/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template b/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template index ca0a2d1..493810d 100644 --- a/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template +++ b/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template @@ -12,14 +12,15 @@ RunAtLoad StartInterval 900 diff --git a/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template b/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template index 1a39f8f..25915a3 100644 --- a/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template +++ b/launchd/com.renacode.cloudmachine.gdrive-buffer.plist.template @@ -12,13 +12,15 @@ RunAtLoad KeepAlive diff --git a/mac-app/.gitignore b/mac-app/.gitignore index b521c66..6d367f7 100644 --- a/mac-app/.gitignore +++ b/mac-app/.gitignore @@ -1,5 +1,5 @@ .build/ build/ *.dmg -# Pliki posrednie generatora ikony - odtwarzalne z generate_icon.swift, tylko finalny .icns jest sledzony. +# Icon generator intermediate files - reproducible from generate_icon.swift; only the final .icns is tracked. Resources/AppIcon.iconset/ diff --git a/mac-app/Package.swift b/mac-app/Package.swift index b793d03..937a487 100644 --- a/mac-app/Package.swift +++ b/mac-app/Package.swift @@ -18,10 +18,10 @@ let package = Package( path: "Sources/CloudMachineApp" ), .executableTarget( - // Nazwa targetu = nazwa skompilowanej binarki w SPM - celowo - // "cloudmachine-agent" (nie "CloudMachineAgent"), zeby zgadzalo - // sie z tym, czego szuka CMPaths.agentBinaryPath, build-app.sh i - // szablony launchd (__CM_AGENT_BIN__). + // Target name = name of the compiled binary in SPM - deliberately + // "cloudmachine-agent" (not "CloudMachineAgent"), so that it matches + // what CMPaths.agentBinaryPath, build-app.sh and the launchd + // templates (__CM_AGENT_BIN__) look for. name: "cloudmachine-agent", dependencies: [ "CloudMachineCore", @@ -30,12 +30,13 @@ let package = Package( path: "Sources/CloudMachineAgent" ), .executableTarget( - // Harnessy pomiarowe (dawne gdrive/poc-*.sh). Celowo OSOBNA - // binarka: mierza zachowanie hdiutil i FUSE-T, nie nasz kod, i nie - // naleza do dzialajacego systemu - `build-app` ich nie kopiuje do - // bundla. Osobny target, a nie podkomendy agenta, wlasnie po to, - // zeby nie dalo sie ich przypadkiem uruchomic na produkcji: - // kazdy z nich tworzy i kasuje obrazy dyskow. + // Measurement harnesses (formerly gdrive/poc-*.sh). Deliberately a + // SEPARATE binary: they measure the behaviour of hdiutil and FUSE-T, + // not our code, and they are not part of the running system - + // `build-app` does not copy them into the bundle. A separate target + // rather than agent subcommands precisely so that they cannot be + // run on production by accident: each of them creates and deletes + // disk images. name: "cloudmachine-poc", dependencies: [ "CloudMachineCore", @@ -45,14 +46,14 @@ let package = Package( ), .testTarget( name: "CloudMachineAppTests", - // `cloudmachine-poc` jest tu od 25.09.2026 i celowo: harnessy - // mierza zachowanie hdiutil i FUSE-T, ale SPOSOB, w jaki zdaja - // z tego relacje, jest zwyklym kodem i psul sie po cichu - - // `pullplug` meldowal "Obraz przezyl kazde wyrwanie podlogi" - // po przebiegu, w ktorym zapis nigdy sie nie zaczal. Testy - // dotykaja WYLACZNIE czystych czesci (klasyfikacja wyniku, - // podsumowanie, proba zapisu do katalogu, ktorego nie ma) - zaden - // z nich nie tworzy obrazu dyskowego. + // `cloudmachine-poc` has been here since 2026-09-25, on purpose: + // the harnesses measure the behaviour of hdiutil and FUSE-T, but + // the WAY they report on it is ordinary code and it broke + // silently - `pullplug` reported "The image survived every + // floor pull" after a run in which writing never started. The + // tests touch ONLY the pure parts (result classification, + // summary, attempting to write to a directory that does not + // exist) - none of them creates a disk image. dependencies: ["CloudMachineApp", "CloudMachineCore", "cloudmachine-poc"], path: "Tests/CloudMachineAppTests" ) diff --git a/mac-app/Resources/Info.plist b/mac-app/Resources/Info.plist index f5b481b..a574833 100644 --- a/mac-app/Resources/Info.plist +++ b/mac-app/Resources/Info.plist @@ -13,10 +13,11 @@ CFBundleShortVersionString __CM_VERSION__ CMGitCommit __CM_COMMIT__ diff --git a/mac-app/Resources/icon-gen/generate_icon.swift b/mac-app/Resources/icon-gen/generate_icon.swift index a21debf..2d38640 100644 --- a/mac-app/Resources/icon-gen/generate_icon.swift +++ b/mac-app/Resources/icon-gen/generate_icon.swift @@ -1,16 +1,16 @@ -// Generuje ikone aplikacji CloudMachine (klepsydra na tle chmury) jako .iconset -// z plikow PNG w roznych rozdzielczosciach, gotowe do spakowania przez -// `iconutil -c icns`. Uruchamiane raz przy zmianie wygladu ikony: +// Generates the CloudMachine app icon (an hourglass on a cloud) as an .iconset +// of PNG files in various resolutions, ready to be packed with +// `iconutil -c icns`. Run once whenever the icon's look changes: // swift Resources/icon-gen/generate_icon.swift Resources/AppIcon.iconset import AppKit let outputDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset" try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true) -/// Rysuje symbol SF (obraz-szablon: czarny ksztalt na przezroczystym tle) -/// wypelniony podanym kolorem, uzywajac go jako maski clipowania - to jedyny -/// niezawodny sposob na "przekolorowanie" NSImage template poza kontekstem -/// NSButton/NSImageView, gdzie automatyczne tintowanie by zadzialalo samo. +/// Draws an SF Symbol (a template image: a black shape on a transparent +/// background) filled with the given color, using it as a clipping mask - the +/// only reliable way to "recolor" a template NSImage outside of an +/// NSButton/NSImageView context, where automatic tinting would just work. func drawTintedSymbol(name: String, pointSize: CGFloat, weight: NSFont.Weight, in rect: NSRect, color: NSColor) { guard let symbol = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return } let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight) @@ -32,7 +32,7 @@ func drawIcon(size: CGFloat) -> NSImage { let rect = NSRect(x: 0, y: 0, width: size, height: size) - // Tlo: zaokraglony kwadrat z gradientem niebieskim (klimat "dysk w chmurze"). + // Background: a rounded square with a blue gradient (a "cloud drive" feel). let cornerRadius = size * 0.225 let backgroundPath = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius) let gradient = NSGradient(colorsAndLocations: @@ -41,7 +41,7 @@ func drawIcon(size: CGFloat) -> NSImage { ) gradient?.draw(in: backgroundPath, angle: -90) - // Cien pod chmura, zeby nie "kleila sie" wizualnie do tla. + // Shadow under the cloud so it does not visually "stick" to the background. NSGraphicsContext.saveGraphicsState() let shadow = NSShadow() shadow.shadowColor = NSColor.black.withAlphaComponent(0.20) @@ -49,13 +49,13 @@ func drawIcon(size: CGFloat) -> NSImage { shadow.shadowOffset = NSSize(width: 0, height: -size * 0.015) shadow.set() - // Chmura biala - symbolizuje Google Drive / backup w chmurze. + // White cloud - stands for Google Drive / cloud backup. let cloudSize = size * 0.80 let cloudRect = NSRect(x: (size - cloudSize) / 2, y: size * 0.17, width: cloudSize, height: cloudSize) drawTintedSymbol(name: "cloud.fill", pointSize: cloudSize, weight: .regular, in: cloudRect, color: .white) NSGraphicsContext.restoreGraphicsState() - // Klepsydra bursztynowa na srodku chmury - symbolizuje historie / Time Machine. + // Amber hourglass in the middle of the cloud - stands for history / Time Machine. let hourglassSize = size * 0.34 let hourglassRect = NSRect(x: (size - hourglassSize) / 2, y: size * 0.34, width: hourglassSize, height: hourglassSize) let amber = NSColor(calibratedRed: 0.80, green: 0.52, blue: 0.06, alpha: 1.0) diff --git a/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift b/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift index d2b3daa..2bf5bd8 100644 --- a/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift +++ b/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift @@ -2,28 +2,34 @@ import ArgumentParser import CloudMachineCore import Foundation -/// Port `scripts/build-app.sh` - buduje `CloudMachine.app` (Release) z pakietu -/// Swift: GUI (`CloudMachineApp`) ORAZ CLI (`cloudmachine-agent`, wolany przez -/// launchd zamiast dawnych skryptow bash) trafiaja jako dwie binarki w tym -/// samym `Contents/MacOS/`, plus szablony launchd/config jako Resources - -/// appka jest wiec w pelni samodzielna, nie wymaga osobno sklonowanego repo obok. +/// Port of `scripts/build-app.sh` - builds `CloudMachine.app` (Release) from the +/// Swift package: the GUI (`CloudMachineApp`) AND the CLI (`cloudmachine-agent`, +/// called by launchd instead of the old bash scripts) go in as two binaries in +/// the same `Contents/MacOS/`, plus the launchd/config templates as Resources - +/// so the app is fully self-contained and does not need a separately cloned +/// repo next to it. /// -/// Podpisuje lokalnym certyfikatem (patrz `setup-signing-cert`), jesli -/// istnieje - a w przeciwnym razie ad-hoc (bez konta Apple Developer). Podpis -/// ad-hoc generuje NOWY hash tozsamosci przy kazdym rebuildzie, wiec macOS -/// cofa wczesniej przyznane Full Disk Access po kazdym rebuildzie; stabilny -/// lokalny certyfikat rozwiazuje ten problem raz na zawsze. +/// Signs with the local certificate (see `setup-signing-cert`) if it exists - +/// and ad-hoc otherwise (no Apple Developer account). An ad-hoc signature +/// produces a NEW identity hash on every rebuild, so macOS revokes previously +/// granted Full Disk Access after every rebuild; a stable local certificate +/// solves this problem once and for all. struct BuildApp: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "build-app", abstract: - "Buduje CloudMachine.app (Release) - GUI + cloudmachine-agent w Contents/MacOS/, plus launchd/config jako Resources." + L10n.tr( + "Builds CloudMachine.app (Release) - GUI + cloudmachine-agent in Contents/MacOS/, plus launchd/config as Resources." + ) ) @Flag( name: .long, help: - "Binarki dla Apple Silicon i Intela naraz (tak buduje wydanie w CI; lokalnie zbedne).") + ArgumentHelp( + L10n.tr( + "Binaries for Apple Silicon and Intel at once (how CI builds a release; unnecessary locally)." + ))) var universal = false func run() async throws { @@ -40,32 +46,34 @@ struct BuildApp: AsyncParsableCommand { let buildNumber = await resolveBuildNumber(projectRoot: projectRoot) print( - "==> Buduje CloudMachineApp + cloudmachine-agent (release) - wersja \(version) (\(buildNumber))" + L10n.tr( + "==> Building CloudMachineApp + cloudmachine-agent (release) - version %@ (%@)", version, + buildNumber) ) - // `/usr/bin/env swift` (nie zahardkodowana sciezka /usr/bin/swift), zeby - // respektowac PATH - deweloperzy z niestandardowym toolchainem (np. - // swift.org installer, TOOLCHAINS env var) moga miec inny `swift` niz - // ten domyslny z Xcode. Oryginalny bash robil to samo (`swift build` - // bez sciezki, resolved przez PATH powloki). + // `/usr/bin/env swift` (not a hard-coded /usr/bin/swift path), to respect + // PATH - developers with a non-standard toolchain (e.g. the swift.org + // installer, the TOOLCHAINS env var) may have a different `swift` than + // the default one from Xcode. The original bash did the same (`swift + // build` with no path, resolved through the shell's PATH). let swiftArgs = ["build", "-c", "release", "--package-path", macAppRoot.path] + (universal ? ["--arch", "arm64", "--arch", "x86_64"] : []) let buildStatus = try await InteractiveProcess.run("/usr/bin/env", ["swift"] + swiftArgs) guard buildStatus == 0 else { - print("BLAD: swift build zakonczyl sie kodem \(buildStatus).") + print(L10n.tr("ERROR: swift build exited with code %@.", "\(buildStatus)")) throw ExitCode.failure } - // Katalog z binarkami podaje sam SwiftPM: przy kilku architekturach to - // nie `.build/release`, tylko katalog zalezny od wersji narzedzi - // (`.build/apple/...` albo `.build/out/...`) - zgadywanie go zepsuloby - // sie przy pierwszej aktualizacji Xcode. + // SwiftPM itself reports the binaries directory: with several + // architectures it is not `.build/release` but a directory that depends on + // the tools version (`.build/apple/...` or `.build/out/...`) - guessing it + // would break with the first Xcode update. guard let binPathResult = try? await ProcessRunner.run( "/usr/bin/env", ["swift"] + swiftArgs + ["--show-bin-path"]), binPathResult.succeeded else { - print("BLAD: swift build --show-bin-path nie podal katalogu z binarkami.") + print(L10n.tr("ERROR: swift build --show-bin-path did not report the binaries directory.")) throw ExitCode.failure } let binDir = URL( @@ -74,12 +82,12 @@ struct BuildApp: AsyncParsableCommand { let agentBinPath = binDir.appendingPathComponent("cloudmachine-agent") for path in [appBinPath, agentBinPath] { guard fm.fileExists(atPath: path.path) else { - print("BLAD: nie znaleziono zbudowanej binarki pod \(path.path)") + print(L10n.tr("ERROR: no built binary found at %@", path.path)) throw ExitCode.failure } } - print("==> Skladam .app bundle w \(appBundle.path)") + print(L10n.tr("==> Assembling the .app bundle in %@", appBundle.path)) try? fm.removeItem(at: appBundle) let macOSDir = appBundle.appendingPathComponent("Contents/MacOS") let resourcesDir = appBundle.appendingPathComponent("Contents/Resources") @@ -87,10 +95,10 @@ struct BuildApp: AsyncParsableCommand { try fm.createDirectory(at: resourcesDir, withIntermediateDirectories: true) try fm.copyItem(at: appBinPath, to: macOSDir.appendingPathComponent(appName)) - // cloudmachine-agent siedzi OBOK glownej binarki GUI w tym samym - // Contents/MacOS - to ta binarka wola launchd (patrz - // launchd/*.plist.template, __CM_AGENT_BIN__) i to na nia wskazuje - // CMPaths.agentBinaryPath, gdy GUI instaluje agentow. + // cloudmachine-agent sits NEXT TO the main GUI binary in the same + // Contents/MacOS - this is the binary launchd calls (see + // launchd/*.plist.template, __CM_AGENT_BIN__) and the one + // CMPaths.agentBinaryPath points to when the GUI installs the agents. try fm.copyItem(at: agentBinPath, to: macOSDir.appendingPathComponent("cloudmachine-agent")) let infoPlistTemplate = macAppRoot.appendingPathComponent("Resources/Info.plist") @@ -103,18 +111,20 @@ struct BuildApp: AsyncParsableCommand { infoPlistContent = infoPlistContent.replacingOccurrences( of: "__CM_DIRTY__", with: dirty ? "true" : "false") if dirty { - // Nie przerywamy - budowanie z brudnego drzewa jest normalne przy pracy. - // Ale binarka niesie wtedy kod, ktorego nie ma w zadnym commicie, wiec - // pozniejsze "zainstalowana jest wersja X" byloby klamstwem, gdyby nikt - // tego nie powiedzial glosno. - print("==> UWAGA: budujesz z BRUDNEGO drzewa - wersja nie wskaze commitu.") + // We do not abort - building from a dirty tree is normal during work. + // But the binary then carries code that is in no commit, so a later + // "version X is installed" would be a lie if nobody said so out loud. + print( + L10n.tr( + "==> WARNING: you are building from a DIRTY tree - the version will not point to a commit." + )) } try infoPlistContent.write( to: appBundle.appendingPathComponent("Contents/Info.plist"), atomically: true, encoding: .utf8 ) - // Bundlujemy szablony launchd i przykladowy config jako Resources - - // to samo, czego uzywa wersja CLI-only (patrz CMPaths.resourcesRoot). + // We bundle the launchd templates and the example config as Resources - + // the same ones the CLI-only version uses (see CMPaths.resourcesRoot). try fm.copyItem( at: projectRoot.appendingPathComponent("launchd"), to: resourcesDir.appendingPathComponent("launchd")) @@ -127,7 +137,9 @@ struct BuildApp: AsyncParsableCommand { let appIcon = macAppRoot.appendingPathComponent("Resources/AppIcon.icns") guard fm.fileExists(atPath: appIcon.path) else { print( - "BLAD: brak Resources/AppIcon.icns - wygeneruj go: swift Resources/icon-gen/generate_icon.swift Resources/AppIcon.iconset && iconutil -c icns Resources/AppIcon.iconset -o Resources/AppIcon.icns" + L10n.tr( + "ERROR: Resources/AppIcon.icns is missing - generate it: swift Resources/icon-gen/generate_icon.swift Resources/AppIcon.iconset && iconutil -c icns Resources/AppIcon.iconset -o Resources/AppIcon.icns" + ) ) throw ExitCode.failure } @@ -142,27 +154,31 @@ struct BuildApp: AsyncParsableCommand { let signStatus: Int32 if certExists { print( - "==> Podpisuje lokalnym certyfikatem '\(certName)' (Pelny dostep do dysku przetrwa kolejne przebudowy)" + L10n.tr( + "==> Signing with the local certificate '%@' (Full Disk Access will survive later rebuilds)", + certName) ) signStatus = try await InteractiveProcess.run( "/usr/bin/codesign", ["--force", "--deep", "--sign", certName, appBundle.path]) } else { print( - "==> Podpisuje ad-hoc (bez konta Apple Developer) - uruchom raz 'cloudmachine-agent setup-signing-cert', zeby uprawnienia TCC przetrwaly kolejne przebudowy" + L10n.tr( + "==> Signing ad-hoc (no Apple Developer account) - run 'cloudmachine-agent setup-signing-cert' once so that TCC permissions survive later rebuilds" + ) ) signStatus = try await InteractiveProcess.run( "/usr/bin/codesign", ["--force", "--deep", "--sign", "-", appBundle.path]) } guard signStatus == 0 else { - print("BLAD: codesign zakonczyl sie kodem \(signStatus).") + print(L10n.tr("ERROR: codesign exited with code %@.", "\(signStatus)")) throw ExitCode.failure } - print("==> Gotowe: \(appBundle.path)") - print("Nastepny krok: cloudmachine-agent make-dmg") + print(L10n.tr("==> Done: %@", appBundle.path)) + print(L10n.tr("Next step: %@", "cloudmachine-agent make-dmg")) } - /// Krotki SHA commitu, z ktorego budujemy. + /// Short SHA of the commit we are building from. private func resolveCommit(projectRoot: URL) async -> String { guard let result = try? await ProcessRunner.run( @@ -173,10 +189,10 @@ struct BuildApp: AsyncParsableCommand { return sha.isEmpty ? AppVersion.unknownCommit : sha } - /// Czy w drzewie sa zmiany, ktorych nie ma w commicie. + /// Whether the tree has changes that are not in the commit. /// - /// `status --porcelain` obejmuje tez pliki nieszledzone - i dobrze: nowy - /// plik zrodlowy, ktorego nikt nie dodal, tak samo wchodzi do binarki. + /// `status --porcelain` also covers untracked files - and rightly so: a new + /// source file that nobody added goes into the binary just the same. private func workingTreeIsDirty(projectRoot: URL) async -> Bool { guard let result = try? await ProcessRunner.run( diff --git a/mac-app/Sources/CloudMachineAgent/BuildPaths.swift b/mac-app/Sources/CloudMachineAgent/BuildPaths.swift index f09d2a5..42ce8f7 100644 --- a/mac-app/Sources/CloudMachineAgent/BuildPaths.swift +++ b/mac-app/Sources/CloudMachineAgent/BuildPaths.swift @@ -1,12 +1,12 @@ import Foundation -/// Sciezki potrzebne WYLACZNIE narzedziom budowania (`build-app`, `make-dmg`, -/// `setup-signing-cert`) - w przeciwienstwie do `CMPaths` (CloudMachineCore), -/// ktore rozwiazuje sciezki dla dzialajacej juz appki/CLI (w tym wewnatrz -/// zainstalowanego .app), te narzedzia maja sens WYLACZNIE uruchomione z -/// checkoutu zrodlowego (to one PRODUKUJA .app, nie go konsumuja) - stad -/// `#filePath` (znane w czasie kompilacji, niezalezne od tego skad polecenie -/// zostanie potem uruchomione) zamiast `CommandLine.arguments[0]`. +/// Paths needed ONLY by the build tools (`build-app`, `make-dmg`, +/// `setup-signing-cert`) - unlike `CMPaths` (CloudMachineCore), which resolves +/// paths for the already running app/CLI (including inside the installed +/// .app), these tools make sense ONLY when run from the source checkout (they +/// PRODUCE the .app, they do not consume it) - hence `#filePath` (known at +/// compile time, independent of where the command is later run from) instead +/// of `CommandLine.arguments[0]`. enum BuildPaths { static var macAppRoot: URL { URL(fileURLWithPath: #filePath) diff --git a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift index 8aeedfd..31d28a9 100644 --- a/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift +++ b/mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift @@ -7,7 +7,9 @@ struct CloudMachineAgent: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "cloudmachine-agent", abstract: - "CloudMachine - weryfikacja, konfiguracja Google Drive i instalacja. Wolane przez launchd na harmonogramie, albo recznie z Terminala.", + L10n.tr( + "CloudMachine - verification, Google Drive setup and installation. Called by launchd on a schedule, or by hand from Terminal." + ), subcommands: [ InstallLaunchd.self, ConfigureRemote.self, @@ -15,7 +17,7 @@ struct CloudMachineAgent: AsyncParsableCommand { BuildApp.self, MakeDmg.self, SetupSigningCert.self, - // Warstwa Google Drive - zastapila skrypty z gdrive/. + // The Google Drive layer - replaced the scripts in gdrive/. InstallRclone.self, InstallFuse.self, MountDrive.self, @@ -32,31 +34,31 @@ struct CloudMachineAgent: AsyncParsableCommand { ) } -/// Wspolny kontekst (config + klucz tej maszyny) ladowany przez kazda -/// subkomende, ktora tego potrzebuje - jedno miejsce do obslugi -/// "config uszkodzony" zamiast powtarzania tego w kazdym pliku. +/// Shared context (config + this machine's key) loaded by every subcommand +/// that needs it - one place to handle "config corrupted" instead of +/// repeating it in every file. enum CLIContext { static func load() async -> (config: MachinesConfig, machineKey: String) { let outcome = ConfigStore.loadOrInitialize() - // PRZERYWAMY, nie ostrzegamy. Wczesniej ten sam komunikat - "oryginal - // zachowany na dysku z kopia zapasowa obok" - szedl takze wtedy, gdy - // `backupCorruptFile()` zwrocilo `nil`, czyli gdy zadnej kopii nie bylo. - // Praca na pustej konfiguracji konczy sie nadpisaniem jedynego egzemplarza - // przy pierwszym zapisie, a tego juz nie da sie cofnac. + // We ABORT, we do not warn. Previously the same message - "original kept + // on disk with a backup copy next to it" - was also printed when + // `backupCorruptFile()` returned `nil`, i.e. when there was no copy at all. + // Working on an empty configuration ends with the only copy being + // overwritten on the first save, and that can no longer be undone. guard let config = outcome.config else { CMLogger.log( """ - PRZERWANO: plik konfiguracyjny \(CMPaths.configPath.path) jest uszkodzony \ - (\(outcome.corruption?.localizedDescription ?? "nieznany blad")) i NIE UDALO SIE \ - odlozyc jego kopii. Z pusta konfiguracja w pamieci pierwszy zapis nadpisalby \ - jedyny egzemplarz. Skopiuj ten plik gdzie indziej, napraw go albo usun - \ - i uruchom polecenie ponownie. + ABORTED: the configuration file \(CMPaths.configPath.path) is corrupted \ + (\(outcome.corruption?.localizedDescription ?? "unknown error")) and a copy of it \ + COULD NOT be set aside. With an empty configuration in memory the first save \ + would overwrite the only copy. Copy this file somewhere else, fix it or delete it - \ + and run the command again. """) exit(1) } if case .corruptButBackedUp(_, let backup, let error) = outcome { CMLogger.log( - "BLAD: plik konfiguracyjny jest uszkodzony (\(error.localizedDescription)) - uzywam pustej konfiguracji w pamieci, oryginal skopiowany do \(backup.path)." + "ERROR: the configuration file is corrupted (\(error.localizedDescription)) - using an empty configuration in memory, original copied to \(backup.path)." ) } let key = await MachineIdentity.currentKey() diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index bf8e6e0..0713471 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -2,33 +2,35 @@ import ArgumentParser import CloudMachineCore import Foundation -/// Podkomendy warstwy Google Drive. Zastepuja skrypty z `gdrive/` - launchd -/// i GUI wolaja odtad wylacznie te binarke, nie powloke. +/// Subcommands of the Google Drive layer. They replace the scripts in +/// `gdrive/` - from now on launchd and the GUI call only this binary, not the +/// shell. -// MARK: - Bufor +// MARK: - Buffer struct MountDrive: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "mount-drive", - abstract: "Montuje Google Drive z buforem zapisu. Zostaje na pierwszym planie (dla launchd).") + abstract: L10n.tr( + "Mounts Google Drive with a write buffer. Stays in the foreground (for launchd).")) func run() async throws { if DriveBufferService.isMounted { - print("Juz zamontowane: \(DriveBufferService.mountPoint.path)") + print(L10n.tr("Already mounted: %@", 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. + // The FUSE-T uninstaller deletes the whole contents of /usr/local/lib under + // its path, including our link - we recreate it before checking anything. 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. + // Without FUSE, rclone exits immediately with "cgofuse: cannot find FUSE". + // The agent has KeepAlive, so it would retry over and over every 30 s and + // flood the log - better to stop right away and say what is missing. 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)) + FileHandle.standardError.write(Data(L10n.tr("Missing: %@\n %@\n", what, how).utf8)) } throw ExitCode(1) } @@ -36,39 +38,39 @@ struct MountDrive: AsyncParsableCommand { await DriveBufferService.excludeBufferFromTimeMachine() let args = try DriveBufferService.prepare() - // Nasza kopia serwera NFS, jesli jest - wtedy osobna instalacja FUSE-T - // w systemie nie jest potrzebna. + // Our own copy of the NFS server, if present - then a separate FUSE-T + // installation in the system is not needed. 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. + // We replace ourselves with rclone instead of supervising it: launchd is + // meant to watch the process that actually holds the mount, not a middleman. 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)) + FileHandle.standardError.write(Data(L10n.tr("Could not start %@\n", rclone).utf8)) throw ExitCode(1) } } -// MARK: - Obraz backupu +// MARK: - Backup image struct CreateImage: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "create-image", - abstract: "Tworzy obraz backupu na Google Drive. Jednorazowo.") + abstract: L10n.tr("Creates the backup image on Google Drive. One-off.")) - @Option(name: .long, help: "Rozmiar deklarowany w GB (obraz jest rzadki).") + @Option(name: .long, help: ArgumentHelp(L10n.tr("Declared size in GB (the image is sparse)."))) 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(L10n.tr("Next step: %@, then", "cloudmachine-agent attach-image")) print(" sudo tmutil setdestination \(BackupImageService.targetPath.path)") } else { throw ExitCode(1) @@ -79,12 +81,12 @@ struct CreateImage: AsyncParsableCommand { struct AttachImage: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "attach-image", - abstract: "Podpina obraz backupu jako cel Time Machine.") + abstract: L10n.tr("Attaches the backup image as the Time Machine destination.")) func run() async throws { - // Time Machine nie moze zobaczyc celu, zanim bufor bedzie gotowy - inaczej - // uzna, ze dysk backupu zniknal. Ile czekamy i na co dokladnie - patrz - // `BufferReadiness`. + // Time Machine must not see the destination before the buffer is ready - + // otherwise it decides that the backup disk has disappeared. How long we + // wait and for what exactly - see `BufferReadiness`. let ready = await BufferReadiness.wait( sleep: { seconds in try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) @@ -96,32 +98,35 @@ struct AttachImage: AsyncParsableCommand { }) if !ready { print( - """ - Bufor nie stanal w \(Int(BufferReadiness.defaultTimeout / 60)) min - nie podpinam obrazu. - Time Machine jest teraz BEZ CELU. Sprawdz: cloudmachine-agent drive-status - """) + L10n.tr( + "The buffer did not come up within %@ min - not attaching the image.", + "\(Int(BufferReadiness.defaultTimeout / 60))")) + print( + L10n.tr( + "Time Machine now has NO DESTINATION. Check: %@", "cloudmachine-agent drive-status")) throw ExitCode(1) } let result = await BackupImageService.attach() print(result.message) - // Trzy przypadki, nie dwa. To polecenie chodzi pod launchd co 900 s, wiec - // jego kod wyjscia jest zapisem w `launchd-gdrive-attach.err.log` - tam, - // gdzie czlowiek patrzy, pytajac "czy backup dziala". "Obraz zajety przez - // odpinanie, ktore wlasnie trwa" NIE JEST awaria: nastepny tik za 15 minut - // zastanie juz wolny obraz i podepnie. Zapisywanie tego jako bledu to ten - // sam wzorzec, ktory ten kod tepi w druga strone - stan normalny czytany - // jako awaria, zamiast braku odpowiedzi czytanego jako odpowiedz. + // Three cases, not two. This command runs under launchd every 900 s, so + // its exit code ends up as an entry in `launchd-gdrive-attach.err.log` - + // the place a person looks when asking "is the backup working". "Image + // busy with a detach that is in progress right now" is NOT a failure: the + // next tick in 15 minutes will find the image free and attach it. Recording + // that as an error is the same pattern this code fights in the other + // direction - a normal state read as a failure, instead of a missing answer + // read as an answer. // - // Rozstrzyga TYP wyniku (`CMActionResult.Disposition`), nie tresc - // komunikatu - dopasowanie do tekstu psuje sie przy pierwszej zmianie - // zdania i nikt tego nie zauwaza. `switch` jest wyczerpujacy, wiec nowy - // przypadek nie przejdzie tedy po cichu. + // The TYPE of the result (`CMActionResult.Disposition`) decides, not the + // message text - matching on text breaks with the first rewording of the + // sentence and nobody notices. The `switch` is exhaustive, so a new case + // will not slip through here silently. switch result.disposition { case .ok: break case .skipped: - print("Nie jest to blad - nastepny przebieg agenta sprobuje ponownie.") + print(L10n.tr("This is not an error - the agent's next run will try again.")) case .failed: throw ExitCode(1) } @@ -131,9 +136,12 @@ struct AttachImage: AsyncParsableCommand { struct DetachImage: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "detach-image", - abstract: "Odpina obraz i czeka, az wszystko doleci na Google Drive.") + abstract: L10n.tr("Detaches the image and waits until everything reaches Google Drive.")) - @Flag(name: .long, help: "Nie czekaj na wysylke - RYZYKOWNE, patrz BackupImageService.detach.") + @Flag( + name: .long, + help: ArgumentHelp( + L10n.tr("Do not wait for the upload - RISKY, see BackupImageService.detach."))) var noWait = false func run() async throws { @@ -147,7 +155,9 @@ struct VerifyImage: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "verify-image", abstract: - "Sprawdza spojnosc obrazu przez fsck_apfs (hdiutil verify na sparsebundle nie dziala).") + L10n.tr( + "Checks the image's consistency with fsck_apfs (hdiutil verify does not work on a sparsebundle)." + )) func run() async throws { let result = await BackupImageService.verify() @@ -156,48 +166,56 @@ struct VerifyImage: AsyncParsableCommand { } } -// MARK: - Dozorca bufora +// MARK: - Buffer guard struct BufferGuard: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "buffer-guard", abstract: - "Wstrzymuje Time Machine, gdy zaleglosc niewyslana rosnie szybciej, niz idzie wysylka.") + L10n.tr( + "Pauses Time Machine when the unsent backlog grows faster than the upload goes.")) - // Domyslne progi bierzemy Z `Thresholds`, ktore wylicza je z rozmiaru - // bufora - NIE wpisujemy ich tu po raz drugi z palca. + // The default thresholds come FROM `Thresholds`, which derives them from the + // buffer size - we do NOT type them in here a second time by hand. // - // Wpisane liczby (150/40/80) zgadzaly sie z wyliczonymi tylko przypadkiem, - // dla bufora 100 GB. launchd uruchamia `buffer-guard` BEZ argumentow, wiec - // to wlasnie te literaly trafialy na produkcje - wyliczanie progow - // z `cacheSizeGB` bylo w praktyce martwe, a trzy testy pilnujace tego - // wyliczenia sprawdzaly `Thresholds()` bezposrednio i przechodzily, nie - // dotykajac sciezki, ktora naprawde dziala. Po zmianie `cacheSizeGB` progi - // rozjechalyby sie po cichu: dozorca albo wstrzymywalby backup bez przerwy, - // albo nie wstrzymalby go nigdy. - // Progi odnosza sie do ZALEGLOSCI NIEWYSLANEJ, nie do rozmiaru cache'a - - // stara miara stala pod limitem stale, wiec prog wznowienia byl nieosiagalny - // (jedna PAUZA i zero WZNOWIEN w calym dzienniku). Patrz `Thresholds.init`. - @Option(name: .long, help: "Powyzej tylu GB zaleglosci niewyslanej wstrzymujemy Time Machine.") + // The typed-in numbers (150/40/80) matched the derived ones only by + // accident, for a 100 GB buffer. launchd runs `buffer-guard` WITHOUT + // arguments, so it was exactly these literals that reached production - + // deriving the thresholds from `cacheSizeGB` was dead in practice, and the + // three tests guarding that derivation checked `Thresholds()` directly and + // passed without touching the path that actually runs. After a change to + // `cacheSizeGB` the thresholds would have drifted apart silently: the guard + // would either pause the backup nonstop, or never pause it at all. + // The thresholds refer to the UNSENT BACKLOG, not to the cache size - the + // old measure sat at the limit constantly, so the resume threshold was + // unreachable (one PAUSE and zero RESUMES in the whole log). See + // `Thresholds.init`. + @Option( + name: .long, + help: ArgumentHelp(L10n.tr("Above this many GB of unsent backlog we pause Time Machine."))) var highGB: Int = BufferGuardService.Thresholds().highGB - @Option(name: .long, help: "Ponizej tylu GB zaleglosci niewyslanej wznawiamy.") + @Option( + name: .long, help: ArgumentHelp(L10n.tr("Below this many GB of unsent backlog we resume."))) var lowGB: Int = BufferGuardService.Thresholds().lowGB - @Option(name: .long, help: "Ponizej tylu GB wolnych na dysku wstrzymujemy niezaleznie od bufora.") + @Option( + name: .long, + help: ArgumentHelp( + L10n.tr("Below this many GB free on disk we pause regardless of the buffer."))) var minFreeGB: Int = BufferGuardService.Thresholds().minFreeGB - @Option(name: .long, help: "Co ile sekund sprawdzac.") + @Option(name: .long, help: ArgumentHelp(L10n.tr("How often to check, in seconds."))) var interval: Int = 30 func run() async throws { let guardService = BufferGuardService( thresholds: .init(highGB: highGB, lowGB: lowGB, minFreeGB: minFreeGB)) CMLogger.log( - "Dozorca bufora: pauza powyzej \(highGB) GB zaleglosci / wznowienie ponizej \(lowGB) GB / min. wolnego na dysku \(minFreeGB) GB" + "Buffer guard: pause above \(highGB) GB backlog / resume below \(lowGB) GB / min. free on disk \(minFreeGB) GB" ) - // Bez konca: dozorca ma przezyc kazdy backup, nie tylko pierwszy. + // Forever: the guard has to outlive every backup, not just the first one. while true { await guardService.step() try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) @@ -205,164 +223,194 @@ struct BufferGuard: AsyncParsableCommand { } } -// MARK: - Czujka cyklu backupu +// MARK: - Backup cycle watchdog struct BackupHealthCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "backup-health", abstract: - "Sprawdza, czy cykl godzinowy NADAL dziala (data ostatniej UDANEJ kopii), i zglasza awarie.") + L10n.tr( + "Checks whether the hourly cycle STILL works (date of the last SUCCESSFUL backup) and reports failures." + )) - @Option(name: .long, help: "Po tylu godzinach bez udanej kopii uznajemy cykl za zerwany.") + @Option( + name: .long, + help: ArgumentHelp( + L10n.tr("After this many hours without a successful backup we consider the cycle broken."))) var maxAgeHours: Double = BackupHealth.maxAgeHours - @Flag(name: .long, help: "Tylko wypisz stan, bez powiadomienia systemowego.") + @Flag( + name: .long, help: ArgumentHelp(L10n.tr("Only print the state, without a system notification.")) + ) var quiet = false @Option( name: .long, - help: "Inny plik preferencji Time Machine - do sprawdzenia czujki na znanej probce.") + help: ArgumentHelp( + L10n.tr( + "A different Time Machine preferences file - to test the watchdog on a known sample."))) var preferences: String = BackupHealth.preferencesPath func run() async throws { let report = await BackupHealth.currentReport( maxAgeHours: maxAgeHours, preferencesFile: preferences) - // Znacznik "czujka przebiegla" - PRZED wypisaniem czegokolwiek i przed - // decyzja o kodzie wyjscia, bo przebieg, ktory znalazl awarie, jest tak - // samo przebiegiem jak ten, ktory nic nie znalazl. Bez tego jedynym - // objawem wyladowanej albo zawieszonej czujki bylaby cisza - a cisza jest - // tu stanem normalnym (patrz `WatchdogHeartbeat`). + // The "watchdog ran" marker - BEFORE printing anything and before deciding + // the exit code, because a run that found a failure is just as much a run + // as one that found nothing. Without it, the only symptom of a dead or hung + // watchdog would be silence - and silence is the normal state here (see + // `WatchdogHeartbeat`). WatchdogHeartbeat.record() if let lastSuccess = report.lastSuccess { - print("Ostatnia udana kopia: \(BackupHealth.stamp(lastSuccess))") + print(L10n.tr("Last successful backup: %@", BackupHealth.stamp(lastSuccess))) } else { - print("Ostatnia udana kopia: BRAK") + print(L10n.tr("Last successful backup: NONE")) } if let lastAttempt = report.lastAttempt { - print("Ostatnia proba: \(BackupHealth.stamp(lastAttempt))") + print(L10n.tr("Last attempt: %@", BackupHealth.stamp(lastAttempt))) } - // Odlozone na okres rozruchu - nie awaria, ale nie przemilczamy ich. + // Deferred for the startup period - not a failure, but we do not hide them. for problem in report.deferred { - print("CZEKAM (start systemu): \(problem.summary)") + print(L10n.tr("WAITING (system startup): %@", problem.summary)) } guard !report.healthy else { - print("Cykl backupu: OK") + print(L10n.tr("Backup cycle: OK")) if !quiet { await HealthAlert.report(report) } return } for problem in report.problems { - print("AWARIA: \(problem.summary)") - print(" \(problem.detail)") + print(L10n.tr("FAILURE: %@", problem.summary)) + print(L10n.tr(" %@", problem.detail)) } if !quiet { await HealthAlert.report(report) } - // Niezerowy kod wyjscia, zeby launchd, `&&` w skrypcie i czlowiek - // patrzacy na `echo $?` dostali ten sam sygnal co tekst powyzej. + // A non-zero exit code, so that launchd, `&&` in a script and a person + // looking at `echo $?` get the same signal as the text above. throw ExitCode(1) } } -// MARK: - Bezpieczne wygaszenie +// MARK: - Safe shutdown struct PrepareShutdown: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "prepare-shutdown", - abstract: "Przygotowuje do restartu: wstrzymuje backup, odpina obraz i czeka na wysylke.") + abstract: L10n.tr( + "Prepares for a restart: pauses the backup, detaches the image and waits for the upload.")) 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. + // The order is not arbitrary. First Time Machine stops adding new writes, + // only then do we detach the image - otherwise the detach would fight with + // the running backup. if await TimeMachineStatus.isRunning() { - print("Wstrzymuje backup...") + print(L10n.tr("Pausing the 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...") + print(L10n.tr("Detaching the image and waiting for the upload...")) 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") + print(L10n.tr("Do NOT restart yet - the buffer holds data that has not reached Drive.")) + print(L10n.tr("Check the state: %@", "cloudmachine-agent drive-status")) throw ExitCode(1) } print("") - print("Mozna restartowac. Po starcie agenty podniosa bufor i podepna obraz same.") + print( + L10n.tr( + "Safe to restart. After startup the agents will bring up the buffer and attach the image themselves." + )) } } -// MARK: - Stan +// MARK: - Status struct DriveStatus: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "drive-status", - abstract: "Stan bufora, kolejki wysylki i Time Machine.") + abstract: L10n.tr("State of the buffer, the upload queue and Time Machine.")) func run() async throws { let readiness = CMTooling.checkReadiness() print( - "Narzedzia: \(readiness.ready ? "OK" : "brakuje: " + readiness.missing.joined(separator: ", "))" - ) + L10n.tr( + "Tools: %@", + readiness.ready + ? "OK" : L10n.tr("missing: %@", readiness.missing.joined(separator: ", ")))) let mounted = DriveBufferService.mountedState() - print("Montowanie Drive: \(StatusLines.mounted(mounted))") - // Sonda czytelnosci ma od 26.09.2026 limit czasu - dlatego to narzedzie - // nie wisi na martwym montowaniu (25.09.2026 wisialo ponad 25 s i trzeba - // bylo je zabic), tylko melduje, ze odpowiedzi nie ma. + print(L10n.tr("Drive mount: %@", StatusLines.mounted(mounted))) + print( + L10n.tr( + "Drive folder: %@", + "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)")) + // Since 26.09.2026 the readability probe has a time limit - which is why + // this tool does not hang on a dead mount (on 25.09.2026 it hung for over + // 25 s and had to be killed), but reports that there is no answer. let image = await BackupImageService.attachmentReading() print( - "Obraz podpiety: \(BackupImageService.describe(image.attachment, probeTimedOut: image.probeTimedOut))" - ) + L10n.tr( + "Image attached: %@", + BackupImageService.describe(image.attachment, probeTimedOut: image.probeTimedOut))) - // Kolejke czytamy PRZED wierszami o buforze, bo obydwa z niej korzystaja. - // Drugie pytanie do rclone kosztowaloby do 60 s przy zapchanym buforze - // (patrz `DriveBufferService.queueStats`). + // We read the queue BEFORE the buffer lines, because both of them use it. + // A second query to rclone would cost up to 60 s with a clogged buffer + // (see `DriveBufferService.queueStats`). let queueStats = await DriveBufferService.queueStats() - // Dwa wiersze, bo to DWIE ROZNE wielkosci. Jeden wiersz "Bufor: 103 GB - // z 100G" wygladal na odpowiedz na pytanie "czy wysylka nadaza", a nia nie - // byl: rozmiar cache'a stoi pod limitem stale. Dozorca bufora podejmowal - // na tej liczbie decyzje i dlatego nie wznowil backupu ani razu. + // Two lines, because these are TWO DIFFERENT quantities. A single line + // "Buffer: 103 GB of 100G" looked like an answer to "is the upload keeping + // up", and it was not: the cache size sits at the limit constantly. The + // buffer guard made decisions on that number and that is why it never + // resumed the backup even once. print( - "Cache na dysku: \(StatusLines.cacheSize(BufferGuardService.cacheSizeGB(stats: queueStats), limitGB: DriveBufferService.cacheSizeGB))" - ) + L10n.tr( + "Cache on disk: %@", + StatusLines.cacheSize( + BufferGuardService.cacheSizeGB(stats: queueStats), + limitGB: DriveBufferService.cacheSizeGB))) print( - "Do wyslania: \(StatusLines.backlog(BufferGuardService.backlogGB(stats: queueStats), items: queueStats?.unsentItems))" - ) - // NIE `\(BufferGuardService.freeGB()) GB` - to zwraca `Int?`, odkad brak - // pomiaru przestal udawac zero, a interpolacja opcjonalnej wartosci - // wypisywala `Wolne na dysku: Optional(427) GB`. Kompilator mowil o tym - // tylko ostrzezeniem, wiec nie zatrzymalo to ani builda, ani testow. - print("Wolne na dysku: \(StatusLines.freeDisk(BufferGuardService.freeGB()))") + L10n.tr( + "To upload: %@", + StatusLines.backlog( + BufferGuardService.backlogGB(stats: queueStats), items: queueStats?.unsentItems))) + // NOT `\(BufferGuardService.freeGB()) GB` - that returns `Int?` since a + // missing measurement stopped pretending to be zero, and interpolating the + // optional value printed `Free on disk: Optional(427) GB`. The compiler + // only said so with a warning, so it stopped neither the build nor the + // tests. + print(L10n.tr("Free on disk: %@", StatusLines.freeDisk(BufferGuardService.freeGB()))) if let stats = queueStats { print( - "Kolejka wysylki: \(stats.uploadsInProgress) w toku, \(stats.uploadsQueued) w kolejce, \(stats.erroredFiles) bledow" - ) + L10n.tr( + "Upload queue: %@ in progress, %@ queued, %@ errors", "\(stats.uploadsInProgress)", + "\(stats.uploadsQueued)", "\(stats.erroredFiles)")) } else { - print("Kolejka wysylki: (interfejs rc nieosiagalny)") + print(L10n.tr("Upload queue: (rc interface unreachable)")) } let safe = await BackupImageService.safeToRebootNow() print( - "Restart bez pytania: \(safe ? "TAK - kolejka pusta" : "NIE - najpierw prepare-shutdown")") + L10n.tr( + "Restart without asking: %@", + safe ? L10n.tr("YES - queue empty") : L10n.tr("NO - run prepare-shutdown first"))) - // Ta sama odpowiedz, co na karcie w interfejsie - jedno zrodlo, zeby CLI - // i GUI nie mogly twierdzic czegos innego o tym samym stanie. + // The same answer as on the card in the UI - one source, so that the CLI + // and the GUI cannot claim different things about the same state. let upload = UploadState.from( - // `UploadState` nie ma stanu "nie wiadomo, czy zamontowane", a dolozenie - // go dotknelo by plikow poza moim zakresem. `?? false` daje wtedy - // `.mountDown` ("Wysylka nie dziala") - czyli ostrzega, zamiast - // uspokajac, a wiersz "Montowanie Drive" wyzej mowi juz wprost - // "NIE WIADOMO". Falszywy alarm jest tu wlasciwym kierunkiem pomylki. + // `UploadState` has no "unknown whether mounted" state, and adding it + // would touch files outside my scope. `?? false` then gives `.mountDown` + // ("Upload is not working") - so it warns instead of reassuring, and the + // "Drive mount" line above already says outright that it is UNKNOWN. A + // false alarm is the right direction of error here. mounted: mounted ?? false, queueKnown: queueStats != nil, queued: queueStats?.uploadsQueued ?? 0, @@ -371,45 +419,49 @@ struct DriveStatus: AsyncParsableCommand { bufferOutOfSpace: queueStats?.outOfSpace ?? false, driveFull: DriveBufferService.hitStorageQuota(), dailyQuotaExhausted: DriveBufferService.uploadStalled()) - print("Wysylka: \(upload.headline)") + print(L10n.tr("Upload: %@", upload.headline)) if !upload.isNominal { print(" \(upload.explanation.replacingOccurrences(of: "\n", with: " "))") } if let mountPoint = await TimeMachineStatus.currentDestinationMountPoint() { - print("Cel Time Machine: \(mountPoint)") + print(L10n.tr("TM destination: %@", mountPoint)) } else { - print("Cel Time Machine: brak") + print(L10n.tr("TM destination: none")) } if await TimeMachineStatus.isRunning(), let progress = await TimeMachineStatus.currentProgress() { let percent = (progress.percent ?? 0) * 100 print( - "Backup: trwa, \(String(format: "%.1f", percent))% (\(progress.phase ?? "?"))") + L10n.tr( + "Backup: running, %@%% (%@)", String(format: "%.1f", percent), + progress.phase ?? "?")) } else { - print("Backup: nie trwa") + print(L10n.tr("Backup: not running")) } - // Kto pilnuje czujki. Bez tego wiersza "brak alarmu" znaczylo jednoczesnie - // "backup dziala" i "nikt nie sprawdzal" - patrz `WatchdogHeartbeat`. - print("Czujka backupu: \(StatusLines.watchdogRun(WatchdogHeartbeat.current()))") - - // Na samym koncu i bez wyrownania do kolumny - to nie jest kolejny wiersz - // stanu, tylko cos, co ma zaklocic czytanie. `HealthAlert` od niedawna nie - // zamyka sprawy znacznikiem, dopoki powiadomienie nie doszlo, wiec - // niedoreczony alarm nie ginie juz na 12 h - ale bez tego bloku nikt by sie - // o nim nie dowiedzial, bo powiadomienia systemowego z definicji nie widac. + // Who watches the watchdog. Without this line "no alarm" meant both + // "the backup works" and "nobody checked" at once - see `WatchdogHeartbeat`. + print(L10n.tr("Backup watchdog: %@", StatusLines.watchdogRun(WatchdogHeartbeat.current()))) + + // At the very end and not aligned to the column - this is not another + // status line but something meant to interrupt the reading. Since recently + // `HealthAlert` does not close the case with a marker until the + // notification has been delivered, so an undelivered alarm no longer gets + // lost for 12 h - but without this block nobody would find out about it, + // because a system notification that failed is by definition not seen. for line in StatusLines.undeliveredAlert(HealthAlert.lastDeliveryFailure()) { print(line) } } } -// MARK: - Instalacja FUSE +// MARK: - FUSE installation struct InstallFuse: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install-fuse", - abstract: "Wciaga FUSE-T do CloudMachine, zeby nie bylo osobnej aplikacji w systemie.") + abstract: L10n.tr( + "Pulls FUSE-T into CloudMachine, so there is no separate app in the system.")) func run() async throws { let result = await FuseInstaller.install() @@ -418,12 +470,13 @@ struct InstallFuse: AsyncParsableCommand { } } -// MARK: - Instalacja rclone +// MARK: - rclone installation struct InstallRclone: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install-rclone", - abstract: "Pobiera oficjalna binarke rclone (ta z Homebrew nie umie montowac).") + abstract: L10n.tr( + "Downloads the official rclone binary (the Homebrew one cannot mount).")) func run() async throws { let result = await RcloneInstaller.install() @@ -432,38 +485,40 @@ struct InstallRclone: AsyncParsableCommand { } } -// MARK: - Wersja +// MARK: - Version -/// Odpowiada na pytanie "czy dziala to, co w repozytorium". +/// Answers the question "is what runs the same as what is in the repository". /// -/// Samo `1.1.0` na to nie odpowiada - dlatego wypisujemy commit i stan drzewa -/// z chwili budowania, a przy braku bundla mowimy wprost, ze to build z drzewa -/// roboczego, zamiast zmyslac numer. +/// `1.1.0` alone does not answer it - so we print the commit and the state of +/// the tree at build time, and with no bundle we say outright that it is a +/// build from the working tree, instead of making up a number. struct Version: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "version", - abstract: "Wypisuje wersje, numer budowy i commit, z ktorego zbudowano te binarke.") + abstract: L10n.tr( + "Prints the version, the build number and the commit this binary was built from.")) - @Flag(name: .long, help: "Tylko jedna linia, bez opisu.") + @Flag(name: .long, help: ArgumentHelp(L10n.tr("Only one line, without a description."))) var short = false func run() async throws { guard let version = AppVersionReader.current() else { - print("Build z drzewa roboczego (poza bundlem) - brak danych o wersji.") + print(L10n.tr("Build from the working tree (outside a bundle) - no version data.")) return } guard !short else { print(version.summary) return } - print("Wersja: \(version.shortVersion)") - print("Budowa: \(version.build)") - print("Commit: \(version.commit)") + print(L10n.tr("Version: %@", version.shortVersion)) + print(L10n.tr("Build: %@", version.build)) + print(L10n.tr("Commit: %@", version.commit)) if version.dirty { print("") - print("UWAGA: zbudowano z BRUDNEGO drzewa - w binarce jest kod, ktorego") - print(" nie ma w zadnym commicie. Numer commitu NIE opisuje tego,") - print(" co naprawde dziala.") + print( + L10n.tr( + "WARNING: built from a DIRTY tree - the binary contains code that\n is in no commit. The commit number does NOT describe\n what actually runs." + )) } } } diff --git a/mac-app/Sources/CloudMachineAgent/InteractiveProcess.swift b/mac-app/Sources/CloudMachineAgent/InteractiveProcess.swift index 6dfcd4f..970bb03 100644 --- a/mac-app/Sources/CloudMachineAgent/InteractiveProcess.swift +++ b/mac-app/Sources/CloudMachineAgent/InteractiveProcess.swift @@ -1,10 +1,9 @@ import Foundation -/// Odpala proces, ktory dziedziczy stdout/stderr biezacego terminala zamiast -/// buforowac je w pamieci (jak `ProcessRunner`) - dla dlugotrwalych, -/// "gadatliwych" narzedzi budowania (`swift build`, `codesign`), gdzie -/// uzytkownik powinien widziec postep na zywo, tak jak przy oryginalnych -/// skryptach bash. +/// Launches a process that inherits the current terminal's stdout/stderr +/// instead of buffering them in memory (like `ProcessRunner`) - for +/// long-running, "chatty" build tools (`swift build`, `codesign`), where the +/// user should see progress live, just as with the original bash scripts. enum InteractiveProcess { @discardableResult static func run(_ executable: String, _ args: [String], currentDirectory: URL? = nil) async throws diff --git a/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift b/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift index 7d72de7..96964c2 100644 --- a/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift +++ b/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift @@ -1,12 +1,13 @@ import ArgumentParser +import CloudMachineCore import Foundation -/// Port `scripts/make-dmg.sh` - pakuje zbudowane `CloudMachine.app` (patrz -/// `build-app`) do instalatora `.dmg` z przeciagalnym skrotem do `/Applications`. +/// Port of `scripts/make-dmg.sh` - packs the built `CloudMachine.app` (see +/// `build-app`) into a `.dmg` installer with a draggable shortcut to `/Applications`. struct MakeDmg: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "make-dmg", - abstract: "Pakuje build/CloudMachine.app do pliku build/CloudMachine-.dmg." + abstract: L10n.tr("Packs build/CloudMachine.app into build/CloudMachine-.dmg.") ) func run() async throws { @@ -22,11 +23,13 @@ struct MakeDmg: AsyncParsableCommand { let fm = FileManager.default guard fm.fileExists(atPath: appBundle.path) else { - print("BLAD: brak \(appBundle.path) - uruchom najpierw 'cloudmachine-agent build-app'") + print( + L10n.tr( + "ERROR: %@ is missing - run 'cloudmachine-agent build-app' first", appBundle.path)) throw ExitCode.failure } - print("==> Przygotowuje folder staging") + print(L10n.tr("==> Preparing the staging folder")) try? fm.removeItem(at: stagingDir) try? fm.removeItem(at: dmgPath) try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) @@ -35,7 +38,7 @@ struct MakeDmg: AsyncParsableCommand { at: stagingDir.appendingPathComponent("Applications"), withDestinationURL: URL(fileURLWithPath: "/Applications")) - print("==> Tworze \(dmgPath.path)") + print(L10n.tr("==> Creating %@", dmgPath.path)) let status = try await InteractiveProcess.run( "/usr/bin/hdiutil", [ @@ -44,19 +47,18 @@ struct MakeDmg: AsyncParsableCommand { ]) try? fm.removeItem(at: stagingDir) guard status == 0 else { - print("BLAD: hdiutil zakonczyl sie kodem \(status).") + print(L10n.tr("ERROR: hdiutil exited with code %@.", "\(status)")) throw ExitCode.failure } - print("==> Gotowe: \(dmgPath.path)") + print(L10n.tr("==> Done: %@", dmgPath.path)) + print("") + print(L10n.tr("On first launch (the app is not signed with an Apple Developer account):")) + print(L10n.tr("1. Open %@ and drag CloudMachine.app to Applications.", dmgPath.path)) print( - """ - - Przy pierwszym uruchomieniu (appka niepodpisana kontem Apple Developer): - 1. Otworz \(dmgPath.path) i przeciagnij CloudMachine.app do Applications. - 2. W Finderze kliknij CloudMachine.app PRAWYM przyciskiem -> Otworz -> Otworz - (samo dwuklikniecie pokaze blokade Gatekeepera "niezidentyfikowany deweloper"). - 3. Kolejne uruchomienia dzialaja juz normalnie, dwuklikiem. - """) + L10n.tr( + "2. In Finder, RIGHT-click CloudMachine.app -> Open -> Open\n (a plain double-click shows the Gatekeeper block \"unidentified developer\")." + )) + print(L10n.tr("3. Later launches work normally, with a double-click.")) } } diff --git a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift index 3b4b9c2..5804724 100644 --- a/mac-app/Sources/CloudMachineAgent/SetupCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/SetupCommands.swift @@ -6,7 +6,8 @@ struct InstallLaunchd: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install-launchd", abstract: - "Generuje i instaluje agentow launchd (bufor Drive, podpiecie obrazu, dozorca bufora)." + L10n.tr( + "Generates and installs the launchd agents (Drive buffer, image attach, buffer guard).") ) func run() async throws { @@ -20,19 +21,30 @@ struct ConfigureRemote: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "configure-remote", abstract: - "Laczy z Google Drive przez rclone (OAuth w przegladarce) i tworzy folder tej maszyny.") + L10n.tr( + "Connects to Google Drive through rclone (OAuth in the browser) and creates this machine's folder." + )) @Flag( name: .long, help: - "Nadpisz istniejacy remote. RYZYKOWNE: podmienia token i uprawnienia." + ArgumentHelp( + L10n.tr("Overwrite the existing remote. RISKY: replaces the token and permissions.")) ) var replaceExisting = false + @Option( + name: .long, + help: ArgumentHelp( + L10n.tr( + "Name of this Mac's folder on Google Drive (default: derived from the computer name). Set once; it cannot be changed later." + ))) + var folder: String? + func run() async throws { let (config, key) = await CLIContext.load() let result = await RemoteConfigurer.connect( - config: config, machineKey: key, replaceExisting: replaceExisting) + config: config, machineKey: key, replaceExisting: replaceExisting, folder: folder) print(result.message) if !result.succeeded { throw ExitCode.failure } } @@ -42,7 +54,9 @@ struct InstallDependencies: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install-dependencies", abstract: - "Instaluje rclone przez Homebrew - UWAGA: ta wersja NIE umie montowac, patrz install-rclone.") + L10n.tr( + "Installs rclone through Homebrew - WARNING: this build CANNOT mount, see install-rclone." + )) func run() async throws { let result = await DependencyInstaller.installRclone() diff --git a/mac-app/Sources/CloudMachineAgent/SetupSigningCertCommand.swift b/mac-app/Sources/CloudMachineAgent/SetupSigningCertCommand.swift index fedc9c4..a53e58b 100644 --- a/mac-app/Sources/CloudMachineAgent/SetupSigningCertCommand.swift +++ b/mac-app/Sources/CloudMachineAgent/SetupSigningCertCommand.swift @@ -2,21 +2,23 @@ import ArgumentParser import CloudMachineCore import Foundation -/// Port `scripts/setup-local-signing-cert.sh` - tworzy jednorazowy, lokalny -/// certyfikat self-signed do podpisywania `CloudMachine.app`, zeby uprawnienia -/// TCC (Pelny dostep do dysku itp.) PRZETRWALY kolejne przebudowy appki. +/// Port of `scripts/setup-local-signing-cert.sh` - creates a one-off, local +/// self-signed certificate for signing `CloudMachine.app`, so that TCC +/// permissions (Full Disk Access etc.) SURVIVE later rebuilds of the app. /// -/// Domyslny podpis ad-hoc w `build-app` generuje NOWY hash tozsamosci (CDHash) -/// przy kazdym rebuildzie, wiec macOS traktuje kazda przebudowana wersje jak -/// zupelnie inna appke i cofa jej wczesniej przyznane uprawnienia. Ten -/// certyfikat jest czysto lokalny: nie jest nigdzie wysylany, nie jest -/// zaufany przez nikogo poza tym Makiem, i sluzy WYLACZNIE do podpisywania -/// kodu. Uruchom RAZ; kazde kolejne `build-app` uzyje go automatycznie. +/// The default ad-hoc signature in `build-app` produces a NEW identity hash +/// (CDHash) on every rebuild, so macOS treats each rebuilt version as a +/// completely different app and revokes the permissions granted to it before. +/// This certificate is purely local: it is not sent anywhere, it is not +/// trusted by anyone but this Mac, and it is used ONLY for code signing. Run +/// it ONCE; every later `build-app` will use it automatically. struct SetupSigningCert: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "setup-signing-cert", abstract: - "Tworzy lokalny certyfikat self-signed, zeby Full Disk Access przetrwalo kolejne przebudowy appki." + L10n.tr( + "Creates a local self-signed certificate so that Full Disk Access survives later rebuilds of the app." + ) ) func run() async throws { @@ -29,7 +31,9 @@ struct SetupSigningCert: AsyncParsableCommand { "/usr/bin/security", ["find-certificate", "-c", certName, keychain.path]), check.succeeded { - print("Certyfikat '\(certName)' juz istnieje w \(keychain.path), nic nie robie.") + print( + L10n.tr( + "Certificate '%@' already exists in %@, nothing to do.", certName, keychain.path)) return } @@ -57,7 +61,7 @@ struct SetupSigningCert: AsyncParsableCommand { """ try configContents.write(to: configFile, atomically: true, encoding: .utf8) - print("==> Generuje klucz i certyfikat self-signed '\(certName)'...") + print(L10n.tr("==> Generating the key and self-signed certificate '%@'...", certName)) let reqStatus = try await InteractiveProcess.run( "/usr/bin/openssl", [ @@ -65,19 +69,20 @@ struct SetupSigningCert: AsyncParsableCommand { "-days", "3650", "-nodes", "-config", configFile.path, "-sha256", ]) guard reqStatus == 0 else { - print("BLAD: openssl req zakonczyl sie kodem \(reqStatus).") + print(L10n.tr("ERROR: openssl req exited with code %@.", "\(reqStatus)")) throw ExitCode.failure } - // -legacy: OpenSSL 3.x domyslnie szyfruje PKCS12 algorytmami (AES-256+ - // SHA-256 MAC), ktorych macOS'owy Security framework (`security import`) - // nie rozumie - bez tej flagi import konczy sie mylacym "MAC - // verification failed (wrong password?)" mimo poprawnego hasla. -legacy - // wraca do 3DES/RC2, ktore macOS poprawnie parsuje. + // -legacy: OpenSSL 3.x encrypts PKCS12 by default with algorithms + // (AES-256 + SHA-256 MAC) that the macOS Security framework (`security + // import`) does not understand - without this flag the import fails with a + // misleading "MAC verification failed (wrong password?)" despite a correct + // password. -legacy goes back to 3DES/RC2, which macOS parses correctly. // - // Ale `/usr/bin/openssl` na macOS to LibreSSL, ktory flagi -legacy NIE - // ZNA i konczy sie bledem (sprawdzone na LibreSSL 3.3.6) - a 3DES/RC2 ma - // juz domyslnie. Flage dokladamy wiec tylko prawdziwemu OpenSSL 3. + // But `/usr/bin/openssl` on macOS is LibreSSL, which does NOT KNOW the + // -legacy flag and fails with an error (checked on LibreSSL 3.3.6) - and it + // already uses 3DES/RC2 by default. So we add the flag only for real + // OpenSSL 3. let versionOutput = (try? await ProcessRunner.run("/usr/bin/openssl", ["version"]))?.stdout ?? "" let legacyFlag = versionOutput.hasPrefix("OpenSSL 3") ? ["-legacy"] : [] @@ -88,12 +93,14 @@ struct SetupSigningCert: AsyncParsableCommand { "-in", certFile.path, "-passout", "pass:cloudmachine-local", ]) guard pkcs12Status == 0 else { - print("BLAD: openssl pkcs12 zakonczyl sie kodem \(pkcs12Status).") + print(L10n.tr("ERROR: openssl pkcs12 exited with code %@.", "\(pkcs12Status)")) throw ExitCode.failure } print( - "==> Importuje certyfikat do \(keychain.path) (z gory autoryzuje /usr/bin/codesign, bez pytania o haslo keychaina za kazdym razem)..." + L10n.tr( + "==> Importing the certificate into %@ (pre-authorizing /usr/bin/codesign, so it does not ask for the keychain password every time)...", + keychain.path) ) let importStatus = try await InteractiveProcess.run( "/usr/bin/security", @@ -102,26 +109,28 @@ struct SetupSigningCert: AsyncParsableCommand { "-T", "/usr/bin/codesign", "-T", "/usr/bin/security", ]) guard importStatus == 0 else { - print("BLAD: security import zakonczyl sie kodem \(importStatus).") + print(L10n.tr("ERROR: security import exited with code %@.", "\(importStatus)")) throw ExitCode.failure } - print("==> Ufam certyfikatowi WYLACZNIE do podpisywania kodu (code signing)...") + print(L10n.tr("==> Trusting the certificate ONLY for code signing...")) let trustStatus = try await InteractiveProcess.run( "/usr/bin/security", ["add-trusted-cert", "-r", "trustRoot", "-p", "codeSign", "-k", keychain.path, certFile.path]) guard trustStatus == 0 else { - print("BLAD: security add-trusted-cert zakonczyl sie kodem \(trustStatus).") + print(L10n.tr("ERROR: security add-trusted-cert exited with code %@.", "\(trustStatus)")) throw ExitCode.failure } + print("") + print(L10n.tr("Done. Certificate '%@' is now available to codesign.", certName)) print( - """ - - Gotowe. Certyfikat '\(certName)' jest teraz dostepny dla codesign. - Nastepne 'cloudmachine-agent build-app' uzyje go automatycznie zamiast podpisu ad-hoc. - Po TYM JEDNYM rebuildzie przyznaj Pelny dostep do dysku ostatni raz - kolejne - przebudowy juz go nie zresetuja, dopoki podpisujesz tym samym certyfikatem. - """) + L10n.tr( + "The next 'cloudmachine-agent build-app' will use it automatically instead of an ad-hoc signature." + )) + print( + L10n.tr( + "After THAT ONE rebuild, grant Full Disk Access one last time - later\nrebuilds will no longer reset it, as long as you sign with the same certificate." + )) } } diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 00be885..8b29b78 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -1,40 +1,41 @@ import CloudMachineCore import Foundation -/// Gotowosc narzedzi, bez ktorych nic nie ruszy. +/// Readiness of the tools without which nothing will run. enum DependencyState: Equatable { case unknown case checking - /// Czego brakuje i czym to naprawic - pary (brak, polecenie). + /// What is missing and how to fix it - pairs of (missing item, command). case missing([String], [String]) case ready } enum TimeMachineState: Equatable { case unknown - /// Time Machine nie wskazuje na nasz obraz - backupu realnie nie ma. + /// Time Machine does not point at our image - there is effectively no backup. case notRegistered - /// `tmutil` NIE ODPOWIEDZIAL w limicie czasu, wiec o celu nie wiemy nic. + /// `tmutil` DID NOT ANSWER within the time limit, so we know nothing about the destination. /// - /// Osobny stan z tego samego powodu, co `TimeMachineStatus.DestinationReading.noAnswer` - /// i `BufferStatus.queueKnown`: brak odpowiedzi nie ma prawa udawac wyniku. - /// Panel wyswietlal tu do 25.09.2026 "Time Machine nie wskazuje na - /// CloudMachine" - zdanie prawdziwie brzmiace i falszywe, ktore wysyla - /// czlowieka rejestrowac cel na nowo, podczas gdy cel jest caly, a zawiesil - /// sie odczyt (`tmutil destinationinfo` siega na montowanie na Google Drive). + /// A separate state for the same reason as `TimeMachineStatus.DestinationReading.noAnswer` + /// and `BufferStatus.queueKnown`: a missing answer has no right to pretend to be a result. + /// Until 25.09.2026 the panel showed "Time Machine does not point to + /// CloudMachine" here - a sentence that sounds true and is false, sending + /// a person off to register the destination again while the destination is + /// intact and it was the read that hung (`tmutil destinationinfo` reaches + /// into the Google Drive mount). case noAnswer case registered(mountPoint: String) } extension TimeMachineState { - /// Przeklada odpowiedz `tmutil` na stan panelu. + /// Translates the `tmutil` answer into a panel state. /// - /// Wydzielone z `CloudMachineController.refreshTimeMachine()` i czyste, - /// zeby dalo sie testem pokazac, ze TRZY odpowiedzi daja TRZY stany. - /// Wczesniej kontroler pytal `currentDestinationMountPoint()`, ktora zwraca - /// `nil` i przy braku celu, i przy braku odpowiedzi - obie sciezki - /// konczyly sie wiec tym samym `.notRegistered`. Czujka `backup-health` - /// rozrozniala je od 23.09.2026 (`destinationReading()`), panel nie. + /// Extracted from `CloudMachineController.refreshTimeMachine()` and pure, + /// so a test can show that THREE answers give THREE states. + /// Previously the controller asked `currentDestinationMountPoint()`, which returns + /// `nil` both when there is no destination and when there is no answer - so both paths + /// ended in the same `.notRegistered`. The `backup-health` watchdog has + /// distinguished them since 23.09.2026 (`destinationReading()`); the panel did not. static func from(_ reading: TimeMachineStatus.DestinationReading, target: String) -> TimeMachineState { @@ -47,42 +48,42 @@ extension TimeMachineState { } } -/// Stan bufora miedzy Time Machine a Google Drive. +/// State of the buffer between Time Machine and Google Drive. struct BufferStatus: Equatable { var mounted: Bool = false var imageAttached: Bool = false var sizeGB: Int = 0 - /// `nil` = pomiaru NIE BYLO (statfs zawiodl), a nie "zero gigabajtow" - - /// patrz `BufferGuardService.freeGB()`. To samo rozroznienie, co - /// `queueKnown` nizej. + /// `nil` = there was NO measurement (statfs failed), not "zero gigabytes" - + /// see `BufferGuardService.freeGB()`. The same distinction as + /// `queueKnown` below. var freeDiskGB: Int? - /// 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. + /// How many files are waiting to be uploaded. This number matters more than the + /// buffer size: if it grows and does not return to zero between backups, uploading + /// is not keeping up with writing. var uploadsQueued: Int = 0 var uploadsInProgress: Int = 0 - /// Czy powyzsze liczniki w ogole pochodza z odczytu. + /// Whether the counters above come from a reading at all. /// - /// Domyslnie `false` i to jest wazniejsze niz wyglada: swiezo utworzony - /// `BufferStatus` ma same zera, ktore nie sa pomiarem. Domyslne `true` - /// znaczyloby "pusta kolejka" i pasek menu swiecilby na zielono, zanim - /// cokolwiek zostalo sprawdzone. + /// `false` by default, and that matters more than it looks: a freshly created + /// `BufferStatus` has nothing but zeros, which are not a measurement. A default of `true` + /// would mean "empty queue" and the menu bar would light up green before + /// anything had been checked. var queueKnown: Bool = false var erroredFiles: Int = 0 - /// Na Google Drive nie ma miejsca. NIE minie samo. + /// There is no space left on Google Drive. It will NOT pass on its own. var driveFull: Bool = false - /// Dobowy limit ZAPISU Google (750 GB) wyczerpany. Mija sam. + /// The Google daily UPLOAD limit (750 GB) is exhausted. It passes on its own. /// - /// Trzymane osobno od `driveFull`, bo to sa dwie rozne sytuacje o tym samym - /// objawie: jedna znaczy "poczekaj", druga "zwolnij miejsce". Wczesniej byly - /// jednym polem i interfejs nie mogl ich rozroznic. + /// Kept separate from `driveFull`, because these are two different situations with + /// the same symptom: one means "wait", the other "free up space". Previously they were + /// a single field and the interface could not tell them apart. var dailyQuotaExhausted: Bool = false - /// rclone nie ma gdzie odlozyc danych - bufor pelny samymi niewyslanymi. + /// rclone has nowhere to put data - the buffer is full of nothing but unsent files. var outOfSpace: Bool = false var draining: Bool { uploadsInProgress > 0 || uploadsQueued > 0 } - /// Jedno zrodlo prawdy o tym, czy kopia dolatuje na Dysk - i dlaczego nie. + /// The single source of truth on whether the backup reaches the Drive - and why not. var uploadState: UploadState { UploadState.from( mounted: mounted, @@ -96,49 +97,49 @@ struct BufferStatus: Equatable { } } -/// Czy cykl backupu NADAL dziala - mierzone data ostatniej UDANEJ kopii. +/// Whether the backup cycle is STILL working - measured by the date of the last SUCCESSFUL backup. /// -/// Do 23.09.2026 interfejs nie zadawal tego pytania ani razu: `grep -rn -/// "BackupHealth" Sources/CloudMachineApp/` nie dawal ani jednego trafienia. -/// Panel liczyl zdrowie wylacznie ze stanu URZADZEN - montowanie, obraz, cel -/// Time Machine, kolejka - czyli ze stanu CHWILOWEGO. Awaria opisana -/// w naglowku `BackupHealth` jako najgrozniejsza wyglada dokladnie odwrotnie: -/// wszystko zamontowane, obraz podpiety, kolejka pusta, a Time Machine od -/// dwoch dni nie dokonczyl kopii. Panel swiecil wtedy "Sprawny / Gotowe". +/// Until 23.09.2026 the interface did not ask this question even once: `grep -rn +/// "BackupHealth" Sources/CloudMachineApp/` returned not a single hit. +/// The panel computed health solely from the state of the DEVICES - the mount, the image, the +/// Time Machine destination, the queue - that is, from the MOMENTARY state. The failure described +/// in the `BackupHealth` header as the most dangerous one looks exactly the other way round: +/// everything mounted, the image attached, the queue empty, and Time Machine has not +/// finished a backup for two days. The panel then showed "Healthy / Ready". struct BackupCycleStatus: Equatable { - /// Czy udalo sie w ogole odczytac preferencje Time Machine. + /// Whether the Time Machine preferences could be read at all. /// - /// Domyslnie `false` i to jest wazniejsze, niz wyglada - tak samo jak przy - /// `queueKnown`: swiezo utworzony stan nie jest pomiarem, a brak Pelnego - /// dostepu do dysku (najczestsza przyczyna nieczytelnego pliku preferencji) - /// nie moze uchodzic za brak problemu. + /// `false` by default, and that matters more than it looks - just as with + /// `queueKnown`: a freshly created state is not a measurement, and missing Full + /// Disk Access (the most common reason the preferences file is unreadable) + /// must not pass for the absence of a problem. var known: Bool = false - /// Data ostatniej ZAKONCZONEJ kopii. `nil` = nie ma ani jednej. + /// Date of the last COMPLETED backup. `nil` = there is not a single one. var lastSuccess: Date? - /// Gotowe zdania z `BackupHealth.Report` - do pokazania bez tlumaczenia. + /// Ready-made sentences from `BackupHealth.Report` - to show without translating. var problems: [String] = [] - /// Kiedy ostatnio pytalismy (czujka chodzi rzadziej niz odswiezanie panelu). + /// When we last asked (the watchdog runs less often than the panel refreshes). var checkedAt: Date? func age(now: Date = Date()) -> TimeInterval? { lastSuccess.map { now.timeIntervalSince($0) } } - /// Czy ostatnia UDANA kopia jest dostatecznie swieza. + /// Whether the last SUCCESSFUL backup is recent enough. /// - /// Brak odczytu i brak kopii daja `false` - jedno i drugie znaczy, ze nikt - /// nie potwierdzil, ze backup dziala, a zielony znaczek jest wlasnie takim - /// potwierdzeniem. + /// No reading and no backup both give `false` - either one means that nobody + /// has confirmed the backup works, and a green badge is exactly such a + /// confirmation. func isFresh(now: Date = Date(), maxAgeHours: Double = BackupHealth.maxAgeHours) -> Bool { guard known, let age = age(now: now) else { return false } return age <= maxAgeHours * 3600 } - /// Wiek slowami, do wiersza w panelu. + /// The age in words, for a row in the panel. func ageText(now: Date = Date()) -> String { - guard known else { return "nie sprawdzono" } - guard let age = age(now: now) else { return "ani jednej" } - return "\(BackupHealth.formatAge(age)) temu" + guard known else { return L10n.tr("not checked") } + guard let age = age(now: now) else { return L10n.tr("none at all") } + return L10n.tr("%@ ago", BackupHealth.formatAge(age)) } } @@ -148,9 +149,9 @@ struct LastRunResult: Equatable { var date: Date } -/// Zywy postep trwajacego backupu (`tmutil status`). `nil`, gdy nic sie nie -/// kopiuje. `transferRateMBs` liczymy sami z roznicy bajtow miedzy -/// odswiezeniami - `tmutil` tego nie podaje. +/// Live progress of a running backup (`tmutil status`). `nil` when nothing is +/// being copied. We compute `transferRateMBs` ourselves from the byte difference between +/// refreshes - `tmutil` does not report it. struct BackupProgressInfo: Equatable { var phase: String? var percent: Double? @@ -167,89 +168,99 @@ final class AppStatus: ObservableObject { @Published var dependencyState: DependencyState = .unknown @Published var remoteConfigured: Bool = false @Published var buffer = BufferStatus() - /// Odpowiedz na pytanie "kiedy ostatnio powstala KOPIA" - jedyna miara, - /// ktora rosnie wylacznie przy sukcesie. + /// The answer to "when was a BACKUP last made" - the only measure + /// that moves only on success. @Published var backupCycle = BackupCycleStatus() @Published var timeMachineState: TimeMachineState = .unknown - /// Kiedy czujka `backup-health` ostatnio PRZEBIEGLA. `nil` = panel jeszcze - /// nie pytal (nie: "nie przebiegla nigdy" - to osobny stan `.never`). + /// When the `backup-health` watchdog last RAN. `nil` = the panel has not + /// asked yet (not: "has never run" - that is a separate state, `.never`). /// - /// Panel pokazuje to z tego samego powodu, dla ktorego pokazuje wiek ostatniej - /// kopii: czujka chodzi z `StartInterval 1800` i bez `KeepAlive`, wiec - /// wyladowana albo zawieszona nie daje zadnego objawu poza cisza - a cisza - /// jest tu stanem normalnym. + /// The panel shows this for the same reason it shows the age of the last + /// backup: the watchdog runs with `StartInterval 1800` and without `KeepAlive`, so + /// when unloaded or hung it gives no symptom other than silence - and silence + /// is the normal state here. /// - /// CELOWO nie wchodzi do `healthy`: swiezosc kopii panel liczy SAM, z tego - /// samego pliku preferencji, z ktorego liczy ja czujka. Martwa czujka nie - /// znaczy wiec, ze backup nie dziala - znaczy, ze nikt o awarii nie donosi, - /// a to inna awaria i ma swoj wlasny, czerwony wiersz. + /// DELIBERATELY not part of `healthy`: the panel computes backup freshness ITSELF, from the + /// same preferences file the watchdog computes it from. A dead watchdog therefore does not + /// mean the backup is not working - it means nobody reports a failure, + /// and that is a different failure with its own red row. @Published var watchdog: WatchdogHeartbeat.Freshness? @Published var backupProgress: BackupProgressInfo? @Published var lastAction: LastRunResult? @Published var hasFullDiskAccess: Bool = false + /// Whether the mount agent is loaded in launchd; `nil` until asked. + @Published var agentsInstalled: Bool? + /// Whether this Mac's image exists on the mounted Drive; `nil` while the + /// Drive is not mounted, because then nobody can know. + @Published var imageExists: Bool? @Published var isBusy: Bool = false @Published var busyLabel: String = "" @Published var errorMessage: String? - /// Kiedy ostatnio udalo sie odczytac stan. Pokazywane w interfejsie, bo - /// zamrozony widok wyglada dokladnie jak awaria - a to dwie rozne rzeczy - /// i uzytkownik musi je odroznic bez zagladania do logow. + /// When the state was last read successfully. Shown in the interface, because + /// a frozen view looks exactly like a failure - and those are two different things + /// that the user has to tell apart without looking into the logs. @Published var lastRefresh: Date? - /// Czy czujka backupu CHODZI. `false` takze wtedy, gdy panel jeszcze nie - /// pytal - niesprawdzone nie ma prawa swiecic na zielono, tak samo jak - /// `queueKnown` i `BackupCycleStatus.known`. + /// Whether the backup watchdog is RUNNING. `false` also when the panel has not + /// asked yet - unchecked has no right to show green, just like + /// `queueKnown` and `BackupCycleStatus.known`. var watchdogRunning: Bool { if case .fresh = watchdog { return true } return false } - /// Jednozdaniowa odpowiedz na pytanie "czy moje dane sa bezpieczne". + /// A one-sentence answer to the question "is my data safe". var headline: String { if case .missing(let what, _) = dependencyState { - return "Brakuje: \(what.joined(separator: ", "))" + return L10n.tr("Missing: %@", 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" } - // Brak odpowiedzi tmutil MUSI brzmiec inaczej niz przestawiony cel: to - // pierwsze zdanie, ktore czlowiek czyta, i ono decyduje, co zrobi. - // "Nie wskazuje" kaze rejestrowac cel na nowo - czynnosc zbedna i myszlaca, - // gdy cel jest caly, a zawiesil sie odczyt. + if !remoteConfigured { return L10n.tr("Google Drive not connected") } + if !buffer.mounted { return L10n.tr("Buffer is not working") } + if !buffer.imageAttached { return L10n.tr("Backup image not attached") } + if case .notRegistered = timeMachineState { + return L10n.tr("Time Machine does not point to CloudMachine") + } + // No answer from tmutil MUST sound different from a changed destination: it is + // the first sentence a person reads, and it decides what they will do. + // "Does not point" tells them to register the destination again - a needless and misleading + // step when the destination is intact and it was the read that hung. if case .noAnswer = timeMachineState { - return "NIE WIADOMO, czy Time Machine wskazuje na CloudMachine - tmutil nie odpowiedzial" + return L10n.tr( + "UNKNOWN whether Time Machine points to CloudMachine - tmutil did not answer") } - // O wysylce mowi JEDNO zrodlo - inaczej pasek menu i karta stanu potrafily - // twierdzic co innego. Pliki, ktorych rclone nie wyslal, istnieja WYLACZNIE - // na tym Macu, czyli dokladnie tam, gdzie backup nie ma prawa byc jedyna - // kopia; `UploadState` stawia je przed limitem dobowym wlasnie dlatego. + // ONE source speaks about uploading - otherwise the menu bar and the status card could + // claim different things. Files that rclone did not upload exist ONLY + // on this Mac, which is exactly where the backup has no right to be the only + // copy; that is precisely why `UploadState` puts them ahead of the daily limit. let upload = buffer.uploadState if !upload.isNominal { return upload.headline } - if backupProgress != nil { return "Backup w toku" } - // Stan urzadzen moze byc nienaganny, a kopii moze nie byc od dwoch dni. - // To zdanie musi paść PRZED "Gotowe", bo inaczej naglowek zaprzecza - // znaczkowi obok (healthy = false, a napis "Gotowe"). + if backupProgress != nil { return L10n.tr("Backup in progress") } + // The state of the devices can be flawless while there has been no backup for two days. + // This sentence must come BEFORE "Ready", otherwise the headline contradicts the + // badge next to it (healthy = false, and the text says "Ready"). if !backupCycle.isFresh() { - guard backupCycle.known else { return "Nie wiadomo, kiedy powstała ostatnia kopia" } - guard let age = backupCycle.age() else { return "Nie ma ani jednej ukończonej kopii" } - return "Brak ukończonej kopii od \(BackupHealth.formatAge(age))" + guard backupCycle.known else { return L10n.tr("Unknown when the last backup was made") } + guard let age = backupCycle.age() else { + return L10n.tr("There is no completed backup at all") + } + return L10n.tr("No completed backup for %@", BackupHealth.formatAge(age)) } if upload.isMovingData { return upload.headline } - return "Gotowe" + return L10n.tr("Ready") } - /// Czy stan jest naprawde dobry. + /// Whether the state is really good. /// - /// UWAGA: `erroredFiles` i `outOfSpace` MUSZA tu byc. Bez nich pasek menu - /// pokazywal zielony znaczek i "Gotowe", podczas gdy czesc pasm obrazu nigdy - /// nie doleciala na Dysk - a taka kopia moze sie nie otworzyc. Zepsute - /// wygladalo dokladnie tak samo jak sprawne. + /// WARNING: `erroredFiles` and `outOfSpace` MUST be here. Without them the menu bar + /// showed a green badge and "Ready" while some of the image's bands never + /// reached the Drive - and such a backup may not open. Broken + /// looked exactly the same as working. /// - /// UWAGA DRUGA, z 23.09.2026: `backupCycle` MUSI tu byc z tego samego - /// powodu. Wszystkie pozostale warunki opisuja stan URZADZEN w tej chwili - /// i kazdy z nich moze byc spelniony, gdy od dwoch dni nie powstala zadna - /// kopia. Zielony znaczek ma znaczyc "dane sa bezpieczne", a to wynika - /// wylacznie z tego, ze kopia POWSTALA - nie z tego, ze dysk jest podpiety. + /// SECOND WARNING, from 23.09.2026: `backupCycle` MUST be here for the same + /// reason. All the other conditions describe the state of the DEVICES at this moment, + /// and each of them can be met when no backup has been made for two days. + /// The green badge is meant to say "the data is safe", and that follows + /// solely from a backup having BEEN MADE - not from the disk being attached. var healthy: Bool { guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached, case .registered = timeMachineState, buffer.uploadState.isNominal, diff --git a/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift b/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift new file mode 100644 index 0000000..b7164f0 --- /dev/null +++ b/mac-app/Sources/CloudMachineApp/Models/SetupPlan.swift @@ -0,0 +1,104 @@ +import CloudMachineCore +import Foundation + +/// What still has to be done before this Mac backs up, in the order it has to +/// be done. +/// +/// Installed from Homebrew, the app is the first thing a person opens, so the +/// setup lives here: each step either has a button that does it, or - for the +/// two that cannot run from the app - a command to copy. Google sign-in needs a +/// browser round trip that `configure-remote` waits on in a terminal, and +/// pointing Time Machine at a disk needs `sudo`. +/// +/// A pure function of what the controller measured, so the order and the +/// conditions are tested without a GUI. `nil` inputs mean "not measured +/// yet" and never produce a step: telling someone to create an image that +/// already exists is worse than showing the step a few seconds late. +struct SetupStep: Equatable { + enum Action: Equatable { + case installRclone + case installFuse + case grantFullDiskAccess + case installAgents + case createImage + case attachImage + } + + let title: String + /// Shown with a Copy button; for steps the app cannot do itself. + let command: String? + let action: Action? +} + +enum SetupPlan { + struct Inputs: Equatable { + var hasRclone: Bool + var hasFuse: Bool + var remoteConfigured: Bool + var hasFullDiskAccess: Bool + var agentsInstalled: Bool? + var mounted: Bool + var imageExists: Bool? + var imageAttached: Bool + var timeMachineNotPointingHere: Bool + var connectCommand: String + var setDestinationCommand: String + } + + static func steps(_ input: Inputs) -> [SetupStep] { + var steps: [SetupStep] = [] + if !input.hasRclone { + steps.append( + SetupStep( + title: L10n.tr("Install rclone (the official build, which can mount)"), command: nil, + action: .installRclone)) + } + if !input.hasFuse { + steps.append( + SetupStep(title: L10n.tr("Install FUSE-T"), command: nil, action: .installFuse)) + } + // Before anything that mounts: the Drive folder of this Mac is chosen + // here, and a mount started earlier would use the legacy name. + if !input.remoteConfigured { + steps.append( + SetupStep( + title: L10n.tr("Connect Google Drive: run this in Terminal and approve in the browser"), + command: input.connectCommand, action: nil)) + return steps + } + if !input.hasFullDiskAccess { + steps.append( + SetupStep( + title: L10n.tr( + "Grant Full Disk Access to CloudMachine, so it can tell whether backups complete"), + command: nil, action: .grantFullDiskAccess)) + } + if input.agentsInstalled == false { + steps.append( + SetupStep( + title: L10n.tr("Install the background agents (mount, image attach, watchdogs)"), + command: nil, action: .installAgents)) + return steps + } + guard input.mounted else { return steps } + if input.imageExists == false { + steps.append( + SetupStep( + title: L10n.tr("Create the backup image on Google Drive"), command: nil, + action: .createImage)) + return steps + } + if input.imageExists == true, !input.imageAttached { + steps.append( + SetupStep(title: L10n.tr("Attach the backup image"), command: nil, action: .attachImage)) + return steps + } + if input.imageAttached, input.timeMachineNotPointingHere { + steps.append( + SetupStep( + title: L10n.tr("Point Time Machine at CloudMachine: run this in Terminal (needs sudo)"), + command: input.setDestinationCommand, action: nil)) + } + return steps + } +} diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index e64ea78..bb49d30 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -2,14 +2,14 @@ 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. +/// Connects the interface to the services in `CloudMachineCore`. It contains no backup +/// logic itself - what it knows about Google Drive, the image and the buffer lives in the services, +/// so that the CLI and the GUI do exactly the same thing. @MainActor final class CloudMachineController: ObservableObject { let status = AppStatus() - /// Stan wlasnych poswiadczen Google - tylko "jest / nie ma", nigdy wartosc. + /// State of our own Google credentials - only "present / absent", never the value. @Published var credentials = RemoteConfigurer.CredentialsState( hasClientID: false, hasClientSecret: false) @@ -17,17 +17,17 @@ final class CloudMachineController: ObservableObject { private var lastBytesDone: Double? private var lastBytesSampledAt: Date? - /// Co ile odswiezamy odpowiedz na pytanie "kiedy ostatnio powstala kopia". + /// How often we refresh the answer to "when was a backup last made". /// - /// Rzadziej niz reszta panelu (10 s) i celowo: `BackupHealth.currentReport()` - /// czyta plik preferencji Time Machine, pyta rclone o pojemnosc Dysku - /// i tmutil o cel backupu - to sekundy pracy, nie mikrosekundy. Cykl - /// backupu jest godzinowy, wiec odpowiedz sprzed pieciu minut jest tak samo - /// dobra jak sprzed pieciu sekund. + /// Less often than the rest of the panel (10 s), and deliberately: `BackupHealth.currentReport()` + /// reads the Time Machine preferences file, asks rclone for the Drive capacity + /// and tmutil for the backup destination - that is seconds of work, not microseconds. The backup + /// cycle is hourly, so an answer from five minutes ago is just as + /// good as one from five seconds ago. private static let backupCycleInterval: TimeInterval = 300 private var backupCycleCheckedAt: Date? - // MARK: - Cykl odswiezania + // MARK: - Refresh cycle func startAutoRefresh(interval: TimeInterval = 10) { refreshTask?.cancel() @@ -51,20 +51,20 @@ final class CloudMachineController: ObservableObject { await refreshTimeMachine() await refreshProgress() await refreshBackupCycle() - // Tani odczyt jednego malego pliku - czujka zostawia w nim date KAZDEGO - // przebiegu. Patrz `WatchdogHeartbeat`: bez tego jedynym objawem - // wyladowanej czujki jest cisza, a cisza jest tu stanem normalnym. + // A cheap read of one small file - the watchdog leaves the date of EVERY + // run in it. See `WatchdogHeartbeat`: without this the only symptom of an + // unloaded watchdog is silence, and silence is the normal state here. status.watchdog = WatchdogHeartbeat.current() status.lastRefresh = Date() } - /// Wiek ostatniej UDANEJ kopii. + /// Age of the last SUCCESSFUL backup. /// - /// Panel nie zadawal tego pytania ani razu (patrz `BackupCycleStatus`), - /// wiec awaria "wszystko podpiete, a kopii nie ma od dwoch dni" wygladala - /// w nim dokladnie tak samo jak sprawny system. Zrodlo jest to samo, z - /// ktorego korzysta czujka `backup-health` - jedno zrodlo prawdy, zeby - /// panel i czujka nie mogly twierdzic czegos innego o tym samym. + /// The panel did not ask this question even once (see `BackupCycleStatus`), + /// so the failure "everything attached, and no backup for two days" looked + /// in it exactly the same as a working system. The source is the same one + /// the `backup-health` watchdog uses - a single source of truth, so that + /// the panel and the watchdog cannot claim different things about the same thing. private func refreshBackupCycle(force: Bool = false) async { if !force, let at = backupCycleCheckedAt, Date().timeIntervalSince(at) < Self.backupCycleInterval @@ -75,10 +75,10 @@ final class CloudMachineController: ObservableObject { let report = await BackupHealth.currentReport() var cycle = BackupCycleStatus() - // "Odczytano" znaczy tu: plik preferencji dalo sie przeczytac. Gdy sie - // nie da (najczesciej brak Pelnego dostepu do dysku), `currentReport` - // zglasza to jako problem i NIE podaje zadnej daty - wtedy `known` - // zostaje `false`, a nie udaje, ze kopii po prostu nie ma. + // "Read" means here: the preferences file could be read. When it + // cannot (most often missing Full Disk Access), `currentReport` + // reports that as a problem and gives NO date - then `known` + // stays `false` instead of pretending there simply is no backup. cycle.known = report.preferencesReadable cycle.lastSuccess = report.lastSuccess cycle.problems = report.problems.map(\.summary) @@ -86,7 +86,7 @@ final class CloudMachineController: ObservableObject { status.backupCycle = cycle } - // MARK: - Poszczegolne odczyty + // MARK: - Individual reads private func refreshDependencies() async { let readiness = CMTooling.checkReadiness() @@ -94,35 +94,38 @@ final class CloudMachineController: ObservableObject { readiness.ready ? .ready : .missing(readiness.missing, readiness.remedies) status.remoteConfigured = await RemoteConfigurer.isConfigured( remoteName: DriveBufferService.remoteName) - // REALNY odczyt tego pliku, o ktory naprawde chodzi - patrz - // `BackupHealth.preferencesReadable`. Wczesniej bylo tu - // `isReadableFile` (czyli `access(R_OK)`) na KATALOGU - // `~/Library/Application Support/com.apple.TCC`: zla sciezka i sprawdzenie, - // ktore pod TCC niczego nie dowodzi. + // A REAL read of the file that actually matters - see + // `BackupHealth.preferencesReadable`. Previously this was + // `isReadableFile` (that is, `access(R_OK)`) on the DIRECTORY + // `~/Library/Application Support/com.apple.TCC`: the wrong path and a check + // that proves nothing under TCC. status.hasFullDiskAccess = BackupHealth.preferencesReadable() + status.agentsInstalled = await LaunchdInstaller.isInstalled( + label: "com.renacode.cloudmachine.gdrive-buffer") } - /// Stan wlasnych poswiadczen OAuth. Sprawdzamy TYLKO istnienie wpisu - - /// siegniecie po sama wartosc potrafi podniesc okno Keychaina, a to okno - /// nie ma prawa wyskakiwac przy zwyklym odswiezaniu interfejsu. + /// State of our own OAuth credentials. We check ONLY that the entry exists - + /// reaching for the value itself can raise a Keychain prompt, and that prompt + /// has no right to pop up during an ordinary interface refresh. private func refreshCredentials() async { credentials = await RemoteConfigurer.credentialsState() } - /// Zapisuje poswiadczenia i odswieza stan. + /// Saves the credentials and refreshes the state. /// - /// Nie przekonfigurowuje remote: token wydany na starym `client_id` dziala - /// dalej, wiec samo wpisanie nowych wartosci NIC nie zmienia, dopoki nie - /// przejdzie `configure-remote --replace-existing`. Mowimy to wprost. + /// Does not reconfigure the remote: a token issued for the old `client_id` keeps + /// working, so entering new values alone changes NOTHING until + /// `configure-remote --replace-existing` is run. We say so plainly. func saveCredentials(clientID: String, clientSecret: String) async -> String { do { try await RemoteConfigurer.storeCredentials( clientID: clientID, clientSecret: clientSecret) await refreshCredentials() - return - "Zapisane w Keychainie. Uwaga: istniejace polaczenie nadal dziala na starym client_id - zeby uzyc nowego, przejdz configure-remote --replace-existing." + return L10n.tr( + "Saved in the Keychain. Note: the existing connection still uses the old client_id - to use the new one, run configure-remote --replace-existing." + ) } catch { - return "Nie zapisano: \(error.localizedDescription)" + return L10n.tr("Not saved: %@", error.localizedDescription) } } @@ -130,26 +133,26 @@ final class CloudMachineController: ObservableObject { let stats = await DriveBufferService.queueStats() var buffer = BufferStatus() buffer.mounted = DriveBufferService.isMounted - // Martwy obraz (w tablicy montowan, ale bez odczytu) liczy sie jako - // NIEPODPIETY - z punktu widzenia Time Machine dokladnie tym jest. + // A dead image (in the mount table, but unreadable) counts as + // NOT ATTACHED - from Time Machine's point of view that is exactly what it is. // - // `await`, a nie wlasciwosc obliczana: ta funkcja chodzi na `@MainActor` - // co 10 s, a w sondzie siedzi `read()` na FUSE-T. Do 26.09.2026 byl to - // zwykly, blokujacy odczyt - zaklinowany wolumen zamrazal caly interfejs - // (ruch okna, menu, przyciski) na tyle, ile trwalo I/O, czyli potencjalnie - // bez konca. Teraz sonda siedzi na wlasnym watku z limitem czasu, a panel - // czeka na wynik bez blokowania watku glownego. + // `await`, not a computed property: this function runs on `@MainActor` + // every 10 s, and the probe contains a `read()` on FUSE-T. Until 26.09.2026 this was + // an ordinary, blocking read - a wedged volume froze the whole interface + // (window movement, menus, buttons) for as long as the I/O took, that is, potentially + // forever. Now the probe runs on its own thread with a time limit, and the panel + // waits for the result without blocking the main thread. buffer.imageAttached = await BackupImageService.attachment().isUsable - // Rozmiar bufora bierzemy od rclone; wlasny obchod katalogu to 6504 - // wywolania stat co 10 sekund na dysku, na ktory leci backup. + // We take the buffer size from rclone; walking the directory ourselves means 6504 + // stat calls every 10 seconds on the disk the backup is being written to. buffer.sizeGB = BufferGuardService.bufferGB(stats: stats) buffer.freeDiskGB = BufferGuardService.freeGB() - // Dwa osobne pytania, bo odpowiedzi znacza co innego: brak miejsca trzeba - // naprawic, limit dobowy mija sam. + // Two separate questions, because the answers mean different things: lack of space has to be + // fixed, the daily limit passes on its own. buffer.driveFull = DriveBufferService.hitStorageQuota() buffer.dailyQuotaExhausted = DriveBufferService.uploadStalled() - // Rozroznienie "odczytano" od "wyszlo zero" - bez tego brak odpowiedzi od - // rclone wygladal na pusta kolejke. + // Distinguishes "read" from "came out as zero" - without it, no answer from + // rclone looked like an empty queue. buffer.queueKnown = stats != nil if let stats { buffer.uploadsQueued = stats.uploadsQueued @@ -158,20 +161,23 @@ final class CloudMachineController: ObservableObject { buffer.outOfSpace = stats.outOfSpace } status.buffer = buffer + status.imageExists = + buffer.mounted + ? FileManager.default.fileExists(atPath: BackupImageService.imagePath.path) : nil } - /// `destinationReading()`, a NIE `currentDestinationMountPoint()`. + /// `destinationReading()`, and NOT `currentDestinationMountPoint()`. /// - /// Ta druga zwraca `nil` zarowno przy braku celu, jak i przy braku - /// odpowiedzi tmutil, wiec panel pokazywal "Time Machine nie wskazuje na - /// CloudMachine" takze wtedy, gdy o celu nie wiedzial NIC. Kierunek pomylki - /// byl bezpieczny (falszywy alarm zamiast falszywego spokoju), ale komunikat - /// wysylal czlowieka rejestrowac cel, ktory jest caly. Rozroznienie istnieje - /// w `DestinationReading` od 23.09.2026 i czujka `backup-health` juz z niego - /// korzysta - panel jest ostatnim miejscem, ktore te dwie rzeczy zlewalo. + /// The latter returns `nil` both when there is no destination and when there is no + /// answer from tmutil, so the panel showed "Time Machine does not point to + /// CloudMachine" even when it knew NOTHING about the destination. The direction of the error + /// was safe (a false alarm instead of false calm), but the message + /// sent a person off to register a destination that is intact. The distinction has existed + /// in `DestinationReading` since 23.09.2026 and the `backup-health` watchdog already + /// uses it - the panel is the last place that conflated these two things. /// - /// Cel moze tez istniec i wskazywac gdzie indziej - wtedy backupu na Drive - /// nie ma, mimo ze Time Machine wyglada na skonfigurowany; to nadal + /// The destination may also exist and point somewhere else - then there is no backup on Drive, + /// even though Time Machine looks configured; that is still /// `.notRegistered`. private func refreshTimeMachine() async { status.timeMachineState = TimeMachineState.from( @@ -194,7 +200,7 @@ final class CloudMachineController: ObservableObject { bytesTotal: progress.totalBytes, filesDone: progress.files, filesTotal: progress.totalFiles, timeRemainingSeconds: progress.timeRemainingSeconds) - // tmutil nie podaje tempa - liczymy je z roznicy miedzy odczytami. + // tmutil does not report the rate - we compute it from the difference between readings. if let bytes = progress.bytes, let previous = lastBytesDone, let at = lastBytesSampledAt { let seconds = Date().timeIntervalSince(at) if seconds > 0, bytes >= previous { @@ -206,63 +212,104 @@ final class CloudMachineController: ObservableObject { status.backupProgress = info } - // MARK: - Akcje + // MARK: - Actions + + func installFuse() async { + await run(L10n.tr("Installing FUSE-T"), log: "Installing FUSE-T") { + await FuseInstaller.install() + } + } + + /// Opens System Settings at Full Disk Access. Granting it is a decision + /// only the user can make; the app can only take them to the right pane. + func openFullDiskAccessSettings() { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") + { + NSWorkspace.shared.open(url) + } + } + + var setupPlan: [SetupStep] { + SetupPlan.steps( + SetupPlan.Inputs( + hasRclone: CMTooling.hasManagedRclone, hasFuse: CMTooling.hasFuse, + remoteConfigured: status.remoteConfigured, hasFullDiskAccess: status.hasFullDiskAccess, + agentsInstalled: status.agentsInstalled, mounted: status.buffer.mounted, + imageExists: status.imageExists, imageAttached: status.buffer.imageAttached, + timeMachineNotPointingHere: status.timeMachineState == .notRegistered, + connectCommand: connectDriveCommand, setDestinationCommand: setDestinationCommand)) + } func installRclone() async { - await run("Instaluje rclone") { await RcloneInstaller.install() } + await run(L10n.tr("Installing rclone"), log: "Installing rclone") { + await RcloneInstaller.install() + } } - /// Polaczenie z Google Drive robi sie z terminala, nie z GUI: OAuth otwiera - /// przegladarke i czeka na zatwierdzenie, a klucze czytamy z Keychaina. + /// Connecting to Google Drive is done from the terminal, not from the GUI: OAuth opens + /// the browser and waits for approval, and we read the keys from the Keychain. var connectDriveCommand: String { "\(CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent") configure-remote" } func createImage(sizeGB: Int) async { - await run("Tworze obraz backupu") { await BackupImageService.create(sizeGB: sizeGB) } + await run(L10n.tr("Creating the backup image"), log: "Creating the backup image") { + await BackupImageService.create(sizeGB: sizeGB) + } } func attachImage() async { - await run("Podpinam obraz") { await BackupImageService.attach() } + await run(L10n.tr("Attaching the image"), log: "Attaching the image") { + await BackupImageService.attach() + } } func verifyImage() async { - await run("Sprawdzam spojnosc obrazu") { await BackupImageService.verify() } + await run(L10n.tr("Checking image consistency"), log: "Checking image consistency") { + await BackupImageService.verify() + } } func installAgents() async { - await run("Instaluje agentow launchd") { await LaunchdInstaller.install() } + await run(L10n.tr("Installing launchd agents"), log: "Installing launchd agents") { + await LaunchdInstaller.install() + } } func startBackup() async { - await run("Uruchamiam backup") { + await run(L10n.tr("Starting backup"), log: "Starting 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.") + ? L10n.tr("Backup started.") : L10n.tr("Could not start the backup.")) } } func stopBackup() async { - await run("Wstrzymuje backup") { + await run(L10n.tr("Stopping backup"), log: "Stopping 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.") + ? L10n.tr("Backup stopped.") : L10n.tr("Could not stop the backup.")) } } - /// Polecenie, ktore uzytkownik musi wkleic sam - `tmutil setdestination` - /// wymaga roota, a aplikacja nie ma reguly sudoers. + /// A command the user has to paste themselves - `tmutil setdestination` + /// requires root, and the app has no sudoers rule. var setDestinationCommand: String { "sudo tmutil setdestination '\(BackupImageService.targetPath.path)'" } - // MARK: - Wspolna obsluga akcji + // MARK: - Shared action handling - private func run(_ label: String, _ action: () async -> CMActionResult) async { + /// `label` is shown in the interface (translated); `logName` goes to the log, + /// which stays in English whatever the system language. + private func run( + _ label: String, log logName: String, _ action: () async -> CMActionResult + ) async { status.isBusy = true status.busyLabel = label status.errorMessage = nil @@ -275,7 +322,7 @@ final class CloudMachineController: ObservableObject { status.lastAction = LastRunResult( succeeded: result.succeeded, message: result.message, date: Date()) if !result.succeeded { status.errorMessage = result.message } - CMLogger.log("[gui] \(label): \(result.message)") + CMLogger.log("[gui] \(logName): \(result.message)") await refreshAll() } } diff --git a/mac-app/Sources/CloudMachineApp/Services/Shell.swift b/mac-app/Sources/CloudMachineApp/Services/Shell.swift index abf77b4..82aa259 100644 --- a/mac-app/Sources/CloudMachineApp/Services/Shell.swift +++ b/mac-app/Sources/CloudMachineApp/Services/Shell.swift @@ -12,14 +12,14 @@ enum ShellError: LocalizedError { } } -/// Cienka warstwa nad NSAppleScript do uruchamiania polecen z podniesionymi -/// uprawnieniami. Uruchamianie zwyklych (nieuprzywilejowanych) polecen - -/// patrz `ProcessRunner` w CloudMachineCore, dzielone z CLI. +/// A thin layer over NSAppleScript for running commands with elevated +/// privileges. For running ordinary (unprivileged) commands - +/// see `ProcessRunner` in CloudMachineCore, shared with the CLI. enum Shell { - /// Uruchamia polecenie z podniesionymi uprawnieniami przez natywny dialog - /// autoryzacji macOS (Touch ID / haslo administratora) - bez potrzeby - /// wczesniejszej konfiguracji sudoers. Uzywane do jednorazowych akcji - /// wykonywanych z poziomu GUI, gdy uzytkownik jest przy komputerze. + /// Runs a command with elevated privileges through the native macOS + /// authorization dialog (Touch ID / administrator password) - without any need + /// to configure sudoers beforehand. Used for one-off actions + /// performed from the GUI, while the user is at the computer. static func runPrivileged(_ shellCommand: String) async throws -> String { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { @@ -30,14 +30,15 @@ enum Shell { let source = "do shell script \"\(escaped)\" with administrator privileges" guard let script = NSAppleScript(source: source) else { continuation.resume( - throwing: ShellError.privilegedFailed("Nie udalo sie przygotowac AppleScript.")) + throwing: ShellError.privilegedFailed(L10n.tr("Could not prepare the AppleScript."))) return } var errorDict: NSDictionary? let output = script.executeAndReturnError(&errorDict) if let errorDict { let message = - errorDict[NSAppleScript.errorMessage] as? String ?? "Nieznany blad autoryzacji." + errorDict[NSAppleScript.errorMessage] as? String + ?? L10n.tr("Unknown authorization error.") continuation.resume(throwing: ShellError.privilegedFailed(message)) return } diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 2551440..5d35e2e 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -1,37 +1,39 @@ import CloudMachineCore import SwiftUI -/// Główny interfejs aplikacji CloudMachine, utrzymany w nowoczesnym systemie -/// wizualnym RenaCode (spójnym z Dietetyk-AI oraz Trader-AI). +/// The main interface of the CloudMachine app, kept in the modern RenaCode +/// visual system (consistent with Dietetyk-AI and Trader-AI). struct DashboardView: View { @EnvironmentObject private var controller: CloudMachineController @State private var clientID = "" @State private var clientSecret = "" @State private var credentialsMessage: String? @State private var credentialsExpanded = false + /// Logical size of a new backup image. Sparse: Drive holds only what is written. + @State private var imageSizeGB = "4000" var body: some View { ZStack { - // Świetliste tło RenaCode + // Glowing RenaCode background AmbientGlowBackground() VStack(spacing: 0) { - // Górna belka / Nagłówek z logo i zakładkami + // Top bar / header with the logo and tabs topHeaderBar - // Główna zawartość + // Main content dashboardContent } } .task { controller.startAutoRefresh() } } - // MARK: - Górna Belka Nawigacyjna + // MARK: - Top Navigation Bar private var topHeaderBar: some View { VStack(spacing: 12) { HStack(spacing: 16) { - // Logo ikona z fioletowym i cyjanowym poświatem + // Logo icon with a violet and cyan glow ZStack { RoundedRectangle(cornerRadius: 12) .fill(RenaCodeTheme.aiGradient) @@ -56,26 +58,26 @@ struct DashboardView: View { ) RenaCodePillBadge( - text: controller.status.healthy ? "Sprawny" : "Uwaga", + text: controller.status.healthy ? L10n.tr("Healthy") : L10n.tr("Attention"), color: controller.status.healthy ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning ) } - Text("Lokalny bufor SSD & kopia zapasowa na Google Drive") + Text(L10n.tr("Local SSD buffer & backup to Google Drive")) .font(.system(size: 12, weight: .medium)) .foregroundStyle(RenaCodeTheme.textMuted) } Spacer() - // Informacja o odświeżeniu i wskaźnik pracy + // Refresh information and activity indicator HStack(spacing: 12) { if let at = controller.status.lastRefresh { HStack(spacing: 5) { Image(systemName: "arrow.clockwise.circle") .font(.system(size: 11)) - Text("Odświeżono \(at.formatted(date: .omitted, time: .standard))") + Text(L10n.tr("Refreshed %@", at.formatted(date: .omitted, time: .standard))) .font(.system(size: 11, weight: .medium, design: .monospaced)) } .foregroundStyle(RenaCodeTheme.textDim) @@ -111,40 +113,40 @@ struct DashboardView: View { ) } - // MARK: - Zawartość Panelu Głównego (Dashboard Content) + // MARK: - Dashboard Content private var dashboardContent: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - // Baner ewentualnego błędu + // Banner for an error, if any if let error = controller.status.errorMessage { errorBanner(error) } - // Siatka kart statystyk KPI (Top Row) + // Grid of KPI stat cards (top row) kpiSummaryGrid - // Czy kopia dolatuje na Dysk - i dlaczego nie, jesli nie + // Whether the backup reaches the Drive - and why not, if it does not uploadStateCard - // Karta aktywnego postępu backupu (jeśli trwa) + // Card with the progress of the running backup (if one is running) if let progress = controller.status.backupProgress { progressCard(progress) } - // Karta kroków konfiguracji ("Do zrobienia") + // Card with setup steps ("To do") if !setupSteps.isEmpty { setupCard } - // Szczegóły bufora i wysyłki + // Buffer and upload details bufferCard - // Poświadczenia Google OAuth + // Google OAuth credentials credentialsCard - // Dolny pasek akcji + // Bottom action bar actionsToolbar } .padding(22) @@ -152,7 +154,7 @@ struct DashboardView: View { } } - // MARK: - Baner Błędu + // MARK: - Error Banner private func errorBanner(_ message: String) -> some View { HStack(spacing: 12) { @@ -176,7 +178,7 @@ struct DashboardView: View { ) } - // MARK: - Siatka Kart Statystyk (KPI Summary Grid) + // MARK: - KPI Summary Grid private var kpiSummaryGrid: some View { LazyVGrid( @@ -188,8 +190,8 @@ struct DashboardView: View { ], spacing: 14 ) { StatCard( - title: "Stan Systemu", - value: controller.status.healthy ? "Sprawny" : "Wymaga akcji", + title: L10n.tr("System Status"), + value: controller.status.healthy ? L10n.tr("Healthy") : L10n.tr("Action needed"), subtitle: controller.status.headline, systemImage: controller.status.healthy ? "checkmark.shield.fill" : "exclamationmark.shield.fill", @@ -198,31 +200,34 @@ struct DashboardView: View { ) StatCard( - title: "Rozmiar Bufora", + title: L10n.tr("Buffer Size"), value: "\(controller.status.buffer.sizeGB) GB", - // Brak pomiaru ma wygladac inaczej niz liczba - patrz - // `BufferGuardService.freeGB()`. Samo wstawienie opcjonalnej wartosci - // do tekstu dalo "Wolne na dysku: Optional(427) GB" i kompilator - // zglaszal to TYLKO jako ostrzezenie, wiec zaden test by tego nie zlapal. - subtitle: - "Wolne na dysku: \(controller.status.buffer.freeDiskGB.map { "\($0) GB" } ?? "nie zmierzono")", + // A missing measurement must look different from a number - see + // `BufferGuardService.freeGB()`. Simply putting the optional value + // into the text gave "Free on disk: Optional(427) GB", and the compiler + // reported it ONLY as a warning, so no test would have caught it. + subtitle: L10n.tr( + "Free on disk: %@", + controller.status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } + ?? L10n.tr("not measured")), systemImage: "internaldrive.fill", iconColor: RenaCodeTheme.colorCyan ) StatCard( - title: "Kolejka Wysyłki", - // Bez odczytu kolejki ta karta nie ma prawa powiedziec "Brak - // zaleglosci" - zera sa wtedy brakiem pomiaru, nie wynikiem. + title: L10n.tr("Upload Queue"), + // Without a queue reading this card has no right to say "Nothing + // pending" - the zeros are then a missing measurement, not a result. value: !controller.status.buffer.queueKnown ? "—" : (controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsQueued) w kolejce" : "Brak zaległości"), + ? L10n.tr("%@ queued", "\(controller.status.buffer.uploadsQueued)") + : L10n.tr("Nothing pending")), subtitle: !controller.status.buffer.queueKnown - ? "rclone nie odpowiedział" + ? L10n.tr("rclone did not answer") : (controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsInProgress) transferów w toku" - : "Wszystko w chmurze"), + ? L10n.tr("%@ transfers in progress", "\(controller.status.buffer.uploadsInProgress)") + : L10n.tr("Everything in the cloud")), systemImage: "icloud.and.arrow.up.fill", iconColor: !controller.status.buffer.queueKnown ? RenaCodeTheme.colorWarning @@ -232,9 +237,10 @@ struct DashboardView: View { StatCard( title: "Time Machine", - value: controller.status.buffer.imageAttached ? "Podpięty" : "Niepodpięty", + value: controller.status.buffer.imageAttached + ? L10n.tr("Attached") : L10n.tr("Not attached"), subtitle: controller.status.buffer.mounted - ? "Google Drive zamontowany" : "Drive rozłączony", + ? L10n.tr("Google Drive mounted") : L10n.tr("Drive disconnected"), systemImage: "clock.arrow.circlepath", iconColor: controller.status.buffer.imageAttached ? RenaCodeTheme.colorPrimaryLight : RenaCodeTheme.textDim @@ -242,7 +248,7 @@ struct DashboardView: View { } } - // MARK: - Postęp Backupu + // MARK: - Backup Progress private func progressCard(_ progress: BackupProgressInfo) -> some View { VStack(alignment: .leading, spacing: 14) { @@ -257,7 +263,7 @@ struct DashboardView: View { .foregroundStyle(RenaCodeTheme.colorCyan) } - Text("Kopia Zapasowa w Toku") + Text(L10n.tr("Backup in Progress")) .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(RenaCodeTheme.textMain) } @@ -292,7 +298,7 @@ struct DashboardView: View { HStack(spacing: 24) { if let done = progress.filesDone, let total = progress.filesTotal, total > 0 { VStack(alignment: .leading, spacing: 2) { - Text("Przetworzone pliki") + Text(L10n.tr("Files processed")) .font(.system(size: 11)) .foregroundStyle(RenaCodeTheme.textMuted) Text("\(done) / \(total)") @@ -303,7 +309,7 @@ struct DashboardView: View { if let rate = progress.transferRateMBs { VStack(alignment: .leading, spacing: 2) { - Text("Prędkość zapisu") + Text(L10n.tr("Write speed")) .font(.system(size: 11)) .foregroundStyle(RenaCodeTheme.textMuted) Text(String(format: "%.1f MB/s", rate)) @@ -314,7 +320,7 @@ struct DashboardView: View { if let phase = progress.phase { VStack(alignment: .leading, spacing: 2) { - Text("Faza operacji") + Text(L10n.tr("Operation phase")) .font(.system(size: 11)) .foregroundStyle(RenaCodeTheme.textMuted) Text(phase) @@ -327,23 +333,60 @@ struct DashboardView: View { .glassCard(borderColor: RenaCodeTheme.colorCyan.opacity(0.35)) } - // MARK: - Kroki Konfiguracji ("Do Zrobienia") + // MARK: - Setup Steps ("To Do") - 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)) } + private var setupSteps: [SetupStep] { controller.setupPlan } + + /// One button per step the app can do itself. Disabled while anything runs: + /// image operations share one lock, and a second click would only report + /// "another operation is in progress". + @ViewBuilder + private func setupActionButton(_ action: SetupStep.Action) -> some View { + HStack(spacing: 8) { + if action == .createImage { + Text(L10n.tr("Size (GB)")) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.textMain) + TextField("", text: $imageSizeGB) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + Button(action: { Task { await perform(action) } }) { + Text(setupActionTitle(action)) + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(SecondaryGlassButtonStyle()) + .disabled(controller.status.isBusy || (action == .createImage && parsedImageSize == nil)) } - if !controller.status.remoteConfigured { - steps.append(("Google Drive niepołączony", controller.connectDriveCommand)) + } + + private var parsedImageSize: Int? { + guard let value = Int(imageSizeGB.trimmingCharacters(in: .whitespaces)), value >= 100 + else { return nil } + return value + } + + private func setupActionTitle(_ action: SetupStep.Action) -> String { + switch action { + case .installRclone: return L10n.tr("Install rclone") + case .installFuse: return L10n.tr("Install FUSE-T") + case .grantFullDiskAccess: return L10n.tr("Open System Settings") + case .installAgents: return L10n.tr("Install agents") + case .createImage: return L10n.tr("Create image") + case .attachImage: return L10n.tr("Attach image") } - if case .notRegistered = controller.status.timeMachineState, - controller.status.buffer.imageAttached - { - steps.append( - ("Time Machine nie wskazuje na CloudMachine", controller.setDestinationCommand)) + } + + private func perform(_ action: SetupStep.Action) async { + switch action { + case .installRclone: await controller.installRclone() + case .installFuse: await controller.installFuse() + case .grantFullDiskAccess: controller.openFullDiskAccessSettings() + case .installAgents: await controller.installAgents() + case .createImage: + if let size = parsedImageSize { await controller.createImage(sizeGB: size) } + case .attachImage: await controller.attachImage() } - return steps } private var setupCard: some View { @@ -352,7 +395,7 @@ struct DashboardView: View { Image(systemName: "wrench.and.screwdriver.fill") .font(.system(size: 15)) .foregroundStyle(RenaCodeTheme.colorWarning) - Text("Wymagane Kroki Konfiguracji") + Text(L10n.tr("Required Setup Steps")) .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundStyle(RenaCodeTheme.textMain) } @@ -368,12 +411,16 @@ struct DashboardView: View { .background(RenaCodeTheme.colorWarning) .clipShape(Circle()) - Text(step.0) + Text(step.title) .font(.system(size: 13, weight: .medium)) .foregroundStyle(RenaCodeTheme.textMain) } - if let command = step.1 { + if let action = step.action { + setupActionButton(action) + } + + if let command = step.command { HStack { Text(command) .font(.system(size: 12, design: .monospaced)) @@ -387,7 +434,7 @@ struct DashboardView: View { HStack(spacing: 4) { Image(systemName: "doc.on.doc") .font(.system(size: 11)) - Text("Kopiuj") + Text(L10n.tr("Copy")) .font(.system(size: 11, weight: .medium)) } } @@ -408,7 +455,7 @@ struct DashboardView: View { .glassCard(borderColor: RenaCodeTheme.colorWarning.opacity(0.35)) } - // MARK: - Szczegóły Bufora i Wysyłki + // MARK: - Buffer and Upload Details private var bufferCard: some View { VStack(alignment: .leading, spacing: 14) { @@ -417,7 +464,7 @@ struct DashboardView: View { Image(systemName: "server.rack") .font(.system(size: 15)) .foregroundStyle(RenaCodeTheme.colorCyan) - Text("Bufor Lokalny & Stan Wysyłki") + Text(L10n.tr("Local Buffer & Upload Status")) .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundStyle(RenaCodeTheme.textMain) } @@ -427,92 +474,97 @@ struct DashboardView: View { VStack(spacing: 10) { row( - "Montowanie Google Drive (FUSE-T)", - controller.status.buffer.mounted ? "Zamontowany" : "Nieaktywny", + L10n.tr("Google Drive mount (FUSE-T)"), + controller.status.buffer.mounted ? L10n.tr("Mounted") : L10n.tr("Inactive"), ok: controller.status.buffer.mounted ) Divider().background(RenaCodeTheme.borderGlass) row( - "Obraz dysku backupu (.sparsebundle)", - controller.status.buffer.imageAttached ? "Podpięty do systemu" : "Odłączony", + L10n.tr("Backup disk image (.sparsebundle)"), + controller.status.buffer.imageAttached + ? L10n.tr("Attached to the system") : L10n.tr("Detached"), ok: controller.status.buffer.imageAttached ) Divider().background(RenaCodeTheme.borderGlass) row( - "Zalokowany bufor na dysku SSD", - "\(controller.status.buffer.sizeGB) GB", + L10n.tr("Buffer allocated on the SSD"), + L10n.tr("%@ GB", "\(controller.status.buffer.sizeGB)"), ok: true ) Divider().background(RenaCodeTheme.borderGlass) - // Brak pomiaru MUSI wygladac inaczej niz "0 GB" - patrz - // `BufferGuardService.freeGB()`. Nieudany statfs to awaria dozorcy - // bufora, a nie informacja o pustym dysku. + // A missing measurement MUST look different from "0 GB" - see + // `BufferGuardService.freeGB()`. A failed statfs is a failure of the buffer + // guard, not information about an empty disk. row( - "Wolne miejsce na lokalnym wolumenie", - controller.status.buffer.freeDiskGB.map { "\($0) GB" } ?? "nie zmierzono", + L10n.tr("Free space on the local volume"), + controller.status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } + ?? L10n.tr("not measured"), ok: (controller.status.buffer.freeDiskGB ?? 0) > 80 ) Divider().background(RenaCodeTheme.borderGlass) - // Jedyny wiersz, ktory odpowiada na pytanie "czy kopia POWSTALA". - // Wszystkie pozostale opisuja stan urzadzen i moga byc zielone, gdy - // Time Machine od dwoch dni nie dokonczyl backupu. + // The only row that answers the question "WAS a backup made". + // All the others describe the state of the devices and can be green while + // Time Machine has not finished a backup for two days. row( - "Ostatnia ukończona kopia", + L10n.tr("Last completed backup"), controller.status.backupCycle.ageText(), ok: controller.status.backupCycle.isFresh() ) Divider().background(RenaCodeTheme.borderGlass) - // Kto pilnuje czujki. Wiersz wyzej mowi, czy kopia powstala; ten mowi, - // czy ktokolwiek to jeszcze SPRAWDZA. Czujka chodzi bez KeepAlive, wiec - // wyladowana albo zawieszona nie daje objawu poza cisza - patrz + // Who watches the watchdog. The row above says whether a backup was made; this one says + // whether anyone is still CHECKING that. The watchdog runs without KeepAlive, so + // when unloaded or hung it gives no symptom other than silence - see // `WatchdogHeartbeat`. row( - "Ostatni przebieg czujki backupu", - controller.status.watchdog.map { StatusLines.watchdogRun($0) } ?? "nie sprawdzono", + L10n.tr("Last backup watchdog run"), + controller.status.watchdog.map { StatusLines.watchdogRun($0) } + ?? L10n.tr("not checked"), ok: controller.status.watchdogRunning ) Divider().background(RenaCodeTheme.borderGlass) row( - "Kolejka synchronizacji z chmurą", + L10n.tr("Cloud sync queue"), !controller.status.buffer.queueKnown - ? "nie odczytano" + ? L10n.tr("not read") : (controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsInProgress) w toku, \(controller.status.buffer.uploadsQueued) w kolejce" - : "Wszystko wysłane"), + ? L10n.tr( + "%@ in progress, %@ queued", "\(controller.status.buffer.uploadsInProgress)", + "\(controller.status.buffer.uploadsQueued)") + : L10n.tr("Everything uploaded")), ok: controller.status.buffer.queueKnown && controller.status.buffer.erroredFiles == 0 ) if controller.status.buffer.erroredFiles > 0 { Divider().background(RenaCodeTheme.borderGlass) row( - "Błędy wysyłki plików", - "\(controller.status.buffer.erroredFiles) plików", + L10n.tr("File upload errors"), + L10n.tr("%@ files", "\(controller.status.buffer.erroredFiles)"), ok: false ) } if controller.status.buffer.driveFull { Divider().background(RenaCodeTheme.borderGlass) - row("Miejsce na Google Drive", "Brak miejsca", ok: false) + row(L10n.tr("Space on Google Drive"), L10n.tr("Out of space"), ok: false) } if controller.status.buffer.dailyQuotaExhausted { Divider().background(RenaCodeTheme.borderGlass) row( - "Limit Google Drive", - "Dobowe 750 GB wyczerpane", + L10n.tr("Google Drive limit"), + L10n.tr("Daily 750 GB exhausted"), ok: false ) } @@ -521,12 +573,12 @@ struct DashboardView: View { .glassCard() } - // MARK: - Poświadczenia Google OAuth + // MARK: - Google OAuth Credentials - /// Poswiadczenia sa zwiniete domyslnie. Wpisuje sie je RAZ, przy zakladaniu - /// wlasnego klienta OAuth, a potem juz nigdy - trzymanie dwoch pol na haslo - /// na wierzchu panelu, ktory ma odpowiadac na pytanie o stan kopii, tylko - /// odciaga uwage. Znaczek przy naglowku mowi, czy jest co rozwijac. + /// The credentials are collapsed by default. They are entered ONCE, when setting up + /// your own OAuth client, and then never again - keeping two password fields + /// on top of a panel that is meant to answer the question about the backup's state only + /// distracts. The badge next to the header says whether there is anything to expand. private var credentialsCard: some View { VStack(alignment: .leading, spacing: 14) { Button { @@ -537,14 +589,15 @@ struct DashboardView: View { .font(.system(size: 15)) .foregroundStyle(RenaCodeTheme.colorPrimaryLight) - Text("Poświadczenia Google Drive (OAuth 2.0)") + Text(L10n.tr("Google Drive Credentials (OAuth 2.0)")) .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundStyle(RenaCodeTheme.textMain) Spacer() RenaCodePillBadge( - text: controller.credentials.isComplete ? "Keychain OK" : "Brak własnych kluczy", + text: controller.credentials.isComplete + ? L10n.tr("Keychain OK") : L10n.tr("No custom keys"), icon: controller.credentials.isComplete ? "checkmark.seal.fill" : "lock.open.fill", color: controller.credentials.isComplete ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning @@ -573,7 +626,7 @@ struct DashboardView: View { .foregroundStyle(RenaCodeTheme.textMuted) .frame(width: 100, alignment: .leading) - SecureField("Wklej client_id...", text: $clientID) + SecureField(L10n.tr("Paste client_id..."), text: $clientID) .textFieldStyle(.plain) .padding(8) .background(RenaCodeTheme.bgInset) @@ -590,7 +643,7 @@ struct DashboardView: View { .foregroundStyle(RenaCodeTheme.textMuted) .frame(width: 100, alignment: .leading) - SecureField("Wklej client_secret...", text: $clientSecret) + SecureField(L10n.tr("Paste client_secret..."), text: $clientSecret) .textFieldStyle(.plain) .padding(8) .background(RenaCodeTheme.bgInset) @@ -603,7 +656,7 @@ struct DashboardView: View { } HStack { - Button("Zapisz bezpiecznie w Keychainie") { + Button(L10n.tr("Save securely in the Keychain")) { let id = clientID let secret = clientSecret Task { @@ -632,7 +685,7 @@ struct DashboardView: View { .glassCard() } - // MARK: - Dolny Pasek Akcji + // MARK: - Bottom Action Bar private var actionsToolbar: some View { HStack(spacing: 12) { @@ -640,7 +693,7 @@ struct DashboardView: View { Button(action: { Task { await controller.startBackup() } }) { HStack(spacing: 6) { Image(systemName: "play.fill") - Text("Zrób backup teraz") + Text(L10n.tr("Back up now")) } } .buttonStyle(PrimaryGradientButtonStyle()) @@ -649,7 +702,7 @@ struct DashboardView: View { Button(action: { Task { await controller.stopBackup() } }) { HStack(spacing: 6) { Image(systemName: "stop.fill") - Text("Wstrzymaj backup") + Text(L10n.tr("Stop backup")) } } .buttonStyle(SecondaryGlassButtonStyle()) @@ -658,7 +711,7 @@ struct DashboardView: View { Button(action: { Task { await controller.verifyImage() } }) { HStack(spacing: 6) { Image(systemName: "checkmark.shield") - Text("Sprawdź spójność obrazu") + Text(L10n.tr("Check image consistency")) } } .buttonStyle(SecondaryGlassButtonStyle()) @@ -667,7 +720,7 @@ struct DashboardView: View { Button(action: { Task { await controller.refreshAll() } }) { HStack(spacing: 6) { Image(systemName: "arrow.clockwise") - Text("Odśwież") + Text(L10n.tr("Refresh")) } } .buttonStyle(SecondaryGlassButtonStyle()) @@ -676,18 +729,18 @@ struct DashboardView: View { } } - // MARK: - Stan Wysylki na Google Drive + // MARK: - Google Drive Upload Status - /// Odpowiada na jedyne pytanie, ktore uzytkownik naprawde zadaje: czy moja - /// kopia jest bezpieczna. Jedno zdanie, pod nim wyjasnienie po ludzku. + /// Answers the only question the user really asks: is my + /// backup safe. One sentence, with a plain-language explanation below it. /// - /// Kolor rozroznia TRZY rzeczy, nie dwie. Zielony - jest dobrze. Bursztynowy - - /// nie jest nominalnie, ale nic nie rob, minie samo (limit dobowy Google). - /// Czerwony - trzeba zareagowac. Bez srodkowego stanu wyczerpany limit - /// musialby udawac albo awarie, albo porzadek, a nie jest ani jednym, ani drugim. + /// The color distinguishes THREE things, not two. Green - all is well. Amber - + /// not nominal, but do nothing, it will pass on its own (the Google daily limit). + /// Red - you need to act. Without the middle state an exhausted limit + /// would have to pretend to be either a failure or all-clear, and it is neither. /// - /// Teksty nie ida przez `.localized` celowo: wiekszosc wariantow wstawia - /// liczbe do zdania, wiec i tak nie trafilaby w slownik tlumaczen. + /// The texts deliberately do not go through `.localized`: most variants insert + /// a number into the sentence, so they would not have matched the translation dictionary anyway. private var uploadStateCard: some View { let state = controller.status.buffer.uploadState let accent = uploadAccent(state) @@ -734,8 +787,8 @@ struct DashboardView: View { return RenaCodeTheme.colorSuccess } - /// Etykieta idzie prosto z `UploadState`. Skladanie jej tutaj z dwoch bool-i - /// ograniczalo interfejs do trzech wariantow, a stanow jest wiecej. + /// The label comes straight from `UploadState`. Assembling it here from two bools + /// limited the interface to three variants, and there are more states. private func uploadBadge(_ state: UploadState) -> String { state.badge } private func uploadIcon(_ state: UploadState) -> String { @@ -751,7 +804,7 @@ struct DashboardView: View { } } - // MARK: - Pomocniczy Wiersz Tabela + // MARK: - Helper Table Row private func row(_ label: String, _ value: String, ok: Bool) -> some View { HStack { diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index 5fd5411..a242d8a 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -1,18 +1,19 @@ +import CloudMachineCore import SwiftUI -/// Zawartość paska menu (MenuBar Extra), utrzymana w nowoczesnym -/// stylu wizualnym RenaCode. +/// Contents of the menu bar (MenuBar Extra), kept in the modern +/// RenaCode visual style. struct MenuBarContentView: View { @EnvironmentObject private var controller: CloudMachineController @Environment(\.openWindow) private var openWindow - /// Otwiera panel i wyciąga go NA WIERZCH. + /// Opens the panel and brings it TO THE FRONT. /// - /// Aplikacja jest agentem paska menu (`LSUIElement`), więc samo - /// `openWindow` tworzy okno, ale nie aktywuje aplikacji - okno lądowało - /// pod oknami programu, w którym użytkownik akurat pracował. Aktywacja - /// musi być jawna i musi iść PO utworzeniu okna, stąd odłożenie na - /// następny obieg pętli zdarzeń. + /// The app is a menu bar agent (`LSUIElement`), so `openWindow` on its own + /// creates the window but does not activate the app - the window ended up + /// beneath the windows of whatever program the user happened to be working in. Activation + /// has to be explicit and has to come AFTER the window is created, hence deferring it to + /// the next pass of the event loop. private func showDashboard() { openWindow(id: "dashboard") DispatchQueue.main.async { @@ -26,7 +27,7 @@ struct MenuBarContentView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - // Nagłówek z logo i indeksem sprawności + // Header with the logo and the health indicator HStack(spacing: 10) { ZStack { RoundedRectangle(cornerRadius: 8) @@ -64,11 +65,11 @@ struct MenuBarContentView: View { Divider().background(RenaCodeTheme.borderGlass) - // Postęp aktywnej kopii zapasowej + // Progress of the running backup if let progress = controller.status.backupProgress, let percent = progress.percent { VStack(alignment: .leading, spacing: 4) { HStack { - Text("Backup w toku") + Text(L10n.tr("Backup in progress")) .font(.system(size: 12, weight: .medium)) .foregroundStyle(RenaCodeTheme.textMuted) Spacer() @@ -92,10 +93,10 @@ struct MenuBarContentView: View { } } - // Stan kolejki i bufora + // Queue and buffer state VStack(spacing: 6) { HStack { - Text("Czeka na wysłanie") + Text(L10n.tr("Waiting to upload")) .font(.system(size: 12)) .foregroundStyle(RenaCodeTheme.textMuted) Spacer() @@ -103,7 +104,8 @@ struct MenuBarContentView: View { !controller.status.buffer.queueKnown ? "?" : (controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsQueued) plików" : "nic") + ? L10n.tr("%@ files", "\(controller.status.buffer.uploadsQueued)") + : L10n.tr("nothing")) ) .font(.system(size: 12, weight: .semibold, design: .monospaced)) .foregroundStyle( @@ -112,7 +114,7 @@ struct MenuBarContentView: View { } HStack { - Text("Bufor SSD") + Text(L10n.tr("SSD buffer")) .font(.system(size: 12)) .foregroundStyle(RenaCodeTheme.textMuted) Spacer() @@ -124,13 +126,13 @@ struct MenuBarContentView: View { Divider().background(RenaCodeTheme.borderGlass) - // Przyciski akcji + // Action buttons VStack(spacing: 6) { if controller.status.backupProgress == nil { Button(action: { Task { await controller.startBackup() } }) { HStack { Image(systemName: "play.fill") - Text("Zrób backup teraz") + Text(L10n.tr("Back up now")) Spacer() } } @@ -140,7 +142,7 @@ struct MenuBarContentView: View { Button(action: { Task { await controller.stopBackup() } }) { HStack { Image(systemName: "stop.fill") - Text("Wstrzymaj backup") + Text(L10n.tr("Stop backup")) Spacer() } } @@ -150,7 +152,7 @@ struct MenuBarContentView: View { Button(action: showDashboard) { HStack { Image(systemName: "macwindow") - Text("Otwórz CloudMachine") + Text(L10n.tr("Open CloudMachine")) Spacer() } } @@ -159,7 +161,7 @@ struct MenuBarContentView: View { Button(action: { NSApplication.shared.terminate(nil) }) { HStack { Image(systemName: "power") - Text("Zakończ") + Text(L10n.tr("Quit")) Spacer() } } diff --git a/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift b/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift index 78ee3e0..c3b8da0 100644 --- a/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift +++ b/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift @@ -1,9 +1,9 @@ import SwiftUI -/// System wizualny RenaCode dla CloudMachine. -/// Spójne tokeny kolorów, typografii i glassmorphismu z Dietetyk-AI i Trader-AI. +/// The RenaCode visual system for CloudMachine. +/// Color, typography and glassmorphism tokens consistent with Dietetyk-AI and Trader-AI. public enum RenaCodeTheme { - // MARK: - Kolory Podstawowe (Tokens) + // MARK: - Base Colors (Tokens) public static let bgDark = Color(red: 7 / 255, green: 9 / 255, blue: 19 / 255) // #070913 public static let bgDarkEnd = Color(red: 13 / 255, green: 17 / 255, blue: 39 / 255) // #0D1127 @@ -18,27 +18,27 @@ public enum RenaCodeTheme { public static let borderGlassGlow = Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) .opacity(0.35) - // Akcenty kolorystyczne - // #7C3AED Fiolet AI + // Color accents + // #7C3AED AI violet public static let colorPrimary = Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) // #C084FC public static let colorPrimaryLight = Color(red: 192 / 255, green: 132 / 255, blue: 252 / 255) - // #06B6D4 Cyjan aktywności + // #06B6D4 Activity cyan public static let colorCyan = Color(red: 6 / 255, green: 182 / 255, blue: 212 / 255) - // #34D399 Szmaragd + // #34D399 Emerald public static let colorSuccess = Color(red: 52 / 255, green: 211 / 255, blue: 153 / 255) // #10B981 public static let colorSuccessDark = Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255) - // #F59E0B Pomarańcz + // #F59E0B Orange public static let colorWarning = Color(red: 245 / 255, green: 158 / 255, blue: 11 / 255) - // #F87171 Czerwień + // #F87171 Red public static let colorDanger = Color(red: 248 / 255, green: 113 / 255, blue: 113 / 255) public static let textMain = Color(red: 241 / 255, green: 245 / 255, blue: 249 / 255) // #F1F5F9 public static let textMuted = Color(red: 148 / 255, green: 163 / 255, blue: 184 / 255) // #94A3B8 public static let textDim = Color(red: 100 / 255, green: 116 / 255, blue: 139 / 255) // #64748B - // MARK: - Gradienty + // MARK: - Gradients public static let aiGradient = LinearGradient( colors: [colorPrimary, colorPrimaryLight], @@ -65,7 +65,7 @@ public enum RenaCodeTheme { ) } -// MARK: - Tło ze świetlistymi kulami (Ambient Orbs Background) +// MARK: - Ambient Orbs Background public struct AmbientGlowBackground: View { public init() {} @@ -75,14 +75,14 @@ public struct AmbientGlowBackground: View { RenaCodeTheme.darkGradient .ignoresSafeArea() - // Fioletowa kuleczka z lewej strony + // Violet orb on the left Circle() .fill(RenaCodeTheme.colorPrimary.opacity(0.18)) .frame(width: 380, height: 380) .blur(radius: 90) .offset(x: -220, y: -180) - // Cyjanowa kuleczka z prawej strony + // Cyan orb on the right Circle() .fill(RenaCodeTheme.colorCyan.opacity(0.14)) .frame(width: 340, height: 340) @@ -93,7 +93,7 @@ public struct AmbientGlowBackground: View { } } -// MARK: - Modyfikator Karty Glassmorphic (GlassCard) +// MARK: - Glassmorphic Card Modifier (GlassCard) public struct GlassCardModifier: ViewModifier { var cornerRadius: CGFloat @@ -140,7 +140,7 @@ extension View { } } -// MARK: - Komponent Karta Statystyk (KPI StatCard) +// MARK: - KPI Stat Card Component (StatCard) public struct StatCard: View { let title: String @@ -233,7 +233,7 @@ public struct RenaCodePillBadge: View { } } -// MARK: - Style Przycieków (ButtonStyles) +// MARK: - Button Styles public struct PrimaryGradientButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled diff --git a/mac-app/Sources/CloudMachineCore/AppVersion.swift b/mac-app/Sources/CloudMachineCore/AppVersion.swift index d0e3fed..e34f583 100644 --- a/mac-app/Sources/CloudMachineCore/AppVersion.swift +++ b/mac-app/Sources/CloudMachineCore/AppVersion.swift @@ -1,15 +1,15 @@ import Foundation -/// Z czego dokladnie zbudowano to, co wlasnie dziala. +/// Exactly what the thing that is running right now was built from. /// -/// Numer wersji sam w sobie nie odpowiada na pytanie "czy zainstalowane jest -/// to, co w repozytorium" - `1.1.0` stoi w pliku VERSION miesiacami, a numer -/// budowy (liczba commitow) powtarza sie miedzy galeziami. Dlatego nosimy tez -/// SHA commitu i informacje, czy drzewo bylo brudne. +/// The version number alone does not answer "is what is installed the same as +/// what is in the repository" - `1.1.0` sits in the VERSION file for months, +/// and the build number (commit count) repeats across branches. That is why we +/// also carry the commit SHA and whether the tree was dirty. /// -/// Powod jest konkretny: 13 wrz 2026 nie dalo sie odpowiedziec na pytanie -/// "czy zainstalowana jest najnowsza wersja" inaczej niz porownujac daty -/// plikow i diffujac drzewo git wzgledem zgadnietego commitu. +/// The reason is concrete: on 13 Sep 2026 the question "is the latest version +/// installed" could not be answered other than by comparing file dates and +/// diffing the git tree against a guessed commit. public struct AppVersion: Equatable { public let shortVersion: String public let build: String @@ -23,26 +23,29 @@ public struct AppVersion: Equatable { self.dirty = dirty } - /// Wartosc wstawiana przy budowaniu poza repozytorium git. - public static let unknownCommit = "nieznany" + /// Value inserted when building outside a git repository. + /// + /// Written into Info.plist by `build-app` and read back only by the binary + /// from that same bundle, so both sides always agree on it. + public static let unknownCommit = "unknown" - /// Jedna linia do logu i do `--version`. + /// One line for the log and for `--version`. public var summary: String { var text = "\(shortVersion) (\(build))" if commit != Self.unknownCommit { text += " \(commit)" } if dirty { - text += " BRUDNE-DRZEWO" + text += " " + L10n.tr("DIRTY-TREE") } return text } - /// Czy da sie z tego jednoznacznie wskazac commit w repozytorium. + /// Whether a commit in the repository can be pointed at unambiguously. /// - /// Brudne drzewo znaczy, ze w binarce siedzi kod, ktorego nie ma w zadnym - /// commicie - numer wersji wtedy KLAMIE i nie wolno go traktowac jako - /// dowodu, ze zainstalowane jest to samo, co na galezi. + /// A dirty tree means the binary contains code that is in no commit - the + /// version number then LIES and must not be taken as proof that what is + /// installed is the same as what is on the branch. public var isTraceable: Bool { commit != Self.unknownCommit && !dirty } @@ -53,8 +56,8 @@ public enum AppVersionReader { static let commitKey = "CMGitCommit" static let dirtyKey = "CMGitDirty" - /// Czysta wersja - odczyt ze slownika, zeby dalo sie sprawdzic testem bez - /// budowania bundla. + /// Pure version - reads from a dictionary, so it can be tested without + /// building a bundle. public static func parse(infoPlist: [String: Any]) -> AppVersion { let dirtyRaw = infoPlist[dirtyKey] let dirty: Bool @@ -70,13 +73,14 @@ public enum AppVersionReader { dirty: dirty) } - /// Wersja binarki, ktora WLASNIE dziala. + /// Version of the binary that is running RIGHT NOW. /// - /// Szukamy `Contents/Info.plist` obok wykonywalnego pliku, a nie przez - /// `Bundle.main`: agent to zwykly plik wykonywalny w `Contents/MacOS`, a nie - /// aplikacja, wiec `Bundle.main` potrafi wskazac katalog zamiast bundla. - /// Przy `swift run` zadnego bundla nie ma i to nie jest blad - zwracamy - /// `nil`, a wolajacy mowi wprost, ze to build z drzewa roboczego. + /// We look for `Contents/Info.plist` next to the executable rather than going + /// through `Bundle.main`: the agent is a plain executable in + /// `Contents/MacOS`, not an application, so `Bundle.main` can point at a + /// directory instead of the bundle. Under `swift run` there is no bundle at + /// all and that is not an error - we return `nil`, and the caller says + /// plainly that this is a build from the working tree. public static func current( executable: URL = CMPaths.runningExecutable ) -> AppVersion? { diff --git a/mac-app/Sources/CloudMachineCore/BackupHealth.swift b/mac-app/Sources/CloudMachineCore/BackupHealth.swift index 256210b..cd47aca 100644 --- a/mac-app/Sources/CloudMachineCore/BackupHealth.swift +++ b/mac-app/Sources/CloudMachineCore/BackupHealth.swift @@ -1,66 +1,71 @@ import Foundation -/// Odpowiada na jedno pytanie: czy cykl godzinowy NADAL dziala. +/// Answers one question: is the hourly cycle STILL working. /// -/// Reszta tego projektu mierzy stan chwilowy - czy montowanie stoi, czy obraz -/// jest podpiety, ile czeka w kolejce. Zaden z tych pomiarow nie wykrywa -/// najgrozniejszej awarii tego systemu: wszystko wyglada na zamontowane -/// i podpiete, a Time Machine od dwoch dni nie dokonczyl ani jednego backupu. -/// Interfejs pokazuje wtedy zielony znaczek i napis "Gotowe". +/// The rest of this project measures the momentary state - whether the mount +/// is up, whether the image is attached, how much is waiting in the queue. None +/// of these measurements detects the most dangerous failure of this system: +/// everything looks mounted and attached, and Time Machine has not finished a +/// single backup for two days. The interface then shows a green badge and the +/// word "Ready". /// -/// Dlatego zrodlem prawdy jest tutaj DATA OSTATNIEJ UDANEJ kopii, a nie stan -/// urzadzen. Licznik, ktory rosnie tylko przy sukcesie, bierzemy od samego -/// macOS: `SnapshotDates` w `/Library/Preferences/com.apple.TimeMachine.plist` -/// dostaje wpis dopiero po ZAKONCZONYM backupie. `AttemptDates` obok niego -/// liczy proby - w tym te, ktore padly - wiec roznica miedzy nimi jest -/// dokladnie tym, czego szukamy. +/// That is why the source of truth here is the DATE OF THE LAST SUCCESSFUL +/// backup, not the state of the devices. We take a counter that grows only on +/// success from macOS itself: `SnapshotDates` in +/// `/Library/Preferences/com.apple.TimeMachine.plist` gets an entry only after +/// a COMPLETED backup. `AttemptDates` next to it counts attempts - including +/// the ones that failed - so the difference between them is exactly what we +/// are looking for. /// -/// Czytamy plik lokalny, a nie `tmutil latestbackup`. To nie jest optymalizacja: -/// `tmutil latestbackup` montuje migawke na wolumenie lezacym na Google Drive -/// i przy chorym montowaniu potrafi wisiec w nieprzerywalnym I/O. Czujka, ktora -/// zawiesza sie dokladnie wtedy, gdy ma zaalarmowac, jest gorsza niz jej brak. +/// We read the local file, not `tmutil latestbackup`. This is not an +/// optimization: `tmutil latestbackup` mounts a snapshot on the volume that +/// lives on Google Drive and, with a sick mount, can hang in uninterruptible +/// I/O. A watchdog that hangs exactly when it should raise the alarm is worse +/// than none. /// -/// To zdanie bylo do 23.09.2026 deklaracja, a nie faktem: `currentReport()` -/// wola `tmutil destinationinfo` (po cel Time Machine), a ten odczyt siega -/// na montowanie i BEZ LIMITU CZASU wisial w nieprzerywalnym I/O dokladnie -/// tak, jak `latestbackup`, przed ktorym ten komentarz ostrzega. Od tej daty -/// kazde wywolanie tmutil ma twardy limit (`TimeMachineStatus.commandTimeout`), -/// a brak odpowiedzi jest zglaszany jako AWARIA - nie jako "cel przestawiony" -/// i nie jako cisza. +/// Until 23.09.2026 that sentence was a declaration, not a fact: +/// `currentReport()` calls `tmutil destinationinfo` (for the Time Machine +/// destination), and that read reaches the mount and hung WITHOUT A TIME LIMIT +/// in uninterruptible I/O exactly like `latestbackup`, which this comment warns +/// against. Since that date every tmutil call has a hard limit +/// (`TimeMachineStatus.commandTimeout`), and no answer is reported as a FAILURE +/// - not as "destination changed" and not as silence. public enum BackupHealth { public static let preferencesPath = "/Library/Preferences/com.apple.TimeMachine.plist" - /// Po tylu godzinach bez UDANEJ kopii uznajemy cykl za zerwany. + /// After this many hours without a SUCCESSFUL backup we consider the cycle + /// broken. /// - /// Cykl jest godzinowy, wiec trzy godziny to trzy pominiete przebiegi z rzedu - /// - za duzo na przypadek. Jednoczesnie zostawia zapas na backup, ktory - /// trwa dlugo, i na dozorce bufora, ktory celowo wstrzymuje Time Machine - /// na czas nadganiania wysylki. + /// The cycle is hourly, so three hours are three missed runs in a row - too + /// many to be chance. At the same time it leaves headroom for a backup that + /// takes long, and for the buffer guard, which deliberately pauses Time + /// Machine while the upload catches up. public static let maxAgeHours = 3.0 - /// Przez tyle minut od startu systemu "montowanie / obraz / cel jeszcze nie - /// stoi" NIE jest awaria. + /// For this many minutes after system startup "the mount / image / + /// destination is not up yet" is NOT a failure. /// - /// Agent `backup-health` ma `RunAtLoad`, wiec odpala sie razem z sesja - - /// kilka sekund po tym, jak rclone dopiero ruszyl. Po kazdym restarcie - /// (21.09, 25.09, 01.10.2026) czujka meldowala wtedy "AWARIA BACKUPU: - /// Montowanie Google Drive nie dziala" 4-8 s po zalogowaniu, zanim cokolwiek - /// mialo szanse wstac. Alarm, ktory pada przy kazdym starcie, uczy go - /// ignorowac - i wtedy przepada ten jeden prawdziwy. + /// The `backup-health` agent has `RunAtLoad`, so it starts together with the + /// session - a few seconds after rclone has only just started. After every + /// restart (21.09, 25.09, 01.10.2026) the watchdog then reported "BACKUP + /// FAILURE: Google Drive mount is not working" 4-8 s after login, before + /// anything had a chance to come up. An alarm that fires on every startup + /// teaches people to ignore it - and then the one real alarm is lost. /// - /// 20 min, bo tyle zmierzono w najgorszym przypadku: 01.10.2026 po - /// restarcie z ~19 GB niewyslanych pasm rclone wczytywal i wysylal zaleglosc - /// do 15:42, a obraz podpial sie o 15:49 - 17 min po starcie agentow. - /// Odroczone sa WYLACZNIE stany `false` urzadzen. Wiek ostatniej udanej - /// kopii, RESULT, bledy wysylki i "nie wiadomo" (zawieszony odczyt) alarmuja - /// od pierwszej sekundy, a nastepny przebieg czujki (co 30 min) wypada juz - /// po tym oknie i zglosi kazdy stan, ktory sam sie nie naprawil. + /// 20 min, because that is what was measured in the worst case: on + /// 01.10.2026, after a restart with ~19 GB of unsent bands, rclone was loading + /// and uploading the backlog until 15:42, and the image attached at 15:49 - + /// 17 min after the agents started. ONLY the devices' `false` states are + /// deferred. The age of the last successful backup, RESULT, upload errors and + /// "unknown" (a hung read) alarm from the first second, and the next + /// watchdog run (every 30 min) already falls after this window and will + /// report every state that did not fix itself. public static let startupGraceMinutes = 20.0 - /// Ile sekund minelo od startu systemu (`kern.boottime`). `nil` = nie - /// udalo sie odczytac - wtedy okresu rozruchu NIE stosujemy, bo "nie wiem" - /// nie moze wyciszac alarmu. + /// How many seconds have passed since system startup (`kern.boottime`). + /// `nil` = could not be read - then the startup grace period is NOT applied, + /// because "I do not know" must not silence an alarm. public static func systemUptime(now: Date = Date()) -> TimeInterval? { var boot = timeval() var size = MemoryLayout.size @@ -71,15 +76,24 @@ public enum BackupHealth { return now.timeIntervalSince(booted) } - /// Pojedyncza rzecz, ktora poszla nie tak. Tekst jest gotowy do pokazania - /// uzytkownikowi - to jedyna forma, w jakiej ktokolwiek to zobaczy. + /// A single thing that went wrong. The text is ready to be shown to the + /// user - it is the only form in which anyone will see it. public struct Problem: Equatable { public var summary: String public var detail: String - - public init(summary: String, detail: String) { + /// Stable, language-independent name of the problem. + /// + /// `summary` follows the UI language, so it cannot identify the problem: + /// `HealthAlert` recognizes "the same failure" by this field, and the alert + /// state written by a Polish-language run must still match an + /// English-language run. Defaults to `summary` for problems built outside + /// `BackupHealth` (tests), which keeps the old behaviour for them. + public var code: String + + public init(summary: String, detail: String, code: String? = nil) { self.summary = summary self.detail = detail + self.code = code ?? summary } } @@ -87,17 +101,19 @@ public enum BackupHealth { public var problems: [Problem] public var lastSuccess: Date? public var lastAttempt: Date? - /// Czy licznik udanych kopii w ogole dalo sie ODCZYTAC. + /// Whether the counter of successful backups could be READ at all. /// - /// Bez tego pola `lastSuccess == nil` znaczylo dwie zupelnie rozne rzeczy: - /// "Time Machine nie zrobil ani jednej kopii" i "nie mamy dostepu do - /// pliku, wiec nic nie wiemy". Kto czyta ten raport (np. panel GUI), musi - /// je rozroznic, zeby nie pokazac braku wiedzy jako faktu. + /// Without this field `lastSuccess == nil` meant two completely different + /// things: "Time Machine has not made a single backup" and "we have no + /// access to the file, so we know nothing". Whoever reads this report + /// (e.g. the GUI panel) has to tell them apart, so as not to present a + /// lack of knowledge as a fact. public var preferencesReadable: Bool - /// Stany "jeszcze niegotowe" odlozone na okres rozruchu - patrz - /// `startupGraceMinutes`. NIE sa awaria i NIE ida do powiadomienia, ale - /// nie znikaja: `backup-health` wypisuje je osobno, zeby czlowiek pytajacy - /// tuz po starcie widzial, na co jeszcze czekamy. + /// "Not ready yet" states deferred for the startup grace period - see + /// `startupGraceMinutes`. They are NOT a failure and do NOT go to a + /// notification, but they do not disappear: `backup-health` prints them + /// separately, so that a person asking right after startup sees what we + /// are still waiting for. public var deferred: [Problem] public var healthy: Bool { problems.isEmpty } @@ -113,22 +129,23 @@ public enum BackupHealth { } } - // MARK: - Odczyt licznika udanych kopii + // MARK: - Reading the counter of successful backups - /// Daty z preferencji Time Machine dla celu pod wskazanym punktem - /// montowania. Czysta funkcja - bierze juz odczytany slownik, zeby dalo sie - /// ja sprawdzic testem bez pliku systemowego i bez Time Machine. + /// Dates from the Time Machine preferences for the destination at the given + /// mount point. Pure function - takes an already read dictionary, so it can + /// be tested without the system file and without Time Machine. /// - /// `result` to pole `RESULT` z tego samego bloku: 0 znaczy, ze ostatni - /// przebieg skonczyl sie dobrze, cokolwiek innego - ze nie. + /// `result` is the `RESULT` field from the same block: 0 means the last run + /// ended well, anything else - that it did not. public static func dates( inPreferences plist: [String: Any], volumeNamed volumeName: String ) -> (lastSuccess: Date?, lastAttempt: Date?, result: Int?) { guard let destinations = plist["Destinations"] as? [[String: Any]] else { return (nil, nil, nil) } - // Cel wybieramy po nazwie wolumenu, nie po indeksie 0 - Mac moze miec - // zarejestrowanych kilka celow Time Machine, a nas obchodzi wylacznie ten. + // We pick the destination by volume name, not by index 0 - a Mac can have + // several Time Machine destinations registered, and we care only about + // this one. let destination = destinations.first { ($0["LastKnownVolumeName"] as? String) == volumeName } ?? (destinations.count == 1 ? destinations[0] : nil) @@ -141,9 +158,9 @@ public enum BackupHealth { return (snapshots.max(), attempts.max(), result) } - /// Ocena stanu. Czysta funkcja - kazde wejscie podaje sie wprost, wiec - /// wstrzykniecie ZNANEJ ZLEJ probki (stara data, niezerowy RESULT, martwe - /// montowanie) jest jednym wywolaniem w tescie, a nie psuciem produkcji. + /// Assessment of the state. Pure function - every input is passed in + /// directly, so injecting a KNOWN BAD sample (an old date, a non-zero + /// RESULT, a dead mount) is one call in a test, not breaking production. public static func evaluate( lastSuccess: Date?, lastAttempt: Date?, @@ -158,255 +175,287 @@ public enum BackupHealth { driveFreeBytes: UInt64? = nil, localFreeGB: Int? = nil, imageDeadErrno: Int32? = nil, - // Obraz JEST w tablicy montowan, ale sonda czytelnosci nie wrocila. - // Chodzi w parze z `attached: nil` i sluzy WYLACZNIE do tego, by - // powiedziec czlowiekowi, czego dokladnie nie wiemy - decyzja jest ta sama. + // The image IS in the mount table, but the readability probe did not + // return. Goes together with `attached: nil` and serves ONLY to tell the + // person exactly what we do not know - the decision is the same. imageProbeTimedOut: Bool = false, maxAgeHours: Double = BackupHealth.maxAgeHours, - // Czy trwa okres rozruchu po starcie systemu - patrz `startupGraceMinutes`. + // Whether the startup grace period is in progress - see `startupGraceMinutes`. withinStartupGrace: Bool = false, - // Czy Time Machine WLASNIE wykonuje przebieg (`tmutil status`). + // Whether Time Machine is RIGHT NOW performing a run (`tmutil status`). backupRunning: Bool? = nil ) -> Report { var problems: [Problem] = [] var deferred: [Problem] = [] - // Stan urzadzenia, ktory tuz po starcie jest NORMALNY, bo jeszcze nic nie - // zdazylo wstac. Po okresie rozruchu to zwykla awaria. + // A device state that is NORMAL right after startup, because nothing has + // had time to come up yet. After the grace period it is an ordinary + // failure. func notReadyYet(_ problem: Problem) { if withinStartupGrace { deferred.append(problem) } else { problems.append(problem) } } - // Kolejnosc od przyczyny do skutku: jesli montowanie lezy, wiek kopii - // i tak bedzie rosl, ale to montowanie trzeba naprawic. + // Order from cause to effect: if the mount is down, the backup age will + // grow anyway, but it is the mount that has to be fixed. // - // `mounted` i `attached` sa TROJSTANOWE z tego samego powodu, co - // `destinationRegistered` nizej: odczyt tablicy montowan moze sie nie - // udac, a wtedy nie wiemy ani ze jest, ani ze nie ma. Zlanie tego - // w `Bool` konczylo sie dwojako i oba sposoby byly zle - `?? false` - // dawalo alarm o odmontowanym Dysku, ktory moze byc zamontowany, - // a `!= .detached` dawalo CISZE o obrazie, o ktorym nie wiemy nic. + // `mounted` and `attached` are THREE-STATE for the same reason as + // `destinationRegistered` below: reading the mount table can fail, and + // then we know neither that it is there nor that it is not. Collapsing + // that into a `Bool` ended in one of two ways and both were wrong - + // `?? false` produced an alarm about an unmounted Drive that may be + // mounted, and `!= .detached` produced SILENCE about an image we know + // nothing about. switch mounted { case .some(true): break case .some(false): notReadyYet( Problem( - summary: "Montowanie Google Drive nie dziala", - detail: "Bez niego obraz backupu jest nieosiagalny i Time Machine nie ma gdzie pisac.")) + summary: L10n.tr("Google Drive mount is not working"), + detail: L10n.tr( + "Without it the backup image is unreachable and Time Machine has nowhere to write."), + code: "drive-not-mounted")) case .none: problems.append( Problem( - summary: "Nie wiadomo, czy montowanie Google Drive dziala", - detail: - "Nie udalo sie odczytac tablicy montowan. To nie znaczy, ze Dysk jest odmontowany - znaczy, ze nikt tego nie sprawdzil. Bez tej odpowiedzi nie da sie stwierdzic, czy kopie maja gdzie powstawac." - )) + summary: L10n.tr("Unknown whether the Google Drive mount is working"), + detail: L10n.tr( + "Could not read the mount table. That does not mean the Drive is unmounted - it means nobody has checked. Without this answer there is no way to tell whether backups have anywhere to go." + ), + code: "drive-mount-unknown")) } switch attached { case .some(true): if let errno = imageDeadErrno { - // Podpiety, ale martwy - stan, ktory do 22 wrz 2026 nie istnial dla - // zadnego czujnika i przez to trwal 15 godzin. Patrz `ImageProbe`. + // Attached but dead - a state that until 22 Sep 2026 did not exist for + // any sensor and therefore lasted 15 hours. See `ImageProbe`. problems.append( Problem( - summary: "Obraz backupu jest podpiety, ale MARTWY (errno \(errno))", - detail: - "Urzadzenie obrazu przestalo oddawac dane - Time Machine widzi to jako odlaczony dysk. " - + "Naprawa: cloudmachine-agent attach-image (odpina na sile i podpina na nowo).")) + summary: L10n.tr("The backup image is attached, but DEAD (errno %@)", "\(errno)"), + detail: L10n.tr( + "The image device stopped returning data - Time Machine sees it as a disconnected disk. Fix: cloudmachine-agent attach-image (force-detaches and attaches again)." + ), + code: "image-dead")) } case .some(false): notReadyYet( Problem( - summary: "Obraz backupu nie jest podpiety", - detail: "Time Machine nie widzi celu \(BackupImageService.targetPath.path).")) + summary: L10n.tr("The backup image is not attached"), + detail: L10n.tr( + "Time Machine cannot see the destination %@.", BackupImageService.targetPath.path), + code: "image-detached")) case .none: - // TA cisza. Do 23.09.2026 wolajacy przekazywal tu `attachment != - // .detached`, wiec nowy przypadek `.unknown` ("tablicy montowan nie - // udalo sie odczytac") wpadal na `true` - czyli "podpiety". Czujka, - // ktorej JEDYNYM zadaniem jest nie twierdzic rzeczy, ktorych nie wie, - // milczala o stanie, ktorego nie znala. Komunikat musi byc INNY niz - // przy realnym odpieciu: "nie jest podpiety" wysyla czlowieka do - // podpinania obrazu, ktory moze byc podpiety poprawnie. - // Dwie przyczyny "nie wiem" i DWA rozne komunikaty, bo wysylaja czlowieka - // w dwa rozne miejsca. Trzeci moment, w ktorym to samo rozroznienie - // ratuje ten raport - patrz `mounted` wyzej i `destinationRegistered` - // nizej. + // THAT silence. Until 23.09.2026 the caller passed `attachment != + // .detached` here, so the new `.unknown` case ("the mount table could + // not be read") fell into `true` - i.e. "attached". The watchdog, whose + // ONLY job is not to claim things it does not know, stayed silent about + // a state it did not know. The message must be DIFFERENT from a real + // detachment: "is not attached" sends the person off to attach an image + // that may be attached correctly. + // Two causes of "I do not know" and TWO different messages, because they + // send the person to two different places. The third point at which the + // same distinction saves this report - see `mounted` above and + // `destinationRegistered` below. if imageProbeTimedOut { problems.append( Problem( - summary: "Nie wiadomo, czy obraz backupu oddaje dane", - detail: - "Obraz \(BackupImageService.targetPath.path) figuruje w tablicy montowan, ale sonda czytelnosci nie odpowiedziala w \(Int(ImageProbe.probeTimeout)) s - tak zachowuje sie odczyt zablokowany na martwym montowaniu FUSE-T. To NIE jest dowod, ze obraz jest martwy, wiec NIE odpinaj go na sile: `attach-image` swiadomie nic wtedy nie robi, bo odpiecie zywego urzadzenia porzuca dane czekajace na wysylke. Sprawdz najpierw, czy rclone odpowiada (cloudmachine-agent drive-status) i czy agent gdrive-buffer zyje." - )) + summary: L10n.tr("Unknown whether the backup image returns data"), + detail: L10n.tr( + "The image %@ is listed in the mount table, but the readability probe did not answer within %@ s - that is how a read blocked on a dead FUSE-T mount behaves. This is NOT proof that the image is dead, so do NOT force-detach it: `attach-image` deliberately does nothing in that case, because detaching a live device abandons data waiting to be uploaded. First check whether rclone responds (cloudmachine-agent drive-status) and whether the gdrive-buffer agent is alive.", + BackupImageService.targetPath.path, "\(Int(ImageProbe.probeTimeout))"), + code: "image-probe-timed-out")) } else { problems.append( Problem( - summary: "Nie wiadomo, czy obraz backupu jest podpiety", - detail: - "Nie udalo sie odczytac tablicy montowan, wiec stan obrazu \(BackupImageService.targetPath.path) jest NIEZNANY. Nie podpinaj go na oslepe - najpierw sprawdz, czy `mount` w ogole odpowiada (przy martwym montowaniu FUSE-T potrafi wisiec)." - )) + summary: L10n.tr("Unknown whether the backup image is attached"), + detail: L10n.tr( + "Could not read the mount table, so the state of the image %@ is UNKNOWN. Do not attach it blindly - first check whether `mount` responds at all (with a dead FUSE-T mount it can hang).", + BackupImageService.targetPath.path), + code: "image-attachment-unknown")) } } - // `nil` to NIE to samo co `false`. Od 23.09.2026 `tmutil` ma limit czasu - // (patrz `TimeMachineStatus.commandTimeout`), wiec przy martwym montowaniu - // czujka wraca z brakiem odpowiedzi zamiast wisiec. Brak odpowiedzi jest - // AWARIA - ale inna niz przestawiony cel, i musi brzmiec inaczej, zeby nie - // wyslac czlowieka do przestawiania czegos, co jest ustawione dobrze. + // `nil` is NOT the same as `false`. Since 23.09.2026 `tmutil` has a time + // limit (see `TimeMachineStatus.commandTimeout`), so with a dead mount the + // watchdog comes back with no answer instead of hanging. No answer is a + // FAILURE - but a different one from a changed destination, and it has to + // sound different, so as not to send the person off to change something + // that is set correctly. switch destinationRegistered { case .some(true): break case .some(false): notReadyYet( Problem( - summary: "Time Machine nie wskazuje na CloudMachine", - detail: "Cel backupu zostal przestawiony albo wyrejestrowany - kopie nie powstaja.")) + summary: L10n.tr("Time Machine does not point to CloudMachine"), + detail: L10n.tr( + "The backup destination was changed or unregistered - backups are not being made."), + code: "destination-not-registered")) case .none: problems.append( Problem( - summary: "tmutil nie odpowiada - nie wiadomo, gdzie idzie backup", - detail: - "Odczyt celu Time Machine nie wrocil w \(Int(TimeMachineStatus.commandTimeout)) s. Tak zachowuje sie tmutil zablokowany na martwym montowaniu Google Drive. Naprawa: cloudmachine-agent attach-image, a gdy to nie pomoze - restart agenta gdrive-buffer." - )) + summary: L10n.tr("tmutil is not responding - unknown where the backup goes"), + detail: L10n.tr( + "Reading the Time Machine destination did not return within %@ s. That is how tmutil behaves when blocked on a dead Google Drive mount. Fix: cloudmachine-agent attach-image, and if that does not help - restart the gdrive-buffer agent.", + "\(Int(TimeMachineStatus.commandTimeout))"), + code: "tmutil-no-answer")) } - // TO jest licznik, ktory rosnie wylacznie przy sukcesie. + // THIS is the counter that grows only on success. if let lastSuccess { let age = now.timeIntervalSince(lastSuccess) if age > maxAgeHours * 3600 { problems.append( Problem( - summary: "Brak udanej kopii od \(formatAge(age))", - detail: - "Ostatnia ZAKONCZONA kopia: \(stamp(lastSuccess)). Cykl jest godzinowy, wiec to \(max(1, Int(age / 3600))) pominietych przebiegow." - )) + summary: L10n.tr("No successful backup for %@", formatAge(age)), + detail: L10n.tr( + "Last COMPLETED backup: %@. The cycle is hourly, so that is %@ missed runs.", + stamp(lastSuccess), "\(max(1, Int(age / 3600)))"), + code: "no-recent-backup")) } } else { problems.append( Problem( - summary: "Nie ma ANI JEDNEJ udanej kopii", - detail: - "Preferencje Time Machine nie zawieraja zadnej daty zakonczonego backupu dla tego celu." - )) + summary: L10n.tr("There is NOT A SINGLE successful backup"), + detail: L10n.tr( + "The Time Machine preferences contain no date of a completed backup for this destination." + ), + code: "no-backup-ever")) } - // Proba bez sukcesu po niej to backup, ktory ruszyl i padl. Sam wiek - // ostatniego sukcesu tego nie pokaze, dopoki nie przekroczy progu. + // An attempt with no success after it is a backup that started and failed. + // The age of the last success alone will not show that until it crosses + // the threshold. // - // Wyjatek: przebieg, ktory WCIAZ TRWA. Po restarcie Time Machine potrafi - // przejsc caly dysk (01.10.2026: 882 GB, 3,6 mln plikow, ~4 h), a czujka - // meldowala wtedy po godzinie "proba nie skonczyla sie kopia" o probie, - // ktora po prostu jeszcze sie nie skonczyla. Przebieg zawieszony na - // zawsze i tak zlapie prog wieku ostatniej udanej kopii wyzej. + // Exception: a run that is STILL IN PROGRESS. After a restart Time Machine + // can walk the whole disk (01.10.2026: 882 GB, 3.6 million files, ~4 h), + // and after an hour the watchdog then reported "the attempt did not end + // in a backup" about an attempt that simply had not finished yet. A run + // stuck forever will be caught by the last-successful-backup age + // threshold above anyway. if let lastAttempt, let lastSuccess, lastAttempt > lastSuccess, now.timeIntervalSince(lastAttempt) > 3600, backupRunning != true { problems.append( Problem( - summary: "Ostatnia proba backupu nie skonczyla sie kopia", - detail: - "Proba \(stamp(lastAttempt)) jest nowsza niz ostatnia udana kopia \(stamp(lastSuccess))." - )) + summary: L10n.tr("The last backup attempt did not end in a backup"), + detail: L10n.tr( + "The attempt at %@ is newer than the last successful backup at %@.", + stamp(lastAttempt), stamp(lastSuccess)), + code: "last-attempt-failed")) } if let result, result != 0 { problems.append( Problem( - summary: "Time Machine zglasza blad ostatniego przebiegu (RESULT=\(result))", - detail: "Niezerowy RESULT w preferencjach Time Machine znaczy, ze przebieg sie nie udal.") - ) + summary: L10n.tr( + "Time Machine reports an error in the last run (RESULT=%@)", "\(result)"), + detail: L10n.tr( + "A non-zero RESULT in the Time Machine preferences means the run did not succeed."), + code: "time-machine-result")) } if erroredFiles > 0 { problems.append( Problem( - summary: "rclone nie wyslal \(erroredFiles) plikow", - detail: - "Te pasma obrazu istnieja tylko lokalnie. Kopia na Google Drive jest NIEPELNA i moze sie nie otworzyc." - )) + summary: L10n.tr("rclone failed to upload %@ files", "\(erroredFiles)"), + detail: L10n.tr( + "These image bands exist only locally. The backup on Google Drive is INCOMPLETE and may not open." + ), + code: "upload-errors")) } if outOfSpace { problems.append( Problem( - summary: "Bufor pelny samymi niewyslanymi danymi", - detail: "rclone nie ma juz czego usunac z bufora - wysylka nie nadaza albo stoi.")) + summary: L10n.tr("Buffer full of nothing but unsent data"), + detail: L10n.tr( + "rclone has nothing left to evict from the buffer - the upload cannot keep up or has stalled." + ), + code: "buffer-out-of-space")) } - // `mounted == true`, nie `mounted != false`: gdy montowania nie ma ALBO - // nie wiadomo, czy jest, mowia o tym juz twardsze komunikaty wyzej, a - // drugi komunikat o tym samym tylko rozmywa ten pierwszy. + // `mounted == true`, not `mounted != false`: when there is no mount OR it + // is unknown whether there is one, harder messages above already say so, + // and a second message about the same thing only dilutes the first. if !queueReadable && mounted == true { problems.append( Problem( - summary: "Interfejs sterujacy rclone nie odpowiada", - detail: - "Bez niego nie da sie sprawdzic, czy cokolwiek dolecialo na Dysk - dozorca bufora jest wtedy slepy." - )) + summary: L10n.tr("The rclone control interface is not responding"), + detail: L10n.tr( + "Without it there is no way to check whether anything reached the Drive - the buffer guard is blind then." + ), + code: "rclone-rc-no-answer")) } - // Miejsce na Dysku. Wyczerpanie go jest dla rclone bledem FATALNYM, wiec - // montowanie znika i Time Machine traci cel - o tym trzeba wiedziec - // WCZESNIEJ, a nie z awarii. Prog liczony w cyklach, nie w procentach: - // przy przyroscie ~600 MB na godzine 30 GB to okolo dwoch tygodni zapasu. + // Space on the Drive. Running out of it is a FATAL error for rclone, so + // the mount disappears and Time Machine loses its destination - this has + // to be known EARLIER, not from a failure. The threshold is counted in + // cycles, not in percent: at a growth of ~600 MB per hour, 30 GB is about + // two weeks of headroom. if let driveFreeBytes { let freeGB = Int(driveFreeBytes / 1_073_741_824) if freeGB < driveFreeWarningGB { problems.append( Problem( - summary: "Konczy sie miejsce na Google Drive (\(freeGB) GB)", - detail: - "Po wyczerpaniu rclone konczy prace z bledem storageQuotaExceeded, montowanie znika i backupy przestaja powstawac. Przy przyroscie ~600 MB na cykl godzinowy to okolo \(max(1, freeGB * 1024 / 600 / 24)) dni." - )) + summary: L10n.tr("Google Drive is running out of space (%@ GB)", "\(freeGB)"), + detail: L10n.tr( + "Once it runs out, rclone exits with a storageQuotaExceeded error, the mount disappears and backups stop being made. At a growth of ~600 MB per hourly cycle that is about %@ days.", + "\(max(1, freeGB * 1024 / 600 / 24))"), + code: "drive-low-space")) } } if let localFreeGB, localFreeGB < localFreeWarningGB { problems.append( Problem( - summary: "Konczy sie miejsce na dysku Maca (\(localFreeGB) GB)", - detail: - "Bufor wysylki lezy na tym dysku. Gdy sie zapelni, dozorca wstrzyma Time Machine, a przy calkowitym braku miejsca rclone nie ma gdzie odlozyc danych czekajacych na wyslanie." - )) + summary: L10n.tr("The Mac's disk is running out of space (%@ GB)", "\(localFreeGB)"), + detail: L10n.tr( + "The upload buffer lives on this disk. When it fills up, the guard pauses Time Machine, and with no space left at all rclone has nowhere to put data waiting to be uploaded." + ), + code: "local-low-space")) } return Report( problems: problems, lastSuccess: lastSuccess, lastAttempt: lastAttempt, deferred: deferred) } - /// Ponizej tylu GB wolnych na Google Drive zglaszamy problem. + /// Below this many GB free on Google Drive we report a problem. public static let driveFreeWarningGB = 30 - /// Ponizej tylu GB wolnych lokalnie zglaszamy problem. Wyzej niz prog pauzy - /// dozorcy bufora - czujka ma ostrzegac, zanim dozorca zacznie hamowac. + /// Below this many GB free locally we report a problem. Higher than the + /// buffer guard's pause threshold - the watchdog should warn before the + /// guard starts braking. public static let localFreeWarningGB = 120 - // MARK: - Odczyt na zywo + // MARK: - Live reading - /// Czy plik, z ktorego czytamy historie kopii, DA SIE PRZECZYTAC. + /// Whether the file we read the backup history from CAN BE READ. /// - /// To jest jednoczesnie jedyna uczciwa odpowiedz na pytanie "czy mamy Pelny - /// dostep do dysku": TCC nie ma interfejsu do zapytania o uprawnienie, wiec - /// sprawdza sie je PROBUJAC. + /// This is at the same time the only honest answer to the question "do we + /// have Full Disk Access": TCC has no interface for asking about the + /// permission, so it is checked by TRYING. /// - /// Interfejs robil to do 25.09.2026 przez - /// `FileManager.isReadableFile(atPath:)` na - /// `~/Library/Application Support/com.apple.TCC`. Dwa bledy w jednej linii: - /// to KATALOG, a nie plik z historia kopii, a `isReadableFile` sprowadza sie - /// do `access(R_OK)`, ktory patrzy tylko na prawa POSIX i o TCC nie wie nic. - /// Odpowiedz wychodzila wiec twierdzaca niezaleznie od stanu uprawnien - - /// a panel mowil "dostep jest" w chwili, w ktorej czujka nie mogla odczytac - /// ani jednej daty kopii. Czlowiek szukal potem awarii wszedzie poza - /// miejscem, w ktorym siedziala. + /// Until 25.09.2026 the interface did this via + /// `FileManager.isReadableFile(atPath:)` on + /// `~/Library/Application Support/com.apple.TCC`. Two bugs in one line: that + /// is a DIRECTORY, not the file with the backup history, and + /// `isReadableFile` boils down to `access(R_OK)`, which looks only at POSIX + /// permissions and knows nothing about TCC. So the answer came out + /// affirmative regardless of the permission state - and the panel said + /// "access granted" at a moment when the watchdog could not read a single + /// backup date. The person then looked for the failure everywhere except + /// where it was. /// - /// `preferencesFile` podmienialny z tego samego powodu, co w `currentReport`. + /// `preferencesFile` is replaceable for the same reason as in `currentReport`. public static func preferencesReadable( preferencesFile: String = BackupHealth.preferencesPath ) -> Bool { (try? Data(contentsOf: URL(fileURLWithPath: preferencesFile))) != nil } - /// `preferencesFile` da sie podmienic, zeby dalo sie PRZEJSC CALA sciezke - /// czujki na znanej zlej probce - odczyt pliku, parsowanie, wybor celu, - /// ocena, zgloszenie, kod wyjscia - bez psucia dzialajacego backupu. Test - /// jednostkowy na `evaluate` nie pokrywa tego, co dzieje sie miedzy plikiem - /// a decyzja, a wlasnie tam siedzialy w tym projekcie ciche awarie. + /// `preferencesFile` can be replaced so that the WHOLE watchdog path can be + /// run on a known bad sample - reading the file, parsing, choosing the + /// destination, assessment, reporting, exit code - without breaking the + /// working backup. A unit test of `evaluate` does not cover what happens + /// between the file and the decision, and that is exactly where the silent + /// failures in this project were. public static func currentReport( now: Date = Date(), maxAgeHours: Double = BackupHealth.maxAgeHours, preferencesFile: String = BackupHealth.preferencesPath @@ -422,10 +471,11 @@ public enum BackupHealth { return Report( problems: [ Problem( - summary: "Nie da sie odczytac preferencji Time Machine", - detail: - "\(preferencesFile) jest nieczytelny - najczesciej brak Pelnego dostepu do dysku. Bez tego pliku NIE WIADOMO, kiedy ostatnio powstala kopia, wiec traktujemy to jak awarie, a nie jak brak problemu." - ) + summary: L10n.tr("Cannot read the Time Machine preferences"), + detail: L10n.tr( + "%@ is unreadable - most often Full Disk Access is missing. Without this file it is UNKNOWN when the last backup was made, so we treat it as a failure, not as the absence of a problem.", + preferencesFile), + code: "preferences-unreadable") ], lastSuccess: nil, lastAttempt: nil, preferencesReadable: false) } @@ -433,34 +483,35 @@ public enum BackupHealth { inPreferences: plist, volumeNamed: BackupImageService.volumeName) let stats = await DriveBufferService.queueStats() - // `attachmentReading()`, nie `attachment()`: sonda czytelnosci ma limit - // czasu i po jego przekroczeniu oddaje `.unknown`. Czujka DOKANCZA wtedy - // przebieg i zglasza brak wiedzy - to jest cala roznica wzgledem stanu do - // 26.09.2026, w ktorym ten odczyt nie mial limitu, a `StartInterval 1800` - // bez `KeepAlive` znaczy, ze launchd NIE uruchomi drugiej instancji, - // dopoki zyje pierwsza. Jedno zawieszenie uciszalo wiec czujke NA STALE, - // a cisza w tym systemie wyglada identycznie jak zdrowie. + // `attachmentReading()`, not `attachment()`: the readability probe has a + // time limit and, once it is exceeded, returns `.unknown`. The watchdog + // then FINISHES the run and reports the lack of knowledge - that is the + // whole difference compared with the state until 26.09.2026, in which + // this read had no limit, and `StartInterval 1800` without `KeepAlive` + // means launchd will NOT start a second instance while the first one is + // alive. A single hang therefore silenced the watchdog PERMANENTLY, and + // silence in this system looks identical to health. let reading = await BackupImageService.attachmentReading() let attachment = reading.attachment var deadErrno: Int32? if case .dead(let errno) = attachment { deadErrno = errno } - // Trzy stany, tak samo jak przy celu Time Machine nizej. + // Three states, just like for the Time Machine destination below. // - // Wyliczamy je z `attachment`, a nie drugim wywolaniem - // `BackupImageService.attachedState()` - ten sam odczyt tablicy montowan - // dal juz `deadErrno` powyzej, a dwa osobne odczyty moglyby sie - // rozjechac i dac raport opisujacy dwie rozne chwile. + // We derive them from `attachment`, not from a second call to + // `BackupImageService.attachedState()` - the same mount-table read already + // gave `deadErrno` above, and two separate reads could diverge and + // produce a report describing two different moments. let attached: Bool? switch attachment { - // `.dead` to nadal PODPIETY obraz - tylko martwy, i to osobny problem - // zglaszany przez `imageDeadErrno`. + // `.dead` is still an ATTACHED image - just a dead one, and that is a + // separate problem reported via `imageDeadErrno`. case .attached, .dead: attached = true case .detached: attached = false case .unknown: attached = nil } - // Trzy stany, nie dwa: `noAnswer` (zawieszony tmutil) nie ma prawa - // udawac "cel przestawiony" - patrz `evaluate`. + // Three states, not two: `noAnswer` (a hung tmutil) has no right to + // pretend to be "destination changed" - see `evaluate`. let registered: Bool? switch await TimeMachineStatus.destinationReading() { case .mountPoint(let path): registered = (path == BackupImageService.targetPath.path) @@ -468,8 +519,8 @@ public enum BackupHealth { case .noAnswer: registered = nil } - // Pomiar wolnego miejsca moze sie NIE UDAC (statfs zwraca blad) i wtedy - // `freeGB()` oddaje `nil`, a nie zmyslone zero - patrz komentarz przy niej. + // Measuring free space can FAIL (statfs returns an error), and then + // `freeGB()` returns `nil`, not a made-up zero - see the comment on it. let localFree = BufferGuardService.freeGB() var report = evaluate( @@ -477,25 +528,26 @@ public enum BackupHealth { lastAttempt: lastAttempt, result: result, now: now, - // `mountedState()`, a NIE `isMounted` - to drugie jest - // `mountedState() ?? false`, czyli zamienia "nie wiem" w "nie dziala" - // i kaze czlowiekowi naprawiac montowanie, ktore moze byc sprawne. + // `mountedState()`, and NOT `isMounted` - the latter is + // `mountedState() ?? false`, i.e. it turns "I do not know" into "not + // working" and tells the person to fix a mount that may be fine. mounted: DriveBufferService.mountedState(), attached: attached, destinationRegistered: registered, erroredFiles: stats?.erroredFiles ?? 0, outOfSpace: stats?.outOfSpace ?? false, queueReadable: stats != nil, - // Nieczytelna pojemnosc Dysku NIE jest tu osobnym alarmem: gdy rclone - // nie odpowiada, mowia o tym juz twardsze sygnaly powyzej, a drugi - // komunikat o tym samym tylko rozmywa ten pierwszy. + // An unreadable Drive quota is NOT a separate alarm here: when rclone + // does not respond, harder signals above already say so, and a second + // message about the same thing only dilutes the first. driveFreeBytes: (await DriveBufferService.remoteQuota())?.free, localFreeGB: localFree, imageDeadErrno: deadErrno, imageProbeTimedOut: reading.probeTimedOut, maxAgeHours: maxAgeHours, - // Zegar RZECZYWISTY, nie `now`: testy podstawiaja `now` z przeszlosci, - // a uptime liczony od niego wychodzilby ujemny, czyli "trwa rozruch". + // The REAL clock, not `now`: tests substitute a `now` from the past, and + // uptime computed from it would come out negative, i.e. "startup in + // progress". withinStartupGrace: (systemUptime() ?? .infinity) < startupGraceMinutes * 60, backupRunning: await TimeMachineStatus.runningState()) @@ -503,40 +555,42 @@ public enum BackupHealth { return report } - /// Problem zglaszany, gdy pomiaru wolnego miejsca NIE DA SIE wykonac. + /// Problem reported when the free-space measurement CANNOT be made. /// - /// `evaluate` traktuje `localFreeGB: nil` jako "nie pytano" (taki jest jego - /// kontrakt od poczatku i opiera sie na nim kilkanascie testow), ale - /// `currentReport` WIE, ze pytalo i nie wyszlo. To osobna awaria: dozorca - /// bufora podejmuje decyzje o wstrzymaniu Time Machine wlasnie na tej - /// liczbie, wiec gdy jej nie ma, nie chroni juz dysku przed zapelnieniem. + /// `evaluate` treats `localFreeGB: nil` as "not asked" (that has been its + /// contract from the start, and a dozen or so tests rely on it), but + /// `currentReport` KNOWS it asked and it did not work. That is a separate + /// failure: the buffer guard decides on pausing Time Machine precisely on + /// this number, so when it is missing, it no longer protects the disk from + /// filling up. /// - /// Wydzielone z `currentReport()` WYLACZNIE po to, zeby dalo sie sprawdzic - /// testem: `currentReport()` dotyka rclone, tmutil i hdiutil, wiec ta galaz - /// bylaby inaczej niesprawdzalna - a galaz "nie wiem", ktorej nikt nie - /// sprawdzil, to dokladnie ten rodzaj martwego kodu, o ktory pytal przeglad - /// (kompilator ostrzegal wczesniej, ze `Int` porownywany do `nil` zawsze - /// daje falsz, czyli ze galaz jest martwa). + /// Split out of `currentReport()` SOLELY so that it can be tested: + /// `currentReport()` touches rclone, tmutil and hdiutil, so this branch + /// would otherwise be untestable - and an "I do not know" branch nobody has + /// checked is exactly the kind of dead code the review asked about (the + /// compiler warned earlier that an `Int` compared to `nil` always yields + /// false, i.e. that the branch was dead). static func unmeasuredLocalDiskProblems(localFreeGB: Int?) -> [Problem] { guard localFreeGB == nil else { return [] } return [ Problem( - summary: "Nie da sie zmierzyc wolnego miejsca na dysku Maca", - detail: - "statfs('/System/Volumes/Data') zwrocil blad. Dozorca bufora nie wstrzyma wtedy Time Machine przed zapelnieniem dysku, bo nie zna liczby, na ktorej opiera ta decyzje." - ) + summary: L10n.tr("Cannot measure free space on the Mac's disk"), + detail: L10n.tr( + "statfs('/System/Volumes/Data') returned an error. The buffer guard will then not pause Time Machine before the disk fills up, because it does not know the number it bases that decision on." + ), + code: "local-space-unmeasured") ] } - // MARK: - Formatowanie + // MARK: - Formatting - /// Wiek slowami. Minuty ponizej dwoch godzin - inaczej przy niskim progu - /// komunikat brzmi "Brak udanej kopii od 0 h", co nie znaczy nic. + /// Age in words. Minutes below two hours - otherwise, with a low threshold, + /// the message reads "No successful backup for 0 h", which means nothing. public static func formatAge(_ seconds: TimeInterval) -> String { let hours = Int(seconds / 3600) if hours < 2 { return "\(Int(seconds / 60)) min" } if hours < 48 { return "\(hours) h" } - return "\(hours / 24) dni" + return L10n.tr("%@ days", "\(hours / 24)") } public static func stamp(_ date: Date) -> String { diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index 4dad60d..8d51ce4 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -1,90 +1,98 @@ import Foundation -/// Obraz backupu lezacy na Google Drive - port `gdrive/create-image.sh`, -/// `attach-image.sh` i `verify-image.sh`. +/// The backup image living on Google Drive - a port of `gdrive/create-image.sh`, +/// `attach-image.sh` and `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. +/// Time Machine is handed an attached, ordinary APFS volume and does not know +/// that the image's bands live in the cloud. Thanks to that there is no network +/// file system in the write path - SMB is gone, and with it a whole class of +/// failures that plague network backups. public enum BackupImageService { - // MARK: - Sciezki + // MARK: - Paths - public static let imageName = "mac-studio" + /// Named after this Mac's Drive folder (`mac-studio` on installations that + /// predate per-Mac folders, where that is the image that already exists). + public static var imageName: String { DriveFolder.name } public static let volumeName = "CloudMachine" public static var imagePath: URL { DriveBufferService.mountPoint.appendingPathComponent("\(imageName).sparsebundle") } - /// Punkt montowania celu. + /// The destination's mount point. /// - /// `/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`. + /// `/Volumes` is a path Time Machine is sure to accept - and that is the only + /// reason it is here. The price: after an unclean detach the + /// `/Volumes/` directory is left behind orphaned and blocks the next + /// attach. It belongs to the user, but lives in root-owned `/Volumes`, so + /// `rmdir` refuses - an agent running as the user cannot clean up after + /// itself. The alternative (a directory wholly ours, self-healing) is + /// untested: it is unknown whether `tmutil setdestination` accepts a + /// destination outside `/Volumes`. public static var targetPath: URL { URL(fileURLWithPath: "/Volumes/\(volumeName)") } - /// 32 MB na pasmo, w sektorach po 512 B. Wybrane pomiarem - patrz - /// `cloudmachine-poc amplification` i tabela w `gdrive/README.md`. + /// 32 MB per band, in 512 B sectors. Chosen by measurement - see + /// `cloudmachine-poc amplification` and the table in `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. + /// Two forces pull in opposite directions. Google Drive lets through about + /// two operations per file per second, so small bands lengthen the first + /// upload. But every change dirties the whole band, so large bands multiply + /// the transfer on every increment - measured 768 MB at 64 MB versus 384 MB + /// at 8 MB for the same 300 MB of real change. 32 MB is the point where the + /// first upload stops being limited by the operation rate and starts being + /// limited by link bandwidth. /// - /// Dziala tylko przy tworzeniu obrazu - pozniej wymaga backupu od zera. + /// Only takes effect when the image is created - changing it later requires a + /// backup from scratch. public static let bandSectors = 65536 private static let fsckPath = "/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs" - // MARK: - Wzajemne wykluczenie + // MARK: - Mutual exclusion - /// Nazwa blokady, pod ktora chodza WSZYSTKIE operacje zmieniajace stan - /// obrazu: `create`, `attach`, `detach`, `verify`. + /// Name of the lock under which ALL operations that change the image's state + /// run: `create`, `attach`, `detach`, `verify`. /// - /// Do 23 wrzesnia 2026 nie wykluczaly sie nawzajem niczym - `CMLock` istnial, - /// ale `withCMLock` nie bylo wolane z ani jednego miejsca w repo. Realny - /// przebieg, ktory to ujawnil: `detach` czeka na drenaz (35 s przerwy na - /// zapelnienie kolejki + do 600 s na cisze, czyli okno do 10,5 minuty), - /// a agent `gdrive-attach` tyka co 900 s. Agent regularnie wchodzil w to - /// okno, widzial obraz jako odpiety - bo `hdiutil detach` juz przeszedl - - /// i podpinal go z powrotem w srodku cudzego odpinania. + /// Until 23 September 2026 they did not exclude each other at all - `CMLock` + /// existed, but `withCMLock` was not called from a single place in the repo. + /// The real run that exposed it: `detach` waits for the drain (35 s pause for + /// the queue to fill + up to 600 s for quiet, i.e. a window of up to 10.5 + /// minutes), and the `gdrive-attach` agent ticks every 900 s. The agent + /// regularly hit that window, saw the image as detached - because + /// `hdiutil detach` had already gone through - and attached it back in the + /// middle of someone else's detach. /// - /// Drugi wariant tego samego wyscigu: `purgeStaleDevices()` z tiku agenta - /// robi `hdiutil detach -force` na urzadzeniu, na ktorym akurat chodzi - /// `fsck_apfs` z `verify` - i `verify` meldowal "Obraz NIESPOJNY" o calym - /// backupie (patrz komentarz przy `verify`). + /// A second variant of the same race: `purgeStaleDevices()` from the agent's + /// tick runs `hdiutil detach -force` on the device that `fsck_apfs` from + /// `verify` is running on at that moment - and `verify` reported "Image + /// INCONSISTENT" about the whole backup (see the comment at `verify`). public static let lockName = "image" - /// Wynik operacji, ktora sie NIE WYDARZYLA, bo obraz zajmuje inna operacja. + /// Result of an operation that DID NOT HAPPEN, because another operation + /// holds the image. /// - /// `withCMLock` oddaje wtedy `nil` i to `nil` nie moze przejsc jako sukces: - /// `attach`, ktorego nie bylo, zameldowalby "Podpiete", a `detach`, ktorego - /// nie bylo - "wszystko wyslane na Google Drive". - private static func busyResult(_ what: String) -> CMActionResult { - CMLogger.log("\(what): blokade '\(lockName)' trzyma inna operacja na obrazie - nie robie nic") + /// `withCMLock` then returns `nil`, and that `nil` must not pass as success: + /// an `attach` that never happened would report "Attached", and a `detach` + /// that never happened - "everything uploaded to Google Drive". + /// + /// `operation` goes to the log, `displayName` to the person. + private static func busyResult(_ operation: String, displayName: String) -> CMActionResult { + CMLogger.log( + "\(operation): lock '\(lockName)' is held by another image operation - doing nothing") return CMActionResult( succeeded: false, - message: """ - \(what): inna operacja na obrazie jest w toku (tworzenie, podpinanie, \ - odpinanie albo sprawdzanie) - NIE zrobiono nic. Sprobuj za chwile. - """, - // Nie awaria, tylko "nie teraz" - patrz `CMActionResult.Disposition`. + message: L10n.tr( + "%@: another image operation is in progress (creating, attaching, detaching or verifying) - NOTHING was done. Try again shortly.", + displayName), + // Not a failure, just "not now" - see `CMActionResult.Disposition`. didNotRun: true) } - // MARK: - Stan + // MARK: - State public static var exists: Bool { var isDir: ObjCBool = false @@ -92,21 +100,22 @@ public enum BackupImageService { return ok && isDir.boolValue } - /// Czy wolumen figuruje w tablicy montowan. + /// Whether the volume is in the mount table. /// - /// UWAGA: to mowi tylko, ze `hdiutil` kiedys podpial obraz - NIE, ze obraz - /// oddaje dane. Martwe urzadzenie (patrz `ImageProbe`) siedzi w tej tablicy - /// tak samo jak zywe. Do pytania "czy Time Machine ma gdzie pisac" sluzy - /// `attachment`; `isAttached` zostaje tam, gdzie chodzi o samo odpiecie. + /// NOTE: this only says that `hdiutil` attached the image at some point - NOT + /// that the image returns data. A dead device (see `ImageProbe`) sits in that + /// table just like a live one. The question "does Time Machine have somewhere + /// to write" is answered by `attachment`; `isAttached` stays where only the + /// detach itself matters. public static var isAttached: Bool { attachedState() ?? false } - /// Jak `isAttached`, ale `nil` = tablicy montowan NIE UDALO SIE odczytac. + /// Like `isAttached`, but `nil` = the mount table COULD NOT be read. /// - /// Ta sama zmiana, co w `DriveBufferService.mountPoints()` i z tego samego - /// powodu: dotad szlo to przez `/sbin/mount` bez limitu czasu, a pyta o - /// wolumen, ktory bywa MARTWY - czyli dokladnie o ten, na ktorym taki odczyt - /// potrafi zawisnac. Teraz idzie przez tablice jadra, bez procesu i bez - /// dotykania systemu plikow. + /// The same change as in `DriveBufferService.mountPoints()` and for the same + /// reason: until now this went through `/sbin/mount` without a time limit, + /// and it asks about a volume that is sometimes DEAD - that is, exactly the + /// one on which such a read can hang. Now it goes through the kernel table, + /// without a process and without touching the file system. public static func attachedState() -> Bool? { guard let points = DriveBufferService.mountPoints() else { return nil } return points.contains(targetPath.path) @@ -115,53 +124,54 @@ public enum BackupImageService { public enum Attachment: Equatable, Sendable { case detached case attached - /// W tablicy montowan, ale odczyt pada z podanym `errno`. Time Machine - /// widzi ten stan jako "dysk odlaczony" i nie zrobi ani jednej kopii, - /// dopoki obraz nie zostanie odpiety i podpiety na nowo. + /// In the mount table, but reads fail with the given `errno`. Time Machine + /// sees this state as "disk disconnected" and will not make a single + /// backup until the image is detached and attached again. case dead(errno: Int32) - /// O stanie obrazu NIE WIADOMO nic. To nie jest `.detached`: `.detached` - /// to twierdzenie ("sprawdzilem, nie ma"), a tu nie bylo czego sprawdzic. + /// NOTHING is known about the image's state. This is not `.detached`: + /// `.detached` is a claim ("I checked, it is not there"), while here there + /// was nothing to check. /// - /// Dwie przyczyny, obie prowadzace do tej samej decyzji (wstrzymaj, nie - /// ruszaj obrazu): + /// Two causes, both leading to the same decision (hold off, do not touch + /// the image): /// - /// 1. Tablicy montowan nie udalo sie odczytac. Po przejsciu na - /// `getmntinfo(MNT_NOWAIT)` skrajnie malo prawdopodobne - to odczyt - /// z pamieci jadra, ktory nie ma jak zawisnac ani pojsc do sieci. - /// 2. Obraz JEST w tablicy montowan, ale sonda czytelnosci nie - /// odpowiedziala w `ImageProbe.probeTimeout` (od 26.09.2026 - wczesniej - /// nie odpowiadala w nieskonczonosc i zabierala ze soba wolajacego). + /// 1. The mount table could not be read. Since the switch to + /// `getmntinfo(MNT_NOWAIT)` extremely unlikely - it is a read from + /// kernel memory that has no way to hang or go to the network. + /// 2. The image IS in the mount table, but the readability probe did not + /// answer within `ImageProbe.probeTimeout` (since 26.09.2026 - before + /// that it did not answer forever and took the caller down with it). /// - /// Osobnego, piatego stanu na drugi przypadek NIE ma swiadomie: kazda - /// decyzja podejmowana na tym typie jest w obu przypadkach identyczna, - /// a rozdzielenie ich zachecaloby do rozjechania sie tych drog. Kto - /// pisze do czlowieka i musi podac przyczyne, bierze ja z + /// There is deliberately NO separate, fifth state for the second case: + /// every decision made on this type is identical in both cases, and + /// separating them would invite those paths to drift apart. Whoever writes + /// to a person and has to give the cause takes it from /// `attachmentReading()`. case unknown - /// Czy Time Machine ma gdzie pisac. `.unknown` swiadomie daje `false` - - /// to jest pytanie "czy MOGE na tym polegac", a na niewiadomej polegac - /// nie mozna. + /// Whether Time Machine has somewhere to write. `.unknown` deliberately + /// gives `false` - the question is "CAN I rely on this", and you cannot + /// rely on an unknown. public var isUsable: Bool { self == .attached } } - /// Stan podpiecia z uwzglednieniem tego, czy urzadzenie ZYJE. + /// Attachment state taking into account whether the device is ALIVE. /// - /// `async`, a nie wlasciwosc obliczana, odkad sonda czytelnosci ma limit - /// czasu: czekanie na nia nie moze blokowac watku wolajacego (patrz - /// `ImageProbe` - `@MainActor` panelu i czujka bez `KeepAlive` placily za to - /// zamrozonym interfejsem i cisza). + /// `async` rather than a computed property ever since the readability probe + /// got a time limit: waiting for it must not block the calling thread (see + /// `ImageProbe` - the panel's `@MainActor` and the monitor without + /// `KeepAlive` paid for that with a frozen interface and silence). public static func attachment() async -> Attachment { await attachmentReading().attachment } - /// Jak `attachment()`, ale mowi TEZ, czy "nie wiem" wzielo sie z sondy, - /// ktora nie odpowiedziala w czasie. + /// Like `attachment()`, but ALSO says whether "I do not know" came from a + /// probe that did not answer in time. /// - /// Dla decyzji ta roznica nie ma znaczenia (oba przypadki wstrzymuja), ale - /// dla KOMUNIKATU ma ogromne: "nie udalo sie odczytac tablicy montowan" kaze - /// czlowiekowi sprawdzic zupelnie co innego niz "obraz jest w tablicy, ale - /// odczyt z niego nie wraca". + /// For the decision the difference does not matter (both cases hold off), + /// but for the MESSAGE it matters a lot: "could not read the mount table" + /// tells a person to check something entirely different than "the image is + /// in the table, but reads from it do not come back". public static func attachmentReading() async -> (attachment: Attachment, probeTimedOut: Bool) { switch attachedState() { case .none: return (.unknown, false) @@ -170,8 +180,8 @@ public enum BackupImageService { switch await ImageProbe.probe(volume: targetPath) { case .dead(let errno): return (.dead(errno: errno), false) case .readable, .nothingToProbe: return (.attached, false) - // NIE `.dead`: `.dead` znaczy "urzadzenie odpowiedzialo bledem - // urzadzenia", a tu urzadzenie nie odpowiedzialo wcale. + // NOT `.dead`: `.dead` means "the device answered with a device error", + // while here the device did not answer at all. case .timedOut: return (.unknown, true) } } @@ -179,40 +189,41 @@ public enum BackupImageService { public static func describe(_ attachment: Attachment, probeTimedOut: Bool = false) -> String { switch attachment { - case .detached: return "BRAK" + case .detached: return L10n.tr("NOT ATTACHED") case .attached: return "OK (\(targetPath.path))" case .dead(let errno): - return - "MARTWY - w tablicy montowan, ale odczyt pada (errno \(errno)); attach-image podpina na nowo" + return L10n.tr( + "DEAD - in the mount table, but reads fail (errno %@); attach-image attaches it again", + "\(errno)") case .unknown where probeTimedOut: - return - "NIE WIADOMO - w tablicy montowan, ale sonda czytelnosci nie odpowiedziala w \(Int(ImageProbe.probeTimeout)) s" + return L10n.tr( + "UNKNOWN - in the mount table, but the readability probe did not answer within %@ s", + "\(Int(ImageProbe.probeTimeout))") case .unknown: - return "NIE WIADOMO - nie udalo sie odczytac tablicy montowan" + return L10n.tr("UNKNOWN - could not read the mount table") } } - /// Punkty montowania przegladanych migawek backupu. + /// Mount points of browsed backup snapshots. /// - /// Czysta wersja, zeby dalo sie ja sprawdzic testem bez montowania - /// czegokolwiek - wczesniej to samo wychodzilo z parsowania wydruku - /// `/sbin/mount` (`" on "` ... `" ("`), wiec nie bylo do czego podstawic - /// probki. + /// A pure version, so that it can be tested without mounting anything - + /// previously the same thing came from parsing `/sbin/mount` output + /// (`" on "` ... `" ("`), so there was nothing to substitute a sample for. static func browsedSnapshotMounts(_ points: [String]) -> [String] { points.filter { $0.hasPrefix("/Volumes/.timemachine/") } } - // MARK: - Zawieszone urzadzenia + // MARK: - Hung devices - /// Urzadzenia `/dev/diskN` podpiete pod wskazany obraz. + /// `/dev/diskN` devices attached to the given image. /// - /// 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. + /// After a forced detach a device can remain in the system as a zombie. + /// A new attach then ends with a "no mountable file systems" error, or - + /// worse - returns a dead handle on which `fsck_apfs` reports "failed to read + /// container superblock" with an all-zero UUID. It looks like a deleted + /// backup, but is only an unreadable device: an earlier version of the + /// pull-the-floor test concluded three times in a row, on that basis, that + /// data was lost which was in fact intact. public static func devicesForImage(_ image: URL = imagePath) async -> [String] { guard let result = try? await ProcessRunner.run("/usr/bin/hdiutil", ["info"], timeout: 60), result.succeeded @@ -220,8 +231,8 @@ public enum BackupImageService { 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`. + /// Pure version of the parser - `hdiutil info` groups entries into blocks, + /// where all `/dev/diskN` lines after an `image-path` line belong to it. public static func parseDevices(hdiutilInfo: String, imagePath: String) -> [String] { var devices: [String] = [] var currentImage: String? @@ -236,12 +247,12 @@ public enum BackupImageService { } 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. + // We want the parent device (/dev/disk7), not the partition + // (/dev/disk7s1) - detaching the parent takes the partitions with it. // - // UWAGA: kuszace `!device.contains("s")` jest BLEDNE, bo "disk" tez - // zawiera "s" i odrzuca wszystko. Sprawdzamy, czy po prefiksie zostaly - // same cyfry. + // NOTE: the tempting `!device.contains("s")` is WRONG, because "disk" + // contains an "s" too and it rejects everything. We check whether only + // digits are left after the prefix. let suffix = device.dropFirst("/dev/disk".count) guard !suffix.isEmpty, suffix.allSatisfy(\.isNumber) else { continue } if !devices.contains(device) { @@ -258,16 +269,16 @@ public enum BackupImageService { } } - // MARK: - Tworzenie + // MARK: - Creation - /// Tworzy obraz NA MIEJSCU, na zamontowanym Drive. + /// Creates the image IN PLACE, on the mounted 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. + /// Creating it locally and moving it gives an image that `hdiutil` later + /// does not open ("CBSDBackingStore::newProbe stat() failed"), even though + /// all files and bands are in place and readable. public static func create(sizeGB: Int) async -> CMActionResult { await withCMLock(lockName) { await createLocked(sizeGB: sizeGB) } - ?? busyResult("Tworzenie obrazu") + ?? busyResult("Image creation", displayName: L10n.tr("Image creation")) } private static func createLocked(sizeGB: Int) async -> CMActionResult { @@ -276,38 +287,37 @@ public enum BackupImageService { break case .some(false): return CMActionResult( - succeeded: false, message: "Drive nie jest zamontowany - najpierw uruchom bufor.") + succeeded: false, message: L10n.tr("Drive is not mounted - start the buffer first.")) case .none: - // Nie `isMounted`: tworzenie obrazu jest NIEODWRACALNE, wiec "nie wiem" - // nie moze tu przejsc jako "nie zamontowany" ani tym bardziej dalej. + // Not `isMounted`: creating the image is IRREVERSIBLE, so "I do not + // know" must not pass here as "not mounted", let alone go any further. return CMActionResult( succeeded: false, - message: """ - Nie udalo sie odczytac tablicy montowan - NIE WIADOMO, czy bufor jest \ - zamontowany. NIE tworze obrazu. - """) - } - - // Straznik "obraz juz istnieje" czyta cache FUSE, a rclone wystawia - // montowanie ZANIM wczyta z Dysku zawartosc katalogu - w tym oknie - // `exists` mowi "nie ma obrazu" o obrazie, ktory jest. `attach-image` - // czeka tu na `BufferReadiness.wait` od 13 wrzesnia 2026, `create` nie - // czekalo wcale. Dla `attach` przegapienie okna kosztuje jedno nieudane - // podpiecie; dla `create` - `hdiutil create` idzie na sciezke istniejacego - // backupu, i to z pieciokrotnym ponawianiem. + message: L10n.tr( + "Could not read the mount table - it is UNKNOWN whether the buffer is mounted. NOT creating the image." + )) + } + + // The "image already exists" guard reads the FUSE cache, and rclone + // exposes the mount BEFORE it loads the directory contents from Drive - in + // that window `exists` says "no image" about an image that is there. + // `attach-image` has waited here on `BufferReadiness.wait` since + // 13 September 2026, `create` did not wait at all. For `attach` missing the + // window costs one failed attach; for `create` - `hdiutil create` goes to + // the path of an existing backup, and with five retries at that. // - // Czekamy na UDANE listowanie punktu montowania, a nie na samo - // `isMounted`: na to drugie odpowiedzial juz straznik wyzej, wiec probka - // przechodzilaby natychmiast i czekanie nie robiloby NIC. Listowanie - // korzenia przy zimnym `--dir-cache-time` idzie po dane do Google, wiec - // jego powodzenie znaczy "rclone faktycznie obsluguje ten katalog"; - // dopoki FUSE nie zaczelo serwowac, konczy sie bledem urzadzenia. + // We wait for a SUCCESSFUL listing of the mount point, not just for + // `isMounted`: the guard above has already answered the latter, so the + // probe would pass immediately and the wait would do NOTHING. Listing the + // root with a cold `--dir-cache-time` goes to Google for data, so its + // success means "rclone is actually serving this directory"; until FUSE + // has started serving, it ends with a device error. // - // UWAGA co do zasiegu: to czekanie usuwa okno "FUSE jeszcze nie odpowiada", - // ale NIE dowodzi nieobecnosci obrazu - puste listowanie wyglada tak samo - // przy pustym koncie i przy niewczytanym katalogu. Dowodem jest dopiero - // `remoteImagePresence()` nizej i to on, a nie to czekanie, wstrzymuje - // operacje nieodwracalna. + // NOTE on scope: this wait removes the "FUSE is not answering yet" window, + // but does NOT prove the image is absent - an empty listing looks the same + // for an empty account and for a directory not yet loaded. The proof is + // `remoteImagePresence()` below, and it, not this wait, holds back the + // irreversible operation. let ready = await BufferReadiness.wait( sleep: { seconds in try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) @@ -320,38 +330,37 @@ public enum BackupImageService { guard ready else { return CMActionResult( succeeded: false, - message: - "Bufor nie stanal w \(Int(BufferReadiness.defaultTimeout / 60)) min - NIE tworze obrazu.") + message: L10n.tr( + "The buffer did not come up within %@ min - NOT creating the image.", + "\(Int(BufferReadiness.defaultTimeout / 60))")) } guard !exists else { return CMActionResult( succeeded: false, - message: "Obraz juz istnieje. Usuniecie go kasuje caly backup - zrob to swiadomie.") + message: L10n.tr( + "The image already exists. Deleting it erases the whole backup - do it deliberately.")) } - // Cache FUSE juz raz sklamal, wiec pytamy jeszcze raz ZDALNEGO, z - // pominieciem montowania. To jest operacja NIEODWRACALNA: brak pewnosci - // musi ja PRZERWAC, a nie tylko wypisac ostrzezenie, ktore i tak nikt nie - // czyta przed zatwierdzeniem. + // The FUSE cache has already lied once, so we ask the REMOTE again, + // bypassing the mount. This is an IRREVERSIBLE operation: lack of certainty + // must ABORT it, not just print a warning that nobody reads before + // confirming anyway. switch await remoteImagePresence() { case .absent: break case .present: return CMActionResult( succeeded: false, - message: """ - Obraz juz istnieje na Google Drive (cache montowania go nie pokazywal, \ - ale zdalny go ma). Usuniecie go kasuje caly backup - zrob to swiadomie. - """) + message: L10n.tr( + "The image already exists on Google Drive (the mount cache did not show it, but the remote has it). Deleting it erases the whole backup - do it deliberately." + )) case .unknown(let why): return CMActionResult( succeeded: false, - message: """ - Nie udalo sie potwierdzic na Google Drive, ze obrazu tam jeszcze nie ma \ - (\(why)) - PRZERYWAM. Tworzenie obrazu na istniejacym backupie jest \ - nieodwracalne, wiec bez tej odpowiedzi nie zaczynam. - """) + message: L10n.tr( + "Could not confirm on Google Drive that the image is not there yet (%@) - ABORTING. Creating the image over an existing backup is irreversible, so I do not start without that answer.", + why)) } await DriveBufferService.waitUntilIdle(timeout: 180) @@ -372,46 +381,51 @@ public enum BackupImageService { guard result?.succeeded == true else { return CMActionResult( succeeded: false, - message: "Nie udalo sie utworzyc obrazu: \(result?.stderr ?? "nieznany blad")") + message: L10n.tr( + "Could not create the image: %@", result?.stderr ?? L10n.tr("unknown error"))) } return CMActionResult( succeeded: true, - message: "Utworzono obraz \(sizeGB) GB, pasmo \(bandSectors * 512 / 1024 / 1024) MB.") + message: L10n.tr( + "Created a %@ GB image, band size %@ MB.", "\(sizeGB)", + "\(bandSectors * 512 / 1024 / 1024)")) } - // MARK: - Obraz na zdalnym + // MARK: - Image on the remote - /// Czy obraz lezy na Google Drive - pytane BEZ posrednictwa montowania. + /// Whether the image is on Google Drive - asked WITHOUT going through the mount. public enum RemotePresence: Equatable { case present case absent - /// Nie wiadomo. `why` idzie do komunikatu, zeby uzytkownik wiedzial, - /// czego dokladnie zabraklo. + /// Unknown. `why` goes into the message, so that the user knows what + /// exactly was missing. case unknown(String) } - /// Pyta `rclone lsf` prosto o zdalny katalog backupu. + /// Asks `rclone lsf` directly about the remote backup directory. /// - /// Sens jest w tym, ze omija cache FUSE - a to wlasnie cache FUSE mowi - /// "nie ma obrazu" przez pierwsze sekundy po wystawieniu montowania. + /// The point is that it bypasses the FUSE cache - and it is precisely the + /// FUSE cache that says "no image" during the first seconds after the mount + /// is exposed. public static func remoteImagePresence() async -> RemotePresence { let remote = "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)" guard let result = try? await CMTooling.runRclone(["lsf", "--dirs-only", remote], timeout: 120) else { - return .unknown("rclone nie odpowiedzial") + return .unknown(L10n.tr("rclone did not answer")) } return classifyRemoteListing( succeeded: result.succeeded, stdout: result.stdout, stderr: result.stderr) } - /// Czysta wersja - zeby dalo sie ja sprawdzic testem bez sieci i bez konta. + /// Pure version - so that it can be tested without a network and without an + /// account. /// - /// Nieudane `lsf` z komunikatem "directory not found" NIE jest brakiem - /// odpowiedzi, tylko odpowiedzia "nie ma tam niczego": tak wyglada pierwsze - /// uruchomienie, zanim cokolwiek zostalo na Dysk wyslane. Gdybysmy zaliczyli - /// to do `.unknown`, `create` nie dalby sie wykonac ANI RAZU - straznik - /// blokowalby dokladnie ten przypadek, dla ktorego istnieje. + /// A failed `lsf` with the message "directory not found" is NOT a missing + /// answer, but the answer "there is nothing there": that is what the first + /// run looks like, before anything has been uploaded to Drive. If we counted + /// it as `.unknown`, `create` could not run EVEN ONCE - the guard would block + /// exactly the case it exists for. static func classifyRemoteListing(succeeded: Bool, stdout: String, stderr: String) -> RemotePresence { @@ -421,11 +435,11 @@ public enum BackupImageService { if stderr.lowercased().contains("directory not found") { return .absent } let reason = stderr.trimmingCharacters(in: .whitespacesAndNewlines) return .unknown( - reason.isEmpty ? "rclone lsf zakonczylo sie bledem" : String(reason.suffix(200))) + reason.isEmpty ? L10n.tr("rclone lsf ended with an error") : String(reason.suffix(200))) } - /// `rclone lsf --dirs-only` konczy nazwy katalogow ukosnikiem, ale nie - /// polegamy na tym - przyjmujemy obie postacie. + /// `rclone lsf --dirs-only` ends directory names with a slash, but we do not + /// rely on it - we accept both forms. static func listingContainsImage(_ listing: String) -> Bool { let wanted = "\(imageName).sparsebundle" return listing.components(separatedBy: .newlines) @@ -433,10 +447,11 @@ public enum BackupImageService { .contains { $0 == wanted || $0 == wanted + "/" } } - // MARK: - Podpinanie + // MARK: - Attaching public static func attach() async -> CMActionResult { - await withCMLock(lockName) { await attachLocked() } ?? busyResult("Podpinanie obrazu") + await withCMLock(lockName) { await attachLocked() } + ?? busyResult("Image attach", displayName: L10n.tr("Image attach")) } private static func attachLocked() async -> CMActionResult { @@ -444,70 +459,70 @@ public enum BackupImageService { case .some(true): break case .some(false): - return CMActionResult(succeeded: false, message: "Drive nie jest zamontowany.") + return CMActionResult(succeeded: false, message: L10n.tr("Drive is not mounted.")) case .none: - // Podpiecie obrazu na NIEZAMONTOWANYM buforze konczy sie obrazem - // wiszacym na pustym katalogu, wiec "nie wiem" ma tu wstrzymac, a nie - // przepuscic. Tik agenta sprobuje znowu za 900 s. + // Attaching the image on an UNMOUNTED buffer ends with an image hanging + // on an empty directory, so "I do not know" must hold off here, not let + // it through. The agent's tick will try again in 900 s. return CMActionResult( succeeded: false, - message: """ - Nie udalo sie odczytac tablicy montowan - NIE WIADOMO, czy bufor jest \ - zamontowany. NIE podpinam obrazu. - """) + message: L10n.tr( + "Could not read the mount table - it is UNKNOWN whether the buffer is mounted. NOT attaching the image." + )) } guard exists else { - return CMActionResult(succeeded: false, message: "Brak obrazu - najpierw go utworz.") + return CMActionResult( + succeeded: false, message: L10n.tr("No image - create it first.")) } let reading = await attachmentReading() switch reading.attachment { case .attached: - return CMActionResult(succeeded: true, message: "Juz podpiete: \(targetPath.path)") + return CMActionResult( + succeeded: true, message: L10n.tr("Already attached: %@", targetPath.path)) case .unknown: - // Nie `.detached`, bo nastepnym krokiem bylby `hdiutil attach` na - // obrazie, ktory moze byc juz podpiety - a wczesniej jeszcze - // `purgeStaleDevices()`, czyli `detach -force` na cudzym, zywym - // urzadzeniu. "Nie wiem" nie moze uruchamiac ani jednego, ani drugiego. + // Not `.detached`, because the next step would be `hdiutil attach` on an + // image that may already be attached - and before that + // `purgeStaleDevices()`, i.e. `detach -force` on someone else's live + // device. "I do not know" must not trigger either of them. // - // Dotyczy to TAKZE sondy, ktora nie odpowiedziala w czasie. Cena jest - // realna i wybrana swiadomie: jesli obraz jest naprawde martwy, a odczyt - // z niego wisi, ta funkcja go nie naprawi i tik agenta sprobuje znowu za - // 900 s. Odwrotna pomylka jest jednak nieodwracalna - `detach -force` na - // wolnym, ale ZYWYM urzadzeniu porzuca zapisy, ktore nie doleciely na - // Dysk. O tym, ze stan jest nieznany, melduje czujka `backup-health`; - // milczenia tu nie ma. + // This ALSO applies to a probe that did not answer in time. The price is + // real and chosen deliberately: if the image really is dead and reads + // from it hang, this function will not fix it, and the agent's tick will + // try again in 900 s. The opposite mistake, however, is irreversible - + // `detach -force` on a slow but LIVE device drops writes that have not + // reached Drive. The `backup-health` monitor reports that the state is + // unknown; there is no silence here. if reading.probeTimedOut { return CMActionResult( succeeded: false, - message: """ - Obraz jest w tablicy montowan, ale sonda czytelnosci nie odpowiedziala w \ - \(Int(ImageProbe.probeTimeout)) s - NIE WIADOMO, czy urzadzenie zyje. NIE \ - odpinam na sile i NIE podpinam. - """) + message: L10n.tr( + "The image is in the mount table, but the readability probe did not answer within %@ s - it is UNKNOWN whether the device is alive. NOT force-detaching and NOT attaching.", + "\(Int(ImageProbe.probeTimeout))")) } return CMActionResult( succeeded: false, - message: """ - Nie udalo sie odczytac tablicy montowan - NIE WIADOMO, czy obraz jest \ - podpiety. NIE podpinam. - """) + message: L10n.tr( + "Could not read the mount table - it is UNKNOWN whether the image is attached. NOT attaching." + )) case .dead(let errno): - // Obraz jest w tablicy montowan, ale nie oddaje danych. Do 22 wrz 2026 - // ta funkcja mowila wtedy "Juz podpiete" i wychodzila - agent podpinajacy - // powtarzal to co 15 minut przez 15 godzin, a Time Machine nie mial celu. - // Jedyna droga jest odpiecie (musi byc `-force`, zwykle odmawia na - // martwym urzadzeniu) i podpiecie od nowa. Czekanie na wysylke zostaje: - // to, co zdazylo trafic do bufora rclone, nadal ma doleciec na Dysk. - CMLogger.log("Obraz martwy (errno \(errno)) - odpinam na sile i podpinam od nowa") - // `detachLocked`, nie `detach`: blokade 'image' trzymamy juz my, - // a `CMLock` nie jest wznawialna - wejscie przez publiczna `detach` - // zobaczyloby wlasna, zywa blokade i odmowilo samo sobie. + // The image is in the mount table, but returns no data. Until 22 Sep 2026 + // this function then said "Already attached" and returned - the attaching + // agent repeated that every 15 minutes for 15 hours, and Time Machine had + // no destination. The only way is to detach (it has to be `-force`, a + // plain one refuses on a dead device) and attach again. The wait for the + // upload stays: whatever made it into the rclone buffer still has to + // reach Drive. + CMLogger.log("Image dead (errno \(errno)) - force-detaching and attaching again") + // `detachLocked`, not `detach`: we already hold the 'image' lock, and + // `CMLock` is not reentrant - going through the public `detach` would see + // our own live lock and refuse itself. let detached = await detachLocked(force: true) - CMLogger.log("Odpiecie martwego obrazu: \(detached.message)") + CMLogger.log("Detaching the dead image: \(detached.message)") guard !isAttached else { return CMActionResult( succeeded: false, - message: "Obraz martwy (errno \(errno)) i nie dal sie odpiac: \(detached.message)") + message: L10n.tr( + "Image dead (errno %@) and could not be detached: %@", "\(errno)", detached.message)) } case .detached: break @@ -515,32 +530,31 @@ public enum BackupImageService { await purgeStaleDevices() - // Osierocony punkt montowania blokuje podpiecie. Jesli lezy w /Volumes, - // usuniecie wymaga roota - mowimy wiec dokladnie, co uruchomic, zamiast - // ponawiac bez konca. + // An orphaned mount point blocks the attach. If it is in /Volumes, removing + // it needs root - so we say exactly what to run, instead of retrying + // forever. 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)' - """) + message: L10n.tr( + "An orphaned mount point blocks the attach: %@\nRemove it and try again: sudo rmdir '%@'", + targetPath.path, targetPath.path)) } } - // Cisza w kolejce nie jest tu wygoda, tylko warunkiem powodzenia: - // `hdiutil` na wolumenie FUSE-T odrzuca montowanie tym czesciej, im - // bardziej rclone jest zajety (patrz `retryingFlakyMount`). Przy - // `writeBackSeconds` liczonym w minutach kolejka sama nie opustoszeje - // w ponizszym limicie czasu, wiec najpierw wymuszamy wysylke - inaczej - // podpiecie po kazdym starcie bylo by loteria. + // A quiet queue is not a convenience here but a condition for success: + // `hdiutil` on a FUSE-T volume rejects the mount the more often, the busier + // rclone is (see `retryingFlakyMount`). With `writeBackSeconds` counted in + // minutes the queue will not empty by itself within the time limit below, + // so we force the upload first - otherwise attaching after every start-up + // would be a lottery. // - // Czekamy, dopoki wysylka robi postep, a nie sztywne 120 s - po restarcie - // bez `prepare-shutdown` zaleglosc siega kilkunastu GB (patrz - // `UploadDrain`). + // We wait as long as the upload makes progress, not a fixed 120 s - after a + // restart without `prepare-shutdown` the backlog reaches a dozen or more GB + // (see `UploadDrain`). let drain = await UploadDrain.wait( sleep: { try? await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000)) }, expire: { _ = await DriveBufferService.expireQueuedUploads() }, @@ -549,13 +563,13 @@ public enum BackupImageService { case .idle: break case .stalled(let unsent): - CMLogger.log("Podpinanie: wysylka stoi (\(unsent) pozycji w kolejce) - podpinam mimo to") + CMLogger.log("Attach: upload stalled (\(unsent) items queued) - attaching anyway") case .timedOut(let unsent): CMLogger.log( - "Podpinanie: zaleglosc nie zeszla w \(Int(UploadDrain.defaultMaxTotal / 60)) min (\(unsent) pozycji) - podpinam mimo to" + "Attach: backlog did not drain within \(Int(UploadDrain.defaultMaxTotal / 60)) min (\(unsent) items) - attaching anyway" ) case .noAnswer: - CMLogger.log("Podpinanie: rclone nie odpowiada o stan kolejki - podpinam na oslep") + CMLogger.log("Attach: rclone does not answer about the queue state - attaching blind") } let result = await retryingFlakyMount(attempts: 5) { @@ -568,26 +582,27 @@ public enum BackupImageService { guard result?.succeeded == true else { return CMActionResult( succeeded: false, - message: "Nie udalo sie podpiac obrazu: \(result?.stderr ?? "nieznany blad")") + message: L10n.tr( + "Could not attach the image: %@", result?.stderr ?? L10n.tr("unknown error"))) } - return CMActionResult(succeeded: true, message: "Podpiete: \(targetPath.path)") + return CMActionResult(succeeded: true, message: L10n.tr("Attached: %@", targetPath.path)) } - /// Odpina obraz i CZEKA, az wszystko doleci na Google Drive. + /// Detaches the image and WAITS until everything has reached 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. + /// The wait is not extra caution. The detach itself writes APFS metadata to + /// the bands, and `--vfs-write-back` delays uploading them by tens of + /// seconds. Losing the buffer in that window does not cost "the latest + /// changes" - it takes the volume's root directory. Observed live: 367 MiB of + /// bands were already on Drive, and the image was empty after re-attaching, + /// because three bands with metadata were killed in the queue. /// - /// Dlatego kazda sciezka wygaszania - odpiecie, zatrzymanie bufora, - /// wylaczenie Maca - musi przepuscic drenaz do konca. + /// That is why every shutdown path - detach, stopping the buffer, turning off + /// the Mac - must let the drain run to the end. public static func detach(force: Bool = false, waitForUpload: Bool = true) async -> CMActionResult { await withCMLock(lockName) { await detachLocked(force: force, waitForUpload: waitForUpload) } - ?? busyResult("Odpinanie obrazu") + ?? busyResult("Image detach", displayName: L10n.tr("Image detach")) } private static func detachLocked(force: Bool = false, waitForUpload: Bool = true) async @@ -599,127 +614,128 @@ public enum BackupImageService { if force { args.append("-force") } let result = try? await ProcessRunner.run("/usr/bin/hdiutil", args, timeout: 120) guard result?.succeeded == true else { - // Podajemy powod, jesli go znamy. `hdiutil` mowi tylko "resource busy" - // i ani slowa o tym, co trzyma urzadzenie - a to prawie zawsze - // przegladana migawka backupu. + // We give the reason if we know it. `hdiutil` only says "resource busy" + // and not a word about what holds the device - and that is almost always + // a browsed backup snapshot. guard stillMounted.isEmpty else { return CMActionResult( succeeded: false, - message: """ - Nie udalo sie odpiac - obraz trzymaja przegladane migawki backupu, \ - ktorych nie dalo sie odmontowac: - \(stillMounted.joined(separator: "\n")) - Zamknij okno Time Machine / Findera na backupie i sprobuj ponownie. - """) + message: L10n.tr( + "Could not detach - the image is held by browsed backup snapshots that could not be unmounted:\n%@\nClose the Time Machine / Finder window on the backup and try again.", + stillMounted.joined(separator: "\n"))) } - return CMActionResult(succeeded: false, message: "Nie udalo sie odpiac.") + return CMActionResult(succeeded: false, message: L10n.tr("Could not detach.")) } guard waitForUpload else { return CMActionResult( succeeded: true, - message: "Odpiete (bez czekania na wysylke - dane moga byc tylko lokalnie).") + message: L10n.tr( + "Detached (without waiting for the upload - the data may be local only).")) } - // Zapisy z odpiecia musza najpierw trafic do kolejki - bez tej przerwy - // wygladalaby na pusta, bo jeszcze by sie nie zdazyla zapelnic. + // Writes from the detach must reach the queue first - without this pause + // it would look empty, because it would not have had time to fill yet. // - // UWAGA co do mechanizmu: pozycja pojawia sie w kolejce ZARAZ po zapisie, - // tyle ze z terminem wysylki `writeBackSeconds` w przod (widac to w - // `vfs/queue` jako dodatnie `expiry`). Ta przerwa czeka wiec na samo - // zakolejkowanie, a NIE na uplyw tego terminu - wczesniejszy komentarz - // w tym miejscu twierdzil odwrotnie. + // NOTE on the mechanism: an item appears in the queue RIGHT after the + // write, only with an upload deadline `writeBackSeconds` ahead (visible in + // `vfs/queue` as a positive `expiry`). So this pause waits for the queuing + // itself, NOT for that deadline to pass - the earlier comment here claimed + // the opposite. try? await Task.sleep(nanoseconds: 35_000_000_000) - // Terminy przesuwamy dopiero teraz, gdy kolejka jest juz kompletna. - // Bez tego drenaz trwalby tyle, co `writeBackSeconds` (dziesiec minut), - // czyli dluzej niz ponizszy limit czasu - i odpiecie zglaszaloby - // niepowodzenie za kazdym razem. + // We move the deadlines only now, when the queue is complete. Without this + // the drain would take as long as `writeBackSeconds` (ten minutes), i.e. + // longer than the time limit below - and the detach would report failure + // every time. CMLogger.log(expiryLogLine(await DriveBufferService.expireQueuedUploads())) return detachVerdict(settled: await DriveBufferService.statsWhenIdle(timeout: 600)) } - /// Co odpiecie wpisuje do logu po probie przyspieszenia kolejki. + /// What the detach writes to the log after trying to speed up the queue. /// - /// TRZY rozne rzeczy wygladaly tu jak dwie. "Nie dostalismy odpowiedzi" od - /// "kolejka byla pusta" odroznilismy 23.09.2026, ale trzeci przypadek - - /// kolejka PELNA, a kazde `vfs/queue-set-expiry` padlo - nadal wychodzil - /// z `expireQueuedUploads` jako `0` i log meldowal "kolejka pusta". - /// Zmierzony stan tej maszyny w chwili audytu: 462 pozycje w kolejce. + /// THREE different things looked like two here. "We got no answer" was told + /// apart from "the queue was empty" on 23.09.2026, but the third case - the + /// queue FULL and every `vfs/queue-set-expiry` failed - still came out of + /// `expireQueuedUploads` as `0`, and the log reported "queue empty". The + /// measured state of this machine at the time of the audit: 462 items queued. /// - /// Tryb awarii jest ciezszy niz sama nieprawda w logu: czlowiek czyta te - /// linie dokladnie wtedy, gdy decyduje, czy wolno skasowac bufor. "Kolejka - /// pusta" czyta sie jako "nic nie czeka na wyslanie", a znaczylo - /// "czekaja 462 pozycje i zadnej nie udalo sie ruszyc". + /// The failure mode is worse than just an untruth in the log: a person reads + /// this line exactly when deciding whether the buffer may be deleted. "Queue + /// empty" reads as "nothing is waiting to be uploaded", while it meant "462 + /// items are waiting and not one of them could be moved". /// - /// Wydzielone i CZYSTE, zeby te trzy przypadki dalo sie sprawdzic testem bez - /// rclone. Funkcja jest wylacznie opisem: o czekaniu na drenaz i o werdykcie - /// decyduje `detachLocked`/`detachVerdict` i ta poprawka ich nie dotyka. + /// Split out and PURE, so that these three cases can be tested without + /// rclone. The function is only a description: waiting for the drain and the + /// verdict are decided by `detachLocked`/`detachVerdict`, and this fix does + /// not touch them. static func expiryLogLine(_ outcome: DriveBufferService.ExpiryOutcome?) -> String { - let drenaz = "drenaz moze trwac do \(DriveBufferService.writeBackSeconds / 60) min" + let drain = "the drain may take up to \(DriveBufferService.writeBackSeconds / 60) min" guard let outcome else { - // Brak odpowiedzi to NIE pusta kolejka - patrz `expireQueuedUploads`. - return "Odpiecie: rclone nie odpowiedzial na pytanie o kolejke - terminow wysylki NIE" - + " przesunieto, \(drenaz)" + // No answer is NOT an empty queue - see `expireQueuedUploads`. + return "Detach: rclone did not answer the question about the queue - upload deadlines were" + + " NOT moved, \(drain)" } if outcome.queued == 0 { - return "Odpiecie: kolejka pusta - nie bylo czego przyspieszac" + return "Detach: queue empty - nothing to speed up" } if outcome.moved == 0 { - return "Odpiecie: UWAGA - kolejka ma \(outcome.queued) pozycji i ANI JEDNEJ nie udalo sie" - + " przyspieszyc (rclone odrzucil kazde vfs/queue-set-expiry), \(drenaz)" + return "Detach: WARNING - the queue has \(outcome.queued) items and NOT ONE could be" + + " sped up (rclone rejected every vfs/queue-set-expiry), \(drain)" } if outcome.moved < outcome.queued { - return "Odpiecie: wymuszono wysylke \(outcome.moved) z \(outcome.queued) pozycji kolejki -" - + " pozostalym \(outcome.queued - outcome.moved) NIE przesunieto terminu, \(drenaz)" + return "Detach: forced upload of \(outcome.moved) of \(outcome.queued) queued items -" + + " the deadline of the remaining \(outcome.queued - outcome.moved) was NOT moved, \(drain)" } - return "Odpiecie: wymuszono wysylke \(outcome.moved) pozycji z kolejki" + return "Detach: forced upload of \(outcome.moved) queued items" } - /// Czysta wersja werdyktu o odpieciu - `settled` to odczyt kolejki z chwili, - /// w ktorej ucichla (`nil` = nie ucichla w czasie albo rclone nie odpowiedzial). + /// Pure version of the detach verdict - `settled` is the queue reading from + /// the moment it went quiet (`nil` = it did not go quiet in time, or rclone + /// did not answer). /// - /// Pusta kolejka to jeszcze nie komplet danych na Dysku. Pasma, ktore rclone - /// PORZUCIL, wypadaja z kolejki dokladnie tak samo jak wyslane i zostaja - /// wylacznie w `erroredFiles`. Do 23 wrzesnia 2026 odpiecie patrzylo tylko - /// na `uploadsInProgress`/`uploadsQueued`, wiec meldowalo "Odpiete, wszystko - /// wyslane na Google Drive" przy danych istniejacych TYLKO na tym Macu - - /// a `UploadState` z tych samych licznikow wyprowadzal juz wtedy - /// `.failedFiles(...)` z etykieta "WYMAGA REAKCJI". CLI i GUI mowily o tej - /// samej chwili dwie rozne rzeczy. + /// An empty queue is not yet complete data on Drive. Bands that rclone + /// ABANDONED drop out of the queue exactly like uploaded ones and remain only + /// in `erroredFiles`. Until 23 September 2026 the detach looked only at + /// `uploadsInProgress`/`uploadsQueued`, so it reported "Detached, everything + /// uploaded to Google Drive" with data existing ONLY on this Mac - while + /// `UploadState`, from the same counters, already derived `.failedFiles(...)` + /// with the label "ACTION NEEDED". The CLI and the GUI said two different + /// things about the same moment. static func detachVerdict(settled: DriveBufferService.QueueStats?) -> CMActionResult { guard let settled else { return CMActionResult( succeeded: false, - message: "Odpiete, ale wysylka NIE zakonczyla sie w czasie - nie kasuj bufora.") + message: L10n.tr( + "Detached, but the upload did NOT finish in time - do not delete the buffer.")) } guard settled.erroredFiles == 0 else { return CMActionResult( succeeded: false, - message: """ - Odpiete, ale rclone PORZUCIL \(settled.erroredFiles) fragmentow kopii - istnieja \ - wylacznie na tym Macu i na Google Drive ich nie ma. Nie kasuj bufora. - """) + message: L10n.tr( + "Detached, but rclone ABANDONED %@ backup fragments - they exist only on this Mac and are not on Google Drive. Do not delete the buffer.", + "\(settled.erroredFiles)")) } - return CMActionResult(succeeded: true, message: "Odpiete, wszystko wyslane na Google Drive.") + return CMActionResult( + succeeded: true, message: L10n.tr("Detached, everything uploaded to Google Drive.")) } - /// Odmontowuje migawki backupu podpiete pod `/Volumes/.timemachine/`. - /// Zwraca sciezki, ktorych NIE udalo sie odmontowac. + /// Unmounts backup snapshots mounted under `/Volumes/.timemachine/`. + /// Returns the paths that could NOT be unmounted. /// - /// 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. + /// Browsing a backup - in Finder or with a plain `ls` on a path from + /// `tmutil listbackups` - mounts its snapshot read-only. Such a mount keeps + /// the image's device busy and `hdiutil detach` refuses, while the message + /// says not a word about what is blocking it. /// - /// UZYWAMY `diskutil unmount`, NIE `/sbin/umount`. Zmierzone na dzialajacej - /// instalacji: `umount` na takiej migawce konczy sie - /// `Operation not permitted` dla uzytkownika (montowaniem zarzadza system), - /// a `diskutil unmount` na tej samej sciezce przechodzi bez roota. - /// Poprzednia wersja wolala `umount` przez `try?` i logowala "Odmontowano" - /// NIEZALEZNIE od wyniku - wiec przy 18 podpietych migawkach log meldowal - /// 18 sukcesow, zadna nie zostala odmontowana, a `hdiutil detach` zaraz - /// potem odmawial bez zwiazku ze soba widocznego w logu. + /// We USE `diskutil unmount`, NOT `/sbin/umount`. Measured on a working + /// installation: `umount` on such a snapshot ends with + /// `Operation not permitted` for the user (the system manages the mount), + /// while `diskutil unmount` on the same path succeeds without root. + /// The previous version called `umount` via `try?` and logged "Unmounted" + /// REGARDLESS of the result - so with 18 mounted snapshots the log reported + /// 18 successes, none was unmounted, and `hdiutil detach` right after refused + /// with no connection visible in the log. @discardableResult public static func unmountBrowsedSnapshots() async -> [String] { guard let points = DriveBufferService.mountPoints() else { return [] } @@ -728,34 +744,35 @@ public enum BackupImageService { let result = try? await ProcessRunner.run( "/usr/sbin/diskutil", ["unmount", path], timeout: 60) if result?.succeeded == true { - CMLogger.log("Odmontowano przegladana migawke backupu: \(path)") + CMLogger.log("Unmounted browsed backup snapshot: \(path)") } else { failed.append(path) - CMLogger.log("NIE udalo sie odmontowac migawki backupu: \(path)") + CMLogger.log("FAILED to unmount backup snapshot: \(path)") } } return failed } - // MARK: - Weryfikacja + // MARK: - Verification - /// Sprawdza spojnosc obrazu. + /// Checks the image's consistency. /// - /// UWAGA: `hdiutil verify` na sparsebundle NIE dziala - taki obraz nie ma - /// sumy kontrolnej i narzedzie konczy komunikatem "has no checksum". - /// Trzeba podpiac urzadzenie bez montowania i puscic na nim `fsck_apfs`. + /// NOTE: `hdiutil verify` on a sparsebundle does NOT work - such an image has + /// no checksum and the tool ends with the message "has no checksum". The + /// device has to be attached without mounting and `fsck_apfs` run on it. public static func verify() async -> CMActionResult { - await withCMLock(lockName) { await verifyLocked() } ?? busyResult("Sprawdzanie obrazu") + await withCMLock(lockName) { await verifyLocked() } + ?? busyResult("Image verify", displayName: L10n.tr("Image verification")) } private static func verifyLocked() async -> CMActionResult { guard exists else { - return CMActionResult(succeeded: false, message: "Brak obrazu.") + return CMActionResult(succeeded: false, message: L10n.tr("No image.")) } if isAttached { return CMActionResult( succeeded: false, - message: "Obraz jest podpiety - odepnij go przed sprawdzeniem.") + message: L10n.tr("The image is attached - detach it before verifying.")) } guard @@ -767,66 +784,68 @@ public enum BackupImageService { .first(where: { $0.contains("41504653") })? .prefix(while: { !$0.isWhitespace }) else { - return CMActionResult(succeeded: false, message: "Nie znalazlem urzadzenia APFS w obrazie.") + return CMActionResult( + succeeded: false, message: L10n.tr("Could not find an APFS device in the image.")) } - // BEZ timeoutu. `fsck_apfs` czyta metadane przez montowanie rclone, wiec - // jego czas zalezy od lacza i od liczby migawek - zmierzone na obrazie - // 210 GiB z 18 migawkami: pojedyncza migawka schodzi w minutach. - // Wczesniejsza granica godziny nie chronila przed niczym, a zamieniala - // "sprawdzenie jeszcze trwa" w "Obraz NIESPOJNY", bo ubity `fsck` zwraca - // niezerowy kod tak samo jak `fsck`, ktory znalazl uszkodzenie. Falszywy - // alarm o utracie backupu jest tu grozniejszy niz dlugie czekanie. + // NO timeout. `fsck_apfs` reads metadata through the rclone mount, so its + // time depends on the link and on the number of snapshots - measured on a + // 210 GiB image with 18 snapshots: a single snapshot takes minutes. + // The earlier one-hour limit protected against nothing, but turned "the + // check is still running" into "Image INCONSISTENT", because a killed + // `fsck` returns a non-zero code just like an `fsck` that found damage. + // A false alarm about losing the backup is more dangerous here than a long + // wait. let fsck = try? await ProcessRunner.run(fsckPath, ["-n", String(device)]) - // Czy urzadzenie bylo jeszcze nasze, gdy `fsck` konczyl? + // Was the device still ours when `fsck` finished? // - // `fsck_apfs` zwraca niezerowy kod tak samo, gdy znalazl uszkodzenie, jak - // i wtedy, gdy ktos wyrwal mu urzadzenie spod nog - a wyrwac je potrafi - // `purgeStaleDevices()` (`hdiutil detach -force`) przy tiku agenta - // `gdrive-attach` co 900 s. Bez tego sprawdzenia `verify` meldowal wtedy - // "Obraz NIESPOJNY", czyli falszywy alarm o utracie calego backupu. - // Blokada 'image' zamyka juz to okno, ale komunikat ma byc uczciwy - // takze wtedy, gdy urzadzenie znika z innego powodu. + // `fsck_apfs` returns a non-zero code both when it found damage and when + // someone pulled the device out from under it - and + // `purgeStaleDevices()` (`hdiutil detach -force`) can do exactly that on + // the `gdrive-attach` agent's tick every 900 s. Without this check `verify` + // then reported "Image INCONSISTENT", i.e. a false alarm about losing the + // whole backup. The 'image' lock already closes that window, but the + // message has to be honest also when the device disappears for another + // reason. let deviceSurvived = await devicesForImage().contains(parentDevice(of: String(device))) - // 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. + // We detach WITH waiting, not via `defer { Task { ... } }`. That version + // returned from the function before the detach happened - and the caller + // usually attaches the image back right away, so the attach raced with a + // pending detach of the same device. _ = try? await ProcessRunner.run( "/usr/bin/hdiutil", ["detach", String(device), "-force", "-quiet"], timeout: 120) - // "Nie udalo sie sprawdzic" to NIE to samo co "niespojny" - jedno znaczy - // brak wyniku, drugie uszkodzony backup. Zlanie ich w jeden komunikat - // kazaloby uzytkownikowi odtwarzac cala kopie z powodu nieudanego - // uruchomienia narzedzia. + // "Could not check" is NOT the same as "inconsistent" - one means no + // result, the other a damaged backup. Merging them into one message would + // make the user restore the whole backup because of a failed tool run. guard let fsck else { return CMActionResult( succeeded: false, - message: "Nie udalo sie uruchomic \(fsckPath) - spojnosc obrazu POZOSTAJE NIESPRAWDZONA.") + message: L10n.tr( + "Could not run %@ - the image's consistency REMAINS UNCHECKED.", fsckPath)) } if !fsck.succeeded && !deviceSurvived { return CMActionResult( succeeded: false, - message: """ - Sprawdzenie PRZERWANE - urzadzenie \(device) zniklo w trakcie (ktos odpial \ - obraz na sile). To nie jest wynik o stanie backupu: spojnosc obrazu \ - POZOSTAJE NIESPRAWDZONA. Powtorz sprawdzenie. - """) + message: L10n.tr( + "Check INTERRUPTED - device %@ disappeared midway (someone force-detached the image). This is not a result about the backup's state: the image's consistency REMAINS UNCHECKED. Repeat the check.", + String(device))) } return CMActionResult( succeeded: fsck.succeeded, message: fsck.succeeded - ? "Obraz spojny." : "Obraz NIESPOJNY: \(fsck.stdout.suffix(500))") + ? L10n.tr("Image consistent.") + : L10n.tr("Image INCONSISTENT: %@", String(fsck.stdout.suffix(500)))) } /// `/dev/disk7s1` -> `/dev/disk7`. /// - /// `fsck_apfs` dostaje partycje APFS, a `hdiutil info` - i wiec - /// `devicesForImage()` - wypisuje urzadzenie NADRZEDNE. Porownanie ich - /// wprost nigdy by sie nie zgodzilo, wiec sprawdzenie "czy urzadzenie - /// przezylo" cicho odpowiadaloby "nie" za kazdym razem. + /// `fsck_apfs` gets the APFS partition, while `hdiutil info` - and therefore + /// `devicesForImage()` - lists the PARENT device. Comparing them directly + /// would never match, so the "did the device survive" check would silently + /// answer "no" every time. static func parentDevice(of device: String) -> String { let prefix = "/dev/disk" guard device.hasPrefix(prefix) else { return device } @@ -834,44 +853,46 @@ public enum BackupImageService { return digits.isEmpty ? device : prefix + digits } - // MARK: - Gotowosc do restartu + // MARK: - Restart readiness - /// Czy mozna bezpiecznie wylaczyc Maca bez `prepare-shutdown`. + /// Whether the Mac can be safely shut down without `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. + /// The risk when shutting down is not constant - it exists only when data + /// not yet uploaded is waiting in the buffer. macOS gives agents a dozen or + /// so seconds to quit, which with an empty queue is plenty, and with a full + /// one not enough at all. /// - /// 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. + /// Measured: the queue returns to zero within a few minutes after every + /// hourly backup, so for most of the day a restart is simply safe. Instead of + /// making the user remember a command before every restart, we tell them + /// when it is really needed. 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". + // Without a reading of the queue state we have no grounds to claim it is + // safe - and for such a question silence must mean "no". return false } - // `isQuiet`, NIE `isIdle`: pusta kolejka nie wystarczy, bo pasma porzucone - // przez rclone (`erroredFiles`) wypadaja z kolejki tak samo jak wyslane. - // Restart przy takim stanie nie niszczy niczego dodatkowo, ale odpowiedz - // "TAK - kolejka pusta" czytalo sie jako "kopia na Dysku jest kompletna", - // a nie byla - i to samo zdanie padalo w `drive-status` obok - // `UploadState.failedFiles` z etykieta "WYMAGA REAKCJI". + // `isQuiet`, NOT `isIdle`: an empty queue is not enough, because bands + // abandoned by rclone (`erroredFiles`) drop out of the queue just like + // uploaded ones. A restart in that state does not destroy anything extra, + // but the answer "YES - queue empty" read as "the backup on Drive is + // complete", and it was not - and the same sentence appeared in + // `drive-status` next to `UploadState.failedFiles` with the label + // "ACTION NEEDED". return stats.isQuiet } - // MARK: - Ponawianie + // MARK: - Retrying - /// Ponawia operacje `hdiutil` na montowaniu FUSE-T. + /// Retries `hdiutil` operations on a FUSE-T mount. /// - /// 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. + /// FUSE-T mounts via NFS, and `hdiutil` on such a volume is sometimes + /// rejected with "RPC version wrong". Measured: the error does not depend on + /// the image size or the data (one run failed for 100 GB and 400 GB, and + /// passed for 600, 1000 and 1500 GB), only on the moment - with an empty + /// upload queue 5 of 5 attempts succeeded, with rclone busy it was random. + /// When creating the production image, the first attempt failed and the + /// second passed. private static func retryingFlakyMount( attempts: Int, _ operation: () async -> ProcessResult? ) async -> ProcessResult? { @@ -880,7 +901,7 @@ public enum BackupImageService { last = await operation() if last?.succeeded == true { return last } guard attempt < attempts else { break } - CMLogger.log("hdiutil: proba \(attempt) nieudana, ponawiam") + CMLogger.log("hdiutil: attempt \(attempt) failed, retrying") try? await Task.sleep(nanoseconds: 5_000_000_000) await DriveBufferService.waitUntilIdle(timeout: 60) } diff --git a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift index a52c470..784827a 100644 --- a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift +++ b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift @@ -1,102 +1,104 @@ import Foundation -/// Pilnuje, zeby bufor nie zjadl dysku - port `gdrive/buffer-guard.sh`. +/// Keeps the buffer from eating the disk - a port of `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. +/// Time Machine writes to the attached image at SSD speed (measured 267 MB/s), +/// while rclone uploads at link speed (~41 MB/s with a 332 Mb/s upload). The +/// difference lands in the buffer. /// -/// `--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. +/// `--vfs-cache-max-size` is a SOFT limit: rclone evicts from the buffer only +/// data already uploaded, so when everything is waiting in the queue the +/// buffer keeps growing and can fill the disk. With a first backup measured in +/// terabytes this is not theory - the measured net growth at the start was +/// 32 MB/s. /// -/// Dozorca wstrzymuje Time Machine, gdy ZALEGLOSC NIEWYSLANA przekroczy prog, -/// i wznawia, gdy wysylka nadgoni. Backup staje sie wolniejszy, ale konczy sie -/// zamiast wysypac maszyne. +/// The watchdog pauses Time Machine when the UNSENT BACKLOG exceeds a +/// threshold, and resumes it when the upload catches up. The backup becomes +/// slower, but it finishes instead of crashing the machine. /// -/// Trzy rzeczy, ktore trzeba tu wiedziec, bo kazda byla kiedys zrobiona -/// odwrotnie i kazda kosztowala cala ochrone: +/// Three things you need to know here, because each was once done the other +/// way round and each cost the whole protection: /// -/// 1. Mierzymy ZALEGLOSC, nie rozmiar cache'a. Rozmiar cache'a stoi pod -/// limitem stale i nie odpowiada na pytanie, czy wysylka nadaza -/// (patrz `backlogGB` i `Thresholds.init`). -/// 2. "Nie wiem" nie jest ani pauza, ani wznowieniem. Brak odpowiedzi rclone -/// nie zamienia sie na liczbe, a nieczytelny log rclone nie zamienia sie -/// na "nie ma problemu". -/// 3. Pauza trwa tyle, ile ja podtrzymujemy. `tmutil stopbackup` anuluje -/// TRWAJACY backup i nie rusza harmonogramu, wiec macOS startuje kolejny -/// w swoim cyklu godzinowym - dlatego wstrzymanie ponawia sie przy kazdym -/// tyknieciu, a nie tylko przy zmianie stanu (patrz `keepPaused`). +/// 1. We measure the BACKLOG, not the cache size. The cache size sits at the +/// limit permanently and does not answer whether the upload is keeping up +/// (see `backlogGB` and `Thresholds.init`). +/// 2. "I do not know" is neither a pause nor a resume. No answer from rclone +/// is not turned into a number, and an unreadable rclone log is not turned +/// into "no problem". +/// 3. A pause lasts as long as we keep it up. `tmutil stopbackup` cancels the +/// RUNNING backup and does not touch the schedule, so macOS starts another +/// one in its hourly cycle - which is why the pause is repeated on every +/// tick, not only on a state change (see `keepPaused`). public actor BufferGuardService { public struct Thresholds: Sendable { - /// Powyzej tylu GB ZALEGLOSCI NIEWYSLANEJ wstrzymujemy Time Machine. + /// Above this many GB of UNSENT BACKLOG we pause Time Machine. /// - /// Zaleglosc, nie rozmiar cache'a - patrz `backlogGB` po powod. + /// Backlog, not cache size - see `backlogGB` for the reason. public var highGB: Int - /// Ponizej tylu GB zaleglosci wznawiamy. + /// Below this many GB of backlog we resume. public var lowGB: Int - /// Ponizej tylu GB wolnych na dysku wstrzymujemy niezaleznie od bufora. + /// Below this many GB free on disk we pause regardless of the buffer. public var minFreeGB: Int - /// Ponizej tylu GB wolnych NA DYSKU GOOGLE nie wolno zdjac pauzy - /// zalozonej z powodu braku miejsca na Dysku. + /// Below this many GB free ON GOOGLE DRIVE a pause put in place because of + /// lack of space on Drive must not be lifted. /// - /// Ta sama liczba, ktora `BackupHealth` uwaza za prog ostrzegawczy dla - /// Dysku - jedno zrodlo prawdy. Przy przyroscie rzedu 600 MB na cykl - /// godzinowy 30 GB to okolo dwoch tygodni zapasu, czyli tyle, zeby - /// wznowiony backup mial gdzie sie zmiescic, a nie wrocil pod sciane - /// w kolejnej godzinie. + /// The same number that `BackupHealth` considers the warning threshold for + /// Drive - one source of truth. With growth of around 600 MB per hourly + /// cycle, 30 GB is about two weeks of headroom, i.e. enough for the resumed + /// backup to have room, rather than hitting the wall again in the next hour. public var minDriveFreeGB: Int - /// Progi wyliczane z rozmiaru bufora, nie wpisane z palca - ale liczone - /// OD NOWA, odkad dozorca mierzy zaleglosc niewyslana, a nie rozmiar - /// cache'a. Dawne 1,5x i 0,4x `cacheSizeGB` nie sa tu przeliczone, bo - /// odnosily sie do innej wielkosci i w tej nie znacza nic. + /// Thresholds derived from the buffer size, not typed in by hand - but + /// computed ANEW since the watchdog measures the unsent backlog rather than + /// the cache size. The old 1.5x and 0.4x of `cacheSizeGB` are not carried + /// over, because they referred to a different quantity and mean nothing in + /// this one. /// - /// CO BYLO ZLE + /// WHAT WAS WRONG /// - /// Stara para (150 GB / 40 GB) odnosila sie do `bytesUsed`, czyli do - /// rozmiaru CALEGO cache'a. Ten przy `--vfs-cache-max-size 100G` i - /// `--vfs-cache-max-age 9999h` stoi pod limitem stale: w dzienniku 281 - /// pomiarow, minimum 99 GB. Prog wznowienia 40 GB byl wiec wartoscia - /// NIEOSIAGALNA, a prog pauzy 150 GB - osiagalnym tylko przez wynik - /// obchodu katalogu, czyli przez INNA miare. Widac to w logu wprost: - /// JEDNA linia PAUZA (23.09.2026 03:34, "bufor 155 GB") i ZERO linii - /// WZNOWIENIE. + /// The old pair (150 GB / 40 GB) referred to `bytesUsed`, i.e. the size of + /// the WHOLE cache. With `--vfs-cache-max-size 100G` and + /// `--vfs-cache-max-age 9999h` that sits at the limit permanently: 281 + /// measurements in the journal, minimum 99 GB. The 40 GB resume threshold + /// was therefore UNREACHABLE, and the 150 GB pause threshold reachable only + /// through the result of the directory walk, i.e. through a DIFFERENT + /// measure. The log shows it plainly: ONE PAUSE line (23.09.2026 03:34, + /// "buffer 155 GB") and ZERO RESUME lines. /// - /// DLACZEGO PROGI SA FRAKCJA `cacheSizeGB`, ALE PONIZEJ NIEGO + /// WHY THE THRESHOLDS ARE A FRACTION OF `cacheSizeGB`, BUT BELOW IT /// - /// Zaleglosc niewyslana to dokladnie ta czesc cache'a, ktorej rclone NIE - /// MOZE usunac - usuwa tylko to, co juz wyslal. Dopoki zaleglosc jest - /// mniejsza od `cacheSizeGB`, cache ma z czego sie kurczyc i limit - /// dziala. Gdy zaleglosc dobija do `cacheSizeGB`, zapasu nie ma i kazdy - /// kolejny gigabajt zapisu idzie PONAD limit, prosto w wolne miejsce na - /// dysku. Prog pauzy musi wiec lezec PONIZEJ rozmiaru cache'a - odwrotnie - /// niz dawne 150 GB, ktore lezalo powyzej. + /// The unsent backlog is exactly the part of the cache that rclone CANNOT + /// evict - it only evicts what it has already uploaded. As long as the + /// backlog is smaller than `cacheSizeGB`, the cache has something to shrink + /// from and the limit works. When the backlog reaches `cacheSizeGB`, there is + /// no headroom and every further gigabyte written goes OVER the limit, + /// straight into free disk space. So the pause threshold must lie BELOW the + /// cache size - unlike the old 150 GB, which lay above it. /// - /// `highGB` = polowa bufora, dzis 50 GB: - /// - zostawia 50 GB zapasu usuwalnego, czyli okolo 26 minut przy - /// zmierzonym przyroscie netto 32 MB/s - z zapasem na tykniecie co - /// 30 s i na to, zeby `tmutil stopbackup` zdazyl zadzialac; - /// - lezy ponad trzykrotnie powyzej najwyzszej zaleglosci widzianej - /// w normalnej pracy (462 pozycje, czyli okolo 15 GB), wiec zwykly - /// backup ani zator na dobowym limicie Google nie wstrzymuja kopii. - /// To ostatnie jest zamierzone i opisane nizej w `step()`. + /// `highGB` = half the buffer, 50 GB today: + /// - leaves 50 GB of evictable headroom, i.e. about 26 minutes at the + /// measured net growth of 32 MB/s - with margin for a tick every 30 s and + /// for `tmutil stopbackup` to take effect; + /// - lies more than three times above the highest backlog seen in normal + /// operation (462 items, i.e. about 15 GB), so neither an ordinary backup + /// nor a jam on Google's daily limit pauses the backups. The latter is + /// intended and described below in `step()`. /// - /// `lowGB` = jedna dziesiata bufora, dzis 10 GB: - /// - musi byc OSIAGALNY, bo na tym przewrocila sie poprzednia wersja. - /// Po pauzie nowe pasma nie powstaja, odroczenie `writeBackSeconds` - /// mija i kolejka schodzi z predkoscia lacza (zmierzone 23.09: 96 Mb/s, - /// czyli okolo 43 GB/h), wiec droga 50 -> 10 GB to okolo godziny; - /// - histereza 40 GB to przy zmierzonej roznicy predkosci (267 MB/s - /// zapisu Time Machine, 41 MB/s wysylki, netto 226 MB/s) okolo trzech - /// minut pracy miedzy kolejnymi pauzami. Prog wznowienia blisko progu - /// pauzy dawalby start/stop przy niemal kazdym tyknieciu. + /// `lowGB` = a tenth of the buffer, 10 GB today: + /// - must be REACHABLE, because that is what the previous version tripped + /// over. After a pause no new bands are created, the `writeBackSeconds` + /// delay passes and the queue drains at link speed (measured 23.09: + /// 96 Mb/s, i.e. about 43 GB/h), so the way from 50 -> 10 GB is about an + /// hour; + /// - a 40 GB hysteresis is, at the measured speed difference (267 MB/s of + /// Time Machine writes, 41 MB/s of upload, 226 MB/s net), about three + /// minutes of work between consecutive pauses. A resume threshold close to + /// the pause threshold would give start/stop on almost every tick. /// - /// Ochrona dysku NIE zalezy od tych dwoch liczb: `minFreeGB` i zglaszany - /// przez rclone `outOfSpace` dzialaja niezaleznie od zaleglosci i w KAZDYM - /// stanie dozorcy (patrz `step()`). + /// Disk protection does NOT depend on these two numbers: `minFreeGB` and the + /// `outOfSpace` reported by rclone work independently of the backlog and in + /// EVERY watchdog state (see `step()`). public init( highGB: Int = DriveBufferService.cacheSizeGB / 2, lowGB: Int = DriveBufferService.cacheSizeGB / 10, @@ -111,72 +113,72 @@ public actor BufferGuardService { } public enum State: String, Sendable { - /// Nadzorujemy trwajacy backup. + /// We are supervising a running backup. case running case pausedForBuffer case pausedForQuota - /// Backup nie trwa - czuwamy do nastepnego. + /// No backup running - we keep watch until the next one. /// - /// 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. + /// The watchdog does NOT exit after a finished backup. It runs under + /// launchd with KeepAlive, so exiting would mean an immediate restart, and + /// with Time Machine not working - a tight restart loop limited only by + /// ThrottleInterval. case idle } public struct Snapshot: Sendable { public var state: State - /// Zaleglosc niewyslana w GB. `nil` = rclone nie odpowiedzial, czyli NIE - /// WIADOMO - i wtedy dozorca ANI nie wstrzymuje, ANI nie wznawia backupu. + /// Unsent backlog in GB. `nil` = rclone did not answer, i.e. UNKNOWN - and + /// then the watchdog NEITHER pauses NOR resumes the backup. public var backlogGB: Int? - /// `nil` = pomiaru NIE BYLO (statfs zawiodl), a nie "zero gigabajtow". + /// `nil` = there was NO measurement (statfs failed), not "zero gigabytes". public var freeGB: Int? - /// `nil` = tmutil nie odpowiedzial, czyli nie wiadomo. + /// `nil` = tmutil did not answer, i.e. unknown. public var backupRunning: Bool? public var percent: Double } - /// Zrodla pomiarow i sterowania. + /// Sources of measurements and control. /// - /// Domyslne (`live`) czytaja prawdziwy system. Test podstawia wlasne i dzieki - /// temu przechodzi CALA sciezke decyzji dozorcy - pauze, zmiane stanu, - /// wznowienie - bez tmutil, rclone i prawdziwego backupu. Ten sam wzorzec, - /// co `preferencesFile` w `BackupHealth.currentReport`: nie da sie inaczej - /// wstrzyknac ZNANEJ ZLEJ probki, a wlasnie w decyzjach dozorcy (a nie - /// w parsowaniu) siedzialy tu ciche awarie. + /// The defaults (`live`) read the real system. A test substitutes its own and + /// thanks to that walks the WHOLE decision path of the watchdog - pause, state + /// change, resume - without tmutil, rclone and a real backup. The same pattern + /// as `preferencesFile` in `BackupHealth.currentReport`: there is no other way + /// to inject a KNOWN BAD sample, and it is precisely in the watchdog's + /// decisions (not in parsing) that the silent failures lived here. public struct Probes: Sendable { public var queueStats: @Sendable () async -> DriveBufferService.QueueStats? - /// Rozmiar cache'a na dysku - WYLACZNIE do jednej linii w logu. + /// Cache size on disk - ONLY for one line in the log. /// - /// Osobna sonda, a nie wywolanie w miejscu, z dwoch powodow. Pierwszy: - /// wolamy ja tylko wtedy, gdy raportujemy brak odpowiedzi rclone, bo - /// w wersji `live` to obchod 6504 plikow na dysku, po ktorym leci backup. - /// Drugi: test musi umiec pokazac, ze ta liczba nie bierze udzialu w - /// ZADNEJ decyzji - podaje jej 155 GB z produkcyjnego przebiegu 23.09 - /// i sprawdza, ze dozorca nadal nie wstrzymuje Time Machine. + /// A separate probe rather than a call in place, for two reasons. First: + /// we call it only when reporting that rclone is not answering, because in + /// the `live` version it is a walk of 6504 files on the disk the backup is + /// going to. Second: the test must be able to show that this number takes + /// part in NO decision - it feeds it the 155 GB from the production run of + /// 23.09 and checks that the watchdog still does not pause Time Machine. public var cacheSizeGB: @Sendable (DriveBufferService.QueueStats?) -> Int? - /// `nil` = nie zmierzono. + /// `nil` = not measured. public var freeGB: @Sendable () -> Int? - /// `nil` = tmutil nie odpowiedzial. + /// `nil` = tmutil did not answer. public var backupRunning: @Sendable () async -> Bool? public var progressPercent: @Sendable () async -> Double - /// Czy na Dysku Google skonczylo sie miejsce. `nil` = LOGU RCLONE NIE DA - /// SIE PRZECZYTAC, czyli nie wiadomo - a nie "nie ma problemu". + /// Whether Google Drive has run out of space. `nil` = THE RCLONE LOG CANNOT + /// BE READ, i.e. unknown - not "no problem". public var hitStorageQuota: @Sendable () -> Bool? - /// Czy wysylka stoi na dobowym limicie. `nil` jak wyzej. + /// Whether the upload is stuck on the daily limit. `nil` as above. public var uploadStalled: @Sendable () -> Bool? - /// Wolne bajty na Dysku Google. `nil` = NIE WIADOMO (rclone nie - /// odpowiedzial) - i to nie jest zgoda na wznowienie. + /// Free bytes on Google Drive. `nil` = UNKNOWN (rclone did not answer) - + /// and that is not consent to resume. public var driveFreeBytes: @Sendable () async -> UInt64? - /// `true` TYLKO gdy tmutil potwierdzil wykonanie polecenia. + /// `true` ONLY when tmutil confirmed that the command was carried out. public var stopBackup: @Sendable () async -> Bool public var startBackup: @Sendable () async -> Bool - /// Zgloszenie zatoru wysylki. `nil` ("nie wiem") NIE MA PRAWA gasic - /// znacznika zatoru - patrz `reportUploadStall`. + /// Reporting an upload jam. `nil` ("I do not know") HAS NO RIGHT to clear + /// the jam marker - see `reportUploadStall`. public var reportStall: @Sendable (Bool?) async -> Void - /// Wydzielone, zeby test nie dopisywal swoich zmyslonych "PAUZA (prog)" - /// do produkcyjnego `cloudmachine.log` - ten log sluzy do diagnozy - /// prawdziwych awarii i nie moze zawierac zdarzen, ktore sie nie zdarzyly. + /// Split out so that a test does not append its made-up "PAUSE (threshold)" + /// to the production `cloudmachine.log` - that log serves to diagnose real + /// failures and must not contain events that did not happen. public var log: @Sendable (String) -> Void public init( @@ -213,8 +215,8 @@ public actor BufferGuardService { freeGB: { BufferGuardService.freeGB() }, backupRunning: { await TimeMachineStatus.runningState() }, progressPercent: { (await TimeMachineStatus.currentProgress())?.percent ?? 0 }, - // Wersje `...State()`, nie `hitStorageQuota()`/`uploadStalled()`: te - // drugie sa do wyswietlenia i zamieniaja "nie wiem" na `false`. + // The `...State()` versions, not `hitStorageQuota()`/`uploadStalled()`: + // the latter are for display and turn "I do not know" into `false`. hitStorageQuota: { DriveBufferService.hitStorageQuotaState() }, uploadStalled: { DriveBufferService.uploadStalledState() }, driveFreeBytes: { await DriveBufferService.remoteQuota()?.free }, @@ -227,22 +229,23 @@ public actor BufferGuardService { private let thresholds: Thresholds private let probes: Probes private var state: State = .idle - /// Czy od ostatniego przejscia w czuwanie widzielismy dzialajacy backup - - /// zeby zameldowac zakonczenie raz, a nie przy kazdym tyknieciu. + /// Whether since the last transition to idle we have seen a running backup - + /// so that the end is reported once, not on every tick. private var sawBackupRunning = false - /// Czy poprzedni krok juz zglosil nieudane wstrzymanie - zeby przy awarii - /// trwajacej godzinami log nie urosl o linie co 30 sekund, ale zeby samo - /// zdarzenie NIE zniknelo (patrz `EdgeTriggeredLog`, ten sam powod). + /// Whether the previous step already reported a failed pause - so that with a + /// failure lasting hours the log does not grow by a line every 30 seconds, + /// but the event itself does NOT disappear (see `EdgeTriggeredLog`, same + /// reason). private var reportedStopFailure = false - /// To samo dla braku pomiaru wolnego miejsca. + /// The same for a missing free-space measurement. private var reportedFreeUnknown = false - /// To samo dla braku odpowiedzi o zaleglosci niewyslanej. + /// The same for no answer about the unsent backlog. private var reportedBacklogUnknown = false - /// To samo dla nieczytelnego logu rclone. + /// The same for an unreadable rclone log. private var reportedLogUnreadable = false - /// To samo dla Time Machine, ktory ruszyl w trakcie pauzy. + /// The same for Time Machine starting during a pause. private var reportedRestop = false - /// To samo dla pauzy z powodu braku miejsca na Dysku trzymanej bez dowodu. + /// The same for a pause for lack of space on Drive held without proof. private var reportedQuotaHold = false public init(thresholds: Thresholds = Thresholds(), probes: Probes = .live) { @@ -250,90 +253,91 @@ public actor BufferGuardService { self.probes = probes } - // MARK: - Pomiary + // MARK: - Measurements - /// ZALEGLOSC NIEWYSLANA w GB - jedyna miara, na ktorej dozorca decyduje - /// o pauzie i o wznowieniu. `nil` = rclone nie odpowiedzial, czyli NIE WIEMY. + /// UNSENT BACKLOG in GB - the only measure on which the watchdog decides to + /// pause and to resume. `nil` = rclone did not answer, i.e. WE DO NOT KNOW. /// - /// DLACZEGO NIE ROZMIAR CACHE'A + /// WHY NOT THE CACHE SIZE /// - /// Do 25.09.2026 dozorca patrzyl na `stats.bytesUsed`, czyli na rozmiar - /// calego cache'a rclone. Przy `--vfs-cache-max-size 100G` i - /// `--vfs-cache-max-age 9999h` ta liczba stoi pod limitem zawsze - rclone - /// trzyma w cache'u takze to, co dawno wyslal. Dozorca mierzyl wiec stan - /// prawie STALY i pytal go o rzecz ZMIENNA: czy wysylka nadaza za zapisem. - /// Skutek w dzienniku: 281 pomiarow, minimum 99 GB, jedna pauza i ani jedno - /// wznowienie. Zaleglosc niewyslana to ta sama wielkosc, ktora decyduje - /// o tym, czy cache w ogole moze sie skurczyc - patrz `Thresholds.init`. + /// Until 25.09.2026 the watchdog looked at `stats.bytesUsed`, i.e. the size of + /// the whole rclone cache. With `--vfs-cache-max-size 100G` and + /// `--vfs-cache-max-age 9999h` that number always sits at the limit - rclone + /// also keeps in the cache what it uploaded long ago. So the watchdog measured + /// an almost CONSTANT state and asked it about a CHANGING thing: whether the + /// upload is keeping up with the writes. Result in the journal: 281 + /// measurements, minimum 99 GB, one pause and not a single resume. The unsent + /// backlog is the same quantity that decides whether the cache can shrink at + /// all - see `Thresholds.init`. /// - /// DLACZEGO Z LICZBY POZYCJI, A NIE Z BAJTOW + /// WHY FROM THE ITEM COUNT, NOT FROM BYTES /// - /// `vfs/stats` nie podaje liczby niewyslanych BAJTOW: ma liczniki pozycji - /// (`uploadsQueued`, `uploadsInProgress`) i `bytesUsed` calego cache'a. - /// Rozmiary pozycji wystawia `vfs/queue`, ale to DRUGIE wywolanie interfejsu - /// rc w kazdym tyknieciu, a samo `vfs/stats` bylo tu zmierzone na 36,7 s - /// przy zapchanym buforze (patrz `DriveBufferService.queueStats`) - - /// podwojenie tego kosztu wydluza reakcje dokladnie wtedy, gdy zaleglosc - /// rosnie najszybciej. I prawie nic by nie dalo: kazda pozycja w tej kolejce - /// to pasmo sparsebundle o STALYM rozmiarze `BackupImageService.bandSectors` - /// (32 MiB), wiec suma rozmiarow to niemal dokladnie liczba pozycji razy - /// 32 MiB. Kontrola na prawdziwych liczbach: 462 pozycje z 23.09 daja stad - /// 14 GB, a wlasciciel liczyl "okolo 15 GB". + /// `vfs/stats` does not report the number of unsent BYTES: it has item + /// counters (`uploadsQueued`, `uploadsInProgress`) and `bytesUsed` of the + /// whole cache. Item sizes are exposed by `vfs/queue`, but that is a SECOND rc + /// interface call on every tick, and `vfs/stats` alone was measured here at + /// 36.7 s with a clogged buffer (see `DriveBufferService.queueStats`) - + /// doubling that cost lengthens the reaction exactly when the backlog grows + /// fastest. And it would give almost nothing: every item in this queue is a + /// sparsebundle band of a FIXED size `BackupImageService.bandSectors` + /// (32 MiB), so the sum of sizes is almost exactly the item count times + /// 32 MiB. Check against real numbers: the 462 items of 23.09 give 14 GB from + /// here, and the owner estimated "about 15 GB". /// - /// TO JEST SZACUNEK, nie pomiar - i tak jest opisany w logu (znak "~"). - /// Blad idzie w JEDNA strone: pasma niepelne i drobne pliki metadanych sa - /// MNIEJSZE niz 32 MiB, wiec szacunek zawyza zaleglosc, a zawyzona zaleglosc - /// wstrzymuje backup wczesniej. Przy ochronie dysku to wlasciwy kierunek - /// pomylki. + /// THIS IS AN ESTIMATE, not a measurement - and it is described as such in the + /// log (the "~" sign). The error goes ONE way: partial bands and small metadata + /// files are SMALLER than 32 MiB, so the estimate overstates the backlog, and an + /// overstated backlog pauses the backup earlier. For disk protection that is + /// the right direction to err in. public static func backlogGB(stats: DriveBufferService.QueueStats?) -> Int? { guard let stats else { return nil } let bandBytes = UInt64(BackupImageService.bandSectors) * 512 return Int(UInt64(max(0, stats.unsentItems)) * bandBytes / 1_073_741_824) } - /// Rozmiar cache'a rclone w GB - do PODGLADU I LOGU, nigdy do decyzji. - /// `nil` = nie zmierzono ani jedna droga. + /// rclone cache size in GB - for THE VIEW AND THE LOG, never for decisions. + /// `nil` = not measured either way. /// - /// Dwa zrodla tej liczby NIE SA ROWNOWAZNE i dlatego nie wolno ich mieszac - /// w decyzji: rclone podaje rozmiar wlasnego cache'a, a obchod katalogu - /// MIEJSCE ZAJETE NA DYSKU, ktore limit `--vfs-cache-max-size` potrafi - /// przekroczyc (stad "155 GB" przy limicie 100 GB). Do wiersza statusu oba - /// nadaja sie na tyle, na ile nadaje sie kazde przyblizenie; do wstrzymania - /// Time Machine nie nadaje sie zadne - patrz - /// `DriveBufferService.cacheSizeBytesByWalk`. + /// The two sources of this number are NOT EQUIVALENT, and that is why they + /// must not be mixed in a decision: rclone reports the size of its own cache, + /// while the directory walk reports DISK SPACE TAKEN, which can exceed the + /// `--vfs-cache-max-size` limit (hence "155 GB" with a 100 GB limit). For a + /// status line both are as good as any approximation; for pausing Time + /// Machine neither is - see `DriveBufferService.cacheSizeBytesByWalk`. public static func cacheSizeGB(stats: DriveBufferService.QueueStats? = nil) -> Int? { if let bytes = stats?.bytesUsed, bytes > 0 { return Int(bytes / 1_073_741_824) } guard let walked = DriveBufferService.cacheSizeBytesByWalk() else { return nil } return Int(walked / 1_073_741_824) } - /// Rozmiar cache'a dla interfejsu, ktory nie ma gdzie pokazac "nie wiem" - /// (`BufferStatus.sizeGB`). Zachowuje sie dokladnie tak, jak zachowywal sie - /// dawny `bufferGB` - z podstawionym zerem wlacznie. + /// Cache size for an interface that has nowhere to show "I do not know" + /// (`BufferStatus.sizeGB`). Behaves exactly as the old `bufferGB` did - + /// substituted zero included. /// - /// ZADNA DECYZJA nie ma prawa tego wolac; dozorca uzywa `backlogGB`. Zero za - /// brak pomiaru zostaje tu do usuniecia razem z `BufferStatus` i - /// `CloudMachineController`, ktore trzeba nauczyc trzeciego stanu - to - /// osobna zmiana, poza ta galezia. + /// NO DECISION may call this; the watchdog uses `backlogGB`. The zero for a + /// missing measurement stays here until it is removed together with + /// `BufferStatus` and `CloudMachineController`, which have to learn a third + /// state - that is a separate change, outside this branch. public static func bufferGB(stats: DriveBufferService.QueueStats? = nil) -> Int { cacheSizeGB(stats: stats) ?? 0 } - /// Wolne miejsce liczone tak, jak liczy je `df` - czyli PESYMISTYCZNIE. + /// Free space counted the way `df` counts it - i.e. PESSIMISTICALLY. /// - /// 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. + /// It is tempting to use `volumeAvailableCapacityForImportantUsageKey`, but + /// that is an optimistic measure: it includes space taken by local snapshots + /// that the system only COULD free. On this machine it showed 1202 GB while + /// `df` said 427 GB. The watchdog is supposed to pause the backup before the + /// disk fills up, so it has to look at the space actually available now, not + /// at a promise. /// - /// `nil` znaczy "NIE ZMIERZONO", i to nie jest kosmetyka. Wczesniej nieudany - /// `statfs` zwracal `0`, czyli liczbe - a wtedy warunek pauzy - /// (`free <= minFreeGB`) byl spelniony natychmiast, warunek wznowienia - /// (`free > minFreeGB`) NIGDY, i dozorca wstrzymywal Time Machine na zawsze. - /// Rownolegle czujka meldowala "Konczy sie miejsce na dysku Maca (0 GB)" - - /// alarm o stanie, ktorego nikt nie zmierzyl. To samo rozroznienie, ktore - /// `BufferStatus.queueKnown` wprowadzil juz dla kolejki wysylki. + /// `nil` means "NOT MEASURED", and that is not cosmetic. Previously a failed + /// `statfs` returned `0`, i.e. a number - and then the pause condition + /// (`free <= minFreeGB`) was met immediately, the resume condition + /// (`free > minFreeGB`) NEVER, and the watchdog paused Time Machine forever. + /// In parallel the monitor reported "The Mac's disk is running out of space + /// (0 GB)" - an alarm about a state nobody measured. The same distinction that + /// `BufferStatus.queueKnown` already introduced for the upload queue. public static func freeGB() -> Int? { var stats = statfs() guard statfs("/System/Volumes/Data", &stats) == 0 else { return nil } @@ -341,45 +345,46 @@ public actor BufferGuardService { return Int(available / 1_073_741_824) } - /// Czy wolno wznowic Time Machine, patrzac WYLACZNIE na pomiary lokalne. + /// Whether Time Machine may be resumed, looking ONLY at local measurements. /// - /// Czysta funkcja - decyzja da sie sprawdzic testem bez dysku i bez tmutil. - /// `free == nil` nie wznawia: brak pomiaru to nie jest dowod, ze miejsce - /// jest. Wznowienie sprawdza wolne miejsce TAK SAMO jak pauza, bo pauza - /// chroniaca dysk nie moze byc odwolywana przez warunek, ktory o dysku nic - /// nie wie. + /// A pure function - the decision can be tested without a disk and without + /// tmutil. `free == nil` does not resume: a missing measurement is not proof + /// that space is there. Resuming checks free space THE SAME WAY as pausing, + /// because a pause protecting the disk must not be lifted by a condition that + /// knows nothing about the disk. /// - /// `backlog == nil` tez nie wznawia, i to jest ta sama regula zastosowana do - /// drugiej liczby. Wczesniej brak odpowiedzi rclone konczyl sie obchodem - /// katalogu, a nieudany obchod - zerem; zero zas spelnia warunek wznowienia - /// natychmiast, czyli ZDEJMOWALO pauze zalozona dlatego, ze bufor byl pelny. + /// `backlog == nil` does not resume either, and that is the same rule applied + /// to the second number. Previously no answer from rclone ended in a directory + /// walk, and a failed walk - in zero; and zero meets the resume condition + /// immediately, i.e. it LIFTED a pause put in place because the buffer was + /// full. static func canResumeLocally(backlog: Int?, free: Int?, thresholds: Thresholds) -> Bool { guard let backlog, let free else { return false } return backlog <= thresholds.lowGB && free > thresholds.minFreeGB } - /// Czy na Dysku Google jest DOWIEDZIONE miejsce na dalsza prace. + /// Whether there is PROVEN room on Google Drive for further work. /// - /// `nil` (rclone nie odpowiedzial) to NIE jest zgoda - patrz `step()`. + /// `nil` (rclone did not answer) is NOT consent - see `step()`. static func driveHasRoom(freeBytes: UInt64?, minGB: Int) -> Bool { guard let freeBytes else { return false } return freeBytes / 1_073_741_824 >= UInt64(max(0, minGB)) } - // MARK: - Jeden krok + // MARK: - One step - /// Wykonuje jeden krok nadzoru i zwraca stan. Wydzielone z petli, zeby dalo - /// sie sprawdzic decyzje testem bez czekania w czasie rzeczywistym. + /// Performs one supervision step and returns the state. Split out of the loop + /// so that decisions can be tested without waiting in real time. /// - /// UKLAD TEJ FUNKCJI JEST CZESCIA POPRAWKI. Do 25.09.2026 sprawdzenia - /// `stats?.outOfSpace` i wolnego miejsca siedzialy WYLACZNIE w galezi - /// `.running`, a galaz `.pausedForBuffer` nie patrzyla na nic poza warunkiem - /// wznowienia. Po jednej pauzie dozorca przestawal wiec pilnowac dysku - - /// czyli ochrona, dla ktorej ten proces istnieje, wylaczala sie do restartu - /// agenta. Zmierzone: 53 godziny w tym stanie (pauza 23.09.2026 03:34 -> - /// restart procesu 25.09.2026 08:46). Dlatego ochrona dysku i `outOfSpace` - /// stoja TERAZ PRZED `switch state` i nie da sie ich pominac zadna sciezka - /// przez te funkcje. + /// THE LAYOUT OF THIS FUNCTION IS PART OF THE FIX. Until 25.09.2026 the checks + /// of `stats?.outOfSpace` and free space sat ONLY in the `.running` branch, + /// and the `.pausedForBuffer` branch looked at nothing except the resume + /// condition. After one pause the watchdog therefore stopped guarding the + /// disk - i.e. the protection this process exists for switched off until the + /// agent restarted. Measured: 53 hours in this state (pause 23.09.2026 03:34 + /// -> process restart 25.09.2026 08:46). That is why disk protection and + /// `outOfSpace` NOW stand BEFORE `switch state`, and no path through this + /// function can skip them. @discardableResult public func step() async -> Snapshot { let stats = await probes.queueStats() @@ -387,8 +392,8 @@ public actor BufferGuardService { let free = probes.freeGB() let running = await probes.backupRunning() let percent = await probes.progressPercent() - // Oba pytania ida do logu rclone i oba sa trzystanowe: nieczytelny plik - // to "nie wiem", nie "nie ma problemu". + // Both questions go to the rclone log and both are three-state: an + // unreadable file is "I do not know", not "no problem". let quota = probes.hitStorageQuota() let stalled = probes.uploadStalled() @@ -397,111 +402,112 @@ public actor BufferGuardService { state: state, backlogGB: backlog, freeGB: free, backupRunning: running, percent: percent) } - // Nieudany pomiar wolnego miejsca NIE moze przejsc po cichu: od tej liczby - // zalezy jedyna ochrona dysku przed zapelnieniem, a bez niej dozorca nie - // wstrzyma Time Machine (i slusznie - nie zgaduje). Czlowiek musi o tym - // wiedziec z logu, a nie z pelnego dysku. + // A failed free-space measurement must NOT pass silently: the only + // protection of the disk against filling up depends on this number, and + // without it the watchdog will not pause Time Machine (and rightly so - it + // does not guess). A person must learn about it from the log, not from a + // full disk. if free == nil, !reportedFreeUnknown { probes.log( - "UWAGA: nie da sie zmierzyc wolnego miejsca na dysku (statfs zawiodl) - dozorca nie wstrzyma Time Machine z powodu dysku, bo nie ma na czym oprzec decyzji." + "WARNING: cannot measure free disk space (statfs failed) - the watchdog will not pause Time Machine because of the disk, as it has nothing to base the decision on." ) } reportedFreeUnknown = (free == nil) - // Brak odpowiedzi rclone tez nie moze przejsc po cichu - ale tym razem NIE - // ZAMIENIAMY go na liczbe. 23.09.2026 dozorca w tej sytuacji schodzil na - // obchod katalogu, dostawal 155 GB (miejsce zajete na dysku - miara - // nieporownywalna z limitem 100 GB), przekraczal tym prog i wstrzymywal - // Time Machine; godzine pozniej czujka zapisala "Interfejs sterujacy - // rclone nie odpowiada". Pauza stala wiec na liczbie wzietej stad, ze - // pomiaru nie bylo. Rozmiar cache'a wypisujemy nadal - ale JAKO CO INNEGO, - // raz na epizod i bez zadnego wplywu na decyzje. + // No answer from rclone must not pass silently either - but this time we do + // NOT TURN it into a number. On 23.09.2026 in this situation the watchdog + // fell back to the directory walk, got 155 GB (disk space taken - a measure + // not comparable with the 100 GB limit), crossed the threshold with it and + // paused Time Machine; an hour later the monitor wrote "rclone remote + // control is not answering". So the pause rested on a number taken from the + // fact that there was no measurement. We still print the cache size - but AS + // SOMETHING ELSE, once per episode and with no effect on decisions. if backlog == nil, !reportedBacklogUnknown { let cache = probes.cacheSizeGB(stats) probes.log( - "UWAGA: interfejs sterujacy rclone nie odpowiada - nie wiadomo, ile zostalo do wyslania. Dozorca ANI nie wstrzyma, ANI nie wznowi Time Machine na tej podstawie. Cache zajmuje na dysku \(cache.map { "\($0) GB" } ?? "nie wiadomo ile") - to MIEJSCE NA DYSKU, nie zaleglosc do wyslania, i nie jest podstawa do pauzy. Ochrona dysku dziala dalej: wolne \(describe(free)), prog \(thresholds.minFreeGB) GB." + "WARNING: rclone remote control is not answering - unknown how much is left to upload. The watchdog will NEITHER pause NOR resume Time Machine on that basis. The cache takes \(cache.map { "\($0) GB" } ?? "an unknown amount") on disk - that is DISK SPACE, not a backlog to upload, and it is no basis for a pause. Disk protection keeps working: free \(describe(free)), threshold \(thresholds.minFreeGB) GB." ) } reportedBacklogUnknown = (backlog == nil) - // Nieczytelny log rclone znaczy "nie wiem" po OBU pytaniach zadawanych - // temu plikowi. Wczesniej znaczyl "nie ma problemu", a to mialo dwa - // skutki: dozorca nie wstrzymywal backupu przy braku miejsca na Dysku, - // a `reportStall(false)` KASOWAL znacznik zatoru i meldowal "Wysylka na - // Google Drive ruszyla z powrotem" - twierdzenie o zdarzeniu, ktorego - // nikt nie sprawdzil. Log ma prawa `-rw-r-----`, a przy starcie rclone - // jest przenoszony na `.1`, wiec nieczytelny log to stan spodziewany, - // nie hipoteza. + // An unreadable rclone log means "I do not know" for BOTH questions asked + // of this file. Previously it meant "no problem", and that had two effects: + // the watchdog did not pause the backup when Drive was out of space, and + // `reportStall(false)` DELETED the jam marker and reported "Upload to Google + // Drive has resumed" - a claim about an event nobody checked. The log has + // `-rw-r-----` permissions, and at rclone start-up it is moved to `.1`, so + // an unreadable log is an expected state, not a hypothesis. let logUnreadable = (quota == nil || stalled == nil) if logUnreadable, !reportedLogUnreadable { probes.log( - "UWAGA: nie da sie przeczytac logu rclone (\(DriveBufferService.logFile.path)) - dozorca nie rozpozna ani braku miejsca na Google Drive, ani zatoru wysylki. Znacznik zatoru zostaje bez zmian, bo 'nie wiem' go nie gasi." + "WARNING: cannot read the rclone log (\(DriveBufferService.logFile.path)) - the watchdog will recognise neither lack of space on Google Drive nor an upload jam. The jam marker stays unchanged, because 'I do not know' does not clear it." ) } reportedLogUnreadable = logUnreadable - // Dobowy limit uploadu to CO INNEGO i celowo NIE wstrzymuje backupu. + // The daily upload limit is SOMETHING ELSE and deliberately does NOT pause + // the backup. // - // Zmierzone na dwoch epizodach (12 i 15 wrzesnia 2026): przy zatorze - // trwajacym kilka godzin bufor ani drgnal - 99-103 GB, dokladnie tyle, - // co zwykle - a kolejka rozeszla sie sama, gdy okno kroczace 24 h - // przesunelo sie do przodu. Pauza kosztowalaby wtedy kopie i nie dalaby - // nic w zamian. Przed zapelnieniem dysku chronia progi ponizej i one - // dzialaja niezaleznie od tego, co jest przyczyna zatoru. + // Measured on two episodes (12 and 15 September 2026): during a jam lasting + // several hours the buffer did not budge - 99-103 GB, exactly as usual - + // and the queue cleared by itself once the rolling 24 h window moved + // forward. A pause would then have cost backups and given nothing in + // return. The thresholds below protect against filling the disk, and they + // work regardless of what causes the jam. // - // Zglaszamy natomiast ZAWSZE, bo zator z 12 wrzesnia przeszedl zupelnie - // niezauwazony - trzy godziny bez wysylki i ani jednego sladu poza - // surowym logiem rclone. `nil` idzie dalej jako `nil`: zgloszenie samo - // wie, ze "nie wiem" niczego nie gasi. + // We do, however, ALWAYS report it, because the jam of 12 September went + // completely unnoticed - three hours without uploads and not a single trace + // except the raw rclone log. `nil` is passed on as `nil`: the report itself + // knows that "I do not know" clears nothing. await probes.reportStall(stalled) - // Brak MIEJSCA na Dysku ma pierwszenstwo i nie minie sam: dopoki - // uzytkownik czegos nie skasuje, wysylka nie ruszy, a dalsza praca - // Time Machine tylko pompuje bufor. `nil` (nieczytelny log) NIE wstrzymuje - // - brak odpowiedzi nie jest dowodem awarii, tak samo jak nie jest - // dowodem jej braku; zglosilismy go wyzej w logu. + // Lack of SPACE on Drive takes precedence and will not pass by itself: + // until the user deletes something, the upload will not move, and further + // Time Machine work only pumps up the buffer. `nil` (unreadable log) does + // NOT pause - no answer is not proof of a failure, just as it is not proof + // of its absence; we reported it in the log above. if quota == true { if state == .pausedForQuota { await keepPaused(backupRunning: running) } else { await pause( reason: - "PAUZA (brak miejsca na Google Drive): zaleglosc \(describeBacklog(backlog)), wolne \(describe(free))", + "PAUSE (no space on Google Drive): backlog \(describeBacklog(backlog)), free \(describe(free))", into: .pausedForQuota, backupRunning: running) } return snapshot() } - // OCHRONA DYSKU - W KAZDYM STANIE, nie tylko w `.running`. + // DISK PROTECTION - IN EVERY STATE, not only in `.running`. // - // `outOfSpace` pochodzi od rclone i znaczy "nie mam juz gdzie odlozyc - // danych" - to twardszy fakt niz jakikolwiek nasz prog, i nie przestaje - // byc faktem dlatego, ze dozorca wlasnie stoi w pauzie. + // `outOfSpace` comes from rclone and means "I have nowhere left to put + // data" - a harder fact than any threshold of ours, and it does not stop + // being a fact because the watchdog happens to be paused. // - // Brak pomiaru wolnego miejsca (`free == nil`) NIE wstrzymuje backupu. - // Wczesniej nieudany `statfs` dawal zero, zero spelnialo warunek pauzy - // i dozorca wstrzymywal Time Machine na podstawie liczby, ktorej nigdy - // nie zmierzyl - a potem nie umial go wznowic, bo warunek wznowienia - // przy zerze nie zachodzi nigdy. + // A missing free-space measurement (`free == nil`) does NOT pause the + // backup. Previously a failed `statfs` gave zero, zero met the pause + // condition and the watchdog paused Time Machine based on a number it + // never measured - and then could not resume it, because the resume + // condition never holds at zero. let lowDisk = free.map { $0 <= thresholds.minFreeGB } ?? false let bufferFull = stats?.outOfSpace == true if bufferFull || lowDisk { let why = - bufferFull ? "rclone zglasza brak miejsca w buforze" : "malo wolnego miejsca na dysku" + bufferFull ? "rclone reports no space in the buffer" : "little free disk space" switch state { case .pausedForBuffer, .pausedForQuota: - // Juz stoimy, wiec nie ma czego oglaszac - ale Time Machine mogl - // ruszyc sam w swoim cyklu godzinowym, wiec wstrzymanie ponawiamy. - // WAZNE: nie wracamy stad do wznawiania. Dopoki dysk jest pod sciana, - // zaden warunek wznowienia nie ma prawa zdjac pauzy. + // Already paused, so there is nothing to announce - but Time Machine may + // have started by itself in its hourly cycle, so we repeat the pause. + // IMPORTANT: we do not go on to resuming from here. As long as the disk + // is against the wall, no resume condition may lift the pause. await keepPaused(backupRunning: running) case .running, .idle: - // Takze z `.idle`: dysk zapelnia sie niezaleznie od tego, czy backup - // trwa w tej sekundzie, a macOS zaczyna kolejny co godzine. Wejscie - // w pauze sprawia, ze nastepne tykniecie go zatrzyma. + // Also from `.idle`: the disk fills up regardless of whether a backup + // is running this second, and macOS starts another one every hour. + // Entering the pause makes the next tick stop it. await pause( reason: - "PAUZA (\(why)): zaleglosc \(describeBacklog(backlog)), wolne \(describe(free)) - czekam na wysylke", + "PAUSE (\(why)): backlog \(describeBacklog(backlog)), free \(describe(free)) - waiting for the upload", into: .pausedForBuffer, backupRunning: running) } return snapshot() @@ -509,36 +515,36 @@ public actor BufferGuardService { switch state { case .idle: - // Tylko JAWNE "tak". `nil` (tmutil nie odpowiedzial) zostawia stan bez - // zmiany - nie zaczynamy nadzoru nad czyms, o czym nic nie wiemy. + // Only an EXPLICIT "yes". `nil` (tmutil did not answer) leaves the state + // unchanged - we do not start supervising something we know nothing about. if running == true { - probes.log("Backup ruszyl - nadzoruje zaleglosc wysylki") + probes.log("Backup started - supervising the upload backlog") sawBackupRunning = true state = .running } case .running: - // `backlog == nil` NIE wstrzymuje: to ten sam wzorzec, co przy `free`. - // Brak odpowiedzi rclone nie jest liczba i nie ma prawa uruchomic - // nieodwracalnej pauzy. + // `backlog == nil` does NOT pause: the same pattern as with `free`. + // No answer from rclone is not a number and has no right to trigger an + // irreversible pause. if let backlog, backlog >= thresholds.highGB { await pause( reason: - "PAUZA (prog zaleglosci \(thresholds.highGB) GB): zaleglosc \(describeBacklog(backlog)), wolne \(describe(free)) - czekam na wysylke", + "PAUSE (backlog threshold \(thresholds.highGB) GB): backlog \(describeBacklog(backlog)), free \(describe(free)) - waiting for the upload", into: .pausedForBuffer, backupRunning: running) } else if running == false { if sawBackupRunning { - probes.log("Time Machine zakonczyl. Zaleglosc \(describeBacklog(backlog))") + probes.log("Time Machine finished. Backlog \(describeBacklog(backlog))") sawBackupRunning = false } state = .idle } case .pausedForBuffer: - // Wznawiamy dopiero, gdy wysylka faktycznie nadgonila - inaczej - // wpadlibysmy w oscylacje start/stop przy progu. Dopoki nie nadgonila, - // PODTRZYMUJEMY wstrzymanie: `tmutil stopbackup` z chwili pauzy dotyczyl - // tylko tego jednego przebiegu. + // We resume only when the upload has actually caught up - otherwise we + // would fall into start/stop oscillation at the threshold. Until it has + // caught up, we KEEP UP the pause: the `tmutil stopbackup` from the moment + // of pausing applied only to that one run. if Self.canResumeLocally(backlog: backlog, free: free, thresholds: thresholds) { await resume(backlogGB: backlog, freeGB: free) } else { @@ -546,32 +552,31 @@ public actor BufferGuardService { } case .pausedForQuota: - // Pauza z powodu BRAKU MIEJSCA na Dysku wymaga do zdjecia POZYTYWNEGO - // dowodu, ze miejsce jest. To nie jest ostroznosc na zapas: + // A pause for LACK OF SPACE on Drive needs POSITIVE proof that space is + // there before it is lifted. This is not extra caution: // - // `hitStorageQuota()` czyta wpisy z ostatnich 30 minut logu rclone. - // Po wstrzymaniu Time Machine nowe pasma przestaja powstawac, rclone - // przestaje probowac wysylac, wpisy sie starzeja i funkcja zaczyna - // zwracac `false` - mimo ze na Dysku jak nie bylo miejsca, tak nie ma. - // Przy spokojnym buforze (a po pauzie bufor sie wlasnie oprozni) - // wspolny warunek wznowienia byl wtedy spelniony natychmiast: dozorca - // puszczal Time Machine, ten pisal kolejne pasma, ktorych nie ma jak - // wyslac, i cala pauza konczyla sie po kilkudziesieciu minutach bez - // zmiany czegokolwiek po stronie Dysku. `UploadState` mowi wprost, ze - // ten stan NIE mija sam. + // `hitStorageQuota()` reads entries from the last 30 minutes of the rclone + // log. After Time Machine is paused no new bands are created, rclone stops + // trying to upload, the entries age and the function starts returning + // `false` - even though Drive has as little space as before. With a quiet + // buffer (and after a pause the buffer does empty) the shared resume + // condition was then met immediately: the watchdog let Time Machine go, it + // wrote more bands that cannot be uploaded, and the whole pause ended after + // a few dozen minutes without anything changing on the Drive side. + // `UploadState` says plainly that this state does NOT pass by itself. guard Self.canResumeLocally(backlog: backlog, free: free, thresholds: thresholds) else { await keepPaused(backupRunning: running) break } let driveFree = await probes.driveFreeBytes() guard Self.driveHasRoom(freeBytes: driveFree, minGB: thresholds.minDriveFreeGB) else { - // Brak odpowiedzi od rclone PODTRZYMUJE pauze - "nie wiem" nigdy nie - // jest zgoda na wznowienie czegos, co zapelnia dysk. + // No answer from rclone KEEPS the pause - "I do not know" is never + // consent to resume something that fills up the disk. if !reportedQuotaHold { - let ile = - driveFree.map { "\($0 / 1_073_741_824) GB" } ?? "nie wiadomo (rclone nie odpowiedzial)" + let amount = + driveFree.map { "\($0 / 1_073_741_824) GB" } ?? "unknown (rclone did not answer)" probes.log( - "PAUZA (brak miejsca na Dysku) utrzymana: wolne na Google Drive \(ile), wymagane co najmniej \(thresholds.minDriveFreeGB) GB." + "PAUSE (no space on Drive) kept: free on Google Drive \(amount), required at least \(thresholds.minDriveFreeGB) GB." ) reportedQuotaHold = true } @@ -587,41 +592,42 @@ public actor BufferGuardService { public func currentState() -> State { state } - /// Wolne miejsce do logu. Brak pomiaru MUSI wygladac inaczej niz zero, - /// inaczej linia w logu klamie tak samo, jak klamala sama liczba. + /// Free space for the log. A missing measurement MUST look different from + /// zero, otherwise the log line lies just as the number itself used to. private func describe(_ freeGB: Int?) -> String { - freeGB.map { "\($0) GB" } ?? "nie zmierzono" + freeGB.map { "\($0) GB" } ?? "not measured" } - /// Zaleglosc do logu. Znak "~" nie jest ozdoba: ta liczba jest SZACOWANA - /// z liczby pozycji w kolejce (patrz `backlogGB`), a log, ktory podaje - /// szacunek jako pomiar, klamie o tym, jak mocna jest podstawa decyzji. + /// Backlog for the log. The "~" sign is not decoration: this number is + /// ESTIMATED from the number of items in the queue (see `backlogGB`), and a + /// log that gives an estimate as a measurement lies about how solid the basis + /// of the decision is. private func describeBacklog(_ gb: Int?) -> String { - gb.map { "~\($0) GB" } ?? "nie wiadomo (rclone nie odpowiedzial)" + gb.map { "~\($0) GB" } ?? "unknown (rclone did not answer)" } - // MARK: - Sterowanie Time Machine + // MARK: - Controlling Time Machine - /// Wstrzymuje Time Machine i przechodzi w `into` TYLKO gdy sie udalo. + /// Pauses Time Machine and moves to `into` ONLY when that succeeded. /// - /// TO jest ta poprawka. Wczesniej wynik `tmutil stopbackup` byl wyrzucany - /// (`_ = try? await ...`), a stan zmienial sie BEZWARUNKOWO. Gdy polecenie - /// padalo - brak uprawnien, przekroczony limit czasu - dozorca uznawal pauze - /// za wykonana, a poniewaz `stopBackup()` wola sie wylacznie przy ZMIANIE - /// stanu, nie ponawial jej nigdy. Time Machine pisal dalej, dozorca czekal - /// na drenaz, dysk zapelnial sie do konca, a w logu stalo "PAUZA ... czekam - /// na wysylke". + /// THIS is the fix. Previously the result of `tmutil stopbackup` was thrown + /// away (`_ = try? await ...`), and the state changed UNCONDITIONALLY. When + /// the command failed - no permissions, time limit exceeded - the watchdog + /// considered the pause done, and since `stopBackup()` was called only on a + /// state CHANGE, it never retried. Time Machine kept writing, the watchdog + /// waited for the drain, the disk filled up completely, and the log said + /// "PAUSE ... waiting for the upload". /// - /// Przy niepowodzeniu stan zostaje na `.running`, wiec warunek pauzy - /// (nadal spelniony) wyzwoli kolejna probe przy nastepnym tyknieciu - czyli - /// za 30 sekund, bez zadnego dodatkowego mechanizmu ponawiania. + /// On failure the state stays at `.running`, so the pause condition (still + /// met) triggers another attempt on the next tick - i.e. in 30 seconds, + /// without any extra retry mechanism. /// - /// `backupRunning == false` (tmutil mowi WPROST, ze backup nie trwa) jest - /// osobna sciezka: nie ma wtedy czego wstrzymywac, a wolanie `stopbackup` - /// bez trwajacego backupu potrafi zwrocic blad - i dozorca zameldowalby - /// wtedy "Time Machine PISZE DALEJ", czyli twierdzenie o zdarzeniu, ktorego - /// nikt nie sprawdzil. `nil` ("tmutil nie odpowiedzial") idzie sciezka - /// scisla, bo brak odpowiedzi nie jest dowodem ciszy. + /// `backupRunning == false` (tmutil says EXPLICITLY that no backup is + /// running) is a separate path: there is nothing to pause then, and calling + /// `stopbackup` without a running backup can return an error - and the + /// watchdog would then report "Time Machine KEEPS WRITING", i.e. a claim + /// about an event nobody checked. `nil` ("tmutil did not answer") takes the + /// strict path, because no answer is not proof of quiet. private func pause(reason: String, into paused: State, backupRunning: Bool?) async { probes.log(reason) if backupRunning == false { @@ -636,38 +642,39 @@ public actor BufferGuardService { } if !reportedStopFailure { probes.log( - "NIE UDALO SIE wstrzymac Time Machine (tmutil stopbackup). Stan zostaje na '\(state.rawValue)', ponawiam przy kazdym kolejnym sprawdzeniu. Time Machine PISZE DALEJ - dysk moze sie zapelnic." + "FAILED to pause Time Machine (tmutil stopbackup). State stays at '\(state.rawValue)', retrying on every subsequent check. Time Machine KEEPS WRITING - the disk may fill up." ) reportedStopFailure = true } } - /// PODTRZYMUJE wstrzymanie w stanie pauzy - przy kazdym tyknieciu. + /// KEEPS UP the pause while in a paused state - on every tick. /// - /// TO jest ta poprawka. `stopBackup()` wolalo sie WYLACZNIE przy zmianie - /// stanu, a `tmutil stopbackup` anuluje tylko TRWAJACY backup i nie rusza - /// harmonogramu (`tmutil disable` nie wystepuje w tym repo ani razu). - /// Godzine po pauzie macOS startowal wiec kolejny backup: dozorca go nie - /// zatrzymywal i nie nadzorowal, bo galaz `.pausedForBuffer` nie patrzyla na - /// nic poza warunkiem wznowienia - a w logu stalo "czekam na wysylke". - /// Pauza wstrzymywala zapis na jeden przebieg, choc sam stan trwal - /// 53 godziny. Dokladnie te wade opisuje i naprawia `pause` dla galezi - /// PORAZKI `stopbackup`; dla powodzenia zostala nietknieta do 25.09.2026. + /// THIS is the fix. `stopBackup()` was called ONLY on a state change, and + /// `tmutil stopbackup` cancels only the RUNNING backup and does not touch the + /// schedule (`tmutil disable` does not appear in this repo even once). An + /// hour after the pause macOS therefore started another backup: the watchdog + /// neither stopped nor supervised it, because the `.pausedForBuffer` branch + /// looked at nothing except the resume condition - and the log said "waiting + /// for the upload". The pause held back writes for one run, even though the + /// state itself lasted 53 hours. `pause` describes and fixes exactly this + /// flaw for the FAILURE branch of `stopbackup`; for success it stayed + /// untouched until 25.09.2026. /// - /// Ponawiamy tylko wtedy, gdy tmutil nie mowi wprost "backup nie trwa": - /// "nie wiem" (`nil`) liczy sie tu jak "trwa", bo brak odpowiedzi nie jest - /// dowodem ciszy. Przy stojacym Time Machine oszczedza to dwa procesy - /// (sudo + tmutil) co 30 sekund przez cala pauze - w epizodzie z 23.09 - /// byloby ich ponad 12 tysiecy. + /// We repeat only when tmutil does not say plainly "no backup running": + /// "I do not know" (`nil`) counts here as "running", because no answer is not + /// proof of quiet. With Time Machine idle this saves two processes (sudo + + /// tmutil) every 30 seconds for the whole pause - in the 23.09 episode it + /// would have been over 12 thousand of them. /// - /// ODRZUCONA ALTERNATYWA: `tmutil disable`. Wylacza harmonogram raz i na - /// dobre, wiec pauza trzymalaby sie bez ponawiania - ale stan pauzy dozorca - /// trzyma W PAMIECI PROCESU, a chodzi pod launchd z `KeepAlive`. Po jego - /// smierci (albo po restarcie Maca) nikt nie wiedzialby, ze Time Machine - /// zostal wylaczony i ze trzeba go wlaczyc z powrotem - cicha utrata - /// backupu na zawsze zamiast wolniejszego backupu. Ponawiane `stopbackup` - /// jest odwracalne samo z siebie: gdy dozorca przestaje dzialac, Time - /// Machine wraca do pracy w swoim cyklu godzinowym. + /// REJECTED ALTERNATIVE: `tmutil disable`. It turns the schedule off once and + /// for good, so the pause would hold without repeating - but the watchdog + /// keeps the pause state IN PROCESS MEMORY, and it runs under launchd with + /// `KeepAlive`. After its death (or after a Mac restart) nobody would know + /// that Time Machine had been disabled and has to be turned back on - a + /// silent loss of backups forever instead of a slower backup. A repeated + /// `stopbackup` is reversible by itself: when the watchdog stops working, + /// Time Machine goes back to work in its hourly cycle. private func keepPaused(backupRunning: Bool?) async { guard backupRunning != false else { reportedRestop = false @@ -675,7 +682,7 @@ public actor BufferGuardService { } if !reportedRestop { probes.log( - "Time Machine pracuje w trakcie pauzy (stan '\(state.rawValue)') - ponawiam wstrzymanie. `tmutil stopbackup` anuluje tylko trwajacy przebieg, a macOS startuje kolejny w swoim cyklu godzinowym." + "Time Machine is running during a pause (state '\(state.rawValue)') - repeating the pause. `tmutil stopbackup` cancels only the running run, and macOS starts another one in its hourly cycle." ) reportedRestop = true } @@ -685,39 +692,39 @@ public actor BufferGuardService { } if !reportedStopFailure { probes.log( - "NIE UDALO SIE ponowic wstrzymania Time Machine (tmutil stopbackup) w stanie '\(state.rawValue)'. Time Machine PISZE DALEJ do bufora, ktorego oprozniania wlasnie czekamy - dysk moze sie zapelnic." + "FAILED to repeat the Time Machine pause (tmutil stopbackup) in state '\(state.rawValue)'. Time Machine KEEPS WRITING to the buffer we are waiting to drain - the disk may fill up." ) reportedStopFailure = true } } - /// Wznawia Time Machine. + /// Resumes Time Machine. /// - /// Asymetria wzgledem `pause` jest celowa. Nieudane `stopbackup` grozi - /// zapelnieniem dysku, wiec nie wolno udawac, ze pauza zaszla. Nieudane - /// `startbackup` nie grozi niczym: Time Machine i tak ruszy sam w swoim - /// cyklu godzinowym, a `startbackup` jest tylko przyspieszeniem tego. - /// Gdybysmy przy jego niepowodzeniu zostawali w pauzie, dozorca tkwilby - /// w stanie, z ktorego jedyne wyjscie wlasnie nie dziala. + /// The asymmetry with `pause` is deliberate. A failed `stopbackup` risks + /// filling the disk, so we must not pretend the pause happened. A failed + /// `startbackup` risks nothing: Time Machine will start by itself in its + /// hourly cycle anyway, and `startbackup` only speeds that up. If we stayed + /// paused when it failed, the watchdog would be stuck in a state whose only + /// exit is exactly what is not working. private func resume(backlogGB: Int?, freeGB: Int?) async { - probes.log("WZNOWIENIE: zaleglosc \(describeBacklog(backlogGB)), wolne \(describe(freeGB))") + probes.log("RESUME: backlog \(describeBacklog(backlogGB)), free \(describe(freeGB))") reportedRestop = false if await probes.startBackup() == false { probes.log( - "tmutil startbackup nie powiodlo sie - Time Machine ruszy sam w swoim cyklu godzinowym.") + "tmutil startbackup failed - Time Machine will start by itself in its hourly cycle.") } state = .running } - /// Co zrobic ze znacznikiem zatoru. Czysta funkcja, zeby "nie wiem" dalo sie - /// sprawdzic testem bez pliku znacznika, bez powiadomienia i bez logu. + /// What to do with the jam marker. A pure function, so that "I do not know" + /// can be tested without a marker file, without a notification and without + /// a log. /// - /// `stalled == nil` to `.doNothing`, i to jest cala poprawka. Wczesniej - /// nieczytelny log rclone wychodzil z `uploadStalled()` jako `false`, `false` - /// oznaczal "zator minal" - wiec znacznik byl USUWANY, a do logu szlo - /// "Wysylka na Google Drive ruszyla z powrotem". Twierdzenie o zdarzeniu, - /// ktorego nikt nie sprawdzil, i zgaszenie zgloszenia dokladnie w tym - /// przypadku, dla ktorego ono istnieje. + /// `stalled == nil` is `.doNothing`, and that is the whole fix. Previously an + /// unreadable rclone log came out of `uploadStalled()` as `false`, `false` + /// meant "the jam is over" - so the marker was DELETED, and the log got + /// "Upload to Google Drive has resumed". A claim about an event nobody + /// checked, and the report cleared in exactly the case it exists for. enum StallAction: Equatable { case raise case clear @@ -730,13 +737,14 @@ public actor BufferGuardService { return stalled ? .raise : .clear } - /// Zglasza poczatek i koniec zatoru wysylki - raz na zmiane stanu. + /// Reports the start and end of an upload jam - once per state change. /// - /// Stan trzymamy w pliku, a nie w polu, bo `buffer-guard` chodzi pod - /// launchd z `KeepAlive`: po kazdym wskrzeszeniu procesu pole zaczynaloby - /// od zera i ten sam zator zglaszalby sie od nowa co 30 sekund. + /// We keep the state in a file, not a field, because `buffer-guard` runs + /// under launchd with `KeepAlive`: after every resurrection of the process the + /// field would start from zero and the same jam would be reported anew every + /// 30 seconds. /// - /// `nil` = "nie wiem" i wtedy nie ruszamy NICZEGO - patrz `stallAction`. + /// `nil` = "I do not know", and then we touch NOTHING - see `stallAction`. static func reportUploadStall(_ stalled: Bool?) async { let marker = CMPaths.appSupportDir.appendingPathComponent(".upload-stalled") let reported = FileManager.default.fileExists(atPath: marker.path) @@ -744,58 +752,62 @@ public actor BufferGuardService { guard action != .doNothing else { return } if action == .raise { - let message = "Wysylka na Google Drive stoi - wyczerpany limit dobowy." - CMLogger.log("\(message) Kopie ida dalej, zator mija sam w kilka godzin.") - // Znacznik zakladamy DOPIERO po doreczeniu powiadomienia. Zalozony - // wczesniej zamykal sprawe takze wtedy, gdy powiadomienie nie doszlo - - // czyli gasil zgloszenie dokladnie w przypadku, dla ktorego istnieje - // (ten sam blad, co w `HealthAlert.report`, patrz tamtejszy komentarz). - if await HealthAlert.notify(title: "CloudMachine: wysylka na Dysk stoi", message: message) { + CMLogger.log( + "Upload to Google Drive is stalled - daily limit exhausted. Backups continue, the jam passes by itself within a few hours." + ) + // The marker is created ONLY after the notification is delivered. Created + // earlier, it closed the matter even when the notification did not get + // through - i.e. it cleared the report in exactly the case it exists for + // (the same bug as in `HealthAlert.report`, see the comment there). + if await HealthAlert.notify( + title: L10n.tr("CloudMachine: upload to Drive is stalled"), + message: L10n.tr("Upload to Google Drive is stalled - daily limit exhausted.")) + { FileManager.default.createFile(atPath: marker.path, contents: nil) } else { CMLogger.log( - "Powiadomienie o zatorze wysylki NIE zostalo doreczone - sprobuje ponownie przy nastepnym sprawdzeniu." + "The upload jam notification was NOT delivered - will retry on the next check." ) } } else { try? FileManager.default.removeItem(at: marker) - CMLogger.log("Wysylka na Google Drive ruszyla z powrotem.") + CMLogger.log("Upload to Google Drive has resumed.") } } - /// Wola `tmutil ` i mowi, czy NAPRAWDE sie udalo. + /// Calls `tmutil ` and says whether it REALLY succeeded. /// - /// Najpierw przez `sudo -n`: `tmutil stopbackup` i `startbackup` wymagaja - /// uprawnien roota, a dozorca chodzi pod launchd w sesji uzytkownika. - /// Istniejacy `runTmutilUnattended` byl tu nieuzyty, mimo ze powstal - /// dokladnie do tego. Regula NOPASSWD w `/etc/sudoers.d/cloudmachine` nie - /// jest przez nic w tym repo zakladana (sprawdzone: zaden instalator jej - /// nie pisze), wiec `sudo -n` dzis odmawia natychmiast - i wlasnie dlatego - /// przy odmowie AUTORYZACJI probujemy jeszcze bez sudo, zamiast uznawac - /// sprawe za przegrana. `isSudoAuthFailure` odroznia "sudo nas nie wpuscilo" - /// od "polecenie sie wykonalo i zwrocilo blad". + /// First via `sudo -n`: `tmutil stopbackup` and `startbackup` need root + /// privileges, and the watchdog runs under launchd in the user session. The + /// existing `runTmutilUnattended` was unused here, even though it was made + /// exactly for this. The NOPASSWD rule in `/etc/sudoers.d/cloudmachine` is not + /// set up by anything in this repo (checked: no installer writes it), so + /// `sudo -n` refuses immediately today - and that is exactly why, on an + /// AUTHORIZATION refusal, we still try without sudo instead of giving up. + /// `isSudoAuthFailure` tells "sudo did not let us in" apart from "the command + /// ran and returned an error". static func tmutil(_ command: String) async -> Bool { if let viaSudo = try? await ProcessRunner.runTmutilUnattended([command], timeout: 120) { if viaSudo.succeeded { return true } if !viaSudo.isSudoAuthFailure { CMLogger.log( - "sudo tmutil \(command): kod \(viaSudo.exitCode) \(shortError(viaSudo))") + "sudo tmutil \(command): exit code \(viaSudo.exitCode) \(shortError(viaSudo))") return false } } guard let direct = try? await ProcessRunner.run("/usr/bin/tmutil", [command], timeout: 120) else { - CMLogger.log("tmutil \(command): BRAK ODPOWIEDZI w limicie czasu.") + CMLogger.log("tmutil \(command): NO ANSWER within the time limit.") return false } if !direct.succeeded { - CMLogger.log("tmutil \(command): kod \(direct.exitCode) \(shortError(direct))") + CMLogger.log("tmutil \(command): exit code \(direct.exitCode) \(shortError(direct))") } return direct.succeeded } private static func shortError(_ result: ProcessResult) -> String { let text = (result.stderr + " " + result.stdout).trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? "(bez komunikatu)" : text.replacingOccurrences(of: "\n", with: " ") + return text.isEmpty ? "(no message)" : text.replacingOccurrences(of: "\n", with: " ") } } diff --git a/mac-app/Sources/CloudMachineCore/BufferReadiness.swift b/mac-app/Sources/CloudMachineCore/BufferReadiness.swift index bd1a755..215e59e 100644 --- a/mac-app/Sources/CloudMachineCore/BufferReadiness.swift +++ b/mac-app/Sources/CloudMachineCore/BufferReadiness.swift @@ -1,38 +1,39 @@ import Foundation -/// Kiedy bufor jest na tyle gotowy, zeby podpiac na nim obraz. +/// When the buffer is ready enough to attach the image on it. /// -/// Wydzielone z `attach-image`, zeby dalo sie sprawdzic testem bez montowania -/// czegokolwiek - tak samo jak `CooldownGate` i `TimeMachineStatus`. +/// Split out of `attach-image` so that it can be tested without mounting +/// anything - just like `CooldownGate` and `TimeMachineStatus`. public enum BufferReadiness { - /// Ile czekac na gotowy bufor, zanim uznamy to za awarie. + /// How long to wait for a ready buffer before we treat it as a failure. /// - /// Dwie minuty wystarczaly, dopoki bufor startowal pusty. Po nieczystym - /// zamknieciu jest inaczej: 13 wrz 2026 rclone wczytywal po starcie 225 - /// brudnych pozycji (9,4 GB), montowanie stanelo o 07:56:30, a czekanie - /// poddalo sie o 07:56:20 - dziesiec sekund za wczesnie. Time Machine - /// zostal bez celu az do nastepnego tykniecia launchd, czyli na 15 minut, - /// i nikt sie o tym nie dowiedzial poza kodem wyjscia, ktorego nikt nie czyta. + /// Two minutes were enough as long as the buffer started empty. After an + /// unclean shutdown it is different: on 13 Sep 2026 rclone read 225 dirty + /// items (9.4 GB) after start-up, the mount came up at 07:56:30, and the wait + /// gave up at 07:56:20 - ten seconds too early. Time Machine was left without + /// a destination until the next launchd tick, i.e. for 15 minutes, and nobody + /// found out except through an exit code that nobody reads. /// - /// Czekanie jest darmowe - `attach` na juz podpietym obrazie tylko mowi - /// "Juz podpiete" - a przegapione okno kosztuje kwadrans bez backupu. + /// Waiting is free - `attach` on an already attached image only says + /// "Already attached" - while a missed window costs a quarter of an hour + /// without a backup. public static let defaultTimeout: TimeInterval = 900 - /// Co ile odpytywac. + /// How often to poll. public static let defaultPoll: TimeInterval = 2 - /// Samo montowanie nie wystarczy. + /// The mount alone is not enough. /// - /// rclone wystawia montowanie, ZANIM wczyta brudny cache, wiec przez chwile - /// katalog jest pusty. Podpiecie odpadloby wtedy na "Brak obrazu" - czyli na - /// tym samym wyscigu, tyle ze o krok pozniej. + /// rclone exposes the mount BEFORE it reads the dirty cache, so for a while + /// the directory is empty. The attach would then fail with "No image" - that + /// is, on the same race, just one step later. public static func isReady(mounted: Bool, imageVisible: Bool) -> Bool { mounted && imageVisible } - /// Czeka na gotowosc bufora. Zwraca `true`, jesli sie doczekal. + /// Waits for the buffer to become ready. Returns `true` if it did. /// - /// Zegar i uspienie sa wstrzykiwane, zeby test nie musial czekac naprawde. + /// The clock and sleep are injected so that a test does not have to really wait. public static func wait( timeout: TimeInterval = defaultTimeout, poll: TimeInterval = defaultPoll, diff --git a/mac-app/Sources/CloudMachineCore/CMActionResult.swift b/mac-app/Sources/CloudMachineCore/CMActionResult.swift index 86e5453..2d5ce6a 100644 --- a/mac-app/Sources/CloudMachineCore/CMActionResult.swift +++ b/mac-app/Sources/CloudMachineCore/CMActionResult.swift @@ -1,25 +1,25 @@ import Foundation -/// Wynik jednorazowej akcji (setup, instalacja, weryfikacja...) - wspolny -/// ksztalt uzywany przez wiele serwisow w CloudMachineCore, zeby CLI i GUI -/// mialy jeden, spojny sposob raportowania sukcesu/porazki. +/// Result of a one-off action (setup, installation, verification...) - a +/// shared shape used by many services in CloudMachineCore, so that the CLI and +/// the GUI have one consistent way of reporting success/failure. public struct CMActionResult { public var succeeded: Bool public var message: String - /// `true`, jesli `succeeded == false` konkretnie dlatego, ze brakowalo - /// reguly sudoers NOPASSWD (patrz `ProcessResult.isSudoAuthFailure`) - a - /// NIE dlatego, ze samo polecenie zawiodlo. Pozwala wywolujacemu (GUI) - /// odroznic "trzeba najpierw ustawic sudoers i sprobowac ponownie" od - /// prawdziwego bledu, zamiast zwracac uzytkownikowi martwy koniec. + /// `true` if `succeeded == false` specifically because the NOPASSWD sudoers + /// rule was missing (see `ProcessResult.isSudoAuthFailure`) - and NOT + /// because the command itself failed. Lets the caller (GUI) tell "sudoers + /// has to be set up first, then retry" apart from a real error, instead of + /// handing the user a dead end. public var isSudoAuthFailure: Bool - /// `true`, jesli operacja w ogole sie NIE ZACZELA - nie dlatego, ze cos - /// poszlo zle, tylko dlatego, ze zasob byl zajety przez inna operacje - /// (patrz `BackupImageService.busyResult`). + /// `true` if the operation did NOT START at all - not because something went + /// wrong, but because the resource was held by another operation (see + /// `BackupImageService.busyResult`). /// - /// Osobne pole, a nie rozpoznawanie po tresci `message`: dopasowanie do - /// tekstu psuje sie przy kazdej zmianie komunikatu, a cicho - to znaczy - /// tak, ze nikt tego nie zauwaza az do awarii. + /// A separate field rather than recognizing it by the content of `message`: + /// matching on text breaks with every change of the message, and does so + /// silently - meaning nobody notices until something fails. public var didNotRun: Bool public init( @@ -31,21 +31,21 @@ public struct CMActionResult { self.didNotRun = didNotRun } - /// Co wolajacy ma z tym wynikiem zrobic - w szczegolnosci z jakim kodem - /// wyjscia ma sie skonczyc polecenie CLI chodzace pod launchd. + /// What the caller should do with this result - in particular, which exit + /// code a CLI command running under launchd should end with. /// - /// Istnieje, bo "zajete przez inna operacje" NIE JEST awaria, a przez kod - /// wyjscia 1 ladowalo w `launchd-gdrive-attach.err.log` - czyli dokladnie - /// tam, gdzie czlowiek patrzy, pytajac "czy backup dziala". To ten sam blad, - /// ktory ten kod tepi w druga strone ("brak odpowiedzi czytany jako - /// odpowiedz"), tylko odwrocony: stan normalny czytany jako awaria. + /// It exists because "busy with another operation" is NOT a failure, yet + /// with exit code 1 it ended up in `launchd-gdrive-attach.err.log` - exactly + /// where a person looks when asking "is the backup working". It is the same + /// bug this code fights in the other direction ("no answer read as an + /// answer"), only reversed: a normal state read as a failure. public enum Disposition: Equatable { - /// Udalo sie. + /// It worked. case ok - /// Nic sie nie wydarzylo i nic sie nie zepsulo. Cykliczny tik ma po - /// prostu sprobowac przy nastepnym przebiegu. + /// Nothing happened and nothing broke. A periodic tick should simply try + /// again on the next run. case skipped - /// Prawdziwa porazka - ta ma byc widoczna. + /// A real failure - this one must be visible. case failed } diff --git a/mac-app/Sources/CloudMachineCore/CMLock.swift b/mac-app/Sources/CloudMachineCore/CMLock.swift index 1079ee3..45889e8 100644 --- a/mac-app/Sources/CloudMachineCore/CMLock.swift +++ b/mac-app/Sources/CloudMachineCore/CMLock.swift @@ -1,11 +1,11 @@ import Foundation -/// Blokada oparta na atomowym `mkdir` (nie `flock`, ktorego macOS nie ma -/// domyslnie), z wykrywaniem osierocenia po PID-zie zapisanym w blokadzie -/// (nie po wieku katalogu - ta sama motywacja co w bash-owym `cm_acquire_lock`: -/// operacja pod blokada moze legalnie trwac dlugo, np. dogananie zaleglej -/// kolejki uploadow, wiec staly prog czasowy falszywie oznaczalby zywy proces -/// jako osierocony). +/// A lock based on an atomic `mkdir` (not `flock`, which macOS does not have +/// by default), with orphan detection by the PID stored in the lock (not by +/// the directory's age - the same motivation as in the bash `cm_acquire_lock`: +/// an operation under the lock can legitimately take long, e.g. catching up +/// with a backlog of uploads, so a fixed time threshold would falsely mark a +/// live process as orphaned). public final class CMLock { private let lockDir: URL private var acquired = false @@ -14,28 +14,29 @@ public final class CMLock { lockDir = CMPaths.logDir.appendingPathComponent("\(name).lock.d") } - /// Wylacznie dla testu: blokada w katalogu, ktory test sam sprzata. Bez tego - /// kazdy test blokady zostawialby katalogi w prawdziwym `~/Library/Logs/ - /// CloudMachine`, czyli tam, gdzie dzialajaca instalacja trzyma blokady - /// produkcyjne - test umialby zablokowac agenta. + /// For tests only: a lock in a directory the test cleans up itself. Without + /// this, every lock test would leave directories in the real `~/Library/Logs/ + /// CloudMachine`, i.e. where the running installation keeps its production + /// locks - a test could block the agent. init(directory: URL) { lockDir = directory } - /// Probuje przejac blokade. Zwraca `false`, jesli inny zywy proces juz ja trzyma. + /// Tries to take the lock. Returns `false` if another live process already holds it. public func acquire() -> Bool { if (try? FileManager.default.createDirectory(at: lockDir, withIntermediateDirectories: false)) != nil { - // Weryfikacja odczytem jest tu potrzebna z DOKLADNIE tego samego powodu, - // co na sciezce przejecia nizej - do 23 wrzesnia 2026 byla tylko tam. - // `writePid()` polyka blad zapisu (`try?`), a proces ubity miedzy - // `createDirectory` a zapisem zostawia katalog blokady BEZ pliku `pid`. - // Konkurent czyta wtedy pusty katalog, nie znajduje PID-a, uznaje - // blokade za osierocona i ja przejmuje - podczas gdy my juz zwrocilismy - // `true` i dzialamy w przekonaniu o wylacznosci. Dwoch wlascicieli tej - // samej blokady "image" to rownolegly `detach` i `attach` na tym samym - // obrazie, czyli dokladnie to, przed czym blokada ma chronic. + // Verification by reading back is needed here for EXACTLY the same + // reason as on the takeover path below - until 23 September 2026 it was + // only there. `writePid()` swallows a write error (`try?`), and a + // process killed between `createDirectory` and the write leaves the + // lock directory WITHOUT a `pid` file. A competitor then reads an empty + // directory, finds no PID, considers the lock orphaned and takes it over + // - while we have already returned `true` and are acting in the belief + // that we have exclusivity. Two owners of the same "image" lock means a + // parallel `detach` and `attach` on the same image, i.e. exactly what + // the lock is meant to protect against. guard writeAndVerifyPid() else { try? FileManager.default.removeItem(at: lockDir) return false @@ -43,7 +44,7 @@ public final class CMLock { acquired = true return true } - // Katalog juz istnieje - sprawdzamy, czy wlasciciel wciaz zyje. + // The directory already exists - check whether the owner is still alive. let pidFile = lockDir.appendingPathComponent("pid") if let content = try? String(contentsOf: pidFile, encoding: .utf8), let pid = Self.parsePid(from: content), @@ -51,8 +52,8 @@ public final class CMLock { { return false } - // Proces-wlasciciel juz nie zyje (lub blokada przerwana zanim zdazyl - // zapisac PID) - osierocona, przejmujemy. + // The owner process is no longer alive (or the lock was interrupted before + // it managed to write its PID) - orphaned, we take it over. try? FileManager.default.removeItem(at: lockDir) guard (try? FileManager.default.createDirectory(at: lockDir, withIntermediateDirectories: false)) @@ -65,20 +66,20 @@ public final class CMLock { return true } - /// Zapisuje wlasny PID i POTWIERDZA go odczytem. `false` znaczy "nie umiem - /// udowodnic, ze blokada jest moja" - a to musi konczyc sie rezygnacja, - /// nie optymizmem. + /// Writes our own PID and CONFIRMS it by reading it back. `false` means "I + /// cannot prove the lock is mine" - and that must end in giving up, not in + /// optimism. /// - /// WAZNE: `removeItem` + `createDirectory` na sciezce przejecia NIE jest - /// atomowe - dwa procesy przejmujace ta sama osierocona blokade w - /// nakladajacym sie oknie moga obie odczytac "martwy PID", obie usunac - /// i utworzyc katalog na nowo, obie zapisac swoj PID - i obie zwrocic - /// `true`. Odczytujemy wlasnie zapisany plik z powrotem: jesli inny proces - /// zdazyl go nadpisac swoim PID-em pomiedzy zapisem a tym odczytem, wiemy, - /// ze przegralismy wyscig, i wycofujemy sie zamiast dzialac w falszywym - /// przekonaniu o wylacznosci. Nie eliminuje to calkowicie okna (obie strony - /// moga jeszcze przejsciowo "wygrac" tuz przed ta weryfikacja), ale - /// gwarantuje, ze co najmniej jedna z nich to wykryje i cofnie. + /// IMPORTANT: `removeItem` + `createDirectory` on the takeover path is NOT + /// atomic - two processes taking over the same orphaned lock in an + /// overlapping window can both read "dead PID", both remove and recreate the + /// directory, both write their PID - and both return `true`. We read the + /// file we just wrote back: if another process managed to overwrite it with + /// its PID between the write and this read, we know we lost the race, and + /// we back off instead of acting in the false belief of exclusivity. This + /// does not eliminate the window completely (both sides can still + /// transiently "win" just before this verification), but it guarantees that + /// at least one of them will detect it and back off. private func writeAndVerifyPid() -> Bool { writePid() guard @@ -91,41 +92,42 @@ public final class CMLock { return true } - /// Sprawdza, czy proces z podanym PID wciaz zyje I nie jest zawieszony na - /// stale w nieprzerywalnym oczekiwaniu kernela (stan 'U' z `ps`). - /// Uzywa synchronicznego `popen("ps -p -o stat=")` bo `acquire()` - /// jest sync - nie mozemy tu czekac na async ProcessRunner. + /// Checks whether the process with the given PID is still alive AND not + /// permanently stuck in an uninterruptible kernel wait (state 'U' from + /// `ps`). Uses a synchronous `popen("ps -p -o stat=")` because + /// `acquire()` is sync - we cannot wait for the async ProcessRunner here. /// - /// Proces w stanie U przechodzi `kill(pid, 0) == 0`, ale nigdy sam nie - /// zwolni blokady (SIGKILL go nie budzi z NFS/I-O wait w jadrze). Traktujemy - /// go jako "martwy dla celow blokady" po `stuckThreshold` minutach - prog - /// wystarczajaco duzy, zeby nie konfliktowac z legalnymi dlugimi operacjami - /// (dogananie kolejki uploadow, weryfikacja checksumow mogace trwac godziny). - private static let stuckLockThreshold: TimeInterval = 15 * 60 // 15 minut + /// A process in state U passes `kill(pid, 0) == 0`, but will never release + /// the lock by itself (SIGKILL does not wake it from an NFS/I-O wait in the + /// kernel). We treat it as "dead for locking purposes" after + /// `stuckThreshold` minutes - a threshold large enough not to conflict with + /// legitimate long operations (catching up with the upload queue, checksum + /// verification that can take hours). + private static let stuckLockThreshold: TimeInterval = 15 * 60 // 15 minutes - /// Znacznik "od kiedy nieprzerwanie widzimy ten proces w stanie U/D" - - /// CELOWO osobny od mtime `lockDir` (czasu PRZEJECIA blokady). Legalna - /// dlugotrwala operacja (weryfikacja checksumow trwajaca godziny) moze - /// trzymac te sama blokade dlugo PRZED tym, jak w ogole wpadnie w U/D - - /// liczenie progu od czasu przejecia blokady (jak robil wczesniejszy kod) - /// zabija taki legalny, dlugo dzialajacy proces przy pierwszej probce w - /// U/D po uplywie progu, co jest dokladnie tym, przed czym ostrzega - /// komentarz w naglowku tego pliku (orphan detection PO PID, NIE po - /// wieku). Ten znacznik zapisujemy przy PIERWSZYM zaobserwowanym U/D i - /// kasujemy, gdy proces wroci do normalnego stanu - wiec mierzy faktyczny, - /// NIEPRZERWANY czas trwania zawieszenia. + /// Marker "since when we have continuously seen this process in state U/D" + /// - DELIBERATELY separate from the mtime of `lockDir` (the time the lock + /// was TAKEN). A legitimate long-running operation (checksum verification + /// lasting hours) can hold the same lock for a long time BEFORE it ever + /// falls into U/D - counting the threshold from the time the lock was taken + /// (as the earlier code did) kills such a legitimate, long-running process + /// at the first U/D sample after the threshold has passed, which is exactly + /// what the comment in this file's header warns against (orphan detection + /// BY PID, NOT by age). We write this marker on the FIRST observed U/D and + /// delete it when the process returns to a normal state - so it measures + /// the actual, UNINTERRUPTED duration of the hang. private var stuckSinceFile: URL { lockDir.appendingPathComponent("stuck-since") } - /// `recordedStartTime` to czas startu procesu-wlasciciela ZAPISANY w pliku - /// blokady w momencie jej przejecia (`writePid()`) - pozwala odroznic - /// "ten sam proces wciaz zyje" od "PID zostal juz ponownie uzyty przez - /// zupelnie inny, nowszy proces" (`kill(pid, 0) == 0` samo w sobie tego - /// nie odroznia - widzi TYLKO, ze COS zyje pod tym numerem PID). Ryzyko - /// jest bardzo niskie w praktyce (przestrzen PID jest duza, watchdogi - /// odpalaja sie rzadko), ale kosztuje niewiele do sprawdzenia. `nil` (plik - /// blokady zapisany przed wprowadzeniem tego pola, albo `ps` chwilowo nie - /// odpowiedzialo przy zapisie) pomija te dodatkowa weryfikacje zamiast - /// falszywie zaklada osierocenie. + /// `recordedStartTime` is the owner process's start time WRITTEN to the + /// lock file when the lock was taken (`writePid()`) - it allows telling + /// "the same process is still alive" apart from "the PID has already been + /// reused by a completely different, newer process" (`kill(pid, 0) == 0` on + /// its own cannot tell these apart - it sees ONLY that SOMETHING is alive + /// under that PID number). The risk is very low in practice (the PID space + /// is large, watchdogs fire rarely), but it costs little to check. `nil` (a + /// lock file written before this field was introduced, or `ps` briefly did + /// not respond at write time) skips this extra verification instead of + /// falsely assuming an orphan. private func isProcessAlive(_ pid: pid_t, recordedStartTime: String?) -> Bool { guard kill(pid, 0) == 0 else { return false } @@ -136,14 +138,15 @@ public final class CMLock { return false } - // Synchronicznie pobierz stan procesu z `ps`. - // Stan 'U' (macOS uninterruptible NFS wait) lub 'D' (Linux disk sleep) - // - oba oznaczaja zawieszenie w jadrze, z ktorego SIGKILL nie wybudza. + // Synchronously fetch the process state from `ps`. + // State 'U' (macOS uninterruptible NFS wait) or 'D' (Linux disk sleep) + // - both mean a hang in the kernel that SIGKILL does not wake from. guard let psState = processState(pid: pid), psState.uppercased().hasPrefix("U") || psState.uppercased().hasPrefix("D") else { - // Proces dziala normalnie (albo `ps` chwilowo nie odpowiedzialo) - - // kasujemy znacznik, gdyby proces przejsciowo wpadl w U/D i wrocil. + // The process is running normally (or `ps` briefly did not respond) - + // delete the marker, in case the process fell into U/D transiently and + // came back. try? FileManager.default.removeItem(at: stuckSinceFile) return true } @@ -155,43 +158,43 @@ public final class CMLock { let stuckDuration = Date().timeIntervalSince1970 - stuckSinceEpoch guard stuckDuration > Self.stuckLockThreshold else { return true } CMLogger.log( - "[CMLock] OSTRZEZENIE: PID \(pid) trzymajacy blokade '\(lockDir.lastPathComponent)'" - + " jest NIEPRZERWANIE zawieszony w stanie U/D (NFS hang?) od ponad" - + " \(Int(Self.stuckLockThreshold/60)) min. Przejmuje blokade. SIGKILL na zawieszony" - + " proces (ignorowany przez jadro, ale sprzatnie PID po odmontowaniu)." + "[CMLock] WARNING: PID \(pid) holding the lock '\(lockDir.lastPathComponent)'" + + " has been CONTINUOUSLY stuck in state U/D (NFS hang?) for more than" + + " \(Int(Self.stuckLockThreshold/60)) min. Taking over the lock. SIGKILL to the stuck" + + " process (ignored by the kernel, but it will clean up the PID after unmounting)." ) kill(pid, SIGKILL) try? FileManager.default.removeItem(at: stuckSinceFile) return false } - // Pierwsza probka w stanie U/D - zapisujemy poczatek okna i czekamy na - // kolejne probki, zanim uznamy proces za utkniety. + // First sample in state U/D - record the start of the window and wait for + // further samples before considering the process stuck. try? "\(Date().timeIntervalSince1970)".write( to: stuckSinceFile, atomically: true, encoding: .utf8) return true } - /// Synchroniczne uruchomienie `ps -p -o ` przez Process/Pipe - - /// wspoldzielone przez `processState` (`stat=`) i `processStartTime` - /// (`lstart=`). Bezpieczne blokujace uzycie `readDataToEndOfFile()` bo `ps` - /// ZAWSZE szybko konczy i zamyka swoje FD - brak ryzyka wiecznego - /// oczekiwania na EOF (ten problem dotyczy wylacznie demonow takich jak - /// rclone --daemon, ktore forkuja dziecko dziedziczace FD i nigdy ich nie - /// zamykaja). + /// Synchronous run of `ps -p -o ` via Process/Pipe - shared by + /// `processState` (`stat=`) and `processStartTime` (`lstart=`). The blocking + /// use of `readDataToEndOfFile()` is safe because `ps` ALWAYS finishes + /// quickly and closes its FDs - no risk of waiting forever for EOF (that + /// problem concerns only daemons such as rclone --daemon, which fork a child + /// that inherits the FDs and never closes them). private static func runPS(pid: pid_t, format: String) -> String? { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/ps") proc.arguments = ["-p", "\(pid)", "-o", format] - // WAZNE: `ps` formatuje daty (np. `lstart=`) wedlug LC_TIME/LANG procesu, - // ktory go wywoluje - NIE tego, ktorego PID sprawdzamy. Bez wymuszenia - // stalego locale ten sam, zywy proces dostawal RUZNY tekst czasu startu - // w zaleznosci od tego, kto go sprawdzal (np. "sr. 29 lip 10:01:01 2026" - // z powloki uzytkownika z LANG=pl_PL.UTF-8, ale "Wed Jul 29 10:01:01 - // 2026" z watchdoga odpalonego przez launchd z innym/domyslnym locale) - - // co `isProcessAlive` mylnie odczytywalo jako "PID zostal ponownie - // uzyty przez inny proces" i pozwalalo ukrasc blokade dalej zywemu - // procesowi. Zaobserwowane na zywo: dwa rownolegle `rclone copy` na ten - // sam cel. `LC_ALL=C` gwarantuje ten sam tekst niezaleznie od tego, kto pyta. + // IMPORTANT: `ps` formats dates (e.g. `lstart=`) according to the + // LC_TIME/LANG of the process that CALLS it - NOT of the one whose PID we + // check. Without forcing a fixed locale, the same live process got a + // DIFFERENT start-time text depending on who was checking it (e.g. the + // Polish "sr. 29 lip 10:01:01 2026" from the user's shell with + // LANG=pl_PL.UTF-8, but "Wed Jul 29 10:01:01 2026" from a watchdog started + // by launchd with another/default locale) - which `isProcessAlive` + // misread as "the PID was reused by another process" and which allowed + // the lock to be stolen from a still-living process. Observed live: two + // parallel `rclone copy` runs to the same destination. `LC_ALL=C` + // guarantees the same text regardless of who asks. proc.environment = ["LC_ALL": "C"] let pipe = Pipe() proc.standardOutput = pipe @@ -208,9 +211,9 @@ public final class CMLock { Self.runPS(pid: pid, format: "stat=") } - /// Czas startu procesu (np. "Wed Jul 29 09:07:59 2026") - unikalny "odcisk - /// palca" konkretnej instancji procesu pod danym PID-em, uzywany do - /// wykrywania ponownego uzycia PID-u (patrz `isProcessAlive`). + /// The process start time (e.g. "Wed Jul 29 09:07:59 2026") - a unique + /// "fingerprint" of a specific process instance under a given PID, used to + /// detect PID reuse (see `isProcessAlive`). private static func processStartTime(pid: pid_t) -> String? { runPS(pid: pid, format: "lstart=") } @@ -227,9 +230,9 @@ public final class CMLock { return pid_t(firstLine.trimmingCharacters(in: .whitespacesAndNewlines)) } - /// `nil` dla plikow blokady zapisanych przed wprowadzeniem tego pola - /// (tylko jedna linia z PID-em) - `isProcessAlive` traktuje to jako brak - /// dodatkowej informacji, nie jako dowod ponownego uzycia PID-u. + /// `nil` for lock files written before this field was introduced (only one + /// line with the PID) - `isProcessAlive` treats that as a lack of extra + /// information, not as proof of PID reuse. private static func parseStartTime(from content: String) -> String? { let lines = content.split(separator: "\n", maxSplits: 1) guard lines.count == 2 else { return nil } @@ -250,15 +253,16 @@ public final class CMLock { } } -/// Wykonuje `body` pod blokada `name`, zwalniajac ja automatycznie na wyjsciu -/// (rowniez przy rzuconym bledzie) - odpowiednik `cm_acquire_lock` + `trap EXIT`. -/// Zwraca `nil` bez wywolania `body`, jesli inna zywa instancja juz trzyma blokade. +/// Runs `body` under the lock `name`, releasing it automatically on exit +/// (also when an error is thrown) - the counterpart of `cm_acquire_lock` + +/// `trap EXIT`. Returns `nil` without calling `body` if another live instance +/// already holds the lock. /// -/// `nil` znaczy **"nic sie nie wydarzylo"** i wolajacy MUSI to odroznic od -/// wyniku `body`. Zapis w rodzaju `(await withCMLock("image") { ... }) ?? true` -/// albo `!= nil ? ... : ...` zamieniajacy brak wykonania w sukces jest bledem: -/// `attach`, ktore nie doszlo do skutku, zameldowaloby "Podpiete", a -/// `detach`, ktore sie nie wykonalo - "wszystko wyslane". +/// `nil` means **"nothing happened"** and the caller MUST tell it apart from +/// the result of `body`. Code such as `(await withCMLock("image") { ... }) ?? +/// true` or `!= nil ? ... : ...` that turns not running into success is a bug: +/// an `attach` that did not happen would report "Attached", and a `detach` +/// that did not run - "everything uploaded". @discardableResult public func withCMLock(_ name: String, _ body: () throws -> T) rethrows -> T? { let lock = CMLock(name: name) diff --git a/mac-app/Sources/CloudMachineCore/CMLogger.swift b/mac-app/Sources/CloudMachineCore/CMLogger.swift index 3885662..2973b26 100644 --- a/mac-app/Sources/CloudMachineCore/CMLogger.swift +++ b/mac-app/Sources/CloudMachineCore/CMLogger.swift @@ -1,70 +1,71 @@ import Foundation -/// Odpowiednik `cm_log`/`cm_rotate_log_if_large` z bash-owego common.sh - -/// timestampowany zapis do wspolnego logu (`cloudmachine.log`) plus rotacja -/// "copytruncate", zeby logi nie rosly bez ograniczen (zaobserwowany na zywo -/// przypadek: rclone-mount.log urosl do 3.3 GiB bez zadnej rotacji). +/// Counterpart of `cm_log`/`cm_rotate_log_if_large` from the bash common.sh - +/// timestamped writes to the shared log (`cloudmachine.log`) plus +/// "copytruncate" rotation, so that logs do not grow without bound (a case +/// observed live: rclone-mount.log grew to 3.3 GiB without any rotation). public enum CMLogger { private static let lock = NSLock() - /// Dopisuje linie do `cloudmachine.log` (z timestampem) i do stdout - - /// odpowiednik `cm_log` (ktory uzywal `tee -a`). + /// Appends a line to `cloudmachine.log` (with a timestamp) and to stdout - + /// the counterpart of `cm_log` (which used `tee -a`). public static func log(_ message: String) { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" let line = "[\(formatter.string(from: Date()))] \(message)\n" emitToStandardOutput(line) append(line, to: CMPaths.combinedLogFile) - // WAZNE: `rotateIfLarge` istnialo wczesniej w kodzie, ale nigdzie nie - // bylo wywolywane - `cloudmachine.log` rosl bez ograniczen (dokladnie - // ten scenariusz, ktory ta funkcja miala zapobiegac, patrz komentarz - // przy niej). Sprawdzenie rozmiaru pliku to tani `stat`, wiec robimy to - // przy kazdym wpisie zamiast polegac na pamieci, zeby to gdzies wywolac. + // IMPORTANT: `rotateIfLarge` existed in the code before, but was never + // called anywhere - `cloudmachine.log` grew without bound (exactly the + // scenario this function was meant to prevent, see the comment on it). + // Checking the file size is a cheap `stat`, so we do it on every entry + // instead of relying on someone remembering to call it somewhere. rotateIfLarge(CMPaths.combinedLogFile) } - /// Wypisuje tekst na stdout i NATYCHMIAST oproznia bufor stdio. + /// Writes text to stdout and IMMEDIATELY flushes the stdio buffer. /// - /// Bez `fflush` linia zostawala w buforze biblioteki C, dopoki nie uzbieralo - /// sie 16 KiB. Pod launchd stdout jest PLIKIEM - /// (`StandardOutPath: __CM_LOG_DIR__/launchd-*.out.log`), a dla pliku stdio - /// wybiera buforowanie BLOKOWE - inaczej niz dla terminala, gdzie buforuje - /// liniami i problem nie istnieje. Dlatego nie dalo sie tego zobaczyc, - /// uruchamiajac to samo polecenie z reki. + /// Without `fflush` the line stayed in the C library buffer until 16 KiB had + /// accumulated. Under launchd stdout is a FILE + /// (`StandardOutPath: __CM_LOG_DIR__/launchd-*.out.log`), and for a file + /// stdio chooses BLOCK buffering - unlike a terminal, where it buffers by + /// line and the problem does not exist. That is why it could not be seen by + /// running the same command by hand. /// - /// Zmierzone 25.09.2026: `~/Library/Logs/CloudMachine/launchd-buffer-guard.out.log` - /// mial DOKLADNIE 16384 bajty, date 2026-09-20 i ostatnia linie urwana w pol - /// slowa, podczas gdy proces `buffer-guard` zyl od 2026-09-25 i normalnie - /// logowal do `cloudmachine.log`. Plik, do ktorego czlowiek zaglada NAJPIERW - /// (bo tak go kieruje nazwa), byl o piec dni z tylu i konczyl sie w polowie - /// zdania - czyli wygladal jak proces, ktory umarl piatego dnia. + /// Measured 25.09.2026: `~/Library/Logs/CloudMachine/launchd-buffer-guard.out.log` + /// was EXACTLY 16384 bytes, dated 2026-09-20, with the last line cut off + /// mid-word, while the `buffer-guard` process had been alive since + /// 2026-09-25 and was logging normally to `cloudmachine.log`. The file a + /// person looks at FIRST (because its name points them there) was five days + /// behind and ended mid-sentence - i.e. it looked like a process that died + /// on the fifth day. /// - /// Gryzie to wylacznie procesy DLUGOWIECZNE i dlatego tak dlugo zostawalo - /// niewidoczne: `buffer-guard` to `while true` + `KeepAlive`, wiec nigdy nie - /// dochodzi do oproznienia bufora przy wyjsciu. Krotkie podkomendy - /// (`attach-image`, `backup-health`) koncza sie po kazdym tiku, a `exit(3)` - /// oproznia bufor za nie - dlatego `launchd-backup-health.out.log` byl - /// aktualny tego samego dnia, w ktorym `launchd-buffer-guard.out.log` stal - /// od pieciu. + /// It bites only LONG-LIVED processes, which is why it stayed invisible for + /// so long: `buffer-guard` is `while true` + `KeepAlive`, so it never gets to + /// flush the buffer on exit. Short subcommands (`attach-image`, + /// `backup-health`) end after every tick, and `exit(3)` flushes the buffer + /// for them - that is why `launchd-backup-health.out.log` was current on the + /// same day on which `launchd-buffer-guard.out.log` had been stuck for five. /// - /// Dlaczego `fflush` tutaj, a nie `setvbuf(stdout, nil, _IOLBF, 0)` przy - /// starcie agenta: + /// Why `fflush` here, and not `setvbuf(stdout, nil, _IOLBF, 0)` at agent + /// startup: /// - /// - `setvbuf` trzeba zawolac w KAZDYM punkcie wejscia (agent CLI, GUI, - /// harnessy POC) i przed pierwszym zapisem na stdout. Zapomniany w jednym - /// z nich daje dokladnie te awarie z powrotem, a jej objawem znow jest - /// plik, ktory wyglada na kompletny. Gwarancja nalezy do ZAPISU, nie do - /// konfiguracji, ktora ktos musi pamietac wlaczyc. - /// - `setvbuf` po pierwszym I/O na strumieniu jest nieokreslony, wiec - /// "ustawimy to gdzies na starcie" jest w praktyce warunkiem na kolejnosc - /// inicjalizacji - a to sie cicho psuje przy przestawianiu kodu. - /// - Koszt jest zaniedbywalny: dziennik ma wpisy w tempie zdarzen (sekundy, - /// nie mikrosekundy), a ten sam wpis i tak leci juz `write(2)` do - /// `cloudmachine.log` obok. + /// - `setvbuf` has to be called at EVERY entry point (CLI agent, GUI, POC + /// harnesses) and before the first write to stdout. Forgotten in one of + /// them, it brings exactly this failure back, and its symptom is again a + /// file that looks complete. The guarantee belongs to the WRITE, not to a + /// configuration someone has to remember to turn on. + /// - `setvbuf` after the first I/O on the stream is undefined, so "we will + /// set it somewhere at startup" is in practice a condition on + /// initialization order - and that breaks silently when code is moved + /// around. + /// - The cost is negligible: the log has entries at the pace of events + /// (seconds, not microseconds), and the same entry already goes via + /// `write(2)` to `cloudmachine.log` next to it anyway. /// - /// To NIE zalatwia buforowania zwyklych `print(...)` z podkomend CLI - te - /// pisza wprost. Zalatwia dziennik, czyli to, co pod launchd jest jedynym - /// sladem po dzialaniu agenta. + /// This does NOT solve buffering of plain `print(...)` calls from CLI + /// subcommands - those write directly. It solves the log, i.e. what is the + /// only trace of the agent's activity under launchd. static func emitToStandardOutput(_ text: String) { print(text, terminator: "") fflush(stdout) @@ -76,9 +77,9 @@ public enum CMLogger { appendLocked(text, to: url) } - /// Zaklada, ze `lock` jest juz przejety przez wywolujacego - wydzielone z - /// `append(_:to:)`, zeby `rotateIfLargeLocked` mogl dopisac swoj wlasny - /// komunikat bez ponownego (rekurencyjnego) `lock.lock()`. + /// Assumes `lock` is already held by the caller - split out of + /// `append(_:to:)` so that `rotateIfLargeLocked` can append its own message + /// without a second (recursive) `lock.lock()`. private static func appendLocked(_ text: String, to url: URL) { guard let data = text.data(using: .utf8) else { return } if FileManager.default.fileExists(atPath: url.path) { @@ -92,17 +93,17 @@ public enum CMLogger { try? data.write(to: url) } - /// Przycina plik logu metoda "copytruncate", jesli przekroczyl `maxBytes` - - /// zachowuje ostatnie `keepLines` linii, POTEM obcina oryginal do 0 - /// bajtow. Bezpieczne dla procesu (np. rclone), ktory trzyma ten sam plik - /// otwarty do dopisywania (O_APPEND) - po obcieciu jadro samo przesuwa - /// nastepny zapis na nowy, mniejszy koniec pliku, wiec NIE trzeba - /// restartowac tego procesu, zeby rotacja zadziala. + /// Trims the log file using the "copytruncate" method if it has exceeded + /// `maxBytes` - keeps the last `keepLines` lines, THEN truncates the + /// original to 0 bytes. Safe for a process (e.g. rclone) that keeps the same + /// file open for appending (O_APPEND) - after truncation the kernel itself + /// moves the next write to the new, smaller end of the file, so that process + /// does NOT have to be restarted for the rotation to work. /// - /// WAZNE: chroniona tym samym `lock` co `append()` (wczesniej NIE byla) - - /// bez tego dwa watki w tym samym procesie logujace dokladnie w momencie - /// przekroczenia `maxBytes` moglyby rownolegle odczytac-przyciac-zapisac - /// ten sam plik bez koordynacji, gubiac swiezo dopisane linie. + /// IMPORTANT: guarded by the same `lock` as `append()` (previously it was + /// NOT) - without that, two threads in the same process logging exactly at + /// the moment `maxBytes` is exceeded could read-trim-write the same file in + /// parallel without coordination, losing freshly appended lines. @discardableResult public static func rotateIfLarge( _ url: URL, maxBytes: Int = 200 * 1024 * 1024, keepLines: Int = 5000 @@ -122,8 +123,8 @@ public enum CMLogger { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" let notice = - "[\(formatter.string(from: Date()))] Przycieto \(url.lastPathComponent)" - + " (bylo \(size) bajtow, zachowano ostatnie \(keepLines) linii).\n" + "[\(formatter.string(from: Date()))] Trimmed \(url.lastPathComponent)" + + " (was \(size) bytes, kept the last \(keepLines) lines).\n" emitToStandardOutput(notice) appendLocked(notice, to: url) return true diff --git a/mac-app/Sources/CloudMachineCore/CMPaths.swift b/mac-app/Sources/CloudMachineCore/CMPaths.swift index 59d928e..2b727e4 100644 --- a/mac-app/Sources/CloudMachineCore/CMPaths.swift +++ b/mac-app/Sources/CloudMachineCore/CMPaths.swift @@ -1,46 +1,49 @@ import Foundation -/// Rozwiazuje wszystkie sciezki uzywane przez CloudMachine - config, logi, -/// punkty montowania, oraz (dla CLI/launchd) katalog z zasobami (`launchd/`, -/// `config/machines.example.json`) - niezaleznie od tego, czy kod dziala jako -/// spakowany .app (Resources sa read-only, obok binarki w Contents/MacOS), czy -/// jako `swift run`/skompilowana binarka CLI uruchomiona z drzewa zrodel. +/// Resolves all paths used by CloudMachine - config, logs, mount points, and +/// (for the CLI/launchd) the resources directory (`launchd/`, +/// `config/machines.example.json`) - regardless of whether the code runs as a +/// packaged .app (Resources are read-only, next to the binary in +/// Contents/MacOS) or as `swift run`/a compiled CLI binary started from the +/// source tree. /// -/// Jedno miejsce prawdy dla GUI i CLI - wczesniej ta sama logika byla -/// zduplikowana (raz jako common.sh, raz czesciowo w CloudMachineController). +/// A single source of truth for the GUI and the CLI - previously the same logic +/// was duplicated (once as common.sh, once partly in CloudMachineController). public enum CMPaths { - /// Prawdziwa sciezka do dzialajacej binarki, po rozwinieciu dowiazan. + /// Real path of the running binary, with symlinks resolved. /// - /// NIE `CommandLine.arguments[0]`: przy wywolaniu z PATH (dowiazanie - /// `/usr/local/bin/cloudmachine-agent`, binarka z Homebrew) powloka podaje - /// tam sama nazwe, a `URL(fileURLWithPath:)` dokleja ja do biezacego - /// katalogu. `cd /tmp && cloudmachine-agent version` meldowal wtedy "Build - /// z drzewa roboczego", a `install-launchd` wskazalby launchd binarke - /// `/tmp/cloudmachine-agent`, ktorej nie ma. `Bundle.main.executableURL` - /// bierze sciezke od jadra, niezaleznie od tego, jak polecenie wpisano. + /// NOT `CommandLine.arguments[0]`: when invoked via PATH (the symlink + /// `/usr/local/bin/cloudmachine-agent`, a binary from Homebrew) the shell + /// puts just the name there, and `URL(fileURLWithPath:)` appends it to the + /// current directory. `cd /tmp && cloudmachine-agent version` then reported + /// "Build from the working tree", and `install-launchd` would have pointed + /// launchd at the binary `/tmp/cloudmachine-agent`, which does not exist. + /// `Bundle.main.executableURL` takes the path from the kernel, regardless of + /// how the command was typed. public static var runningExecutable: URL { (Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0])) .resolvingSymlinksInPath() } - /// Katalog z zasobami projektu (`launchd/`, `config/`) - w .app to - /// `Contents/Resources`, w checkoutcie deweloperskim to korzen repo - /// (rodzic `mac-app/`). `nil`, jesli zaden z tych katalogow nie istnieje - /// (np. binarka uruchomiona calkowicie poza kontekstem projektu). + /// Directory with the project resources (`launchd/`, `config/`) - in the + /// .app it is `Contents/Resources`, in a development checkout it is the repo + /// root (the parent of `mac-app/`). `nil` if none of these directories + /// exists (e.g. a binary started completely outside the project context). public static var resourcesRoot: URL? { if let bundled = Bundle.main.resourceURL, FileManager.default.fileExists(atPath: bundled.appendingPathComponent("launchd").path) { return bundled } - // Fallback dla `swift run`/`.build/*/cloudmachine-agent` w drzewie repo: - // ta binarka siedzi pod mac-app/.build///, wiec korzen - // repo to 5 poziomow wyzej. Sprawdzamy tez plytsza sciezke na wypadek - // uruchomienia bezposrednio z katalogu mac-app. + // Fallback for `swift run`/`.build/*/cloudmachine-agent` in the repo tree: + // that binary sits under mac-app/.build///, so the repo + // root is 5 levels up. We also check a shallower path in case it is run + // directly from the mac-app directory. let exeDir = runningExecutable.deletingLastPathComponent() - // Agent wolany przez dowiazanie: `Bundle.main` bywa wtedy liczony od - // katalogu dowiazania, nie od .app - wiec Resources szukamy tez obok - // prawdziwej binarki (Contents/MacOS -> Contents/Resources). + // Agent invoked through a symlink: `Bundle.main` is then sometimes + // computed from the symlink's directory, not from the .app - so we also + // look for Resources next to the real binary (Contents/MacOS -> + // Contents/Resources). let bundleResources = exeDir.deletingLastPathComponent().appendingPathComponent("Resources") if FileManager.default.fileExists( atPath: bundleResources.appendingPathComponent("launchd").path) @@ -83,15 +86,15 @@ public enum CMPaths { public static var combinedLogFile: URL { logDir.appendingPathComponent("cloudmachine.log") } - /// Sciezka do skompilowanej binarki `cloudmachine-agent`, ktora launchd ma - /// wolac zamiast dawnych `.sh`. Rozwiazywana w 3 krokach: - /// 1. Jesli TO WLASNIE MY jestesmy `cloudmachine-agent` (subkomenda - /// `install-launchd` odpalona z CLI) - wskazujemy na wlasna, biezaco - /// dzialajaca binarke. Dziala identycznie w .app i w checkoutcie deweloperskim. - /// 2. W przeciwnym razie (GUI woła to z poziomu `CloudMachineApp`) - - /// binarka-siostra obok binarki GUI w tym samym bundlu `.app`. - /// 3. Fallback dla GUI uruchomionego przez `swift run` w drzewie repo - - /// szukamy `cloudmachine-agent` w `.build/*/{release,debug}/` obok binarki GUI. + /// Path to the compiled `cloudmachine-agent` binary that launchd should call + /// instead of the old `.sh` scripts. Resolved in 3 steps: + /// 1. If WE OURSELVES are `cloudmachine-agent` (the `install-launchd` + /// subcommand run from the CLI) - point at our own, currently running + /// binary. Works identically in the .app and in a development checkout. + /// 2. Otherwise (the GUI calls this from `CloudMachineApp`) - the sibling + /// binary next to the GUI binary in the same `.app` bundle. + /// 3. Fallback for a GUI started via `swift run` in the repo tree - look for + /// `cloudmachine-agent` in `.build/*/{release,debug}/` next to the GUI binary. public static var agentBinaryPath: URL? { let selfURL = runningExecutable if selfURL.lastPathComponent == "cloudmachine-agent" { diff --git a/mac-app/Sources/CloudMachineCore/CMTooling.swift b/mac-app/Sources/CloudMachineCore/CMTooling.swift index 30ffe23..af42974 100644 --- a/mac-app/Sources/CloudMachineCore/CMTooling.swift +++ b/mac-app/Sources/CloudMachineCore/CMTooling.swift @@ -1,22 +1,22 @@ import Foundation -/// Rozwiazuje zewnetrzne narzedzia potrzebne warstwie Google Drive i sprawdza, -/// czy w ogole nadaja sie do uzycia. +/// Resolves the external tools the Google Drive layer needs and checks whether +/// they are usable at all. /// -/// 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: +/// It exists because `ProcessRunner.runRclone` calls `/usr/bin/env rclone`, +/// and that hits rclone from Homebrew - built WITHOUT FUSE support. When asked +/// to mount, it refuses outright: /// /// 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. +/// The official binary from rclone.org is needed. We keep it in our own +/// directory so as not to clash with the Homebrew installation, which the +/// remaining, non-mounting code paths use. public enum CMTooling { // MARK: - rclone - /// Katalog na narzedzia zarzadzane przez CloudMachine. + /// Directory for tools managed by CloudMachine. public static var toolsDir: URL { let dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cloudmachine/bin") @@ -24,7 +24,7 @@ public enum CMTooling { return dir } - /// Oficjalna binarka rclone z obsluga montowania. + /// Official rclone binary with mount support. public static var managedRclonePath: URL { toolsDir.appendingPathComponent("rclone") } @@ -33,25 +33,25 @@ public enum CMTooling { 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`. + /// Runs an rclone that CERTAINLY can mount. Every code path touching + /// mounting must go through here, not through `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 + // MARK: - Availability from the terminal - /// Sciezka, pod ktora `cloudmachine-agent` ma byc widoczny w PATH. + /// Path under which `cloudmachine-agent` should be visible in PATH. public static let commandLinkPath = "/usr/local/bin/cloudmachine-agent" - /// Zaklada dowiazanie do binarki agenta w PATH. + /// Creates a symlink to the agent binary in 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. + /// Without it, every command from the documentation - `prepare-shutdown`, + /// `drive-status` - ends in "command not found", because the binary sits + /// inside the app bundle. Root is not needed: `/usr/local/bin` belongs to + /// the user and the admin group. @discardableResult public static func linkCommandIntoPath() -> Bool { guard let agent = CMPaths.agentBinaryPath else { return false } @@ -68,7 +68,7 @@ public enum CMTooling { try? fm.removeItem(at: link) do { try fm.createSymbolicLink(at: link, withDestinationURL: agent) - CMLogger.log("Dodano \(commandLinkPath) -> \(agent.path)") + CMLogger.log("Added \(commandLinkPath) -> \(agent.path)") return true } catch { return false @@ -77,8 +77,8 @@ public enum CMTooling { // MARK: - FUSE - /// Nasza kopia FUSE-T - zeby nie trzymac w systemie osobnej aplikacji. - /// Patrz `FuseInstaller`. + /// Our copy of FUSE-T - so as not to keep a separate application in the + /// system. See `FuseInstaller`. public static var bundledFuseDir: URL { let dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cloudmachine/fuse") @@ -88,11 +88,11 @@ public enum CMTooling { 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. + /// The NFS server that actually holds the mount. Its path can be given via + /// the `FUSE_NFSSRV_PATH` variable, so it can live in our directory. public static var bundledNfsServer: URL { bundledFuseDir.appendingPathComponent("go-nfsv4") } - /// Sciezki, pod ktorymi moze siedziec FUSE. Wystarczy jedna. + /// Paths where FUSE may live. One is enough. private static let fuseCandidates = [ "/usr/local/lib/libfuse-t.dylib", "/usr/local/lib/libfuse.2.dylib", @@ -100,42 +100,44 @@ public enum CMTooling { "/Library/Filesystems/fuse-t.fs", ] - /// Czy FUSE jest zainstalowane. + /// Whether FUSE is installed. /// - /// 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. + /// BEWARE of a trap that has already sprung once: the first version of this + /// check in bash ran `ls a b c` and checked the exit code. `ls` returns an + /// error when ANY of the paths is missing, not when all of them are - so it + /// refused to start with a correctly installed FUSE-T. We check one by one. 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. + // Our own copy counts the same as a system installation: we can recreate + // the symlink in /usr/local/lib ourselves (FuseInstaller.ensureSystemLink), + // so its temporary absence does not mean there is no FUSE. The FUSE-T + // uninstaller deletes that symlink when removing the separate application + // - without this condition the status would then report FUSE missing even + // though mounting works. if FileManager.default.fileExists(atPath: bundledFuseLib.path) { return true } return fuseCandidates.contains { FileManager.default.fileExists(atPath: $0) } } - // MARK: - Diagnostyka gotowosci + // MARK: - Readiness diagnostics public struct Readiness { public var ready: Bool { missing.isEmpty } - /// Czego brakuje, w kolejnosci, w jakiej trzeba to naprawic. + /// What is missing, in the order in which it has to be fixed. public var missing: [String] - /// Polecenia, ktore to naprawiaja - gotowe do pokazania uzytkownikowi. + /// Commands that fix it - ready to be shown to the user. 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. + // The check is also an opportunity to repair - the symlink is sometimes + // deleted by the FUSE-T uninstaller and there is no reason to wait with + // that until the next startup. FuseInstaller.ensureSystemLink() var missing: [String] = [] var remedies: [String] = [] if !hasManagedRclone { - missing.append("rclone z obsluga montowania") + missing.append(L10n.tr("rclone with mount support")) remedies.append("cloudmachine-agent install-rclone") } if !hasFuse { diff --git a/mac-app/Sources/CloudMachineCore/ConfigStore.swift b/mac-app/Sources/CloudMachineCore/ConfigStore.swift index a432da7..e0a8f3e 100644 --- a/mac-app/Sources/CloudMachineCore/ConfigStore.swift +++ b/mac-app/Sources/CloudMachineCore/ConfigStore.swift @@ -1,9 +1,10 @@ import Foundation -/// Wynik proby wczytania configu - rozroznia "pliku nie ma" (bezpieczne, nowa -/// instalacja) od "plik jest, ale sie nie parsuje" (COS poszlo nie tak - reczna -/// edycja z bledem, przerwany zapis, niezgodny schemat). Te dwa przypadki NIE -/// moga byc tak samo obslugiwane - patrz komentarz przy `load()`. +/// Result of an attempt to load the config - tells "there is no file" (safe, a +/// new installation) apart from "the file is there, but does not parse" +/// (SOMETHING went wrong - a manual edit with a mistake, an interrupted write, +/// an incompatible schema). These two cases must NOT be handled the same way - +/// see the comment on `load()`. public enum ConfigLoadResult { case loaded(MachinesConfig) case missing @@ -11,9 +12,9 @@ public enum ConfigLoadResult { } public enum ConfigStore { - /// Wczytuje config, rozrozniajac przyczyne niepowodzenia - uzywaj tego, - /// nie `load()`, wszedzie tam gdzie brak configu powinien byc widoczny dla - /// uzytkownika (np. przy starcie appki). + /// Loads the config, distinguishing the cause of failure - use this, not + /// `load()`, wherever a missing config should be visible to the user (e.g. + /// at app startup). public static func loadResult() -> ConfigLoadResult { guard FileManager.default.fileExists(atPath: CMPaths.configPath.path) else { return .missing @@ -27,12 +28,11 @@ public enum ConfigStore { } } - /// Wygodny wrapper na `loadResult()` dla miejsc, ktorym wystarczy sama - /// wartosc - zwraca `.empty` zarowno dla "brak pliku" jak i "plik - /// uszkodzony", WIEC NIE uzywaj go przy starcie appki/CLI (tam trzeba - /// odroznic te dwa przypadki, zeby nie nadpisac cicho uszkodzonego-ale- - /// mozliwego-do-odzyskania pliku pusta konfiguracja przy pierwszym - /// auto-zapisie). + /// Convenience wrapper around `loadResult()` for places that only need the + /// value - returns `.empty` both for "no file" and for "corrupt file", SO do + /// NOT use it at app/CLI startup (there the two cases have to be told apart, + /// so that a corrupt-but-recoverable file is not silently overwritten with an + /// empty configuration on the first auto-save). public static func load() -> MachinesConfig { if case .loaded(let config) = loadResult() { return config @@ -51,15 +51,15 @@ public enum ConfigStore { FileManager.default.fileExists(atPath: CMPaths.configPath.path) } - /// Kopiuje uszkodzony plik configu obok, z sufiksem znacznika czasu, ZANIM - /// cokolwiek go nadpisze - jedyna siec bezpieczenstwa miedzy "plik sie nie - /// sparsowal" a "auto-zapis cicho nadpisal go pusta konfiguracja". - /// `nil` = kopii NIE MA. Wolajacy nie ma wtedy prawa niczego nadpisac - - /// patrz `ConfigInitialization`. + /// Copies the corrupt config file next to itself, with a timestamp suffix, + /// BEFORE anything overwrites it - the only safety net between "the file did + /// not parse" and "an auto-save silently overwrote it with an empty + /// configuration". `nil` = there is NO copy. The caller then has no right to + /// overwrite anything - see `ConfigInitialization`. /// - /// `configPath` jest podmienialny, zeby test mogl sprawdzic OBA warianty - - /// kopia powstala i kopia nie powstala - bez ruszania prawdziwego pliku - /// konfiguracyjnego tej maszyny. + /// `configPath` is replaceable so that a test can check BOTH variants - the + /// copy was made and the copy was not made - without touching this machine's + /// real configuration file. @discardableResult public static func backupCorruptFile(configPath: URL = CMPaths.configPath) -> URL? { guard FileManager.default.fileExists(atPath: configPath.path) else { return nil } @@ -74,10 +74,10 @@ public enum ConfigStore { } } - /// Odpowiednik `cm_require_config` - jesli brak configu, tworzy pusty (tak - /// samo jak GUI robilo dotad w `init()`) i zwraca go razem z wynikiem, tak - /// zeby wywolujacy (GUI init, watchdogi CLI) mogl wyswietlic komunikat o - /// uszkodzeniu, jesli taki byl, zamiast go cicho polykac. + /// Counterpart of `cm_require_config` - if there is no config, creates an + /// empty one (just as the GUI used to do in `init()`) and returns it together + /// with the result, so that the caller (GUI init, CLI watchdogs) can display + /// a corruption message, if there was one, instead of silently swallowing it. public static func loadOrInitialize() -> ConfigInitialization { switch loadResult() { case .loaded(let config): @@ -90,40 +90,41 @@ public enum ConfigStore { } } - /// Co robimy po nieudanym parsowaniu - w zaleznosci od tego, czy kopia - /// bezpieczenstwa POWSTALA. + /// What we do after a failed parse - depending on whether the safety copy + /// WAS MADE. /// - /// Czysta, zeby dalo sie sprawdzic testem oba warianty bez psucia - /// prawdziwego pliku konfiguracyjnego. + /// Pure, so that both variants can be tested without breaking the real + /// configuration file. static func decideAfterCorruption(backup: URL?, error: Error) -> ConfigInitialization { guard let backup else { return .corruptWithoutBackup(error: error) } return .corruptButBackedUp(config: .empty, backup: backup, error: error) } } -/// Wynik `loadOrInitialize()`. Trzy stany, nie para `(config, corruption)`. +/// Result of `loadOrInitialize()`. Three states, not a `(config, corruption)` +/// pair. /// -/// Do 25.09.2026 wynik `backupCorruptFile()` byl tu IGNOROWANY, a ta funkcja -/// przy porazce kopiowania oddaje `nil`. Wolajacy dostawal wiec pusta -/// konfiguracje i - w `CLIContext.load()` - komunikat "oryginal zachowany na -/// dysku z kopia zapasowa obok" takze wtedy, gdy zadnej kopii nie bylo. -/// Komentarz przy `backupCorruptFile` nazywa te kopie jedyna siecia -/// bezpieczenstwa miedzy "plik sie nie sparsowal" a "auto-zapis cicho nadpisal -/// go pusta konfiguracja" - i wlasnie ta siec potrafila nie istniec. +/// Until 25.09.2026 the result of `backupCorruptFile()` was IGNORED here, and +/// that function returns `nil` when copying fails. The caller therefore got an +/// empty configuration and - in `CLIContext.load()` - the message "original +/// kept on disk with a backup copy next to it" even when there was no copy at +/// all. The comment on `backupCorruptFile` calls this copy the only safety net +/// between "the file did not parse" and "an auto-save silently overwrote it +/// with an empty configuration" - and that very net could be missing. /// -/// Brak kopii musi PRZERWAC operacje nieodwracalna, a nie tylko dopisac -/// ostrzezenie do logu. Dlatego `.corruptWithoutBackup` NIE NIESIE -/// konfiguracji: typ nie pozwala dokonczyc pracy bez kopii, wiec zaden przyszly -/// wolajacy nie ma jak przeoczyc tego przypadku. +/// A missing copy must ABORT an irreversible operation, not just add a warning +/// to the log. That is why `.corruptWithoutBackup` does NOT CARRY a +/// configuration: the type does not allow finishing the work without a copy, +/// so no future caller can overlook this case. public enum ConfigInitialization { case ready(MachinesConfig) - /// Plik uszkodzony, ale lezy juz jego kopia pod `backup` - wolno pracowac - /// na pustej konfiguracji, bo oryginal da sie odzyskac. + /// File corrupt, but a copy of it already sits at `backup` - it is fine to + /// work on an empty configuration, because the original can be recovered. case corruptButBackedUp(config: MachinesConfig, backup: URL, error: Error) - /// Plik uszkodzony I kopii nie udalo sie zrobic. Pracowac NIE WOLNO. + /// File corrupt AND the copy could not be made. Work must NOT proceed. case corruptWithoutBackup(error: Error) - /// Konfiguracja do pracy - `nil` znaczy "przerwij", nie "pusta". + /// Configuration to work with - `nil` means "abort", not "empty". public var config: MachinesConfig? { switch self { case .ready(let config): return config diff --git a/mac-app/Sources/CloudMachineCore/DependencyInstaller.swift b/mac-app/Sources/CloudMachineCore/DependencyInstaller.swift index 3c3b066..a49315a 100644 --- a/mac-app/Sources/CloudMachineCore/DependencyInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/DependencyInstaller.swift @@ -1,17 +1,18 @@ import Foundation -/// Port `install.sh` - instaluje `rclone` przez Homebrew. Zaklada, ze Homebrew -/// jest juz obecny (dla bootstrapowania samego Homebrew z zera - GUI ma -/// wlasny, bardziej rozbudowany krok wymagajacy dialogu autoryzacji, patrz +/// Port of `install.sh` - installs `rclone` via Homebrew. Assumes Homebrew is +/// already present (for bootstrapping Homebrew itself from scratch the GUI has +/// its own, more elaborate step that needs an authorization dialog, see /// `CloudMachineController.installDependencies`). /// -/// `jq` NIE jest juz wymagane - bylo potrzebne wylacznie do parsowania JSON -/// w bashu; caly config parsuje teraz natywny `JSONDecoder` (patrz -/// `MachinesConfig`), wiec ta zaleznosc odpadla calkowicie przy migracji do Swift. +/// `jq` is NO longer required - it was needed only to parse JSON in bash; the +/// whole config is now parsed by the native `JSONDecoder` (see +/// `MachinesConfig`), so that dependency went away entirely with the migration +/// to Swift. public enum DependencyInstaller { public static let requiredTools = ["rclone"] - /// Sciezka do binarki brew, jesli Homebrew jest juz zainstalowany (Apple + /// Path to the brew binary, if Homebrew is already installed (Apple /// Silicon: /opt/homebrew, Intel: /usr/local). public static func resolvedBrewPath() -> String? { ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].first { @@ -35,14 +36,15 @@ public enum DependencyInstaller { guard let brewPath = resolvedBrewPath() else { return CMActionResult( succeeded: false, - message: "Homebrew nie jest zainstalowany. Zainstaluj go recznie: https://brew.sh") + message: L10n.tr("Homebrew is not installed. Install it manually: https://brew.sh")) } let result = try? await ProcessRunner.run(brewPath, ["install", "rclone"], timeout: 600) guard result?.succeeded == true else { return CMActionResult( succeeded: false, - message: "Instalacja rclone nie powiodla sie: \(result?.stderr ?? "nieznany blad")") + message: L10n.tr( + "Installing rclone failed: %@", result?.stderr ?? L10n.tr("unknown error"))) } - return CMActionResult(succeeded: true, message: "Zainstalowano rclone.") + return CMActionResult(succeeded: true, message: L10n.tr("Installed rclone.")) } } diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index 311633b..c9081b9 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -1,16 +1,16 @@ import Foundation -/// Bufor miedzy Time Machine a Google Drive - port `gdrive/mount-drive.sh`. +/// The buffer between Time Machine and Google Drive - a port of `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. +/// Mounts Drive as a volume with a local write cache. A write completes the +/// moment it reaches the cache, the upload goes on in the background - which +/// is why a broken link pauses the drain instead of interrupting the backup. /// -/// Proces rclone zostaje na pierwszym planie; cyklem zycia zarzadza launchd +/// The rclone process stays in the foreground; launchd manages its life cycle /// (KeepAlive). public enum DriveBufferService { - // MARK: - Sciezki i ustawienia + // MARK: - Paths and settings public static var root: URL { let dir = FileManager.default.homeDirectoryForCurrentUser @@ -24,68 +24,70 @@ public enum DriveBufferService { 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. + /// Top-level folder on Drive; each Mac has its own subfolder in it. + public static let remoteRoot = "CloudMachine" + /// This Mac's folder - see `DriveFolder` for why the name is fixed once set. + public static var remotePath: String { "\(remoteRoot)/\(DriveFolder.name)" } + /// Buffer size. Kept as a number, because the watchdog's thresholds are + /// derived from it - otherwise changing one without the other gives + /// thresholds that never fire or fire immediately. public static let cacheSizeGB = 100 public static var cacheSize: String { "\(cacheSizeGB)G" } - /// Ile rclone czeka od ostatniej zmiany pasma, zanim je wysle. + /// How long rclone waits after a band's last change before uploading it. /// - /// To NIE jest ustawienie ostroznosciowe, tylko zderzak na wzmocnienie - /// zapisu. Time Machine przepisuje te same pasma przez caly przebieg, a przy - /// krotkim odroczeniu kazde dotkniecie to pelne 32 MB wysylane od nowa. - /// Zmierzone na wlasnym logu (56 533 odstepow miedzy kolejnymi wysylkami - /// TEGO SAMEGO pasma): mediana odstepu to 9,5 minuty, wiec 10 minut sklei - /// okolo polowy powtorzen. Dalsze wydluzanie oplaca sie coraz slabiej - /// (15 min -> 57%, 30 min -> 70%), a rosnie okno, w ktorym dane sa TYLKO - /// lokalnie. + /// This is NOT a caution setting, but a bumper against write amplification. + /// Time Machine rewrites the same bands throughout a run, and with a short + /// delay every touch is a full 32 MB uploaded again. Measured on our own log + /// (56,533 intervals between consecutive uploads of THE SAME band): the median + /// interval is 9.5 minutes, so 10 minutes merges about half of the repeats. + /// Going longer pays off less and less (15 min -> 57%, 30 min -> 70%), while + /// the window in which data exists ONLY locally grows. /// - /// Historia: bylo 30 s i przy tej wartosci doba 14/15 wrzesnia 2026 - /// wypchnela 823 GB na Dysk przy realnej zmianie okolo 45 GB - czyli ponad - /// dobowy limit Google (750 GB), co zablokowalo wysylke na kilka godzin. + /// History: it was 30 s, and with that value the day of 14/15 September 2026 + /// pushed 823 GB to Drive for about 45 GB of real change - i.e. over Google's + /// daily limit (750 GB), which blocked the upload for several hours. /// - /// Kazda sciezka wygaszania MUSI wymuszac wysylke przez - /// `expireQueuedUploads()`, inaczej odpiecie czekaloby tyle, co to odroczenie. + /// Every shutdown path MUST force the upload via `expireQueuedUploads()`, + /// otherwise a detach would wait as long as this delay. public static let writeBackSeconds = 600 - /// 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`. + /// Address of rclone's remote control interface. It listens on loopback only, + /// but any local process can control the mount through it - if we ever decide + /// that is too loose, `--rc-user`/`--rc-pass` have to be added. 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. + /// Above this size the rclone log is trimmed at start-up. rclone does not + /// rotate its own log, and this project has already lost 3.3 GiB once to a log + /// that grew without limit. private static let logSizeLimit: UInt64 = 100 * 1024 * 1024 - // MARK: - Stan + // MARK: - State - /// Punkty montowania prosto z tablicy jadra. `nil` = tablicy NIE UDALO SIE - /// odczytac, co jest czyms innym niz "nic nie jest zamontowane". + /// Mount points straight from the kernel table. `nil` = the table COULD NOT + /// be read, which is something else than "nothing is mounted". /// - /// DLACZEGO NIE `/sbin/mount` + /// WHY NOT `/sbin/mount` /// - /// Do 23 wrzesnia 2026 bylo tu uruchomienie `/sbin/mount` z - /// `readDataToEndOfFile()` + `waitUntilExit()` BEZ limitu czasu. Przy martwym - /// montowaniu FUSE-T (incydent ENXIO z 22.09) taki odczyt potrafi wejsc w - /// nieprzerywalne I/O i nie wrocic - a czyta stad `isMounted`, czyli czujka - /// `backup-health` ORAZ petla odswiezania GUI chodzaca co 10 sekund. - /// Zawieszenie wieszalo wiec i podglad, i nadzor, na tej samej awarii, - /// ktora oba maja wykryc. + /// Until 23 September 2026 this ran `/sbin/mount` with + /// `readDataToEndOfFile()` + `waitUntilExit()` WITHOUT a time limit. With a + /// dead FUSE-T mount (the ENXIO incident of 22.09) such a read can enter + /// uninterruptible I/O and never return - and `isMounted` reads from here, + /// i.e. the `backup-health` monitor AND the GUI refresh loop running every 10 + /// seconds. The hang thus froze both the view and the supervision, on the very + /// failure both are meant to detect. /// - /// Nalozenie limitu czasu (jak w `TimeMachineStatus.commandTimeout`) - /// usuneloby zawieszenie, ale kazdy taki limit jest tu czystym kosztem: - /// przy odswiezaniu co 10 s wywolania zaczelyby sie nakladac, a odpowiedz - /// i tak by nie przyszla. `getmntinfo(MNT_NOWAIT)` usuwa problem u zrodla - - /// czyta tablice montowan z pamieci jadra i NIE odpytuje zadnego systemu - /// plikow (od tego jest `MNT_WAIT`, ktore wlasnie umialoby zawisnac). - /// Nie ma tu procesu, potoku ani wejscia/wyjscia, wiec nie ma czego - /// ograniczac limitem. Zmierzone na tej maszynie: 19 montowan w 0,0002 s. + /// Adding a time limit (as in `TimeMachineStatus.commandTimeout`) would remove + /// the hang, but any such limit is pure cost here: with a refresh every 10 s + /// the calls would start to overlap, and the answer would not come anyway. + /// `getmntinfo(MNT_NOWAIT)` removes the problem at the source - it reads the + /// mount table from kernel memory and does NOT query any file system (that is + /// what `MNT_WAIT` does, which is exactly what could hang). There is no + /// process, pipe or I/O here, so there is nothing to put a limit on. Measured + /// on this machine: 19 mounts in 0.0002 s. /// - /// Przy okazji znika parsowanie tekstu: `f_mntonname` to sciezka wprost, - /// zamiast szukania `" on "` w wydruku. + /// Text parsing goes away as a bonus: `f_mntonname` is the path itself, + /// instead of searching for `" on "` in the output. public static func mountPoints() -> [String]? { var raw: UnsafeMutablePointer? let count = getmntinfo(&raw, MNT_NOWAIT) @@ -97,60 +99,59 @@ public enum DriveBufferService { } } - /// Czy bufor jest zamontowany. `nil` = NIE WIADOMO. + /// Whether the buffer is mounted. `nil` = UNKNOWN. /// - /// Rozroznienie jest tu istotne, bo na tej odpowiedzi stoi decyzja o - /// podpieciu i o utworzeniu obrazu - a "nie wiem" udajace "nie zamontowane" - /// to ten sam rodzaj cichej awarii, ktory w tym pliku zamyka juz - /// `UploadState.queueUnknown` po stronie kolejki. + /// The distinction matters here, because the decision to attach and to create + /// the image rests on this answer - and "I do not know" posing as "not + /// mounted" is the same kind of silent failure that `UploadState.queueUnknown` + /// already closes on the queue side. public static func mountedState() -> Bool? { guard let points = mountPoints() else { return nil } - // Tablica montowan jest zrodlem prawdy - samo istnienie katalogu nic nie - // znaczy, bo punkt montowania zostaje na dysku po odmontowaniu. + // The mount table is the source of truth - the directory merely existing + // means nothing, because the mount point stays on disk after unmounting. return points.contains(mountPoint.path) } - /// Skrot dla miejsc, w ktorych brak odczytu i "nie zamontowane" znacza to - /// samo - czyli tam, gdzie i tak czekamy na montowanie albo tylko je - /// wypisujemy. Wszedzie, gdzie z odpowiedzi wynika DECYZJA, uzywaj - /// `mountedState()`. + /// Shortcut for places where a failed read and "not mounted" mean the same + /// thing - i.e. where we wait for the mount anyway or only print it. + /// Wherever a DECISION follows from the answer, use `mountedState()`. public static var isMounted: Bool { mountedState() ?? false } - // MARK: - Uruchomienie + // MARK: - Start-up - /// Argumenty `rclone mount`. Wydzielone, zeby dalo sie je sprawdzic testem - /// bez uruchamiania czegokolwiek. + /// Arguments for `rclone mount`. Split out so that they can be tested without + /// running anything. 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. + // The cache must not evict data waiting to be uploaded - hence the high + // age. Size is governed by --vfs-cache-max-size. "--vfs-cache-max-age", "9999h", "--vfs-write-back", "\(writeBackSeconds)s", "--vfs-cache-poll-interval", "1m", "--cache-dir", cacheDir.path, - // Do tego folderu pisze WYLACZNIE ten Mac, wiec powiadomienia o zmianach - // z Dysku niosa tylko nasze wlasne wysylki - a kazde uniewaznia katalog - // `bands` (18 853 pliki 02.10.2026). Nastepny `stat` przeladowywal go - // z Google stronami po 1000, TRZYMAJAC blokade katalogu: ~42 s co - // minute stal kazdy Getattr z FUSE (Time Machine, hdiutil), kazde - // `vfs/stats` i kazde zakonczenie wysylki. Z tego braly sie zawieszone - // `hdiutil attach`, `validateMountPoint timed out` w Time Machine - // i czerwony panel. Bez powiadomien i z dlugim cache katalog laduje sie - // raz po starcie; wlasne zapisy rclone dopisuje do niego sam. + // ONLY this Mac writes to this folder, so change notifications from Drive + // carry only our own uploads - and each one invalidates the `bands` + // directory (18,853 files on 02.10.2026). The next `stat` reloaded it from + // Google in pages of 1000, HOLDING the directory lock: for ~42 s out of every + // minute every Getattr from FUSE (Time Machine, hdiutil), every `vfs/stats` + // and every upload completion stood still. That is where the hung + // `hdiutil attach`, `validateMountPoint timed out` in Time Machine and the red + // panel came from. Without notifications and with a long cache the directory + // loads once after start-up; rclone adds its own writes to it by itself. "--poll-interval", "0", "--dir-cache-time", "9999h", "--attr-timeout", "5m", "--transfers", "8", - // Rozmiar kawalka dopasowany do rozmiaru pasma obrazu. + // Chunk size matched to the image's band size. "--drive-chunk-size", "32M", - // Bez tego skasowane pasma ida do kosza Dysku i dalej licza sie - // do limitu pojemnosci. + // Without this, deleted bands go to Drive's trash and keep counting + // towards the storage limit. "--drive-use-trash=false", - // Po przekroczeniu dobowego limitu 750 GB rclone ma stanac, a nie - // kreci sie w 403 do konca swiata. + // After exceeding the daily 750 GB limit rclone is to stop, not spin in + // 403s until the end of the world. "--drive-stop-on-upload-limit", "--volname", remoteName, "--rc", "--rc-addr", rcAddress, "--rc-no-auth", @@ -159,9 +160,9 @@ public enum DriveBufferService { ] } - /// 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. + /// Prepares the environment and returns the arguments to run with. Does not + /// start rclone itself - `cloudmachine-agent mount-drive` does that, and it + /// has to stay in the foreground under launchd. public static func prepare() throws -> [String] { try FileManager.default.createDirectory(at: mountPoint, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) @@ -179,10 +180,11 @@ public enum DriveBufferService { 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. + /// Excludes the buffer directory from Time Machine. The buffer holds a copy of + /// the backup data - if Time Machine covered it, it would back up its own + /// backup and grow forever. The exclusion is stored as an xattr on the + /// directory, so it disappears together with it; that is why we renew it on + /// every start-up, not once in setup. @discardableResult public static func excludeBufferFromTimeMachine() async -> Bool { let result = try? await ProcessRunner.run( @@ -190,74 +192,74 @@ public enum DriveBufferService { return result?.succeeded == true } - // MARK: - Kolejka wysylki + // MARK: - Upload queue 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. + /// Buffer size according to rclone itself. Computing it with our own walk of + /// the directory meant 6504 stat calls on every interface refresh, every 10 + /// seconds, on the same disk the backup is going to. 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. + /// rclone has nowhere left to put data - it has not managed to upload what + /// it holds, so there is nothing to evict. A stronger signal than any + /// threshold, because it comes from the one who really knows. public var outOfSpace: Bool - /// rclone nic TERAZ nie robi. To warunek STABILNOSCI `hdiutil` na - /// montowaniu FUSE-T (patrz `retryingFlakyMount`) i nic wiecej - w - /// szczegolnosci NIE jest dowodem, ze kopia doleciala na Dysk. + /// rclone is doing nothing RIGHT NOW. This is a STABILITY condition for + /// `hdiutil` on a FUSE-T mount (see `retryingFlakyMount`) and nothing more - + /// in particular it is NOT proof that the backup has reached Drive. public var isIdle: Bool { uploadsInProgress == 0 && uploadsQueued == 0 } - /// Nic nie czeka I nic nie zostalo po drodze porzucone. + /// Nothing is waiting AND nothing was abandoned along the way. /// - /// Do 23 wrzesnia 2026 to pytanie mialo tylko jedna odpowiedz - te, ktora - /// dzis nazywa sie `isIdle` - i to ona szla do komunikatu odpiecia oraz do - /// `safeToRebootNow()`. Pasmo, ktore rclone porzucil, wypada z kolejki - /// dokladnie tak samo jak pasmo wyslane: `uploadsQueued` wraca do zera, - /// a slad zostaje wylacznie w `erroredFiles`. Skutek: "Odpiete, wszystko - /// wyslane na Google Drive" i "Restart bez pytania: TAK" przy danych - /// istniejacych tylko lokalnie - podczas gdy `UploadState` z tych samych - /// licznikow wyprowadzal juz `.failedFiles(...)` i "WYMAGA REAKCJI". + /// Until 23 September 2026 this question had only one answer - the one now + /// called `isIdle` - and it went into the detach message and into + /// `safeToRebootNow()`. A band that rclone abandoned drops out of the queue + /// exactly like an uploaded band: `uploadsQueued` returns to zero, and the + /// only trace is left in `erroredFiles`. Result: "Detached, everything + /// uploaded to Google Drive" and "Restart without asking: YES" with data + /// existing only locally - while `UploadState`, from the same counters, + /// already derived `.failedFiles(...)` and "ACTION NEEDED". public var isQuiet: Bool { isIdle && erroredFiles == 0 } - /// Ile pozycji CZEKA na wyslanie: kolejka plus to, co wlasnie leci. + /// How many items are WAITING to be uploaded: the queue plus what is + /// uploading right now. /// - /// To jest ta wielkosc, z ktorej dozorca bufora wyprowadza swoja miare - /// (patrz `BufferGuardService.backlogGB`) - a NIE `bytesUsed`. Rozmiar - /// cache'a przy `--vfs-cache-max-size 100G` i `--vfs-cache-max-age 9999h` - /// stoi pod limitem stale (w dzienniku 281 pomiarow, minimum 99 GB), bo - /// rclone trzyma tam takze to, co dawno wyslal. Zaleglosc niewyslana jest - /// jedyna z tych dwoch liczb, ktora odpowiada na pytanie "czy wysylka - /// nadaza". + /// This is the quantity from which the buffer watchdog derives its measure + /// (see `BufferGuardService.backlogGB`) - and NOT `bytesUsed`. The cache size + /// with `--vfs-cache-max-size 100G` and `--vfs-cache-max-age 9999h` sits at + /// the limit permanently (281 measurements in the journal, minimum 99 GB), + /// because rclone also keeps there what it uploaded long ago. The unsent + /// backlog is the only one of these two numbers that answers the question + /// "is the upload keeping up". /// - /// `uploadsInProgress` wchodzi do sumy, bo pozycja w trakcie wysylki tez - /// jeszcze nie jest na Dysku i tez zajmuje bufor. Przy `--transfers 8` to - /// najwyzej osiem pozycji, ale pusta kolejka z osmioma transferami w toku - /// nie jest zerowa zaleglosci. + /// `uploadsInProgress` is part of the sum, because an item being uploaded is + /// not on Drive yet either and also takes up the buffer. With `--transfers 8` + /// that is at most eight items, but an empty queue with eight transfers in + /// progress is not a zero backlog. public var unsentItems: Int { uploadsQueued + uploadsInProgress } } - /// Odczytuje stan kolejki przez interfejs sterujacy rclone. + /// Reads the queue state through rclone's remote control interface. /// - /// 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. + /// NOTE: `--rc-no-auth` is a SERVER flag. The `rclone rc` client does not + /// accept it and ends with an "unknown flag" error - this has already cost one + /// silent breakage of the status view. /// - /// Limit czasu 60 s, a nie 30 s: 23.09.2026 to samo wywolanie trwalo - /// **36,7 s** przy zapchanym buforze (kolejne 0,03 s - wiec sporadycznie, pod - /// obciazeniem). Przy 30 s konczylo sie `nil`, a `nil` szedl dalej jako - /// komplet zer i interfejs oglaszal "Wszystko wyslane" przy 386 pasmach w - /// kolejce. Samo podniesienie limitu tego nie naprawia - od tego jest - /// `UploadState.queueUnknown` - ale sprawia, ze pytanie zwykle dostaje - /// odpowiedz. + /// Time limit 60 s, not 30 s: on 23.09.2026 the same call took **36.7 s** with + /// a clogged buffer (the next one 0.03 s - so sporadic, under load). With 30 s + /// it ended with `nil`, and `nil` went on as a set of zeros and the interface + /// announced "Everything uploaded" with 386 bands in the queue. Raising the + /// limit alone does not fix that - that is what `UploadState.queueUnknown` is + /// for - but it means the question usually gets an answer. /// - /// Wyzej nie warto. Petla odswiezania interfejsu chodzi co 10 s i czeka na - /// ten odczyt, a `drive-status` pyta dwa razy (drugi raz przez - /// `safeToRebootNow`). Przy martwym rclone kazda sekunda limitu to sekunda - /// zamrozonego okna, a odpowiedz i tak nie przyjdzie. + /// Higher is not worth it. The interface refresh loop runs every 10 s and + /// waits for this read, and `drive-status` asks twice (the second time via + /// `safeToRebootNow`). With a dead rclone every second of the limit is a + /// second of a frozen window, and the answer will not come anyway. public static func queueStats() async -> QueueStats? { guard let result = try? await CMTooling.runRclone( @@ -267,30 +269,31 @@ public enum DriveBufferService { return parseQueueStats(result.stdout) } - /// Czysta wersja parsowania odpowiedzi `vfs/stats`. `nil` znaczy "nie wiem", - /// nigdy "same zera". + /// Pure version of parsing the `vfs/stats` response. `nil` means "I do not + /// know", never "all zeros". /// - /// Wydzielone, zeby dalo sie to sprawdzic testem - dotad parsowanie siedzialo - /// w funkcji async wolajacej rclone i nie bylo do niego dostepu z zadnej - /// strony poza uruchomieniem calego bufora. + /// Split out so that it can be tested - until now the parsing sat in an async + /// function calling rclone and there was no way to reach it other than + /// running the whole buffer. /// - /// Dwie rzeczy, ktore tu byly i musialy zniknac: + /// Two things that were here and had to go: /// - /// 1. `(json["diskCache"] as? [String: Any]) ?? json` - odpowiedz BEZ sekcji - /// `diskCache` (rclone zbudowane bez cache dysku, inna wersja interfejsu, - /// obcieta odpowiedz) wpadala na `json`, gdzie zadnego z licznikow nie ma. - /// 2. `number(_:in:)` oddajace 0 dla brakujacego klucza. + /// 1. `(json["diskCache"] as? [String: Any]) ?? json` - a response WITHOUT the + /// `diskCache` section (rclone built without the disk cache, a different + /// interface version, a truncated response) fell through to `json`, where + /// none of the counters are. + /// 2. `number(_:in:)` returning 0 for a missing key. /// - /// Razem dawaly `QueueStats` z samymi zerami zamiast `nil`, czyli - /// `queueKnown == true` i znow plansza "Wszystko wyslane na Google Drive". - /// To ten sam wzorzec, ktory naprawiono wyzej przez `UploadState.queueUnknown`, - /// tyle ze przesuniety o jeden krok - do parsowania. + /// Together they gave `QueueStats` with all zeros instead of `nil`, i.e. + /// `queueKnown == true` and again the "Everything uploaded to Google Drive" + /// screen. It is the same pattern that was fixed above by + /// `UploadState.queueUnknown`, just moved one step - into the parsing. static func parseQueueStats(_ raw: String) -> QueueStats? { guard let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - // vfs/stats zwraca liczniki zagniezdzone w sekcji "diskCache". Jej brak - // to brak odpowiedzi na zadane pytanie, a nie odpowiedz "zero". + // vfs/stats returns the counters nested in the "diskCache" section. Its + // absence is no answer to the question asked, not the answer "zero". let disk = json["diskCache"] as? [String: Any] else { return nil } @@ -314,22 +317,22 @@ public enum DriveBufferService { files: files, erroredFiles: errored, bytesUsed: UInt64(max(0, bytesUsed)), - // Jedyne pole, ktorego brak wolno nadrobic domyslna wartoscia: to flaga, - // a nie licznik - starsze rclone jej nie wystawia, a jej brak nie da sie - // pomylic z "bufor pelny". + // The only field whose absence may be made up with a default: it is a + // flag, not a counter - older rclone does not expose it, and its absence + // cannot be mistaken for "buffer full". outOfSpace: (disk["outOfSpace"] as? Bool) ?? false ) } - /// Pojemnosc konta Google Drive, prosto od rclone. + /// Google Drive account capacity, straight from rclone. /// - /// NIKT tego dotad nie sprawdzal. `machines.json` ma pola `drive_total_gb` - /// i `limit_gb`, ale nie uzywa ich ani jedna linia kodu poza samym modelem - - /// to byl budzet na papierze. Tymczasem wyczerpanie miejsca na Dysku jest dla - /// rclone bledem FATALNYM (`--drive-stop-on-upload-limit` + - /// `storageQuotaExceeded`): montowanie znika, a Time Machine traci cel. - /// Przy przyroscie rzedu 600 MB na cykl godzinowy to nie jest problem - /// odlegly, tylko kwestia daty. + /// NOBODY checked this until now. `machines.json` has the fields + /// `drive_total_gb` and `limit_gb`, but not a single line of code outside the + /// model itself uses them - it was a budget on paper. Meanwhile running out of + /// space on Drive is a FATAL error for rclone (`--drive-stop-on-upload-limit` + + /// `storageQuotaExceeded`): the mount disappears and Time Machine loses its + /// destination. With growth of around 600 MB per hourly cycle this is not a + /// distant problem, only a matter of the date. public static func remoteQuota() async -> (used: UInt64, total: UInt64, free: UInt64)? { guard let result = try? await CMTooling.runRclone( @@ -343,26 +346,25 @@ public enum DriveBufferService { if let v = json[key] as? NSNumber, v.int64Value >= 0 { return UInt64(v.int64Value) } return nil } - // `total` bywa nieobecne (konta bez limitu) - wtedy nie ma czego pilnowac. + // `total` is sometimes absent (accounts without a limit) - then there is nothing to watch. guard let total = bytes("total"), total > 0 else { return nil } let used = bytes("used") ?? 0 let free = bytes("free") ?? (total > used ? total - used : 0) return (used, total, free) } - /// Czeka, az rclone przestanie cokolwiek wysylac. Operacje `hdiutil` na - /// montowaniu FUSE-T sa stabilne tylko przy pustej kolejce - patrz + /// Waits until rclone stops uploading anything. `hdiutil` operations on a + /// FUSE-T mount are stable only with an empty queue - see /// `BackupImageService.retryingFlakyMount`. /// - /// Warunkiem jest `isIdle`, a NIE `isQuiet`: pasma porzucone przez rclone - /// zostaja w `erroredFiles` do konca zycia procesu, wiec czekanie na - /// `isQuiet` nigdy by sie nie doczekalo i kazde podpiecie placilo by pelny - /// limit czasu za nic. + /// The condition is `isIdle`, NOT `isQuiet`: bands abandoned by rclone stay in + /// `erroredFiles` until the process ends, so waiting for `isQuiet` would never + /// finish and every attach would pay the full time limit for nothing. /// - /// Zwraca odczyt kolejki z chwili uciszenia - `nil`, gdy nie ucichla w czasie - /// albo gdy rclone nie odpowiedzial. Wolajacy dostaje go po to, zeby moc - /// sprawdzic `erroredFiles` bez zadawania rclone tego samego pytania drugi - /// raz (kosztuje do 60 s - patrz `queueStats`). + /// Returns the queue reading from the moment it went quiet - `nil` when it did + /// not go quiet in time or rclone did not answer. The caller gets it so that it + /// can check `erroredFiles` without asking rclone the same question a second + /// time (costs up to 60 s - see `queueStats`). @discardableResult public static func statsWhenIdle(timeout: TimeInterval = 180) async -> QueueStats? { let deadline = Date().addingTimeInterval(timeout) @@ -373,58 +375,61 @@ public enum DriveBufferService { return nil } - /// Jak `statsWhenIdle`, gdy wolajacego interesuje wylacznie "doczekalem sie". + /// Like `statsWhenIdle`, when the caller only cares about "it got there". @discardableResult public static func waitUntilIdle(timeout: TimeInterval = 180) async -> Bool { await statsWhenIdle(timeout: timeout) != nil } - /// Przesuwa termin wysylki wszystkich czekajacych pozycji na "teraz". - /// - /// Potrzebne przy KAZDYM wygaszaniu. Pozycja trafia do kolejki zaraz po - /// zapisie - widac ja w `vfs/queue` od razu - ale z terminem wymagalnosci - /// `writeBackSeconds` w przod. Bez przesuniecia odpiecie czekaloby cale te - /// dziesiec minut, a `prepare-shutdown` przed restartem Maca stalby sie nie - /// do zniesienia. To nie jest kosmetyka: uzytkownik, ktory nie chce czekac, - /// wylaczy Maca bez `prepare-shutdown`, a to juz raz zostawilo Time Machine - /// bez celu na cala noc. - /// - /// Wolac PO tym, jak zapisy z odpiecia zdazyly trafic do kolejki - pozycje - /// dolozone pozniej nie zostana ruszone. - /// - /// Zwraca `ExpiryOutcome` - ile pozycji ZASTALISMY i ilu udalo sie przesunac - /// termin - albo `nil`, gdy rclone nie odpowiedzial na pytanie o kolejke. - /// - /// `nil` i `0` to DWIE ROZNE RZECZY i dlatego typ jest opcjonalny. Do 23 - /// wrzesnia 2026 obie sytuacje - "kolejka byla pusta" i "nie dostalismy - /// odpowiedzi" - wychodzily stad jako `0`, wiec `detach` milczal w logu - /// dokladnie w tym przypadku, w ktorym terminow NIE przesunieto i drenaz - /// mogl potrwac cale `writeBackSeconds` (dziesiec minut) zamiast chwili. - /// - /// Sama liczba przesunietych pozycji nie wystarcza, bo TRZECI przypadek - /// wyglada jak pierwszy: gdy kolejka ma pozycje, ale kazde - /// `vfs/queue-set-expiry` padnie, "przesunieto 0" bylo nieodroznialne od - /// "nie bylo czego przesuwac". Dlatego `queued` i `moved` sa osobno - patrz + /// Moves the upload deadline of all waiting items to "now". + /// + /// Needed on EVERY shutdown. An item enters the queue right after the write - + /// it is visible in `vfs/queue` immediately - but with a due date + /// `writeBackSeconds` ahead. Without moving it a detach would wait those whole + /// ten minutes, and `prepare-shutdown` before a Mac restart would become + /// unbearable. This is not cosmetic: a user who does not want to wait will + /// shut down the Mac without `prepare-shutdown`, and that has already once left + /// Time Machine without a destination for a whole night. + /// + /// Call AFTER the writes from the detach have had time to reach the queue - + /// items added later will not be touched. + /// + /// Returns `ExpiryOutcome` - how many items we FOUND and for how many the + /// deadline was moved - or `nil` when rclone did not answer the question about + /// the queue. + /// + /// `nil` and `0` are TWO DIFFERENT THINGS, and that is why the type is + /// optional. Until 23 September 2026 both situations - "the queue was empty" + /// and "we got no answer" - came out of here as `0`, so `detach` stayed silent + /// in the log in exactly the case where the deadlines were NOT moved and the + /// drain could take the whole `writeBackSeconds` (ten minutes) instead of a + /// moment. + /// + /// The number of moved items alone is not enough, because a THIRD case looks + /// like the first: when the queue has items but every `vfs/queue-set-expiry` + /// fails, "moved 0" was indistinguishable from "there was nothing to move". + /// That is why `queued` and `moved` are separate - see /// `BackupImageService.expiryLogLine`. /// - /// Limit na samo listowanie kolejki podniesiony z 30 s do 60 s: ten sam plik - /// dokumentuje pomiar **36,7 s** dla LZEJSZEGO `vfs/stats` przy zapchanym - /// buforze (patrz `queueStats`), a `vfs/queue` wypisuje wtedy setki pozycji. - /// Przy 30 s odpowiedz nie zdazala przyjsc dokladnie wtedy, gdy przesuniecie - /// terminow bylo najbardziej potrzebne. - /// - /// Limit pojedynczego `queue-set-expiry` zostaje na 30 s CELOWO: tamto jedno - /// wywolanie decyduje o calej funkcji, a to jest jedno z setek i jego strata - /// kosztuje jedna pozycje. Przy kilkuset pozycjach sufit 60 s na sztuke - /// zamienilby odpiecie w operacje bez gornego ograniczenia czasu. - /// Ile pozycji do przyspieszenia bylo w kolejce i ilu FAKTYCZNIE przesunieto - /// termin. Dwa pola, nie jedno, bo "zero" znaczy cos innego w zaleznosci od - /// tego, ile bylo prob - patrz `expireQueuedUploads`. + /// The limit for listing the queue itself raised from 30 s to 60 s: this same + /// file documents a measurement of **36.7 s** for the LIGHTER `vfs/stats` with + /// a clogged buffer (see `queueStats`), and `vfs/queue` lists hundreds of items + /// at such times. With 30 s the answer failed to arrive exactly when moving + /// the deadlines was needed most. + /// + /// The limit for a single `queue-set-expiry` stays at 30 s ON PURPOSE: that + /// one call decides the whole function, while this is one of hundreds and + /// losing it costs one item. With several hundred items a 60 s ceiling per + /// item would turn the detach into an operation without an upper time bound. + /// How many items to speed up were in the queue and for how many the deadline + /// was ACTUALLY moved. Two fields, not one, because "zero" means something + /// different depending on how many attempts there were - see + /// `expireQueuedUploads`. public struct ExpiryOutcome: Sendable, Equatable { - /// Pozycje zastane w kolejce, ktore dalo sie przyspieszyc (bez tych juz - /// wysylanych - patrz `parseQueueIDs`). + /// Items found in the queue that could be sped up (without those already + /// uploading - see `parseQueueIDs`). public var queued: Int - /// Ile z nich rclone potwierdzil. + /// How many of them rclone confirmed. public var moved: Int public init(queued: Int, moved: Int) { @@ -444,7 +449,7 @@ public enum DriveBufferService { var moved = 0 for id in ids { - // Duza liczba ujemna zamiast zera - tak opisuje to samo rclone. + // A large negative number instead of zero - that is how rclone itself describes it. let response = try? await CMTooling.runRclone( ["rc", "--url", rcAddress, "vfs/queue-set-expiry", "id=\(id)", "expiry=-1000000000"], timeout: 30) @@ -453,9 +458,9 @@ public enum DriveBufferService { return ExpiryOutcome(queued: ids.count, moved: moved) } - /// Czysta wersja: numery pozycji z odpowiedzi `vfs/queue`, ktorym da sie - /// przesunac termin. `nil` = odpowiedzi nie da sie odczytac, `[]` = kolejka - /// jest pusta. Wydzielone, zeby to rozroznienie dalo sie sprawdzic testem. + /// Pure version: IDs of items from the `vfs/queue` response whose deadline can + /// be moved. `nil` = the response cannot be read, `[]` = the queue is empty. + /// Split out so that this distinction can be tested. static func parseQueueIDs(_ raw: String) -> [Int]? { guard let data = raw.data(using: .utf8), @@ -464,37 +469,37 @@ public enum DriveBufferService { else { return nil } return queue.compactMap { item in - // Pozycji juz wysylanej nie da sie przyspieszyc - rclone to ignoruje, - // wiec nie marnujemy na nia wywolania. + // An item already uploading cannot be sped up - rclone ignores it, so we + // do not waste a call on it. if (item["uploading"] as? Bool) == true { return nil } return (item["id"] as? NSNumber)?.intValue } } - /// MIEJSCE ZAJETE NA DYSKU przez katalog bufora. `nil` = NIE ZMIERZONO. - /// - /// Normalnie rozmiar cache'a 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. - /// - /// UWAGA: to NIE jest zapas dla miary, na ktorej dozorca podejmuje decyzje, - /// i nie wolno go tam podstawiac. Dwa powody, oba zmierzone: - /// - /// 1. Ta funkcja liczy MIEJSCE ZAJETE NA DYSKU (`totalFileAllocatedSize`), - /// czyli wielkosc NIEPOROWNYWALNA z limitem `--vfs-cache-max-size` - - /// potrafi go przekroczyc. Stad "bufor 155 GB" przy limicie 100 GB - /// w jedynej linii PAUZA w calym dzienniku (23.09.2026 03:34). Godzine - /// pozniej czujka zapisala "Interfejs sterujacy rclone nie odpowiada": - /// brak odpowiedzi zamieniono na liczbe z INNEJ miary, i ta liczba - /// uruchomila nieodwracalna pauze. - /// 2. Gdy obchod padnie (odmowa praw, znikniete `~/.cloudmachine`), wynik - /// `0` wyglada jak PUSTY bufor, czyli jak spelniony warunek wznowienia - /// Time Machine wstrzymanego dlatego, ze bufor byl pelny. Dokladnie ten - /// wzorzec zamknal `BufferGuardService.freeGB()` dla `statfs` - stad - /// tutaj `nil`, a nie zero. - /// - /// Zostaje wiec do JEDNEGO: powiedzenia czlowiekowi, ile miejsca na dysku - /// zajmuje cache. Zadna decyzja tego nie czyta. + /// DISK SPACE TAKEN by the buffer directory. `nil` = NOT MEASURED. + /// + /// Normally rclone itself reports the cache size (`QueueStats.bytesUsed`): + /// walking the directory is 6504 stat calls, and with a refresh every 10 + /// seconds needless load on the disk the backup is going to. + /// + /// NOTE: this is NOT a fallback for the measure the watchdog makes decisions + /// on, and must not be substituted there. Two reasons, both measured: + /// + /// 1. This function counts DISK SPACE TAKEN (`totalFileAllocatedSize`), a + /// quantity NOT COMPARABLE with the `--vfs-cache-max-size` limit - it can + /// exceed it. Hence "buffer 155 GB" with a 100 GB limit in the only PAUSE + /// line in the whole journal (23.09.2026 03:34). An hour later the monitor + /// wrote "rclone remote control is not answering": a missing answer was + /// replaced with a number from a DIFFERENT measure, and that number + /// triggered an irreversible pause. + /// 2. When the walk fails (permission denied, `~/.cloudmachine` gone), the + /// result `0` looks like an EMPTY buffer, i.e. like a met condition for + /// resuming a Time Machine that was paused because the buffer was full. + /// Exactly this pattern was closed by `BufferGuardService.freeGB()` for + /// `statfs` - hence `nil` here, not zero. + /// + /// So it is left for ONE thing: telling a person how much disk space the + /// cache takes. No decision reads it. public static func cacheSizeBytesByWalk() -> UInt64? { guard let enumerator = FileManager.default.enumerator( @@ -509,38 +514,39 @@ public enum DriveBufferService { 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 jest dla rclone fatalny (`--drive-stop-on-upload-limit` - /// dziala dokladnie dla `storageQuotaExceeded` i `teamDriveFileLimitExceeded`), - /// wiec proces konczy prace i montowanie znika. - /// - /// UWAGA: NIE wolno dokladac tu warunku "tylko gdy montowanie lezy". Taka - /// wersja tu byla i byla martwa: agent `gdrive-buffer` ma `KeepAlive` - /// z `ThrottleInterval` 30 s, wiec launchd podnosi rclone z powrotem szybciej, - /// niz dozorca bufora zdazy tyknac (co 30 s). Okno, w ktorym montowania - /// faktycznie nie ma, jest krotsze od okresu odpytywania - wykrycie limitu - /// bylo rzutem moneta, a w praktyce nie zdarzalo sie wcale. Rozpoznanie po - /// SWIEZYM wpisie w logu dziala niezaleznie od tego, czy launchd zdazyl juz - /// wskrzesic montowanie. - /// - /// Chwilowa przepustnica (`userRateLimitExceeded`) nadal NIE jest tu lapana - - /// patrz `logMentionsUploadLimit`. To ona kiedys wstrzymala backup po - /// 109 GiB i to jej dotyczyla ostroznosc, nie stanu montowania. - /// - /// `nil` = LOGU NIE DA SIE PRZECZYTAC, co jest czyms innym niz "nie ma - /// sladu limitu". Wczesniej oba przypadki wychodzily stad jako `false`, - /// czyli jako odpowiedz "nie ma problemu" na pytanie, na ktore nie bylo - /// odpowiedzi - a dozorca nie wstrzymuje wtedy backupu na brak miejsca na - /// Dysku. To nie jest teoretyczne: log rclone ma prawa `-rw-r-----`, - /// a przy starcie jest przenoszony na `.1` (patrz `rotateLogIfLarge`). + /// Whether rclone stopped on the Google Drive daily limit (750 GB/day). + /// + /// We recognise it by rclone's BEHAVIOUR, not by the error text. The reason is + /// concrete: `403 userRateLimitExceeded` is a momentary rate throttle that + /// rclone retries by itself ("will retry in 1m0s"), but it describes it with + /// the message "Received upload limit error" - indistinguishable by text alone + /// from the daily limit. The first version of this function caught exactly + /// that and paused the backup after 109 GiB uploaded, i.e. at a seventh of the + /// limit. + /// + /// The real limit is fatal for rclone (`--drive-stop-on-upload-limit` acts + /// exactly on `storageQuotaExceeded` and `teamDriveFileLimitExceeded`), so the + /// process exits and the mount disappears. + /// + /// NOTE: do NOT add a condition "only when the mount is down" here. Such a + /// version was here and it was dead: the `gdrive-buffer` agent has `KeepAlive` + /// with a `ThrottleInterval` of 30 s, so launchd brings rclone back faster than + /// the buffer watchdog manages to tick (every 30 s). The window in which the + /// mount is actually gone is shorter than the polling period - detecting the + /// limit was a coin toss, and in practice never happened at all. Recognising + /// it by a FRESH log entry works regardless of whether launchd has already + /// resurrected the mount. + /// + /// The momentary throttle (`userRateLimitExceeded`) is still NOT caught here - + /// see `logMentionsUploadLimit`. That is what once paused the backup after + /// 109 GiB, and that is what the caution was about, not the mount state. + /// + /// `nil` = THE LOG CANNOT BE READ, which is something else than "no trace of + /// the limit". Previously both cases came out of here as `false`, i.e. as the + /// answer "no problem" to a question that had no answer - and the watchdog + /// then does not pause the backup for lack of space on Drive. This is not + /// theoretical: the rclone log has `-rw-r-----` permissions, and at start-up + /// it is moved to `.1` (see `rotateLogIfLarge`). public static func hitStorageQuotaState(logFile: URL? = nil) -> Bool? { guard let text = recentLog(bytes: 256 * 1024, from: logFile ?? Self.logFile) else { return nil @@ -548,105 +554,105 @@ public enum DriveBufferService { return logMentionsUploadLimit(text, now: Date(), within: 30) } - /// Wersja DO POKAZANIA CZLOWIEKOWI, gdzie trzeciego stanu nie ma gdzie - /// wstawic (`BufferStatus.driveFull`, `UploadState.from`). + /// Version FOR SHOWING TO A PERSON, where there is no place to put a third + /// state (`BufferStatus.driveFull`, `UploadState.from`). /// - /// ZADNA DECYZJA nie ma prawa jej uzywac: `?? false` to dokladnie to - /// podstawienie, ktore opisuje komentarz wyzej. Dozorca bufora czyta - /// `hitStorageQuotaState()` i sam rozstrzyga, co zrobic z "nie wiem". - /// Trzeci stan w interfejsie wymaga zmiany `BufferStatus` i - /// `CloudMachineController` - to osobna zmiana, poza ta galezia. + /// NO DECISION may use it: `?? false` is exactly the substitution described in + /// the comment above. The buffer watchdog reads `hitStorageQuotaState()` and + /// decides itself what to do with "I do not know". A third state in the + /// interface requires changing `BufferStatus` and `CloudMachineController` - + /// that is a separate change, outside this branch. public static func hitStorageQuota() -> Bool { hitStorageQuotaState() ?? false } - /// Czy wysylka faktycznie STOI - rozpoznane po zachowaniu, nie po tresci. - /// - /// Dobowy limit uploadu Google (750 GB) zglasza sie jako `403 - /// userRateLimitExceeded`, czyli DOKLADNIE tym samym kodem, co zwykle - /// chwilowe dlawienie tempa. Po tekscie rozroznic sie ich nie da i nie - /// nalezy probowac - pierwsza wersja `logMentionsUploadLimit` probowala - /// i wstrzymala backup po 109 GiB z 750 GB. - /// - /// Rozroznia je natomiast STOSUNEK sukcesow do bledow w oknie czasowym. - /// Zmierzone na wlasnym logu: - /// - dlawienie: 11 wrz 14h -> 4833, 12 wrz 09h -> 1,07, 15 wrz 08h -> 2,39 - /// - realny zator: 12 wrz 10-12h -> 0,002-0,011, 15 wrz 09h -> 0,003 - /// Miedzy jednym a drugim leza DWA RZEDY WIELKOSCI, wiec prog 0,1 ma zapas - /// w obie strony. - /// - /// `minErrors` chroni przed cisza: w oknie bez ruchu jest zero bledow - /// i zero sukcesow, a to nie jest zator. - /// - /// `nil` = LOGU NIE DA SIE PRZECZYTAC. Rozroznienie jest tu grozniejsze niz - /// przy samym pomiarze: `false` szedl dalej do `reportUploadStall`, ktore - /// USUWALO znacznik zatoru i zapisywalo "Wysylka na Google Drive ruszyla - /// z powrotem" - twierdzenie o zdarzeniu, ktorego nikt nie sprawdzil, na - /// podstawie pliku, ktorego nikt nie przeczytal. + /// Whether the upload is actually STALLED - recognised by behaviour, not by + /// text. + /// + /// Google's daily upload limit (750 GB) reports itself as `403 + /// userRateLimitExceeded`, i.e. with EXACTLY the same code as an ordinary + /// momentary rate throttle. They cannot be told apart by text and one should + /// not try - the first version of `logMentionsUploadLimit` tried and paused the + /// backup after 109 GiB of 750 GB. + /// + /// What does tell them apart is the RATIO of successes to errors in a time + /// window. Measured on our own log: + /// - throttling: 11 Sep 14h -> 4833, 12 Sep 09h -> 1.07, 15 Sep 08h -> 2.39 + /// - real jam: 12 Sep 10-12h -> 0.002-0.011, 15 Sep 09h -> 0.003 + /// TWO ORDERS OF MAGNITUDE lie between one and the other, so the 0.1 threshold + /// has margin both ways. + /// + /// `minErrors` protects against silence: a window without traffic has zero + /// errors and zero successes, and that is not a jam. + /// + /// `nil` = THE LOG CANNOT BE READ. The distinction is more dangerous here than + /// for the measurement itself: `false` went on to `reportUploadStall`, which + /// REMOVED the jam marker and wrote "Upload to Google Drive has resumed" - a + /// claim about an event nobody checked, based on a file nobody read. public static func uploadStalledState(logFile: URL? = nil) -> Bool? { - // Wieksze okno niz przy tescie tekstowym: w trakcie zatoru log rosnie - // o okolo 90 KB na minute, wiec 256 KB pokazaloby tylko ostatnie trzy - // minuty i stosunek liczylby sie z probki bez ani jednego sukcesu. + // A bigger window than for the text test: during a jam the log grows by + // about 90 KB per minute, so 256 KB would show only the last three minutes + // and the ratio would be computed from a sample without a single success. guard let text = recentLog(bytes: 4 * 1024 * 1024, from: logFile ?? Self.logFile) else { return nil } return logShowsUploadStalled(text, now: Date(), within: 30) } - /// Wersja do pokazania czlowiekowi - patrz `hitStorageQuota()`, ten sam - /// powod i to samo ostrzezenie: zadna decyzja nie czyta tej wersji. + /// Version for showing to a person - see `hitStorageQuota()`, same reason and + /// same warning: no decision reads this version. public static func uploadStalled() -> Bool { uploadStalledState() ?? false } - /// Zbiorcza odpowiedz "wysylka na Dysk nie idzie" - do pokazania - /// uzytkownikowi. Dozorca bufora NIE uzywa tej funkcji, bo dla niego roznica - /// miedzy jednym a drugim jest zasadnicza: brak miejsca nie minie sam, - /// a limit dobowy mija w kilka godzin. + /// Combined answer "the upload to Drive is not going" - for showing to the + /// user. The buffer watchdog does NOT use this function, because for it the + /// difference between the two is fundamental: lack of space will not pass by + /// itself, while the daily limit passes within a few hours. public static func hitDailyQuota() -> Bool { hitStorageQuota() || uploadStalled() } - /// Ogon logu rclone jako tekst. `nil` = pliku NIE DA SIE PRZECZYTAC: nie ma - /// go, nie ma do niego prawa albo odczyt padl. + /// Tail of the rclone log as text. `nil` = the file CANNOT BE READ: it does not + /// exist, there is no permission for it, or the read failed. /// - /// Wolajacy MUSI oddac to `nil` dalej jako "nie wiem". Brak wpisow o limicie - /// i brak dostepu do logu to dwie rozne rzeczy, a tylko pierwsza znaczy "nie - /// ma problemu". Plik bierzemy z parametru, zeby oba pytania zadawane temu - /// logowi dalo sie sprawdzic testem na wlasnym pliku - bez `~/.cloudmachine` - /// i bez zgadywania praw. + /// The caller MUST pass that `nil` on as "I do not know". No limit entries and + /// no access to the log are two different things, and only the first means "no + /// problem". The file is taken from a parameter so that both questions asked of + /// this log can be tested on our own file - without `~/.cloudmachine` and + /// without guessing permissions. private static func recentLog(bytes: UInt64, from file: URL) -> String? { guard let handle = try? FileHandle(forReadingFrom: file) else { return nil } defer { try? handle.close() } let size = (try? handle.seekToEnd()) ?? 0 try? handle.seek(toOffset: size > bytes ? size - bytes : 0) guard let data = try? handle.readToEnd() else { return nil } - // Ogon prawie zawsze zaczyna sie w polowie znaku wielobajtowego, wiec - // dekodujemy stratnie - inaczej caly odczyt przepadalby przez jeden bajt. + // The tail almost always starts in the middle of a multi-byte character, so + // we decode lossily - otherwise the whole read would be lost to one byte. return String(decoding: data, as: UTF8.self) } - /// Locale, ktorym czytamy znaczniki czasu z logu rclone. - /// - /// `en_US_POSIX`, a NIE `Locale.current`. `DateFormatter` z ustalonym - /// `dateFormat` i domyslnym locale bierze z tego locale kalendarz: na - /// maszynie z kalendarzem buddyjskim (`th_TH`) "2026" znaczy rok buddyjski, - /// czyli gregorianski 1483, a przy kalendarzu perskim albo hidzri wychodzi - /// jeszcze inna data. Znacznik parsuje sie wtedy BEZ BLEDU i wypada 543 lata - /// za wczesnie, wiec `stamp < cutoff` konczy petle na pierwszej linii, - /// `errors` zostaje zerem i `uploadStalled()` melduje "nie ma zatoru" - /// dokladnie wtedy, gdy zator trwa - a dozorca bufora na tej podstawie nie - /// wstrzymuje Time Machine. - /// - /// Ta sama klasa bledu, co `LC_ALL=C` wymuszane w `CMLock` (patrz tam opis - /// realnego incydentu): tekst maszynowy czyta sie ustawieniami maszyny, nie - /// czlowieka. + /// The locale we read rclone log timestamps with. + /// + /// `en_US_POSIX`, NOT `Locale.current`. A `DateFormatter` with a fixed + /// `dateFormat` and the default locale takes the calendar from that locale: on + /// a machine with the Buddhist calendar (`th_TH`) "2026" means the Buddhist + /// year, i.e. Gregorian 1483, and with the Persian or Hijri calendar yet + /// another date comes out. The timestamp then parses WITHOUT AN ERROR and lands + /// 543 years too early, so `stamp < cutoff` ends the loop on the first line, + /// `errors` stays zero and `uploadStalled()` reports "no jam" exactly when the + /// jam is going on - and on that basis the buffer watchdog does not pause Time + /// Machine. + /// + /// The same class of bug as `LC_ALL=C` forced in `CMLock` (see the description + /// of the real incident there): machine text is read with the machine's + /// settings, not the person's. public static let rcloneLogLocale = Locale(identifier: "en_US_POSIX") - /// Czysta wersja rozpoznania zatoru - liczy sukcesy i bledy w oknie. + /// Pure version of jam detection - counts successes and errors in the window. /// - /// Sukcesem jest linia `... : Copied (...)`, bledem `Received upload limit - /// error`. Oba pochodza z tego samego logu i tego samego zdarzenia, wiec - /// stosunek nie wymaga zadnej kalibracji miedzy maszynami. + /// A success is a `... : Copied (...)` line, an error `Received upload limit + /// error`. Both come from the same log and the same event, so the ratio needs + /// no calibration between machines. /// - /// `locale` istnieje wylacznie po to, zeby test mogl wstrzyknac ZNANY ZLY - /// kalendarz - patrz `rcloneLogLocale`. Kod produkcyjny go nie podaje. + /// `locale` exists only so that a test can inject a KNOWN BAD calendar - see + /// `rcloneLogLocale`. Production code does not pass it. public static func logShowsUploadStalled( _ text: String, now: Date, within minutes: Int, minErrors: Int = 300, maxSuccessRatio: Double = 0.1, @@ -664,8 +670,8 @@ public enum DriveBufferService { guard line.count > 19, let stamp = formatter.date(from: String(line.prefix(19))) else { continue } - // Log jest chronologiczny, wiec pierwsza linia starsza od okna konczy - // liczenie - dalej sa juz same starsze. + // The log is chronological, so the first line older than the window ends + // the counting - everything further back is older still. if stamp < cutoff { break } let lower = line.lowercased() if lower.contains("received upload limit error") { @@ -679,13 +685,13 @@ public enum DriveBufferService { return Double(successes) < maxSuccessRatio * Double(errors) } - /// 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. + /// Looks for a trace of the limit only in fresh entries. Without a time bound + /// an alarm once raised would never go out, because the entry stays in the log + /// forever - the backup would fall into a pause-resume-pause cycle. /// - /// Czysta wersja, zeby dalo sie ja sprawdzic testem bez pliku i bez zegara. + /// A pure version, so that it can be tested without a file and without a clock. /// - /// `locale` jak w `logShowsUploadStalled` - tylko dla testu. + /// `locale` as in `logShowsUploadStalled` - for tests only. public static func logMentionsUploadLimit( _ text: String, now: Date, within minutes: Int, locale: Locale = DriveBufferService.rcloneLogLocale @@ -702,8 +708,9 @@ public enum DriveBufferService { } 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. + // userRateLimitExceeded deliberately SKIPPED - it is an ordinary throttle + // that rclone retries by itself. Catching it paused the backup at 109 GiB of + // 750 GB. if lower.contains("storagequotaexceeded") || lower.contains("teamdrivefilelimitexceeded") { return true } diff --git a/mac-app/Sources/CloudMachineCore/DriveFolder.swift b/mac-app/Sources/CloudMachineCore/DriveFolder.swift new file mode 100644 index 0000000..b5b6b45 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/DriveFolder.swift @@ -0,0 +1,124 @@ +import Foundation + +/// The folder on Google Drive that holds THIS Mac's backup image: +/// `gdrive:CloudMachine/`, with the image `.sparsebundle` inside. +/// +/// It used to be the constant `mac-studio` on every Mac, so two Macs on one +/// Google account would have shared one folder and one image. Each Mac now +/// keeps its own name in `~/Library/Application Support/CloudMachine/drive-folder`, +/// chosen once by `configure-remote` and never changed afterwards. +/// +/// "Never changed" is the point. A different name is a different, empty +/// folder: Time Machine starts from zero and the old backup sits orphaned on +/// Drive, still using quota. So the name is decided once, and an installation +/// that predates this file keeps `mac-studio` - it is where its backup lives. +public enum DriveFolder { + /// The name every installation used before names were per Mac. + public static let legacyName = "mac-studio" + + static var file: URL { CMPaths.appSupportDir.appendingPathComponent("drive-folder") } + + /// The name the mount, the image and the status use. + /// + /// No file means an installation from before this setting existed (a new + /// one gets the file from `configure-remote` before anything is mounted), + /// so the answer is the legacy name, not a fresh one. + public static var name: String { + stored(in: file) ?? legacyName + } + + static func stored(in file: URL) -> String? { + guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return isValid(trimmed) ? trimmed : nil + } + + /// Lowercase letters, digits and dashes; 1-63 characters, not starting with + /// a dash. Safe as a Drive folder, a file name and an rclone path segment. + public static func isValid(_ name: String) -> Bool { + name.range(of: #"^[a-z0-9][a-z0-9-]{0,62}$"#, options: .regularExpression) != nil + } + + /// Whether this Mac was set up before folder names existed. + /// + /// The evidence is the rclone remote itself. `configure-remote` decides the + /// folder BEFORE it creates the remote, so on a new Mac the remote is not + /// there yet; on an installation that predates `drive-folder` it is, and its + /// backup lives under the legacy name. Weaker traces were rejected: the + /// buffer directory appears after any mount attempt, and the old + /// `mount-desired.state` is no longer written by anything. + static func hasLegacyInstallation() async -> Bool { + await RemoteConfigurer.isConfigured(remoteName: DriveBufferService.remoteName) + } + + public enum Decision: Equatable { + /// Already decided; nothing to write. + case keep(String) + /// Write this name now. + case assign(String) + /// Refuse, with the reason to show. + case refuse(String) + } + + /// Pure decision, so every branch can be tested without touching the disk. + /// + /// - `existing`: the name already stored, if any. + /// - `requested`: a name passed with `--folder`, if any. + /// - `legacyEvidence`: `hasLegacyInstallation()`, asked before the remote + /// is created. + /// - `machineKey`: `MachineIdentity` key, the default for a new Mac. + public static func decide( + existing: String?, requested: String?, legacyEvidence: Bool, machineKey: String + ) -> Decision { + if let requested, !isValid(requested) { + return .refuse( + L10n.tr( + "'%@' is not a valid folder name: use lowercase letters, digits and dashes.", requested)) + } + if let existing { + guard let requested, requested != existing else { return .keep(existing) } + return .refuse( + L10n.tr( + "This Mac already backs up to folder '%@'. Switching to '%@' would start a new, empty backup and orphan the existing one, so nothing was changed.", + existing, requested)) + } + if legacyEvidence { + guard let requested, requested != legacyName else { return .assign(legacyName) } + return .refuse( + L10n.tr( + "This Mac already has a CloudMachine installation, whose backup is in folder '%@'. Switching to '%@' would orphan it, so nothing was changed.", + legacyName, requested)) + } + let fallback = isValid(machineKey) ? machineKey : "this-mac" + return .assign(requested ?? fallback) + } + + /// Decides and stores the name for this Mac. Returns the name to use, or + /// the reason it refused. + public static func resolve(requested: String?) async -> Result { + let decision = decide( + existing: stored(in: file), requested: requested, + legacyEvidence: await hasLegacyInstallation(), machineKey: await MachineIdentity.currentKey()) + switch decision { + case .keep(let name): + return .success(name) + case .assign(let name): + do { + try name.write(to: file, atomically: true, encoding: .utf8) + } catch { + return .failure( + RefusedError( + message: L10n.tr( + "Could not save the folder name to %@: %@", file.path, error.localizedDescription))) + } + CMLogger.log("Drive folder for this Mac set to '\(name)'") + return .success(name) + case .refuse(let reason): + return .failure(RefusedError(message: reason)) + } + } + + public struct RefusedError: Error, Equatable { + public let message: String + } +} diff --git a/mac-app/Sources/CloudMachineCore/EdgeTriggeredLog.swift b/mac-app/Sources/CloudMachineCore/EdgeTriggeredLog.swift index 4ce7ecc..c23c3fc 100644 --- a/mac-app/Sources/CloudMachineCore/EdgeTriggeredLog.swift +++ b/mac-app/Sources/CloudMachineCore/EdgeTriggeredLog.swift @@ -1,15 +1,15 @@ import Foundation -/// Loguje `message` tylko przy PIERWSZYM napotkaniu danego stanu (`active == -/// true`) - kolejne cykle watchdoga w tym samym stanie milcza, zeby nie -/// zasypac wspolnego logu setkami identycznych linii przy dluzszej awarii. -/// Gdy `active` wroci do `false`, znacznik sie czysci i nastepne wystapienie -/// znow zaloguje. +/// Logs `message` only the FIRST time a given state is met (`active == +/// true`) - later watchdog cycles in the same state stay quiet, so that a longer +/// failure does not flood the shared log with hundreds of identical lines. +/// When `active` goes back to `false`, the marker is cleared and the next +/// occurrence logs again. /// -/// Powstalo z realnego przypadku: `BackupWatchdogService`/ -/// `VerifyWatchdogService` milczaly przez >2 dni, bo ich strazniki "mount -/// jeszcze niegotowy" po prostu `return`owaly bez sladu w logu - diagnoza -/// zajela znacznie dluzej, niz gdyby ta granica stanu byla widoczna. +/// Born from a real case: `BackupWatchdogService`/`VerifyWatchdogService` were +/// silent for >2 days, because their "mount not ready yet" guards simply +/// `return`ed without a trace in the log - the diagnosis took much longer than +/// it would have if that state boundary had been visible. public enum EdgeTriggeredLog { public static func log(marker: URL, active: Bool, _ message: @autoclosure () -> String) { if active { diff --git a/mac-app/Sources/CloudMachineCore/FuseInstaller.swift b/mac-app/Sources/CloudMachineCore/FuseInstaller.swift index adee9f9..3640be2 100644 --- a/mac-app/Sources/CloudMachineCore/FuseInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/FuseInstaller.swift @@ -1,36 +1,36 @@ import Foundation -/// Wciaga FUSE-T do CloudMachine, zeby nie bylo osobnej aplikacji w systemie. +/// Pulls FUSE-T into CloudMachine, so that there is no separate application in +/// the system. /// -/// 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. +/// The official FUSE-T installer puts down `/Applications/fuse-t.app`, +/// libraries in `/usr/local/lib` and a server in +/// `/Library/Application Support/fuse-t`. That application hosts the FSKit +/// extension (`FskitSrvModule.appex`) - a backend we do not use anyway, +/// because we mount over NFS. So an icon and a package that do nothing are +/// left in the system. /// -/// 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. +/// Exactly two files are needed, both depending only on system libraries: +/// - `libfuse-t.dylib` - rclone opens it via dlopen, +/// - `go-nfsv4` - the NFS server that actually holds the mount. /// -/// 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. +/// We point at the server via the `FUSE_NFSSRV_PATH` variable, so it can live +/// anywhere. The library cannot: rclone has absolute paths baked in +/// (`/usr/local/lib/libfuse-t.dylib`, `/usr/local/lib/libfuse.2.dylib`), and +/// `dlopen` on an absolute path ignores `DYLD_*`. That is why we leave a +/// SYMLINK to our copy there - `/usr/local/lib` belongs to a user in the admin +/// group, so root is not needed for that. /// -/// 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. +/// LICENSE: FUSE-T is not open-source software. The binary distribution is free +/// for non-commercial use provided the copyright notice is kept - that is why +/// we copy `LICENSE.rtf` next to the binaries. Bundling with commercial +/// software requires a separate license from the FUSE-T authors. 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. + /// Path where rclone looks for the library. It cannot be changed. static let systemLibLink = "/usr/local/lib/libfuse-t.dylib" public static var isInstalled: Bool { @@ -38,12 +38,12 @@ public enum FuseInstaller { && FileManager.default.fileExists(atPath: systemLibLink) } - /// Odtwarza dowiazanie w `/usr/local/lib`, jesli zniknelo. + /// Recreates the symlink in `/usr/local/lib` if it has disappeared. /// - /// 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. + /// Needed because the FUSE-T uninstaller deletes everything under that path + /// - including our symlink. Without this, removing the separate fuse-t + /// application would take the mount down with it, even though our copy of + /// the library lies untouched in its place. @discardableResult public static func ensureSystemLink() -> Bool { guard FileManager.default.fileExists(atPath: CMTooling.bundledFuseLib.path) else { @@ -56,7 +56,7 @@ public enum FuseInstaller { } do { try linkSystemLibrary() - CMLogger.log("Odtworzono dowiazanie \(systemLibLink) do kopii w CloudMachine") + CMLogger.log("Recreated the symlink \(systemLibLink) to the copy in CloudMachine") return true } catch { return false @@ -70,7 +70,8 @@ public enum FuseInstaller { 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.") + return CMActionResult( + succeeded: false, message: L10n.tr("Could not determine the FUSE-T version.")) } let pkgPath = workDir.appendingPathComponent("fuse-t.pkg") @@ -79,7 +80,8 @@ public enum FuseInstaller { "/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.") + return CMActionResult( + succeeded: false, message: L10n.tr("Could not download the FUSE-T package.")) } let expanded = workDir.appendingPathComponent("expanded") @@ -88,7 +90,8 @@ public enum FuseInstaller { "/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.") + return CMActionResult( + succeeded: false, message: L10n.tr("Could not unpack the FUSE-T package.")) } guard @@ -97,7 +100,8 @@ public enum FuseInstaller { let server = findFile(under: expanded, matching: { $0.hasPrefix("go-nfsv4") }) else { return CMActionResult( - succeeded: false, message: "W pakiecie FUSE-T nie ma spodziewanych plikow.") + succeeded: false, + message: L10n.tr("The FUSE-T package does not contain the expected files.")) } let destination = CMTooling.bundledFuseDir @@ -106,7 +110,7 @@ public enum FuseInstaller { do { try copy(dylib, to: CMTooling.bundledFuseLib) try copy(server, to: CMTooling.bundledNfsServer) - // Licencja wymaga zachowania noty o prawach autorskich przy redystrybucji. + // The license requires keeping the copyright notice on redistribution. if let license = findFile(under: expanded, matching: { $0 == "LICENSE.rtf" }) { try? copy(license, to: destination.appendingPathComponent("LICENSE.rtf")) } @@ -114,25 +118,24 @@ public enum FuseInstaller { } catch { return CMActionResult( succeeded: false, - message: "Instalacja FUSE-T nie powiodla sie: \(error.localizedDescription)") + message: L10n.tr("Installing FUSE-T failed: %@", 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" - """) + message: L10n.tr( + "Installed FUSE-T %@ inside CloudMachine (%@).\nThe separate fuse-t application is no longer needed - you can remove it:\n sudo \"/Library/Application Support/fuse-t/uninstall.sh\"", + version, destination.path)) } - // MARK: - Szczegoly + // MARK: - Details - /// Podmienia `/usr/local/lib/libfuse-t.dylib` na dowiazanie do naszej kopii. + /// Replaces `/usr/local/lib/libfuse-t.dylib` with a symlink to our copy. /// - /// 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. + /// Does not need root: `/usr/local/lib` belongs to the user and the admin + /// group. If a real file from the official installer lies there, we remove + /// it - our copy is bit-for-bit identical, because it comes from the same + /// package. private static func linkSystemLibrary() throws { let fm = FileManager.default let linkURL = URL(fileURLWithPath: systemLibLink) @@ -165,7 +168,7 @@ public enum FuseInstaller { return nil } - /// Wersja i adres pakietu z ostatniego wydania na GitHubie. + /// Version and URL of the package from the latest release on GitHub. static func latestPackage() async -> (version: String, url: String)? { guard let result = try? await ProcessRunner.run( diff --git a/mac-app/Sources/CloudMachineCore/HealthAlert.swift b/mac-app/Sources/CloudMachineCore/HealthAlert.swift index 399fa05..1b247c7 100644 --- a/mac-app/Sources/CloudMachineCore/HealthAlert.swift +++ b/mac-app/Sources/CloudMachineCore/HealthAlert.swift @@ -1,75 +1,84 @@ import Foundation -/// Donosi o zerwanym cyklu backupu tam, gdzie uzytkownik to zobaczy BEZ -/// otwierania czegokolwiek. +/// Reports a broken backup cycle where the user will see it WITHOUT opening +/// anything. /// -/// Powod istnienia: caly dotychczasowy "monitoring" tego projektu polegal na -/// tym, ze ktos otworzy aplikacje i spojrzy na ikonke. Awaria, ktora nie -/// przeszkadza w codziennej pracy - a taka jest kazda awaria backupu - nie -/// daje zadnego powodu, zeby tam zajrzec. Kopia moze nie powstawac tygodniami -/// i nic tego nie zdradzi. +/// Reason for existence: all the "monitoring" this project had so far relied +/// on someone opening the app and looking at the icon. A failure that does not +/// get in the way of daily work - and every backup failure is like that - +/// gives no reason to look there. Backups may not be made for weeks and +/// nothing will give it away. /// -/// Kanalem jest powiadomienie systemowe macOS: nie wymaga serwera pocztowego -/// ani sekretu, ktorego brak bylby kolejna cicha awaria. +/// The channel is a macOS system notification: it needs no mail server and no +/// secret whose absence would be yet another silent failure. public enum HealthAlert { - /// Plik ze stanem ostatniego zgloszenia. Trzymany OBOK bufora i obrazu, - /// w katalogu, ktory zyje niezaleznie od nich - czujka nie moze dzielic losu - /// tego, co nadzoruje. + /// File with the state of the last report. Kept APART from the buffer and + /// the image, in a directory that lives independently of them - the + /// watchdog must not share the fate of what it supervises. public static var stateFile: URL { CMPaths.appSupportDir.appendingPathComponent("health-alert.json") } struct AlertState: Codable { - /// Ostatnio POKAZANY tekst - trzymany dla czlowieka (`drive-status`, - /// diagnoza z pliku), nie do porownywania. + /// The text last SHOWN - kept for a person (`drive-status`, diagnosis from + /// the file), not for comparison. It is in the UI language of the run that + /// wrote it. var lastSummary: String var lastAlertAt: Date - /// Tozsamosc problemow, czyli to, po czym poznajemy, ze chodzi o TE SAMA - /// awarie - patrz `identity(of:)`. Opcjonalne, zeby plik zapisany przed - /// 23.09.2026 nadal sie czytal; przy `nil` porownujemy po tekscie jak - /// dawniej (najwyzej jedno powiadomienie wiecej, raz). + /// Identity of the problems, i.e. how we recognize that it is THE SAME + /// failure - see `identity(of:)`. Optional, so that a file written before + /// 23.09.2026 can still be read; with `nil` we compare by text as before + /// (at most one extra notification, once). + /// + /// Built from `Problem.code`, which does not depend on the UI language. + /// Files written before the switch to codes hold a fingerprint of the + /// Polish summary instead; it does not match any code, so the first run + /// after the update notifies once more - the same one-off cost as above. var lastIdentity: String? - /// Czy powiadomienie FAKTYCZNIE doszlo. `nil` = plik w starym formacie, - /// czyli sprzed czasow, gdy ktokolwiek to sprawdzal - traktujemy jak - /// doreczone, bo inaczej po aktualizacji posypalyby sie ponowienia. + /// Whether the notification was ACTUALLY delivered. `nil` = a file in the + /// old format, i.e. from before anyone checked this - treated as delivered, + /// because otherwise retries would pour in after the update. var delivered: Bool? - /// Dlaczego nie doszlo - do pokazania czlowiekowi. + /// Why it was not delivered - to be shown to a person. Written as the + /// language-independent `osascriptFailureReason` and translated only when + /// displayed (see `lastDeliveryFailure`); older files may hold Polish text, + /// which is then shown as it is. var deliveryError: String? } - /// Po tylu godzinach przypominamy o TYM SAMYM problemie jeszcze raz. + /// After this many hours we remind about THE SAME problem once more. /// - /// Bez przypomnienia alarm zapala sie raz i gasnie na zawsze - a awaria - /// backupu trwa, dopoki ktos jej nie naprawi. Bez odstepu zamienia sie - /// w szum co 15 minut i przestaje cokolwiek znaczyc. + /// Without a reminder the alarm lights up once and goes out forever - while + /// a backup failure lasts until someone fixes it. Without a gap it turns + /// into noise every 15 minutes and stops meaning anything. static let reminderHours = 12.0 - /// Zglasza problemy, jesli sa NOWE albo jesli minal czas przypomnienia. - /// Zwraca `true`, gdy faktycznie cos zgloszono I DORECZONO. + /// Reports the problems if they are NEW or if the reminder time has passed. + /// Returns `true` when something was actually reported AND DELIVERED. /// - /// `stateFile`, `deliver` i `log` sa podmienialne, zeby dalo sie sprawdzic - /// testem CALA sciezke - z odmowa doreczenia wlacznie - bez pisania do - /// prawdziwego katalogu uzytkownika, bez wyswietlania komukolwiek - /// powiadomien i bez dopisywania zmyslonych awarii do produkcyjnego logu. + /// `stateFile`, `deliver` and `log` are replaceable so that the WHOLE path + /// can be tested - including a refused delivery - without writing to the + /// user's real directory, without showing anyone notifications and without + /// adding made-up failures to the production log. /// - /// `log` jest wstrzykiwalny z dokladnie tego samego powodu, co - /// `BufferGuardService.Probes.log`. Dopoki nie byl, kazdy przebieg - /// `swift test` dopisywal swoje wymyslone "AWARIA BACKUPU" do prawdziwego - /// `cloudmachine.log` - zmierzone 25.09.2026: 117 linii zawierajacych - /// slowo "szczegoly", ktore istnieje wylacznie w - /// `HealthAlertTests.raport(_:)`, wszystkie z jednego dnia. Ten log jest - /// JEDYNYM sladem po awariach backupu i przestal pozwalac odroznic - /// zdarzenia, ktore sie staly, od tych, ktore ktos tylko przetestowal - - /// a po awarii czyta sie go wlasnie po to, zeby ustalic, co sie stalo. + /// `log` is injectable for exactly the same reason as + /// `BufferGuardService.Probes.log`. Until it was, every `swift test` run + /// appended its invented "BACKUP FAILURE" (then still in Polish) to the + /// real `cloudmachine.log` - measured 25.09.2026: 117 lines containing a + /// word that existed only in `HealthAlertTests.raport(_:)` (now + /// `report(_:)`), all from one day. This log is the ONLY trace of backup + /// failures and it stopped allowing events that happened to be told apart + /// from ones someone merely tested - and after a failure it is read + /// precisely to establish what happened. /// - /// Odrzucone: globalne przekierowanie `CMLogger` na plik tymczasowy w - /// `setUp` testu. To wspolny stan procesu, wiec przy testach biegnacych - /// rownolegle uciszalby rowniez te, ktore maja pisac, a wlaczony przez - /// pomylke w kodzie produkcyjnym uciszylby produkcje - czyli zamienilby - /// halas w logu na cisze w logu, co jest zamiana na gorsze. Domyslna - /// wartosc tego parametru idzie do prawdziwego logu i zaden kod - /// produkcyjny jej nie podaje. + /// Rejected: globally redirecting `CMLogger` to a temporary file in the + /// test's `setUp`. That is shared process state, so with tests running in + /// parallel it would also silence the ones that are supposed to write, and + /// switched on by mistake in production code it would silence production - + /// i.e. it would trade noise in the log for silence in the log, which is a + /// change for the worse. The default value of this parameter goes to the + /// real log and no production code passes it. @discardableResult public static func report( _ report: BackupHealth.Report, @@ -81,8 +90,8 @@ public enum HealthAlert { log: @Sendable (String) -> Void = { CMLogger.log($0) } ) async -> Bool { guard let first = report.problems.first else { - // Wyzdrowienie kasuje stan, zeby nastepna awaria zglosila sie od razu, - // a nie czekala na okno przypomnienia. + // Recovery deletes the state, so that the next failure is reported + // right away instead of waiting for the reminder window. try? FileManager.default.removeItem(at: stateFile) return false } @@ -92,26 +101,26 @@ public enum HealthAlert { if !shouldAlert(identity: identity, now: now, stateFile: stateFile) { return false } let body = report.problems.map { "\($0.summary): \($0.detail)" }.joined(separator: "\n") - log("AWARIA BACKUPU: \(body)") - let delivered = await deliver("CloudMachine: backup nie dziala", first.summary) + log("BACKUP FAILURE: \(body)") + let delivered = await deliver(L10n.tr("CloudMachine: backup is not working"), first.summary) - // Stan zapisujemy ZAWSZE, ale z informacja, czy powiadomienie doszlo. + // We ALWAYS write the state, but with the information whether the + // notification was delivered. // - // Wczesniej zapisywalo sie bezwarunkowo jako sukces, wiec nieudane - // powiadomienie (odmowa uprawnien dla procesu launchd, brak sesji Aqua, - // przekroczony limit czasu osascript) zamykalo okno ciszy na 12 godzin. - // Alarm ginal po cichu - czyli nadzor ginal razem z nadzorowanym, przed - // czym ostrzega naglowek tego pliku. + // Previously it was written unconditionally as a success, so a failed + // notification (permission refused for the launchd process, no Aqua + // session, osascript time limit exceeded) closed the quiet window for 12 + // hours. The alarm vanished silently - i.e. the supervision died together + // with the supervised, which the header of this file warns against. if !delivered { log( - "NIE UDALO SIE pokazac powiadomienia o awarii backupu. Tresc poszla do logu powyzej; sprobuje ponownie przy nastepnym sprawdzeniu." + "FAILED to show the backup failure notification. The content went to the log above; will retry at the next check." ) } let state = AlertState( lastSummary: summary, lastAlertAt: now, lastIdentity: identity, delivered: delivered, - deliveryError: delivered - ? nil : "osascript nie pokazal powiadomienia (uprawnienia albo brak sesji graficznej)") + deliveryError: delivered ? nil : osascriptFailureReason) if let data = try? JSONEncoder().encode(state) { try? data.write(to: stateFile, options: .atomic) } @@ -122,28 +131,34 @@ public enum HealthAlert { -> Bool { guard let state = loadState(stateFile) else { return true } - // Nieudane doreczenie NIE zamyka okna ciszy - inaczej pierwsza nieudana - // proba uciszalaby alarm na 12 godzin. + // A failed delivery does NOT close the quiet window - otherwise the first + // failed attempt would silence the alarm for 12 hours. if state.delivered == false { return true } if (state.lastIdentity ?? state.lastSummary) != identity { return true } return now.timeIntervalSince(state.lastAlertAt) > reminderHours * 3600 } - /// Tozsamosc zestawu problemow: te same summary z wycietymi LICZBAMI. + /// Identity of a set of problems: their codes with NUMBERS cut out. /// - /// Porownywanie gotowego tekstu dla uzytkownika nie dziala, bo ten tekst - /// zawiera zmienne: "Brak udanej kopii od 3 h" zmienia sie w "... od 4 h" - /// po godzinie. Warunek "inny tekst = nowy problem" byl wiec spelniony przy - /// KAZDYM przebiegu czujki i powiadomienie wracalo co godzine zamiast raz na - /// dwanascie - a alarm bez odstepu zamienia sie w szum i przestaje cokolwiek - /// znaczyc (patrz `reminderHours`). Ta sama awaria musi miec te sama - /// tozsamosc niezaleznie od tego, jak dlugo trwa. + /// Comparing the finished text for the user does not work, because that + /// text contains variables: "No successful backup for 3 h" turns into "... + /// for 4 h" an hour later. The condition "different text = new problem" was + /// therefore met on EVERY watchdog run and the notification came back every + /// hour instead of once every twelve - and an alarm without a gap turns into + /// noise and stops meaning anything (see `reminderHours`). The same failure + /// must have the same identity regardless of how long it lasts. /// - /// Ciag cyfr zastepujemy jednym `#`, zeby "od 9 h" i "od 12 h" dawaly ten - /// sam odcisk. NOWY problem dokladany do listy zmienia odcisk i alarmuje od - /// razu - i tak ma byc. + /// It also must not depend on the UI language: the summary is translated, + /// so the same failure seen by a Polish-language run and an English-language + /// run would look like two different ones. That is why we take + /// `Problem.code`, not `summary`. + /// + /// A run of digits is replaced by a single `#`, so that "for 9 h" and "for + /// 12 h" give the same fingerprint (this matters for problems whose code + /// defaults to their summary). A NEW problem added to the list changes the + /// fingerprint and alarms right away - and that is how it should be. static func identity(of problems: [BackupHealth.Problem]) -> String { - problems.map { fingerprint($0.summary) }.joined(separator: " | ") + problems.map { fingerprint($0.code) }.joined(separator: " | ") } static func fingerprint(_ text: String) -> String { @@ -168,28 +183,43 @@ public enum HealthAlert { return try? JSONDecoder().decode(AlertState.self, from: data) } - /// Ostatnie zgloszenie, ktorego NIE udalo sie doreczyc - do pokazania - /// w `drive-status`. `nil`, gdy ostatnie zgloszenie doszlo albo gdy nie bylo - /// zadnego. Cichy alarm musi byc widoczny gdzies, gdzie czlowiek zaglada - /// sam, bo z definicji nie przyjdzie do niego po powiadomieniu. + /// Reason written to `deliveryError` when `osascript` did not show the + /// notification. Persisted in English and translated only for display, so + /// the file does not depend on the language of the run that wrote it. + static let osascriptFailureReason = + "osascript did not show the notification (permissions or no graphical session)" + + /// The last report that could NOT be delivered - to be shown in + /// `drive-status`. `nil` when the last report was delivered or when there + /// was none. A silent alarm has to be visible somewhere a person looks on + /// their own, because by definition it will not reach them via a + /// notification. public static func lastDeliveryFailure(stateFile: URL = HealthAlert.stateFile) -> ( at: Date, summary: String, reason: String )? { guard let state = loadState(stateFile), state.delivered == false else { return nil } - return (state.lastAlertAt, state.lastSummary, state.deliveryError ?? "nieznany powod") + let reason: String + switch state.deliveryError { + case .none: reason = L10n.tr("unknown reason") + case .some(osascriptFailureReason): + reason = L10n.tr( + "osascript did not show the notification (permissions or no graphical session)") + case .some(let other): reason = other + } + return (state.lastAlertAt, state.lastSummary, reason) } - /// Powiadomienie systemowe przez `osascript`. Sam tekst wstawiamy jako - /// literal AppleScript z ucieknietymi cudzyslowami - inaczej komunikat - /// zawierajacy `"` (a komunikaty rclone je zawieraja) rozwalilby skrypt - /// i alarm zginalby po cichu, czyli dokladnie tak, jak awaria, ktora ma - /// zglaszac. + /// System notification via `osascript`. The text itself is inserted as an + /// AppleScript literal with escaped quotes - otherwise a message containing + /// `"` (and rclone messages do contain them) would break the script and the + /// alarm would vanish silently, i.e. exactly like the failure it is meant to + /// report. /// - /// Zwraca `true` tylko wtedy, gdy `osascript` FAKTYCZNIE zakonczyl sie - /// powodzeniem. Wynik byl wczesniej wyrzucany przez `_ = try?`, wiec odmowa - /// uprawnien do powiadomien (typowa dla procesu launchd), brak sesji Aqua - /// albo przekroczony limit czasu wygladaly dokladnie tak samo, jak - /// pokazane powiadomienie. + /// Returns `true` only when `osascript` ACTUALLY finished successfully. The + /// result used to be thrown away with `_ = try?`, so a refused notification + /// permission (typical for a launchd process), no Aqua session or an + /// exceeded time limit looked exactly the same as a notification that was + /// shown. @discardableResult public static func notify(title: String, message: String) async -> Bool { let script = @@ -198,14 +228,15 @@ public enum HealthAlert { let result = try? await ProcessRunner.run( "/usr/bin/osascript", ["-e", script], timeout: 30) else { - CMLogger.log("osascript nie odpowiedzial w limicie czasu - powiadomienie nie poszlo.") + CMLogger.log( + "osascript did not respond within the time limit - the notification was not sent.") return false } if !result.succeeded { let text = (result.stderr + result.stdout).trimmingCharacters(in: .whitespacesAndNewlines) CMLogger.log( - "osascript zwrocil kod \(result.exitCode): " - + (text.isEmpty ? "(bez komunikatu)" : text)) + "osascript returned code \(result.exitCode): " + + (text.isEmpty ? "(no message)" : text)) } return result.succeeded } diff --git a/mac-app/Sources/CloudMachineCore/ImageProbe.swift b/mac-app/Sources/CloudMachineCore/ImageProbe.swift index be7e60e..26d6375 100644 --- a/mac-app/Sources/CloudMachineCore/ImageProbe.swift +++ b/mac-app/Sources/CloudMachineCore/ImageProbe.swift @@ -1,61 +1,64 @@ import Foundation -/// Czy podpiety obraz NAPRAWDE oddaje dane - a nie tylko figuruje w tablicy -/// montowan. +/// Whether the attached image REALLY returns data - and does not merely appear +/// in the mount table. /// -/// DLACZEGO TO ISTNIEJE +/// WHY THIS EXISTS /// -/// 22 wrz 2026 o 00:17 rclone dostal od Google HTTP 401 na listowaniu pasm, -/// oddal blad we/wy do FUSE-T, a sterownik obrazu uznal urzadzenie za -/// odlaczone. Od tej chwili kazdy odczyt pliku spod `/Volumes/CloudMachine` -/// konczyl sie `errno 6 ENXIO` ("Device not configured") i Time Machine -/// padal co przebieg z `BACKUP_FAILED_DISCONNECTED_DESTINATION`. +/// On 22 Sep 2026 at 00:17 rclone got HTTP 401 from Google while listing the +/// bands, passed an I/O error to FUSE-T, and the image driver considered the +/// device disconnected. From that moment every read of a file under +/// `/Volumes/CloudMachine` ended with `errno 6 ENXIO` ("Device not +/// configured") and Time Machine failed every run with +/// `BACKUP_FAILED_DISCONNECTED_DESTINATION`. /// -/// Tymczasem `mount` nadal pokazywal wolumen, `hdiutil info` nadal pokazywal -/// obraz, `ls` katalogu dzialal (z cache jadra), a `statfs` oddawal wolne -/// miejsce. Wszystko, na czym stal `isAttached`, mowilo "OK". Skutek: -/// `drive-status` "Obraz podpiety: OK", GUI "Wszystko wyslane", a agent -/// `gdrive-attach` co 15 minut meldowal "Juz podpiete" i NIE podpinal na nowo. -/// Przez 15 godzin jedynym, co krzyczalo, byl `backup-health` - po fakcie, -/// z wieku ostatniej kopii. +/// Meanwhile `mount` still showed the volume, `hdiutil info` still showed the +/// image, `ls` of the directory worked (from the kernel cache), and `statfs` +/// returned free space. Everything `isAttached` was built on said "OK". The +/// result: `drive-status` said "Image attached: OK", the GUI "Everything +/// uploaded", and the `gdrive-attach` agent reported "Already attached" every +/// 15 minutes and did NOT reattach. For 15 hours the only thing that cried out +/// was `backup-health` - after the fact, from the age of the last backup. /// -/// Zmierzone na martwym urzadzeniu: `open()` katalogu - OK, `listdir` - OK, -/// `statvfs` - OK, `fsync` - OK, **`open`+`read` 1 bajtu zwyklego pliku - ENXIO**. -/// Dlatego sonda czyta bajt. Nic slabszego nie odroznia zywego od martwego. +/// Measured on the dead device: `open()` of the directory - OK, `listdir` - +/// OK, `statvfs` - OK, `fsync` - OK, **`open`+`read` of 1 byte of a regular +/// file - ENXIO**. That is why the probe reads a byte. Nothing weaker tells a +/// live device from a dead one. public enum ImageProbe { public enum Verdict: Equatable, Sendable { - /// Odczyt sie udal. + /// The read succeeded. case readable - /// Urzadzenie nie oddaje danych. `errno` z nieudanego odczytu. + /// The device does not return data. `errno` from the failed read. case dead(errno: Int32) - /// W katalogu glownym nie ma zwyklego pliku, ktory daloby sie przeczytac - - /// tak wyglada swiezy wolumen przed pierwsza kopia. Nie da sie stwierdzic - /// awarii, wiec NIE zglaszamy jej. + /// There is no regular file in the root directory that could be read - + /// that is what a fresh volume looks like before the first backup. A + /// failure cannot be established, so we do NOT report one. case nothingToProbe - /// Sonda NIE ODPOWIEDZIALA w wyznaczonym czasie. + /// The probe DID NOT ANSWER within the allotted time. /// - /// To NIE jest `.dead` i zlanie tych dwoch przypadkow byloby grozne: - /// `.dead` wyzwala w `attach-image` odpiecie NA SILE obrazu, na ktorym - /// czekaja jeszcze niewyslane dane, a tutaj nie wiemy nawet tego, czy - /// urzadzenie jest martwe. Brak wiedzy ma WSTRZYMYWAC operacje - /// nieodwracalna, nie ja wyzwalac - dlatego ten werdykt mapuje sie na - /// `BackupImageService.Attachment.unknown`, ktore juz blokuje `attach` - /// i `create`. + /// This is NOT `.dead`, and merging the two cases would be dangerous: + /// `.dead` triggers a FORCED detach in `attach-image` of an image on which + /// unsent data is still waiting, while here we do not even know whether + /// the device is dead. A lack of knowledge must HOLD BACK an irreversible + /// operation, not trigger it - that is why this verdict maps to + /// `BackupImageService.Attachment.unknown`, which already blocks `attach` + /// and `create`. case timedOut } - /// Bledy, ktore znacza "urzadzenie zniklo", a nie "plik jest dziwny". + /// Errors that mean "the device is gone", not "the file is odd". /// - /// EACCES czy EISDIR to wlasciwosc pliku, nie wolumenu - taki plik pomijamy - /// i probujemy nastepnego. ENXIO/EIO/ENODEV/ENOTCONN to wolumen. + /// EACCES or EISDIR are a property of the file, not of the volume - we skip + /// such a file and try the next one. ENXIO/EIO/ENODEV/ENOTCONN are the + /// volume. static let deviceErrors: Set = [ENXIO, EIO, ENODEV, ENOTCONN] - /// Czysta wersja: listowanie i odczyt sa wstrzykiwane, zeby test mogl - /// podstawic ENXIO bez psucia prawdziwego urzadzenia. + /// Pure version: listing and reading are injected, so that a test can + /// substitute ENXIO without breaking a real device. /// - /// - `regularFiles`: zwykle pliki w katalogu glownym wolumenu. - /// - `readFirstByte`: rzuca `POSIXError`-podobny blad z `errno`, gdy odczyt pada. + /// - `regularFiles`: regular files in the volume's root directory. + /// - `readFirstByte`: returns the `errno` when the read fails. public static func probe( regularFiles: () throws -> [URL], readFirstByte: (URL) -> Int32? @@ -64,39 +67,41 @@ public enum ImageProbe { do { files = try regularFiles() } catch { - // Listowanie tez potrafi pasc na martwym urzadzeniu - i to jest ten - // przypadek, ktory `try?` polykal. `--dir-cache-time` wynosi 5 minut: - // dopoki cache jest swiezy, `contentsOfDirectory` chodzi z pamieci jadra - // i dziala nawet po ENXIO (stad zdanie wyzej, ze listowanie "to nie jest - // test"). Po wygasnieciu cache to samo listowanie idzie po dane do - // rclone i pada tym samym ENXIO, co odczyt. Do 23 wrzesnia 2026 sonda - // mowila wtedy `.nothingToProbe`, `BackupImageService.attachment` - // mapowalo to na `.attached`, a agent `gdrive-attach` co 15 minut - // meldowal "Juz podpiete" - czyli dokladnie ta awaria, dla ktorej ta - // sonda powstala, wracala tylnymi drzwiami po piatej minucie. + // Listing can also fail on a dead device - and that is the case `try?` + // used to swallow. `--dir-cache-time` is 5 minutes: while the cache is + // fresh, `contentsOfDirectory` runs from kernel memory and works even + // after ENXIO (hence the statement above that listing "is not a test"). + // Once the cache expires, the same listing goes to rclone for data and + // fails with the same ENXIO as the read. Until 23 September 2026 the + // probe then said `.nothingToProbe`, `BackupImageService.attachment` + // mapped that to `.attached`, and the `gdrive-attach` agent reported + // "Already attached" every 15 minutes - i.e. exactly the failure this + // probe was created for came back through the back door after the + // fifth minute. if let code = deviceErrno(of: error), deviceErrors.contains(code) { return .dead(errno: code) } - // Blad bez rozpoznanego errno urzadzenia nie dowodzi niczego o wolumenie. + // An error without a recognized device errno proves nothing about the volume. return .nothingToProbe } guard !files.isEmpty else { return .nothingToProbe } for file in files { guard let errno = readFirstByte(file) else { return .readable } if deviceErrors.contains(errno) { return .dead(errno: errno) } - // Blad wlasciwy dla pliku - sprobuj innego. + // An error specific to the file - try another one. } return .nothingToProbe } - /// Wyciaga surowe `errno` z bledu rzuconego przez listowanie katalogu. + /// Extracts the raw `errno` from an error thrown by the directory listing. /// - /// Foundation nie oddaje go wprost: `contentsOfDirectory` opakowuje blad - /// POSIX-a w `NSCocoaErrorDomain` (np. 256 `NSFileReadUnknownError`), - /// a oryginalne `errno` chowa pod `NSUnderlyingErrorKey` jako - /// `NSPOSIXErrorDomain`. Sprawdzamy trzy postacie, bo kazda z nich wychodzi - /// z innej warstwy: `POSIXError` z kodu wolajacego libc wprost, - /// `NSPOSIXErrorDomain` z cienkiego opakowania, i dopiero potem zagniezdzenie. + /// Foundation does not hand it over directly: `contentsOfDirectory` wraps + /// the POSIX error in `NSCocoaErrorDomain` (e.g. 256 + /// `NSFileReadUnknownError`), and hides the original `errno` under + /// `NSUnderlyingErrorKey` as `NSPOSIXErrorDomain`. We check three forms, + /// because each comes from a different layer: `POSIXError` from code calling + /// libc directly, `NSPOSIXErrorDomain` from a thin wrapper, and only then the + /// nested one. static func deviceErrno(of error: Error) -> Int32? { if let posix = error as? POSIXError { return posix.code.rawValue } let ns = error as NSError @@ -109,67 +114,68 @@ public enum ImageProbe { return nil } - // MARK: - Limit czasu + // MARK: - Time limit // - // DLACZEGO WATEK ODDZIELONY DEADLINEM, A NIE `ProcessRunner.run(timeout:)` + // WHY A THREAD SEPARATED BY A DEADLINE, AND NOT `ProcessRunner.run(timeout:)` // - // Sonda to `readdir` plus `open`/`read` na wolumenie stojacym na FUSE-T. - // Kiedy rclone przestaje odpowiadac, te wywolania wchodza w NIEPRZERYWALNE - // oczekiwanie w jadrze (stan "U" w `ps`). Takiego watku nie da sie ani - // anulowac, ani ubic: `Task.cancel()` jest kooperacyjne i `read()` w jadrze - // z nim nie wspolpracuje, a SIGKILL tez nie dziala - to samo ograniczenie - // opisuje juz `ProcessRunner` przy swojej "ostatecznej granicy". Skoro sondy - // nie da sie PRZERWAC, jedyne, co da sie zagwarantowac, to ze jej - // zawieszenie nie zawiesza WOLAJACEGO. Sonda dostaje wiec wlasny watek, - // a wolajacy deadline i werdykt `.timedOut`. + // The probe is a `readdir` plus `open`/`read` on a volume living on FUSE-T. + // When rclone stops responding, these calls enter an UNINTERRUPTIBLE wait in + // the kernel (state "U" in `ps`). Such a thread can be neither cancelled + // nor killed: `Task.cancel()` is cooperative and `read()` in the kernel does + // not cooperate with it, and SIGKILL does not work either - the same + // limitation `ProcessRunner` already describes for its "final limit". Since + // the probe cannot be INTERRUPTED, the only thing that can be guaranteed is + // that its hang does not hang the CALLER. So the probe gets its own thread, + // and the caller gets a deadline and the `.timedOut` verdict. // - // Dlatego tez nie ma tu (i nie moze byc) synchronicznego `probe(volume:)` - - // byl do 26.09.2026 i wlasnie on zamrazal panel na `@MainActor` oraz - // uciszal czujke `backup-health` na stale. Jedyne wejscie na zywy wolumen - // jest `async`, zeby wolajacy czekal bez blokowania watku. + // That is also why there is not (and cannot be) a synchronous + // `probe(volume:)` here - there was one until 26.09.2026 and it was exactly + // what froze the panel on `@MainActor` and silenced the `backup-health` + // watchdog permanently. The only entry point onto a live volume is `async`, + // so that the caller waits without blocking a thread. // - // Rozwazone i ODRZUCONE: + // Considered and REJECTED: // - // - Sonda w PODPROCESIE przez `ProcessRunner.run(..., timeout:)` - wzorzec, - // ktory w tym repo ratuje `tmutil`. Kupuje tu dokladnie tyle samo, co - // watek (zawieszenie nie zatrzymuje wolajacego), a placi znacznie wiecej: - // nowa podkomenda agenta, odnajdywanie binarki w trzech ukladach (bundel - // GUI, `.build/` przy pracy z terminala, `/Applications` pod launchd), - // `fork`+`exec` co 10 s w petli odswiezania panelu i - tak samo jak tu - - // osierocony proces zawieszony w jadrze, ktorego nikt nie ubije. Trzy nowe - // miejsca, w ktorych sonda moze przestac dzialac po cichu, za zysk - // ograniczony do tego, ze zaklinowany watek nalezy do obcego procesu. + // - A probe in a SUBPROCESS via `ProcessRunner.run(..., timeout:)` - the + // pattern that saves `tmutil` in this repo. Here it buys exactly as much + // as a thread (a hang does not stop the caller), and costs much more: a + // new agent subcommand, finding the binary in three layouts (GUI bundle, + // `.build/` when working from the terminal, `/Applications` under + // launchd), a `fork`+`exec` every 10 s in the panel's refresh loop and - + // just like here - an orphaned process stuck in the kernel that nobody + // will kill. Three new places where the probe can silently stop working, + // for a gain limited to the stuck thread belonging to another process. // - // - `open(..., O_NONBLOCK)`. Na PLIKU ZWYKLYM O_NONBLOCK nie czyni `read()` - // nieblokujacym - dotyczy FIFO, gniazd i urzadzen znakowych, a nie - // oczekiwania na I/O pliku; `readdir` nie ma nawet takiego wariantu. - // Sonda stracilaby wiec czytelnosc kodu, nie zyskujac gwarancji, a przy - // okazji przestalaby mierzyc to, po co istnieje: ODDANIE bajtu przez - // urzadzenie. + // - `open(..., O_NONBLOCK)`. On a REGULAR FILE O_NONBLOCK does not make + // `read()` non-blocking - it applies to FIFOs, sockets and character + // devices, not to waiting for file I/O; `readdir` does not even have such + // a variant. The probe would thus lose code clarity without gaining a + // guarantee, and would incidentally stop measuring what it exists for: the + // device HANDING OVER a byte. // - // - Wyscig dwoch `Task` z `Task.sleep` i `cancel()` na przegranym - patrz - // wyzej, anulowanie nie ma jak dosiegnac `read()` w jadrze. Watek z puli - // `DispatchQueue.global()` odpada z tego samego powodu, tylko gorzej: - // zaklinowany watek zostaje zajety na zawsze, a pula ma ~64 miejsca - // i jest wspoldzielona z cala reszta procesu. + // - A race between two `Task`s with `Task.sleep` and `cancel()` on the loser + // - see above, cancellation has no way to reach `read()` in the kernel. A + // thread from the `DispatchQueue.global()` pool is out for the same + // reason, only worse: a stuck thread stays occupied forever, and the pool + // has ~64 slots and is shared with the whole rest of the process. - /// Ile czekamy na werdykt, zanim oglosimy `.timedOut`. + /// How long we wait for a verdict before declaring `.timedOut`. /// - /// Na zywym wolumenie sonda trwa mikrosekundy - jeden `readdir` i odczyt - /// jednego bajtu. Te 15 s to wiec nie budzet na prace, a granica - /// cierpliwosci. Dolna granice wyznacza ZYWY, ale wolny FUSE-T (pasmo - /// sciagane z Dysku w trakcie odczytu), ktorego nie wolno brac za - /// niewiadoma; gorna - to, po co ten limit istnieje: 25.09.2026 - /// `drive-status` wisial ponad 25 s i trzeba go bylo zabic recznie, a czujka - /// `backup-health` chodzi co 1800 s, wiec pelne 15 s i tak nie zblizy sie - /// do jej okna. + /// On a live volume the probe takes microseconds - one `readdir` and a read + /// of one byte. So these 15 s are not a budget for work but a limit of + /// patience. The lower bound is set by a LIVE but slow FUSE-T (a band being + /// downloaded from the Drive during the read), which must not be taken for + /// an unknown; the upper one - by what this limit exists for: on 25.09.2026 + /// `drive-status` hung for over 25 s and had to be killed by hand, and the + /// `backup-health` watchdog runs every 1800 s, so the full 15 s will not come + /// anywhere near its window anyway. public static let probeTimeout: TimeInterval = 15 - /// Werdykt przekazywany z watku sondujacego do wolajacego. + /// Verdict passed from the probing thread to the caller. /// - /// Obie strony musza przezyc brak drugiej: wolajacy moze sie poddac na - /// deadline i nigdy nie odebrac werdyktu, a watek moze nigdy nie dojsc do - /// `finish`, bo utknal w jadrze. + /// Both sides must survive the absence of the other: the caller may give up + /// on the deadline and never collect the verdict, and the thread may never + /// reach `finish`, because it got stuck in the kernel. private final class ProbeBox: @unchecked Sendable { private let lock = NSLock() private var verdict: Verdict? @@ -188,8 +194,8 @@ public enum ImageProbe { waiting?(value) } - /// Wola `handler` z werdyktem - natychmiast, jesli sonda zdazyla - /// odpowiedziec, zanim wolajacy zapisal sie na powiadomienie. + /// Calls `handler` with the verdict - immediately if the probe managed to + /// answer before the caller subscribed to the notification. func whenDone(_ handler: @escaping (Verdict) -> Void) { lock.lock() if let verdict { @@ -202,13 +208,13 @@ public enum ImageProbe { } } - /// Ktore wolumeny maja wlasnie sonde w locie. + /// Which volumes currently have a probe in flight. private final class ProbeSlots: @unchecked Sendable { static let shared = ProbeSlots() private let lock = NSLock() private var busy: Set = [] - /// `true` = slot byl wolny i od tej chwili nalezy do wolajacego. + /// `true` = the slot was free and from now on belongs to the caller. func claim(_ slot: String) -> Bool { lock.lock() defer { lock.unlock() } @@ -222,14 +228,15 @@ public enum ImageProbe { } } - /// Startuje sonde na WLASNYM watku. `nil` = sonda tego slotu wciaz trwa. + /// Starts the probe on its OWN thread. `nil` = the probe for this slot is + /// still running. /// - /// Jedna sonda na slot to nie optymalizacja. Bez tego panel GUI, ktory - /// odswieza sie co 10 s, zostawialby na trwale zaklinowanym wolumenie po - /// jednym wiszacym watku na przebieg - kilkaset na godzine, kazdy z wlasnym - /// stosem i zaden do odzyskania. Drugi watek i tak nie dowiedzialby sie - /// niczego nowego: skoro pierwszy stoi w jadrze, odpowiedzi nie ma, wiec - /// kolejny wolajacy dostaje `.timedOut` od razu. + /// One probe per slot is not an optimization. Without it, the GUI panel, + /// which refreshes every 10 s, would leave one hanging thread per run on a + /// permanently stuck volume - several hundred per hour, each with its own + /// stack and none recoverable. A second thread would not learn anything new + /// anyway: since the first one is stuck in the kernel, there is no answer, + /// so the next caller gets `.timedOut` right away. private static func startProbe( slot: String, regularFiles: @escaping @Sendable () throws -> [URL], @@ -239,8 +246,8 @@ public enum ImageProbe { let box = ProbeBox() let thread = Thread { let verdict = probe(regularFiles: regularFiles, readFirstByte: readFirstByte) - // Zwolnienie slotu PRZED oddaniem werdyktu: inaczej wolajacy obudzony - // przez `finish` widzialby slot jako wciaz zajety. + // Release the slot BEFORE handing over the verdict: otherwise a caller + // woken by `finish` would see the slot as still occupied. ProbeSlots.shared.release(slot) box.finish(verdict) } @@ -250,9 +257,10 @@ public enum ImageProbe { return box } - /// Sonda na zywym wolumenie. Po `timeout` oddaje `.timedOut`, a wolajacy - /// idzie dalej - sam odczyt moze zostac w jadrze na zawsze i to jest - /// przyjete, byle nie zabral ze soba czujki ani interfejsu. + /// Probe on a live volume. After `timeout` it returns `.timedOut` and the + /// caller moves on - the read itself may stay in the kernel forever and that + /// is accepted, as long as it does not take the watchdog or the interface + /// down with it. public static func probe(volume: URL, timeout: TimeInterval = probeTimeout) async -> Verdict { await probe( slot: volume.path, timeout: timeout, @@ -260,10 +268,10 @@ public enum ImageProbe { readFirstByte: { readFirstByteErrno(of: $0) }) } - /// Jak wyzej, ale z wstrzykiwanym listowaniem i odczytem - zeby test mogl - /// podstawic sonde, ktora NIGDY NIE ODPOWIADA, bez martwego wolumenu pod - /// reka. `slot` jest osobnym parametrem z tego samego powodu: dwa testy nie - /// moga sobie wzajemnie zajmowac tego samego slotu. + /// As above, but with injected listing and reading - so that a test can + /// substitute a probe that NEVER ANSWERS, without a dead volume at hand. + /// `slot` is a separate parameter for the same reason: two tests must not + /// occupy each other's slot. static func probe( slot: String, timeout: TimeInterval, @@ -284,12 +292,12 @@ public enum ImageProbe { } } - /// Zwykle pliki w katalogu glownym. Dopoki cache katalogu jest swiezy - /// (`--dir-cache-time 5m`), listowanie chodzi z pamieci i dziala takze na - /// martwym urzadzeniu - dlatego samo powodzenie listowania NIE jest dowodem - /// zycia, tylko lista kandydatow do testu. Po wygasnieciu cache to samo - /// listowanie pada ENXIO i wtedy jest juz dowodem smierci - obsluguje to - /// `probe`, nie ta funkcja. + /// Regular files in the root directory. While the directory cache is fresh + /// (`--dir-cache-time 5m`), listing runs from memory and works on a dead + /// device too - that is why a successful listing alone is NOT proof of + /// life, only a list of candidates for the test. Once the cache expires, + /// the same listing fails with ENXIO and then it is proof of death - that + /// is handled by `probe`, not by this function. static func regularFiles(in volume: URL) throws -> [URL] { try FileManager.default.contentsOfDirectory( at: volume, includingPropertiesForKeys: [.isRegularFileKey], @@ -299,10 +307,10 @@ public enum ImageProbe { .sorted { $0.lastPathComponent < $1.lastPathComponent } } - /// `nil` = odczyt sie udal (takze pusty plik), inaczej `errno`. + /// `nil` = the read succeeded (an empty file too), otherwise `errno`. /// - /// Przez `open`/`read` z libc, nie przez `Data(contentsOf:)`: Foundation - /// czyta caly plik, a nas interesuje jeden bajt i surowe errno. + /// Via libc `open`/`read`, not via `Data(contentsOf:)`: Foundation reads the + /// whole file, and we are interested in one byte and the raw errno. static func readFirstByteErrno(of file: URL) -> Int32? { let fd = open(file.path, O_RDONLY) guard fd >= 0 else { return errno } diff --git a/mac-app/Sources/CloudMachineCore/KeychainStore.swift b/mac-app/Sources/CloudMachineCore/KeychainStore.swift index b87ec70..09d460b 100644 --- a/mac-app/Sources/CloudMachineCore/KeychainStore.swift +++ b/mac-app/Sources/CloudMachineCore/KeychainStore.swift @@ -1,24 +1,25 @@ import Foundation -/// Zapis poswiadczen do Keychaina. +/// Writing credentials to the Keychain. /// -/// **Dlaczego przez `security`, a nie przez API Security (SecItemAdd).** -/// Wpis zalozony przez `SecItemAdd` dostaje ACL ograniczony do programu, ktory -/// go utworzyl. Odczyt z INNEJ binarki - a dokladnie to robi -/// `RemoteConfigurer.keychainSecret`, wolane z agenta launchd - podnosi wtedy -/// okno "pozwol na dostep". Agent launchd nie ma komu tego okna pokazac, wiec -/// odczyt zawisa albo wraca pusty, a rclone po cichu laczy sie na -/// wspoldzielonym `client_id`. Zmierzone 13 wrz 2026: `SecItemAdd` zwrocil 0, -/// po czym `security find-generic-password -w` z innego procesu zawisl na -/// oknie SecurityAgent. +/// **Why via `security` and not via the Security API (SecItemAdd).** +/// An item created by `SecItemAdd` gets an ACL restricted to the program that +/// created it. Reading it from ANOTHER binary - which is exactly what +/// `RemoteConfigurer.keychainSecret`, called from the launchd agent, does - +/// then raises an "allow access" dialog. The launchd agent has nobody to show +/// that dialog to, so the read hangs or comes back empty, and rclone silently +/// connects with the shared `client_id`. Measured 13 Sep 2026: `SecItemAdd` +/// returned 0, after which `security find-generic-password -w` from another +/// process hung on a SecurityAgent dialog. /// -/// Zapis przez `security` daje wpis czytelny dla `security` - czyli dokladnie -/// dla tej sciezki, ktorej uzywa dzialajacy system. +/// Writing via `security` produces an item readable by `security` - i.e. +/// exactly by the path the running system uses. /// -/// **Cena: haslo idzie w argv `security`,** wiec przez ulamek sekundy widac je -/// w `ps`. Swiadomy kompromis wobec alternatywy, ktora jest cicha awaria -/// backupu. Ekspozycja dotyczy procesu zyjacego milisekundy i wylacznie na tej -/// maszynie; sekret i tak zaraz laduje w Keychainie tego samego uzytkownika. +/// **The price: the password goes into `security`'s argv,** so for a fraction +/// of a second it is visible in `ps`. A deliberate trade-off against the +/// alternative, which is a silent backup failure. The exposure concerns a +/// process living for milliseconds and only on this machine; the secret lands +/// in the same user's Keychain right afterwards anyway. public enum KeychainStore { public enum StoreError: LocalizedError { @@ -27,15 +28,15 @@ public enum KeychainStore { public var errorDescription: String? { switch self { - case .emptyValue: return "Pusta wartosc - nie zapisuje." - case .failed(let detail): return "Keychain odmowil: \(detail)" + case .emptyValue: return L10n.tr("Empty value - not saving.") + case .failed(let detail): return L10n.tr("Keychain refused: %@", detail) } } } - /// Zapisuje albo nadpisuje wpis. `-U` znaczy "podmien, jesli juz jest" - - /// bez tego poprawienie literowki konczyloby sie bledem i stara wartoscia - /// nadal w uzyciu. + /// Saves or overwrites an item. `-U` means "replace if it already exists" - + /// without it, fixing a typo would end in an error with the old value still + /// in use. public static func save(_ value: String, account: String, service: String) async throws { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw StoreError.emptyValue } @@ -45,15 +46,17 @@ public enum KeychainStore { ["add-generic-password", "-a", account, "-s", service, "-w", trimmed, "-U"], timeout: 30) guard result?.succeeded == true else { - throw StoreError.failed(result?.stderr ?? "nieznany blad") + throw StoreError.failed(result?.stderr ?? L10n.tr("unknown error")) } } - /// Czy wpis istnieje - BEZ `-w`, czyli bez siegania po sama wartosc. + /// Whether the item exists - WITHOUT `-w`, i.e. without reaching for the + /// value itself. /// - /// To nie jest drobiazg: samo sprawdzenie istnienia nie rusza ACL i nie - /// podnosi okna, a odczyt wartosci (`-w`) potrafi. Interfejs ma pokazac - /// "ustawione / brak" i do tego wartosc nie jest potrzebna. + /// This is not a detail: checking existence alone does not touch the ACL + /// and does not raise a dialog, while reading the value (`-w`) can. The + /// interface has to show "set / missing", and the value is not needed for + /// that. public static func exists(account: String, service: String) async -> Bool { let result = try? await ProcessRunner.run( "/usr/bin/security", @@ -67,9 +70,9 @@ public enum KeychainStore { "/usr/bin/security", ["delete-generic-password", "-a", account, "-s", service], timeout: 30) - // Brak wpisu to nie blad - kasowanie ma byc idempotentne. + // A missing item is not an error - deletion must be idempotent. guard result?.succeeded == true || result?.stderr.contains("could not be found") == true else { - throw StoreError.failed(result?.stderr ?? "nieznany blad") + throw StoreError.failed(result?.stderr ?? L10n.tr("unknown error")) } } } diff --git a/mac-app/Sources/CloudMachineCore/L10n.swift b/mac-app/Sources/CloudMachineCore/L10n.swift new file mode 100644 index 0000000..29bcc44 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/L10n.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Text shown to people: the menu-bar app, CLI output and alerts. +/// +/// The English text is the key, so the source reads naturally and a missing +/// translation falls back to English instead of to an identifier. Polish +/// translations live in the `L10nPolish+*.swift` tables. Logs are NOT +/// localized: a log is read when diagnosing, often by someone else, and a file +/// that switches language with the system setting cannot be searched. +/// +/// The language follows the system (first preferred language). `CM_LANGUAGE` +/// (`en` or `pl`) overrides it; tests always run in English, so their expected +/// strings do not depend on the Mac they run on. +/// +/// Rules, enforced by `L10nTests`: +/// - the key is a single-line string literal written inline in the call; +/// - placeholders are `%@` only (pass numbers as `"\(n)"`); `String(format:)` +/// with a wrong argument type crashes, and `%@` cannot be mismatched; +/// - every key has a Polish entry with the same number of placeholders. +public enum L10n { + public enum Language: String { + case en + case pl + } + + /// Settable for tests and previews; resolved once otherwise. + public static var language: Language = detectLanguage() + + static func detectLanguage( + environment: [String: String] = ProcessInfo.processInfo.environment, + preferredLanguages: [String] = Locale.preferredLanguages, + isRunningTests: Bool = NSClassFromString("XCTestCase") != nil + ) -> Language { + if let forced = environment["CM_LANGUAGE"].flatMap({ Language(rawValue: $0.lowercased()) }) { + return forced + } + if isRunningTests { return .en } + return preferredLanguages.first?.lowercased().hasPrefix("pl") == true ? .pl : .en + } + + /// Translates `english`; with arguments, fills its `%@` placeholders. + public static func tr(_ english: String, _ arguments: String...) -> String { + let template = language == .pl ? (polish[english] ?? english) : english + guard !arguments.isEmpty else { return template } + return String(format: template, arguments: arguments.map { $0 as NSString }) + } + + /// All Polish tables merged. Split into files so that parts of the code can + /// be translated in parallel without editing one shared dictionary. + static let polish: [String: String] = { + var merged: [String: String] = [:] + for table in polishTables { + merged.merge(table) { first, _ in first } + } + return merged + }() + + static let polishTables: [[String: String]] = [ + L10nPolish.storage, + L10nPolish.system, + L10nPolish.app, + L10nPolish.agent, + ] +} + +/// Namespace for the Polish tables; each `L10nPolish+*.swift` adds one. +enum L10nPolish {} diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift new file mode 100644 index 0000000..dd8ea40 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift @@ -0,0 +1,217 @@ +// Polish translations, keyed by the English text passed to `L10n.tr`. +extension L10nPolish { + static let agent: [String: String] = [ + "CloudMachine - verification, Google Drive setup and installation. Called by launchd on a schedule, or by hand from Terminal.": + "CloudMachine - weryfikacja, konfiguracja Google Drive i instalacja. Wołane przez launchd według harmonogramu albo ręcznie z Terminala.", + "Generates and installs the launchd agents (Drive buffer, image attach, buffer guard).": + "Generuje i instaluje agentów launchd (bufor Drive, podpięcie obrazu, dozorca bufora).", + "Connects to Google Drive through rclone (OAuth in the browser) and creates this machine's folder.": + "Łączy z Google Drive przez rclone (OAuth w przeglądarce) i tworzy folder tej maszyny.", + "Overwrite the existing remote. RISKY: replaces the token and permissions.": + "Nadpisz istniejący remote. RYZYKOWNE: podmienia token i uprawnienia.", + "Installs rclone through Homebrew - WARNING: this build CANNOT mount, see install-rclone.": + "Instaluje rclone przez Homebrew - UWAGA: ta wersja NIE umie montować, patrz install-rclone.", + "Packs build/CloudMachine.app into build/CloudMachine-.dmg.": + "Pakuje build/CloudMachine.app do pliku build/CloudMachine-.dmg.", + "ERROR: %@ is missing - run 'cloudmachine-agent build-app' first": + "BŁĄD: brak %@ - uruchom najpierw 'cloudmachine-agent build-app'", + "==> Preparing the staging folder": + "==> Przygotowuję folder staging", + "==> Creating %@": + "==> Tworzę %@", + "ERROR: hdiutil exited with code %@.": + "BŁĄD: hdiutil zakończył się kodem %@.", + "==> Done: %@": + "==> Gotowe: %@", + "On first launch (the app is not signed with an Apple Developer account):": + "Przy pierwszym uruchomieniu (appka niepodpisana kontem Apple Developer):", + "1. Open %@ and drag CloudMachine.app to Applications.": + "1. Otwórz %@ i przeciągnij CloudMachine.app do Applications.", + "2. In Finder, RIGHT-click CloudMachine.app -> Open -> Open\n (a plain double-click shows the Gatekeeper block \"unidentified developer\").": + "2. W Finderze kliknij CloudMachine.app PRAWYM przyciskiem -> Otwórz -> Otwórz\n (samo dwukliknięcie pokaże blokadę Gatekeepera „niezidentyfikowany deweloper”).", + "3. Later launches work normally, with a double-click.": + "3. Kolejne uruchomienia działają już normalnie, dwuklikiem.", + "Builds CloudMachine.app (Release) - GUI + cloudmachine-agent in Contents/MacOS/, plus launchd/config as Resources.": + "Buduje CloudMachine.app (Release) - GUI + cloudmachine-agent w Contents/MacOS/, plus launchd/config jako Resources.", + "Binaries for Apple Silicon and Intel at once (how CI builds a release; unnecessary locally).": + "Binarki dla Apple Silicon i Intela naraz (tak buduje wydanie CI; lokalnie zbędne).", + "==> Building CloudMachineApp + cloudmachine-agent (release) - version %@ (%@)": + "==> Buduję CloudMachineApp + cloudmachine-agent (release) - wersja %@ (%@)", + "ERROR: swift build exited with code %@.": + "BŁĄD: swift build zakończył się kodem %@.", + "ERROR: swift build --show-bin-path did not report the binaries directory.": + "BŁĄD: swift build --show-bin-path nie podał katalogu z binarkami.", + "ERROR: no built binary found at %@": + "BŁĄD: nie znaleziono zbudowanej binarki pod %@", + "==> Assembling the .app bundle in %@": + "==> Składam bundle .app w %@", + "==> WARNING: you are building from a DIRTY tree - the version will not point to a commit.": + "==> UWAGA: budujesz z BRUDNEGO drzewa - wersja nie wskaże commitu.", + "ERROR: Resources/AppIcon.icns is missing - generate it: swift Resources/icon-gen/generate_icon.swift Resources/AppIcon.iconset && iconutil -c icns Resources/AppIcon.iconset -o Resources/AppIcon.icns": + "BŁĄD: brak Resources/AppIcon.icns - wygeneruj go: swift Resources/icon-gen/generate_icon.swift Resources/AppIcon.iconset && iconutil -c icns Resources/AppIcon.iconset -o Resources/AppIcon.icns", + "==> Signing with the local certificate '%@' (Full Disk Access will survive later rebuilds)": + "==> Podpisuję lokalnym certyfikatem '%@' (Pełny dostęp do dysku przetrwa kolejne przebudowy)", + "==> Signing ad-hoc (no Apple Developer account) - run 'cloudmachine-agent setup-signing-cert' once so that TCC permissions survive later rebuilds": + "==> Podpisuję ad-hoc (bez konta Apple Developer) - uruchom raz 'cloudmachine-agent setup-signing-cert', żeby uprawnienia TCC przetrwały kolejne przebudowy", + "ERROR: codesign exited with code %@.": + "BŁĄD: codesign zakończył się kodem %@.", + "Next step: %@": + "Następny krok: %@", + "Creates a local self-signed certificate so that Full Disk Access survives later rebuilds of the app.": + "Tworzy lokalny certyfikat self-signed, żeby Pełny dostęp do dysku przetrwał kolejne przebudowy appki.", + "Certificate '%@' already exists in %@, nothing to do.": + "Certyfikat '%@' już istnieje w %@, nic nie robię.", + "==> Generating the key and self-signed certificate '%@'...": + "==> Generuję klucz i certyfikat self-signed '%@'...", + "ERROR: openssl req exited with code %@.": + "BŁĄD: openssl req zakończył się kodem %@.", + "ERROR: openssl pkcs12 exited with code %@.": + "BŁĄD: openssl pkcs12 zakończył się kodem %@.", + "==> Importing the certificate into %@ (pre-authorizing /usr/bin/codesign, so it does not ask for the keychain password every time)...": + "==> Importuję certyfikat do %@ (z góry autoryzuję /usr/bin/codesign, bez pytania o hasło pęku kluczy za każdym razem)...", + "ERROR: security import exited with code %@.": + "BŁĄD: security import zakończył się kodem %@.", + "==> Trusting the certificate ONLY for code signing...": + "==> Ufam certyfikatowi WYŁĄCZNIE do podpisywania kodu (code signing)...", + "ERROR: security add-trusted-cert exited with code %@.": + "BŁĄD: security add-trusted-cert zakończył się kodem %@.", + "Done. Certificate '%@' is now available to codesign.": + "Gotowe. Certyfikat '%@' jest teraz dostępny dla codesign.", + "The next 'cloudmachine-agent build-app' will use it automatically instead of an ad-hoc signature.": + "Następne 'cloudmachine-agent build-app' użyje go automatycznie zamiast podpisu ad-hoc.", + "After THAT ONE rebuild, grant Full Disk Access one last time - later\nrebuilds will no longer reset it, as long as you sign with the same certificate.": + "Po TYM JEDNYM rebuildzie przyznaj Pełny dostęp do dysku ostatni raz - kolejne\nprzebudowy już go nie zresetują, dopóki podpisujesz tym samym certyfikatem.", + "Mounts Google Drive with a write buffer. Stays in the foreground (for launchd).": + "Montuje Google Drive z buforem zapisu. Zostaje na pierwszym planie (dla launchd).", + "Already mounted: %@": + "Już zamontowane: %@", + "Missing: %@\n %@\n": + "Brakuje: %@\n %@\n", + "Could not start %@\n": + "Nie udało się uruchomić %@\n", + "Creates the backup image on Google Drive. One-off.": + "Tworzy obraz backupu na Google Drive. Jednorazowo.", + "Declared size in GB (the image is sparse).": + "Rozmiar deklarowany w GB (obraz jest rzadki).", + "Next step: %@, then": + "Następny krok: %@, potem", + "Attaches the backup image as the Time Machine destination.": + "Podpina obraz backupu jako cel Time Machine.", + "The buffer did not come up within %@ min - not attaching the image.": + "Bufor nie stanął w %@ min - nie podpinam obrazu.", + "Time Machine now has NO DESTINATION. Check: %@": + "Time Machine jest teraz BEZ CELU. Sprawdź: %@", + "This is not an error - the agent's next run will try again.": + "Nie jest to błąd - następny przebieg agenta spróbuje ponownie.", + "Detaches the image and waits until everything reaches Google Drive.": + "Odpina obraz i czeka, aż wszystko doleci na Google Drive.", + "Do not wait for the upload - RISKY, see BackupImageService.detach.": + "Nie czekaj na wysyłkę - RYZYKOWNE, patrz BackupImageService.detach.", + "Checks the image's consistency with fsck_apfs (hdiutil verify does not work on a sparsebundle).": + "Sprawdza spójność obrazu przez fsck_apfs (hdiutil verify na sparsebundle nie działa).", + "Pauses Time Machine when the unsent backlog grows faster than the upload goes.": + "Wstrzymuje Time Machine, gdy zaległość niewysłana rośnie szybciej, niż idzie wysyłka.", + "Above this many GB of unsent backlog we pause Time Machine.": + "Powyżej tylu GB zaległości niewysłanej wstrzymujemy Time Machine.", + "Below this many GB of unsent backlog we resume.": + "Poniżej tylu GB zaległości niewysłanej wznawiamy.", + "Below this many GB free on disk we pause regardless of the buffer.": + "Poniżej tylu GB wolnych na dysku wstrzymujemy niezależnie od bufora.", + "How often to check, in seconds.": + "Co ile sekund sprawdzać.", + "Checks whether the hourly cycle STILL works (date of the last SUCCESSFUL backup) and reports failures.": + "Sprawdza, czy cykl godzinowy NADAL działa (data ostatniej UDANEJ kopii), i zgłasza awarie.", + "After this many hours without a successful backup we consider the cycle broken.": + "Po tylu godzinach bez udanej kopii uznajemy cykl za zerwany.", + "Only print the state, without a system notification.": + "Tylko wypisz stan, bez powiadomienia systemowego.", + "A different Time Machine preferences file - to test the watchdog on a known sample.": + "Inny plik preferencji Time Machine - do sprawdzenia czujki na znanej próbce.", + "Last successful backup: %@": + "Ostatnia udana kopia: %@", + "Last successful backup: NONE": + "Ostatnia udana kopia: BRAK", + "Last attempt: %@": + "Ostatnia próba: %@", + "WAITING (system startup): %@": + "CZEKAM (start systemu): %@", + "Backup cycle: OK": + "Cykl backupu: OK", + "FAILURE: %@": + "AWARIA: %@", + " %@": + " %@", + "Prepares for a restart: pauses the backup, detaches the image and waits for the upload.": + "Przygotowuje do restartu: wstrzymuje backup, odpina obraz i czeka na wysyłkę.", + "Pausing the backup...": + "Wstrzymuję backup...", + "Detaching the image and waiting for the upload...": + "Odpinam obraz i czekam na wysyłkę...", + "Do NOT restart yet - the buffer holds data that has not reached Drive.": + "NIE RESTARTUJ jeszcze - w buforze są dane, które nie doleciały na Dysk.", + "Check the state: %@": + "Sprawdź stan: %@", + "Safe to restart. After startup the agents will bring up the buffer and attach the image themselves.": + "Można restartować. Po starcie agenty podniosą bufor i podepną obraz same.", + "State of the buffer, the upload queue and Time Machine.": + "Stan bufora, kolejki wysyłki i Time Machine.", + "Tools: %@": + "Narzędzia: %@", + "missing: %@": + "brakuje: %@", + "Drive mount: %@": + "Montowanie Drive: %@", + "Drive folder: %@": + "Folder na Drive: %@", + "Name of this Mac's folder on Google Drive (default: derived from the computer name). Set once; it cannot be changed later.": + "Nazwa folderu tego Maca na Google Drive (domyślnie z nazwy komputera). Ustawiana raz; później nie da się jej zmienić.", + "Image attached: %@": + "Obraz podpięty: %@", + "Cache on disk: %@": + "Cache na dysku: %@", + "To upload: %@": + "Do wysłania: %@", + "Free on disk: %@": + "Wolne na dysku: %@", + "Upload queue: %@ in progress, %@ queued, %@ errors": + "Kolejka wysyłki: %@ w toku, %@ w kolejce, %@ błędów", + "Upload queue: (rc interface unreachable)": + "Kolejka wysyłki: (interfejs rc nieosiągalny)", + "Restart without asking: %@": + "Restart bez pytania: %@", + "YES - queue empty": + "TAK - kolejka pusta", + "NO - run prepare-shutdown first": + "NIE - najpierw prepare-shutdown", + "Upload: %@": + "Wysyłka: %@", + "TM destination: %@": + "Cel Time Machine: %@", + "TM destination: none": + "Cel Time Machine: brak", + "Backup: running, %@%% (%@)": + "Backup: trwa, %@%% (%@)", + "Backup: not running": + "Backup: nie trwa", + "Backup watchdog: %@": + "Czujka backupu: %@", + "Pulls FUSE-T into CloudMachine, so there is no separate app in the system.": + "Wciąga FUSE-T do CloudMachine, żeby nie było osobnej aplikacji w systemie.", + "Downloads the official rclone binary (the Homebrew one cannot mount).": + "Pobiera oficjalną binarkę rclone (ta z Homebrew nie umie montować).", + "Prints the version, the build number and the commit this binary was built from.": + "Wypisuje wersję, numer budowy i commit, z którego zbudowano tę binarkę.", + "Only one line, without a description.": + "Tylko jedna linia, bez opisu.", + "Build from the working tree (outside a bundle) - no version data.": + "Build z drzewa roboczego (poza bundlem) - brak danych o wersji.", + "Version: %@": + "Wersja: %@", + "Build: %@": + "Budowa: %@", + "Commit: %@": + "Commit: %@", + "WARNING: built from a DIRTY tree - the binary contains code that\n is in no commit. The commit number does NOT describe\n what actually runs.": + "UWAGA: zbudowano z BRUDNEGO drzewa - w binarce jest kod, którego\n nie ma w żadnym commicie. Numer commitu NIE opisuje tego,\n co naprawdę działa.", + ] +} diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift new file mode 100644 index 0000000..8672b50 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -0,0 +1,129 @@ +// Polish translations, keyed by the English text passed to `L10n.tr`. +extension L10nPolish { + static let app: [String: String] = [ + // SetupPlan / setup card + "Install rclone (the official build, which can mount)": + "Zainstaluj rclone (oficjalną wersję, która umie montować)", + "Install FUSE-T": "Zainstaluj FUSE-T", + "Connect Google Drive: run this in Terminal and approve in the browser": + "Połącz Google Drive: uruchom to w Terminalu i zatwierdź w przeglądarce", + "Grant Full Disk Access to CloudMachine, so it can tell whether backups complete": + "Nadaj CloudMachine Pełny dostęp do dysku, żeby mógł sprawdzać, czy kopie się kończą", + "Install the background agents (mount, image attach, watchdogs)": + "Zainstaluj agentów w tle (montowanie, podpinanie obrazu, czujki)", + "Create the backup image on Google Drive": "Utwórz obraz kopii na Google Drive", + "Attach the backup image": "Podepnij obraz kopii", + "Point Time Machine at CloudMachine: run this in Terminal (needs sudo)": + "Ustaw CloudMachine jako dysk Time Machine: uruchom to w Terminalu (wymaga sudo)", + "Size (GB)": "Rozmiar (GB)", + "Install rclone": "Zainstaluj rclone", + "Open System Settings": "Otwórz Ustawienia systemowe", + "Install agents": "Zainstaluj agentów", + "Create image": "Utwórz obraz", + "Attach image": "Podepnij obraz", + "Installing FUSE-T": "Instaluję FUSE-T", + // AppStatus + "not checked": "nie sprawdzono", + "none at all": "ani jednej", + "%@ ago": "%@ temu", + "Missing: %@": "Brakuje: %@", + "Google Drive not connected": "Google Drive niepołączony", + "Buffer is not working": "Bufor nie działa", + "Backup image not attached": "Obraz backupu niepodpięty", + "Time Machine does not point to CloudMachine": "Time Machine nie wskazuje na CloudMachine", + "UNKNOWN whether Time Machine points to CloudMachine - tmutil did not answer": + "NIE WIADOMO, czy Time Machine wskazuje na CloudMachine - tmutil nie odpowiedział", + "Backup in progress": "Backup w toku", + "Unknown when the last backup was made": "Nie wiadomo, kiedy powstała ostatnia kopia", + "There is no completed backup at all": "Nie ma ani jednej ukończonej kopii", + "No completed backup for %@": "Brak ukończonej kopii od %@", + "Ready": "Gotowe", + + // CloudMachineController + "Saved in the Keychain. Note: the existing connection still uses the old client_id - to use the new one, run configure-remote --replace-existing.": + "Zapisane w Keychainie. Uwaga: istniejące połączenie nadal działa na starym client_id - żeby użyć nowego, przejdź configure-remote --replace-existing.", + "Not saved: %@": "Nie zapisano: %@", + "Installing rclone": "Instaluję rclone", + "Creating the backup image": "Tworzę obraz backupu", + "Attaching the image": "Podpinam obraz", + "Checking image consistency": "Sprawdzam spójność obrazu", + "Installing launchd agents": "Instaluję agentów launchd", + "Starting backup": "Uruchamiam backup", + "Backup started.": "Backup uruchomiony.", + "Could not start the backup.": "Nie udało się uruchomić backupu.", + "Stopping backup": "Wstrzymuję backup", + "Backup stopped.": "Backup wstrzymany.", + "Could not stop the backup.": "Nie udało się wstrzymać backupu.", + + // Shell + "Could not prepare the AppleScript.": "Nie udało się przygotować AppleScript.", + "Unknown authorization error.": "Nieznany błąd autoryzacji.", + + // MenuBarContentView + "Waiting to upload": "Czeka na wysłanie", + "%@ files": "%@ plików", + "nothing": "nic", + "SSD buffer": "Bufor SSD", + "Back up now": "Zrób backup teraz", + "Stop backup": "Wstrzymaj backup", + "Open CloudMachine": "Otwórz CloudMachine", + "Quit": "Zakończ", + + // DashboardView + "Healthy": "Sprawny", + "Attention": "Uwaga", + "Local SSD buffer & backup to Google Drive": + "Lokalny bufor SSD & kopia zapasowa na Google Drive", + "Refreshed %@": "Odświeżono %@", + "System Status": "Stan Systemu", + "Action needed": "Wymaga akcji", + "Buffer Size": "Rozmiar Bufora", + "Free on disk: %@": "Wolne na dysku: %@", + "%@ GB": "%@ GB", + "not measured": "nie zmierzono", + "Upload Queue": "Kolejka Wysyłki", + "%@ queued": "%@ w kolejce", + "Nothing pending": "Brak zaległości", + "rclone did not answer": "rclone nie odpowiedział", + "%@ transfers in progress": "%@ transferów w toku", + "Everything in the cloud": "Wszystko w chmurze", + "Attached": "Podpięty", + "Not attached": "Niepodpięty", + "Google Drive mounted": "Google Drive zamontowany", + "Drive disconnected": "Drive rozłączony", + "Backup in Progress": "Kopia Zapasowa w Toku", + "Files processed": "Przetworzone pliki", + "Write speed": "Prędkość zapisu", + "Operation phase": "Faza operacji", + "Required Setup Steps": "Wymagane Kroki Konfiguracji", + "Copy": "Kopiuj", + "Local Buffer & Upload Status": "Bufor Lokalny & Stan Wysyłki", + "Google Drive mount (FUSE-T)": "Montowanie Google Drive (FUSE-T)", + "Mounted": "Zamontowany", + "Inactive": "Nieaktywny", + "Backup disk image (.sparsebundle)": "Obraz dysku backupu (.sparsebundle)", + "Attached to the system": "Podpięty do systemu", + "Detached": "Odłączony", + "Buffer allocated on the SSD": "Zalokowany bufor na dysku SSD", + "Free space on the local volume": "Wolne miejsce na lokalnym wolumenie", + "Last completed backup": "Ostatnia ukończona kopia", + "Last backup watchdog run": "Ostatni przebieg czujki backupu", + "Cloud sync queue": "Kolejka synchronizacji z chmurą", + "not read": "nie odczytano", + "%@ in progress, %@ queued": "%@ w toku, %@ w kolejce", + "Everything uploaded": "Wszystko wysłane", + "File upload errors": "Błędy wysyłki plików", + "Space on Google Drive": "Miejsce na Google Drive", + "Out of space": "Brak miejsca", + "Google Drive limit": "Limit Google Drive", + "Daily 750 GB exhausted": "Dobowe 750 GB wyczerpane", + "Google Drive Credentials (OAuth 2.0)": "Poświadczenia Google Drive (OAuth 2.0)", + "Keychain OK": "Keychain OK", + "No custom keys": "Brak własnych kluczy", + "Paste client_id...": "Wklej client_id...", + "Paste client_secret...": "Wklej client_secret...", + "Save securely in the Keychain": "Zapisz bezpiecznie w Keychainie", + "Check image consistency": "Sprawdź spójność obrazu", + "Refresh": "Odśwież", + ] +} diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Storage.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Storage.swift new file mode 100644 index 0000000..bc70040 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Storage.swift @@ -0,0 +1,104 @@ +// Polish translations, keyed by the English text passed to `L10n.tr`. +extension L10nPolish { + static let storage: [String: String] = [ + // UploadState + "ACTION NEEDED": "WYMAGA REAKCJI", + "WILL PASS BY ITSELF — DO NOTHING": "MINIE SAMO — NIC NIE RÓB", + "UNKNOWN — CHECK AGAIN SHORTLY": "NIE WIADOMO — SPRAWDŹ ZA CHWILĘ", + "ALL GOOD": "W PORZĄDKU", + "Upload is not working": "Wysyłka nie działa", + "Upload stopped — no space left on Google Drive": "Wysyłka stoi — brak miejsca na Google Drive", + "%@ backup fragments not uploaded": "Nie wysłano %@ fragmentów kopii", + "Upload cannot keep up with writes": "Wysyłka nie nadąża za zapisem", + "Upload paused — Google daily limit": "Wysyłka wstrzymana — dobowy limit Google", + "Uploading to Google Drive — %@ queued": "Wysyłanie na Google Drive — %@ w kolejce", + "Everything uploaded to Google Drive": "Wszystko wysłane na Google Drive", + "Unknown what is waiting in the queue": "Nie wiadomo, co czeka w kolejce", + "There is no connection to Google Drive, so backups are only made on this Mac. If this does not pass by itself within a few minutes, check the network and the connection to Drive.": + "Nie ma połączenia z Google Drive, więc kopie powstają tylko na tym Macu. Jeśli to nie minie samo w kilka minut, sprawdź sieć i połączenie z Dyskiem.", + "There is no space left on Google Drive. This will NOT pass by itself — you need to free up space on Drive. Until then Time Machine is paused, so that it does not fill up this Mac's disk.": + "Na Google Drive nie ma już miejsca. To NIE minie samo — trzeba zwolnić miejsce na Dysku. Do tego czasu Time Machine jest wstrzymany, żeby nie zapełnić dysku tego Maca.", + "%@ backup fragments could not be uploaded and rclone stopped trying. These fragments exist only on this Mac, so the backup on Drive is incomplete. This needs checking.": + "%@ fragmentów kopii nie udało się wysłać i rclone przestał próbować. Te fragmenty istnieją wyłącznie na tym Macu, więc kopia na Dysku jest niekompletna. To wymaga sprawdzenia.", + "Time Machine is writing faster than the upload goes, and the buffer has filled up. The backup will be paused until the upload catches up — this is a safeguard against filling up the disk, not a failure.": + "Time Machine pisze szybciej, niż idzie wysyłka, i bufor się zapełnił. Backup zostanie wstrzymany, aż wysyłka nadgoni — to zabezpieczenie przed zapełnieniem dysku, nie awaria.", + "Google accepts 750 GB per day and that limit has been used up. There is nothing to do: the limit renews by itself, usually within a few hours. Time Machine backups are made normally in the meantime and wait in the buffer — they will be uploaded as soon as Google starts accepting again.": + "Google przyjmuje 750 GB na dobę i ten limit został wyczerpany. Nie trzeba nic robić: limit odnawia się sam, zwykle w kilka godzin. Kopie Time Machine powstają przez ten czas normalnie i czekają w buforze — wyślą się, gdy tylko Google znów zacznie przyjmować.", + "%@ backup fragments are queued and on their way to Drive.": + "%@ fragmentów kopii czeka w kolejce i leci na Dysk.", + "Nothing is waiting in the queue — the backup on Google Drive is complete.": + "Nic nie czeka w kolejce — kopia na Google Drive jest kompletna.", + "rclone did not answer the question about the queue, so it is unknown how many backups are still waiting to be uploaded. This does not mean something broke — under load the answer can be late. It only means that right now nobody knows. If it persists, the backup cycle check will report it.": + "rclone nie odpowiedział na pytanie o kolejkę, więc nie wiadomo, ile kopii czeka jeszcze na wysłanie. To nie znaczy, że coś się zepsuło — pod obciążeniem odpowiedź potrafi się spóźnić. Znaczy tylko tyle, że w tej chwili nikt tego nie wie. Jeśli utrzymuje się dłużej, zgłosi to kontrola cyklu backupu.", + // BackupImageService + "%@: another image operation is in progress (creating, attaching, detaching or verifying) - NOTHING was done. Try again shortly.": + "%@: inna operacja na obrazie jest w toku (tworzenie, podpinanie, odpinanie albo sprawdzanie) - NIE zrobiono nic. Spróbuj za chwilę.", + "NOT ATTACHED": "BRAK", + "DEAD - in the mount table, but reads fail (errno %@); attach-image attaches it again": + "MARTWY - w tablicy montowań, ale odczyt pada (errno %@); attach-image podpina na nowo", + "UNKNOWN - in the mount table, but the readability probe did not answer within %@ s": + "NIE WIADOMO - w tablicy montowań, ale sonda czytelności nie odpowiedziała w %@ s", + "UNKNOWN - could not read the mount table": + "NIE WIADOMO - nie udało się odczytać tablicy montowań", + "Image creation": "Utworzenie obrazu", + "Drive is not mounted - start the buffer first.": + "Drive nie jest zamontowany - najpierw uruchom bufor.", + "Could not read the mount table - it is UNKNOWN whether the buffer is mounted. NOT creating the image.": + "Nie udało się odczytać tablicy montowań - NIE WIADOMO, czy bufor jest zamontowany. NIE tworzę obrazu.", + "The buffer did not come up within %@ min - NOT creating the image.": + "Bufor nie stanął w %@ min - NIE tworzę obrazu.", + "The image already exists. Deleting it erases the whole backup - do it deliberately.": + "Obraz już istnieje. Usunięcie go kasuje cały backup - zrób to świadomie.", + "The image already exists on Google Drive (the mount cache did not show it, but the remote has it). Deleting it erases the whole backup - do it deliberately.": + "Obraz już istnieje na Google Drive (cache montowania go nie pokazywał, ale zdalny go ma). Usunięcie go kasuje cały backup - zrób to świadomie.", + "Could not confirm on Google Drive that the image is not there yet (%@) - ABORTING. Creating the image over an existing backup is irreversible, so I do not start without that answer.": + "Nie udało się potwierdzić na Google Drive, że obrazu tam jeszcze nie ma (%@) - PRZERYWAM. Tworzenie obrazu na istniejącym backupie jest nieodwracalne, więc bez tej odpowiedzi nie zaczynam.", + "Could not create the image: %@": "Nie udało się utworzyć obrazu: %@", + "unknown error": "nieznany błąd", + "Created a %@ GB image, band size %@ MB.": "Utworzono obraz %@ GB, pasmo %@ MB.", + "rclone did not answer": "rclone nie odpowiedział", + "rclone lsf ended with an error": "rclone lsf zakończyło się błędem", + "Image attach": "Podpięcie obrazu", + "Drive is not mounted.": "Drive nie jest zamontowany.", + "Could not read the mount table - it is UNKNOWN whether the buffer is mounted. NOT attaching the image.": + "Nie udało się odczytać tablicy montowań - NIE WIADOMO, czy bufor jest zamontowany. NIE podpinam obrazu.", + "No image - create it first.": "Brak obrazu - najpierw go utwórz.", + "Already attached: %@": "Już podpięte: %@", + "The image is in the mount table, but the readability probe did not answer within %@ s - it is UNKNOWN whether the device is alive. NOT force-detaching and NOT attaching.": + "Obraz jest w tablicy montowań, ale sonda czytelności nie odpowiedziała w %@ s - NIE WIADOMO, czy urządzenie żyje. NIE odpinam na siłę i NIE podpinam.", + "Could not read the mount table - it is UNKNOWN whether the image is attached. NOT attaching.": + "Nie udało się odczytać tablicy montowań - NIE WIADOMO, czy obraz jest podpięty. NIE podpinam.", + "Image dead (errno %@) and could not be detached: %@": + "Obraz martwy (errno %@) i nie dał się odpiąć: %@", + "An orphaned mount point blocks the attach: %@\nRemove it and try again: sudo rmdir '%@'": + "Osierocony punkt montowania blokuje podpięcie: %@\nUsuń go i spróbuj ponownie: sudo rmdir '%@'", + "Could not attach the image: %@": "Nie udało się podpiąć obrazu: %@", + "Attached: %@": "Podpięte: %@", + "Image detach": "Odpięcie obrazu", + "Could not detach - the image is held by browsed backup snapshots that could not be unmounted:\n%@\nClose the Time Machine / Finder window on the backup and try again.": + "Nie udało się odpiąć - obraz trzymają przeglądane migawki backupu, których nie dało się odmontować:\n%@\nZamknij okno Time Machine / Findera na backupie i spróbuj ponownie.", + "Could not detach.": "Nie udało się odpiąć.", + "Detached (without waiting for the upload - the data may be local only).": + "Odpięte (bez czekania na wysyłkę - dane mogą być tylko lokalnie).", + "Detached, but the upload did NOT finish in time - do not delete the buffer.": + "Odpięte, ale wysyłka NIE zakończyła się w czasie - nie kasuj bufora.", + "Detached, but rclone ABANDONED %@ backup fragments - they exist only on this Mac and are not on Google Drive. Do not delete the buffer.": + "Odpięte, ale rclone PORZUCIŁ %@ fragmentów kopii - istnieją wyłącznie na tym Macu i na Google Drive ich nie ma. Nie kasuj bufora.", + "Detached, everything uploaded to Google Drive.": "Odpięte, wszystko wysłane na Google Drive.", + "Image verification": "Sprawdzenie obrazu", + "No image.": "Brak obrazu.", + "The image is attached - detach it before verifying.": + "Obraz jest podpięty - odepnij go przed sprawdzeniem.", + "Could not find an APFS device in the image.": "Nie znalazłem urządzenia APFS w obrazie.", + "Could not run %@ - the image's consistency REMAINS UNCHECKED.": + "Nie udało się uruchomić %@ - spójność obrazu POZOSTAJE NIESPRAWDZONA.", + "Check INTERRUPTED - device %@ disappeared midway (someone force-detached the image). This is not a result about the backup's state: the image's consistency REMAINS UNCHECKED. Repeat the check.": + "Sprawdzenie PRZERWANE - urządzenie %@ zniknęło w trakcie (ktoś odpiął obraz na siłę). To nie jest wynik o stanie backupu: spójność obrazu POZOSTAJE NIESPRAWDZONA. Powtórz sprawdzenie.", + "Image consistent.": "Obraz spójny.", + "Image INCONSISTENT: %@": "Obraz NIESPÓJNY: %@", + // BufferGuardService + "CloudMachine: upload to Drive is stalled": "CloudMachine: wysyłka na Dysk stoi", + "Upload to Google Drive is stalled - daily limit exhausted.": + "Wysyłka na Google Drive stoi - wyczerpany limit dobowy.", + ] +} diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift new file mode 100644 index 0000000..59ea720 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift @@ -0,0 +1,222 @@ +// Polish translations, keyed by the English text passed to `L10n.tr`. +extension L10nPolish { + static let system: [String: String] = [ + // DriveFolder + "'%@' is not a valid folder name: use lowercase letters, digits and dashes.": + "'%@' to nieprawidłowa nazwa folderu: użyj małych liter, cyfr i myślników.", + "This Mac already backs up to folder '%@'. Switching to '%@' would start a new, empty backup and orphan the existing one, so nothing was changed.": + "Ten Mac już robi kopię do folderu '%@'. Przejście na '%@' zaczęłoby nową, pustą kopię i osierociło istniejącą, więc nic nie zmieniono.", + "This Mac already has a CloudMachine installation, whose backup is in folder '%@'. Switching to '%@' would orphan it, so nothing was changed.": + "Ten Mac ma już instalację CloudMachine, której kopia leży w folderze '%@'. Przejście na '%@' by ją osierociło, więc nic nie zmieniono.", + "Could not save the folder name to %@: %@": + "Nie udało się zapisać nazwy folderu w %@: %@", + "Homebrew is not installed. Install it manually: https://brew.sh": + "Homebrew nie jest zainstalowany. Zainstaluj go ręcznie: https://brew.sh", + "Installing rclone failed: %@": + "Instalacja rclone nie powiodła się: %@", + "unknown error": + "nieznany błąd", + "Installed rclone.": + "Zainstalowano rclone.", + "Own Google credentials: set.": + "Własne poświadczenia Google: ustawione.", + "INCOMPLETE: %@ is missing - rclone will use rclone's shared client_id anyway.": + "NIEPEŁNE: brakuje %@ - rclone i tak użyje współdzielonego client_id rclone.", + "No own credentials - rclone uses the shared client_id, rate-limited jointly and being retired in 2026.": + "Brak własnych poświadczeń - rclone używa współdzielonego client_id, limitowanego wspólnie i wycofywanego w 2026.", + "Empty value - not saving.": + "Pusta wartość - nie zapisuję.", + "Keychain refused: %@": + "Keychain odmówił: %@", + "DIRTY-TREE": + "BRUDNE-DRZEWO", + "rclone with mount support": + "rclone z obsługą montowania", + "Could not determine the FUSE-T version.": + "Nie udało się ustalić wersji FUSE-T.", + "Could not download the FUSE-T package.": + "Nie udało się pobrać pakietu FUSE-T.", + "Could not unpack the FUSE-T package.": + "Nie udało się rozpakować pakietu FUSE-T.", + "The FUSE-T package does not contain the expected files.": + "W pakiecie FUSE-T nie ma spodziewanych plików.", + "Installing FUSE-T failed: %@": + "Instalacja FUSE-T nie powiodła się: %@", + "Installed FUSE-T %@ inside CloudMachine (%@).\nThe separate fuse-t application is no longer needed - you can remove it:\n sudo \"/Library/Application Support/fuse-t/uninstall.sh\"": + "Zainstalowano FUSE-T %@ wewnątrz CloudMachine (%@).\nOsobna aplikacja fuse-t nie jest już potrzebna - możesz ją usunąć:\n sudo \"/Library/Application Support/fuse-t/uninstall.sh\"", + "Cannot create the working directory: %@": + "Nie mogę utworzyć katalogu roboczego: %@", + "Could not read the rclone version number.": + "Nie udało się odczytać numeru wersji rclone.", + "Could not download %@.": + "Nie udało się pobrać %@.", + "No entry for %@ in SHA256SUMS.": + "Brak wpisu dla %@ w SHA256SUMS.", + "Could not compute the checksum.": + "Nie udało się policzyć sumy kontrolnej.", + "SHA256 checksum does not match - NOT installing.\n expected: %@\n computed: %@": + "Suma SHA256 się nie zgadza - NIE instaluję.\n oczekiwana: %@\n policzona: %@", + "Could not unpack the archive.": + "Nie udało się rozpakować archiwum.", + "Could not install the binary: %@": + "Nie udało się zainstalować binarki: %@", + "Installed rclone %@ in %@ (SHA256 checksum matches).": + "Zainstalowano rclone %@ w %@ (suma SHA256 zgodna).", + "%@ did not respond within the allotted time (the process was left orphaned in the background).": + "%@ nie odpowiedziało w wyznaczonym czasie (proces został osierocony w tle).", + "Cannot launch %@: %@": + "Nie można uruchomić %@: %@", + "Remote '%@' already exists and was NOT touched.\nOverwriting it replaces the token and the permission scope; a credential with the 'drive.file' scope does not see files created by the previous one, so the existing backup becomes unreachable.\nIf you really want to replace it, first back up ~/.config/rclone/rclone.conf and run again with --replace-existing.": + "Remote '%@' już istnieje i NIE został ruszony.\nNadpisanie go podmienia token i zakres uprawnień; poświadczenie z zakresem 'drive.file' nie widzi plików założonych przez poprzednie, więc istniejący backup staje się nieosiągalny.\nJeśli naprawdę chcesz go zastąpić, zrób najpierw kopię ~/.config/rclone/rclone.conf i uruchom ponownie z --replace-existing.", + "rclone authorize failed (%@). If that binary is missing, start with: cloudmachine-agent install-rclone.": + "rclone authorize nie powiodło się (%@). Jeśli tej binarki nie ma, zacznij od: cloudmachine-agent install-rclone.", + "Could not read the token from the output of rclone authorize.": + "Nie udało się odczytać tokenu z wyniku rclone authorize.", + "rclone config create failed.": + "rclone config create nie powiodło się.", + "Connected to Google Drive, but could not create the folder '%@' - without it the mount will not start. Check the account permissions and try again.": + "Połączono z Google Drive, ale nie udało się utworzyć folderu '%@' - bez niego montowanie nie ruszy. Sprawdź uprawnienia konta i spróbuj ponownie.", + "Connected to Google Drive as remote '%@', folder %@.": + "Połączono z Google Drive jako remote '%@', folder %@.", + "Google Drive mount is not working": + "Montowanie Google Drive nie działa", + "Without it the backup image is unreachable and Time Machine has nowhere to write.": + "Bez niego obraz backupu jest nieosiągalny i Time Machine nie ma gdzie pisać.", + "Unknown whether the Google Drive mount is working": + "Nie wiadomo, czy montowanie Google Drive działa", + "Could not read the mount table. That does not mean the Drive is unmounted - it means nobody has checked. Without this answer there is no way to tell whether backups have anywhere to go.": + "Nie udało się odczytać tablicy montowań. To nie znaczy, że Dysk jest odmontowany - znaczy, że nikt tego nie sprawdził. Bez tej odpowiedzi nie da się stwierdzić, czy kopie mają gdzie powstawać.", + "The backup image is attached, but DEAD (errno %@)": + "Obraz backupu jest podpięty, ale MARTWY (errno %@)", + "The image device stopped returning data - Time Machine sees it as a disconnected disk. Fix: cloudmachine-agent attach-image (force-detaches and attaches again).": + "Urządzenie obrazu przestało oddawać dane - Time Machine widzi to jako odłączony dysk. Naprawa: cloudmachine-agent attach-image (odpina na siłę i podpina na nowo).", + "The backup image is not attached": + "Obraz backupu nie jest podpięty", + "Time Machine cannot see the destination %@.": + "Time Machine nie widzi celu %@.", + "Unknown whether the backup image returns data": + "Nie wiadomo, czy obraz backupu oddaje dane", + "The image %@ is listed in the mount table, but the readability probe did not answer within %@ s - that is how a read blocked on a dead FUSE-T mount behaves. This is NOT proof that the image is dead, so do NOT force-detach it: `attach-image` deliberately does nothing in that case, because detaching a live device abandons data waiting to be uploaded. First check whether rclone responds (cloudmachine-agent drive-status) and whether the gdrive-buffer agent is alive.": + "Obraz %@ figuruje w tablicy montowań, ale sonda czytelności nie odpowiedziała w %@ s - tak zachowuje się odczyt zablokowany na martwym montowaniu FUSE-T. To NIE jest dowód, że obraz jest martwy, więc NIE odpinaj go na siłę: `attach-image` świadomie nic wtedy nie robi, bo odpięcie żywego urządzenia porzuca dane czekające na wysyłkę. Sprawdź najpierw, czy rclone odpowiada (cloudmachine-agent drive-status) i czy agent gdrive-buffer żyje.", + "Unknown whether the backup image is attached": + "Nie wiadomo, czy obraz backupu jest podpięty", + "Could not read the mount table, so the state of the image %@ is UNKNOWN. Do not attach it blindly - first check whether `mount` responds at all (with a dead FUSE-T mount it can hang).": + "Nie udało się odczytać tablicy montowań, więc stan obrazu %@ jest NIEZNANY. Nie podpinaj go na oślep - najpierw sprawdź, czy `mount` w ogóle odpowiada (przy martwym montowaniu FUSE-T potrafi wisieć).", + "Time Machine does not point to CloudMachine": + "Time Machine nie wskazuje na CloudMachine", + "The backup destination was changed or unregistered - backups are not being made.": + "Cel backupu został przestawiony albo wyrejestrowany - kopie nie powstają.", + "tmutil is not responding - unknown where the backup goes": + "tmutil nie odpowiada - nie wiadomo, gdzie idzie backup", + "Reading the Time Machine destination did not return within %@ s. That is how tmutil behaves when blocked on a dead Google Drive mount. Fix: cloudmachine-agent attach-image, and if that does not help - restart the gdrive-buffer agent.": + "Odczyt celu Time Machine nie wrócił w %@ s. Tak zachowuje się tmutil zablokowany na martwym montowaniu Google Drive. Naprawa: cloudmachine-agent attach-image, a gdy to nie pomoże - restart agenta gdrive-buffer.", + "No successful backup for %@": + "Brak udanej kopii od %@", + "Last COMPLETED backup: %@. The cycle is hourly, so that is %@ missed runs.": + "Ostatnia ZAKOŃCZONA kopia: %@. Cykl jest godzinowy, więc to %@ pominiętych przebiegów.", + "There is NOT A SINGLE successful backup": + "Nie ma ANI JEDNEJ udanej kopii", + "The Time Machine preferences contain no date of a completed backup for this destination.": + "Preferencje Time Machine nie zawierają żadnej daty zakończonego backupu dla tego celu.", + "The last backup attempt did not end in a backup": + "Ostatnia próba backupu nie skończyła się kopią", + "The attempt at %@ is newer than the last successful backup at %@.": + "Próba %@ jest nowsza niż ostatnia udana kopia %@.", + "Time Machine reports an error in the last run (RESULT=%@)": + "Time Machine zgłasza błąd ostatniego przebiegu (RESULT=%@)", + "A non-zero RESULT in the Time Machine preferences means the run did not succeed.": + "Niezerowy RESULT w preferencjach Time Machine znaczy, że przebieg się nie udał.", + "rclone failed to upload %@ files": + "rclone nie wysłał %@ plików", + "These image bands exist only locally. The backup on Google Drive is INCOMPLETE and may not open.": + "Te pasma obrazu istnieją tylko lokalnie. Kopia na Google Drive jest NIEPEŁNA i może się nie otworzyć.", + "Buffer full of nothing but unsent data": + "Bufor pełny samymi niewysłanymi danymi", + "rclone has nothing left to evict from the buffer - the upload cannot keep up or has stalled.": + "rclone nie ma już czego usunąć z bufora - wysyłka nie nadąża albo stoi.", + "The rclone control interface is not responding": + "Interfejs sterujący rclone nie odpowiada", + "Without it there is no way to check whether anything reached the Drive - the buffer guard is blind then.": + "Bez niego nie da się sprawdzić, czy cokolwiek doleciało na Dysk - dozorca bufora jest wtedy ślepy.", + "Google Drive is running out of space (%@ GB)": + "Kończy się miejsce na Google Drive (%@ GB)", + "Once it runs out, rclone exits with a storageQuotaExceeded error, the mount disappears and backups stop being made. At a growth of ~600 MB per hourly cycle that is about %@ days.": + "Po wyczerpaniu rclone kończy pracę z błędem storageQuotaExceeded, montowanie znika i backupy przestają powstawać. Przy przyroście ~600 MB na cykl godzinowy to około %@ dni.", + "The Mac's disk is running out of space (%@ GB)": + "Kończy się miejsce na dysku Maca (%@ GB)", + "The upload buffer lives on this disk. When it fills up, the guard pauses Time Machine, and with no space left at all rclone has nowhere to put data waiting to be uploaded.": + "Bufor wysyłki leży na tym dysku. Gdy się zapełni, dozorca wstrzyma Time Machine, a przy całkowitym braku miejsca rclone nie ma gdzie odłożyć danych czekających na wysłanie.", + "Cannot read the Time Machine preferences": + "Nie da się odczytać preferencji Time Machine", + "%@ is unreadable - most often Full Disk Access is missing. Without this file it is UNKNOWN when the last backup was made, so we treat it as a failure, not as the absence of a problem.": + "%@ jest nieczytelny - najczęściej brak Pełnego dostępu do dysku. Bez tego pliku NIE WIADOMO, kiedy ostatnio powstała kopia, więc traktujemy to jak awarię, a nie jak brak problemu.", + "Cannot measure free space on the Mac's disk": + "Nie da się zmierzyć wolnego miejsca na dysku Maca", + "statfs('/System/Volumes/Data') returned an error. The buffer guard will then not pause Time Machine before the disk fills up, because it does not know the number it bases that decision on.": + "statfs('/System/Volumes/Data') zwrócił błąd. Dozorca bufora nie wstrzyma wtedy Time Machine przed zapełnieniem dysku, bo nie zna liczby, na której opiera tę decyzję.", + "%@ days": + "%@ dni", + "CloudMachine: backup is not working": + "CloudMachine: backup nie działa", + "unknown reason": + "nieznany powód", + "osascript did not show the notification (permissions or no graphical session)": + "osascript nie pokazał powiadomienia (uprawnienia albo brak sesji graficznej)", + "MISSING": + "BRAK", + "UNKNOWN - could not read the mount table": + "NIE WIADOMO - nie udało się odczytać tablicy montowań", + "NOT MEASURED - the buffer guard will not pause Time Machine before the disk fills up": + "NIE ZMIERZONO - dozorca bufora nie wstrzyma Time Machine przed zapełnieniem dysku", + "NOT MEASURED - rclone did not respond, and walking the buffer directory failed": + "NIE ZMIERZONO - rclone nie odpowiedział, a obchód katalogu bufora się nie udał", + "%@ GB of %@G": + "%@ GB z %@G", + "UNKNOWN - the rclone control interface did not respond (the buffer guard will neither pause nor resume Time Machine on this basis)": + "NIE WIADOMO - interfejs sterujący rclone nie odpowiedział (dozorca bufora nie wstrzyma ani nie wznowi Time Machine na tej podstawie)", + "~%@ GB (%@ items)": + "~%@ GB (%@ pozycji)", + "UNDELIVERED ALARM: %@": + "NIEDORĘCZONY ALARM: %@", + "from %@, reason: %@": + "z %@, powód: %@", + "The system notification was not delivered - you will see this alarm ONLY here.": + "Powiadomienie systemowe nie doszło - ten alarm zobaczysz TYLKO tutaj.", + "%@ (%@ ago)": + "%@ (%@ temu)", + "%@ - marker from the FUTURE": + "%@ - znacznik z PRZYSZŁOŚCI", + "%@ (%@ ago) - THE WATCHDOG MAY NOT BE RUNNING": + "%@ (%@ temu) - CZUJKA MOŻE NIE CHODZIĆ", + "NEVER - the watchdog has not recorded a single run": + "NIGDY - czujka nie zapisała żadnego przebiegu", + "The image was not detached before reloading the agents - aborting, so as not to lose data waiting in the buffer.\n%@": + "Nie odpięto obrazu przed przeładowaniem agentów - przerywam, żeby nie stracić danych czekających w buforze.\n%@", + "Could not find the launchd/ directory with templates.": + "Nie znaleziono katalogu launchd/ z szablonami.", + "Could not find the compiled cloudmachine-agent binary.": + "Nie znaleziono skompilowanej binarki cloudmachine-agent.", + "ABORTED: could not put cloudmachine-agent in a stable location\n(%@) - most often a lack of space or permissions.\nNOT installing agents pointing at %@: that path\ndisappears on the next `swift build` or `git clean`, and backups stop\nwithout any visible signal.": + "PRZERWANO: nie udało się odłożyć cloudmachine-agent w stabilnym miejscu\n(%@) - najczęściej brak miejsca albo uprawnień.\nNIE instaluję agentów wskazujących na %@: ta ścieżka\nznika przy kolejnym `swift build` albo `git clean`, a backupy ustają\nbez żadnego widocznego sygnału.", + "ABORTED: %@": + "PRZERWANO: %@", + "Could not list the templates in %@.": + "Nie udało się wylistować szablonów w %@.", + "could not read the template %@": + "nie dało się odczytać szablonu %@", + "could not write %@ (lack of space or permissions)": + "nie udało się zapisać %@ (brak miejsca albo uprawnień)", + "launchctl load refused to load %@": + "launchctl load odmówił załadowania %@", + "NOT A SINGLE agent was loaded.": + "Nie załadowano ANI JEDNEGO agenta.", + "Only these went in: %@.": + "Weszły tylko: %@.", + "Agent installation INCOMPLETE - %@ of %@ did not go in:\n%@\n%@\nEvery missing agent is a function that has silently stopped working (buffer-guard watches the disk, backup-health reports failures). Fix the cause and repeat the installation.": + "Instalacja agentów NIEPEŁNA - nie weszło %@ z %@:\n%@\n%@\nKażdy brakujący agent to funkcja, która przestała działać po cichu (buffer-guard pilnuje dysku, backup-health zgłasza awarie). Napraw powód i powtórz instalację.", + "Could not load any launchd agent.": + "Nie udało się załadować żadnego agenta launchd.", + "Installed agents: %@": + "Zainstalowano agentów: %@", + ] +} diff --git a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift index 5b1a72d..7b4af52 100644 --- a/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/LaunchdInstaller.swift @@ -1,35 +1,36 @@ import Foundation -/// Generuje pliki `.plist` z podstawiona sciezka do skompilowanej binarki -/// `cloudmachine-agent` i instaluje je jako LaunchAgents (sesja zalogowanego -/// uzytkownika) - instaluje generycznie KAZDY szablon `*.plist.template` -/// znaleziony w `launchd/` (obecnie `verify-watchdog` i `archive-watchdog`). -/// W usunietej wczesniejszej architekturze sieciowego mountu NFS istnialy tu -/// dodatkowo szablony dla mount/backup/quota, ktore odpadly wraz z nia. +/// Generates `.plist` files with the path to the compiled `cloudmachine-agent` +/// binary substituted in and installs them as LaunchAgents (the logged-in +/// user's session) - generically installs EVERY `*.plist.template` template +/// found in `launchd/` (currently `verify-watchdog` and `archive-watchdog`). +/// The removed earlier network NFS mount architecture additionally had +/// templates for mount/backup/quota here, which went away with it. public enum LaunchdInstaller { public static var launchAgentsDir: URL { FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents") } - /// Nazwa procesu interfejsu - binarka w `Contents/MacOS`, nie bundel. + /// Process name of the interface - the binary in `Contents/MacOS`, not the bundle. static let appProcessName = "CloudMachine.app/Contents/MacOS/CloudMachine" - /// Zamyka dzialajacy interfejs, zeby launchd mogl wystartowac NOWY. + /// Closes the running interface so that launchd can start a NEW one. /// - /// Najpierw grzecznie (`osascript quit`), zeby aplikacja zdazyla posprzatac; - /// dopiero potem twardo. Interfejs nie robi backupow - robia je agenty - wiec - /// jego ubicie niczego nie przerywa. + /// Politely first (`osascript quit`), so the app has time to clean up; only + /// then forcefully. The interface does not make backups - the agents do - so + /// killing it interrupts nothing. static func terminateRunningApp() async { let running = try? await ProcessRunner.run("/usr/bin/pgrep", ["-f", appProcessName]) guard running?.succeeded == true, !(running?.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) else { return } - CMLogger.log("Instalacja agentow: zamykam dzialajacy interfejs, zeby wstal na nowej binarce") + CMLogger.log( + "Agent installation: closing the running interface so it comes up on the new binary") _ = try? await ProcessRunner.run( "/usr/bin/osascript", ["-e", "quit app \"CloudMachine\""], timeout: 30) - // Dajemy chwile na czyste zamkniecie, potem sprawdzamy i dobijamy. + // Give it a moment to close cleanly, then check and finish it off. for _ in 0..<10 { try? await Task.sleep(nanoseconds: 500_000_000) let still = try? await ProcessRunner.run("/usr/bin/pgrep", ["-f", appProcessName]) @@ -38,107 +39,107 @@ public enum LaunchdInstaller { && !(still?.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) if !alive { return } } - CMLogger.log("Instalacja agentow: interfejs nie zamknal sie sam - koncze go twardo") + CMLogger.log( + "Agent installation: the interface did not close by itself - terminating it forcefully") _ = try? await ProcessRunner.run("/usr/bin/pkill", ["-f", appProcessName], timeout: 30) } 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. + // So that the commands from the documentation work from the terminal + // instead of ending in "command not found" - the binary sits inside the + // app bundle. 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. + // Installation reloads the agents, including the one holding the mount. + // Doing that with the image attached pulls the floor out from under it + // mid-way - and detaching is a write that still has to reach the Drive. I + // made this mistake three times in a row, so we no longer rely on + // remembering it. if BackupImageService.isAttached { - // Martwy obraz (patrz `ImageProbe`) nie da sie odpiac grzecznie - - // `hdiutil detach` bez `-force` odmawia, a instalacja stanelaby na - // dokladnie tym stanie, ktory ma naprawic. - // WYLACZNIE `.dead`, a nie `!isUsable`. `.unknown` tez nie jest - // "uzywalny", ale znaczy "nie wiem" - a `detach -force` na urzadzeniu, - // ktore moze byc zywe, porzuca zapisy czekajace na wysylke na Dysk. - // Od 26.09.2026 `.unknown` jest tu OSIAGALNY (sonda czytelnosci ma limit - // czasu i po jego przekroczeniu oddaje wlasnie ten stan), wiec roznica - // przestala byc teoretyczna. Bez `-force` `hdiutil detach` po prostu - // odmowi, instalacja przerwie sie z komunikatem i nikt nie straci danych. + // A dead image (see `ImageProbe`) cannot be detached politely - + // `hdiutil detach` without `-force` refuses, and the installation would + // get stuck on exactly the state it is meant to fix. + // ONLY `.dead`, not `!isUsable`. `.unknown` is not "usable" either, but + // it means "I do not know" - and `detach -force` on a device that may be + // alive abandons writes waiting to be uploaded to the Drive. Since + // 26.09.2026 `.unknown` is REACHABLE here (the readability probe has a + // time limit and, once it is exceeded, returns exactly this state), so + // the difference is no longer theoretical. Without `-force`, + // `hdiutil detach` simply refuses, the installation aborts with a + // message and nobody loses data. var force = false if case .dead = await BackupImageService.attachment() { force = true } CMLogger.log( - "Instalacja agentow: najpierw odpinam obraz\(force ? " (martwy - na sile)" : "") i czekam na wysylke" + "Agent installation: detaching the image first\(force ? " (dead - forcefully)" : "") and waiting for the upload" ) let detached = await BackupImageService.detach(force: force) - CMLogger.log("Instalacja agentow: \(detached.message)") + CMLogger.log("Agent installation: \(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) - """) + message: L10n.tr( + "The image was not detached before reloading the agents - aborting, so as not to lose data waiting in the buffer.\n%@", + detached.message)) } } guard let templatesDir = CMPaths.launchdTemplatesDir else { return CMActionResult( - succeeded: false, message: "Nie znaleziono katalogu launchd/ z szablonami.") + succeeded: false, message: L10n.tr("Could not find the launchd/ directory with templates.")) } guard let resolvedAgentBin = CMPaths.agentBinaryPath else { return CMActionResult( - succeeded: false, message: "Nie znaleziono skompilowanej binarki cloudmachine-agent.") + succeeded: false, + message: L10n.tr("Could not find the compiled cloudmachine-agent binary.")) } - // PRZERYWAMY, nie ostrzegamy. Wczesniej nieudane odlozenie binarki - // konczylo sie wpisem "OSTRZEZENIE" w logu i dokonczeniem instalacji - - // launchd dostawal sciezke do `.build/`, ktora kolejny `swift build` albo - // `git clean` kasuje spod dzialajacych agentow. Agent, ktory znika, to - // backup, ktory przestaje powstawac, a jedynym sladem jest linia w logu, - // do ktorej nikt nie zaglada. Instalacja bez stabilnej binarki jest gorsza - // niz brak instalacji, bo wyglada na udana. + // We ABORT, not warn. Previously a failure to stash the binary ended with + // a "WARNING" entry in the log and the installation being completed - + // launchd got a path into `.build/`, which the next `swift build` or `git + // clean` deletes from under the running agents. An agent that disappears + // is a backup that stops being made, and the only trace is a log line + // nobody looks at. An installation without a stable binary is worse than + // no installation, because it looks successful. let agentBin: URL do { agentBin = try stableAgentBinaryPath(resolvedFrom: resolvedAgentBin) } catch let error as NoStableBinary { return CMActionResult( succeeded: false, - message: """ - PRZERWANO: nie udalo sie odlozyc cloudmachine-agent w stabilnym miejscu - (\(error.attemptedPath)) - najczesciej brak miejsca albo uprawnien. - NIE instaluje agentow wskazujacych na \(error.fallbackPath): ta sciezka - znika przy kolejnym `swift build` albo `git clean`, a backupy ustaja - bez zadnego widocznego sygnalu. - """) + message: L10n.tr( + "ABORTED: could not put cloudmachine-agent in a stable location\n(%@) - most often a lack of space or permissions.\nNOT installing agents pointing at %@: that path\ndisappears on the next `swift build` or `git clean`, and backups stop\nwithout any visible signal.", + error.attemptedPath, error.fallbackPath)) } catch { return CMActionResult( - succeeded: false, message: "PRZERWANO: \(error.localizedDescription)") + succeeded: false, message: L10n.tr("ABORTED: %@", error.localizedDescription)) } try? FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true) - // Migracja: starsza wersja instalowala oddzielny agent - // "com.renacode.cloudmachine.mount", zastapiony dawno przez - // mount-watchdog - usuwamy, jesli nadal zaladowany na czyims Maku. + // Migration: an older version installed a separate agent + // "com.renacode.cloudmachine.mount", long since replaced by + // mount-watchdog - remove it if it is still loaded on someone's Mac. let oldMountPlist = launchAgentsDir.appendingPathComponent( "com.renacode.cloudmachine.mount.plist") if FileManager.default.fileExists(atPath: oldMountPlist.path) { CMLogger.log( - "Usuwam przestarzaly agent com.renacode.cloudmachine.mount (zastapiony przez mount-watchdog)." + "Removing the obsolete agent com.renacode.cloudmachine.mount (replaced by mount-watchdog)." ) _ = try? await ProcessRunner.run("/bin/launchctl", ["unload", oldMountPlist.path]) try? FileManager.default.removeItem(at: oldMountPlist) } - // Interfejs trzeba UBIC, zanim launchd wystartuje go na nowo. + // The interface has to be KILLED before launchd starts it again. // - // Agent uruchamia go przez `open -a`, a `open -a` na DZIALAJACEJ aplikacji - // tylko ja uaktywnia - nie podmienia. Dzialajacy proces trzyma stary, - // odlaczony plik wykonywalny (inode sprzed podmiany bundla) i chodzi na nim - // do wylogowania albo restartu Maca. + // The agent starts it via `open -a`, and `open -a` on a RUNNING application + // only activates it - it does not replace it. The running process holds + // the old, unlinked executable (the inode from before the bundle was + // replaced) and keeps running on it until logout or a Mac restart. // - // Zaobserwowane 13 wrz 2026: po DWoCH wdrozeniach pasek menu wciaz pokazywal - // "dysk niepodpiety", bo interfejs byl z 12 wrz - inode procesu 1129507643 - // wobec 1129717794 na dysku. Wersja z CLI byla juz nowa, wiec CLI i GUI - // mowily co innego o tej samej maszynie. + // Observed 13 Sep 2026: after TWO deployments the menu bar still showed + // "disk not attached", because the interface was from 12 Sep - process + // inode 1129507643 versus 1129717794 on disk. The CLI version was already + // new, so the CLI and the GUI said different things about the same + // machine. await terminateRunningApp() guard @@ -146,7 +147,8 @@ public enum LaunchdInstaller { at: templatesDir, includingPropertiesForKeys: nil) else { return CMActionResult( - succeeded: false, message: "Nie udalo sie wylistowac szablonow w \(templatesDir.path).") + succeeded: false, + message: L10n.tr("Could not list the templates in %@.", templatesDir.path)) } return installVerdict( @@ -154,11 +156,12 @@ public enum LaunchdInstaller { templates: templates, into: launchAgentsDir, agentBin: agentBin, logDir: CMPaths.logDir)) } - /// Co weszlo i co NIE weszlo - z powodem, po jednym na agenta. + /// What went in and what did NOT - with a reason, one per agent. /// - /// Do 25.09.2026 zbieralismy tylko `installedLabels`, a porazki nie zostawialy - /// sladu w wyniku: nieczytelny szablon szedl przez `continue`, nieudany zapis - /// przez `try?`, a nieudany `launchctl load` po prostu nie dopisywal etykiety. + /// Until 25.09.2026 we collected only `installedLabels`, and failures left no + /// trace in the result: an unreadable template went through `continue`, a + /// failed write through `try?`, and a failed `launchctl load` simply did not + /// add the label. struct InstallOutcome: Equatable { struct Failure: Equatable { var label: String @@ -169,15 +172,16 @@ public enum LaunchdInstaller { var failed: [Failure] = [] } - /// Generuje `.plist` z szablonow i przeladowuje agentow, ZBIERAJAC porazki. + /// Generates `.plist` files from the templates and reloads the agents, + /// COLLECTING failures. /// - /// Czytanie szablonu, zapis i przeladowanie sa podmienialne, bo inaczej nie da - /// sie wstrzyknac ZNANEJ ZLEJ probki - nieczytelnego szablonu, zapisu bez - /// uprawnien, `launchctl` odmawiajacego zaladowania - a wlasnie w obsludze - /// tych trzech przypadkow siedziala usterka. Test podstawia je zamiast pisac - /// do prawdziwego `~/Library/LaunchAgents` i przeladowywac agentow tej - /// maszyny, czyli zamiast rozbierac dzialajacy backup, zeby sprawdzic - /// komunikat o bledzie. + /// Reading the template, writing and reloading are replaceable, because + /// otherwise a KNOWN BAD sample cannot be injected - an unreadable template, + /// a write without permission, `launchctl` refusing to load - and the defect + /// was precisely in the handling of these three cases. The test substitutes + /// them instead of writing to the real `~/Library/LaunchAgents` and reloading + /// this machine's agents, i.e. instead of taking the working backup apart to + /// check an error message. static func installAgents( templates: [URL], into destinationDir: URL, @@ -196,124 +200,122 @@ public enum LaunchdInstaller { template.deletingPathExtension().lastPathComponent) let label = destURL.deletingPathExtension().lastPathComponent - let szablon: String + let templateText: String do { - szablon = try read(template) + templateText = try read(template) } catch { - // Wczesniej: `guard ... else { continue }`. Szablon, ktorego nie dalo - // sie przeczytac, wypadal z instalacji BEZ SLADU - ani w logu, ani - // w wyniku - a `buffer-guard` jest jedyna ochrona dysku na tej maszynie. - let powod = "nie dalo sie odczytac szablonu \(template.lastPathComponent)" - outcome.failed.append(.init(label: label, reason: powod)) - log("NIE zainstalowano \(label): \(powod)") + // Previously: `guard ... else { continue }`. A template that could not + // be read dropped out of the installation WITHOUT A TRACE - neither in + // the log nor in the result - and `buffer-guard` is the only protection + // of the disk on this machine. + let reason = L10n.tr("could not read the template %@", template.lastPathComponent) + outcome.failed.append(.init(label: label, reason: reason)) + log("NOT installed \(label): \(reason)") continue } - var content = szablon.replacingOccurrences(of: "__CM_AGENT_BIN__", with: agentBin.path) + var content = templateText.replacingOccurrences(of: "__CM_AGENT_BIN__", with: agentBin.path) content = content.replacingOccurrences(of: "__CM_LOG_DIR__", with: logDir.path) do { try write(content, destURL) } catch { - // `continue` jest tu ISTOTNY, nie porzadkowy. Wczesniej zapis szedl - // przez `try?` i po nieudanym zapisie lecialo `launchctl load` na - // STARYM pliku .plist, ktory nadal lezy w ~/Library/LaunchAgents. - // `launchctl` konczyl sie kodem 0, agent ladowal na wynikowej liscie - // i instalacja meldowala sukces - przy launchd chodzacym na - // poprzedniej wersji, byc moze wskazujacej na binarke, ktorej juz nie - // ma. Sukces jest wtedy gorszy od porazki, bo nikt nie szuka. - let powod = "nie udalo sie zapisac \(destURL.path) (brak miejsca albo uprawnien)" - outcome.failed.append(.init(label: label, reason: powod)) - log("NIE zainstalowano \(label): \(powod) - NIE przeladowuje, zeby nie zaliczyc starego") + // `continue` is ESSENTIAL here, not cosmetic. Previously the write went + // through `try?` and after a failed write `launchctl load` ran on the + // OLD .plist file, which still lies in ~/Library/LaunchAgents. + // `launchctl` exited with code 0, the agent landed on the result list + // and the installation reported success - with launchd running the + // previous version, possibly pointing at a binary that no longer + // exists. Success is then worse than failure, because nobody looks. + let reason = L10n.tr( + "could not write %@ (lack of space or permissions)", destURL.path) + outcome.failed.append(.init(label: label, reason: reason)) + log("NOT installed \(label): \(reason) - NOT reloading, so as not to count the old one") continue } - log("Wygenerowano \(destURL.path)") + log("Generated \(destURL.path)") if await reload(destURL) { outcome.installed.append(label) - log("Zaladowano \(label) przez launchctl") + log("Loaded \(label) via launchctl") } else { - let powod = "launchctl load odmowil zaladowania \(destURL.lastPathComponent)" - outcome.failed.append(.init(label: label, reason: powod)) - log("NIE zaladowano \(label): \(powod)") + let reason = L10n.tr("launchctl load refused to load %@", destURL.lastPathComponent) + outcome.failed.append(.init(label: label, reason: reason)) + log("NOT loaded \(label): \(reason)") } } return outcome } - /// Werdykt calej instalacji - czysty, zeby dal sie sprawdzic testem. + /// Verdict of the whole installation - pure, so it can be tested. /// - /// JEDEN udany agent wystarczal do `succeeded: true` i do komunikatu - /// "Zainstalowano agentow: ...", ktory wymienial wylacznie te udane. - /// Zaobserwowany skutek: `buffer-guard` nie ladowal sie, instalator meldowal - /// sukces, jedyna ochrona dysku nie dzialala i nikt o tym nie wiedzial - a - /// brakujacej nazwy na liscie nie widzi nikt, kto nie zna listy z pamieci. + /// ONE successful agent was enough for `succeeded: true` and for the message + /// "Installed agents: ...", which listed only the successful ones. The + /// observed result: `buffer-guard` did not load, the installer reported + /// success, the only protection of the disk was not working and nobody knew + /// - and nobody who does not know the list by heart sees a name missing from + /// it. /// - /// Ten sam powod, co przy `stableAgentBinaryPath`: instalacja niepelna jest - /// gorsza niz brak instalacji, bo wyglada na udana. + /// The same reason as with `stableAgentBinaryPath`: an incomplete + /// installation is worse than none, because it looks successful. static func installVerdict(_ outcome: InstallOutcome) -> CMActionResult { guard outcome.failed.isEmpty else { - let lista = outcome.failed.map { " - \($0.label): \($0.reason)" }.joined(separator: "\n") - let weszly = + let list = outcome.failed.map { " - \($0.label): \($0.reason)" }.joined(separator: "\n") + let wentIn = outcome.installed.isEmpty - ? "Nie zaladowano ANI JEDNEGO agenta." - : "Weszly tylko: \(outcome.installed.joined(separator: ", "))." + ? L10n.tr("NOT A SINGLE agent was loaded.") + : L10n.tr("Only these went in: %@.", outcome.installed.joined(separator: ", ")) return CMActionResult( succeeded: false, - message: """ - Instalacja agentow NIEPELNA - nie weszlo \(outcome.failed.count) \ - z \(outcome.failed.count + outcome.installed.count): - \(lista) - \(weszly) - Kazdy brakujacy agent to funkcja, ktora przestala dzialac po cichu \ - (buffer-guard pilnuje dysku, backup-health zglasza awarie). Napraw \ - powod i powtorz instalacje. - """) + message: L10n.tr( + "Agent installation INCOMPLETE - %@ of %@ did not go in:\n%@\n%@\nEvery missing agent is a function that has silently stopped working (buffer-guard watches the disk, backup-health reports failures). Fix the cause and repeat the installation.", + "\(outcome.failed.count)", "\(outcome.failed.count + outcome.installed.count)", list, + wentIn)) } guard !outcome.installed.isEmpty else { return CMActionResult( - succeeded: false, message: "Nie udalo sie zaladowac zadnego agenta launchd.") + succeeded: false, message: L10n.tr("Could not load any launchd agent.")) } return CMActionResult( succeeded: true, - message: "Zainstalowano agentow: \(outcome.installed.joined(separator: ", "))") + message: L10n.tr("Installed agents: %@", outcome.installed.joined(separator: ", "))) } - /// Przeladowanie jednego agenta: `unload` (moze nie byc zaladowany - dlatego - /// wynik ignorujemy), potem `load -w`. `true` tylko gdy `load` sie UDAL. + /// Reloading one agent: `unload` (it may not be loaded - that is why we + /// ignore the result), then `load -w`. `true` only when `load` SUCCEEDED. private static func launchctlReload(_ plist: URL) async -> Bool { _ = try? await ProcessRunner.run("/bin/launchctl", ["unload", plist.path]) let loaded = try? await ProcessRunner.run("/bin/launchctl", ["load", "-w", plist.path]) return loaded?.succeeded == true } - /// Rzucane, gdy nie da sie odlozyc binarki w stabilnym miejscu. Wolajacy ma - /// wtedy PRZERWAC instalacje, nie dokonczyc jej gorszym wariantem. + /// Thrown when the binary cannot be put in a stable location. The caller + /// must then ABORT the installation, not complete it with a worse variant. struct NoStableBinary: Error { var attemptedPath: String var fallbackPath: String } - /// Jesli `resolved` wskazuje do wewnatrz `.build/` checkoutu - /// deweloperskiego (przypadek 3 w `CMPaths.agentBinaryPath` - GUI/CLI - /// odpalone przez `swift run` w drzewie repo), zywa automatyzacja launchd - /// wskazywalaby WPROST na plik, ktory kazdy kolejny `swift build`/`git - /// clean` w repo moze podmienic albo skasowac (zaobserwowane realnie: to - /// dokladnie sciezka, ktora prowadzila produkcyjne watchdogi tej - /// instalacji). Kopiujemy wiec binarke RAZ, przy kazdej instalacji, do - /// stabilnej lokalizacji poza drzewem repo - launchd wskazuje na TA kopie. - /// Binarka spakowana w .app (przypadek 1/2) jest juz stabilna sama w - /// sobie i nie wymaga kopiowania. + /// If `resolved` points inside the `.build/` of a development checkout + /// (case 3 in `CMPaths.agentBinaryPath` - GUI/CLI started via `swift run` in + /// the repo tree), the live launchd automation would point DIRECTLY at a + /// file that every subsequent `swift build`/`git clean` in the repo can + /// replace or delete (observed for real: that was exactly the path the + /// production watchdogs of this installation ran from). So we copy the + /// binary ONCE, on every installation, to a stable location outside the + /// repo tree - launchd points at THAT copy. A binary packaged in the .app + /// (case 1/2) is already stable in itself and needs no copying. /// - /// Rzuca `NoStableBinary`, gdy sie nie uda - patrz `install()`. + /// Throws `NoStableBinary` when that fails - see `install()`. private static func stableAgentBinaryPath(resolvedFrom resolved: URL) throws -> URL { guard resolved.path.contains("/.build/") else { return resolved } let stableDir = CMPaths.appSupportDir.appendingPathComponent("bin") try? FileManager.default.createDirectory(at: stableDir, withIntermediateDirectories: true) let stableBin = stableDir.appendingPathComponent("cloudmachine-agent") - // Kopiujemy OBOK, a stara kopie podmieniamy dopiero po udanym zapisie. - // Poprzednia wersja kasowala stary plik PRZED kopiowaniem, wiec nieudane - // kopiowanie zostawialo instalacje bez stabilnej binarki w ogole. + // We copy ALONGSIDE, and replace the old copy only after a successful + // write. The previous version deleted the old file BEFORE copying, so a + // failed copy left the installation without a stable binary at all. + // l10n-polish-ok: staging file name on disk; "nowy" means "new". let staging = stableDir.appendingPathComponent("cloudmachine-agent.nowy") try? FileManager.default.removeItem(at: staging) guard (try? FileManager.default.copyItem(at: resolved, to: staging)) != nil else { diff --git a/mac-app/Sources/CloudMachineCore/Loc.swift b/mac-app/Sources/CloudMachineCore/Loc.swift deleted file mode 100644 index df2a1c7..0000000 --- a/mac-app/Sources/CloudMachineCore/Loc.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation - -public enum Loc { - public static var currentLanguage: String { - get { - UserDefaults.standard.string(forKey: "CM_Language") ?? "pl" - } - set { - UserDefaults.standard.set(newValue, forKey: "CM_Language") - NotificationCenter.default.post(name: Notification.Name("CMLanguageChanged"), object: nil) - } - } - - private static let translations: [String: [String: String]] = [ - "en": [ - "Narzędzia": "Tools", - "Status": "Status", - "Kreator": "Wizard", - "Maszyny i limity": "Machines & Limits", - "Logi systemowe": "System Logs", - "Stan systemu": "System Status", - "Kreator konfiguracji": "Configuration Wizard", - "Zarządzanie maszynami i limitami": "Machine & Limit Management", - "Logi konsoli": "Console Logs", - "Problem z konfiguracją": "Configuration Problem", - "Połączono z chmurą": "Connected to Cloud", - "Montowanie...": "Mounting...", - "Błąd montowania": "Mounting Error", - "Brak połączenia": "No Connection", - "NFS wolumin gotowy": "NFS Volume Ready", - "Nawiązywanie połączenia...": "Connecting...", - "Dysk nie jest zamontowany": "Disk is not mounted", - "Wyloguj": "Log Out", - "Język": "Language", - "Język / Language": "Language", - // SetupWizardView - "Konfiguracja CloudMachine": "CloudMachine Setup", - "Ten kreator pomoże Ci skonfigurować połączenie z Google Drive przy użyciu Rclone, zainstalować demona montującego i ustawić pierwszą maszynę.": - "This wizard will help you set up connection to Google Drive using Rclone, install the mount daemon, and set up your first machine.", - "Połączenie Google Drive": "Google Drive Connection", - "Nazwa pilota Rclone": "Rclone remote name", - "Ścieżka w chmurze (katalog główny)": "Cloud root path", - "Całkowity rozmiar dysku (GB)": "Total drive size (GB)", - "Margines bezpieczeństwa (%)": "Safety margin (%)", - "Pierwsza maszyna": "First Machine", - "Nazwa komputera (klucz, np. macbook-pro)": "Computer key (e.g., macbook-pro)", - "Nazwa wyświetlana (np. MacBook Pro)": "Display name (e.g., MacBook Pro)", - "Limit dysku (GB)": "Disk limit (GB)", - "Zainstaluj demona": "Install Daemon", - "Wymagane hasło sudo w konsoli": "Sudo password required in console", - "Zapisz konfigurację": "Save Configuration", - "Dalej": "Next", - "Wstecz": "Back", - "Zakończ": "Finish", - // MenuBarContentView - "Pokaż główne okno": "Show Main Window", - "Zakończ CloudMachine": "Quit CloudMachine", - "Rozpocznij backup teraz": "Start Backup Now", - "Panel sterowania": "Control Panel", - "Połączono": "Connected", - "Łączenie...": "Connecting...", - "Rozłączono": "Disconnected", - "Błąd": "Error", - "Nieznany": "Unknown", - "Limit przestrzeni": "Space Limit", - "Wysyłanie": "Uploading", - // StatusView - "Stan połączenia": "Connection Status", - "Aktywne zadania": "Active Tasks", - "Konfiguracja": "Configuration", - "Zarządzaj": "Manage", - "Szczegóły": "Details", - "Margines": "Margin", - "Zajęte miejsce": "Used Space", - "Darmowe miejsce": "Free Space", - "Limity maszyn": "Machine Limits", - "Razem przydzielono": "Total Allocated", - "Budżet bezpieczeństwa": "Safety Budget", - "Kopia zapasowa Time Machine": "Time Machine Backup", - "Nie skonfigurowano dysku Time Machine": "Time Machine backup not configured", - "Ostatnia weryfikacja": "Last verification", - "Nigdy": "Never", - "Weryfikacja w toku": "Verification in progress", - "Rozpocznij weryfikację": "Start Verification", - "Uruchom demona": "Start Daemon", - "Zatrzymaj demona": "Stop Daemon", - "Otwórz katalog w Finderze": "Open Folder in Finder", - "Otwórz w Finderze": "Open in Finder", - // MachinesConfigView - "Dodaj maszynę": "Add Machine", - "Dodawanie nowej maszyny": "Adding New Machine", - "Klucz maszyny": "Machine key", - "Nazwa wyświetlana": "Display name", - "Limit (GB)": "Limit (GB)", - "Anuluj": "Cancel", - "Dodaj": "Add", - "Brak maszyn": "No machines", - "Przekroczono budżet bezpieczeństwa!": "Safety budget exceeded!", - "Suma limitów maszyn wynosi": "The sum of machine limits is", - "GB, podczas gdy bezpieczny budżet to": "GB, while the safety budget is", - "GB. Zmniejsz limity lub zwiększ rozmiar dysku w Ustawieniach.": - "GB. Decrease limits or increase drive size in Settings.", - "Zapisano pomyślnie": "Saved successfully", - "Błąd zapisu": "Error saving", - "Zapisz zmiany": "Save Changes", - "Zarządzanie maszyną": "Machine Management", - ] - ] - - public static func translate(_ text: String) -> String { - let lang = currentLanguage - if lang == "en" { - return translations["en"]?[text] ?? text - } - return text - } -} - -extension String { - public var localized: String { - return Loc.translate(self) - } -} diff --git a/mac-app/Sources/CloudMachineCore/MachineIdentity.swift b/mac-app/Sources/CloudMachineCore/MachineIdentity.swift index 786c43c..b60a6dd 100644 --- a/mac-app/Sources/CloudMachineCore/MachineIdentity.swift +++ b/mac-app/Sources/CloudMachineCore/MachineIdentity.swift @@ -1,21 +1,21 @@ import Foundation -/// Znormalizowany klucz tej maszyny - jedno miejsce prawdy dla GUI i CLI -/// (wczesniej zduplikowane jako `cm_machine_key` w common.sh ORAZ -/// `CloudMachineController.currentMachineKey()` w GUI). +/// Normalized key of this machine - a single source of truth for the GUI and +/// the CLI (previously duplicated as `cm_machine_key` in common.sh AND +/// `CloudMachineController.currentMachineKey()` in the GUI). public enum MachineIdentity { private static var identityFilePath: URL { CMPaths.appSupportDir.appendingPathComponent("machine-id") } - /// Trwaly klucz tej maszyny - ustalany RAZ i zapisywany na dysk, NIE - /// przeliczany na zywo z `scutil --get ComputerName` przy kazdym - /// wywolaniu. Bez tego zmiana nazwy komputera (reczna, albo automatyczna, - /// np. Migration Assistant dopisujacy "(2)" przy duplikacie) cicho - /// fragmentowalaby tozsamosc backupu: nowy klucz = nowy folder na Google - /// Drive, nowy wpis w machines.json, a caly dotychczasowy backup pod - /// starym kluczem zostaje osierocony (i dalej zajmuje limit). Raz zapisany - /// klucz przetrwa kazda pozniejsza zmiane nazwy Maca. + /// Persistent key of this machine - determined ONCE and written to disk, NOT + /// recomputed live from `scutil --get ComputerName` on every call. Without + /// this, a computer rename (manual, or automatic, e.g. Migration Assistant + /// appending "(2)" on a duplicate) would silently fragment the backup + /// identity: new key = new folder on Google Drive, new entry in + /// machines.json, and the whole existing backup under the old key is left + /// orphaned (and still counts against the quota). Once written, the key + /// survives every later rename of the Mac. public static func currentKey() async -> String { if let saved = try? String(contentsOf: identityFilePath, encoding: .utf8) { let trimmed = saved.trimmingCharacters(in: .whitespacesAndNewlines) @@ -26,10 +26,10 @@ public enum MachineIdentity { return key } - /// Wydzielone z `currentKey()`, zeby przejsciowy blad `scutil` (np. bardzo - /// wczesnie przy starcie systemu) nie utrwalil na stale fallbacku - /// "this-mac" - w takim wypadku po prostu nic nie zapisujemy i probujemy - /// wyprowadzic prawdziwy klucz ponownie przy nastepnym wywolaniu. + /// Split out of `currentKey()` so that a transient `scutil` failure (e.g. + /// very early during system startup) does not permanently persist the + /// "this-mac" fallback - in that case we simply write nothing and try to + /// derive the real key again on the next call. private static func deriveFromComputerName() async -> String? { guard let result = try? await ProcessRunner.run("/usr/sbin/scutil", ["--get", "ComputerName"]) else { return nil } @@ -38,7 +38,7 @@ public enum MachineIdentity { return normalizedKey(fromComputerName: raw) } - /// Czysta funkcja (bez efektow ubocznych), testowalna bez shellowania do `scutil`. + /// Pure function (no side effects), testable without shelling out to `scutil`. public static func normalizedKey(fromComputerName raw: String) -> String { let lowered = raw.lowercased().replacingOccurrences(of: " ", with: "-") let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") diff --git a/mac-app/Sources/CloudMachineCore/MachinesConfig.swift b/mac-app/Sources/CloudMachineCore/MachinesConfig.swift index acb02e7..764a452 100644 --- a/mac-app/Sources/CloudMachineCore/MachinesConfig.swift +++ b/mac-app/Sources/CloudMachineCore/MachinesConfig.swift @@ -18,11 +18,11 @@ public struct MachinesConfig: Codable, Equatable { public var safetyMarginPercent: Int public var remoteName: String public var remoteRootFolder: String - /// Limit predkosci wysylania rclone (Mbps - megabity/s, jak u dostawcow - /// internetu), przekazywany jako `--bwlimit` (ktory oczekuje megabajtow/s - - /// konwersja w `CloudArchiveService.bwLimitArgs`). `0` = bez limitu. - /// Uzywane przez warstwe archiwizacji w chmurze (`CloudArchiveService`) przy - /// kazdym `rclone copy` ukonczonego backupu na Google Drive. + /// rclone upload speed limit (Mbps - megabits/s, as internet providers + /// quote it), passed as `--bwlimit` (which expects megabytes/s - conversion + /// in `CloudArchiveService.bwLimitArgs`). `0` = no limit. Used by the cloud + /// archiving layer (`CloudArchiveService`) on every `rclone copy` of a + /// finished backup to Google Drive. public var bwLimitMbps: Int public var machines: [MachineEntry] @@ -44,10 +44,10 @@ public struct MachinesConfig: Codable, Equatable { machines: [] ) - /// Suma limitow przydzielonych maszynom, w GB. + /// Sum of the limits allocated to machines, in GB. public var allocatedGB: Int { machines.reduce(0) { $0 + $1.limitGB } } - /// Realny budzet po odjeciu marginesu bezpieczenstwa. + /// Real budget after subtracting the safety margin. public var safeBudgetGB: Int { driveTotalGB - (driveTotalGB * safetyMarginPercent / 100) } @@ -73,9 +73,10 @@ public struct MachinesConfig: Codable, Equatable { safetyMarginPercent = try container.decode(Int.self, forKey: .safetyMarginPercent) remoteName = try container.decode(String.self, forKey: .remoteName) remoteRootFolder = try container.decode(String.self, forKey: .remoteRootFolder) - // WAZNE: `decodeIfPresent` - pole dodane pozniej, istniejace pliki - // machines.json na dyskach uzytkownikow go nie maja. Domyslnie bez - // limitu, zeby nie zmienic zachowania juz dzialajacych instalacji. + // IMPORTANT: `decodeIfPresent` - a field added later; existing + // machines.json files on users' disks do not have it. No limit by + // default, so as not to change the behaviour of installations already + // running. bwLimitMbps = try container.decodeIfPresent(Int.self, forKey: .bwLimitMbps) ?? 0 let dict = try container.decode([String: MachineEntryPayload].self, forKey: .machines) machines = dict.map { @@ -99,19 +100,19 @@ public struct MachinesConfig: Codable, Equatable { try container.encode(dict, forKey: .machines) } - /// Odpowiednik `cm_remote_path_for` z common.sh, np. - /// `gdrive-cloudmachine:CloudMachine/marcin-mac-studio`. + /// Counterpart of `cm_remote_path_for` from common.sh, e.g. + /// `gdrive-cloudmachine:CloudMachine/alex-mac-studio`. public func remotePath(forMachineKey key: String) -> String { "\(remoteName):\(remoteRootFolder)/\(key)" } - /// Odpowiednik `cm_machine_limit_gb` - `nil`, jesli maszyna nie jest zdefiniowana. + /// Counterpart of `cm_machine_limit_gb` - `nil` if the machine is not defined. public func limitGB(forMachineKey key: String) -> Int? { machines.first(where: { $0.key == key })?.limitGB } } -/// Ksztalt pojedynczego wpisu maszyny w JSON-ie (bez klucza, ktory jest kluczem slownika). +/// Shape of a single machine entry in the JSON (without the key, which is the dictionary key). private struct MachineEntryPayload: Codable { var displayName: String var limitGB: Int diff --git a/mac-app/Sources/CloudMachineCore/ProcessRunner.swift b/mac-app/Sources/CloudMachineCore/ProcessRunner.swift index f355433..74c6ae3 100644 --- a/mac-app/Sources/CloudMachineCore/ProcessRunner.swift +++ b/mac-app/Sources/CloudMachineCore/ProcessRunner.swift @@ -6,13 +6,13 @@ public struct ProcessResult { public var exitCode: Int32 public var succeeded: Bool { exitCode == 0 } - /// `true`, jesli to niepowodzenie to `sudo -n` odmawiajace natychmiast z - /// powodu brakujacej reguly NOPASSWD w sudoers (a NIE realny blad - /// polecenia, ktore sudo zdazylo faktycznie uruchomic) - odroznia "trzeba - /// dopisac regule i sprobowac ponownie" od "polecenie i tak zawiedzie - /// identycznie przy ponownej probie", wiec nie ma sensu prosic uzytkownika - /// o haslo administratora ani straszyc go w logu/notyfikacji fikcyjnym - /// problemem z danymi. + /// `true` if this failure is `sudo -n` refusing immediately because of a + /// missing NOPASSWD rule in sudoers (and NOT a real failure of a command + /// that sudo actually managed to run) - tells "the rule has to be added and + /// the action retried" apart from "the command will fail identically on a + /// retry anyway", so there is no point asking the user for the administrator + /// password or scaring them in the log/notification with a fictitious data + /// problem. public var isSudoAuthFailure: Bool { let text = (stderr + stdout).lowercased() return text.contains("a password is required") || text.contains("no tty present") @@ -27,28 +27,29 @@ public enum ProcessRunnerError: LocalizedError { switch self { case .launchFailed(let msg): return msg case .timedOut(let executable): - return - "\(executable) nie odpowiedzialo w wyznaczonym czasie (proces zostal osierocony w tle)." + return L10n.tr( + "%@ did not respond within the allotted time (the process was left orphaned in the background).", + executable) } } } -/// Chroni `continuation` przed podwojnym wznowieniem - potrzebne, odkad -/// `run(timeout:)` moze "poddac sie" i zwrocic blad, ZANIM proces faktycznie -/// sie zakonczy (patrz komentarz przy `timeout` nizej). Jesli terminationHandler -/// i tak pozniej sie odpali, MUSI juz nic nie robic zamiast wywolac fatal error -/// przez powtorne `continuation.resume`. +/// Protects `continuation` against being resumed twice - needed ever since +/// `run(timeout:)` can "give up" and return an error BEFORE the process has +/// actually ended (see the comment on `timeout` below). If terminationHandler +/// still fires later, it MUST do nothing instead of causing a fatal error with +/// a second `continuation.resume`. /// -/// Wewnetrzny, a nie prywatny: od 26.09.2026 `ImageProbe` poddaje sie na -/// deadline dokladnie tak samo (sonda zawieszona w jadrze moze odpowiedziec -/// pozniej albo nigdy), wiec obie sciezki musza miec te sama, sprawdzona -/// semantyke "wznawia ten, kto byl pierwszy". +/// Internal rather than private: since 26.09.2026 `ImageProbe` gives up on a +/// deadline in exactly the same way (a probe stuck in the kernel may answer +/// later or never), so both paths must share the same, proven semantics of +/// "whoever was first resumes". final class ContinuationGuard: @unchecked Sendable { private let lock = NSLock() private var done = false - /// Zwraca `true` tylko za PIERWSZYM razem - a wiec "to Ty masz prawo - /// wznowic continuation", kazde kolejne wywolanie dostaje `false`. + /// Returns `true` only the FIRST time - i.e. "you are the one entitled to + /// resume the continuation"; every subsequent call gets `false`. func claim() -> Bool { lock.lock() defer { lock.unlock() } @@ -58,10 +59,10 @@ final class ContinuationGuard: @unchecked Sendable { } } -/// Cienka warstwa nad `Process` do uruchamiania zewnetrznych narzedzi -/// (rclone, tmutil, hdiutil, diskutil...) - dzielona przez GUI i CLI. Wczesniej -/// zyla wylacznie w GUI jako `Shell.run`; przeniesiona tu, zeby watchdogi CLI -/// mialy dokladnie te sama, juz sprawdzona semantyke timeoutu. +/// A thin layer over `Process` for running external tools (rclone, tmutil, +/// hdiutil, diskutil...) - shared by the GUI and the CLI. Previously it lived +/// only in the GUI as `Shell.run`; moved here so that the CLI watchdogs have +/// exactly the same, already proven timeout semantics. public enum ProcessRunner { public static func run( _ executable: String, _ args: [String], env: [String: String] = [:], @@ -73,7 +74,7 @@ public enum ProcessRunner { process.arguments = args var fullEnv = ProcessInfo.processInfo.environment - // Homebrew na Apple Silicon instaluje do /opt/homebrew/bin - dorzucamy na wszelki wypadek. + // Homebrew on Apple Silicon installs to /opt/homebrew/bin - added just in case. fullEnv["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (fullEnv["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin") for (k, v) in env { fullEnv[k] = v } @@ -107,16 +108,16 @@ public enum ProcessRunner { stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil - // WAZNE: NIE wolno tu wolac readDataToEndOfFile() - blokuje sie do - // zamkniecia pisania konca potoku przez WSZYSTKICH jego posiadaczy. - // Procesy odpalane z "--daemon" (np. `rclone nfsmount --daemon`) - // forkuja dziecko, ktore dziedziczy te same FD stdout/stderr i NIGDY - // ich nie zamyka - terminationHandler natychmiastowego procesu-rodzica - // odpala sie normalnie, ale readDataToEndOfFile() wisi wtedy w - // nieskonczonosc, bo EOF nigdy nie nadejdzie (zaobserwowane realnie: - // watchdog zawieszony na >10 min po kazdym swiezym starcie rclone). - // `readabilityHandler` juz i tak zbiera wszystko na biezaco w miare - // nadchodzenia danych - nie potrzeba dodatkowego, blokujacego dobicia. + // IMPORTANT: readDataToEndOfFile() must NOT be called here - it blocks + // until the write end of the pipe is closed by ALL of its holders. + // Processes started with "--daemon" (e.g. `rclone nfsmount --daemon`) + // fork a child that inherits the same stdout/stderr FDs and NEVER + // closes them - the terminationHandler of the immediate parent process + // fires normally, but readDataToEndOfFile() then hangs forever, + // because EOF never arrives (observed for real: a watchdog stuck for + // >10 min after every fresh rclone start). `readabilityHandler` already + // collects everything as the data arrives - no extra, blocking final + // read is needed. queue.async { let result = ProcessResult( stdout: String(data: stdoutData, encoding: .utf8) ?? "", @@ -135,7 +136,7 @@ public enum ProcessRunner { if resumeGuard.claim() { continuation.resume( throwing: ProcessRunnerError.launchFailed( - "Nie mozna uruchomic \(executable): \(error.localizedDescription)")) + L10n.tr("Cannot launch %@: %@", executable, error.localizedDescription))) } return } @@ -146,40 +147,38 @@ public enum ProcessRunner { process.terminate() } } - // Eskalacja do SIGKILL, jesli proces zignoruje SIGTERM - patrz - // uzasadnienie przy tym samym mechanizmie w dawnym Shell.swift - // (zawieszony potomny diskutil/umount ignoruje SIGTERM bez konca). + // Escalation to SIGKILL if the process ignores SIGTERM - see the + // rationale for the same mechanism in the old Shell.swift (a hung + // child diskutil/umount ignores SIGTERM indefinitely). DispatchQueue.global().asyncAfter(deadline: .now() + timeout + 5) { if process.isRunning { kill(process.processIdentifier, SIGKILL) } } - // WAZNE: SIGKILL NIE dziala na proces zawieszony w jadrze w - // nieprzerywalnym oczekiwaniu (stan "U" w `ps`, np. hdiutil/ - // diskimages-helper czekajacy na I/O przez martwy/wolny NFS - - // zaobserwowane realnie na zywo). Bez tej ostatecznej granicy - // `timeout` NIE bylby prawdziwym gornym ograniczeniem czasu - // oczekiwania, wbrew temu co sugeruje parametr - `continuation` - // czekalaby w nieskonczonosc na `terminationHandler`, ktory nigdy by - // sie nie odpalil. Tutaj poddajemy sie i zwracamy blad zamiast - // wisiec; proces zostaje osierocony w tle (nieszkodliwie - kiedys - // sam dokonczy, gdy jadro w koncu dostanie odpowiedz, a - // `resumeGuard` zapobiegnie podwojnemu wznowieniu, jesli - // `terminationHandler` odpali sie pozniej). + // IMPORTANT: SIGKILL does NOT work on a process stuck in the kernel in + // an uninterruptible wait (state "U" in `ps`, e.g. hdiutil/ + // diskimages-helper waiting for I/O over a dead/slow NFS - observed + // for real, live). Without this final limit `timeout` would NOT be a + // real upper bound on the waiting time, contrary to what the parameter + // suggests - `continuation` would wait forever for a + // `terminationHandler` that would never fire. Here we give up and + // return an error instead of hanging; the process is left orphaned in + // the background (harmlessly - it will finish by itself some day, when + // the kernel finally gets an answer, and `resumeGuard` prevents a + // double resume if `terminationHandler` fires later). DispatchQueue.global().asyncAfter(deadline: .now() + timeout + 10) { if resumeGuard.claim() { - // WAZNE: NIE zerujemy handlera do `nil` - to zostawia potok bez - // ZADNEGO czytelnika. Jesli proces przetrwal nawet SIGKILL - // (zawieszony w jadrze w nieprzerywalnym I/O - patrz komentarz - // wyzej) i kiedys jednak wznowi dzialanie, dalej moze pisac do - // stdout/stderr; bez czytelnika zapelniony bufor potoku - // zablokowalby go na `write()` NA ZAWSZE, zamieniajac - // "niegrozny osierocony proces" w trwale zawieszony zombie, - // ktory nigdy nie zostanie sprzatniety. Podmieniamy wiec handler - // na taki, ktory nadal drenuje i odrzuca dane - wynik i tak juz - // nie zostanie odczytany (continuation ponizej wznawia sie - // bledem timeoutu), ale osierocony proces moze swobodnie - // dokonczyc zapis i zakonczyc sie samodzielnie. + // IMPORTANT: we do NOT reset the handler to `nil` - that leaves the + // pipe with NO reader at all. If the process survived even SIGKILL + // (stuck in the kernel in uninterruptible I/O - see the comment + // above) and does resume some day, it may still write to + // stdout/stderr; without a reader, a full pipe buffer would block + // it on `write()` FOREVER, turning "a harmless orphaned process" + // into a permanently stuck zombie that never gets cleaned up. So we + // replace the handler with one that keeps draining and discarding + // the data - the result will not be read anyway (the continuation + // below resumes with the timeout error), but the orphaned process + // can freely finish writing and exit on its own. stdoutPipe.fileHandleForReading.readabilityHandler = { handle in _ = handle.availableData } @@ -193,18 +192,18 @@ public enum ProcessRunner { } } - /// Uruchamia `rclone` przez `/usr/bin/env`, zeby dzialalo niezaleznie od - /// tego, czy Homebrew zainstalowal je do /opt/homebrew/bin czy /usr/local/bin. + /// Runs `rclone` via `/usr/bin/env`, so that it works regardless of whether + /// Homebrew installed it to /opt/homebrew/bin or /usr/local/bin. public static func runRclone(_ args: [String], timeout: TimeInterval? = nil) async throws -> ProcessResult { try await run("/usr/bin/env", ["rclone"] + args, timeout: timeout) } - /// Uruchamia `tmutil` przez `sudo -n` (bez pytania o haslo) - wymaga - /// wczesniej skonfigurowanej reguly NOPASSWD w /etc/sudoers.d/cloudmachine - /// (patrz LaunchdInstaller/DependencyInstaller). Uzywane przez watchdogi - /// dzialajace bez sesji GUI (nie moga pokazac dialogu autoryzacji). + /// Runs `tmutil` via `sudo -n` (without asking for a password) - requires a + /// NOPASSWD rule configured beforehand in /etc/sudoers.d/cloudmachine (see + /// LaunchdInstaller/DependencyInstaller). Used by watchdogs running without + /// a GUI session (they cannot show an authorization dialog). public static func runTmutilUnattended(_ args: [String], timeout: TimeInterval? = nil) async throws -> ProcessResult { diff --git a/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift b/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift index 0f3bda4..2d2fa51 100644 --- a/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift +++ b/mac-app/Sources/CloudMachineCore/RcloneInstaller.swift @@ -1,15 +1,15 @@ import Foundation -/// Pobiera oficjalna binarke rclone - port `gdrive/install-rclone.sh`. +/// Downloads the official rclone binary - port of `gdrive/install-rclone.sh`. /// -/// Istnieje, bo rclone z Homebrew jest zbudowane bez obslugi FUSE i przy -/// probie montowania odmawia wprost: +/// It exists because rclone from Homebrew is built without FUSE support and, +/// when asked to mount, refuses outright: /// /// 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. +/// We install it alongside, in our own directory +/// (`CMTooling.managedRclonePath`), without touching the Homebrew installation +/// - the remaining, non-mounting code paths can keep using it. public enum RcloneInstaller { private static let versionURL = "https://downloads.rclone.org/version.txt" @@ -24,12 +24,12 @@ public enum RcloneInstaller { } catch { return CMActionResult( succeeded: false, - message: "Nie moge utworzyc katalogu roboczego: \(error.localizedDescription)") + message: L10n.tr("Cannot create the working directory: %@", error.localizedDescription)) } guard let version = await latestVersion() else { return CMActionResult( - succeeded: false, message: "Nie udalo sie odczytac numeru wersji rclone.") + succeeded: false, message: L10n.tr("Could not read the rclone version number.")) } let arch = currentArch() @@ -40,23 +40,24 @@ public enum RcloneInstaller { 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).") + return CMActionResult(succeeded: false, message: L10n.tr("Could not download %@.", zipName)) } - // Weryfikacja sumy nie jest ozdoba: sciagamy wykonywalna binarke, ktora - // bedzie miala dostep do calego Dysku Google. + // Verifying the checksum is not decoration: we are downloading an + // executable binary that will have access to the whole Google Drive. guard let expected = expectedChecksum(sumsFile: sumsPath, zipName: zipName) else { - return CMActionResult(succeeded: false, message: "Brak wpisu dla \(zipName) w SHA256SUMS.") + return CMActionResult( + succeeded: false, message: L10n.tr("No entry for %@ in SHA256SUMS.", zipName)) } guard let actual = await checksum(of: zipPath) else { - return CMActionResult(succeeded: false, message: "Nie udalo sie policzyc sumy kontrolnej.") + return CMActionResult(succeeded: false, message: L10n.tr("Could not compute the checksum.")) } guard expected == actual else { return CMActionResult( succeeded: false, - message: - "Suma SHA256 sie nie zgadza - NIE instaluje.\n oczekiwana: \(expected)\n policzona : \(actual)" - ) + message: L10n.tr( + "SHA256 checksum does not match - NOT installing.\n expected: %@\n computed: %@", + expected, actual)) } guard @@ -64,7 +65,7 @@ public enum RcloneInstaller { "/usr/bin/unzip", ["-oq", zipPath.path, "-d", workDir.path], timeout: 300), unzip.succeeded else { - return CMActionResult(succeeded: false, message: "Nie udalo sie rozpakowac archiwum.") + return CMActionResult(succeeded: false, message: L10n.tr("Could not unpack the archive.")) } let extracted = @@ -80,15 +81,16 @@ public enum RcloneInstaller { } catch { return CMActionResult( succeeded: false, - message: "Nie udalo sie zainstalowac binarki: \(error.localizedDescription)") + message: L10n.tr("Could not install the binary: %@", error.localizedDescription)) } return CMActionResult( succeeded: true, - message: "Zainstalowano rclone \(version) w \(destination.path) (suma SHA256 zgodna).") + message: L10n.tr( + "Installed rclone %@ in %@ (SHA256 checksum matches).", version, destination.path)) } - // MARK: - Szczegoly + // MARK: - Details static func currentArch() -> String { #if arch(arm64) @@ -98,8 +100,8 @@ public enum RcloneInstaller { #endif } - /// Wyciaga oczekiwana sume dla danego archiwum z pliku SHA256SUMS. - /// Czysta funkcja - testowalna bez sieci. + /// Extracts the expected checksum for the given archive from the SHA256SUMS + /// file. Pure function - testable without the network. public static func expectedChecksum(sumsContent: String, zipName: String) -> String? { for line in sumsContent.components(separatedBy: .newlines) { let parts = line.split(separator: " ", omittingEmptySubsequences: true) diff --git a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift index ceca2fa..e32489a 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift @@ -1,17 +1,17 @@ import Foundation -/// Port `configure-remote.sh` (+ scalone z dawnym `CloudMachineController. -/// connectGoogleDrive()` w GUI) - laczy z Google Drive przez `rclone -/// authorize` (nieinteraktywny OAuth w przegladarce), zamiast starszego, -/// interaktywnego kreatora `rclone config`. CLI i GUI uzywaja teraz -/// DOKLADNIE tej samej sciezki logowania. +/// Port of `configure-remote.sh` (+ merged with the old `CloudMachineController. +/// connectGoogleDrive()` in the GUI) - connects to Google Drive via `rclone +/// authorize` (non-interactive OAuth in the browser), instead of the older, +/// interactive `rclone config` wizard. The CLI and the GUI now use EXACTLY the +/// same sign-in path. public enum RemoteConfigurer { - /// Czy remote istnieje w konfiguracji rclone. + /// Whether the remote exists in the rclone configuration. /// - /// 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. + /// Asks the binary managed by CloudMachine, not the one from Homebrew. Both + /// read the same configuration file, but the rest of the system runs on ours + /// - and the state shown to the user must describe what we really use, not + /// an incidental second installation that may one day not be there. public static func isConfigured(remoteName: String) async -> Bool { guard let result = try? await CMTooling.runRclone(["listremotes"], timeout: 30) else { return false @@ -19,16 +19,16 @@ public enum RemoteConfigurer { return result.stdout.contains("\(remoteName):") } - /// Usluga w Keychainie, pod ktora leza wlasne poswiadczenia OAuth. + /// Keychain service under which the own OAuth credentials are stored. public static let keychainService = "cloudmachine-gdrive" - /// Czyta `client_id` / `client_secret` z Keychaina. + /// Reads `client_id` / `client_secret` from the Keychain. /// - /// README opisywal to od dawna jako dzialajaca czesc `configure-remote`, - /// a ANI JEDNA linia kodu tego nie robila - `rclone authorize drive` szlo - /// na wspoldzielonym `client_id` rclone, tym samym, o ktorym README pisze, - /// ze jest wycofywany i limitowany wspolnie ze wszystkimi uzytkownikami - /// rclone. Dokumentacja opisywala zabezpieczenie, ktorego nie bylo. + /// The README had long described this as a working part of + /// `configure-remote`, yet NOT A SINGLE line of code did it - `rclone + /// authorize drive` ran on rclone's shared `client_id`, the very one the + /// README says is being retired and is rate-limited jointly with all rclone + /// users. The documentation described a safeguard that did not exist. static func keychainSecret(account: String) async -> String? { guard let result = try? await ProcessRunner.run( @@ -49,92 +49,103 @@ public enum RemoteConfigurer { return token.trimmingCharacters(in: .whitespacesAndNewlines) } - /// Laczy z Google Drive i przygotowuje DOKLADNIE ten remote i folder, ktorych - /// uzywa montowanie. + /// Connects to Google Drive and prepares EXACTLY the remote and folder that + /// the mount uses. /// - /// Wczesniej nazwy brano z `machines.json` (`remote_name`, domyslnie - /// `gdrive-cloudmachine`, plus folder z kluczem maszyny), a montowanie - /// chodzi na stalych `DriveBufferService.remoteName` / `.remotePath` - /// (`gdrive:CloudMachine/mac-studio`). Udokumentowana sciezka instalacji - - /// `configure-remote`, potem `create-image` - konczyla sie wiec remote'em, - /// ktorego nikt nigdy nie uzywa, i bledem "Drive nie jest zamontowany" przy - /// nastepnym kroku. Dzialajaca instalacja na tej maszynie ma `[gdrive]` - /// zlozony recznie; z samego repo nie dalo sie jej odtworzyc. + /// Previously the names were taken from `machines.json` (`remote_name`, + /// `gdrive-cloudmachine` by default, plus a folder with the machine key), + /// while the mount runs on the constants `DriveBufferService.remoteName` / + /// `.remotePath` (`gdrive:CloudMachine/mac-studio`). The documented + /// installation path - `configure-remote`, then `create-image` - therefore + /// ended with a remote nobody ever uses, and a "Drive is not mounted" error + /// at the next step. The working installation on this machine has a + /// `[gdrive]` put together by hand; it could not be reproduced from the repo + /// alone. /// - /// `config` i `machineKey` zostaja w sygnaturze, bo GUI i CLI je maja, ale - /// o nazwie remote'a decyduje odtad ta sama stala, ktora buduje polecenie - /// montowania. Jedno zrodlo prawdy albo zaden. + /// `config` and `machineKey` stay in the signature because the GUI and the + /// CLI have them, but from now on the remote's name is decided by the same + /// constant that builds the mount command. One source of truth or none. @discardableResult public static func connect( - config: MachinesConfig, machineKey: String, replaceExisting: Bool = false + config: MachinesConfig, machineKey: String, replaceExisting: Bool = false, + folder: String? = nil ) async -> CMActionResult { let remoteName = DriveBufferService.remoteName - let remotePath = "\(remoteName):\(DriveBufferService.remotePath)" - // PRZERYWAMY, a nie ostrzegamy. `rclone config create` nadpisuje wpis - // o tej samej nazwie bez pytania, a wraz z nim token, `client_id` - // i `scope` dzialajacej instalacji. Nowe poswiadczenie z zakresem - // `drive.file` widzi wylacznie pliki utworzone przez SIEBIE - istniejacy - // obraz backupu, zalozony przez poprzednie poswiadczenie, staje sie - // wtedy niewidoczny i montowanie przestaje go znajdowac. Kopia jest cala, - // ale niedostepna, co w praktyce znaczy to samo. + // We ABORT rather than warn. `rclone config create` overwrites an entry + // with the same name without asking, and with it the token, `client_id` + // and `scope` of the working installation. A new credential with the + // `drive.file` scope sees only files created by ITSELF - the existing + // backup image, created by the previous credential, then becomes + // invisible and the mount stops finding it. The backup is intact, but + // inaccessible, which in practice means the same thing. if !replaceExisting, await isConfigured(remoteName: remoteName) { return CMActionResult( succeeded: false, - message: """ - Remote '\(remoteName)' juz istnieje i NIE zostal ruszony. - Nadpisanie go podmienia token i zakres uprawnien; poswiadczenie \ - z zakresem 'drive.file' nie widzi plikow zalozonych przez poprzednie, \ - wiec istniejacy backup staje sie nieosiagalny. - Jesli naprawde chcesz go zastapic, zrob najpierw kopie \ - ~/.config/rclone/rclone.conf i uruchom ponownie z --replace-existing. - """) + message: L10n.tr( + "Remote '%@' already exists and was NOT touched.\nOverwriting it replaces the token and the permission scope; a credential with the 'drive.file' scope does not see files created by the previous one, so the existing backup becomes unreachable.\nIf you really want to replace it, first back up ~/.config/rclone/rclone.conf and run again with --replace-existing.", + remoteName)) + } + + // The folder is decided BEFORE the remote is created: an existing remote + // is how `DriveFolder` recognises an installation whose backup already + // lives under the legacy name. Asked after `config create`, every new Mac + // would look like an old one and land in `mac-studio`. + switch await DriveFolder.resolve(requested: folder) { + case .failure(let refused): + return CMActionResult(succeeded: false, message: refused.message) + case .success: + break } + let remotePath = "\(remoteName):\(DriveBufferService.remotePath)" - // Wlasne poswiadczenia OAuth z Keychaina. Na wspoldzielonym `client_id` - // rclone konkurujemy o limit tempa ze wszystkimi uzytkownikami rclone, - // a sam ten `client_id` jest wycofywany w 2026. + // Own OAuth credentials from the Keychain. On rclone's shared `client_id` + // we compete for the rate limit with all rclone users, and that + // `client_id` itself is being retired in 2026. let clientID = await keychainSecret(account: "client_id") let clientSecret = await keychainSecret(account: "client_secret") - // Do `authorize` podajemy je przez SRODOWISKO, nie przez argumenty: - // wiersz polecenia kazdego procesu widzi na macOS kazdy uzytkownik przez - // `ps`, a srodowisko - tylko wlasciciel procesu i root. `rclone authorize` - // czeka na zatwierdzenie w przegladarce, wiec ten proces zyje minutami, - // nie ulamkiem sekundy. + // We pass them to `authorize` through the ENVIRONMENT, not as arguments: + // on macOS every user can see every process's command line via `ps`, + // while the environment is visible only to the process owner and root. + // `rclone authorize` waits for approval in the browser, so this process + // lives for minutes, not for a fraction of a second. var authEnv: [String: String] = [:] if let clientID, let clientSecret { authEnv["RCLONE_DRIVE_CLIENT_ID"] = clientID authEnv["RCLONE_DRIVE_CLIENT_SECRET"] = clientSecret } else { - // NIE przerywamy - bez wlasnych kluczy polaczenie nadal dziala, tylko - // gorzej. Ale mowimy o tym wprost, zamiast milczec: to byla dokladnie - // ta roznica, ktora README opisywal jako zalatwiona, a ktorej nie bylo. + // We do NOT abort - without our own keys the connection still works, + // just worse. But we say so plainly instead of staying silent: this was + // exactly the difference the README described as handled, and which + // was not. CMLogger.log( - "UWAGA: brak client_id/client_secret w Keychainie (usluga '\(keychainService)') - lacze na wspoldzielonym client_id rclone, ktory jest limitowany wspolnie i wycofywany w 2026. Patrz README." + "WARNING: no client_id/client_secret in the Keychain (service '\(keychainService)') - connecting with rclone's shared client_id, which is rate-limited jointly and being retired in 2026. See the README." ) } - // `drive.file` ogranicza dostep do plikow, ktore ta aplikacja sama - // utworzyla. Pelne `drive` - domyslne dla `rclone authorize drive`, i to, - // co ma dzialajaca instalacja - daje odczyt, zmiane i KASOWANIE calej - // zawartosci konta Google. To nieporownanie szersze uprawnienie, niz - // potrzebuje katalog z pasmami jednego obrazu, tym bardziej ze montowanie - // chodzi z `--drive-use-trash=false`, wiec kasowanie nie ma kosza, z - // ktorego dalo by sie cokolwiek cofnac. + // `drive.file` restricts access to files this application created + // itself. Full `drive` - the default for `rclone authorize drive`, and + // what the working installation has - grants reading, changing and + // DELETING the entire contents of the Google account. That is an + // incomparably broader permission than a directory with the bands of one + // image needs, all the more so because the mount runs with + // `--drive-use-trash=false`, so deletion has no trash from which anything + // could be undone. // - // Binarka: TA SAMA, ktorej uzywa reszta systemu (`~/.cloudmachine/bin/ - // rclone`), a nie `/usr/bin/env rclone`. Do 23.09.2026 `connect` szlo - // przez `env`, czyli w rclone z Homebrew - podczas gdy `isConfigured` - // w tym samym pliku i cale montowanie ida przez `CMTooling.runRclone`. - // Konsekwencje byly dwie i obie ciche: bez Homebrew udokumentowana - // sciezka instalacji (`install-rclone`, potem `configure-remote`) konczyla - // sie kodem 127 i komunikatem bez przyczyny, a Z Homebrew konfiguracje - // zapisywala INNA binarka niz ta, ktorej system potem uzywa. + // Binary: THE SAME one the rest of the system uses + // (`~/.cloudmachine/bin/rclone`), not `/usr/bin/env rclone`. Until + // 23.09.2026 `connect` went through `env`, i.e. to rclone from Homebrew - + // while `isConfigured` in this same file and the whole mount go through + // `CMTooling.runRclone`. There were two consequences, both silent: without + // Homebrew the documented installation path (`install-rclone`, then + // `configure-remote`) ended with exit code 127 and a message without a + // cause, and WITH Homebrew the configuration was written by a DIFFERENT + // binary than the one the system then uses. // - // `authorize` woly przez `ProcessRunner.run` wprost na sciezce - // zarzadzanej binarki, bo `CMTooling.runRclone` nie przyjmuje `env`, - // a klucze OAuth MUSZA isc srodowiskiem (patrz komentarz wyzej) i - // `CMTooling.swift` nie nalezy do zakresu tej poprawki. + // `authorize` is called via `ProcessRunner.run` directly on the managed + // binary's path, because `CMTooling.runRclone` does not accept `env`, + // and the OAuth keys MUST go through the environment (see the comment + // above) and `CMTooling.swift` was outside the scope of this fix. guard let authResult = try? await ProcessRunner.run( CMTooling.managedRclonePath.path, @@ -144,43 +155,47 @@ public enum RemoteConfigurer { else { return CMActionResult( succeeded: false, - message: - "rclone authorize nie powiodlo sie (\(CMTooling.managedRclonePath.path)). Jesli tej binarki nie ma, zacznij od: cloudmachine-agent install-rclone." - ) + message: L10n.tr( + "rclone authorize failed (%@). If that binary is missing, start with: cloudmachine-agent install-rclone.", + CMTooling.managedRclonePath.path)) } guard let token = extractToken(from: authResult.stdout) else { return CMActionResult( - succeeded: false, message: "Nie udalo sie odczytac tokenu z wyniku rclone authorize.") + succeeded: false, + message: L10n.tr("Could not read the token from the output of rclone authorize.")) } - // Tutaj klucze MUSZA przejsc argumentami - `config create` ma je zapisac - // do `rclone.conf`, wiec srodowisko nic by nie dalo. Proces trwa ulamek - // sekundy i jest jednorazowy, w odroznieniu od `authorize` powyzej. + // Here the keys MUST go as arguments - `config create` is supposed to + // write them to `rclone.conf`, so the environment would not help. The + // process takes a fraction of a second and runs once, unlike `authorize` + // above. var createArgs = ["config", "create", remoteName, "drive", "scope=drive.file"] if let clientID, let clientSecret { createArgs += ["client_id=\(clientID)", "client_secret=\(clientSecret)"] } createArgs.append("token=\(token)") - // Znowu ta sama binarka co montowanie: gdyby `config create` poszlo przez - // Homebrew, zapisalby wpis w konfiguracji, ktorej moze nie czytac binarka - // uzywana przez system - a wtedy `isConfigured` mowi "nie ma remote'a" - // zaraz po udanym "polaczono". + // Again the same binary as the mount: if `config create` went through + // Homebrew, it would write the entry into a configuration that the binary + // used by the system may not read - and then `isConfigured` says "there is + // no remote" right after a successful "connected". let createResult = try? await CMTooling.runRclone(createArgs, timeout: 60) guard createResult?.succeeded == true else { - return CMActionResult(succeeded: false, message: "rclone config create nie powiodlo sie.") + return CMActionResult(succeeded: false, message: L10n.tr("rclone config create failed.")) } let mkdirResult = try? await CMTooling.runRclone(["mkdir", remotePath], timeout: 120) guard mkdirResult?.succeeded == true else { - // ZWRACAMY BLAD, nie "sukces": remote istnieje, ale bez tego folderu nie - // mamy potwierdzenia, ze zapis na to konto faktycznie dziala - a kolejny - // krok instalacji (`create-image`) zaklada, ze dziala. + // We RETURN AN ERROR, not "success": the remote exists, but without + // this folder we have no confirmation that writing to this account + // actually works - and the next installation step (`create-image`) + // assumes it does. return CMActionResult( succeeded: false, - message: - "Polaczono z Google Drive, ale nie udalo sie utworzyc folderu '\(remotePath)' - bez niego montowanie nie ruszy. Sprawdz uprawnienia konta i sprobuj ponownie." - ) + message: L10n.tr( + "Connected to Google Drive, but could not create the folder '%@' - without it the mount will not start. Check the account permissions and try again.", + remotePath)) } return CMActionResult( succeeded: true, - message: "Polaczono z Google Drive jako remote '\(remoteName)', folder \(remotePath).") + message: L10n.tr( + "Connected to Google Drive as remote '%@', folder %@.", remoteName, remotePath)) } } diff --git a/mac-app/Sources/CloudMachineCore/RemoteCredentials.swift b/mac-app/Sources/CloudMachineCore/RemoteCredentials.swift index d738ca7..1040894 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteCredentials.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteCredentials.swift @@ -1,16 +1,16 @@ import Foundation -/// Wlasne poswiadczenia OAuth do Google Drive. +/// Own OAuth credentials for Google Drive. /// -/// Nie sa ozdobnikiem: bez nich rclone laczy sie na WSPoLDZIELONYM `client_id` -/// rclone, limitowanym wspolnie ze wszystkimi jego uzytkownikami i wycofywanym -/// w 2026. Do tej pory dalo sie je wprowadzic wylacznie recznym -/// `security add-generic-password` z README - czyli krokiem, ktorego nikt nie -/// robi, dopoki cos nie przestanie dzialac. +/// They are not decoration: without them rclone connects with rclone's SHARED +/// `client_id`, rate-limited jointly with all of its users and being retired +/// in 2026. Until now they could only be entered by a manual +/// `security add-generic-password` from the README - i.e. a step nobody takes +/// until something stops working. /// -/// W repozytorium ICH NIE MA i nigdy nie bylo: kod czyta je z Keychaina, a -/// historia gita zawiera wylacznie placeholder `...apps.googleusercontent.com` -/// w komentarzu. Sprawdzone 13 wrz 2026. +/// They are NOT in the repository and never were: the code reads them from the +/// Keychain, and the git history contains only the placeholder +/// `...apps.googleusercontent.com` in a comment. Verified 13 Sep 2026. extension RemoteConfigurer { public static let clientIDAccount = "client_id" @@ -25,20 +25,22 @@ extension RemoteConfigurer { self.hasClientSecret = hasClientSecret } - /// Polowiczna konfiguracja jest gorsza niz zadna, bo wyglada na zrobiona. - /// rclone potrzebuje OBU wartosci - przy jednej i tak wraca na - /// wspoldzielony `client_id`. + /// A half-done configuration is worse than none, because it looks done. + /// rclone needs BOTH values - with only one it falls back to the shared + /// `client_id` anyway. public var isComplete: Bool { hasClientID && hasClientSecret } public var isPartial: Bool { (hasClientID || hasClientSecret) && !isComplete } public var summary: String { - if isComplete { return "Wlasne poswiadczenia Google: ustawione." } + if isComplete { return L10n.tr("Own Google credentials: set.") } if isPartial { - return - "NIEPELNE: brakuje \(hasClientID ? "client_secret" : "client_id") - rclone i tak uzyje wspoldzielonego client_id rclone." + return L10n.tr( + "INCOMPLETE: %@ is missing - rclone will use rclone's shared client_id anyway.", + hasClientID ? "client_secret" : "client_id") } - return - "Brak wlasnych poswiadczen - rclone uzywa wspoldzielonego client_id, limitowanego wspolnie i wycofywanego w 2026." + return L10n.tr( + "No own credentials - rclone uses the shared client_id, rate-limited jointly and being retired in 2026." + ) } } @@ -49,12 +51,13 @@ extension RemoteConfigurer { return CredentialsState(hasClientID: await id, hasClientSecret: await secret) } - /// Zapisuje oba poswiadczenia. + /// Stores both credentials. /// - /// Zmiana poswiadczen NIE przekonfigurowuje istniejacego remote - token juz - /// wydany dziala dalej na starym `client_id`. Zeby nowe weszly w zycie, - /// trzeba przejsc `configure-remote --replace-existing`, i o tym mowi - /// komunikat, zamiast zostawiac zludzenie, ze samo zadziala. + /// Changing the credentials does NOT reconfigure an existing remote - a + /// token already issued keeps working on the old `client_id`. For the new + /// ones to take effect, `configure-remote --replace-existing` has to be run, + /// and the message says so instead of leaving the illusion that it will + /// happen by itself. public static func storeCredentials(clientID: String, clientSecret: String) async throws { try await KeychainStore.save(clientID, account: clientIDAccount, service: keychainService) try await KeychainStore.save( diff --git a/mac-app/Sources/CloudMachineCore/StatusLines.swift b/mac-app/Sources/CloudMachineCore/StatusLines.swift index 6fc32ac..9c6832a 100644 --- a/mac-app/Sources/CloudMachineCore/StatusLines.swift +++ b/mac-app/Sources/CloudMachineCore/StatusLines.swift @@ -1,125 +1,133 @@ import Foundation -/// Skladanie wierszy `drive-status`. Czyste funkcje, bo wiersz statusu jest -/// tym, co czlowiek CZYTA, pytajac "czy backup dziala" - a dotad nie dalo sie -/// go sprawdzic testem, bo powstawal w `print` wewnatrz polecenia CLI. +/// Building the `drive-status` lines. Pure functions, because the status line +/// is what a person READS when asking "is the backup working" - and until now +/// it could not be tested, because it was produced in a `print` inside a CLI +/// command. /// -/// Te wiersze lamaly sie juz dwa razy w ten sam sposob: przez zamiane "nie -/// wiem" na jakas wartosc. Raz przez podstawienie zer za brak odpowiedzi -/// rclone (stad `UploadState.queueUnknown`), raz przez `Optional(427)` po tym, -/// jak `BufferGuardService.freeGB()` slusznie przestal udawac, ze brak pomiaru -/// to zero. Dlatego kazda z ponizszych funkcji ma jawna galaz dla braku danych. +/// These lines have already broken twice in the same way: by turning "I do +/// not know" into some value. Once by substituting zeros for rclone not +/// answering (hence `UploadState.queueUnknown`), once via `Optional(427)` +/// after `BufferGuardService.freeGB()` rightly stopped pretending that a +/// missing measurement is zero. That is why each of the functions below has an +/// explicit branch for missing data. public enum StatusLines { - /// Wiersz "Montowanie Drive". + /// The "Drive mount" line. /// - /// Trzeci stan jest osobny z tego samego powodu, co przy kolejce: `BRAK` - /// znaczy "sprawdzilem i nie ma", a to jest wniosek, ktorego przy nieudanym - /// odczycie tablicy montowan nikt nie ma prawa wyciagnac. + /// The third state is separate for the same reason as with the queue: + /// `MISSING` means "I checked and it is not there", and that is a conclusion + /// nobody has the right to draw when reading the mount table failed. public static func mounted(_ state: Bool?) -> String { switch state { case .some(true): return "OK" - case .some(false): return "BRAK" - case .none: return "NIE WIADOMO - nie udalo sie odczytac tablicy montowan" + case .some(false): return L10n.tr("MISSING") + case .none: return L10n.tr("UNKNOWN - could not read the mount table") } } - /// Wiersz "Wolne na dysku". + /// The "Free on disk" line. /// - /// `nil` MUSI byc nazwany. Nie `Optional(427)` (bo to wyglada na usterke - /// programu, a nie na informacje) i nie podstawione zero (bo zero jest - /// KONKRETNA liczba, na ktorej dozorca bufora wstrzymuje Time Machine - - /// dokladnie ten blad naprawial drugi agent, zmieniajac typ na `Int?`). - /// Brak pomiaru znaczy, ze dozorca nie chroni juz dysku przed zapelnieniem, - /// wiec wiersz ma to powiedziec wprost. + /// `nil` MUST be named. Not `Optional(427)` (because that looks like a + /// program defect, not like information) and not a substituted zero + /// (because zero is a CONCRETE number at which the buffer guard pauses Time + /// Machine - exactly the bug the other agent fixed by changing the type to + /// `Int?`). A missing measurement means the guard no longer protects the + /// disk from filling up, so the line has to say that plainly. public static func freeDisk(_ gb: Int?) -> String { guard let gb else { - return "NIE ZMIERZONO - dozorca bufora nie wstrzyma Time Machine przed zapelnieniem dysku" + return L10n.tr( + "NOT MEASURED - the buffer guard will not pause Time Machine before the disk fills up") } return "\(gb) GB" } - /// Wiersz "Cache na dysku". + /// The "Cache on disk" line. /// - /// Osobny od wiersza o zaleglosci i to jest tu rzecz najwazniejsza: przez - /// caly wrzesien 2026 jeden wiersz "Bufor: 103 GB z 100G" mial odpowiadac - /// na dwa pytania - ile miejsca zajmuje cache i ile zostalo do wyslania. - /// Na drugie nie odpowiadal, bo cache przy `--vfs-cache-max-age 9999h` stoi - /// pod limitem stale (281 pomiarow, minimum 99 GB). Dozorca bufora podejmowal - /// na tej liczbie decyzje o wstrzymaniu Time Machine - stad ta zmiana. + /// Separate from the backlog line, and that is the most important thing + /// here: throughout September 2026 a single line "Buffer: 103 GB of 100G" was + /// supposed to answer two questions - how much space the cache takes and + /// how much is left to upload. It did not answer the second one, because + /// with `--vfs-cache-max-age 9999h` the cache sits at the limit constantly + /// (281 measurements, minimum 99 GB). The buffer guard made its decision to + /// pause Time Machine on this number - hence this change. public static func cacheSize(_ gb: Int?, limitGB: Int) -> String { guard let gb else { - return - "NIE ZMIERZONO - rclone nie odpowiedzial, a obchod katalogu bufora sie nie udal" + return L10n.tr( + "NOT MEASURED - rclone did not respond, and walking the buffer directory failed") } - return "\(gb) GB z \(limitGB)G" + return L10n.tr("%@ GB of %@G", "\(gb)", "\(limitGB)") } - /// Wiersz "Do wyslania" - ZALEGLOSC NIEWYSLANA, czyli ta wielkosc, na ktorej - /// dozorca bufora decyduje o pauzie i wznowieniu. + /// The "To upload" line - the UNSENT BACKLOG, i.e. the quantity on which the + /// buffer guard decides to pause and resume. /// - /// Liczba pozycji jest POMIAREM, gigabajty sa SZACUNKIEM z tej liczby (patrz - /// `BufferGuardService.backlogGB`) - dlatego stoi przy nich "~" i dlatego - /// pokazujemy oba. Wiersz, ktory podaje sam szacunek jako liczbe, ukrywa, jak - /// mocna jest podstawa decyzji o wstrzymaniu backupu. + /// The item count is a MEASUREMENT, the gigabytes are an ESTIMATE from that + /// count (see `BufferGuardService.backlogGB`) - that is why they carry a "~" + /// and why we show both. A line that gives only the estimate as a number + /// hides how solid the basis for the decision to pause the backup is. public static func backlog(_ gb: Int?, items: Int?) -> String { guard let gb, let items else { - return - "NIE WIADOMO - interfejs sterujacy rclone nie odpowiedzial (dozorca bufora nie wstrzyma ani nie wznowi Time Machine na tej podstawie)" + return L10n.tr( + "UNKNOWN - the rclone control interface did not respond (the buffer guard will neither pause nor resume Time Machine on this basis)" + ) } - return "~\(gb) GB (\(items) pozycji)" + return L10n.tr("~%@ GB (%@ items)", "\(gb)", "\(items)") } - /// Wiersze o powiadomieniu, ktorego NIE udalo sie doreczyc. + /// Lines about a notification that could NOT be delivered. /// - /// `HealthAlert.notify` zwraca od niedawna `Bool`, a `HealthAlert.report` - /// nie zamyka sprawy znacznikiem, dopoki powiadomienie nie doszlo - dzieki - /// temu alarm nie ginie juz po cichu na 12 godzin. Ale samo to nie wystarczy: - /// dopoki nikt tego nie WYPISUJE, czlowiek dowiaduje sie o niedoreczonym - /// alarmie tylko wtedy, gdy sam zajrzy do pliku stanu. Odmowa uprawnien do - /// powiadomien jest typowa dla procesu launchd, wiec to nie jest przypadek - /// teoretyczny. + /// `HealthAlert.notify` has recently started returning `Bool`, and + /// `HealthAlert.report` does not close the matter with a marker until the + /// notification has been delivered - thanks to that the alarm no longer + /// vanishes silently for 12 hours. But that alone is not enough: as long as + /// nobody PRINTS it, a person learns about an undelivered alarm only if they + /// look into the state file themselves. A refused notification permission + /// is typical for a launchd process, so this is not a theoretical case. /// - /// Pusta tablica = nie ma czego zglaszac. + /// Empty array = nothing to report. public static func undeliveredAlert(_ failure: (at: Date, summary: String, reason: String)?) -> [String] { guard let failure else { return [] } return [ - "NIEDORECZONY ALARM: \(failure.summary)", - " z \(BackupHealth.stamp(failure.at)), powod: \(failure.reason)", - " Powiadomienie systemowe nie doszlo - ten alarm zobaczysz TYLKO tutaj.", + L10n.tr("UNDELIVERED ALARM: %@", failure.summary), + " " + + L10n.tr("from %@, reason: %@", BackupHealth.stamp(failure.at), failure.reason), + " " + + L10n.tr("The system notification was not delivered - you will see this alarm ONLY here."), ] } - /// Wiersz "Czujka backupu", czyli kiedy `backup-health` ostatnio przebiegla. + /// The "Backup watchdog" line, i.e. when `backup-health` last ran. /// - /// Trzeci wiersz z tej samej rodziny, co dwa powyzej: pokazuje fakt, ktorego - /// inaczej nie widac. Czujka chodzi z `StartInterval 1800` i bez `KeepAlive`, - /// wiec wyladowana albo zawieszona nie daje ZADNEGO objawu poza cisza - a - /// cisza jest tu stanem normalnym (README: "Empty logs after a fresh install - /// are normal"). Bez tego wiersza "brak alarmu" znaczylo jednoczesnie - /// "backup dziala" i "nikt nie sprawdzal", czyli nie znaczylo nic. + /// A third line from the same family as the two above: it shows a fact that + /// is otherwise invisible. The watchdog runs with `StartInterval 1800` and + /// without `KeepAlive`, so when unloaded or hung it gives NO symptom other + /// than silence - and silence is the normal state here (README: "Empty logs + /// after a fresh install are normal"). Without this line "no alarm" meant + /// both "the backup works" and "nobody checked", i.e. it meant nothing. /// - /// Nazwany osobno i dodany na koncu `StatusLines`, zamiast wpleciony w - /// istniejace funkcje - `drive-status` przebudowuje rownolegle galaz - /// `naprawy/dozorca-bufora`. + /// Named separately and added at the end of `StatusLines` instead of being + /// woven into the existing functions - `drive-status` is being rebuilt in + /// parallel on the `naprawy/dozorca-bufora` branch (l10n-polish-ok: a git branch name). public static func watchdogRun(_ freshness: WatchdogHeartbeat.Freshness) -> String { switch freshness { case .fresh(let lastRun, let age): - return "\(BackupHealth.stamp(lastRun)) (\(BackupHealth.formatAge(age)) temu)" + return L10n.tr( + "%@ (%@ ago)", BackupHealth.stamp(lastRun), BackupHealth.formatAge(age)) case .stale(let lastRun, let age): - // Znacznik z przyszlosci (przestawiony zegar, plik przeniesiony z innej - // maszyny) tez jest brakiem wiedzy, a nie wiekiem - "-60 min temu" nie - // jest zdaniem, ktore cokolwiek mowi. + // A marker from the future (a clock that was changed, a file moved from + // another machine) is also a lack of knowledge, not an age - "-60 min + // ago" is not a sentence that says anything. guard age >= 0 else { - return "\(BackupHealth.stamp(lastRun)) - znacznik z PRZYSZLOSCI" + return L10n.tr("%@ - marker from the FUTURE", BackupHealth.stamp(lastRun)) } - return - "\(BackupHealth.stamp(lastRun)) (\(BackupHealth.formatAge(age)) temu) - " - + "CZUJKA MOZE NIE CHODZIC" + return L10n.tr( + "%@ (%@ ago) - THE WATCHDOG MAY NOT BE RUNNING", BackupHealth.stamp(lastRun), + BackupHealth.formatAge(age)) case .never: - return "NIGDY - czujka nie zapisala zadnego przebiegu" + return L10n.tr("NEVER - the watchdog has not recorded a single run") } } } diff --git a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift index 5e9a942..43be8a7 100644 --- a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift +++ b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift @@ -1,12 +1,12 @@ import Foundation -/// Parsowanie tekstowego wyjscia `tmutil status`/`tmutil destinationinfo` - -/// oba to wlasciwie "plist-jak" tekst, nie prawdziwy JSON/plist, wiec -/// najprosciej i najbezpieczniej parsowac linia po linii, tak jak robily to -/// oryginalne `awk` w bash. -/// Postep aktywnego backupu, wyciagniety z bloku `Progress = { ... }` w -/// `tmutil status`. Wszystkie pola opcjonalne - macOS nie zawsze wypelnia -/// caly blok (np. w fazach innych niz "Copying" czesc pol moze brakowac). +/// Parsing the text output of `tmutil status`/`tmutil destinationinfo` - both +/// are really "plist-like" text, not real JSON/plist, so it is simplest and +/// safest to parse line by line, the way the original `awk` in bash did. +/// Progress of an active backup, extracted from the `Progress = { ... }` block +/// in `tmutil status`. All fields optional - macOS does not always fill the +/// whole block (e.g. in phases other than "Copying" some fields may be +/// missing). public struct TimeMachineProgress: Equatable { public var phase: String? public var percent: Double? @@ -19,39 +19,42 @@ public struct TimeMachineProgress: Equatable { public enum TimeMachineStatus { - /// Limit czasu dla KAZDEGO wywolania `tmutil` w tym pliku. + /// Time limit for EVERY `tmutil` call in this file. /// - /// Do 23.09.2026 nie bylo tu zadnego limitu i to byla awaria czekajaca na - /// swoj dzien. `tmutil destinationinfo` siega do celu backupu, czyli na - /// montowanie FUSE-T lezace na Google Drive. Przy martwym montowaniu - /// (incydent ENXIO z 22.09) odczyt wchodzi w nieprzerywalne I/O i nie wraca - /// NIGDY. Czujka `backup-health` wisi wtedy na `currentReport()`, a launchd - /// ze `StartInterval` nie uruchamia drugiej instancji, dopoki zyje pierwsza - /// - czyli czujka milknie NA STALE, dokladnie w chwili, w ktorej ma mowic. + /// Until 23.09.2026 there was no limit here at all, and that was a failure + /// waiting for its day. `tmutil destinationinfo` reaches the backup + /// destination, i.e. the FUSE-T mount living on Google Drive. With a dead + /// mount (the ENXIO incident of 22.09) the read enters uninterruptible I/O + /// and NEVER returns. The `backup-health` watchdog then hangs on + /// `currentReport()`, and launchd with `StartInterval` does not start a + /// second instance while the first one is alive - i.e. the watchdog goes + /// silent PERMANENTLY, exactly at the moment it is supposed to speak. /// - /// Dobor liczby, a nie "jakis limit z palca": - /// - na zdrowym systemie `tmutil status` i `destinationinfo` odpowiadaja - /// grubo ponizej sekundy (mierzone recznie na tej maszynie), - /// - na montowaniu, ktore jeszcze zyje, ale odpowiada wolno, ten sam - /// odczyt potrafi trwac dziesiatki sekund, bo idzie przez siec, - /// - projekt ma juz jedna wpadke z limitem dobranym dla CZYSTEGO startu: - /// 120 s wystarczalo po restarcie, a po awarii zabraklo 10 s. Dlatego - /// 90 s to nie jest "tyle, ile zwykle trwa", tylko dwa rzedy wielkosci - /// zapasu nad przypadkiem zdrowym i spory zapas nad wolnym. + /// How the number was chosen, rather than "some limit off the top of the + /// head": + /// - on a healthy system `tmutil status` and `destinationinfo` answer well + /// below a second (measured by hand on this machine), + /// - on a mount that is still alive but answers slowly, the same read can + /// take tens of seconds, because it goes over the network, + /// - the project already has one slip with a limit chosen for a CLEAN + /// start: 120 s was enough after a restart, and after a failure it was + /// 10 s short. That is why 90 s is not "as long as it usually takes" but + /// two orders of magnitude of headroom over the healthy case and a + /// generous margin over the slow one. /// - /// Gorna granica CZEKANIA jest wyzsza niz ta liczba: `ProcessRunner` przy - /// `timeout` wysyla SIGTERM, po +5 s SIGKILL, a po +10 s poddaje sie - /// i zwraca blad (SIGKILL nie dziala na proces w stanie "U"). Realne - /// maksimum to wiec 100 s na jedno wywolanie. `backup-health` robi ich na - /// przebieg jedno, przy `StartInterval` 1800 s - zapas 18-krotny, wiec - /// limit nie moze zjesc okna uruchomienia. + /// The upper bound on WAITING is higher than this number: on `timeout` + /// `ProcessRunner` sends SIGTERM, after +5 s SIGKILL, and after +10 s it + /// gives up and returns an error (SIGKILL does not work on a process in + /// state "U"). The real maximum is therefore 100 s per call. + /// `backup-health` makes one of them per run, with a `StartInterval` of + /// 1800 s - an 18-fold margin, so the limit cannot eat up the run window. public static let commandTimeout: TimeInterval = 90 - /// Surowe wyjscie `tmutil`. `nil` znaczy DOKLADNIE jedno: tmutil NIE - /// ODPOWIEDZIAL (limit czasu albo nie dalo sie go uruchomic) - a nie - /// "odpowiedzial, ze nie". Kazdy wolajacy musi te dwie rzeczy rozroznic - /// sam, bo zlanie ich w `false`/`nil` to wlasnie ten rodzaj cichej awarii, - /// przed ktorym ostrzega naglowek `BackupHealth`. + /// Raw `tmutil` output. `nil` means EXACTLY one thing: tmutil DID NOT + /// ANSWER (time limit, or it could not be started) - not "it answered no". + /// Every caller has to tell these two apart itself, because merging them + /// into `false`/`nil` is exactly the kind of silent failure the + /// `BackupHealth` header warns against. private static func output(_ arguments: [String]) async -> String? { do { let result = try await ProcessRunner.run( @@ -59,35 +62,36 @@ public enum TimeMachineStatus { return result.stdout } catch { CMLogger.log( - "tmutil \(arguments.joined(separator: " ")): BRAK ODPOWIEDZI - \(error.localizedDescription)" + "tmutil \(arguments.joined(separator: " ")): NO ANSWER - \(error.localizedDescription)" ) return nil } } - /// Czy backup trwa. `nil` = tmutil nie odpowiedzial, czyli NIE WIADOMO. + /// Whether a backup is in progress. `nil` = tmutil did not answer, i.e. + /// UNKNOWN. /// - /// Rozroznienie jest tu istotne dla dozorcy bufora: "nie trwa" kaze mu - /// przejsc w czuwanie i zapomniec, ze nadzorowal backup, a "nie wiadomo" - /// musi zostawic stan bez zmiany. + /// The distinction matters here for the buffer guard: "not in progress" + /// tells it to go into standby and forget it was supervising a backup, while + /// "unknown" must leave the state unchanged. public static func runningState() async -> Bool? { guard let out = await output(["status"]) else { return nil } return isRunning(statusOutput: out) } - /// Skrot dla miejsc CZYSTO INFORMACYJNYCH (wydruk stanu, podglad w GUI), - /// gdzie brak odpowiedzi i "nie trwa" wygladaja tak samo i nic z tego nie - /// wynika. Wszedzie, gdzie z odpowiedzi wynika DECYZJA, uzywaj - /// `runningState()` i obsluz `nil` osobno. + /// Shortcut for PURELY INFORMATIONAL places (a status printout, a preview in + /// the GUI), where no answer and "not in progress" look the same and nothing + /// follows from it. Wherever a DECISION follows from the answer, use + /// `runningState()` and handle `nil` separately. public static func isRunning() async -> Bool { await runningState() ?? false } - /// Czysta funkcja parsujaca - wydzielona z `isRunning()`, zeby dalo sie ja - /// przetestowac bez `tmutil` na prawdziwym Maku (patrz `CooldownGate` dla - /// tego samego wzorca w tym projekcie). Cala logika parsujaca w tym pliku - /// wczesniej nie miala ani jednego testu, mimo ze to wlasnie tutaj (blednie - /// zgadywana nazwa wolumenu, kolizje mountowania) siedzialy realne bugi. + /// Pure parsing function - split out of `isRunning()` so it can be tested + /// without `tmutil` on a real Mac (see `CooldownGate` for the same pattern + /// in this project). All the parsing logic in this file previously had not a + /// single test, even though this is exactly where real bugs were (a wrongly + /// guessed volume name, mount collisions). static func isRunning(statusOutput: String) -> Bool { for line in statusOutput.split(separator: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -98,15 +102,16 @@ public enum TimeMachineStatus { return false } - /// Parsuje `tmutil status` linia po linii (ten sam styl co reszta pliku) - - /// klucze wewnatrz bloku `Progress` (`bytes`, `files`, `TimeRemaining`...) - /// sa unikalne w calym wyjsciu, wiec nie trzeba osobno sledzic zagniezdzenia - /// nawiasow klamrowych. Zwraca `nil`, jesli aktualnie nic nie kopiuje. + /// Parses `tmutil status` line by line (the same style as the rest of the + /// file) - the keys inside the `Progress` block (`bytes`, `files`, + /// `TimeRemaining`...) are unique in the whole output, so there is no need to + /// track the nesting of braces separately. Returns `nil` if nothing is being + /// copied at the moment. /// - /// `nil` znaczy tu takze "tmutil nie odpowiedzial" i to jedyne miejsce - /// w tym pliku, gdzie zlanie tych dwoch przypadkow jest w porzadku: postep - /// sluzy WYLACZNIE do pokazania paska w interfejsie i zadna decyzja z niego - /// nie wynika. Kto pyta o postep, i tak wczesniej pyta `runningState()`. + /// Here `nil` also means "tmutil did not answer", and this is the only place + /// in this file where merging the two cases is fine: progress serves ONLY to + /// show a bar in the interface and no decision follows from it. Whoever asks + /// about progress asks `runningState()` beforehand anyway. public static func currentProgress() async -> TimeMachineProgress? { guard let out = await output(["status"]) else { return nil } return currentProgress(statusOutput: out) @@ -139,8 +144,9 @@ public enum TimeMachineStatus { return running ? progress : nil } - /// Odpowiednik `tmutil destinationinfo | awk ... -v mp="$SP_MOUNT"` - szuka - /// bloku, ktorego "Mount Point" zawiera `mountPoint`, i zwraca jego ID. + /// Counterpart of `tmutil destinationinfo | awk ... -v mp="$SP_MOUNT"` - + /// looks for the block whose "Mount Point" contains `mountPoint`, and returns + /// its ID. public static func destinationID(forMountPointContaining mountPoint: String) async -> String? { guard let out = await output(["destinationinfo"]) else { return nil } return destinationID(forMountPointContaining: mountPoint, destinationInfoOutput: out) @@ -167,9 +173,9 @@ public enum TimeMachineStatus { return nil } - /// Limit (GB) skonfigurowany dla celu TM pod danym punktem montowania - - /// parsuje linie "Quota" (np. "300 GB") z bloku znalezionego tak samo jak - /// w `destinationID`. `nil`, jesli TM nie raportuje limitu dla tego celu. + /// Quota (GB) configured for the TM destination at the given mount point - + /// parses the "Quota" line (e.g. "300 GB") from the block found the same way + /// as in `destinationID`. `nil` if TM reports no quota for this destination. public static func destinationQuotaGB(forMountPointContaining mountPoint: String) async -> Double? { @@ -198,38 +204,38 @@ public enum TimeMachineStatus { return nil } - /// Punkt montowania AKTUALNIE zarejestrowanego celu Time Machine - /// (architektura gwarantuje dokladnie jeden aktywny cel lokalny - patrz - /// LocalBackupService). Zwraca prawdziwa, zarejestrowana sciezke zamiast - /// zgadywac ja z domyslnej nazwy wolumenu - uzytkownik moze nazwac lokalny - /// wolumin dowolnie (np. recznie utworzona partycja "TimeMachine" zamiast - /// domyslnej "CloudMachine-Local"), a zgadywanie po nazwie bylo realnym - /// bugiem: po recznej zmianie nazwy wolumenu caly status GUI/watchdogow - /// pokazywal "brak lokalnego woluminu" / "TimeMachine niezarejestrowany", - /// mimo poprawnie dzialajacego, zarejestrowanego celu. + /// Mount point of the CURRENTLY registered Time Machine destination (the + /// architecture guarantees exactly one active local destination - see + /// LocalBackupService). Returns the real, registered path instead of + /// guessing it from the default volume name - the user may name the local + /// volume anything (e.g. a manually created "TimeMachine" partition instead + /// of the default "CloudMachine-Local"), and guessing by name was a real + /// bug: after a manual volume rename the whole GUI/watchdog status showed + /// "no local volume" / "TimeMachine not registered", despite a correctly + /// working, registered destination. /// - /// Zwraca `nil` zarowno przy braku celu, jak i przy braku odpowiedzi od - /// tmutil - kto musi te dwie rzeczy rozroznic (czujka `backup-health`: - /// jedno znaczy "ktos przestawil cel", drugie "nie wiemy nic"), pyta - /// `destinationReading()`. + /// Returns `nil` both when there is no destination and when tmutil did not + /// answer - whoever has to tell these two apart (the `backup-health` + /// watchdog: one means "someone changed the destination", the other "we know + /// nothing") asks `destinationReading()`. public static func currentDestinationMountPoint() async -> String? { if case .mountPoint(let path) = await destinationReading() { return path } return nil } - /// Odpowiedz `tmutil destinationinfo` z jawnym, trzecim stanem: BRAK - /// ODPOWIEDZI. + /// The answer of `tmutil destinationinfo` with an explicit third state: NO + /// ANSWER. /// - /// Trzeci stan musi istniec osobno z tego samego powodu, co `queueUnknown` - /// w `UploadState`: bez niego zawieszony tmutil wygladal dokladnie tak samo - /// jak wyrejestrowany cel i czujka zglaszalaby "Time Machine nie wskazuje - /// na CloudMachine" - zdanie prawdziwie brzmiace i falszywe, ktore wysyla - /// czlowieka w zla strone. + /// The third state has to exist separately for the same reason as + /// `queueUnknown` in `UploadState`: without it a hung tmutil looked exactly + /// like an unregistered destination and the watchdog would report "Time + /// Machine does not point to CloudMachine" - a true-sounding and false + /// sentence that sends the person the wrong way. public enum DestinationReading: Equatable, Sendable { case mountPoint(String) - /// tmutil odpowiedzial, ale zadnego celu nie ma. + /// tmutil answered, but there is no destination. case none - /// tmutil nie odpowiedzial w limicie czasu. + /// tmutil did not answer within the time limit. case noAnswer } @@ -250,15 +256,15 @@ public enum TimeMachineStatus { return nil } - /// Wszystkie zarejestrowane ID celow Time Machine - uzywane przez - /// `LocalBackupService.setAsDestination` do usuniecia poprzednich celow - /// przed zarejestrowaniem nowego (ta architektura utrzymuje dokladnie - /// jeden aktywny lokalny cel, w przeciwienstwie do legacy podejscia). + /// All registered Time Machine destination IDs - used by + /// `LocalBackupService.setAsDestination` to remove previous destinations + /// before registering a new one (this architecture keeps exactly one active + /// local destination, unlike the legacy approach). /// - /// Pusta lista przy braku odpowiedzi jest tu BEZPIECZNA i tylko dlatego - /// zostaje: jedyny wolajacy kasuje po kolei zwrocone cele przed - /// zarejestrowaniem nowego, wiec "nie wiem" konczy sie nieusunieciem - /// czegos, a nie usunieciem czegos nie tego. + /// An empty list on no answer is SAFE here, and that is the only reason it + /// stays: the only caller deletes the returned destinations one by one + /// before registering a new one, so "I do not know" ends with something not + /// being removed, not with the wrong thing being removed. public static func allDestinationIDs() async -> [String] { guard let out = await output(["destinationinfo"]) else { return [] } return allDestinationIDs(destinationInfoOutput: out) @@ -276,8 +282,9 @@ public enum TimeMachineStatus { return ids } - /// `nil` = tmutil nie odpowiedzial. Celowo NIE `false`: "w konfiguracji nie - /// ma tego napisu" i "nie udalo sie zapytac" to dwie rozne odpowiedzi. + /// `nil` = tmutil did not answer. Deliberately NOT `false`: "the + /// configuration does not contain this text" and "could not ask" are two + /// different answers. public static func destinationInfoContains(_ needle: String) async -> Bool? { guard let out = await output(["destinationinfo"]) else { return nil } return out.contains(needle) diff --git a/mac-app/Sources/CloudMachineCore/UploadDrain.swift b/mac-app/Sources/CloudMachineCore/UploadDrain.swift index 130dc21..63a6eec 100644 --- a/mac-app/Sources/CloudMachineCore/UploadDrain.swift +++ b/mac-app/Sources/CloudMachineCore/UploadDrain.swift @@ -1,51 +1,52 @@ import Foundation -/// Czekanie, az rclone wysle zaleglosc, ZANIM ruszy `hdiutil attach`. +/// Waiting for rclone to upload the backlog BEFORE `hdiutil attach` starts. /// -/// Do 01.10.2026 `attach()` czekal na pusta kolejke sztywne 120 s. To starcza -/// przy zwyklym podpieciu, ale nie po restarcie Maca bez `prepare-shutdown`: -/// przy `--vfs-write-back 600s` w buforze zostaje wtedy ~10 min zapisow -/// (01.10 - ~19 GB, ~600 pasm). Zmierzone tego dnia: rclone wczytywal brudny -/// cache 15:32-15:36, wysylal 15:37-15:42 (~120 pasm/min), a 120 s minelo -/// w polowie. Pierwsze `hdiutil attach` ruszylo o 15:39 w pelnej wysylce, -/// otworzylo plik blokady i WISIALO 5 min, po czym padlo z "image not -/// recognized"; druga proba padla po 90 s, trzecia - juz w ciszy - przeszla -/// w 15 s. Time Machine stal bez celu 17 min, a czujka krzyczala AWARIA. +/// Until 01.10.2026 `attach()` waited a fixed 120 s for an empty queue. That is +/// enough for an ordinary attach, but not after a Mac restart without +/// `prepare-shutdown`: with `--vfs-write-back 600s` about 10 min of writes are +/// left in the buffer (01.10 - ~19 GB, ~600 bands). Measured that day: rclone +/// read the dirty cache 15:32-15:36, uploaded 15:37-15:42 (~120 bands/min), and +/// the 120 s ran out halfway through. The first `hdiutil attach` started at +/// 15:39 in the middle of the full upload, opened the lock file and HUNG for +/// 5 min, then failed with "image not recognized"; the second attempt failed +/// after 90 s, the third - by then in quiet - succeeded in 15 s. Time Machine +/// sat without a destination for 17 min, and the monitor was shouting FAILURE. /// -/// Sztywny dluzszy limit nie jest odpowiedzia: przy wyczerpanym dobowym -/// limicie Google kolejka nie zejdzie wcale i kazde podpiecie placilo by go -/// w calosci. Czekamy wiec tak dlugo, jak wysylka ROBI POSTEP, a poddajemy -/// sie, gdy przez `stallTimeout` liczba niewyslanych pozycji nie spadla -/// ponizej dotychczasowego minimum. `maxTotal` to twardy sufit - launchd -/// czeka na ten proces, a obraz bez podpiecia to Time Machine bez celu. +/// A longer fixed limit is not the answer: with Google's daily limit exhausted +/// the queue will not drain at all, and every attach would pay the whole limit. +/// So we wait as long as the upload IS MAKING PROGRESS, and give up when for +/// `stallTimeout` the number of unsent items has not dropped below its minimum +/// so far. `maxTotal` is a hard ceiling - launchd waits for this process, and +/// an image that is not attached means Time Machine has no destination. public enum UploadDrain { public static let defaultStallTimeout: TimeInterval = 120 - /// 20 min: zaleglosc z 01.10 (~19 GB) zeszla w ~6 min, wiec to trzy razy - /// tyle. Wiecej i tak nie ma sensu - kolejka, ktora rosnie szybciej, niz - /// schodzi, to juz nie rozruch, tylko zator, i zglosi go dozorca. + /// 20 min: the backlog of 01.10 (~19 GB) drained in ~6 min, so this is three + /// times that. More makes no sense anyway - a queue that grows faster than it + /// drains is no longer a start-up but a jam, and the watchdog will report it. public static let defaultMaxTotal: TimeInterval = 1200 public static let defaultPoll: TimeInterval = 5 - /// Co ile ponawiamy przesuniecie terminow wysylki. Po starcie rclone - /// wczytuje brudny cache pasmo po pasmie (01.10: cztery minuty) i kazde - /// dostaje termin `writeBackSeconds` w przod - jedno przesuniecie na - /// poczatku nie obejmie tych wczytanych pozniej. + /// How often we repeat moving the upload deadlines forward. After start-up + /// rclone reads the dirty cache band by band (01.10: four minutes) and each + /// one gets a deadline `writeBackSeconds` ahead - a single move at the start + /// would not cover the ones read later. public static let defaultExpiryInterval: TimeInterval = 60 public enum Outcome: Equatable { - /// Kolejka pusta - mozna montowac. + /// Queue empty - safe to mount. case idle - /// Przez `stallTimeout` brak postepu. + /// No progress for `stallTimeout`. case stalled(unsent: Int) - /// Postep byl, ale nie zdazyl przed `maxTotal`. + /// There was progress, but it did not finish before `maxTotal`. case timedOut(unsent: Int) - /// rclone nie odpowiadal przez caly `stallTimeout`. + /// rclone did not answer for the whole `stallTimeout`. case noAnswer } - /// `unsent` zwraca liczbe niewyslanych pozycji albo `nil`, gdy rclone nie - /// odpowiedzial. Brak odpowiedzi nie jest postepem: liczy sie do - /// `stallTimeout` tak samo jak stojaca kolejka. + /// `unsent` returns the number of unsent items, or `nil` when rclone did not + /// answer. No answer is not progress: it counts towards `stallTimeout` just + /// like a queue that is standing still. public static func wait( stallTimeout: TimeInterval = defaultStallTimeout, maxTotal: TimeInterval = defaultMaxTotal, diff --git a/mac-app/Sources/CloudMachineCore/UploadState.swift b/mac-app/Sources/CloudMachineCore/UploadState.swift index 7326d68..da60252 100644 --- a/mac-app/Sources/CloudMachineCore/UploadState.swift +++ b/mac-app/Sources/CloudMachineCore/UploadState.swift @@ -1,60 +1,61 @@ import Foundation -/// Odpowiedz na jedyne pytanie, ktore uzytkownik naprawde zadaje: czy kopia -/// dolatuje na Google Drive, a jesli nie - dlaczego i czy trzeba cos zrobic. +/// The answer to the only question the user really asks: is the backup +/// reaching Google Drive, and if not - why, and do I have to do something. /// -/// Powod istnienia: dotychczas interfejs pokazywal liczniki (kolejka, bledy, -/// rozmiar bufora) i surowy dziennik. Z jednego i drugiego da sie wyczytac -/// odpowiedz, ale trzeba wiedziec, czego szukac - a przy zatorze 12 wrzesnia -/// 2026 nie wyczytal jej nikt. Liczby opisuja stan, nie tlumacza go. +/// Reason to exist: until now the interface showed counters (queue, errors, +/// buffer size) and the raw log. The answer can be read from both, but you have +/// to know what to look for - and during the jam of 12 September 2026 nobody +/// read it. Numbers describe the state, they do not explain it. /// -/// Rozroznienie, ktore najbardziej tu wazy: **limit dobowy mija sam, brak -/// miejsca nie**. Jedno znaczy "poczekaj", drugie "zrob cos". Wygladaja -/// podobnie w kazdym liczniku i roznia sie wszystkim, co z nich wynika. +/// The distinction that matters most here: **the daily limit passes by itself, +/// lack of space does not**. One means "wait", the other "do something". They +/// look alike in every counter and differ in everything that follows from them. public enum UploadState: Equatable, Sendable { - /// Nie ma polaczenia z Dyskiem - kopie zapisuja sie tylko lokalnie. + /// No connection to Drive - backups are only written locally. case mountDown - /// Dysk pelny. NIE minie samo. + /// Drive is full. It will NOT pass by itself. case driveFull - /// rclone odpuscil te pliki. Istnieja wylacznie na tym Macu. + /// rclone gave up on these files. They exist only on this Mac. case failedFiles(Int) - /// Bufor zapchany samymi niewyslanymi danymi. + /// Buffer clogged with nothing but unsent data. case bufferFull - /// Dobowy limit zapisu Google wyczerpany. Mija SAM. + /// Google's daily write limit is exhausted. It passes BY ITSELF. case dailyQuotaExhausted - /// Wysylka idzie. + /// Upload is moving. case flowing(queued: Int) - /// Nic nie czeka - wszystko jest na Dysku. + /// Nothing waiting - everything is on Drive. case upToDate - /// Nie udalo sie odczytac kolejki - stan wysylki jest NIEZNANY. + /// The queue could not be read - the upload state is UNKNOWN. /// - /// Trzeci stan obok "dobrze" i "zle", i musi istniec osobno. Wczesniej brak - /// odpowiedzi od `rclone rc` konczyl sie podstawieniem zer, z czego wychodzil - /// `.upToDate`: przy 386 pasmach w kolejce interfejs pisal "Wszystko wyslane - /// na Google Drive". Falszywy spokoj jest gorszy od braku odpowiedzi, bo - /// gasi czujke dokladnie wtedy, gdy nikt nie wie, co sie dzieje. + /// A third state next to "good" and "bad", and it must exist on its own. + /// Previously no answer from `rclone rc` ended with zeros substituted, which + /// gave `.upToDate`: with 386 bands in the queue the interface said + /// "Everything uploaded to Google Drive". False calm is worse than no answer, + /// because it silences the monitor exactly when nobody knows what is going on. case queueUnknown - /// Czy stan wymaga reakcji czlowieka. `false` znaczy "samo sie ulozy", - /// a nie "wszystko dobrze" - patrz `dailyQuotaExhausted`. + /// Whether the state needs a person to react. `false` means "it will sort + /// itself out", not "all good" - see `dailyQuotaExhausted`. public var needsAttention: Bool { switch self { case .mountDown, .driveFull, .failedFiles, .bufferFull: return true - // Nieznany stan NIE wola o czlowieka: pojedyncze przekroczenie limitu - // czasu zdarza sie przy obciazonym rclone i mija samo. Gdy nie mija, - // alarmuje `backup-health` - od trwalosci jest on, nie kolor karty. + // An unknown state does NOT call for a person: a single timeout happens + // when rclone is under load and passes by itself. When it does not pass, + // `backup-health` raises the alarm - persistence is its job, not the + // card's colour. case .dailyQuotaExhausted, .flowing, .upToDate, .queueUnknown: return false } } - /// Czy stan jest NOMINALNY. + /// Whether the state is NOMINAL. /// - /// Rozne od `needsAttention` i celowo: przy wyczerpanym limicie dobowym nikt - /// nie musi nic robic, ale pasma leza wtedy wylacznie na tym Macu - a pasek - /// menu nie ma prawa swiecic wtedy na zielono. Audyt wrzesniowy zaczal sie - /// dokladnie od tego, ze "Gotowe" wyswietlalo sie przy pasmach, ktore nigdy - /// nie dolecialy na Dysk. + /// Different from `needsAttention`, on purpose: with the daily limit + /// exhausted nobody has to do anything, but the bands then sit only on this + /// Mac - and the menu bar has no business glowing green then. The September + /// audit started exactly from "Ready" being shown for bands that never made + /// it to Drive. public var isNominal: Bool { switch self { case .flowing, .upToDate: return true @@ -63,101 +64,88 @@ public enum UploadState: Equatable, Sendable { } } - /// Czy kopia faktycznie dolatuje na Dysk w tej chwili. + /// Whether the backup is actually reaching Drive right now. public var isMovingData: Bool { if case .flowing = self { return true } return false } - /// Etykieta nad naglowkiem karty: co uzytkownik ma z tym zrobic. + /// Label above the card heading: what the user should do about it. /// - /// Wczesniej interfejs skladal ja z dwoch bool-i (`needsAttention`, - /// `isNominal`), wiec umial wyrazic tylko trzy warianty i kazdy nowy stan - /// musial sie do ktoregos wcisnac. `queueUnknown` nie pasuje do zadnego: - /// nie jest awaria, nie jest porzadkiem i nie jest tez "minie samo", bo nikt - /// nie wie, czy jest co przeczekiwac. + /// Previously the interface built it from two bools (`needsAttention`, + /// `isNominal`), so it could express only three variants and every new state + /// had to squeeze into one of them. `queueUnknown` fits none: it is not a + /// failure, it is not fine, and it is not "passes by itself" either, because + /// nobody knows whether there is anything to wait out. public var badge: String { switch self { - case .mountDown, .driveFull, .failedFiles, .bufferFull: return "WYMAGA REAKCJI" - case .dailyQuotaExhausted: return "MINIE SAMO — NIC NIE RÓB" - case .queueUnknown: return "NIE WIADOMO — SPRAWDŹ ZA CHWILĘ" - case .flowing, .upToDate: return "W PORZĄDKU" + case .mountDown, .driveFull, .failedFiles, .bufferFull: return L10n.tr("ACTION NEEDED") + case .dailyQuotaExhausted: return L10n.tr("WILL PASS BY ITSELF — DO NOTHING") + case .queueUnknown: return L10n.tr("UNKNOWN — CHECK AGAIN SHORTLY") + case .flowing, .upToDate: return L10n.tr("ALL GOOD") } } - /// Jedno zdanie do paska i naglowka karty. + /// One sentence for the menu bar and the card heading. public var headline: String { switch self { - case .mountDown: return "Wysyłka nie działa" - case .driveFull: return "Wysyłka stoi — brak miejsca na Google Drive" - case .failedFiles(let count): return "Nie wysłano \(count) fragmentów kopii" - case .bufferFull: return "Wysyłka nie nadąża za zapisem" - case .dailyQuotaExhausted: return "Wysyłka wstrzymana — dobowy limit Google" - case .flowing(let queued): return "Wysyłanie na Google Drive — \(queued) w kolejce" - case .upToDate: return "Wszystko wysłane na Google Drive" - case .queueUnknown: return "Nie wiadomo, co czeka w kolejce" + case .mountDown: return L10n.tr("Upload is not working") + case .driveFull: return L10n.tr("Upload stopped — no space left on Google Drive") + case .failedFiles(let count): return L10n.tr("%@ backup fragments not uploaded", "\(count)") + case .bufferFull: return L10n.tr("Upload cannot keep up with writes") + case .dailyQuotaExhausted: return L10n.tr("Upload paused — Google daily limit") + case .flowing(let queued): return L10n.tr("Uploading to Google Drive — %@ queued", "\(queued)") + case .upToDate: return L10n.tr("Everything uploaded to Google Drive") + case .queueUnknown: return L10n.tr("Unknown what is waiting in the queue") } } - /// Co to znaczy i co z tym zrobic. Pisane do czytania, nie do diagnozy - - /// komu potrzebne liczby, ten ma `cloudmachine-agent drive-status`. + /// What it means and what to do about it. Written for reading, not for + /// diagnosis - whoever needs numbers has `cloudmachine-agent drive-status`. public var explanation: String { switch self { case .mountDown: - return """ - Nie ma połączenia z Google Drive, więc kopie powstają tylko na tym Macu. \ - Jeśli to nie minie samo w kilka minut, sprawdź sieć i połączenie z Dyskiem. - """ + return L10n.tr( + "There is no connection to Google Drive, so backups are only made on this Mac. If this does not pass by itself within a few minutes, check the network and the connection to Drive." + ) case .driveFull: - return """ - Na Google Drive nie ma już miejsca. To NIE minie samo — trzeba zwolnić \ - miejsce na Dysku. Do tego czasu Time Machine jest wstrzymany, żeby nie \ - zapełnić dysku tego Maca. - """ + return L10n.tr( + "There is no space left on Google Drive. This will NOT pass by itself — you need to free up space on Drive. Until then Time Machine is paused, so that it does not fill up this Mac's disk." + ) case .failedFiles(let count): - return """ - \(count) fragmentów kopii nie udało się wysłać i rclone przestał próbować. \ - Te fragmenty istnieją wyłącznie na tym Macu, więc kopia na Dysku jest \ - niekompletna. To wymaga sprawdzenia. - """ + return L10n.tr( + "%@ backup fragments could not be uploaded and rclone stopped trying. These fragments exist only on this Mac, so the backup on Drive is incomplete. This needs checking.", + "\(count)") case .bufferFull: - return """ - Time Machine pisze szybciej, niż idzie wysyłka, i bufor się zapełnił. \ - Backup zostanie wstrzymany, aż wysyłka nadgoni — to zabezpieczenie przed \ - zapełnieniem dysku, nie awaria. - """ + return L10n.tr( + "Time Machine is writing faster than the upload goes, and the buffer has filled up. The backup will be paused until the upload catches up — this is a safeguard against filling up the disk, not a failure." + ) case .dailyQuotaExhausted: - return """ - Google przyjmuje 750 GB na dobę i ten limit został wyczerpany. \ - Nie trzeba nic robić: limit odnawia się sam, zwykle w kilka godzin. \ - Kopie Time Machine powstają przez ten czas normalnie i czekają w buforze — \ - wyślą się, gdy tylko Google znów zacznie przyjmować. - """ + return L10n.tr( + "Google accepts 750 GB per day and that limit has been used up. There is nothing to do: the limit renews by itself, usually within a few hours. Time Machine backups are made normally in the meantime and wait in the buffer — they will be uploaded as soon as Google starts accepting again." + ) case .flowing(let queued): - return "\(queued) fragmentów kopii czeka w kolejce i leci na Dysk." + return L10n.tr("%@ backup fragments are queued and on their way to Drive.", "\(queued)") case .upToDate: - return "Nic nie czeka w kolejce — kopia na Google Drive jest kompletna." + return L10n.tr("Nothing is waiting in the queue — the backup on Google Drive is complete.") case .queueUnknown: - return """ - rclone nie odpowiedział na pytanie o kolejkę, więc nie wiadomo, ile kopii \ - czeka jeszcze na wysłanie. To nie znaczy, że coś się zepsuło — pod obciążeniem \ - odpowiedź potrafi się spóźnić. Znaczy tylko tyle, że w tej chwili nikt tego \ - nie wie. Jeśli utrzymuje się dłużej, zgłosi to kontrola cyklu backupu. - """ + return L10n.tr( + "rclone did not answer the question about the queue, so it is unknown how many backups are still waiting to be uploaded. This does not mean something broke — under load the answer can be late. It only means that right now nobody knows. If it persists, the backup cycle check will report it." + ) } } - /// Sklada stan z pojedynczych faktow. + /// Builds the state from individual facts. /// - /// Kolejnosc NIE jest dowolna - od najtwardszego faktu do najmiekszego. - /// `failedFiles` wyprzedza limit dobowy, bo "rclone odpuscil" znaczy, ze - /// kopia jest niekompletna TERAZ, a limit znaczy tylko, ze poczeka. - /// `queueKnown` NIE ma wartosci domyslnej i to jest celowe. Wszystkie - /// liczniki ponizej pochodza z `vfs/stats`; gdy rclone nie odpowie, wolajacy - /// ma pod reka same zera i zadne z nich nie znaczy "zero". Wymuszony - /// argument zmusza kazde miejsce w kodzie do odpowiedzi na pytanie, ktore - /// wczesniej przemilczano - stad brala sie plansza "Wszystko wyslane" przy - /// pelnej kolejce. + /// The order is NOT arbitrary - from the hardest fact to the softest. + /// `failedFiles` comes before the daily limit, because "rclone gave up" means + /// the backup is incomplete NOW, while the limit only means it will wait. + /// `queueKnown` has NO default value, and that is deliberate. All counters + /// below come from `vfs/stats`; when rclone does not answer, the caller has + /// only zeros at hand and none of them means "zero". The required argument + /// forces every place in the code to answer the question that used to be + /// skipped - that is where the "Everything uploaded" screen with a full queue + /// came from. public static func from( mounted: Bool, queueKnown: Bool, @@ -170,10 +158,11 @@ public enum UploadState: Equatable, Sendable { ) -> UploadState { if !mounted { return .mountDown } if driveFull { return .driveFull } - // Przed kazdym stanem liczonym z licznikow, bo bez odczytu kolejki nie da - // sie odroznic "nic nie czeka" od "nie wiem, co czeka". Limit dobowy tez - // tu przepada, i slusznie: skoro nie wiadomo, czy rclone czegos nie - // porzucil, to "poczekaj, minie samo" nie jest uczciwa odpowiedzia. + // Before every state computed from counters, because without reading the + // queue "nothing is waiting" cannot be told from "I do not know what is + // waiting". The daily limit is lost here too, and rightly so: when it is + // unknown whether rclone has abandoned something, "wait, it will pass" is + // not an honest answer. if !queueKnown { return .queueUnknown } if failedFiles > 0 { return .failedFiles(failedFiles) } if bufferOutOfSpace { return .bufferFull } diff --git a/mac-app/Sources/CloudMachineCore/WatchdogHeartbeat.swift b/mac-app/Sources/CloudMachineCore/WatchdogHeartbeat.swift index 946efc5..c097108 100644 --- a/mac-app/Sources/CloudMachineCore/WatchdogHeartbeat.swift +++ b/mac-app/Sources/CloudMachineCore/WatchdogHeartbeat.swift @@ -1,67 +1,67 @@ import Foundation -/// Znacznik "czujka NAPRAWDE przebiegla", czyli nadzor nad samym nadzorem. +/// A "the watchdog REALLY ran" marker, i.e. supervision of the supervisor +/// itself. /// -/// `backup-health` chodzi z `StartInterval 1800` i BEZ `KeepAlive`. Jesli -/// agent zostanie wyladowany (`launchctl bootout`, nieudana instalacja, zmiana -/// nazwy binarki) albo zawisnie w nieprzerywalnym I/O na montowaniu Google -/// Drive, to jedynym objawem jest CISZA - a cisza jest tu domyslnym, -/// oczekiwanym stanem. README mowi wprost: "Empty logs after a fresh install -/// are normal - the agents only write when something happens". Czyli dokladnie -/// tak samo wyglada czujka, ktora dziala i nie ma o czym donosic, jak czujka, -/// ktorej nie ma. +/// `backup-health` runs with `StartInterval 1800` and WITHOUT `KeepAlive`. If +/// the agent gets unloaded (`launchctl bootout`, a failed install, a renamed +/// binary) or hangs in uninterruptible I/O on the Google Drive mount, the only +/// symptom is SILENCE - and silence is the default, expected state here. The +/// README says it plainly: "Empty logs after a fresh install are normal - the +/// agents only write when something happens". So a watchdog that works and has +/// nothing to report looks exactly like a watchdog that is not there. /// -/// Dlatego kazdy przebieg zostawia po sobie PLIK z data. Brak alarmu przestaje -/// znaczyc "wszystko dobrze" i zaczyna znaczyc "czujka przebiegla o 14:32 i nie -/// miala o czym donosic" - albo "czujka nie przebiegla od trzech dni", co jest -/// zupelnie inna informacja. +/// That is why every run leaves a FILE with a date behind. No alert stops +/// meaning "all good" and starts meaning "the watchdog ran at 14:32 and had +/// nothing to report" - or "the watchdog has not run for three days", which is +/// completely different information. /// -/// Znacznik NIE jest kanalem alarmu. Zewnetrzny kanal (poczta, push) to -/// decyzja wlasciciela o architekturze, nie poprawka - tutaj tylko odkladamy -/// fakt, ktory `drive-status` i panel POKAZUJA, gdy czlowiek zaglada sam. +/// The marker is NOT an alert channel. An external channel (mail, push) is the +/// owner's architectural decision, not a fix - here we only record a fact that +/// `drive-status` and the panel SHOW when a person looks for themselves. /// -/// Plik lezy w `appSupportDir`, obok stanu alarmu (`health-alert.json`), a nie -/// w buforze ani w obrazie - czujka nie moze dzielic losu tego, co nadzoruje. +/// The file lives in `appSupportDir`, next to the alert state +/// (`health-alert.json`), not in the buffer or in the image - the watchdog must +/// not share the fate of what it supervises. public enum WatchdogHeartbeat { - /// Znacznik czujki `backup-health`. + /// Marker of the `backup-health` watchdog. /// - /// Zwykly tekst, nie JSON: to plik, ktory czlowiek `cat`-uje w trakcie - /// diagnozy, i ma byc czytelny bez narzedzi. + /// Plain text, not JSON: it is a file a person `cat`s during diagnosis, and + /// it has to be readable without tools. public static var backupHealthFile: URL { CMPaths.appSupportDir.appendingPathComponent("backup-health-last-run") } - /// Po tylu godzinach ciszy uznajemy, ze czujka NIE CHODZI. + /// After this many hours of silence we consider the watchdog NOT RUNNING. /// - /// `StartInterval` czujki to 1800 s, wiec godzina to dwa pominiete przebiegi - /// z rzedu - za duzo na przypadek, a jednoczesnie z zapasem na przebieg, - /// ktory trwa dlugo (kazde wywolanie tmutil ma limit czasu i czujka potrafi - /// go wykorzystac). + /// The watchdog's `StartInterval` is 1800 s, so an hour is two missed runs in + /// a row - too many to be chance, while leaving headroom for a run that takes + /// long (every tmutil call has a time limit and the watchdog can use it up). public static let maxSilenceHours = 1.0 - /// Co wiemy o ostatnim przebiegu czujki. + /// What we know about the watchdog's last run. /// - /// Trzy stany, nie dwa, z tego samego powodu co `DestinationReading` i - /// `queueKnown`: "czujka nie zapisala ani jednego przebiegu" to inna - /// informacja niz "ostatni przebieg byl dawno". Pierwsze zdarza sie na - /// swiezej instalacji i po aktualizacji, ktora dodala ten znacznik. + /// Three states, not two, for the same reason as `DestinationReading` and + /// `queueKnown`: "the watchdog has not recorded a single run" is different + /// information from "the last run was long ago". The former happens on a + /// fresh install and after the update that added this marker. public enum Freshness: Equatable { case fresh(lastRun: Date, age: TimeInterval) case stale(lastRun: Date, age: TimeInterval) - /// Nie ma znacznika w ogole. + /// There is no marker at all. case never } - /// Odklada fakt "czujka przebiegla teraz". + /// Records the fact "the watchdog ran now". /// - /// Wolane ZANIM czujka cokolwiek wypisze i zanim zdecyduje o kodzie wyjscia: - /// przebieg, ktory znalazl awarie, jest tak samo przebiegiem jak ten, ktory - /// nic nie znalazl. Gdyby znacznik powstawal tylko na zdrowej sciezce, - /// zepsuty backup wygladalby jak nieczynna czujka i odwrotnie. + /// Called BEFORE the watchdog prints anything and before it decides on the + /// exit code: a run that found a failure is just as much a run as one that + /// found nothing. If the marker were written only on the healthy path, a + /// broken backup would look like an inactive watchdog and vice versa. /// - /// Zwraca `false`, gdy zapis sie NIE UDAL - wtedy znacznik bedzie stary, - /// czyli pomyli sie w bezpieczna strone ("czujka moze nie chodzic"). + /// Returns `false` when the write did NOT succeed - the marker will then be + /// old, i.e. it errs on the safe side ("the watchdog may not be running"). @discardableResult public static func record(now: Date = Date(), file: URL = WatchdogHeartbeat.backupHealthFile) -> Bool @@ -72,23 +72,24 @@ public enum WatchdogHeartbeat { return (try? data.write(to: file, options: .atomic)) != nil } - /// Data ostatniego przebiegu albo `nil`, gdy znacznika nie ma (albo jest - /// nieczytelny - jedno i drugie znaczy tu "nie wiem, kiedy czujka chodzila"). + /// Date of the last run, or `nil` when there is no marker (or it is + /// unreadable - both mean "I do not know when the watchdog ran" here). public static func lastRun(file: URL = WatchdogHeartbeat.backupHealthFile) -> Date? { guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } return ISO8601DateFormatter().date(from: text.trimmingCharacters(in: .whitespacesAndNewlines)) } - /// Czysta ocena wieku znacznika - osobno od odczytu pliku, zeby dalo sie ja - /// sprawdzic testem bez dotykania dysku. + /// Pure assessment of the marker's age - separate from reading the file, so + /// it can be tested without touching the disk. public static func freshness( lastRun: Date?, now: Date = Date(), maxSilenceHours: Double = WatchdogHeartbeat.maxSilenceHours ) -> Freshness { guard let lastRun else { return .never } let age = now.timeIntervalSince(lastRun) - // Ujemny wiek (znacznik z przyszlosci - przestawiony zegar, kopia z innej - // maszyny) NIE jest swiezoscia: nie wiemy, kiedy czujka chodzila. + // A negative age (a marker from the future - a clock that was changed, a + // copy from another machine) is NOT freshness: we do not know when the + // watchdog ran. guard age >= 0, age <= maxSilenceHours * 3600 else { return .stale(lastRun: lastRun, age: age) } diff --git a/mac-app/Sources/CloudMachinePOC/AmplificationCommand.swift b/mac-app/Sources/CloudMachinePOC/AmplificationCommand.swift index a140334..04fca33 100644 --- a/mac-app/Sources/CloudMachinePOC/AmplificationCommand.swift +++ b/mac-app/Sources/CloudMachinePOC/AmplificationCommand.swift @@ -1,47 +1,46 @@ import ArgumentParser import Foundation -/// Mierzy wzmocnienie zapisu: ile megabajtow trzeba wyslac do Drive'a, zeby -/// utrwalic jeden megabajt faktycznej zmiany. +/// Measures write amplification: how many megabytes have to be uploaded to +/// Drive to persist one megabyte of actual change. /// -/// 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. +/// This is the number that decides the band size. Large bands save file +/// operations (Drive lets through ~2/s and has a limit of 400,000 files), but +/// every small change forces the whole band to be uploaded again. If the +/// amplification turns out high, 64 MB is a mistake and we have to go lower. /// -/// Uruchamiane dla kazdego rozmiaru pasma osobno; wynik to tabela do -/// porownania. +/// Run separately for each band size; the result is a table for comparison. struct AmplificationCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "amplification", - abstract: "Mierzy wzmocnienie zapisu dla zadanego rozmiaru pasma.") + abstract: "Measures write amplification for a given band size.") enum Workload: String, ExpressibleByArgument, CaseIterable { - /// Przepisanie rozrzuconych plikow - najgorszy realny przypadek. + /// Rewriting scattered files - the worst realistic case. case scatter - /// Dopisanie nowych plikow - tak zachowuje sie Time Machine. + /// Adding new files - this is how Time Machine behaves. case append } - @Option(name: .long, help: "Rozmiar pasma w MB.") + @Option(name: .long, help: "Band size in MB.") var bandMB: Int = 64 - @Option(name: .long, help: "Ile plikow w pierwszym zapisie.") + @Option(name: .long, help: "How many files in the first write.") var seedFiles: Int = 3000 - @Option(name: .long, help: "Rozmiar pojedynczego pliku w KB.") + @Option(name: .long, help: "Size of a single file in KB.") var fileKB: Int = 64 - @Option(name: .long, help: "Ile plikow zmieniamy w drugim przebiegu.") + @Option(name: .long, help: "How many files to change in the second pass.") var touchFiles: Int = 300 - @Option(name: .long, help: "scatter = rozrzucone przepisanie, append = nowe pliki jak TM.") + @Option(name: .long, help: "scatter = scattered rewrite, append = new files like TM.") var workload: Workload = .scatter - @Option(name: .long, help: "Katalog roboczy.") + @Option(name: .long, help: "Working directory.") var root: String = "/tmp/cm-amp" - @Flag(name: .long, help: "Tylko posprzataj po poprzednim przebiegu i zakoncz.") + @Flag(name: .long, help: "Only clean up after the previous run and exit.") var clean = false private var runRoot: URL { URL(fileURLWithPath: root).appendingPathComponent("b\(bandMB)") } @@ -59,12 +58,12 @@ struct AmplificationCommand: AsyncParsableCommand { guard !clean else { await cleanup() try? FileManager.default.removeItem(at: URL(fileURLWithPath: root)) - print("Posprzatane.") + print("Cleaned up.") return } - // `trap cleanup EXIT` z wersji powlokowej: cokolwiek pojdzie nie tak, - // nie zostawiamy podpietych obrazow. + // `trap cleanup EXIT` from the shell version: whatever goes wrong, we do + // not leave images attached. do { try await measure() } catch { @@ -85,11 +84,11 @@ struct AmplificationCommand: AsyncParsableCommand { at: image, sizeGB: 30, volumeName: "AmpPOC\(bandMB)", bandMB: bandMB) try await POC.attach(image, mountpoint: target) - // Pierwszy zapis - odpowiednik pelnego backupu. - let dataDir = target.appendingPathComponent("dane") + // First write - the equivalent of a full backup. + let dataDir = target.appendingPathComponent("data") try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true) for i in 1...seedFiles { - try POC.createFile(dataDir.appendingPathComponent("plik-\(i).bin"), kilobytes: fileKB) + try POC.createFile(dataDir.appendingPathComponent("file-\(i).bin"), kilobytes: fileKB) } await POC.sync() await POC.detachQuietly(target.path) @@ -98,30 +97,30 @@ struct AmplificationCommand: AsyncParsableCommand { let seedBands = POC.fileCount(in: bands) let seedMB = POC.allocatedMegabytes(of: image) - // Znacznik czasu, wzgledem ktorego liczymy zmienione pasma. + // The timestamp against which we count the changed bands. let mark = Date() try await Task.sleep(nanoseconds: 1_000_000_000) - // Drugi przebieg - odpowiednik backupu przyrostowego. + // Second pass - the equivalent of an incremental backup. try await POC.attach(image, mountpoint: target) var changedKB = 0 switch workload { case .append: - // Time Machine nie przepisuje istniejacych danych w miejscu - kazdy - // backup doklada nowe pliki. Zapis jest wtedy skupiony, nie rozrzucony. - let growth = dataDir.appendingPathComponent("przyrost") + // Time Machine does not rewrite existing data in place - every backup + // adds new files. The writes are then clustered, not scattered. + let growth = dataDir.appendingPathComponent("growth") try FileManager.default.createDirectory(at: growth, withIntermediateDirectories: true) for i in 1...touchFiles { - try POC.createFile(growth.appendingPathComponent("nowy-\(i).bin"), kilobytes: fileKB) + try POC.createFile(growth.appendingPathComponent("new-\(i).bin"), kilobytes: fileKB) changedKB += fileKB } case .scatter: - // Zmieniamy rozrzucone pliki, zeby trafic w mozliwie wiele roznych pasm; - // to najgorszy realny przypadek, nie sredni. + // We change scattered files to hit as many different bands as possible; + // this is the worst realistic case, not the average one. let step = max(1, seedFiles / touchFiles) for i in stride(from: 1, through: seedFiles, by: step) { try POC.overwriteInPlace( - dataDir.appendingPathComponent("plik-\(i).bin"), + dataDir.appendingPathComponent("file-\(i).bin"), with: POC.randomData(kilobytes: fileKB)) changedKB += fileKB } @@ -134,11 +133,11 @@ struct AmplificationCommand: AsyncParsableCommand { let changedMB = max(1, changedKB / 1024) print("---") - print("pasmo : \(bandMB) MB (scenariusz: \(workload.rawValue))") - print("po pelnym zapisie : \(seedBands) pasm, \(seedMB) MB") - print("zmieniono realnie : \(changedMB) MB w \(touchFiles) plikach") - print("pobrudzonych pasm : \(dirty)") - print("do wyslania : \(uploadMB) MB") - print("WZMOCNIENIE : \(uploadMB / changedMB)x") + print("band : \(bandMB) MB (workload: \(workload.rawValue))") + print("after full write : \(seedBands) bands, \(seedMB) MB") + print("actually changed : \(changedMB) MB in \(touchFiles) files") + print("dirtied bands : \(dirty)") + print("to upload : \(uploadMB) MB") + print("AMPLIFICATION : \(uploadMB / changedMB)x") } } diff --git a/mac-app/Sources/CloudMachinePOC/CloudMachinePOC.swift b/mac-app/Sources/CloudMachinePOC/CloudMachinePOC.swift index ba8c28b..b42ae1d 100644 --- a/mac-app/Sources/CloudMachinePOC/CloudMachinePOC.swift +++ b/mac-app/Sources/CloudMachinePOC/CloudMachinePOC.swift @@ -1,14 +1,15 @@ import ArgumentParser -/// Harnessy pomiarowe - OSOBNA binarka, celowo poza `CloudMachine.app`. +/// Measurement harnesses - a SEPARATE binary, deliberately outside `CloudMachine.app`. /// -/// Mierza zachowanie `hdiutil` i FUSE-T, a nie nasz kod, i nie sa czescia -/// dzialajacego systemu: nic ich nie wola z launchd ani z aplikacji. -/// `build-app` nie kopiuje tej binarki do bundla, wiec nie trafia na maszyny -/// uzytkownikow - a mimo to jest budowana i sprawdzana przez CI razem z reszta. +/// They measure the behaviour of `hdiutil` and FUSE-T, not our code, and they +/// are not part of the running system: nothing calls them from launchd or from +/// the app. `build-app` does not copy this binary into the bundle, so it never +/// reaches users' machines - and yet it is built and checked by CI together +/// with the rest. /// -/// Uruchamia sie je recznie, gdy trzeba cos zmierzyc albo potwierdzic -/// regresje: +/// They are run by hand when something needs measuring or a regression needs +/// confirming: /// /// swift run cloudmachine-poc amplification --band-mb 32 --workload append /// swift run cloudmachine-poc pullplug --band-mb 32 --rounds 3 @@ -16,6 +17,6 @@ import ArgumentParser struct CloudMachinePOC: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "cloudmachine-poc", - abstract: "Harnessy pomiarowe architektury backupu (nie czesc dzialajacego systemu).", + abstract: "Measurement harnesses for the backup architecture (not part of the running system).", subcommands: [AmplificationCommand.self, PullPlugCommand.self]) } diff --git a/mac-app/Sources/CloudMachinePOC/POCSupport.swift b/mac-app/Sources/CloudMachinePOC/POCSupport.swift index 1a8ead9..5398ba1 100644 --- a/mac-app/Sources/CloudMachinePOC/POCSupport.swift +++ b/mac-app/Sources/CloudMachinePOC/POCSupport.swift @@ -1,12 +1,13 @@ import CloudMachineCore import Foundation -/// Wspolne czesci harnessow pomiarowych. +/// Shared parts of the measurement harnesses. /// -/// Same harnessy mierza zachowanie `hdiutil` i FUSE-T, a nie nasz kod. Nie sa -/// czescia dzialajacego systemu - dlatego siedza w OSOBNEJ binarce -/// `cloudmachine-poc`, ktorej `build-app` nie wklada do bundla. Uruchamia sie -/// je recznie, gdy trzeba cos zmierzyc albo potwierdzic regresje. +/// The harnesses themselves measure the behaviour of `hdiutil` and FUSE-T, not +/// our code. They are not part of the running system - which is why they live +/// in a SEPARATE binary, `cloudmachine-poc`, which `build-app` does not put +/// into the bundle. They are run by hand when something needs measuring or a +/// regression needs confirming. enum POC { struct Failure: LocalizedError { @@ -14,9 +15,9 @@ enum POC { var errorDescription: String? { message } } - // MARK: - Procesy + // MARK: - Processes - /// Odpowiednik `set -e`: nieudany proces przerywa harness. + /// The equivalent of `set -e`: a failed process aborts the harness. @discardableResult static func run( _ executable: String, _ args: [String], timeout: TimeInterval? = 600 @@ -25,15 +26,15 @@ enum POC { guard result.succeeded else { throw Failure( message: """ - Nie powiodlo sie: \(executable) \(args.joined(separator: " ")) + Failed: \(executable) \(args.joined(separator: " ")) \(result.stderr.isEmpty ? result.stdout : result.stderr) """) } return result } - /// Odpowiednik `... || true` - wolane tam, gdzie porazka jest spodziewana - /// (sprzatanie po czyms, co moze nie istniec). + /// The equivalent of `... || true` - called where failure is expected + /// (cleaning up after something that may not exist). static func runIgnoringFailure( _ executable: String, _ args: [String], timeout: TimeInterval? = 300 ) async { @@ -46,18 +47,18 @@ enum POC { await runIgnoringFailure("/usr/bin/hdiutil", args) } - /// 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. + /// After a forced detach a device can linger in the system as a zombie. + /// Attaching then returns a dead handle on which `fsck_apfs` reports + /// "failed to read container superblock" with an all-zero UUID - it looks + /// like a deleted backup, but it is only an unreadable device. /// - /// Logika parsowania `hdiutil info` mieszka w `CloudMachineCore` i jest - /// pokryta testami - harness jej nie powiela. + /// The `hdiutil info` parsing logic lives in `CloudMachineCore` and is + /// covered by tests - the harness does not duplicate it. static func purgeStaleDevices(forImage image: URL) async { await BackupImageService.purgeStaleDevices(image) } - // MARK: - Obrazy + // MARK: - Images static func bandSectors(bandMB: Int) -> Int { bandMB * 1024 * 1024 / 512 @@ -91,7 +92,7 @@ enum POC { ["attach", image.path, "-nobrowse", "-mountpoint", mountpoint.path]) } - // MARK: - Pliki + // MARK: - Files static func recreateDirectory(_ url: URL) throws { try? FileManager.default.removeItem(at: url) @@ -107,14 +108,15 @@ enum POC { return data } - /// Nadpisuje plik W MIEJSCU - odpowiednik `dd conv=notrunc`. + /// Overwrites a file IN PLACE - the equivalent of `dd conv=notrunc`. /// - /// To nie jest drobiazg: zapis przez `Data.write(to:)` tworzy nowy plik i - /// podmienia go, przez co dane ladowalyby w innych miejscach obrazu i - /// pomiar brudzonych pasm mierzylby cos innego niz realna zmiana w miejscu. + /// This is not a detail: writing through `Data.write(to:)` creates a new + /// file and swaps it in, so the data would land in other places of the image + /// and the dirtied-bands measurement would measure something other than a + /// real in-place change. static func overwriteInPlace(_ url: URL, with data: Data) throws { guard let handle = FileHandle(forWritingAtPath: url.path) else { - throw Failure(message: "Nie mozna otworzyc do zapisu: \(url.path)") + throw Failure(message: "Cannot open for writing: \(url.path)") } defer { try? handle.close() } try handle.seek(toOffset: 0) @@ -130,7 +132,7 @@ enum POC { (try? FileManager.default.contentsOfDirectory(atPath: directory.path).count) ?? 0 } - /// Ile pasm zmienilo sie po znaczniku - odpowiednik `find -newer`. + /// How many bands changed after the marker - the equivalent of `find -newer`. static func filesModified(after mark: Date, in directory: URL) -> Int { guard let entries = try? FileManager.default.contentsOfDirectory( @@ -145,8 +147,8 @@ enum POC { }.count } - /// Zajetosc na dysku w MB - odpowiednik `du -sk`, czyli miejsce FAKTYCZNIE - /// zajete, nie suma rozmiarow logicznych. + /// Disk usage in MB - the equivalent of `du -sk`, i.e. the space ACTUALLY + /// allocated, not the sum of logical sizes. static func allocatedMegabytes(of directory: URL) -> Int { guard let walker = FileManager.default.enumerator( diff --git a/mac-app/Sources/CloudMachinePOC/PullPlugCommand.swift b/mac-app/Sources/CloudMachinePOC/PullPlugCommand.swift index 9d55b89..6a047f5 100644 --- a/mac-app/Sources/CloudMachinePOC/PullPlugCommand.swift +++ b/mac-app/Sources/CloudMachinePOC/PullPlugCommand.swift @@ -2,34 +2,36 @@ import ArgumentParser import CloudMachineCore import Foundation -/// Symuluje smierc warstwy chmurowej w trakcie zapisu. +/// Simulates the death of the cloud layer in the middle of a write. /// -/// 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. +/// The most dangerous scenario of this architecture: Time Machine writes to +/// the attached image, and underneath it the rclone mount disappears - because +/// the process died, because FUSE-T crashed, because the system put the disk +/// to sleep. The image loses its bands in the middle of a write. /// -/// Test odpina zewnetrzny obraz (zastepnik montowania rclone) w trakcie zapisu -/// do wewnetrznego, potem podpina wszystko z powrotem i sprawdza `fsck_apfs`. +/// The test detaches the outer image (the stand-in for the rclone mount) while +/// writing to the inner one, then attaches everything back and runs +/// `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". +/// What interests us is not whether the write survives - it will not - but +/// whether the image can be repaired afterwards or has to be thrown away. The +/// difference between "backup interrupted, it will resume" and "backup lost, +/// we start from zero". struct PullPlugCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "pullplug", - abstract: "Wyrywa warstwe chmurowa w trakcie zapisu i sprawdza, czy obraz przezyl.") + abstract: "Pulls the cloud layer out mid-write and checks whether the image survived.") - @Option(name: .long, help: "Rozmiar pasma w MB.") + @Option(name: .long, help: "Band size in MB.") var bandMB: Int = 64 - @Option(name: .long, help: "Ile razy powtorzyc wyrwanie podlogi.") + @Option(name: .long, help: "How many times to repeat pulling the floor out.") var rounds: Int = 3 - @Option(name: .long, help: "Katalog roboczy.") + @Option(name: .long, help: "Working directory.") var root: String = "/tmp/cm-plug" - @Flag(name: .long, help: "Tylko posprzataj po poprzednim przebiegu i zakoncz.") + @Flag(name: .long, help: "Only clean up after the previous run and exit.") var clean = false private static let fsck = @@ -50,7 +52,7 @@ struct PullPlugCommand: AsyncParsableCommand { guard !clean else { await detachAll() try? FileManager.default.removeItem(at: runRoot) - print("Posprzatane.") + print("Cleaned up.") return } @@ -68,73 +70,74 @@ struct PullPlugCommand: AsyncParsableCommand { try POC.recreateDirectory(runRoot) try FileManager.default.createDirectory(at: mountPoint, withIntermediateDirectories: true) - print("Przygotowanie") + print("Preparation") try await POC.createSparseImage(at: outerImage, sizeGB: 20, volumeName: "PlugStandIn") try await POC.attach(outerImage, mountpoint: mountPoint) try await POC.createSparsebundle( at: image, sizeGB: 10, volumeName: "PlugPOC", bandMB: bandMB) var failed = 0 - // Rundy, ktore NIC NIE ZMIERZYLY: zapis sie nie zaczal, skonczyl sie przed - // wyrwaniem podlogi albo obrazu nie dalo sie sprawdzic. Bez tego licznika - // przebieg bez ani jednego pomiaru konczyl sie zdaniem "Obraz przezyl - // kazde wyrwanie podlogi". + // Rounds that MEASURED NOTHING: the write did not start, finished before + // the floor was pulled out, or the image could not be checked. Without + // this counter a run without a single measurement ended with the sentence + // "The image survived every floor pull". var unmeasured = 0 var executed = 0 for round in 1...rounds { executed += 1 print("") - print("--- runda \(round) ---") + print("--- round \(round) ---") try await POC.attach(image, mountpoint: target) - // Zapis w tle, zeby wyrwac podloge w jego trakcie. Ten zapis MA paść - - // przerwanie w polowie jest cala trescia testu. + // A background write, so the floor can be pulled out from under it. This + // write IS MEANT to fail - interrupting it halfway is the whole point of + // the test. let writer = Task.detached { [target] in writeUntilItBreaks( - to: target.appendingPathComponent("obciazenie-\(round).bin"), megabytes: 1500) + to: target.appendingPathComponent("load-\(round).bin"), megabytes: 1500) } try await Task.sleep(nanoseconds: 3_000_000_000) - print("Wyrywam podloge (odpinam zastepnik montowania)") + print("Pulling the floor out (detaching the mount stand-in)") await POC.detachQuietly(mountPoint.path, force: true) - // "Nie zaczalem pisac" NIE jest "zapis przerwany". Pierwsze znaczy, ze - // runda nie miala czego przerywac, czyli nie zmierzyla niczego. + // "I never started writing" is NOT "write interrupted". The former means + // the round had nothing to interrupt, i.e. it measured nothing. switch await writer.value { - case .neverStarted(let powod): - print(" ZAPIS NIGDY NIE WYSTARTOWAL: \(powod)") - print(" runda NIC NIE MIERZY - nie bylo czego przerywac") + case .neverStarted(let reason): + print(" THE WRITE NEVER STARTED: \(reason)") + print(" round MEASURES NOTHING - there was nothing to interrupt") unmeasured += 1 case .interrupted(let megabytes): - print(" zapis przerwany po \(megabytes) MB, zgodnie z oczekiwaniem") + print(" write interrupted after \(megabytes) MB, as expected") case .completed(let megabytes): - print(" UWAGA: zapis \(megabytes) MB skonczyl sie PRZED wyrwaniem podlogi") - print(" runda NIC NIE MIERZY - podloga zniknela juz po zapisie") + print(" WARNING: the \(megabytes) MB write finished BEFORE the floor was pulled out") + print(" round MEASURES NOTHING - the floor disappeared only after the write") unmeasured += 1 } await POC.detachQuietly(target.path, force: true) - print("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. + print("Restoring the layer and checking the image") + // We do not check the image in place. After a forced detach a device can + // linger in the system as a zombie; attaching then returns a dead handle, + // and fsck_apfs reports "failed to read container superblock" with an + // all-zero UUID. It looks like irreversible damage, but it is only an + // unreadable device - an earlier version of this test, on that basis, + // declared three times in a row the loss of a backup that was intact. // - // Kopia pod swieza sciezka jest odporna na ten artefakt: nowy plik, nowe - // urzadzenie, zaden stary uchwyt nie ma z nim zwiazku. + // A copy under a fresh path is immune to this artefact: new file, new + // device, no old handle has anything to do with it. await POC.detachQuietly(target.path, force: true) await POC.purgeStaleDevices(forImage: image) try await Task.sleep(nanoseconds: 2_000_000_000) try await POC.attach(outerImage, mountpoint: mountPoint) try await Task.sleep(nanoseconds: 1_000_000_000) - let copy = runRoot.appendingPathComponent("kontrola-\(round).sparsebundle") + let copy = runRoot.appendingPathComponent("check-\(round).sparsebundle") try? FileManager.default.removeItem(at: copy) try FileManager.default.copyItem(at: image, to: copy) guard let device = try await attachWithoutMounting(copy) else { - print(" WYNIK: obrazu nie da sie nawet podpiac - stracony") + print(" RESULT: the image cannot even be attached - lost") failed += 1 break } @@ -142,24 +145,24 @@ struct PullPlugCommand: AsyncParsableCommand { let log = runRoot.appendingPathComponent("fsck-\(round).log") switch await checkImage(device: device, writingTo: log, repair: false) { case .consistent: - print(" WYNIK: spojny") - case .notChecked(let powod): - // NIE "stracony": nie mamy ani jednego wyniku. Na podstawie tego zdania - // odtwarza sie backup od zera, wiec nie ma prawa go mowic zgadywanie. - print(" WYNIK: NIE UDALO SIE SPRAWDZIC (\(powod))") - print(" spojnosc obrazu POZOSTAJE NIESPRAWDZONA - runda nic nie mierzy") + print(" RESULT: consistent") + case .notChecked(let reason): + // NOT "lost": we do not have a single result. A backup gets rebuilt from + // zero on the strength of that sentence, so a guess has no right to say it. + print(" RESULT: COULD NOT CHECK (\(reason))") + print(" image consistency REMAINS UNCHECKED - the round measures nothing") unmeasured += 1 case .inconsistent: - print(" WYNIK: niespojny - probuje naprawic") + print(" RESULT: inconsistent - trying to repair") switch await checkImage(device: device, writingTo: log, repair: true, append: true) { case .consistent: - print(" naprawa udana - backup do uratowania") + print(" repair succeeded - the backup can be saved") case .inconsistent: - print(" naprawa nieudana - backup stracony (log: \(log.path))") + print(" repair failed - backup lost (log: \(log.path))") failed += 1 - case .notChecked(let powod): - print(" naprawy NIE UDALO SIE uruchomic (\(powod))") - print(" nie wiadomo, czy backup da sie uratowac (log: \(log.path))") + case .notChecked(let reason): + print(" the repair COULD NOT be started (\(reason))") + print(" unknown whether the backup can be saved (log: \(log.path))") unmeasured += 1 } } @@ -176,8 +179,9 @@ struct PullPlugCommand: AsyncParsableCommand { } } - /// Podpina kopie bez montowania i zwraca urzadzenie z kontenerem APFS. - /// `41504653` to typ partycji Apple_APFS w wydruku `hdiutil attach -nomount`. + /// Attaches the copy without mounting and returns the device with the APFS + /// container. `41504653` is the Apple_APFS partition type in the output of + /// `hdiutil attach -nomount`. private func attachWithoutMounting(_ copy: URL) async throws -> String? { guard let result = try? await POC.run( @@ -190,9 +194,9 @@ struct PullPlugCommand: AsyncParsableCommand { return nil } - /// `fsck_apfs` na uszkodzonym obrazie potrafi chodzic godzinami - dlatego - /// `timeout: nil`. Zabity fsck zglasza porazke, ktorej nie da sie odroznic - /// od realnej niespojnosci, a to tutaj jest cala mierzona wielkosc. + /// `fsck_apfs` on a damaged image can run for hours - hence `timeout: nil`. + /// A killed fsck reports a failure that cannot be told apart from a real + /// inconsistency, and that is the whole quantity measured here. private func checkImage( device: String, writingTo log: URL, repair: Bool, append: Bool = false ) async -> ImageCheck { @@ -204,110 +208,113 @@ struct PullPlugCommand: AsyncParsableCommand { return Self.classify(fsck: result) } - /// "Nie udalo sie sprawdzic" to NIE to samo co "niespojny". + /// "Could not check" is NOT the same as "inconsistent". /// - /// Ten sam wzorzec, co w `BackupImageService.verifyLocked()`: `fsck_apfs` - /// nieuruchomiony (brak binarki, ubity proces, wyrwane urzadzenie) dawal - /// `false` dokladnie tak samo jak `fsck_apfs`, ktory znalazl uszkodzenie - - /// harness meldowal wtedy "backup stracony" i liczyl nieodwracalna strate, - /// nie majac ani jednego wyniku. Falszywy alarm o utracie calej kopii jest - /// tu grozniejszy niz brak odpowiedzi, bo na jego podstawie odtwarza sie - /// backup od zera. + /// The same pattern as in `BackupImageService.verifyLocked()`: a `fsck_apfs` + /// that never ran (missing binary, killed process, pulled device) gave + /// `false` exactly like a `fsck_apfs` that found damage - the harness then + /// reported "backup lost" and counted an irreversible loss without having a + /// single result. A false alarm about losing the whole backup is more + /// dangerous here than no answer, because the backup gets rebuilt from zero + /// on the strength of it. static func classify(fsck result: ProcessResult?) -> ImageCheck { guard let result else { - return .notChecked("fsck_apfs nie dal sie uruchomic albo nie zwrocil wyniku") + return .notChecked("fsck_apfs could not be started or returned no result") } return result.succeeded ? .consistent : .inconsistent } - /// Ostatnie zdanie przebiegu - jedyne, ktore ktokolwiek zapamieta. + /// The last sentence of the run - the only one anybody will remember. /// - /// Czyste i wydzielone, bo to tutaj byla usterka: dopoki liczyly sie tylko - /// straty, przebieg BEZ ANI JEDNEGO pomiaru konczyl sie zdaniem "Obraz - /// przezyl kazde wyrwanie podlogi". Harness nie ma prawa orzekac, ze cos - /// przezylo, jesli nie wie, czy to cos w ogole probowal zabic. + /// Pure and extracted, because this is where the bug was: as long as only + /// losses counted, a run WITHOUT A SINGLE measurement ended with the + /// sentence "The image survived every floor pull". The harness has no right + /// to declare that something survived if it does not know whether it even + /// tried to kill it. static func summary(requestedRounds: Int, executedRounds: Int, lost: Int, unmeasured: Int) -> [String] { var lines = [ - "Rundy: \(requestedRounds) zamowione, \(executedRounds) wykonane " - + "nieodwracalnych strat: \(lost) rund bez pomiaru: \(unmeasured)" + "Rounds: \(requestedRounds) requested, \(executedRounds) executed " + + "irreversible losses: \(lost) rounds without a measurement: \(unmeasured)" ] if executedRounds == 0 { - lines.append("PRZEBIEG NIC NIE ZMIERZYL: nie wykonano ani jednej rundy.") + lines.append("THE RUN MEASURED NOTHING: not a single round was executed.") return lines } if lost > 0 { - lines.append("UWAGA: architektura gubi backup przy utracie warstwy chmurowej.") + lines.append("WARNING: the architecture loses the backup when the cloud layer is lost.") return lines } if unmeasured > 0 { lines.append( - "PRZEBIEG NIC NIE DOWODZI: \(unmeasured) z \(executedRounds) rund nie zmierzylo niczego") + "THE RUN PROVES NOTHING: \(unmeasured) of \(executedRounds) rounds measured nothing") lines.append( - "(zapis sie nie zaczal, skonczyl sie przed wyrwaniem podlogi albo obrazu nie " - + "dalo sie sprawdzic).") + "(the write did not start, finished before the floor was pulled out, or the image " + + "could not be checked).") return lines } - lines.append("Obraz przezyl kazde wyrwanie podlogi.") + lines.append("The image survived every floor pull.") return lines } } -/// Wynik sprawdzenia obrazu `fsck_apfs`. Trzy stany, bo "nie udalo sie -/// sprawdzic" i "niespojny" to dwie rozne odpowiedzi - patrz `classify`. +/// The result of checking the image with `fsck_apfs`. Three states, because +/// "could not check" and "inconsistent" are two different answers - see +/// `classify`. enum ImageCheck: Equatable { case consistent case inconsistent case notChecked(String) } -/// Co sie stalo z probnym zapisem. +/// What happened to the test write. /// -/// Trzy stany, nie dwa. `writeUntilItBreaks` zwracalo `Bool`, a `false` znaczylo -/// jednoczesnie "zapis przerwano w polowie" (cala tresc testu) i "zapisu nie -/// dalo sie w ogole zaczac" - `createFile` albo `FileHandle` padly od razu, na -/// przyklad bo sciezki nie ma. Harness drukowal wtedy "zapis przerwany, zgodnie -/// z oczekiwaniem" i konczyl "Obraz przezyl kazde wyrwanie podlogi", nie -/// napisawszy ani jednego bajtu. +/// Three states, not two. `writeUntilItBreaks` used to return `Bool`, and +/// `false` meant both "the write was interrupted halfway" (the whole point of +/// the test) and "the write could not start at all" - `createFile` or +/// `FileHandle` failed immediately, for example because the path does not +/// exist. The harness then printed "write interrupted, as expected" and ended +/// with "The image survived every floor pull", without having written a single +/// byte. /// -/// `completed` tez jest osobno i tez NIE jest sukcesem testu: zapis, ktory -/// skonczyl sie przed wyrwaniem podlogi, nie zmierzyl niczego (dokladnie ta -/// pulapka, przed ktora ostrzega komentarz o `arc4random_buf` przy funkcji). +/// `completed` is separate too and is NOT a test success either: a write that +/// finished before the floor was pulled out measured nothing (exactly the trap +/// the comment about `arc4random_buf` on the function warns about). enum WriteProbe: Equatable { - /// Zapisu NIE ZACZELISMY - runda nic nie mierzy. + /// We did NOT START the write - the round measures nothing. case neverStarted(String) - /// Zapis szedl i zostal przerwany - oczekiwany przypadek. + /// The write was running and got interrupted - the expected case. case interrupted(megabytesWritten: Int) - /// Zapis doszedl do konca, czyli podloga zniknela za pozno albo wcale. + /// The write reached the end, i.e. the floor disappeared too late or not at all. case completed(megabytesWritten: Int) } -/// Leje losowe dane, dopoki podloga nie zniknie. +/// Pours random data until the floor disappears. /// -/// Wewnetrzna (nie `private`), zeby test mogl sprawdzic, ze "nie zaczalem -/// pisac" i "zapis przerwany" to dwie rozne odpowiedzi. +/// Internal (not `private`), so that a test can check that "I never started +/// writing" and "write interrupted" are two different answers. /// -/// Pisze porcjami przez `FileHandle`, a nie jednym `Data.write`, zeby zapis -/// naprawde trwal i dalo sie go przerwac w polowie. +/// Writes in chunks through `FileHandle`, not with a single `Data.write`, so +/// that the write really takes time and can be interrupted halfway. /// -/// Zrodlem danych jest `/dev/urandom` - dokladnie jak `dd if=/dev/urandom` w -/// wersji powlokowej. To nie jest przesadna wiernosc, tylko warunek dzialania -/// testu: to urandom wyznacza tempo zapisu. Wersja losujaca przez -/// `arc4random_buf` przepychala 1500 MB w mniej niz trzy sekundy, wiec zapis -/// konczyl sie PRZED wyrwaniem podlogi - harness meldowal "obraz przezyl", nie -/// sprawdziwszy tego, po co istnieje. Zmierzone: z urandom zapis wciaz trwa, -/// gdy znika montowanie. +/// The data source is `/dev/urandom` - exactly like `dd if=/dev/urandom` in +/// the shell version. This is not excessive fidelity but a condition for the +/// test to work: urandom sets the pace of the write. The version generating +/// data with `arc4random_buf` pushed 1500 MB in under three seconds, so the +/// write finished BEFORE the floor was pulled out - the harness reported "the +/// image survived" without having checked what it exists for. Measured: with +/// urandom the write is still running when the mount disappears. func writeUntilItBreaks(to url: URL, megabytes: Int) -> WriteProbe { guard FileManager.default.createFile(atPath: url.path, contents: nil) else { - return .neverStarted("nie udalo sie utworzyc \(url.path)") + return .neverStarted("could not create \(url.path)") } guard let handle = FileHandle(forWritingAtPath: url.path) else { - return .neverStarted("nie udalo sie otworzyc do zapisu \(url.path)") + return .neverStarted("could not open for writing \(url.path)") } guard let entropy = FileHandle(forReadingAtPath: "/dev/urandom") else { try? handle.close() - return .neverStarted("nie udalo sie otworzyc /dev/urandom") + return .neverStarted("could not open /dev/urandom") } defer { try? handle.close() @@ -317,22 +324,22 @@ func writeUntilItBreaks(to url: URL, megabytes: Int) -> WriteProbe { for _ in 0.. AppStatus { + /// A state in which everything really works - the reference point. + private func healthy() -> AppStatus { let status = AppStatus() status.dependencyState = .ready status.remoteConfigured = true @@ -22,159 +26,163 @@ final class AppStatusHealthTests: XCTestCase { var buffer = BufferStatus() buffer.mounted = true buffer.imageAttached = true - // Musi byc jawne: `BufferStatus` zaczyna od "kolejki nie odczytano", zeby - // swiezy, niesprawdzony stan nie uchodzil za pusta kolejke. + // Must be explicit: `BufferStatus` starts from "queue not read", so that + // a fresh, unchecked state does not pass for an empty queue. buffer.queueKnown = true buffer.freeDiskGB = 400 status.buffer = buffer - // ZMIANA 23.09.2026: "wszystko podpiete" przestalo wystarczac do zielonego - // znaczka. Punkt odniesienia musi teraz zawierac takze fakt, ze kopia - // FAKTYCZNIE powstala - bo dokladnie tego brakowalo w awarii, dla ktorej - // `BackupHealth` w ogole powstal. Wczesniej ten helper opisywal stan - // urzadzen i milczal o tym, czy backup sie udal; test "zdrowy stan jest - // zdrowy" przechodzil wiec takze dla Maca, ktory nie zrobil kopii od - // dwoch dni. + // CHANGE 23.09.2026: "everything attached" stopped being enough for the green + // badge. The reference point now also has to contain the fact that a backup + // WAS ACTUALLY made - because that is exactly what was missing in the failure for which + // `BackupHealth` was created in the first place. Previously this helper described the state of the + // devices and said nothing about whether the backup succeeded; the test "a healthy state + // is healthy" therefore also passed for a Mac that had not made a backup for + // two days. status.backupCycle = BackupCycleStatus( known: true, lastSuccess: Date().addingTimeInterval(-1800), problems: [], checkedAt: Date()) return status } - /// REGRESJA 23.09.2026: `rclone rc` nie odpowiedzial w limicie czasu, - /// wolajacy podstawil zera i pasek menu pokazal "Gotowe" przy 386 pasmach - /// czekajacych w kolejce. - func testNieodczytanaKolejkaOdbieraZielonyZnaczek() { - let status = zdrowy() + /// REGRESSION 23.09.2026: `rclone rc` did not answer within the time limit, + /// the caller substituted zeros and the menu bar showed "Ready" with 386 bands + /// waiting in the queue. + func testUnreadQueueTakesAwayGreenBadge() { + let status = healthy() status.buffer.queueKnown = false - XCTAssertFalse(status.healthy, "Nie wiadomo = nie zielono.") - XCTAssertNotEqual(status.headline, "Gotowe") + XCTAssertFalse(status.healthy, "Unknown = not green.") + XCTAssertNotEqual(status.headline, "Ready") XCTAssertEqual(status.buffer.uploadState, .queueUnknown) } - func testZdrowyStanJestZdrowy() { - let status = zdrowy() + func testHealthyStateIsHealthy() { + let status = healthy() XCTAssertTrue(status.healthy) - XCTAssertEqual(status.headline, "Gotowe") + XCTAssertEqual(status.headline, "Ready") } - /// TO jest ta awaria. Pasma, ktorych rclone nie wyslal, istnieja wylacznie - /// na tym Macu - czyli backup nie jest kopia. Interfejs pokazywal wtedy - /// zielony znaczek i "Gotowe". - func testNiewyslanePlikiOdbierajaZielonyZnaczek() { - let status = zdrowy() + /// THIS is the failure. Bands that rclone did not upload exist only + /// on this Mac - so the backup is not a copy. The interface then showed + /// a green badge and "Ready". + func testUnsentFilesTakeAwayGreenBadge() { + let status = healthy() status.buffer.erroredFiles = 7 - XCTAssertFalse(status.healthy, "Niewyslane pasma NIE moga uchodzic za zdrowy stan.") - XCTAssertEqual(status.headline, "Nie wysłano 7 fragmentów kopii") + XCTAssertFalse(status.healthy, "Unsent bands MUST NOT pass for a healthy state.") + XCTAssertEqual(status.buffer.uploadState, .failedFiles(7)) + XCTAssertEqual(status.headline, UploadState.failedFiles(7).headline) } - /// rclone melduje, ze nie ma juz gdzie odlozyc danych. Mocniejszy sygnal niz - /// jakikolwiek nasz prog, bo pochodzi od tego, kto naprawde wie. - func testPelnyBuforOdbieraZielonyZnaczek() { - let status = zdrowy() + /// rclone reports that it has nowhere left to put data. A stronger signal than + /// any threshold of ours, because it comes from the one who really knows. + func testFullBufferTakesAwayGreenBadge() { + let status = healthy() status.buffer.outOfSpace = true XCTAssertFalse(status.healthy) - XCTAssertEqual(status.headline, "Wysyłka nie nadąża za zapisem") + XCTAssertEqual(status.buffer.uploadState, .bufferFull) + XCTAssertEqual(status.headline, UploadState.bufferFull.headline) } - func testBrakMontowaniaOdbieraZielonyZnaczek() { - let status = zdrowy() + func testMissingMountTakesAwayGreenBadge() { + let status = healthy() status.buffer.mounted = false XCTAssertFalse(status.healthy) - XCTAssertEqual(status.headline, "Bufor nie dziala") + XCTAssertEqual(status.headline, "Buffer is not working") } - func testNiepodpietyObrazOdbieraZielonyZnaczek() { - let status = zdrowy() + func testDetachedImageTakesAwayGreenBadge() { + let status = healthy() status.buffer.imageAttached = false XCTAssertFalse(status.healthy) - XCTAssertEqual(status.headline, "Obraz backupu niepodpiety") + XCTAssertEqual(status.headline, "Backup image not attached") } - func testPrzestawionyCelTimeMachineOdbieraZielonyZnaczek() { - let status = zdrowy() + func testChangedTimeMachineDestinationTakesAwayGreenBadge() { + let status = healthy() status.timeMachineState = .notRegistered XCTAssertFalse(status.healthy) - XCTAssertEqual(status.headline, "Time Machine nie wskazuje na CloudMachine") + XCTAssertEqual(status.headline, "Time Machine does not point to CloudMachine") } - /// Limit dobowy NIE wymaga reakcji, ale pasma leza wtedy tylko na tym Macu - - /// wiec zielony znaczek sie nie nalezy. - func testWyczerpanyLimitDriveOdbieraZielonyZnaczek() { - let status = zdrowy() + /// The daily limit does NOT require action, but the bands then sit only on this Mac - + /// so the green badge is not deserved. + func testExhaustedDriveLimitTakesAwayGreenBadge() { + let status = healthy() status.buffer.dailyQuotaExhausted = true XCTAssertFalse(status.healthy) XCTAssertFalse(status.buffer.uploadState.needsAttention) } - /// Brak miejsca na Dysku to co INNEGO niz limit dobowy: nie minie samo. - func testBrakMiejscaNaDyskuWymagaReakcji() { - let status = zdrowy() + /// No space on the Drive is something DIFFERENT from the daily limit: it will not pass on its own. + func testNoSpaceOnDriveRequiresAction() { + let status = healthy() status.buffer.driveFull = true XCTAssertFalse(status.healthy) XCTAssertTrue(status.buffer.uploadState.needsAttention) } - func testNiepolaczonyDriveOdbieraZielonyZnaczek() { - let status = zdrowy() + func testUnconnectedDriveTakesAwayGreenBadge() { + let status = healthy() status.remoteConfigured = false XCTAssertFalse(status.healthy) } - /// Trwajaca wysylka to NIE awaria - dopoki kolejka maleje, wszystko idzie - /// zgodnie z projektem. Bez tego testu "naprawa" polegajaca na alarmowaniu - /// przy kazdej niepustej kolejce przeszlaby niezauwazona. - func testTrwajacaWysylkaNieJestAwaria() { - let status = zdrowy() + /// An upload in progress is NOT a failure - as long as the queue shrinks, everything goes + /// as designed. Without this test a "fix" consisting of raising an alarm + /// on every non-empty queue would have gone unnoticed. + func testUploadInProgressIsNotAFailure() { + let status = healthy() status.buffer.uploadsQueued = 12 XCTAssertTrue(status.healthy) - XCTAssertEqual(status.headline, "Wysyłanie na Google Drive — 12 w kolejce") + XCTAssertEqual(status.buffer.uploadState, .flowing(queued: 12)) + XCTAssertEqual(status.headline, UploadState.flowing(queued: 12).headline) } - // MARK: - Wiek ostatniej UDANEJ kopii + // MARK: - Age of the last SUCCESSFUL backup - /// TA awaria. Montowanie stoi, obraz podpiety, kolejka pusta, cel Time - /// Machine ustawiony - a ostatnia ZAKONCZONA kopia ma dwa dni. Panel - /// pokazywal wtedy "Sprawny / Gotowe", bo nie pytal o to ani razu: - /// `grep -rn "BackupHealth" Sources/CloudMachineApp/` nie dawal trafien. - func testStaraKopiaOdbieraZielonyZnaczek() { - let status = zdrowy() + /// THE failure. The mount is up, the image attached, the queue empty, the Time + /// Machine destination set - and the last COMPLETED backup is two days old. The panel + /// showed "Healthy / Ready" then, because it never asked about this even once: + /// `grep -rn "BackupHealth" Sources/CloudMachineApp/` gave no hits. + func testOldBackupTakesAwayGreenBadge() { + let status = healthy() status.backupCycle.lastSuccess = Date().addingTimeInterval(-48 * 3600) XCTAssertFalse( status.healthy, - "Wszystkie urzadzenia moga byc sprawne, a kopii moze nie byc od dwoch dni.") - XCTAssertEqual(status.headline, "Brak ukończonej kopii od 2 dni") + "All the devices can be fine while there has been no backup for two days.") + XCTAssertEqual( + status.headline, "No completed backup for \(BackupHealth.formatAge(48 * 3600))") } - /// Granica progu. Tuz pod nia jest jeszcze dobrze, tuz nad nia juz nie - - /// bez tego testu "naprawa" ustawiajaca prog na 100 lat przeszlaby cicho. - func testProgWiekuKopiiDzialaWObieStrony() { - let tuzPrzed = zdrowy() - tuzPrzed.backupCycle.lastSuccess = Date().addingTimeInterval( + /// The threshold boundary. Just below it is still fine, just above it no longer - + /// without this test a "fix" setting the threshold to 100 years would pass silently. + func testBackupAgeThresholdWorksBothWays() { + let justBefore = healthy() + justBefore.backupCycle.lastSuccess = Date().addingTimeInterval( -(BackupHealth.maxAgeHours * 3600 - 60)) - XCTAssertTrue(tuzPrzed.healthy) + XCTAssertTrue(justBefore.healthy) - let tuzPo = zdrowy() - tuzPo.backupCycle.lastSuccess = Date().addingTimeInterval( + let justAfter = healthy() + justAfter.backupCycle.lastSuccess = Date().addingTimeInterval( -(BackupHealth.maxAgeHours * 3600 + 60)) - XCTAssertFalse(tuzPo.healthy) + XCTAssertFalse(justAfter.healthy) } - /// Nieodczytany licznik kopii to NIE to samo, co kopia sprzed chwili. - /// Domyslny `BackupCycleStatus` ma `known == false` wlasnie po to, zeby - /// panel nie swiecil na zielono, zanim ktokolwiek o cokolwiek zapytal. - func testNieodczytanyLicznikKopiiOdbieraZielonyZnaczek() { - let status = zdrowy() + /// An unread backup counter is NOT the same as a backup made a moment ago. + /// The default `BackupCycleStatus` has `known == false` precisely so that + /// the panel does not show green before anyone has asked anything. + func testUnreadBackupCounterTakesAwayGreenBadge() { + let status = healthy() status.backupCycle = BackupCycleStatus() XCTAssertFalse(status.healthy) - XCTAssertEqual(status.headline, "Nie wiadomo, kiedy powstała ostatnia kopia") + XCTAssertEqual(status.headline, "Unknown when the last backup was made") } - /// Odczytano preferencje i nie ma w nich ANI JEDNEJ udanej kopii - co jest - /// czyms innym niz "nie udalo sie odczytac" i musi brzmiec inaczej. - func testBrakJakiejkolwiekKopiiOdbieraZielonyZnaczek() { - let status = zdrowy() + /// The preferences were read and there is NOT A SINGLE successful backup in them - which is + /// something other than "could not read" and must sound different. + func testNoBackupAtAllTakesAwayGreenBadge() { + let status = healthy() status.backupCycle = BackupCycleStatus(known: true, lastSuccess: nil, checkedAt: Date()) XCTAssertFalse(status.healthy) - XCTAssertEqual(status.headline, "Nie ma ani jednej ukończonej kopii") + XCTAssertEqual(status.headline, "There is no completed backup at all") } } diff --git a/mac-app/Tests/CloudMachineAppTests/AppVersionTests.swift b/mac-app/Tests/CloudMachineAppTests/AppVersionTests.swift index fc1a436..4cca254 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppVersionTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppVersionTests.swift @@ -2,11 +2,11 @@ import XCTest @testable import CloudMachineCore -/// Testy odczytu wersji. +/// Tests for reading the version. /// -/// Pytanie, na ktore ma odpowiadac ten kod, brzmi "czy dziala to, co w -/// repozytorium". Kazdy test wstrzykuje wiec plist, ktory KLAMALBY, gdyby -/// czytac tylko numer wersji. +/// The question this code is meant to answer is "is what is running the same +/// as what is in the repository". So every test injects a plist that WOULD LIE +/// if only the version number were read. final class AppVersionTests: XCTestCase { private func plist( @@ -22,7 +22,7 @@ final class AppVersionTests: XCTestCase { return dict } - func testCzytaWersjeBudoweICommit() { + func testReadsVersionBuildAndCommit() { let version = AppVersionReader.parse(infoPlist: plist()) XCTAssertEqual(version.shortVersion, "1.1.0") XCTAssertEqual(version.build, "67") @@ -30,43 +30,43 @@ final class AppVersionTests: XCTestCase { XCTAssertFalse(version.dirty) } - /// `build-app` wpisuje "true"/"false" jako STRING (podstawienie w szablonie - /// XML), ale recznie poprawiony plist moze miec . Oba musza znaczyc - /// to samo, inaczej brudny build zameldowalby sie jako czysty. - func testBrudneDrzewoRozpoznaneZeStringaIZBoola() { + /// `build-app` writes "true"/"false" as a STRING (substitution in the XML + /// template), but a manually edited plist may have . Both must mean + /// the same, otherwise a dirty build would report itself as clean. + func testDirtyTreeRecognizedFromStringAndFromBool() { XCTAssertTrue(AppVersionReader.parse(infoPlist: plist(dirty: "true")).dirty) XCTAssertTrue(AppVersionReader.parse(infoPlist: plist(dirty: true)).dirty) XCTAssertFalse(AppVersionReader.parse(infoPlist: plist(dirty: "false")).dirty) XCTAssertFalse(AppVersionReader.parse(infoPlist: plist(dirty: false)).dirty) } - /// Stary bundel, zbudowany przed dodaniem tych kluczy, nie moze udawac, ze - /// wie, z czego powstal. - func testStaryBundelBezCommituNieUdajeZeWie() { + /// An old bundle, built before these keys were added, must not pretend to + /// know what it was built from. + func testOldBundleWithoutCommitDoesNotPretendToKnow() { let version = AppVersionReader.parse(infoPlist: plist(commit: nil)) XCTAssertEqual(version.commit, AppVersion.unknownCommit) - XCTAssertFalse(version.isTraceable, "Bez commitu nie da sie wskazac zrodla") + XCTAssertFalse(version.isTraceable, "Without a commit the source cannot be pointed at") } - /// Sedno: brudne drzewo znaczy, ze w binarce jest kod spoza commitu, wiec - /// numer commitu NIE dowodzi zgodnosci z galezia. - func testBrudnyBuildNieJestIdentyfikowalnyMimoZnanegoCommitu() { + /// The core: a dirty tree means the binary contains code from outside the + /// commit, so the commit number does NOT prove it matches the branch. + func testDirtyBuildIsNotTraceableDespiteKnownCommit() { let version = AppVersionReader.parse(infoPlist: plist(commit: "abc1234", dirty: "true")) XCTAssertEqual(version.commit, "abc1234") - XCTAssertFalse(version.isTraceable, "Brudne drzewo unieważnia commit jako dowod") + XCTAssertFalse(version.isTraceable, "A dirty tree invalidates the commit as proof") } - func testCzystyBuildZCommitemJestIdentyfikowalny() { + func testCleanBuildWithCommitIsTraceable() { XCTAssertTrue(AppVersionReader.parse(infoPlist: plist()).isTraceable) } - func testPodsumowanieNiesieCommitIOstrzezenie() { + func testSummaryCarriesCommitAndWarning() { XCTAssertEqual(AppVersionReader.parse(infoPlist: plist()).summary, "1.1.0 (67) abc1234") XCTAssertTrue( - AppVersionReader.parse(infoPlist: plist(dirty: "true")).summary.contains("BRUDNE-DRZEWO")) + AppVersionReader.parse(infoPlist: plist(dirty: "true")).summary.contains("DIRTY-TREE")) XCTAssertFalse( AppVersionReader.parse(infoPlist: plist(commit: nil)).summary.contains( AppVersion.unknownCommit), - "Brak commitu nie ma zasmiecac jednolinijkowca slowem 'nieznany'") + "A missing commit must not clutter the one-liner with the word 'unknown'") } } diff --git a/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift index 9a2fc2e..b60cf42 100644 --- a/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift @@ -2,18 +2,18 @@ import XCTest @testable import CloudMachineCore -/// Testy czujki cyklu backupu. +/// Tests of the backup cycle watchdog. /// -/// Kazdy z nich WSTRZYKUJE ZNANA ZLA PROBKE i sprawdza, ze czujka to ZGLASZA. -/// Cisza detektora niczego nie dowodzi - wczesniej caly "monitoring" tego -/// projektu polegal na tym, ze nikt nie widzial ostrzezenia, i to uchodzilo za -/// dowod, ze wszystko dziala. +/// Each of them INJECTS A KNOWN BAD SAMPLE and checks that the watchdog +/// REPORTS it. A detector's silence proves nothing - previously all the +/// "monitoring" of this project consisted of nobody seeing a warning, and that +/// passed for proof that everything worked. final class BackupHealthTests: XCTestCase { private let now = Date(timeIntervalSince1970: 1_757_700_000) - /// Wszystko sprawne - punkt odniesienia. Bez niego test "wykrywa awarie" - /// przechodzilby tez dla czujki, ktora krzyczy zawsze. + /// Everything working - the reference point. Without it a "detects a + /// failure" test would also pass for a watchdog that always screams. private func healthyInput( lastSuccess: Date? = nil, lastAttempt: Date? = nil, @@ -40,142 +40,146 @@ final class BackupHealthTests: XCTestCase { imageProbeTimedOut: imageProbeTimedOut) } - /// Sonda czytelnosci nie odpowiedziala w czasie. To NIE jest "obraz nie jest - /// podpiety" (obraz siedzi w tablicy montowan) i NIE jest "obraz MARTWY" - /// (urzadzenie nie odpowiedzialo wcale, a nie bledem). Pierwszy komunikat - /// wyslalby czlowieka podpinac cos, co jest podpiete; drugi - odpinac NA - /// SILE urzadzenie, ktore moze byc zywe i trzymac niewyslane dane. + /// The readability probe did not answer in time. This is NOT "the image is + /// not attached" (the image is in the mount table) and NOT "the image is + /// DEAD" (the device did not answer at all, rather than with an error). The + /// first message would send the person to attach something that is + /// attached; the second - to FORCE-detach a device that may be alive and + /// holding unsent data. /// - /// Najwazniejsze jednak jest to, ze czujka W OGOLE tu dociera: przed - /// poprawka ten odczyt nie mial limitu czasu, a `StartInterval 1800` bez - /// `KeepAlive` znaczy, ze jedno zawieszenie uciszalo czujke NA STALE. - func testSondaBezOdpowiedziJestZglaszanaJakoBrakWiedzy() { + /// Most important, though, is that the watchdog gets here AT ALL: before the + /// fix this read had no time limit, and `StartInterval 1800` without + /// `KeepAlive` means a single hang silenced the watchdog PERMANENTLY. + func testUnansweredProbeIsReportedAsLackOfKnowledge() { let report = healthyInput(attached: nil, imageProbeTimedOut: true) - XCTAssertFalse(report.healthy, "cisza o stanie, ktorego nie znamy, jest tu awaria") + XCTAssertFalse(report.healthy, "silence about a state we do not know is a failure here") XCTAssertTrue( - report.problems.contains { $0.summary.contains("oddaje dane") }, - "czujka ma DOKONCZYC przebieg i zglosic brak wiedzy: \(report.problems)") - XCTAssertFalse(report.problems.contains { $0.summary.contains("nie jest podpiety") }) - XCTAssertFalse(report.problems.contains { $0.summary.contains("MARTWY") }) + report.problems.contains { $0.summary.contains("returns data") }, + "the watchdog must FINISH the run and report the lack of knowledge: \(report.problems)") + XCTAssertFalse(report.problems.contains { $0.summary.contains("is not attached") }) + XCTAssertFalse(report.problems.contains { $0.summary.contains("DEAD") }) } - /// Dwie przyczyny "nie wiem" wysylaja czlowieka w dwa rozne miejsca, wiec - /// nie moga dostac tego samego zdania. - func testDwiePrzyczynyBrakuWiedzyOObrazieMajaRozneKomunikaty() { - let sonda = healthyInput(attached: nil, imageProbeTimedOut: true).problems - let tablica = healthyInput(attached: nil).problems - XCTAssertNotEqual(sonda, tablica) - XCTAssertFalse(sonda.isEmpty) - XCTAssertFalse(tablica.isEmpty) + /// Two causes of "I do not know" send the person to two different places, + /// so they must not get the same sentence. + func testTwoCausesOfUnknownImageStateHaveDifferentMessages() { + let probe = healthyInput(attached: nil, imageProbeTimedOut: true).problems + let table = healthyInput(attached: nil).problems + XCTAssertNotEqual(probe, table) + XCTAssertFalse(probe.isEmpty) + XCTAssertFalse(table.isEmpty) } - /// Obraz w tablicy montowan, ale odczyt pada - 22 wrz 2026 przez 15 h zaden - /// czujnik nie mial dla tego stanu nazwy. Teraz ma. - func testMartwyObrazAlarmuje() { + /// The image is in the mount table, but reading fails - on 22 Sep 2026 for + /// 15 h no sensor had a name for this state. Now it does. + func testDeadImageAlarms() { let report = BackupHealth.evaluate( lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), result: 0, now: now, mounted: true, attached: true, destinationRegistered: true, erroredFiles: 0, outOfSpace: false, queueReadable: true, imageDeadErrno: ENXIO) XCTAssertFalse(report.healthy) - XCTAssertTrue(report.problems.contains { $0.summary.contains("MARTWY") }) + XCTAssertTrue(report.problems.contains { $0.summary.contains("DEAD") }) XCTAssertFalse( - report.problems.contains { $0.summary.contains("nie jest podpiety") }, - "Martwy to inny stan niz niepodpiety - jeden alarm, nie dwa.") + report.problems.contains { $0.summary.contains("is not attached") }, + "Dead is a different state from detached - one alarm, not two.") } - func testSprawnyCyklNieAlarmuje() { - XCTAssertTrue(healthyInput().healthy, "Czujka, ktora alarmuje zawsze, nie niesie informacji.") + func testWorkingCycleDoesNotAlarm() { + XCTAssertTrue(healthyInput().healthy, "A watchdog that always alarms carries no information.") } - // MARK: - Znane zle probki + // MARK: - Known bad samples - /// TO jest awaria, ktorej caly dotychczasowy system NIE wykrywal: - /// montowanie stoi, obraz podpiety, cel zarejestrowany - a kopia nie - /// powstala od dwoch dni. Interfejs pokazywal wtedy zielony znaczek. - func testCichaAwariaCykluJestZglaszana() { + /// THIS is the failure the whole previous system did NOT detect: the mount + /// is up, the image attached, the destination registered - and no backup + /// has been made for two days. The interface showed a green badge then. + func testSilentCycleFailureIsReported() { let report = healthyInput( lastSuccess: now.addingTimeInterval(-48 * 3600), lastAttempt: now.addingTimeInterval(-48 * 3600)) XCTAssertFalse(report.healthy) XCTAssertTrue( - report.problems.contains { $0.summary.contains("Brak udanej kopii") }, - "Wiek ostatniej UDANEJ kopii to jedyny licznik, ktory rosnie tylko przy sukcesie.") + report.problems.contains { $0.summary.contains("No successful backup") }, + "The age of the last SUCCESSFUL backup is the only counter that grows only on success.") } - /// Proba nowsza niz sukces = backup ruszyl i padl. Sam prog wieku tego nie - /// zlapie, dopoki nie minie - a ten sygnal jest dostepny od razu. - func testProbaBezSukcesuJestZglaszana() { + /// An attempt newer than the success = the backup started and failed. The + /// age threshold alone will not catch that until it passes - and this signal + /// is available right away. + func testAttemptWithoutSuccessIsReported() { let report = healthyInput( lastSuccess: now.addingTimeInterval(-2 * 3600), lastAttempt: now.addingTimeInterval(-90 * 60)) XCTAssertFalse(report.healthy) - XCTAssertTrue(report.problems.contains { $0.summary.contains("nie skonczyla sie kopia") }) + XCTAssertTrue(report.problems.contains { $0.summary.contains("did not end in a backup") }) } - func testNiezerowyResultJestZglaszany() { + func testNonZeroResultIsReported() { let report = healthyInput(result: 27) XCTAssertFalse(report.healthy) XCTAssertTrue(report.problems.contains { $0.summary.contains("RESULT=27") }) } - /// Pasma, ktorych rclone nie wyslal, istnieja tylko na tym Macu. Kopia na - /// Dysku jest wtedy NIEPELNA i moze sie nie otworzyc. - func testNiewyslanePlikiSaZglaszane() { + /// Bands rclone did not upload exist only on this Mac. The backup on the + /// Drive is then INCOMPLETE and may not open. + func testUnsentFilesAreReported() { let report = healthyInput(erroredFiles: 12) XCTAssertFalse(report.healthy) - XCTAssertTrue(report.problems.contains { $0.summary.contains("12 plikow") }) + XCTAssertTrue(report.problems.contains { $0.summary.contains("12 files") }) } - func testBrakMontowaniaJestZglaszany() { + func testMissingMountIsReported() { XCTAssertTrue( - healthyInput(mounted: false).problems.contains { $0.summary.contains("Montowanie") }) + healthyInput(mounted: false).problems.contains { $0.summary.contains("mount") }) } - func testNiepodpietyObrazJestZglaszany() { + func testDetachedImageIsReported() { XCTAssertTrue( - healthyInput(attached: false).problems.contains { $0.summary.contains("nie jest podpiety") }) + healthyInput(attached: false).problems.contains { $0.summary.contains("is not attached") }) } - func testPrzestawionyCelTimeMachineJestZglaszany() { + func testChangedTimeMachineDestinationIsReported() { XCTAssertTrue( healthyInput(destinationRegistered: false).problems.contains { - $0.summary.contains("nie wskazuje") + $0.summary.contains("does not point") }) } - func testPelnyBuforJestZglaszany() { - XCTAssertTrue(healthyInput(outOfSpace: true).problems.contains { $0.summary.contains("Bufor") }) + func testFullBufferIsReported() { + XCTAssertTrue( + healthyInput(outOfSpace: true).problems.contains { $0.summary.contains("Buffer") }) } - /// Brak odczytu ze stanu kolejki NIE moze uchodzic za "wszystko dobrze" - - /// przy pytaniu o bezpieczenstwo danych milczenie musi znaczyc "nie wiem", - /// a "nie wiem" traktujemy jak awarie. - func testNieczytelnaKolejkaJestZglaszana() { + /// Failing to read the queue state must NOT pass for "all good" - when the + /// question is data safety, silence must mean "I do not know", and we treat + /// "I do not know" as a failure. + func testUnreadableQueueIsReported() { XCTAssertTrue( healthyInput(queueReadable: false).problems.contains { $0.summary.contains("rclone") }) } - /// Brak JAKIEJKOLWIEK udanej kopii to nie to samo co swieza kopia - a przy - /// naiwnym porownaniu dat `nil` latwo wpada w "nie przekroczono progu". - func testBrakJakiejkolwiekKopiiJestZglaszany() { + /// The absence of ANY successful backup is not the same as a fresh backup - + /// and with a naive date comparison `nil` easily falls into "threshold not + /// exceeded". + func testAbsenceOfAnyBackupIsReported() { let report = BackupHealth.evaluate( lastSuccess: nil, lastAttempt: nil, result: 0, now: now, mounted: true, attached: true, destinationRegistered: true, erroredFiles: 0, outOfSpace: false, queueReadable: true) XCTAssertFalse(report.healthy) - XCTAssertTrue(report.problems.contains { $0.summary.contains("ANI JEDNEJ") }) + XCTAssertTrue(report.problems.contains { $0.summary.contains("NOT A SINGLE") }) } - // MARK: - Odczyt preferencji Time Machine + // MARK: - Reading the Time Machine preferences - /// Ksztalt odwzorowany z prawdziwego - /// `/Library/Preferences/com.apple.TimeMachine.plist` na dzialajacej - /// instalacji: `SnapshotDates` dostaje wpis dopiero po ZAKONCZONYM backupie, - /// `AttemptDates` liczy takze te, ktore padly. + /// The shape mirrored from the real + /// `/Library/Preferences/com.apple.TimeMachine.plist` on a working + /// installation: `SnapshotDates` gets an entry only after a COMPLETED + /// backup, `AttemptDates` also counts the ones that failed. private func preferences(volumeName: String = "CloudMachine") -> [String: Any] { [ "Destinations": [ [ - "LastKnownVolumeName": "JakisInnyDysk", + "LastKnownVolumeName": "SomeOtherDisk", "SnapshotDates": [Date(timeIntervalSince1970: 1)], "AttemptDates": [Date(timeIntervalSince1970: 1)], "RESULT": NSNumber(value: 5), @@ -193,7 +197,7 @@ final class BackupHealthTests: XCTestCase { ] } - func testOdczytBierzeNAJNOWSZAKopie() { + func testReadingTakesTheNEWESTBackup() { let (lastSuccess, lastAttempt, result) = BackupHealth.dates( inPreferences: preferences(), volumeNamed: "CloudMachine") XCTAssertEqual(lastSuccess, Date(timeIntervalSince1970: 1_757_698_000)) @@ -201,29 +205,29 @@ final class BackupHealthTests: XCTestCase { XCTAssertEqual(result, 0) } - /// Mac moze miec wiecej niz jeden zarejestrowany cel Time Machine. Branie - /// pierwszego z brzegu czytaloby cudze daty - i pokazywaloby cudzy sukces - /// jako nasz. - func testOdczytWybieraWlasciwyCelPoNazwieWolumenu() { + /// A Mac can have more than one registered Time Machine destination. Taking + /// whichever comes first would read someone else's dates - and show someone + /// else's success as ours. + func testReadingPicksTheRightDestinationByVolumeName() { let (lastSuccess, _, result) = BackupHealth.dates( - inPreferences: preferences(), volumeNamed: "JakisInnyDysk") + inPreferences: preferences(), volumeNamed: "SomeOtherDisk") XCTAssertEqual(lastSuccess, Date(timeIntervalSince1970: 1)) XCTAssertEqual(result, 5) } - func testOdczytNieZgadujePrzyBrakuNaszegoCelu() { + func testReadingDoesNotGuessWhenOurDestinationIsMissing() { let (lastSuccess, lastAttempt, result) = BackupHealth.dates( - inPreferences: preferences(), volumeNamed: "CalkiemInny") + inPreferences: preferences(), volumeNamed: "SomethingElseEntirely") XCTAssertNil(lastSuccess) XCTAssertNil(lastAttempt) XCTAssertNil(result) } - /// Domyka luke miedzy PLIKIEM a ocena: zapis do pliku, odczyt tak samo jak - /// robi to `currentReport`, i dopiero potem `dates`. Testy na samym - /// `evaluate` nie pokrywaja serializacji, a to wlasnie tam "czujka milczy" - /// wyglada identycznie jak "wszystko dobrze". - func testOdczytPrzezPrawdziwyPlikPlistDajeTeSameDaty() throws { + /// Closes the gap between the FILE and the assessment: writing to a file, + /// reading it the same way `currentReport` does, and only then `dates`. Tests + /// on `evaluate` alone do not cover serialization, and that is exactly where + /// "the watchdog is silent" looks identical to "all good". + func testReadingThroughARealPlistFileGivesTheSameDates() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("cm-health-\(UUID().uuidString).plist") defer { try? FileManager.default.removeItem(at: url) } @@ -243,80 +247,84 @@ final class BackupHealthTests: XCTestCase { XCTAssertEqual(result, 0) } - /// Nieczytelny plik NIE moze wygladac jak zdrowy cykl. - func testNieczytelnyPlikNiePrzechodziZaSukces() async { + /// An unreadable file must NOT look like a healthy cycle. + func testUnreadableFileDoesNotPassForSuccess() async { let report = await BackupHealth.currentReport( - preferencesFile: "/nie/ma/takiego/pliku.plist") + preferencesFile: "/no/such/file.plist") XCTAssertFalse(report.healthy) - XCTAssertTrue(report.problems.contains { $0.summary.contains("preferencji Time Machine") }) + XCTAssertTrue( + report.problems.contains { $0.summary.contains("Time Machine preferences") }) } - // MARK: - Zglaszanie + // MARK: - Reporting - /// Komunikat z cudzyslowem musi przejsc przez AppleScript bez rozwalenia - /// skryptu - inaczej alarm ginie po cichu, czyli zachowuje sie dokladnie - /// tak jak awaria, ktora mial zglosic. Komunikaty rclone cudzyslowy maja. - func testCudzyslowWKomunikacieNieRozwalaPowiadomienia() { + /// A message with a quote must get through AppleScript without breaking the + /// script - otherwise the alarm vanishes silently, i.e. behaves exactly like + /// the failure it was meant to report. rclone messages do contain quotes. + func testQuoteInTheMessageDoesNotBreakTheNotification() { XCTAssertEqual( - HealthAlert.appleScriptLiteral("Post \"https://x\" anulowano"), - "\"Post \\\"https://x\\\" anulowano\"") + HealthAlert.appleScriptLiteral("Post \"https://x\" canceled"), + "\"Post \\\"https://x\\\" canceled\"") } - func testOdwrotnyUkosnikTezJestUciekany() { + func testBackslashIsEscapedToo() { XCTAssertEqual(HealthAlert.appleScriptLiteral("a\\b"), "\"a\\\\b\"") } - func testNowaLiniaNieRozwalaPowiadomienia() { + func testNewlineDoesNotBreakTheNotification() { XCTAssertFalse(HealthAlert.appleScriptLiteral("a\nb").contains("\n")) } - // MARK: - Ustalenie 15c: Pelny dostep do dysku sprawdza sie PROBUJAC + // MARK: - Finding 15c: Full Disk Access is checked by TRYING - /// ZNANA ZLA PROBKA: katalog. `FileManager.isReadableFile(atPath:)` - - /// czyli `access(R_OK)` - mowi o katalogu "czytelny", a odczytac go jako plik - /// nie da sie wcale. Interfejs pytal dokladnie tak i dokladnie o katalog - /// (`~/Library/Application Support/com.apple.TCC`), wiec odpowiadal "Pelny - /// dostep jest" niezaleznie od stanu uprawnien - w tym w chwili, w ktorej - /// czujka nie mogla odczytac ani jednej daty kopii. + /// KNOWN BAD SAMPLE: a directory. `FileManager.isReadableFile(atPath:)` - + /// i.e. `access(R_OK)` - calls a directory "readable", yet it cannot be read + /// as a file at all. The interface asked exactly this way and about exactly + /// a directory (`~/Library/Application Support/com.apple.TCC`), so it + /// answered "Full Disk Access granted" regardless of the permission state - + /// including at a moment when the watchdog could not read a single backup + /// date. /// - /// Do TCC nie ma pytania, jest tylko proba. Ten test porownuje oba sposoby na - /// tej samej sciezce. - func testKatalogNieDowodziCzytelnosciPliku() throws { - let katalog = FileManager.default.temporaryDirectory + /// There is no question to ask TCC, only an attempt. This test compares both + /// approaches on the same path. + func testDirectoryDoesNotProveFileReadability() throws { + let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cm-fda-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: katalog) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } XCTAssertTrue( - FileManager.default.isReadableFile(atPath: katalog.path), - "access(R_OK) na katalogu mowi 'czytelny' - i to bylo cale dawne sprawdzenie") + FileManager.default.isReadableFile(atPath: directory.path), + "access(R_OK) on a directory says 'readable' - and that was the whole old check") XCTAssertFalse( - BackupHealth.preferencesReadable(preferencesFile: katalog.path), - "realny odczyt musi powiedziec NIE - katalog nie jest plistem z historia kopii") + BackupHealth.preferencesReadable(preferencesFile: directory.path), + "a real read must say NO - a directory is not a plist with the backup history") } - /// Plik, ktorego nie ma, to brak dostepu do jego tresci - a nie milczenie. - func testBrakPlikuToBrakDostepu() { + /// A file that does not exist means no access to its content - not silence. + func testMissingFileMeansNoAccess() { XCTAssertFalse( BackupHealth.preferencesReadable( - preferencesFile: "/nie-ma-takiej-sciezki/com.apple.TimeMachine.plist")) + preferencesFile: "/no-such-path/com.apple.TimeMachine.plist")) } - /// I odwrotny blad: czytelny plik MUSI wychodzic jako czytelny, inaczej panel - /// straszylby brakiem uprawnien na sprawnej maszynie. - func testCzytelnyPlikWychodziJakoCzytelny() throws { - let plik = FileManager.default.temporaryDirectory + /// And the opposite bug: a readable file MUST come out as readable, otherwise + /// the panel would scare people with missing permissions on a working + /// machine. + func testReadableFileComesOutAsReadable() throws { + let file = FileManager.default.temporaryDirectory .appendingPathComponent("cm-fda-\(UUID().uuidString).plist") - try Data("cokolwiek".utf8).write(to: plik) - defer { try? FileManager.default.removeItem(at: plik) } - XCTAssertTrue(BackupHealth.preferencesReadable(preferencesFile: plik.path)) + try Data("anything".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + XCTAssertTrue(BackupHealth.preferencesReadable(preferencesFile: file.path)) } - // MARK: - Okres rozruchu + // MARK: - Startup grace period - /// ZNANA ZLA PROBKA z 21.09, 25.09 i 01.10.2026: czujka odpala sie razem - /// z sesja, kilka sekund po starcie rclone - nic jeszcze nie stoi. - private func tuzPoStarcie(grace: Bool, lastSuccessAgo: TimeInterval = 1800) + /// KNOWN BAD SAMPLE from 21.09, 25.09 and 01.10.2026: the watchdog starts + /// together with the session, a few seconds after rclone starts - nothing is + /// up yet. + private func rightAfterStartup(grace: Bool, lastSuccessAgo: TimeInterval = 1800) -> BackupHealth.Report { BackupHealth.evaluate( @@ -327,29 +335,29 @@ final class BackupHealthTests: XCTestCase { withinStartupGrace: grace) } - func testTuzPoStarcieNiegotoweUrzadzeniaNieSaAwaria() { - let report = tuzPoStarcie(grace: true) + func testRightAfterStartupDevicesNotReadyAreNotAFailure() { + let report = rightAfterStartup(grace: true) XCTAssertTrue(report.healthy, "\(report.problems)") - XCTAssertEqual(report.deferred.count, 3, "odlozone, nie zgubione: \(report.deferred)") + XCTAssertEqual(report.deferred.count, 3, "deferred, not lost: \(report.deferred)") } - /// Ten sam stan PO okresie rozruchu musi alarmowac - inaczej poprzedni test - /// przechodzilby tez dla czujki, ktora milczy zawsze. - func testPoOkresieRozruchuTenSamStanAlarmuje() { - let report = tuzPoStarcie(grace: false) + /// The same state AFTER the grace period must alarm - otherwise the previous + /// test would also pass for a watchdog that is always silent. + func testAfterTheGracePeriodTheSameStateAlarms() { + let report = rightAfterStartup(grace: false) XCTAssertEqual(report.problems.count, 3, "\(report.problems)") XCTAssertTrue(report.deferred.isEmpty) } - /// Okres rozruchu NIE wycisza starej kopii: Mac wylaczony na noc to wiek, - /// ktory czujka ma zglosic od pierwszej sekundy. - func testOkresRozruchuNieWyciszaStarejKopii() { - let report = tuzPoStarcie(grace: true, lastSuccessAgo: 5 * 3600) - XCTAssertTrue(report.problems.contains { $0.summary.contains("Brak udanej kopii") }) + /// The grace period does NOT silence an old backup: a Mac switched off for + /// the night is an age the watchdog must report from the first second. + func testGracePeriodDoesNotSilenceAnOldBackup() { + let report = rightAfterStartup(grace: true, lastSuccessAgo: 5 * 3600) + XCTAssertTrue(report.problems.contains { $0.summary.contains("No successful backup") }) } - /// "Nie wiem" (zawieszony tmutil) nie jest normalnym stanem rozruchu. - func testOkresRozruchuNieWyciszaBrakuWiedzy() { + /// "I do not know" (a hung tmutil) is not a normal startup state. + func testGracePeriodDoesNotSilenceLackOfKnowledge() { let report = BackupHealth.evaluate( lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), result: 0, now: now, mounted: true, attached: true, destinationRegistered: nil, @@ -357,15 +365,15 @@ final class BackupHealthTests: XCTestCase { XCTAssertFalse(report.healthy) } - func testUptimeDaSieOdczytac() { + func testUptimeCanBeRead() { let uptime = BackupHealth.systemUptime() XCTAssertNotNil(uptime) XCTAssertGreaterThan(uptime ?? -1, 0) } - // MARK: - Przebieg w toku + // MARK: - Run in progress - private func probaSprzedDwochGodzin(running: Bool?) -> BackupHealth.Report { + private func attemptTwoHoursAgo(running: Bool?) -> BackupHealth.Report { BackupHealth.evaluate( lastSuccess: now.addingTimeInterval(-2.5 * 3600), lastAttempt: now.addingTimeInterval(-2 * 3600), @@ -373,20 +381,40 @@ final class BackupHealthTests: XCTestCase { erroredFiles: 0, outOfSpace: false, queueReadable: true, backupRunning: running) } - /// ZNANA ZLA PROBKA z 01.10.2026 17:02: przejscie calego dysku po - /// restarcie trwa godzinami, a czujka zglaszala probe, ktora jeszcze trwa. - func testTrwajacyPrzebiegNieJestNieudanaProba() { - XCTAssertTrue(probaSprzedDwochGodzin(running: true).healthy) + /// KNOWN BAD SAMPLE from 01.10.2026 17:02: walking the whole disk after a + /// restart takes hours, and the watchdog reported an attempt that was still + /// in progress. + func testRunInProgressIsNotAFailedAttempt() { + XCTAssertTrue(attemptTwoHoursAgo(running: true).healthy) } - /// Ta sama proba, gdy Time Machine juz NIE pracuje (albo nie wiadomo) - - /// to jest prawdziwy nieudany przebieg i ma alarmowac. - func testZakonczonaProbaBezKopiiAlarmuje() { + /// The same attempt when Time Machine is NO longer working (or it is + /// unknown) - that is a real failed run and must alarm. + func testFinishedAttemptWithoutBackupAlarms() { for running in [false, nil] as [Bool?] { XCTAssertTrue( - probaSprzedDwochGodzin(running: running).problems.contains { - $0.summary.contains("nie skonczyla sie kopia") + attemptTwoHoursAgo(running: running).problems.contains { + $0.summary.contains("did not end in a backup") }, "running=\(String(describing: running))") } } + + // MARK: - Problem codes + + /// `HealthAlert` recognizes "the same failure" by `code`, so every problem + /// `evaluate` can produce must carry its own stable code - not the + /// translated summary it falls back to. + func testEveryProblemHasALanguageIndependentCode() { + let report = BackupHealth.evaluate( + lastSuccess: now.addingTimeInterval(-5 * 3600), lastAttempt: nil, result: 3, now: now, + mounted: false, attached: nil, destinationRegistered: nil, erroredFiles: 2, + outOfSpace: true, queueReadable: false, driveFreeBytes: 1_073_741_824, localFreeGB: 1) + XCTAssertFalse(report.problems.isEmpty) + for problem in report.problems { + XCTAssertNotEqual(problem.code, problem.summary, "no own code: \(problem.summary)") + } + XCTAssertEqual( + Set(report.problems.map(\.code)).count, report.problems.count, + "two different problems must not share a code: \(report.problems.map(\.code))") + } } diff --git a/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift b/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift index d1f3155..11efcf5 100644 --- a/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift @@ -2,68 +2,69 @@ import XCTest @testable import CloudMachineCore -/// Operacje na obrazie backupu: wzajemne wykluczenie i te werdykty, ktore -/// wczesniej klamaly - "wszystko wyslane" przy porzuconych pasmach i "obraz -/// NIESPOJNY" po wyrwaniu urzadzenia spod `fsck`. +/// Operations on the backup image: mutual exclusion and the verdicts that used +/// to lie - "everything uploaded" with abandoned bands and "image +/// INCONSISTENT" after the device was pulled out from under `fsck`. final class BackupImageServiceTests: XCTestCase { - // MARK: - Wzajemne wykluczenie + // MARK: - Mutual exclusion - /// Blokada nazwana `BackupImageService.lockName` lezy w prawdziwym katalogu - /// logow, bo to tej samej blokady uzywaja `attach`/`detach`/`verify`/`create`. - /// Trzymamy ja przez ulamek sekundy i tylko po to, zeby sprawdzic, ze - /// operacje jej PRZESTRZEGAJA - zadna z nich nie dochodzi wtedy do `hdiutil`. + /// The lock named `BackupImageService.lockName` lives in the real log + /// directory, because `attach`/`detach`/`verify`/`create` use that same lock. + /// We hold it for a fraction of a second and only to check that the + /// operations RESPECT it - none of them gets as far as `hdiutil` then. private func withHeldImageLock(_ body: () async -> Void) async throws { let lock = CMLock(name: BackupImageService.lockName) try XCTSkipUnless( lock.acquire(), - "blokade '\(BackupImageService.lockName)' trzyma cos innego na tej maszynie") + "the '\(BackupImageService.lockName)' lock is held by something else on this machine") defer { lock.release() } await body() } - /// Sedno poprawki: do 23 wrzesnia 2026 `withCMLock` nie bylo wolane z ani - /// jednego miejsca w repo, wiec kazda z tych czterech operacji szla przy - /// trzymanej blokadzie tak samo jak bez niej. Test sprawdza nie tylko - /// `succeeded == false` (to akurat wychodzilo juz wczesniej, bo bufor nie - /// jest zamontowany), ale takze tresc i - co wazniejsze - `disposition`: - /// operacja ma rozpoznac zajetosc, a nie odbic sie od czegos innego po drodze. - func testOperacjeNaObrazieNieWchodzaSobieWDroge() async throws { + /// The heart of the fix: until 23 September 2026 `withCMLock` was not called + /// from a single place in the repo, so each of these four operations ran with + /// the lock held just as without it. The test checks not only + /// `succeeded == false` (that already came out before, because the buffer is + /// not mounted), but also the content and - more importantly - `disposition`: + /// the operation has to recognise the lock is taken, not bounce off something + /// else along the way. + func testImageOperationsDoNotGetInEachOthersWay() async throws { try await withHeldImageLock { - for (nazwa, wynik) in [ + for (name, result) in [ ("create", await BackupImageService.create(sizeGB: 100)), ("attach", await BackupImageService.attach()), ("detach", await BackupImageService.detach()), ("verify", await BackupImageService.verify()), ] { - XCTAssertFalse(wynik.succeeded, "\(nazwa) przy zajetym obrazie nie moze meldowac sukcesu") + XCTAssertFalse(result.succeeded, "\(name) must not report success with the image busy") XCTAssertTrue( - wynik.message.contains("inna operacja na obrazie"), - "\(nazwa) ma powiedziec, ze NIE zrobilo nic - dostalem: \(wynik.message)") + result.message.contains("another image operation"), + "\(name) has to say that it did NOTHING - got: \(result.message)") XCTAssertEqual( - wynik.disposition, .skipped, - "\(nazwa): zajetosc ma byc rozpoznawalna po TYPIE wyniku, nie po tresci komunikatu") + result.disposition, .skipped, + "\(name): busy has to be recognisable by the result TYPE, not by the message text") } } } - /// `attach-image` chodzi pod launchd co 900 s i jego kod wyjscia laduje w - /// `launchd-gdrive-attach.err.log`. Zajetosc obrazu nie moze sie tam - /// zapisywac jako awaria - ale prawdziwa porazka MUSI, inaczej schowalibysmy - /// realny blad za kodem 0. - func testZajeteToNieAwariaAlePorazkaNadalJestPorazka() { + /// `attach-image` runs under launchd every 900 s and its exit code lands in + /// `launchd-gdrive-attach.err.log`. A busy image must not be recorded there + /// as a failure - but a real failure MUST be, otherwise we would hide a real + /// error behind exit code 0. + func testBusyIsNotAFailureButAFailureIsStillAFailure() { XCTAssertEqual( - CMActionResult(succeeded: true, message: "Podpiete").disposition, .ok) + CMActionResult(succeeded: true, message: "Attached").disposition, .ok) XCTAssertEqual( - CMActionResult(succeeded: false, message: "zajete", didNotRun: true).disposition, + CMActionResult(succeeded: false, message: "busy", didNotRun: true).disposition, .skipped) XCTAssertEqual( - CMActionResult(succeeded: false, message: "Nie udalo sie podpiac obrazu").disposition, + CMActionResult(succeeded: false, message: "Could not attach the image").disposition, .failed, - "brak `didNotRun` ma znaczyc realna porazke - domyslna wartosc nie moze uciszac bledow") + "no `didNotRun` has to mean a real failure - the default value must not silence errors") } - // MARK: - Werdykt o odpieciu + // MARK: - Detach verdict private func stats(queued: Int = 0, inProgress: Int = 0, errored: Int = 0) -> DriveBufferService.QueueStats @@ -73,139 +74,139 @@ final class BackupImageServiceTests: XCTestCase { erroredFiles: errored, bytesUsed: 1024, outOfSpace: false) } - func testPustaKolejkaBezBledowToWszystkoWyslane() { - let wynik = BackupImageService.detachVerdict(settled: stats()) - XCTAssertTrue(wynik.succeeded) - XCTAssertTrue(wynik.message.contains("wszystko wyslane")) + func testEmptyQueueWithoutErrorsMeansEverythingUploaded() { + let result = BackupImageService.detachVerdict(settled: stats()) + XCTAssertTrue(result.succeeded) + XCTAssertTrue(result.message.contains("everything uploaded")) } - /// Pasma porzucone przez rclone wypadaja z kolejki tak samo jak wyslane, - /// wiec sama pusta kolejka meldowala "Odpiete, wszystko wyslane na Google - /// Drive" przy danych istniejacych TYLKO na tym Macu. - func testPorzuconePasmaNieSaWyslane() { - let wynik = BackupImageService.detachVerdict(settled: stats(errored: 7)) - XCTAssertFalse(wynik.succeeded) - XCTAssertFalse(wynik.message.contains("wszystko wyslane")) - XCTAssertTrue(wynik.message.contains("7")) + /// Bands abandoned by rclone drop out of the queue just like uploaded ones, + /// so an empty queue alone reported "Detached, everything uploaded to Google + /// Drive" with data existing ONLY on this Mac. + func testAbandonedBandsAreNotUploaded() { + let result = BackupImageService.detachVerdict(settled: stats(errored: 7)) + XCTAssertFalse(result.succeeded) + XCTAssertFalse(result.message.contains("everything uploaded")) + XCTAssertTrue(result.message.contains("7")) } - /// Brak odczytu to nie sukces - patrz `UploadState.queueUnknown`. - func testBrakOdczytuKolejkiToNieSukces() { - let wynik = BackupImageService.detachVerdict(settled: nil) - XCTAssertFalse(wynik.succeeded) + /// No reading is not success - see `UploadState.queueUnknown`. + func testNoQueueReadingIsNotSuccess() { + let result = BackupImageService.detachVerdict(settled: nil) + XCTAssertFalse(result.succeeded) } - // MARK: - Tablica montowan + // MARK: - Mount table - /// Dotad ta lista powstawala z parsowania wydruku `/sbin/mount` (`" on "` … - /// `" ("`) wewnatrz `unmountBrowsedSnapshots()`, wiec nie bylo do czego - /// podstawic probki. Migawka backupu montuje sie pod - /// `/Volumes/.timemachine//.backup/` i trzyma - /// urzadzenie obrazu zajete, przez co `hdiutil detach` odmawia. - func testWybieraTylkoPrzegladaneMigawkiBackupu() { - let punkty = [ + /// Until now this list came from parsing `/sbin/mount` output (`" on "` … + /// `" ("`) inside `unmountBrowsedSnapshots()`, so there was nothing to + /// substitute a sample for. A backup snapshot mounts under + /// `/Volumes/.timemachine//.backup/` and keeps the image's + /// device busy, which makes `hdiutil detach` refuse. + func testPicksOnlyBrowsedBackupSnapshots() { + let points = [ "/", "/Volumes/CloudMachine", "/Users/mbeczynski/.cloudmachine/drive", "/Volumes/.timemachine/mac-studio/2026-09-23-101500.backup/CloudMachine", "/Volumes/.timemachine/mac-studio/2026-09-22-231500.backup/CloudMachine", - // Pulapka: podobna nazwa, ale NIE pod katalogiem migawek. - "/Volumes/timemachine-kopia", + // Trap: a similar name, but NOT under the snapshot directory. + "/Volumes/timemachine-copy", ] XCTAssertEqual( - BackupImageService.browsedSnapshotMounts(punkty), + BackupImageService.browsedSnapshotMounts(points), [ "/Volumes/.timemachine/mac-studio/2026-09-23-101500.backup/CloudMachine", "/Volumes/.timemachine/mac-studio/2026-09-22-231500.backup/CloudMachine", ]) } - func testBrakMigawekToPustaLista() { + func testNoSnapshotsGivesEmptyList() { XCTAssertEqual(BackupImageService.browsedSnapshotMounts(["/", "/Volumes/CloudMachine"]), []) } - /// Tablica montowan czytana z jadra, nie z `/sbin/mount`. Sprawdzamy na - /// zywo, bo cala poprawka polega na tym, ze ten odczyt NIE uruchamia procesu - /// i NIE dotyka systemu plikow - czego atrapa by nie pokazala. - func testTablicaMontowanJestCzytelnaIZawieraKorzen() throws { - let punkty = try XCTUnwrap( - DriveBufferService.mountPoints(), "getmntinfo nie oddal tablicy montowan") - XCTAssertTrue(punkty.contains("/"), "kazdy system ma zamontowany korzen - dostalem: \(punkty)") + /// The mount table read from the kernel, not from `/sbin/mount`. We check it + /// live, because the whole fix is that this read does NOT start a process + /// and does NOT touch the file system - which a fake would not show. + func testMountTableIsReadableAndContainsRoot() throws { + let points = try XCTUnwrap( + DriveBufferService.mountPoints(), "getmntinfo did not return the mount table") + XCTAssertTrue(points.contains("/"), "every system has the root mounted - got: \(points)") } - // MARK: - Stan podpiecia - - /// `.unknown` to NIE `.detached`. `.detached` jest twierdzeniem - /// („sprawdzilem, nie ma"), a przy nieodczytanej tablicy montowan nie bylo - /// czego sprawdzic. Rozroznienie ma znaczenie, bo `attach` na podstawie - /// `.detached` robi `purgeStaleDevices()`, czyli `detach -force` na - /// urzadzeniu, ktore moze byc w tym czasie zywe. - func testNieznanyStanToNiePodpietyIleczNieodpiety() { - let nieznany = BackupImageService.Attachment.unknown - XCTAssertNotEqual(nieznany, .detached) - XCTAssertNotEqual(nieznany, .attached) + // MARK: - Attachment state + + /// `.unknown` is NOT `.detached`. `.detached` is a claim ("I checked, it is + /// not there"), while with an unread mount table there was nothing to check. + /// The distinction matters, because on `.detached` `attach` runs + /// `purgeStaleDevices()`, i.e. `detach -force` on a device that may be alive + /// at that moment. + func testUnknownStateIsNeitherAttachedNorDetached() { + let unknown = BackupImageService.Attachment.unknown + XCTAssertNotEqual(unknown, .detached) + XCTAssertNotEqual(unknown, .attached) XCTAssertFalse( - nieznany.isUsable, - "na niewiadomej nie wolno polegac - Time Machine nie ma tu gwarancji celu") + unknown.isUsable, + "you must not rely on an unknown - Time Machine has no guaranteed destination here") } - /// Kazdy stan ma dawac inne zdanie. Wspolny opis dla `.detached` - /// i `.unknown` przywrocilby zlanie, ktore ta poprawka usuwa - tyle ze - /// w warstwie, ktora czyta czlowiek. - func testKazdyStanPodpieciaMaWlasnyOpis() { - let opisy = [ + /// Every state has to give a different sentence. A shared description for + /// `.detached` and `.unknown` would bring back the merging this fix removes - + /// just in the layer a person reads. + func testEveryAttachmentStateHasItsOwnDescription() { + let descriptions = [ BackupImageService.describe(.attached), BackupImageService.describe(.detached), BackupImageService.describe(.dead(errno: ENXIO)), BackupImageService.describe(.unknown), - // Ten sam stan, INNA przyczyna: sonda czytelnosci nie odpowiedziala - // w czasie. Decyzja jest ta sama (wstrzymaj), ale zdanie dla czlowieka - // musi byc inne - patrz `attachmentReading()`. + // The same state, a DIFFERENT cause: the readability probe did not + // answer in time. The decision is the same (hold off), but the sentence + // for a person must differ - see `attachmentReading()`. BackupImageService.describe(.unknown, probeTimedOut: true), ] - XCTAssertEqual(Set(opisy).count, opisy.count, "opisy sie powtarzaja: \(opisy)") - XCTAssertTrue(BackupImageService.describe(.unknown).contains("NIE WIADOMO")) + XCTAssertEqual( + Set(descriptions).count, descriptions.count, "descriptions repeat: \(descriptions)") + XCTAssertTrue(BackupImageService.describe(.unknown).contains("UNKNOWN")) XCTAssertTrue( - BackupImageService.describe(.unknown, probeTimedOut: true).contains("sonda"), - "opis ma mowic, ze to sonda nie odpowiedziala, a nie ze tablica montowan") + BackupImageService.describe(.unknown, probeTimedOut: true).contains("probe"), + "the description has to say that the probe did not answer, not the mount table") } - // MARK: - Urzadzenie nadrzedne + // MARK: - Parent device - /// `fsck_apfs` dostaje partycje, `hdiutil info` wypisuje urzadzenie - /// nadrzedne - bez tego przeliczenia sprawdzenie "czy urzadzenie przezylo" - /// odpowiadaloby "nie" zawsze. - func testUrzadzenieNadrzedneZPartycji() { + /// `fsck_apfs` gets the partition, `hdiutil info` lists the parent device - + /// without this conversion the "did the device survive" check would always + /// answer "no". + func testParentDeviceFromPartition() { XCTAssertEqual(BackupImageService.parentDevice(of: "/dev/disk7s1"), "/dev/disk7") XCTAssertEqual(BackupImageService.parentDevice(of: "/dev/disk12s3"), "/dev/disk12") XCTAssertEqual(BackupImageService.parentDevice(of: "/dev/disk7"), "/dev/disk7") - XCTAssertEqual(BackupImageService.parentDevice(of: "cos-innego"), "cos-innego") + XCTAssertEqual(BackupImageService.parentDevice(of: "something-else"), "something-else") } - // MARK: - Obraz na zdalnym + // MARK: - Image on the remote private let listing = """ - inne-dane/ + other-data/ mac-studio.sparsebundle/ """ - func testWypisZdalnegoZObrazem() { + func testRemoteListingWithImage() { XCTAssertEqual( BackupImageService.classifyRemoteListing(succeeded: true, stdout: listing, stderr: ""), .present) } - func testPustyWypisZdalnegoToBrakObrazu() { + func testEmptyRemoteListingMeansNoImage() { XCTAssertEqual( BackupImageService.classifyRemoteListing(succeeded: true, stdout: "", stderr: ""), .absent) } - /// Pierwsze uruchomienie: zdalnego katalogu jeszcze nie ma. To jest - /// ODPOWIEDZ ("nie ma tam nic"), a nie jej brak - inaczej straznik - /// blokowalby `create` dokladnie w tym jedynym przypadku, dla ktorego - /// `create` istnieje. - func testBrakKataloguNaZdalnymToBrakObrazu() { + /// First run: the remote directory does not exist yet. That is an ANSWER + /// ("there is nothing there"), not the lack of one - otherwise the guard would + /// block `create` in exactly the one case `create` exists for. + func testMissingRemoteDirectoryMeansNoImage() { XCTAssertEqual( BackupImageService.classifyRemoteListing( succeeded: false, stdout: "", @@ -213,68 +214,69 @@ final class BackupImageServiceTests: XCTestCase { .absent) } - /// Zerwane lacze to NIE dowod nieobecnosci obrazu. Tworzenie obrazu jest - /// nieodwracalne, wiec brak pewnosci musi je przerwac. - func testBrakLaczaToNieDowodNieobecnosci() { - let wynik = BackupImageService.classifyRemoteListing( + /// A broken link is NOT proof that the image is absent. Creating the image is + /// irreversible, so lack of certainty has to abort it. + func testNoLinkIsNotProofOfAbsence() { + let result = BackupImageService.classifyRemoteListing( succeeded: false, stdout: "", stderr: "Failed to lsf with 2 errors: couldn't connect to Google Drive") - guard case .unknown = wynik else { - return XCTFail("brak odpowiedzi ma byc .unknown, dostalem \(wynik)") + guard case .unknown = result else { + return XCTFail("no answer has to be .unknown, got \(result)") } } - // MARK: - Ustalenie 5: co odpiecie mowi o kolejce + // MARK: - Finding 5: what the detach says about the queue - /// TA usterka. `expireQueuedUploads()` liczylo tylko SUKCESY, wiec kolejka - /// pelna pozycji, z ktorych zadnej nie udalo sie przyspieszyc, wychodzila - /// stad jako `0` - dokladnie tak samo jak kolejka pusta. Zmierzony stan tej - /// maszyny w chwili audytu: 462 pozycje, a log twierdzilby "kolejka pusta". + /// THE defect. `expireQueuedUploads()` counted only SUCCESSES, so a queue + /// full of items none of which could be sped up came out of it as `0` - + /// exactly like an empty queue. The measured state of this machine at the + /// time of the audit: 462 items, and the log would claim "queue empty". /// - /// Te linie czyta czlowiek w chwili, w ktorej decyduje, czy wolno skasowac - /// bufor - "kolejka pusta" czyta sie tam jako "nic nie czeka na wyslanie". - func testKolejkaPelnaBezAniJednegoSukcesuToNiePustaKolejka() { - let linia = BackupImageService.expiryLogLine( + /// A person reads these lines at the moment of deciding whether the buffer + /// may be deleted - "queue empty" reads there as "nothing is waiting to be + /// uploaded". + func testFullQueueWithoutASingleSuccessIsNotAnEmptyQueue() { + let line = BackupImageService.expiryLogLine( DriveBufferService.ExpiryOutcome(queued: 462, moved: 0)) XCTAssertFalse( - linia.contains("kolejka pusta"), - "462 pozycje w kolejce to nie pusta kolejka - dostalem: \(linia)") - XCTAssertTrue(linia.contains("462"), "liczba czekajacych pozycji musi byc widoczna: \(linia)") + line.contains("queue empty"), + "462 queued items are not an empty queue - got: \(line)") + XCTAssertTrue(line.contains("462"), "the number of waiting items must be visible: \(line)") XCTAssertTrue( - linia.contains("nie udalo sie"), - "log musi powiedziec, ze terminow NIE przesunieto: \(linia)") + line.contains("NOT ONE could be"), + "the log has to say that the deadlines were NOT moved: \(line)") } - /// Pusta kolejka nadal ma sie opisywac jako pusta - inaczej "naprawa" - /// polegajaca na skasowaniu tego przypadku przeszlaby niezauwazona. - func testPustaKolejkaNadalMowiZeJestPusta() { + /// An empty queue still has to describe itself as empty - otherwise a + /// "fix" consisting of deleting this case would go unnoticed. + func testEmptyQueueStillSaysItIsEmpty() { XCTAssertTrue( BackupImageService.expiryLogLine( DriveBufferService.ExpiryOutcome(queued: 0, moved: 0) - ).contains("kolejka pusta")) + ).contains("queue empty")) } - /// Czesciowa porazka tez nie jest sukcesem: pozycje bez przesunietego terminu - /// beda czekac cale `writeBackSeconds` i drenaz potrwa dluzej, niz wynikaloby - /// z linii "wymuszono wysylke N pozycji". - func testCzesciowePrzesuniecieMowiIleZOSTALO() { - let linia = BackupImageService.expiryLogLine( + /// A partial failure is not a success either: items without a moved deadline + /// will wait the whole `writeBackSeconds` and the drain will take longer than + /// the line "forced upload of N items" would suggest. + func testPartialMoveSaysHowManyAreLeft() { + let line = BackupImageService.expiryLogLine( DriveBufferService.ExpiryOutcome(queued: 100, moved: 60)) - XCTAssertTrue(linia.contains("60 z 100"), linia) - XCTAssertTrue(linia.contains("40"), "brakujace 40 pozycji musi byc widoczne: \(linia)") + XCTAssertTrue(line.contains("60 of 100"), line) + XCTAssertTrue(line.contains("40"), "the missing 40 items must be visible: \(line)") } - func testWszystkiePrzesunieteToZwyklyKomunikat() { - let linia = BackupImageService.expiryLogLine( + func testAllMovedGivesThePlainMessage() { + let line = BackupImageService.expiryLogLine( DriveBufferService.ExpiryOutcome(queued: 12, moved: 12)) - XCTAssertEqual(linia, "Odpiecie: wymuszono wysylke 12 pozycji z kolejki") + XCTAssertEqual(line, "Detach: forced upload of 12 queued items") } - /// Brak odpowiedzi rclone to trzeci, osobny przypadek - nie wolno go zlac - /// ani z pusta kolejka, ani z porazka przesuwania. - func testBrakOdpowiedziToNadalOsobnyPrzypadek() { - let linia = BackupImageService.expiryLogLine(nil) - XCTAssertTrue(linia.contains("nie odpowiedzial"), linia) - XCTAssertFalse(linia.contains("kolejka pusta"), linia) + /// No answer from rclone is a third, separate case - it must not be merged + /// with either the empty queue or the failure to move. + func testNoAnswerIsStillASeparateCase() { + let line = BackupImageService.expiryLogLine(nil) + XCTAssertTrue(line.contains("did not answer"), line) + XCTAssertFalse(line.contains("queue empty"), line) } } diff --git a/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift b/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift index 4eb040b..180e62a 100644 --- a/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift @@ -2,46 +2,46 @@ import XCTest @testable import CloudMachineCore -/// Decyzje dozorcy bufora przechodzone CALA sciezka - przez `step()`, ze -/// zmiana stanu wlacznie - a nie tylko przez czyste funkcje pomocnicze. +/// Buffer watchdog decisions walked through the WHOLE path - via `step()`, +/// state change included - and not only through the pure helper functions. /// -/// Powod: wszystkie trzy awarie naprawione 23.09.2026 (wyrzucany wynik -/// `stopbackup`, wspolna galaz wznowienia dla braku miejsca na Dysku, -/// zmyslone zero z nieudanego `statfs`) siedzialy w SEKWENCJI krokow, a nie -/// w pojedynczym wyrazeniu. Test czystego predykatu przeszedlby dla kazdej -/// z nich. Dlatego `BufferGuardService` ma teraz wstrzykiwalne `Probes` - -/// ten sam zabieg, co `preferencesFile` w `BackupHealth.currentReport`. +/// Reason: all three failures fixed on 23.09.2026 (the discarded `stopbackup` +/// result, the shared resume branch for lack of space on Drive, the made-up +/// zero from a failed `statfs`) lived in a SEQUENCE of steps, not in a single +/// expression. A test of the pure predicate would have passed for each of +/// them. That is why `BufferGuardService` now has injectable `Probes` - the +/// same trick as `preferencesFile` in `BackupHealth.currentReport`. /// -/// Piec awarii naprawionych 25.09.2026 (miara bufora, trzeci stan rozmiaru, -/// martwa ochrona dysku w pauzie, pauza na jeden przebieg, nieczytelny log -/// czytany jako "nie ma problemu") siedzialo tam samo i tez wymagalo calej -/// sekwencji: kazda z nich objawia sie dopiero w DRUGIM albo TRZECIM kroku, -/// po zmianie stanu. +/// The five failures fixed on 25.09.2026 (the buffer measure, the third state +/// of the size, dead disk protection during a pause, a pause for one run, an +/// unreadable log read as "no problem") lived in the same place and also +/// needed the whole sequence: each of them shows up only in the SECOND or +/// THIRD step, after a state change. final class BufferGuardDecisionTests: XCTestCase { - /// Zapisuje, co dozorca zrobil, i pozwala sterowac tym, co "widzi". - /// Klasa, nie struktura, bo te same wartosci czyta i zmienia kilka domkniec. - private final class Atrapa: @unchecked Sendable { + /// Records what the watchdog did and lets us control what it "sees". + /// A class, not a struct, because several closures read and change the same values. + private final class Fake: @unchecked Sendable { private let lock = NSLock() - /// ZALEGLOSC NIEWYSLANA w GB. Atrapa przeklada ja na POZYCJE w kolejce, - /// bo dokladnie tak widzi ja dozorca (`backlogGB` szacuje gigabajty - /// z liczby pozycji po 32 MiB). Podanie jej wprost w GB pozwalalo - /// atrapie udawac, ze rclone podaje bajty - a nie podaje. + /// UNSENT BACKLOG in GB. The fake translates it into queue ITEMS, because + /// that is exactly how the watchdog sees it (`backlogGB` estimates gigabytes + /// from the number of 32 MiB items). Giving it directly in GB let the fake + /// pretend that rclone reports bytes - and it does not. private var _backlogGB = 0 - /// Rozmiar cache'a rclone. Trzymany OSOBNO od zaleglosci, bo na tym - /// rozroznieniu stoi cala poprawka: cache przy `--vfs-cache-max-age 9999h` - /// siedzi pod limitem stale (na produkcji 281 pomiarow, minimum 99 GB), - /// niezaleznie od tego, ile zostalo do wyslania. Domyslnie wiec 100. + /// rclone cache size. Kept SEPARATE from the backlog, because the whole fix + /// rests on that distinction: with `--vfs-cache-max-age 9999h` the cache sits + /// at the limit permanently (281 measurements in production, minimum 99 GB), + /// regardless of how much is left to upload. Hence 100 by default. private var _cacheGB: Int? = 100 - /// Czy interfejs sterujacy rclone odpowiada. `false` = `vfs/stats` oddaje - /// `nil`, czyli produkcyjny przebieg z 23.09.2026. + /// Whether rclone's remote control answers. `false` = `vfs/stats` returns + /// `nil`, i.e. the production run of 23.09.2026. private var _statsAvailable = true private var _outOfSpace = false private var _freeGB: Int? = 500 private var _running: Bool? = true - /// `nil` = logu rclone NIE DA SIE PRZECZYTAC (prawa `-rw-r-----`, - /// przeniesienie na `.1` przy starcie). + /// `nil` = the rclone log CANNOT BE READ (`-rw-r-----` permissions, moved to + /// `.1` at start-up). private var _quotaHit: Bool? = false private var _stalled: Bool? = false private var _driveFreeBytes: UInt64? = 1_000 * 1_073_741_824 @@ -49,8 +49,9 @@ final class BufferGuardDecisionTests: XCTestCase { private var _stopCalls = 0 private var _startCalls = 0 private var _log: [String] = [] - /// Co dozorca zglosil o zatorze. `Bool?`, bo "nie wiem" MUSI dojsc do - /// zgloszenia jako "nie wiem" - inaczej gasi znacznik zatoru. + /// What the watchdog reported about the jam. `Bool?`, because "I do not + /// know" MUST reach the report as "I do not know" - otherwise it clears the + /// jam marker. private var _stallReports: [Bool?] = [] private func read(_ body: () -> T) -> T { @@ -115,8 +116,8 @@ final class BufferGuardDecisionTests: XCTestCase { guard statsAvailable else { return nil } return DriveBufferService.QueueStats( uploadsInProgress: 0, - // 1 GiB zaleglosci to 32 pasma po 32 MiB - tak samo, jak liczy to - // `BufferGuardService.backlogGB`. + // 1 GiB of backlog is 32 bands of 32 MiB - the same way + // `BufferGuardService.backlogGB` computes it. uploadsQueued: max(0, backlogGB) * 32, files: 0, erroredFiles: 0, bytesUsed: UInt64(max(0, cacheGB ?? 0)) * 1_073_741_824, @@ -137,62 +138,63 @@ final class BufferGuardDecisionTests: XCTestCase { write { _startCalls += 1 } return true }, - // Zgloszenie zatoru dotyka pliku znacznika w katalogu uzytkownika - // i pokazuje powiadomienie - w tescie zapisujemy tylko, CO uslyszalo. + // Reporting a jam touches a marker file in the user's directory and shows + // a notification - in the test we only record WHAT it heard. reportStall: { [self] value in write { _stallReports.append(value) } }, log: { [self] line in write { _log.append(line) } }) } } - /// Progi jak na produkcji po 25.09.2026: liczone od ZALEGLOSCI niewyslanej - /// i lezace PONIZEJ rozmiaru cache'a (100 GB). - private let progi = BufferGuardService.Thresholds( + /// Thresholds as in production after 25.09.2026: computed from the UNSENT + /// backlog and lying BELOW the cache size (100 GB). + private let thresholds = BufferGuardService.Thresholds( highGB: 50, lowGB: 10, minFreeGB: 80, minDriveFreeGB: 30) - /// Doprowadza dozorce do stanu `.running` - punkt wyjscia dla reszty. - private func nadzorujacy(_ atrapa: Atrapa) async -> BufferGuardService { - let dozorca = BufferGuardService(thresholds: progi, probes: atrapa.probes()) - atrapa.backlogGB = 2 - atrapa.running = true - await dozorca.step() - let stan = await dozorca.currentState() - XCTAssertEqual(stan, .running, "Punkt wyjscia: dozorca ma nadzorowac trwajacy backup.") - return dozorca + /// Brings the watchdog to the `.running` state - the starting point for the rest. + private func supervising(_ fake: Fake) async -> BufferGuardService { + let watchdog = BufferGuardService(thresholds: thresholds, probes: fake.probes()) + fake.backlogGB = 2 + fake.running = true + await watchdog.step() + let state = await watchdog.currentState() + XCTAssertEqual( + state, .running, "Starting point: the watchdog has to supervise a running backup.") + return watchdog } - /// Doprowadza dozorce do pauzy za zaleglosc - punkt wyjscia dla testow pauzy. - private func wstrzymany(_ atrapa: Atrapa) async -> BufferGuardService { - let dozorca = await nadzorujacy(atrapa) - atrapa.backlogGB = 200 - await dozorca.step() - let stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForBuffer, "Punkt wyjscia: dozorca ma stac w pauzie.") - return dozorca + /// Brings the watchdog to a backlog pause - the starting point for the pause tests. + private func paused(_ fake: Fake) async -> BufferGuardService { + let watchdog = await supervising(fake) + fake.backlogGB = 200 + await watchdog.step() + let state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForBuffer, "Starting point: the watchdog has to be paused.") + return watchdog } - // MARK: - Ustalenie 1: miara bufora i progi - - /// Progi MUSZA lezec ponizej rozmiaru cache'a, bo odnosza sie do zaleglosci - /// niewyslanej - czyli do tej czesci cache'a, ktorej rclone nie moze usunac. - /// Stara para (150/40) odnosila sie do rozmiaru CALEGO cache'a i dlatego - /// prog pauzy byl nieosiagalny bez siegania po inna miare, a prog wznowienia - /// nieosiagalny w ogole. - func testProgiOdnoszaSieDoZaleglosciILezaPonizejRozmiaruBufora() { - let domyslne = BufferGuardService.Thresholds() - XCTAssertEqual(domyslne.highGB, DriveBufferService.cacheSizeGB / 2) - XCTAssertEqual(domyslne.lowGB, DriveBufferService.cacheSizeGB / 10) + // MARK: - Finding 1: the buffer measure and the thresholds + + /// The thresholds MUST lie below the cache size, because they refer to the + /// unsent backlog - i.e. the part of the cache that rclone cannot evict. The + /// old pair (150/40) referred to the size of the WHOLE cache, which is why the + /// pause threshold was unreachable without reaching for another measure, and + /// the resume threshold unreachable at all. + func testThresholdsReferToTheBacklogAndLieBelowTheBufferSize() { + let defaults = BufferGuardService.Thresholds() + XCTAssertEqual(defaults.highGB, DriveBufferService.cacheSizeGB / 2) + XCTAssertEqual(defaults.lowGB, DriveBufferService.cacheSizeGB / 10) XCTAssertLessThan( - domyslne.highGB, DriveBufferService.cacheSizeGB, - "Prog pauzy powyzej rozmiaru cache'a jest osiagalny tylko przez pomiar INNEJ wielkosci.") + defaults.highGB, DriveBufferService.cacheSizeGB, + "A pause threshold above the cache size is reachable only by measuring a DIFFERENT quantity.") XCTAssertLessThan( - domyslne.lowGB, domyslne.highGB, - "Bez histerezy dozorca przelaczalby stan przy niemal kazdym tyknieciu.") + defaults.lowGB, defaults.highGB, + "Without hysteresis the watchdog would switch state on almost every tick.") } - /// Zaleglosc liczy sie z POZYCJI w kolejce, bo `vfs/stats` nie podaje - /// niewyslanych bajtow. Kontrola na produkcyjnej liczbie: 462 pozycje - /// z 23.09.2026, ktore wlasciciel oszacowal na "okolo 15 GB". - func testSzacunekZaleglosciLiczySieZPozycjiKolejki() { + /// The backlog is computed from queue ITEMS, because `vfs/stats` does not + /// report unsent bytes. Check against a production number: 462 items from + /// 23.09.2026, which the owner estimated at "about 15 GB". + func testBacklogEstimateIsComputedFromQueueItems() { func stats(queued: Int, inProgress: Int = 0, cacheGB: Int = 100) -> DriveBufferService.QueueStats { @@ -204,463 +206,464 @@ final class BufferGuardDecisionTests: XCTestCase { XCTAssertEqual(BufferGuardService.backlogGB(stats: stats(queued: 462)), 14) XCTAssertEqual(BufferGuardService.backlogGB(stats: stats(queued: 32)), 1) XCTAssertEqual(BufferGuardService.backlogGB(stats: stats(queued: 0)), 0) - // Pozycja w trakcie wysylki tez jeszcze nie jest na Dysku. + // An item being uploaded is not on Drive yet either. XCTAssertEqual(BufferGuardService.backlogGB(stats: stats(queued: 16, inProgress: 16)), 1) - // Pelny cache przy pustej kolejce to ZERO zaleglosci - to jest cala - // roznica miedzy stara i nowa miara. + // A full cache with an empty queue is ZERO backlog - that is the whole + // difference between the old and the new measure. XCTAssertEqual(BufferGuardService.backlogGB(stats: stats(queued: 0, cacheGB: 100)), 0) XCTAssertNil( BufferGuardService.backlogGB(stats: nil), - "Brak odpowiedzi rclone to nie zero pozycji.") + "No answer from rclone is not zero items.") } - /// TA awaria, ta z dziennika: JEDNA linia PAUZA i ZERO linii WZNOWIENIE. + /// THE failure, the one from the journal: ONE PAUSE line and ZERO RESUME lines. /// - /// Prog wznowienia 40 GB odnosil sie do rozmiaru cache'a, a ten stoi pod - /// limitem 100 GB caly czas - takze wtedy, gdy kolejka jest juz pusta, bo - /// rclone trzyma w cache'u dane dawno wyslane (`--vfs-cache-max-age 9999h`). - /// Warunek wznowienia nie mial wiec jak zachodzic i dozorca zostawal - /// w pauzie do restartu procesu. - func testWznowienieNastepujeGdyKolejkaOpustialaChocCacheStoiPodLimitem() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) - - // Wysylka nadgonila: kolejka pusta. Cache nadal pelny - i to jest - // dokladnie stan, w ktorym stara wersja nie wznawiala nigdy. - atrapa.backlogGB = 0 - atrapa.cacheGB = 100 - await dozorca.step() - - let stan = await dozorca.currentState() + /// The 40 GB resume threshold referred to the cache size, and that sits at the + /// 100 GB limit all the time - also when the queue is already empty, because + /// rclone keeps long-uploaded data in the cache (`--vfs-cache-max-age 9999h`). + /// So the resume condition had no way of being met and the watchdog stayed + /// paused until the process restarted. + func testResumesWhenTheQueueIsEmptyEvenThoughTheCacheSitsAtTheLimit() async { + let fake = Fake() + let watchdog = await paused(fake) + + // The upload caught up: queue empty. The cache is still full - and that is + // exactly the state in which the old version never resumed. + fake.backlogGB = 0 + fake.cacheGB = 100 + await watchdog.step() + + let state = await watchdog.currentState() XCTAssertEqual( - stan, .running, - "Pusta kolejka to nadgoniona wysylka - pelny cache nie ma prawa trzymac pauzy.") - XCTAssertEqual(atrapa.startCalls, 1) + state, .running, + "An empty queue means the upload caught up - a full cache has no right to hold the pause.") + XCTAssertEqual(fake.startCalls, 1) } - // MARK: - Ustalenie 6: rozmiar bufora potrzebuje trzeciego stanu + // MARK: - Finding 6: the buffer size needs a third state - /// TA awaria, odtworzona z produkcji krok po kroku (23.09.2026 03:34). + /// THE failure, reproduced from production step by step (23.09.2026 03:34). /// - /// rclone nie odpowiada -> dozorca schodzi na obchod katalogu -> obchod - /// oddaje 155 GB, bo liczy MIEJSCE ZAJETE NA DYSKU (miare, ktora limit - /// cache'a potrafi przekroczyc) -> 155 >= prog -> nieodwracalna pauza. - /// Godzine pozniej czujka zapisala "Interfejs sterujacy rclone nie - /// odpowiada", czyli pauza stala na liczbie wzietej stad, ze pomiaru nie - /// bylo. + /// rclone does not answer -> the watchdog falls back to the directory walk -> + /// the walk returns 155 GB, because it counts DISK SPACE TAKEN (a measure the + /// cache limit can exceed) -> 155 >= threshold -> irreversible pause. An hour + /// later the monitor wrote "rclone remote control is not answering", i.e. the + /// pause rested on a number taken from the fact that there was no measurement. /// - /// Po poprawce ta liczba moze sie pojawic w LOGU, ale nie w decyzji. - func testBrakOdpowiedziRcloneNieWstrzymujeBackupu() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) + /// After the fix this number may appear in the LOG, but not in the decision. + func testNoAnswerFromRcloneDoesNotPauseTheBackup() async { + let fake = Fake() + let watchdog = await supervising(fake) - atrapa.statsAvailable = false - atrapa.cacheGB = 155 // dokladnie liczba z tamtej jedynej linii PAUZA - atrapa.freeGB = 300 // dysku nic nie grozi, wiec pauza moglaby wyjsc TYLKO z tej liczby - await dozorca.step() + fake.statsAvailable = false + fake.cacheGB = 155 // exactly the number from that single PAUSE line + fake.freeGB = 300 // the disk is in no danger, so a pause could come ONLY from this number + await watchdog.step() - let stan = await dozorca.currentState() + let state = await watchdog.currentState() XCTAssertEqual( - stan, .running, - "Brak odpowiedzi rclone zamieniony na liczbe z innej miary uruchamial pauze.") - XCTAssertEqual(atrapa.stopCalls, 0) + state, .running, + "No answer from rclone turned into a number from another measure triggered a pause.") + XCTAssertEqual(fake.stopCalls, 0) XCTAssertTrue( - atrapa.log.contains { $0.contains("interfejs sterujacy rclone nie odpowiada") }, - "...ale milczec tez nie wolno: dozorca wlasnie przestal umiec wstrzymac backup.") + fake.log.contains { $0.contains("rclone remote control is not answering") }, + "...but staying silent is not allowed either: the watchdog has just lost the ability to pause the backup." + ) XCTAssertTrue( - atrapa.log.contains { $0.contains("155 GB") && $0.contains("MIEJSCE NA DYSKU") }, - "Skoro podajemy te liczbe, musi byc nazwana jako CO INNEGO niz zaleglosc.") + fake.log.contains { $0.contains("155 GB") && $0.contains("DISK SPACE") }, + "Since we give this number, it has to be named as SOMETHING ELSE than the backlog.") } - /// Zgloszenie raz na epizod - jak dla `freeGB()`. Przy awarii trwajacej - /// 53 godziny linia co 30 sekund zatopilaby log. - func testOstrzezenieOBrakuOdpowiedziLogujeSieRaz() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) + /// Reported once per episode - as for `freeGB()`. With a failure lasting + /// 53 hours a line every 30 seconds would flood the log. + func testNoAnswerWarningIsLoggedOnce() async { + let fake = Fake() + let watchdog = await supervising(fake) - atrapa.statsAvailable = false - await dozorca.step() - await dozorca.step() - await dozorca.step() + fake.statsAvailable = false + await watchdog.step() + await watchdog.step() + await watchdog.step() - let ostrzezenia = atrapa.log.filter { $0.contains("interfejs sterujacy rclone nie odpowiada") } - XCTAssertEqual(ostrzezenia.count, 1, "dostalem: \(atrapa.log)") + let warnings = fake.log.filter { $0.contains("rclone remote control is not answering") } + XCTAssertEqual(warnings.count, 1, "got: \(fake.log)") } - /// Druga strona tego samego klamstwa. Gdy obchod katalogu PADL, oddawal `0`, - /// a zero wygladalo jak pusty bufor - czyli zdejmowalo pauze zalozona - /// dlatego, ze bufor byl pelny. - func testBrakOdpowiedziRcloneNieZdejmujePauzy() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) + /// The other side of the same lie. When the directory walk FAILED, it returned + /// `0`, and zero looked like an empty buffer - i.e. it lifted a pause put in + /// place because the buffer was full. + func testNoAnswerFromRcloneDoesNotLiftThePause() async { + let fake = Fake() + let watchdog = await paused(fake) - atrapa.statsAvailable = false - atrapa.cacheGB = nil // obchod katalogu tez sie nie udal - atrapa.freeGB = 900 // wszystko inne sprzyja wznowieniu - await dozorca.step() + fake.statsAvailable = false + fake.cacheGB = nil // the directory walk failed too + fake.freeGB = 900 // everything else favours resuming + await watchdog.step() - let stan = await dozorca.currentState() + let state = await watchdog.currentState() XCTAssertEqual( - stan, .pausedForBuffer, - "Wznowienie wymaga dowodu, ze wysylka nadgonila - brak pomiaru dowodem nie jest.") - XCTAssertEqual(atrapa.startCalls, 0) + state, .pausedForBuffer, + "Resuming needs proof that the upload caught up - a missing measurement is no proof.") + XCTAssertEqual(fake.startCalls, 0) } - // MARK: - Ustalenie 1a: ochrona dysku dziala w KAZDYM stanie + // MARK: - Finding 1a: disk protection works in EVERY state - /// TA awaria. `stats?.outOfSpace`, prog i `lowDisk` siedzialy WYLACZNIE - /// w galezi `.running`. Po jednej pauzie dozorca przestawal patrzyc na dysk, - /// a galaz pauzy sprawdzala tylko warunek wznowienia - wiec rclone moglo - /// krzyczec "nie mam gdzie odlozyc danych", a dozorca w tej samej chwili - /// wznawial Time Machine, bo kolejka akurat zeszla. - func testRcloneBezMiejscaNieDajeSieZignorowacWPauzie() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) + /// THE failure. `stats?.outOfSpace`, the threshold and `lowDisk` sat ONLY in + /// the `.running` branch. After one pause the watchdog stopped looking at the + /// disk, and the pause branch checked only the resume condition - so rclone + /// could shout "I have nowhere to put data", and at the same moment the + /// watchdog resumed Time Machine because the queue happened to drain. + func testRcloneOutOfSpaceCannotBeIgnoredDuringAPause() async { + let fake = Fake() + let watchdog = await paused(fake) - atrapa.backlogGB = 0 // kolejka zeszla, czyli warunek wznowienia spelniony - atrapa.freeGB = 900 - atrapa.outOfSpace = true // ...ale rclone nie ma gdzie odlozyc danych - await dozorca.step() + fake.backlogGB = 0 // the queue drained, i.e. the resume condition is met + fake.freeGB = 900 + fake.outOfSpace = true // ...but rclone has nowhere to put data + await watchdog.step() - let stan = await dozorca.currentState() + let state = await watchdog.currentState() XCTAssertEqual( - stan, .pausedForBuffer, - "outOfSpace to twardszy fakt niz nasz prog i nie przestaje nim byc w pauzie.") - XCTAssertEqual(atrapa.startCalls, 0) + state, .pausedForBuffer, + "outOfSpace is a harder fact than our threshold and does not stop being one during a pause.") + XCTAssertEqual(fake.startCalls, 0) } - /// To samo w pauzie za brak miejsca na Dysku Google: dowod z `rclone about` - /// nie ma prawa zdjac pauzy, gdy BUFOR jest pod sciana. - func testRcloneBezMiejscaNieDajeSieZignorowacWPauzieZaDyskGoogle() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) - - atrapa.quotaHit = true - await dozorca.step() - var stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForQuota) - - atrapa.quotaHit = false // wpisy w logu rclone sie zestarzaly - atrapa.backlogGB = 0 - atrapa.freeGB = 900 - atrapa.driveFreeBytes = 500 * 1_073_741_824 // miejsce na Dysku faktycznie sie znalazlo - atrapa.outOfSpace = true // ale bufor nie ma gdzie odlozyc danych - await dozorca.step() - - stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForQuota, "Dowod o Dysku nie jest dowodem o buforze.") - XCTAssertEqual(atrapa.startCalls, 0) + /// The same in a pause for lack of space on Google Drive: proof from + /// `rclone about` has no right to lift the pause when the BUFFER is against the + /// wall. + func testRcloneOutOfSpaceCannotBeIgnoredDuringAGoogleDrivePause() async { + let fake = Fake() + let watchdog = await supervising(fake) + + fake.quotaHit = true + await watchdog.step() + var state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForQuota) + + fake.quotaHit = false // the entries in the rclone log have aged + fake.backlogGB = 0 + fake.freeGB = 900 + fake.driveFreeBytes = 500 * 1_073_741_824 // space on Drive really did appear + fake.outOfSpace = true // but the buffer has nowhere to put data + await watchdog.step() + + state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForQuota, "Proof about Drive is not proof about the buffer.") + XCTAssertEqual(fake.startCalls, 0) } - /// Konczace sie miejsce na dysku musi wstrzymywac takze wtedy, gdy backup - /// wlasnie nie trwa: macOS zaczyna kolejny co godzine, a galaz `.idle` - /// patrzyla dotad wylacznie na to, czy backup ruszyl. - func testMaloMiejscaNaDyskuWstrzymujeTakzeGdyBackupNieTrwa() async { - let atrapa = Atrapa() - let dozorca = BufferGuardService(thresholds: progi, probes: atrapa.probes()) - atrapa.running = false // czuwanie, nie nadzor - atrapa.freeGB = 10 // ponizej minFreeGB - await dozorca.step() - - let stan = await dozorca.currentState() + /// Running out of disk space has to pause also when no backup is running at + /// the moment: macOS starts another one every hour, and the `.idle` branch + /// used to look only at whether a backup had started. + func testLowDiskSpacePausesAlsoWhenNoBackupIsRunning() async { + let fake = Fake() + let watchdog = BufferGuardService(thresholds: thresholds, probes: fake.probes()) + fake.running = false // keeping watch, not supervising + fake.freeGB = 10 // below minFreeGB + await watchdog.step() + + let state = await watchdog.currentState() XCTAssertEqual( - stan, .pausedForBuffer, - "Dysk zapelnia sie niezaleznie od tego, czy backup trwa w tej sekundzie.") - XCTAssertTrue(atrapa.log.contains { $0.contains("malo wolnego miejsca na dysku") }) - // Nie ma czego wstrzymywac, wiec `stopbackup` nie leci - i slusznie: - // jego porazka kazalaby dozorcy zameldowac "Time Machine PISZE DALEJ". - XCTAssertEqual(atrapa.stopCalls, 0) + state, .pausedForBuffer, + "The disk fills up regardless of whether a backup is running this second.") + XCTAssertTrue(fake.log.contains { $0.contains("little free disk space") }) + // There is nothing to pause, so `stopbackup` is not sent - and rightly so: + // its failure would make the watchdog report "Time Machine KEEPS WRITING". + XCTAssertEqual(fake.stopCalls, 0) } - // MARK: - Ustalenie 2: pauza trwa tyle, ile ja podtrzymujemy - - /// TA awaria. `tmutil stopbackup` anuluje TRWAJACY backup i nie rusza - /// harmonogramu, a `stopBackup()` wolalo sie wylacznie przy ZMIANIE stanu. - /// Godzine po pauzie macOS startowal kolejny backup, dozorca go nie - /// zatrzymywal - a w logu stalo "czekam na wysylke". Stan trwal 53 godziny, - /// wstrzymanie zapisu jeden przebieg. - func testWstrzymanieJestPonawianeWKazdymTyknieciuPauzy() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) - XCTAssertEqual(atrapa.stopCalls, 1) - - // Time Machine ruszyl sam w swoim cyklu godzinowym, zaleglosc nadal duza. - atrapa.running = true - await dozorca.step() - XCTAssertEqual(atrapa.stopCalls, 2, "Kolejne tykniecie MUSI ponowic wstrzymanie.") - await dozorca.step() - XCTAssertEqual(atrapa.stopCalls, 3) - - let stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForBuffer) - XCTAssertEqual(atrapa.startCalls, 0) + // MARK: - Finding 2: a pause lasts as long as we keep it up + + /// THE failure. `tmutil stopbackup` cancels the RUNNING backup and does not + /// touch the schedule, and `stopBackup()` was called only on a state CHANGE. + /// An hour after the pause macOS started another backup, the watchdog did not + /// stop it - and the log said "waiting for the upload". The state lasted 53 + /// hours, the pause of writes one run. + func testPauseIsRepeatedOnEveryTickOfThePause() async { + let fake = Fake() + let watchdog = await paused(fake) + XCTAssertEqual(fake.stopCalls, 1) + + // Time Machine started by itself in its hourly cycle, the backlog is still large. + fake.running = true + await watchdog.step() + XCTAssertEqual(fake.stopCalls, 2, "The next tick MUST repeat the pause.") + await watchdog.step() + XCTAssertEqual(fake.stopCalls, 3) + + let state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForBuffer) + XCTAssertEqual(fake.startCalls, 0) XCTAssertTrue( - atrapa.log.contains { $0.contains("ponawiam wstrzymanie") }, - "Ponowne wstrzymanie to zdarzenie warte sladu - znaczy, ze backup ruszyl w pauzie.") + fake.log.contains { $0.contains("repeating the pause") }, + "Repeating the pause is an event worth a trace - it means a backup started during the pause.") } - /// ...ale bez potrzeby nie ponawiamy. Gdy tmutil mowi wprost, ze backup nie - /// trwa, nie ma czego wstrzymywac - dwa procesy co 30 sekund przez 53 - /// godziny to ponad 12 tysiecy wywolan za nic. - func testWstrzymanieNieJestPonawianeGdyBackupNieTrwa() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) - XCTAssertEqual(atrapa.stopCalls, 1) - - atrapa.running = false - await dozorca.step() - await dozorca.step() - XCTAssertEqual(atrapa.stopCalls, 1) - - // "Nie wiem" to NIE jest "nie trwa" - brak odpowiedzi tmutil liczy sie - // jak trwajacy backup. - atrapa.running = nil - await dozorca.step() - XCTAssertEqual(atrapa.stopCalls, 2) + /// ...but we do not repeat without need. When tmutil says plainly that no + /// backup is running, there is nothing to pause - two processes every 30 + /// seconds for 53 hours is over 12 thousand calls for nothing. + func testPauseIsNotRepeatedWhenNoBackupIsRunning() async { + let fake = Fake() + let watchdog = await paused(fake) + XCTAssertEqual(fake.stopCalls, 1) + + fake.running = false + await watchdog.step() + await watchdog.step() + XCTAssertEqual(fake.stopCalls, 1) + + // "I do not know" is NOT "not running" - no answer from tmutil counts as + // a running backup. + fake.running = nil + await watchdog.step() + XCTAssertEqual(fake.stopCalls, 2) } - // MARK: - Ustalenie 7: nieczytelny log rclone + // MARK: - Finding 7: unreadable rclone log - /// TA awaria, w jej najgrozniejszej czesci. `recentLog` oddaje `nil` przy - /// nieotwieralnym pliku, a `uploadStalled()` zamienialo to na `false`; - /// `false` znaczy "zator minal", wiec `reportStall` USUWAL znacznik i pisal - /// "Wysylka na Google Drive ruszyla z powrotem" - o zdarzeniu, ktorego nikt - /// nie sprawdzil. Log rclone ma prawa `-rw-r-----`, a przy starcie jest - /// przenoszony na `.1`, wiec to nie jest przypadek teoretyczny. - func testNieWiemNieGasiZnacznikaZatoru() { + /// THE failure, in its most dangerous part. `recentLog` returns `nil` for an + /// unopenable file, and `uploadStalled()` turned that into `false`; `false` + /// means "the jam is over", so `reportStall` DELETED the marker and wrote + /// "Upload to Google Drive has resumed" - about an event nobody checked. The + /// rclone log has `-rw-r-----` permissions, and at start-up it is moved to + /// `.1`, so this is not a theoretical case. + func testIDoNotKnowDoesNotClearTheJamMarker() { XCTAssertEqual( BufferGuardService.stallAction(stalled: nil, markerExists: true), .doNothing, - "Nieczytelny log nie jest dowodem, ze zator minal.") + "An unreadable log is no proof that the jam is over.") XCTAssertEqual( BufferGuardService.stallAction(stalled: nil, markerExists: false), .doNothing) - // Zmierzone odpowiedzi dzialaja jak dotad - inaczej "naprawa" polegajaca - // na wylaczeniu zgloszen przeszlaby niezauwazona. + // Measured answers work as before - otherwise a "fix" consisting of + // switching off the reports would go unnoticed. XCTAssertEqual(BufferGuardService.stallAction(stalled: true, markerExists: false), .raise) XCTAssertEqual(BufferGuardService.stallAction(stalled: false, markerExists: true), .clear) XCTAssertEqual(BufferGuardService.stallAction(stalled: true, markerExists: true), .doNothing) XCTAssertEqual(BufferGuardService.stallAction(stalled: false, markerExists: false), .doNothing) } - /// "Nie wiem" musi DOJSC do zgloszenia jako "nie wiem". Podstawienie `false` - /// juz w sondzie zamykalo sprawe, zanim ktokolwiek zdazyl sie zastanowic. - func testNieczytelnyLogIdzieDoZgloszeniaJakoNieWiem() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) + /// "I do not know" has to REACH the report as "I do not know". Substituting + /// `false` already in the probe closed the matter before anyone had a chance + /// to think about it. + func testUnreadableLogReachesTheReportAsIDoNotKnow() async { + let fake = Fake() + let watchdog = await supervising(fake) - atrapa.stalled = nil - await dozorca.step() + fake.stalled = nil + await watchdog.step() - XCTAssertEqual(atrapa.stallReports.count, 2) - XCTAssertEqual(atrapa.stallReports.first, .some(false)) + XCTAssertEqual(fake.stallReports.count, 2) + XCTAssertEqual(fake.stallReports.first, .some(false)) XCTAssertNil( - atrapa.stallReports.last!, "Brak odczytu logu nie ma prawa zglosic 'zator minal'.") + fake.stallReports.last!, "A failed log read has no right to report 'the jam is over'.") } - /// Nieczytelny log nie wstrzymuje backupu (bo nie jest dowodem awarii), ale - /// nie wolno o nim milczec: dozorca wlasnie przestal umiec rozpoznac brak - /// miejsca na Dysku Google. - func testNieczytelnyLogAniNieWstrzymujeAniNieMilczy() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) - - atrapa.quotaHit = nil - atrapa.stalled = nil - await dozorca.step() - await dozorca.step() - - let stan = await dozorca.currentState() - XCTAssertEqual(stan, .running) - XCTAssertEqual(atrapa.stopCalls, 0) - let ostrzezenia = atrapa.log.filter { $0.contains("nie da sie przeczytac logu rclone") } - XCTAssertEqual(ostrzezenia.count, 1, "Raz na epizod - dostalem: \(atrapa.log)") + /// An unreadable log does not pause the backup (as it is no proof of a + /// failure), but it must not be kept quiet: the watchdog has just lost the + /// ability to recognise lack of space on Google Drive. + func testUnreadableLogNeitherPausesNorStaysSilent() async { + let fake = Fake() + let watchdog = await supervising(fake) + + fake.quotaHit = nil + fake.stalled = nil + await watchdog.step() + await watchdog.step() + + let state = await watchdog.currentState() + XCTAssertEqual(state, .running) + XCTAssertEqual(fake.stopCalls, 0) + let warnings = fake.log.filter { $0.contains("cannot read the rclone log") } + XCTAssertEqual(warnings.count, 1, "Once per episode - got: \(fake.log)") } - // MARK: - Punkt 2 z 23.09: nieudane wstrzymanie NIE jest pauza + // MARK: - Point 2 of 23.09: a failed pause is NOT a pause - /// TA awaria. `tmutil stopbackup` pada (brak uprawnien albo limit czasu), - /// a dozorca i tak przechodzil w `.pausedForBuffer`. Poniewaz wstrzymanie - /// wola sie wylacznie przy ZMIANIE stanu, nie ponawial go juz nigdy: - /// Time Machine pisal dalej, dozorca czekal na drenaz, dysk zapelnial sie - /// do konca, a w logu stalo "PAUZA ... czekam na wysylke". - func testNieudaneWstrzymanieNieZmieniaStanuIJestPonawiane() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) + /// THE failure. `tmutil stopbackup` fails (no permissions or a timeout), and + /// the watchdog moved to `.pausedForBuffer` anyway. Since the pause is called + /// only on a state CHANGE, it never retried it: Time Machine kept writing, the + /// watchdog waited for the drain, the disk filled up completely, and the log + /// said "PAUSE ... waiting for the upload". + func testFailedPauseDoesNotChangeTheStateAndIsRetried() async { + let fake = Fake() + let watchdog = await supervising(fake) - atrapa.stopSucceeds = false - atrapa.backlogGB = 200 // powyzej progu pauzy - await dozorca.step() + fake.stopSucceeds = false + fake.backlogGB = 200 // above the pause threshold + await watchdog.step() - var stan = await dozorca.currentState() + var state = await watchdog.currentState() XCTAssertEqual( - stan, .running, - "Nieudane 'tmutil stopbackup' NIE jest pauza - Time Machine nadal pisze.") - XCTAssertEqual(atrapa.stopCalls, 1) + state, .running, + "A failed 'tmutil stopbackup' is NOT a pause - Time Machine is still writing.") + XCTAssertEqual(fake.stopCalls, 1) XCTAssertTrue( - atrapa.log.contains { $0.contains("NIE UDALO SIE wstrzymac") }, - "Cicha porazka jest gorsza od glosnej - musi byc slad w logu.") - - // Kolejny krok MUSI sprobowac jeszcze raz - bez tego jedna nieudana proba - // zostawiala backup bez nadzoru az do restartu agenta. - await dozorca.step() - XCTAssertEqual(atrapa.stopCalls, 2, "Dozorca ma ponawiac wstrzymanie przy kazdym kroku.") - stan = await dozorca.currentState() - XCTAssertEqual(stan, .running) - - // Gdy wreszcie sie uda - dopiero wtedy stan sie zmienia. - atrapa.stopSucceeds = true - await dozorca.step() - stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForBuffer) - XCTAssertEqual(atrapa.stopCalls, 3) + fake.log.contains { $0.contains("FAILED to pause") }, + "A silent failure is worse than a loud one - there has to be a trace in the log.") + + // The next step MUST try again - without that one failed attempt left the + // backup unsupervised until the agent restarted. + await watchdog.step() + XCTAssertEqual(fake.stopCalls, 2, "The watchdog has to retry the pause on every step.") + state = await watchdog.currentState() + XCTAssertEqual(state, .running) + + // When it finally succeeds - only then does the state change. + fake.stopSucceeds = true + await watchdog.step() + state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForBuffer) + XCTAssertEqual(fake.stopCalls, 3) } - // MARK: - Punkt 3 z 23.09: pauza za brak miejsca na Dysku wymaga DOWODU - - /// TA awaria. `hitStorageQuota()` patrzy na wpisy z ostatnich 30 minut logu - /// rclone. Po wstrzymaniu Time Machine nowe pasma nie powstaja, rclone - /// przestaje probowac, wpisy sie starzeja - i funkcja zaczyna zwracac - /// `false`, mimo ze na Dysku jak nie bylo miejsca, tak nie ma. Wspolna - /// galaz wznowienia patrzyla wtedy wylacznie na bufor i wolne miejsce - /// LOKALNE, czyli na dwie liczby, ktore o Dysku Google nie wiedza nic, - /// i zdejmowala pauze natychmiast. - func testPauzaZaBrakMiejscaNaDyskuNieMijaSama() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) - - atrapa.quotaHit = true - await dozorca.step() - var stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForQuota) - - // Wpisy w logu sie zestarzaly, kolejka zeszla, dysk lokalny pusty - - // czyli DOKLADNIE sytuacja, w ktorej stara wersja wznawiala backup. - atrapa.quotaHit = false - atrapa.backlogGB = 1 - atrapa.freeGB = 900 - - // 1. rclone nie odpowiada: "nie wiem" NIE jest zgoda na wznowienie. - atrapa.driveFreeBytes = nil - await dozorca.step() - stan = await dozorca.currentState() + // MARK: - Point 3 of 23.09: a pause for lack of space on Drive needs PROOF + + /// THE failure. `hitStorageQuota()` looks at entries from the last 30 minutes + /// of the rclone log. After Time Machine is paused no new bands are created, + /// rclone stops trying, the entries age - and the function starts returning + /// `false`, even though Drive has as little space as before. The shared resume + /// branch then looked only at the buffer and LOCAL free space, i.e. at two + /// numbers that know nothing about Google Drive, and lifted the pause + /// immediately. + func testPauseForLackOfSpaceOnDriveDoesNotPassByItself() async { + let fake = Fake() + let watchdog = await supervising(fake) + + fake.quotaHit = true + await watchdog.step() + var state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForQuota) + + // The log entries have aged, the queue drained, the local disk is empty - + // i.e. EXACTLY the situation in which the old version resumed the backup. + fake.quotaHit = false + fake.backlogGB = 1 + fake.freeGB = 900 + + // 1. rclone does not answer: "I do not know" is NOT consent to resume. + fake.driveFreeBytes = nil + await watchdog.step() + state = await watchdog.currentState() XCTAssertEqual( - stan, .pausedForQuota, - "Brak odpowiedzi o pojemnosci Dysku ma PODTRZYMAC pauze, nie ja zniesc.") - XCTAssertEqual(atrapa.startCalls, 0) - - // 2. rclone odpowiada, ale miejsca nadal praktycznie nie ma. - atrapa.driveFreeBytes = 2 * 1_073_741_824 - await dozorca.step() - stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForQuota, "2 GB to nie jest miejsce na dalsze kopie.") - XCTAssertEqual(atrapa.startCalls, 0) - - // 3. Miejsce faktycznie sie znalazlo - dopiero to jest dowod. - atrapa.driveFreeBytes = 500 * 1_073_741_824 - await dozorca.step() - stan = await dozorca.currentState() - XCTAssertEqual(stan, .running) - XCTAssertEqual(atrapa.startCalls, 1) + state, .pausedForQuota, + "No answer about Drive capacity has to KEEP the pause, not lift it.") + XCTAssertEqual(fake.startCalls, 0) + + // 2. rclone answers, but there is still practically no space. + fake.driveFreeBytes = 2 * 1_073_741_824 + await watchdog.step() + state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForQuota, "2 GB is no room for further backups.") + XCTAssertEqual(fake.startCalls, 0) + + // 3. Space really did appear - only that is proof. + fake.driveFreeBytes = 500 * 1_073_741_824 + await watchdog.step() + state = await watchdog.currentState() + XCTAssertEqual(state, .running) + XCTAssertEqual(fake.startCalls, 1) } - /// Pauza z powodu ZALEGLOSCI nie potrzebuje niczego od Dysku Google - - /// inaczej nieosiagalny rclone blokowalby kazde wznowienie w systemie. - func testPauzaZaZaleglocWznawiaSieBezPytaniaODysk() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) - - atrapa.backlogGB = 2 - atrapa.driveFreeBytes = nil // rclone milczy, ale to nie ta pauza - await dozorca.step() - let stan = await dozorca.currentState() - XCTAssertEqual(stan, .running) + /// A pause because of the BACKLOG needs nothing from Google Drive - otherwise + /// an unreachable rclone would block every resume in the system. + func testBacklogPauseResumesWithoutAskingDrive() async { + let fake = Fake() + let watchdog = await paused(fake) + + fake.backlogGB = 2 + fake.driveFreeBytes = nil // rclone is silent, but this is not that pause + await watchdog.step() + let state = await watchdog.currentState() + XCTAssertEqual(state, .running) } - // MARK: - Punkt 4 z 23.09: nieudany pomiar wolnego miejsca + // MARK: - Point 4 of 23.09: failed free-space measurement - /// TA awaria. `freeGB()` zwracalo `0`, gdy `statfs` zawiodl. Zero spelnialo - /// warunek pauzy (`free <= minFreeGB`) natychmiast i NIGDY nie spelnialo - /// warunku wznowienia (`free > minFreeGB`) - dozorca wstrzymywal Time - /// Machine na podstawie liczby, ktorej nie zmierzyl, i nie wznawial go juz - /// nigdy. - func testNieudanyPomiarWolnegoMiejscaNieWstrzymujeBackupu() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) + /// THE failure. `freeGB()` returned `0` when `statfs` failed. Zero met the + /// pause condition (`free <= minFreeGB`) immediately and NEVER met the resume + /// condition (`free > minFreeGB`) - the watchdog paused Time Machine based on + /// a number it did not measure, and never resumed it again. + func testFailedFreeSpaceMeasurementDoesNotPauseTheBackup() async { + let fake = Fake() + let watchdog = await supervising(fake) - atrapa.freeGB = nil - atrapa.backlogGB = 2 // zaleglosc w porzadku, wiec jedyny powod pauzy to dysk - await dozorca.step() + fake.freeGB = nil + fake.backlogGB = 2 // backlog is fine, so the only reason to pause is the disk + await watchdog.step() - let stan = await dozorca.currentState() + let state = await watchdog.currentState() XCTAssertEqual( - stan, .running, - "Brak pomiaru to nie jest pomiar zerowy - nie wolno na nim wstrzymywac backupu.") - XCTAssertEqual(atrapa.stopCalls, 0) + state, .running, + "No measurement is not a zero measurement - the backup must not be paused on it.") + XCTAssertEqual(fake.stopCalls, 0) XCTAssertTrue( - atrapa.log.contains { $0.contains("nie da sie zmierzyc wolnego miejsca") }, - "...ale nie wolno tez o tym milczec: to awaria samej ochrony dysku.") + fake.log.contains { $0.contains("cannot measure free disk space") }, + "...but it must not be kept quiet either: this is a failure of the disk protection itself.") } - /// Zmierzone zero to co INNEGO niz brak pomiaru - i musi pauzowac. - /// Bez tego testu "naprawa" polegajaca na zignorowaniu wolnego miejsca - /// w ogole przeszlaby niezauwazona. - func testZmierzoneZeroNadalWstrzymujeBackup() async { - let atrapa = Atrapa() - let dozorca = await nadzorujacy(atrapa) + /// A measured zero is SOMETHING ELSE than no measurement - and it has to pause. + /// Without this test a "fix" consisting of ignoring free space altogether + /// would go unnoticed. + func testMeasuredZeroStillPausesTheBackup() async { + let fake = Fake() + let watchdog = await supervising(fake) - atrapa.freeGB = 0 - await dozorca.step() + fake.freeGB = 0 + await watchdog.step() - let stan = await dozorca.currentState() - XCTAssertEqual(stan, .pausedForBuffer) - XCTAssertEqual(atrapa.stopCalls, 1) + let state = await watchdog.currentState() + XCTAssertEqual(state, .pausedForBuffer) + XCTAssertEqual(fake.stopCalls, 1) } - /// Brak pomiaru nie moze tez UDAWAC zgody na wznowienie. - func testNieudanyPomiarNieWznawiaBackupu() async { - let atrapa = Atrapa() - let dozorca = await wstrzymany(atrapa) + /// No measurement must not PRETEND to be consent to resume either. + func testFailedMeasurementDoesNotResumeTheBackup() async { + let fake = Fake() + let watchdog = await paused(fake) - atrapa.backlogGB = 1 - atrapa.freeGB = nil - await dozorca.step() - let stan = await dozorca.currentState() + fake.backlogGB = 1 + fake.freeGB = nil + await watchdog.step() + let state = await watchdog.currentState() XCTAssertEqual( - stan, .pausedForBuffer, - "Wznowienie wymaga dowodu, ze miejsce JEST - brak pomiaru dowodem nie jest.") - XCTAssertEqual(atrapa.startCalls, 0) + state, .pausedForBuffer, + "Resuming needs proof that the space IS there - a missing measurement is no proof.") + XCTAssertEqual(fake.startCalls, 0) } - /// `freeGB()` na prawdziwym systemie ma oddawac to samo, co `df`, i ma to - /// byc wartosc OPCJONALNA. Sciezka udana - odpowiednik dawnego - /// `testFreeSpaceMatchesStatfs`, ktory jako jedyny testowal te funkcje. - func testPomiarWolnegoMiejscaZgadzaSieZeStatfs() { + /// `freeGB()` on the real system has to return the same as `df`, and it has to + /// be an OPTIONAL value. The successful path - the counterpart of the old + /// `testFreeSpaceMatchesStatfs`, which was the only test of this function. + func testFreeSpaceMeasurementMatchesStatfs() { var stats = statfs() XCTAssertEqual(statfs("/System/Volumes/Data", &stats), 0) - let oczekiwane = Int(UInt64(stats.f_bavail) * UInt64(stats.f_bsize) / 1_073_741_824) - XCTAssertEqual(BufferGuardService.freeGB(), oczekiwane) + let expected = Int(UInt64(stats.f_bavail) * UInt64(stats.f_bsize) / 1_073_741_824) + XCTAssertEqual(BufferGuardService.freeGB(), expected) } - // MARK: - Czyste predykaty + // MARK: - Pure predicates - func testWznowienieWymagaObuWarunkowIPomiaru() { - // Wysylka nadgonila i miejsce jest - jedyny przypadek, ktory wznawia. + func testResumeNeedsBothConditionsAndAMeasurement() { + // The upload caught up and there is space - the only case that resumes. XCTAssertTrue( - BufferGuardService.canResumeLocally(backlog: 2, free: 500, thresholds: progi)) - // Wysylka nadgonila, ale dysk nadal pelny. + BufferGuardService.canResumeLocally(backlog: 2, free: 500, thresholds: thresholds)) + // The upload caught up, but the disk is still full. XCTAssertFalse( - BufferGuardService.canResumeLocally(backlog: 2, free: 10, thresholds: progi)) - // Dysk pusty, ale zaleglosc jeszcze nie zeszla. + BufferGuardService.canResumeLocally(backlog: 2, free: 10, thresholds: thresholds)) + // The disk is empty, but the backlog has not drained yet. XCTAssertFalse( - BufferGuardService.canResumeLocally(backlog: 100, free: 500, thresholds: progi)) - // Brak pomiaru wolnego miejsca - nie wiadomo, wiec nie wznawiamy. + BufferGuardService.canResumeLocally(backlog: 100, free: 500, thresholds: thresholds)) + // No free-space measurement - unknown, so we do not resume. XCTAssertFalse( - BufferGuardService.canResumeLocally(backlog: 2, free: nil, thresholds: progi)) - // Brak odpowiedzi o zaleglosci - to samo. + BufferGuardService.canResumeLocally(backlog: 2, free: nil, thresholds: thresholds)) + // No answer about the backlog - the same. XCTAssertFalse( - BufferGuardService.canResumeLocally(backlog: nil, free: 500, thresholds: progi)) + BufferGuardService.canResumeLocally(backlog: nil, free: 500, thresholds: thresholds)) } - func testMiejsceNaDyskuLiczySieTylkoGdyJestZmierzone() { + func testDriveSpaceCountsOnlyWhenMeasured() { XCTAssertFalse(BufferGuardService.driveHasRoom(freeBytes: nil, minGB: 30)) XCTAssertFalse(BufferGuardService.driveHasRoom(freeBytes: 0, minGB: 30)) XCTAssertFalse( @@ -669,32 +672,33 @@ final class BufferGuardDecisionTests: XCTestCase { BufferGuardService.driveHasRoom(freeBytes: 30 * 1_073_741_824, minGB: 30)) } - /// Galaz "nie wiem" w czujce MUSI byc zywa. + /// The "I do not know" branch in the monitor MUST be alive. /// - /// Przeglad zlapal moment, w ktorym `BackupHealth` porownywal do `nil` - /// wartosc nieopcjonalna - takie porownanie zawsze daje falsz, wiec galaz - /// byla martwa, a kod i tak sie kompilowal i testy przechodzily. Ten test - /// sprawdza SAMA galaz, nie typ: przy braku pomiaru ma powstac problem, - /// przy pomiarze - nie. - func testBrakPomiaruDyskuJestZglaszanyPrzezCzujke() { - let brak = BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: nil) - XCTAssertEqual(brak.count, 1, "Nieudany statfs to awaria ochrony dysku, nie cisza.") - // `first`, nie `[0]`: przy porazce tej asercji indeks przerwalby CALY - // przebieg fatal errorem zamiast zglosic jeden nieudany test. - XCTAssertEqual(brak.first?.summary, "Nie da sie zmierzyc wolnego miejsca na dysku Maca") + /// A review caught the moment when `BackupHealth` compared a non-optional + /// value to `nil` - such a comparison always gives false, so the branch was + /// dead, and the code still compiled and the tests passed. This test checks + /// THE BRANCH ITSELF, not the type: with no measurement a problem has to + /// appear, with a measurement - not. + func testMissingDiskMeasurementIsReportedByTheMonitor() { + let problems = BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: nil) + XCTAssertEqual( + problems.count, 1, "A failed statfs is a failure of the disk protection, not silence.") + // `first`, not `[0]`: if this assertion failed, the index would abort the + // WHOLE run with a fatal error instead of reporting one failed test. + XCTAssertEqual(problems.first?.summary, "Cannot measure free space on the Mac's disk") XCTAssertTrue( BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: 400).isEmpty, - "Udany pomiar nie ma prawa niczego zglaszac.") - // Zmierzone zero to WYNIK, a nie brak wyniku - o niskim stanie mowi - // osobny prog w `evaluate`, nie ta funkcja. + "A successful measurement has no right to report anything.") + // A measured zero is a RESULT, not a lack of one - a low level is reported + // by a separate threshold in `evaluate`, not by this function. XCTAssertTrue(BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: 0).isEmpty) } - /// Prog wolnego miejsca na Dysku jest ten sam, ktory `BackupHealth` uznaje - /// za ostrzegawczy - jedno zrodlo prawdy, zeby czujka i dozorca nie mogly - /// twierdzic czegos innego o tej samej liczbie. - func testProgMiejscaNaDyskuZgadzaSieZCzujka() { + /// The Drive free-space threshold is the same one `BackupHealth` treats as a + /// warning - one source of truth, so that the monitor and the watchdog cannot + /// claim different things about the same number. + func testDriveSpaceThresholdMatchesTheMonitor() { XCTAssertEqual( BufferGuardService.Thresholds().minDriveFreeGB, BackupHealth.driveFreeWarningGB) } diff --git a/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift b/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift index 881d419..245d23e 100644 --- a/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift @@ -2,56 +2,57 @@ import XCTest @testable import CloudMachineCore -/// Testy czekania na gotowy bufor. +/// Tests of waiting for a ready buffer. /// -/// Kazdy odtwarza konkretny wyscig, ktory juz zdarzyl sie na zywo - nie -/// sprawdzamy, ze funkcja "dziala", tylko ze zachowuje sie inaczej niz wersja, -/// ktora 13 wrz 2026 zostawila Time Machine bez celu. +/// Each one replays a specific race that has already happened live - we do +/// not check that the function "works", but that it behaves differently from +/// the version that left Time Machine without a destination on 13 Sep 2026. final class BufferReadinessTests: XCTestCase { - /// Zegar, ktory rusza sie tylko wtedy, gdy kod naprawde by spal. Dzieki temu - /// test mierzy CZEKANIE, a nie predkosc maszyny. + /// A clock that moves only when the code would really sleep. Thanks to that + /// the test measures WAITING, not the speed of the machine. private final class FakeClock { private(set) var now = Date(timeIntervalSince1970: 1_757_700_000) func sleep(_ seconds: TimeInterval) { now.addTimeInterval(seconds) } } - // MARK: - Warunek gotowosci + // MARK: - Readiness condition - /// To jest ten drugi wyscig: montowanie juz stoi, ale rclone nie wczytal - /// jeszcze brudnego cache, wiec katalog jest pusty. Stara wersja uznawala to - /// za gotowosc i podpiecie odpadalo na "Brak obrazu". - func testMontowanieBezObrazuToNieGotowosc() { + /// This is the second race: the mount is already up, but rclone has not yet + /// read the dirty cache, so the directory is empty. The old version treated + /// that as ready and the attach failed with "No image". + func testMountWithoutImageIsNotReady() { XCTAssertFalse(BufferReadiness.isReady(mounted: true, imageVisible: false)) } - func testObrazBezMontowaniaToNieGotowosc() { + func testImageWithoutMountIsNotReady() { XCTAssertFalse(BufferReadiness.isReady(mounted: false, imageVisible: true)) } - func testJednoIDrugieToGotowosc() { + func testBothMeanReady() { XCTAssertTrue(BufferReadiness.isReady(mounted: true, imageVisible: true)) } - // MARK: - Czekanie + // MARK: - Waiting - /// ZNANA ZLA PROBKA: bufor staje po 150 s. Stary limit 120 s poddawal sie - /// dziesiec sekund za wczesnie i wlasnie to zdarzylo sie 13 wrz 2026. - func testDoczekaSieBuforaKtoryStajePo150s() async { + /// KNOWN BAD SAMPLE: the buffer comes up after 150 s. The old 120 s limit + /// gave up ten seconds too early, and that is exactly what happened on + /// 13 Sep 2026. + func testWaitsForABufferThatComesUpAfter150s() async { let clock = FakeClock() - let gotowyOd = clock.now.addingTimeInterval(150) + let readyAt = clock.now.addingTimeInterval(150) let ready = await BufferReadiness.wait( now: { clock.now }, sleep: { clock.sleep($0) }, - probe: { clock.now >= gotowyOd }) + probe: { clock.now >= readyAt }) - XCTAssertTrue(ready, "Bufor stanal po 150 s - czekanie musi go zlapac") + XCTAssertTrue(ready, "The buffer came up after 150 s - the wait has to catch it") } - /// Dowod, ze poprzedni test nie przechodzi dlatego, ze funkcja zwraca zawsze - /// `true`: bufor, ktory nie staje NIGDY, musi zostac zgloszony jako awaria. - func testPoddajeSieGdyBuforNieStajeWcale() async { + /// Proof that the previous test does not pass because the function always + /// returns `true`: a buffer that NEVER comes up must be reported as a failure. + func testGivesUpWhenTheBufferNeverComesUp() async { let clock = FakeClock() let ready = await BufferReadiness.wait( @@ -59,12 +60,12 @@ final class BufferReadinessTests: XCTestCase { sleep: { clock.sleep($0) }, probe: { false }) - XCTAssertFalse(ready, "Bufor nigdy nie stanal - to musi byc awaria, nie cisza") + XCTAssertFalse(ready, "The buffer never came up - this has to be a failure, not silence") } - /// Czekanie ma sie skonczyc mniej wiecej na zadeklarowanym limicie, a nie - /// ciagnac w nieskonczonosc: launchd czeka na ten proces. - func testKonczyCzekanieNaZadeklarowanymLimicie() async { + /// The wait has to end roughly at the declared limit, not drag on forever: + /// launchd waits for this process. + func testStopsWaitingAtTheDeclaredLimit() async { let clock = FakeClock() let start = clock.now @@ -78,9 +79,9 @@ final class BufferReadinessTests: XCTestCase { XCTAssertLessThan(elapsed, BufferReadiness.defaultTimeout + BufferReadiness.defaultPoll * 2) } - /// Gotowy bufor nie moze kosztowac ani jednego uspienia - `attach-image` - /// chodzi tez z reki i po kazdym tyknieciu launchd. - func testGotowyBuforNieCzekaWcale() async { + /// A ready buffer must not cost a single sleep - `attach-image` also runs by + /// hand and on every launchd tick. + func testReadyBufferDoesNotWaitAtAll() async { let clock = FakeClock() let start = clock.now @@ -90,12 +91,13 @@ final class BufferReadinessTests: XCTestCase { probe: { true }) XCTAssertTrue(ready) - XCTAssertEqual(clock.now, start, "Gotowy bufor ma wracac natychmiast") + XCTAssertEqual(clock.now, start, "A ready buffer has to return immediately") } - /// Limit musi byc wiekszy niz zaobserwowane 150 s, inaczej naprawa jest - /// pozorna. Zapisane wprost, zeby nikt go nie scial z powrotem do dwoch minut. - func testLimitJestWiekszyNizZaobserwowanyWyscig() { + /// The limit has to be greater than the observed 150 s, otherwise the fix is + /// only apparent. Written down explicitly, so that nobody cuts it back to two + /// minutes. + func testLimitIsGreaterThanTheObservedRace() { XCTAssertGreaterThan(BufferReadiness.defaultTimeout, 150) } } diff --git a/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift b/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift index 1670c55..abd9dc9 100644 --- a/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift @@ -2,11 +2,11 @@ import XCTest @testable import CloudMachineCore -/// Blokada wzajemnego wykluczenia. Testy chodza po PRAWDZIWYM katalogu - -/// atrapa systemu plikow nie sprawdzilaby tu niczego, bo caly mechanizm to -/// atomowosc `mkdir` i to, czy zapisany PID da sie odczytac z powrotem. -/// Katalog jest wlasny i tymczasowy, zeby test nie dotykal blokad -/// produkcyjnych w `~/Library/Logs/CloudMachine`. +/// Mutual exclusion lock. The tests run on a REAL directory - a file system +/// fake would check nothing here, because the whole mechanism is the atomicity +/// of `mkdir` and whether the written PID can be read back. The directory is +/// private and temporary, so that the test does not touch the production locks +/// in `~/Library/Logs/CloudMachine`. final class CMLockTests: XCTestCase { private var dir: URL! @@ -23,7 +23,7 @@ final class CMLockTests: XCTestCase { private var lockPath: URL { dir.appendingPathComponent("image.lock.d") } - func testDrugaInstancjaNieDostajeTrzymanejBlokady() { + func testSecondInstanceDoesNotGetAHeldLock() { let first = CMLock(directory: lockPath) XCTAssertTrue(first.acquire()) defer { first.release() } @@ -31,26 +31,26 @@ final class CMLockTests: XCTestCase { let second = CMLock(directory: lockPath) XCTAssertFalse( second.acquire(), - "blokade trzyma zywy proces (ten test) - druga instancja nie ma prawa jej dostac") + "the lock is held by a live process (this test) - a second instance has no right to get it") } - func testPoZwolnieniuBlokadaJestZnowDoWziecia() { + func testAfterReleaseTheLockCanBeTakenAgain() { let first = CMLock(directory: lockPath) XCTAssertTrue(first.acquire()) first.release() XCTAssertFalse( FileManager.default.fileExists(atPath: lockPath.path), - "zwolnienie ma usunac katalog blokady, nie tylko zapomniec o nim") + "release must remove the lock directory, not just forget about it") let second = CMLock(directory: lockPath) XCTAssertTrue(second.acquire()) second.release() } - /// Dokladnie ten stan, ktory zostawia proces ubity miedzy `createDirectory` - /// a zapisem PID-a: katalog blokady jest, pliku `pid` nie ma. Konkurent ma - /// prawo go przejac - bo nikt zywy sie do niego nie przyznaje. - func testKatalogBezPidJestOsieroconyIDaSiePrzejac() throws { + /// Exactly the state left by a process killed between `createDirectory` and + /// writing the PID: the lock directory exists, the `pid` file does not. A + /// competitor has the right to take it over - because nobody alive claims it. + func testDirectoryWithoutPidIsOrphanedAndCanBeTakenOver() throws { try FileManager.default.createDirectory(at: lockPath, withIntermediateDirectories: false) let lock = CMLock(directory: lockPath) @@ -61,14 +61,15 @@ final class CMLockTests: XCTestCase { contentsOf: lockPath.appendingPathComponent("pid"), encoding: .utf8) XCTAssertEqual( pid.split(separator: "\n").first.map(String.init), "\(getpid())", - "po przejeciu w pliku ma stac NASZ PID - inaczej nastepny konkurent uzna blokade za wolna") + "after the takeover the file must hold OUR PID - otherwise the next competitor will consider the lock free" + ) } - /// Blokada po martwym procesie nie moze zostac na zawsze - watchdog - /// przerwany SIGKILL-em nie zdazy wywolac `release()`. - func testBlokadaPoMartwymPidzieJestPrzejmowana() throws { + /// A lock left by a dead process must not stay forever - a watchdog + /// interrupted by SIGKILL does not get to call `release()`. + func testLockOfDeadPidIsTakenOver() throws { try FileManager.default.createDirectory(at: lockPath, withIntermediateDirectories: false) - // PID, ktorego na pewno nie ma: `kill(pid, 0)` odmawia z ESRCH. + // A PID that certainly does not exist: `kill(pid, 0)` refuses with ESRCH. try "999999\n".write( to: lockPath.appendingPathComponent("pid"), atomically: true, encoding: .utf8) diff --git a/mac-app/Tests/CloudMachineAppTests/CMLoggerFlushTests.swift b/mac-app/Tests/CloudMachineAppTests/CMLoggerFlushTests.swift index d6327a8..2115fee 100644 --- a/mac-app/Tests/CloudMachineAppTests/CMLoggerFlushTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/CMLoggerFlushTests.swift @@ -3,97 +3,97 @@ import XCTest @testable import CloudMachineCore -/// Czy wpis dziennika jest w pliku ZARAZ po zalogowaniu, czy dopiero po 16 KiB. +/// Whether a log entry is in the file RIGHT AFTER logging, or only after 16 KiB. /// -/// Pod launchd stdout agenta jest PLIKIEM (`StandardOutPath` w kazdym -/// szablonie z `launchd/`), a dla pliku stdio wybiera buforowanie BLOKOWE. -/// Zmierzone 25.09.2026: `launchd-buffer-guard.out.log` mial dokladnie 16384 -/// bajty i date 2026-09-20, podczas gdy proces `buffer-guard` (`while true` + -/// `KeepAlive`, wiec nigdy nie dochodzi do oproznienia bufora przy wyjsciu) -/// zyl od 2026-09-25. Plik konczyl sie w pol slowa, wiec wygladal dokladnie -/// jak proces, ktory umarl piatego dnia. +/// Under launchd the agent's stdout is a FILE (`StandardOutPath` in every +/// template in `launchd/`), and for a file stdio chooses BLOCK buffering. +/// Measured 25.09.2026: `launchd-buffer-guard.out.log` was exactly 16384 bytes +/// and dated 2026-09-20, while the `buffer-guard` process (`while true` + +/// `KeepAlive`, so it never gets to flush the buffer on exit) had been alive +/// since 2026-09-25. The file ended mid-word, so it looked exactly like a +/// process that died on the fifth day. /// -/// Test NIE wola `CMLogger.log`, tylko sam zapis na stdout. Powod jest taki -/// sam, jak przy `HealthAlert.log`: `CMLogger.log` dopisuje do prawdziwego -/// `~/Library/Logs/CloudMachine/cloudmachine.log`, a ten plik jest jedynym -/// sladem po awariach backupu i nie ma prawa zbierac linii z przebiegow -/// `swift test`. +/// The test does NOT call `CMLogger.log`, only the write to stdout itself. The +/// reason is the same as with `HealthAlert.log`: `CMLogger.log` appends to the +/// real `~/Library/Logs/CloudMachine/cloudmachine.log`, and that file is the +/// only trace of backup failures and has no right to collect lines from +/// `swift test` runs. final class CMLoggerFlushTests: XCTestCase { - /// TA usterka. Buforowanie blokowe ustawiamy JAWNIE (`_IOFBF`), zamiast - /// liczyc na to, ze srodowisko testu je wybierze - inaczej wynik zalezalby - /// od tego, czy `swift test` odpalono z terminala (stdout = tty, buforowanie - /// liniowe, usterki nie widac) czy z CI (stdout = potok). Test ma mierzyc - /// nasz kod, nie to, gdzie go uruchomiono. - func testWpisJestWPlikuNatychmiast() throws { - let plik = try przekierowanyStdout() - defer { przywrocStdout() } + /// THAT defect. We set block buffering EXPLICITLY (`_IOFBF`) instead of + /// counting on the test environment to choose it - otherwise the result + /// would depend on whether `swift test` was started from a terminal (stdout + /// = tty, line buffering, the defect is invisible) or from CI (stdout = + /// pipe). The test is meant to measure our code, not where it was run. + func testEntryIsInTheFileImmediately() throws { + let file = try redirectedStdout() + defer { restoreStdout() } - CMLogger.emitToStandardOutput("[2026-09-25 22:00:00] pierwsza linia dziennika\n") + CMLogger.emitToStandardOutput("[2026-09-25 22:00:00] first log line\n") - // Czytamy BEZ zadnego `fflush` z naszej strony - dokladnie tak, jak - // czlowiek zagladajacy do pliku w trakcie zycia procesu. - let tresc = (try? String(contentsOf: plik, encoding: .utf8)) ?? "" + // We read WITHOUT any `fflush` on our side - exactly like a person looking + // into the file while the process is alive. + let content = (try? String(contentsOf: file, encoding: .utf8)) ?? "" XCTAssertTrue( - tresc.contains("pierwsza linia dziennika"), + content.contains("first log line"), """ - Wpis zostal w buforze stdio. Pod launchd znaczy to, ze plik, do ktorego \ - czlowiek zaglada NAJPIERW, jest z tylu az do uzbierania 16 KiB - \ - a ostatnia jego linia jest urwana w pol slowa. Dostalem: \ - "\(tresc)" (\(tresc.utf8.count) bajtow) + The entry stayed in the stdio buffer. Under launchd this means the file \ + a person looks at FIRST lags behind until 16 KiB accumulate - and its \ + last line is cut off mid-word. Got: \ + "\(content)" (\(content.utf8.count) bytes) """) } - /// Druga strona tej samej poprawki: tresc MA byc kompletna, nie tylko - /// wczesna. `fflush` po kazdym wpisie nie moze gubic ani sklejac linii. - func testKolejneWpisyLadujaWKolejnosciIWCalosci() throws { - let plik = try przekierowanyStdout() - defer { przywrocStdout() } + /// The other side of the same fix: the content MUST be complete, not just + /// early. `fflush` after every entry must not lose or merge lines. + func testSuccessiveEntriesLandInOrderAndInFull() throws { + let file = try redirectedStdout() + defer { restoreStdout() } - for numer in 1...5 { - CMLogger.emitToStandardOutput("linia \(numer)\n") + for number in 1...5 { + CMLogger.emitToStandardOutput("line \(number)\n") } - let tresc = (try? String(contentsOf: plik, encoding: .utf8)) ?? "" - XCTAssertEqual(tresc, "linia 1\nlinia 2\nlinia 3\nlinia 4\nlinia 5\n") + let content = (try? String(contentsOf: file, encoding: .utf8)) ?? "" + XCTAssertEqual(content, "line 1\nline 2\nline 3\nline 4\nline 5\n") } - // MARK: - Podmiana stdout na plik (czyli to, co robi launchd) + // MARK: - Replacing stdout with a file (i.e. what launchd does) - private var zapasowyDeskryptor: Int32 = -1 - private var katalog: URL? + private var savedDescriptor: Int32 = -1 + private var directory: URL? - /// Podstawia plik pod deskryptor 1 i WYMUSZA buforowanie blokowe - czyli - /// odtwarza warunki z launchd wewnatrz procesu testowego. - private func przekierowanyStdout() throws -> URL { - let katalog = FileManager.default.temporaryDirectory + /// Puts a file under descriptor 1 and FORCES block buffering - i.e. + /// reproduces the launchd conditions inside the test process. + private func redirectedStdout() throws -> URL { + let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cm-logger-flush-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - self.katalog = katalog - let plik = katalog.appendingPathComponent("launchd-udawany.out.log") - FileManager.default.createFile(atPath: plik.path, contents: nil) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + self.directory = directory + let file = directory.appendingPathComponent("launchd-fake.out.log") + FileManager.default.createFile(atPath: file.path, contents: nil) - // Wszystko, co juz czeka na prawdziwym stdout, wypychamy PRZED podmiana - - // inaczej wyladowaloby w naszym pliku i udawalo nasz wpis. + // Everything already waiting on the real stdout is pushed out BEFORE the + // swap - otherwise it would land in our file and pose as our entry. fflush(stdout) - zapasowyDeskryptor = dup(1) - let nowy = open(plik.path, O_WRONLY | O_APPEND) - XCTAssertGreaterThanOrEqual(nowy, 0, "nie udalo sie otworzyc \(plik.path)") - dup2(nowy, 1) - close(nowy) + savedDescriptor = dup(1) + let replacement = open(file.path, O_WRONLY | O_APPEND) + XCTAssertGreaterThanOrEqual(replacement, 0, "could not open \(file.path)") + dup2(replacement, 1) + close(replacement) setvbuf(stdout, nil, _IOFBF, 16384) - return plik + return file } - private func przywrocStdout() { + private func restoreStdout() { fflush(stdout) - if zapasowyDeskryptor >= 0 { - dup2(zapasowyDeskryptor, 1) - close(zapasowyDeskryptor) - zapasowyDeskryptor = -1 + if savedDescriptor >= 0 { + dup2(savedDescriptor, 1) + close(savedDescriptor) + savedDescriptor = -1 } setvbuf(stdout, nil, _IOLBF, 0) - if let katalog { try? FileManager.default.removeItem(at: katalog) } - katalog = nil + if let directory { try? FileManager.default.removeItem(at: directory) } + directory = nil } } diff --git a/mac-app/Tests/CloudMachineAppTests/ConfigStoreTests.swift b/mac-app/Tests/CloudMachineAppTests/ConfigStoreTests.swift index cbf315d..4493e94 100644 --- a/mac-app/Tests/CloudMachineAppTests/ConfigStoreTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/ConfigStoreTests.swift @@ -2,88 +2,90 @@ import XCTest @testable import CloudMachineCore -/// Ustalenie 15b: wynik `backupCorruptFile()` byl ignorowany. +/// Finding 15b: the result of `backupCorruptFile()` was ignored. /// -/// Ta funkcja przy porazce kopiowania oddaje `nil`, a jej wlasny komentarz -/// nazywa ta kopie JEDYNA siecia bezpieczenstwa miedzy "plik sie nie sparsowal" -/// a "auto-zapis cicho nadpisal go pusta konfiguracja". `loadOrInitialize()` -/// wolalo ja przez `backupCorruptFile()` bez sprawdzenia wyniku i oddawalo -/// `(.empty, error)`, a CLI logowalo "oryginal zachowany na dysku z kopia -/// zapasowa obok" - zdanie nieprawdziwe dokladnie w tym przypadku, w ktorym -/// jedyny egzemplarz danych mial zginac przy nastepnym zapisie. +/// That function returns `nil` when copying fails, and its own comment calls +/// this copy the ONLY safety net between "the file did not parse" and "an +/// auto-save silently overwrote it with an empty configuration". +/// `loadOrInitialize()` called it via `backupCorruptFile()` without checking +/// the result and returned `(.empty, error)`, and the CLI logged "original +/// kept on disk with a backup copy next to it" - a sentence that was false +/// exactly in the case where the only copy of the data was about to be lost on +/// the next write. final class ConfigStoreTests: XCTestCase { - private var katalog: URL! - private var plik: URL! + private var directory: URL! + private var file: URL! override func setUpWithError() throws { - katalog = FileManager.default.temporaryDirectory + directory = FileManager.default.temporaryDirectory .appendingPathComponent("cm-config-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - plik = katalog.appendingPathComponent("machines.json") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + file = directory.appendingPathComponent("machines.json") } override func tearDownWithError() throws { - try? FileManager.default.removeItem(at: katalog) + try? FileManager.default.removeItem(at: directory) } - private struct Uszkodzony: Error { - var localizedDescription: String { "nieoczekiwany znak na pozycji 12" } + private struct Corrupt: Error { + var localizedDescription: String { "unexpected character at position 12" } } - // MARK: - Brak kopii przerywa + // MARK: - A missing copy aborts - /// SEDNO poprawki. Bez kopii nie ma konfiguracji do pracy - `config` jest - /// `nil`, a nie "pusta". Wolajacy nie ma wiec czego zapisac i nie moze - /// nadpisac uszkodzonego-ale-mozliwego-do-odzyskania pliku. - func testBrakKopiiNieDajeKonfiguracjiDoPracy() { - let wynik = ConfigStore.decideAfterCorruption(backup: nil, error: Uszkodzony()) + /// The CORE of the fix. Without a copy there is no configuration to work + /// with - `config` is `nil`, not "empty". So the caller has nothing to save + /// and cannot overwrite a corrupt-but-recoverable file. + func testMissingCopyGivesNoConfigurationToWorkWith() { + let result = ConfigStore.decideAfterCorruption(backup: nil, error: Corrupt()) XCTAssertNil( - wynik.config, - "brak kopii musi PRZERWAC, a nie oddac pusta konfiguracje do nadpisania oryginalu") - XCTAssertNotNil(wynik.corruption, "powod uszkodzenia musi dojsc do czlowieka") + result.config, + "a missing copy must ABORT, not hand over an empty configuration that overwrites the original" + ) + XCTAssertNotNil(result.corruption, "the reason for the corruption must reach a person") } - /// Gdy kopia POWSTALA, praca na pustej konfiguracji jest bezpieczna - oryginal - /// da sie odzyskac z pliku obok. Bez tego testu "naprawa" przerywajaca - /// zawsze przeszlaby niezauwazona, a config uszkodzony reczna edycja - /// blokowalby cale narzedzie. - func testUdanaKopiaPozwalaPracowacDalej() { - let kopia = katalog.appendingPathComponent("machines.json.corrupt-1") - let wynik = ConfigStore.decideAfterCorruption(backup: kopia, error: Uszkodzony()) - XCTAssertNotNil(wynik.config) - XCTAssertNotNil(wynik.corruption, "uszkodzenie nadal musi byc widoczne") - guard case .corruptButBackedUp(_, let gdzie, _) = wynik else { - return XCTFail("oczekiwalem .corruptButBackedUp, dostalem \(wynik)") + /// When the copy WAS MADE, working on an empty configuration is safe - the + /// original can be recovered from the file next to it. Without this test a + /// "fix" that always aborts would go unnoticed, and a config corrupted by a + /// manual edit would block the whole tool. + func testSuccessfulCopyAllowsWorkToContinue() { + let copy = directory.appendingPathComponent("machines.json.corrupt-1") + let result = ConfigStore.decideAfterCorruption(backup: copy, error: Corrupt()) + XCTAssertNotNil(result.config) + XCTAssertNotNil(result.corruption, "the corruption must still be visible") + guard case .corruptButBackedUp(_, let location, _) = result else { + return XCTFail("expected .corruptButBackedUp, got \(result)") } - XCTAssertEqual(gdzie, kopia, "komunikat ma powiedziec, GDZIE lezy kopia") + XCTAssertEqual(location, copy, "the message must say WHERE the copy is") } - /// Zdrowy plik nie jest uszkodzeniem. - func testZdrowaKonfiguracjaNieZglaszaUszkodzenia() { + /// A healthy file is not a corruption. + func testHealthyConfigurationReportsNoCorruption() { XCTAssertNil(ConfigInitialization.ready(.empty).corruption) XCTAssertNotNil(ConfigInitialization.ready(.empty).config) } - // MARK: - Sama kopia + // MARK: - The copy itself - func testKopiaUszkodzonegoPlikuPowstajeObok() throws { - try "{ to nie jest json".write(to: plik, atomically: true, encoding: .utf8) + func testCopyOfCorruptFileIsMadeNextToIt() throws { + try "{ this is not json".write(to: file, atomically: true, encoding: .utf8) - let kopia = try XCTUnwrap(ConfigStore.backupCorruptFile(configPath: plik)) - XCTAssertTrue(FileManager.default.fileExists(atPath: kopia.path)) - XCTAssertEqual(try String(contentsOf: kopia, encoding: .utf8), "{ to nie jest json") + let copy = try XCTUnwrap(ConfigStore.backupCorruptFile(configPath: file)) + XCTAssertTrue(FileManager.default.fileExists(atPath: copy.path)) + XCTAssertEqual(try String(contentsOf: copy, encoding: .utf8), "{ this is not json") XCTAssertTrue( - FileManager.default.fileExists(atPath: plik.path), - "kopia nie moze zabierac oryginalu - to kopia, nie przeniesienie") - XCTAssertTrue(kopia.lastPathComponent.contains("corrupt-"), kopia.lastPathComponent) + FileManager.default.fileExists(atPath: file.path), + "the copy must not take the original away - it is a copy, not a move") + XCTAssertTrue(copy.lastPathComponent.contains("corrupt-"), copy.lastPathComponent) } - /// Nieudana kopia MUSI byc rozpoznawalna po wyniku - tu przez sciezke - /// w katalogu, ktorego nie ma. - func testNieudanaKopiaOddajeNil() { - let nieistniejacy = katalog.appendingPathComponent("nie-ma-takiego-katalogu") + /// A failed copy MUST be recognizable from the result - here via a path in a + /// directory that does not exist. + func testFailedCopyReturnsNil() { + let missing = directory.appendingPathComponent("no-such-directory") .appendingPathComponent("machines.json") - XCTAssertNil(ConfigStore.backupCorruptFile(configPath: nieistniejacy)) + XCTAssertNil(ConfigStore.backupCorruptFile(configPath: missing)) } } diff --git a/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift new file mode 100644 index 0000000..1033567 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift @@ -0,0 +1,87 @@ +import XCTest + +@testable import CloudMachineCore + +/// The cases that matter are the ones that would orphan a backup: a Mac that +/// already backs up somewhere must never be moved to another folder, whatever +/// is passed on the command line. +final class DriveFolderTests: XCTestCase { + + func testNewMacGetsItsMachineKey() { + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: nil, legacyEvidence: false, machineKey: "macbook-pro"), + .assign("macbook-pro")) + } + + func testNewMacCanChooseItsFolder() { + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: "office-imac", legacyEvidence: false, machineKey: "imac"), + .assign("office-imac")) + } + + func testInstallationFromBeforeFoldersKeepsTheLegacyFolder() { + XCTAssertEqual( + DriveFolder.decide( + existing: nil, requested: nil, legacyEvidence: true, machineKey: "mac-studio-2"), + .assign(DriveFolder.legacyName)) + } + + func testInstallationFromBeforeFoldersCannotBeMovedByAccident() { + guard + case .refuse = DriveFolder.decide( + existing: nil, requested: "new-name", legacyEvidence: true, machineKey: "x") + else { return XCTFail("a legacy installation was moved to a new, empty folder") } + } + + func testStoredFolderIsKept() { + XCTAssertEqual( + DriveFolder.decide( + existing: "macbook-pro", requested: nil, legacyEvidence: true, machineKey: "other"), + .keep("macbook-pro")) + XCTAssertEqual( + DriveFolder.decide( + existing: "macbook-pro", requested: "macbook-pro", legacyEvidence: false, + machineKey: "other"), + .keep("macbook-pro")) + } + + func testStoredFolderCannotBeChanged() { + guard + case .refuse = DriveFolder.decide( + existing: "macbook-pro", requested: "imac", legacyEvidence: false, machineKey: "x") + else { return XCTFail("a Mac with a backup was moved to a new, empty folder") } + } + + func testInvalidNamesAreRefused() { + for name in [ + "", "-leading-dash", "Upper", "with space", "a/b", "..", String(repeating: "a", count: 64), + ] { + guard + case .refuse = DriveFolder.decide( + existing: nil, requested: name, legacyEvidence: false, machineKey: "x") + else { return XCTFail("accepted invalid folder name '\(name)'") } + } + } + + func testUnusableMachineKeyFallsBackToAValidName() { + XCTAssertEqual( + DriveFolder.decide(existing: nil, requested: nil, legacyEvidence: false, machineKey: ""), + .assign("this-mac")) + } + + func testStoredFileIsReadAndAMissingOrBrokenOneIsIgnored() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("drive-folder-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("drive-folder") + + XCTAssertNil(DriveFolder.stored(in: file)) + try "macbook-pro\n".write(to: file, atomically: true, encoding: .utf8) + XCTAssertEqual(DriveFolder.stored(in: file), "macbook-pro") + try "../../etc".write(to: file, atomically: true, encoding: .utf8) + XCTAssertNil(DriveFolder.stored(in: file)) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift index c533035..472b6c2 100644 --- a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift @@ -2,15 +2,15 @@ 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". +/// Tests of the Google Drive layer. They cover parsing and decisions - i.e. +/// the places where bugs were silent and costly, and cannot be seen from the +/// fact that "the backup is being made". final class DriveLayerTests: XCTestCase { - // MARK: - Parsowanie hdiutil info + // MARK: - Parsing hdiutil info - /// `hdiutil info` grupuje wpisy w bloki: po linii `image-path` naleza - /// wszystkie kolejne linie `/dev/diskN`, az do nastepnego `image-path`. + /// `hdiutil info` groups entries into blocks: all `/dev/diskN` lines after an + /// `image-path` line belong to it, up to the next `image-path`. private let hdiutilInfo = """ framework : 595.100.2 driver : 595.100.2 @@ -42,11 +42,11 @@ final class DriveLayerTests: XCTestCase { XCTAssertEqual(devices, ["/dev/disk4"]) } - /// Regresja: gdy obraz nie jest podpiety, nie wolno zwrocic cudzych - /// urzadzen - odpiecie ich zabiloby czyjs wolumen. + /// Regression: when the image is not attached, other images' devices must + /// not be returned - detaching them would kill someone's volume. func testParseDevicesReturnsNothingForUnknownImage() { let devices = BackupImageService.parseDevices( - hdiutilInfo: hdiutilInfo, imagePath: "/Users/x/nieistniejacy.sparsebundle") + hdiutilInfo: hdiutilInfo, imagePath: "/Users/x/nonexistent.sparsebundle") XCTAssertTrue(devices.isEmpty) } @@ -54,7 +54,7 @@ final class DriveLayerTests: XCTestCase { XCTAssertTrue(BackupImageService.parseDevices(hdiutilInfo: "", imagePath: "/x").isEmpty) } - // MARK: - Suma kontrolna rclone + // MARK: - rclone checksum private let sums = """ 3a1f0000000000000000000000000000000000000000000000000000000000aa rclone-v1.75.1-osx-amd64.zip @@ -68,40 +68,41 @@ final class DriveLayerTests: XCTestCase { "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. + /// A missing entry MUST give nil, not some other checksum - otherwise the + /// installer would compare the archive with another file's checksum and + /// either reject a correct download or (worse) let an incorrect one through. func testExpectedChecksumReturnsNilWhenArchiveMissing() { XCTAssertNil( RcloneInstaller.expectedChecksum(sumsContent: sums, zipName: "rclone-v9.9.9-osx-arm64.zip")) } - // MARK: - Argumenty montowania + // MARK: - Mount arguments func testMountArgumentsCarryTheNonObviousFlags() { let args = DriveBufferService.mountArguments() - // Bez tego skasowane pasma ida do kosza Dysku i dalej licza sie do limitu. + // Without this, deleted bands go to Drive's trash and keep counting towards the limit. XCTAssertTrue(args.contains("--drive-use-trash=false")) - // Po przekroczeniu dobowego limitu 750 GB rclone ma stanac, a nie kreci - // sie w 403. + // After exceeding the daily 750 GB limit rclone is to stop, not spin in + // 403s. XCTAssertTrue(args.contains("--drive-stop-on-upload-limit")) - // Bez pelnego cache zapis nie jest buforowany, czyli cala obietnica - // nieprzerywalnosci znika. + // Without the full cache writes are not buffered, i.e. the whole promise + // of not being interrupted disappears. 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. + // The rc interface is the only source of the queue state - without it the + // buffer watchdog is blind. XCTAssertTrue(args.contains("--rc")) } - /// 02.10.2026: powiadomienia o zmianach z Dysku (domyslnie co minute) - /// uniewaznialy katalog `bands` po kazdej wlasnej wysylce, a jego - /// przeladowanie trzymalo blokade ~42 s - cale montowanie stalo co minute. - func testMountNieUniewazniaKataloguPoWlasnychWysylkach() { + /// 02.10.2026: change notifications from Drive (every minute by default) + /// invalidated the `bands` directory after every upload of our own, and + /// reloading it held the lock for ~42 s - the whole mount stood still every + /// minute. + func testMountDoesNotInvalidateTheDirectoryAfterOwnUploads() { let args = DriveBufferService.mountArguments() func value(_ flag: String) -> String? { args.firstIndex(of: flag).map { args[$0 + 1] } @@ -110,24 +111,25 @@ final class DriveLayerTests: XCTestCase { XCTAssertEqual(value("--dir-cache-time"), "9999h") } - /// Rozmiar pasma zostal wybrany pomiarem (patrz gdrive/README.md). Zmiana - /// dziala tylko przy tworzeniu obrazu, wiec nie wolno jej przeoczyc. + /// The band size was chosen by measurement (see gdrive/README.md). A change + /// only takes effect when the image is created, so it must not be missed. func testBandSizeIs32MB() { XCTAssertEqual(BackupImageService.bandSectors * 512, 32 * 1024 * 1024) } - // MARK: - Progi dozorcy + // MARK: - Watchdog thresholds func testGuardThresholdsAreOrdered() { let t = BufferGuardService.Thresholds() XCTAssertLessThan( t.lowGB, t.highGB, - "Prog wznowienia musi byc nizszy niz prog pauzy, inaczej dozorca wpadnie w oscylacje.") + "The resume threshold has to be lower than the pause threshold, otherwise the watchdog will oscillate." + ) } - /// 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. + /// Free space has to be counted pessimistically, like `df`. The "important + /// usage" measure included space taken by snapshots and showed 1202 GB where + /// `df` said 427 GB - the watchdog would have paused too late. func testFreeSpaceMatchesStatfs() { var stats = statfs() XCTAssertEqual(statfs("/System/Volumes/Data", &stats), 0) @@ -136,16 +138,16 @@ final class DriveLayerTests: XCTestCase { } } -/// Wykrywanie dobowego limitu Google Drive. Osobna klasa, bo to pojedyncza -/// pomylka, ktora zatrzymala prawdziwy backup - zasluguje na wlasne miejsce. +/// Detection of the Google Drive daily limit. A separate class, because this is +/// the single mistake that stopped a real backup - it deserves its own place. final class DailyQuotaDetectionTests: XCTestCase { private let formatter: DateFormatter = { let f = DateFormatter() - // Probka udaje log rclone, wiec musi wygladac tak samo na kazdej maszynie. - // Literal, a NIE `DriveBufferService.rcloneLogLocale`: generator probki nie - // moze zalezec od stalej, ktorej poprawnosc wlasnie sprawdzamy - inaczej - // podmiana tej stalej przestawilaby generator razem z parserem i test - // przechodzilby w obu stanach. + // The sample imitates an rclone log, so it has to look the same on every + // machine. A literal, NOT `DriveBufferService.rcloneLogLocale`: the sample + // generator must not depend on the constant whose correctness we are + // checking - otherwise replacing that constant would switch the generator + // together with the parser and the test would pass in both states. f.locale = Locale(identifier: "en_US_POSIX") f.dateFormat = "yyyy/MM/dd HH:mm:ss" return f @@ -156,10 +158,10 @@ final class DailyQuotaDetectionTests: XCTestCase { 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. + /// THIS is the bug. rclone describes the momentary throttle with the message + /// "Received upload limit error", indistinguishable by text from the daily + /// limit - and retries it by itself. Catching it paused the backup after + /// uploading 109 GiB of the 750 GB allowed per day. func testTransientRateLimitIsNotTheDailyQuota() { let now = Date() let log = [ @@ -181,23 +183,23 @@ final class DailyQuotaDetectionTests: XCTestCase { 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. + /// Without a time window an alarm once raised would never go out - the entry + /// stays in the log, so the backup would fall into a pause-resume-pause cycle. func testOldQuotaErrorIsIgnored() { let now = Date() let log = line(120, "googleapi: Error 403: storageQuotaExceeded", now: now) XCTAssertFalse(DriveBufferService.logMentionsUploadLimit(log, now: now, within: 30)) } - // MARK: - Ustalenie 15a: kalendarz czlowieka nie moze uciszac parsera + // MARK: - Finding 15a: a person's calendar must not silence the parser - /// ZNANY ZLY KALENDARZ - taki, jaki `Locale.current` oddaje na tajskim Macu. + /// KNOWN BAD CALENDAR - the one `Locale.current` returns on a Thai Mac. /// - /// `DateFormatter` z ustalonym `dateFormat` bierze kalendarz z locale, wiec - /// "2026/09/25" parsuje sie BEZ BLEDU jako rok buddyjski 2026, czyli - /// gregorianski 1483. Data wypada 543 lata przed oknem, `stamp < cutoff` - /// konczy petle na pierwszej linii i realny limit dysku przestaje istniec. - func testKalendarzBuddyjskiKasowalWykrycieLimitu() { + /// A `DateFormatter` with a fixed `dateFormat` takes the calendar from the + /// locale, so "2026/09/25" parses WITHOUT AN ERROR as Buddhist year 2026, i.e. + /// Gregorian 1483. The date lands 543 years before the window, `stamp < cutoff` + /// ends the loop on the first line and the real disk limit ceases to exist. + func testBuddhistCalendarErasedLimitDetection() { let now = Date() let log = line( 1, @@ -206,10 +208,10 @@ final class DailyQuotaDetectionTests: XCTestCase { XCTAssertFalse( DriveBufferService.logMentionsUploadLimit( log, now: now, within: 30, locale: Locale(identifier: "th_TH@calendar=buddhist")), - "to jest opis USTERKI, nie oczekiwanie - naprawa siedzi w domyslnym locale") + "this describes the DEFECT, not an expectation - the fix lives in the default locale") XCTAssertTrue( DriveBufferService.logMentionsUploadLimit(log, now: now, within: 30), - "domyslny parser musi czytac ten sam log niezaleznie od ustawien czlowieka") + "the default parser has to read the same log regardless of the person's settings") } func testEmptyLogIsNotAQuotaError() { @@ -217,25 +219,25 @@ final class DailyQuotaDetectionTests: XCTestCase { } } -/// Rozpoznanie ZATORU wysylki po zachowaniu rclone. +/// Recognising an upload JAM by rclone's behaviour. /// -/// Wszystkie proporcje ponizej sa ZMIERZONE na produkcyjnym `rclone.log` tej -/// maszyny, nie wymyslone. Tekst bledu jest w obu przypadkach identyczny - -/// gdyby dalo sie je rozroznic po tresci, ta klasa nie musialaby istniec. +/// All ratios below are MEASURED on this machine's production `rclone.log`, not +/// made up. The error text is identical in both cases - if they could be told +/// apart by content, this class would not have to exist. final class UploadStallDetectionTests: XCTestCase { private let formatter: DateFormatter = { let f = DateFormatter() - // Probka udaje log rclone, wiec musi wygladac tak samo na kazdej maszynie. - // Literal, a NIE `DriveBufferService.rcloneLogLocale`: generator probki nie - // moze zalezec od stalej, ktorej poprawnosc wlasnie sprawdzamy - inaczej - // podmiana tej stalej przestawilaby generator razem z parserem i test - // przechodzilby w obu stanach. + // The sample imitates an rclone log, so it has to look the same on every + // machine. A literal, NOT `DriveBufferService.rcloneLogLocale`: the sample + // generator must not depend on the constant whose correctness we are + // checking - otherwise replacing that constant would switch the generator + // together with the parser and the test would pass in both states. f.locale = Locale(identifier: "en_US_POSIX") f.dateFormat = "yyyy/MM/dd HH:mm:ss" return f }() - /// Buduje probke o zadanym stosunku sukcesow do bledow, w oknie. + /// Builds a sample with the given ratio of successes to errors, in the window. private func sample(errors: Int, successes: Int, minutesAgo: Int, now: Date) -> String { let stamp = formatter.string(from: now.addingTimeInterval(-Double(minutesAgo) * 60)) var lines: [String] = [] @@ -252,160 +254,162 @@ final class UploadStallDetectionTests: XCTestCase { return lines.joined(separator: "\n") } - /// ZNANA ZLA PROBKA. 12 wrzesnia 2026, godzina 10: 5467 bledow i 59 udanych - /// wysylek. Wysylka stala wtedy trzy godziny i NIC tego nie zglosilo. + /// KNOWN BAD SAMPLE. 12 September 2026, 10 o'clock: 5467 errors and 59 + /// successful uploads. The upload stood still for three hours then and NOTHING + /// reported it. func testRealStallIsDetected() { let now = Date() let log = sample(errors: 5467, successes: 59, minutesAgo: 5, now: now) XCTAssertTrue(DriveBufferService.logShowsUploadStalled(log, now: now, within: 30)) } - /// Drugi zator, 15 wrzesnia godzina 9: 8915 bledow, 31 sukcesow. + /// The second jam, 15 September 9 o'clock: 8915 errors, 31 successes. func testSecondRealStallIsDetected() { let now = Date() let log = sample(errors: 8915, successes: 31, minutesAgo: 2, now: now) XCTAssertTrue(DriveBufferService.logShowsUploadStalled(log, now: now, within: 30)) } - /// ZNANA DOBRA PROBKA. 12 wrzesnia godzina 9, tuz PRZED zatorem: tyle samo - /// bledow co sukcesow (781 do 833). Wysylka szla. To jest ta sytuacja, - /// ktora kiedys niepotrzebnie wstrzymala backup po 109 GiB. + /// KNOWN GOOD SAMPLE. 12 September 9 o'clock, right BEFORE the jam: as many + /// errors as successes (781 to 833). The upload was moving. This is the + /// situation that once needlessly paused the backup after 109 GiB. func testThrottlingWithUploadsFlowingIsNotAStall() { let now = Date() let log = sample(errors: 781, successes: 833, minutesAgo: 5, now: now) XCTAssertFalse(DriveBufferService.logShowsUploadStalled(log, now: now, within: 30)) } - /// 15 wrzesnia godzina 8: 1040 bledow, ale 2481 sukcesow - dlawienie tempa - /// przy wysylce idacej pelna para. Godzine pozniej to samo przeszlo w zator - /// i wtedy juz musi zadzialac. + /// 15 September 8 o'clock: 1040 errors, but 2481 successes - rate throttling + /// with the upload going at full steam. An hour later the same turned into a + /// jam, and then it has to fire. func testHeavyThrottlingWithMoreSuccessesIsNotAStall() { let now = Date() let log = sample(errors: 1040, successes: 2481, minutesAgo: 10, now: now) XCTAssertFalse(DriveBufferService.logShowsUploadStalled(log, now: now, within: 30)) } - /// 11 wrzesnia godzina 14: JEDEN blad na 4833 udane wysylki. Pojedyncze - /// odbicie nie jest zatorem, choc stosunek sukcesow bylby tu bez znaczenia - - /// ratuje nas dolny prog liczby bledow. + /// 11 September 14 o'clock: ONE error per 4833 successful uploads. A single + /// bounce is not a jam, although the success ratio would mean nothing here - + /// the lower bound on the error count saves us. func testSingleErrorIsNotAStall() { let now = Date() let log = sample(errors: 1, successes: 4833, minutesAgo: 5, now: now) XCTAssertFalse(DriveBufferService.logShowsUploadStalled(log, now: now, within: 30)) } - /// Zator, ktory byl i minal, nie moze trzymac alarmu w nieskonczonosc - - /// wpis zostaje w logu na zawsze. + /// A jam that came and went must not hold the alarm forever - the entry stays + /// in the log for good. func testStallOutsideTheWindowIsIgnored() { let now = Date() let log = sample(errors: 5467, successes: 59, minutesAgo: 120, now: now) XCTAssertFalse(DriveBufferService.logShowsUploadStalled(log, now: now, within: 30)) } - /// Cisza to nie zator. W oknie bez ruchu nie ma ani bledow, ani sukcesow - - /// bez dolnego progu liczby bledow stosunek 0/0 dalby falszywy alarm. + /// Silence is not a jam. A window without traffic has neither errors nor + /// successes - without the lower bound on the error count the 0/0 ratio would + /// give a false alarm. func testSilenceIsNotAStall() { XCTAssertFalse(DriveBufferService.logShowsUploadStalled("", now: Date(), within: 30)) } - // MARK: - Ustalenie 15a: kalendarz czlowieka nie moze uciszac zatoru + // MARK: - Finding 15a: a person's calendar must not silence the jam - /// Ten sam zator, ktory `testRealStallIsDetected` wykrywa, znikal bez sladu - /// na maszynie z kalendarzem niegregorianskim: wszystkie linie wypadaly poza - /// okno, `errors` zostawalo zerem i `uploadStalled()` meldowal "nie ma - /// zatoru" - a dozorca bufora na tej podstawie NIE wstrzymuje Time Machine. - func testKalendarzBuddyjskiKasowalWykrycieZatoru() { + /// The same jam that `testRealStallIsDetected` detects disappeared without a + /// trace on a machine with a non-Gregorian calendar: all lines fell outside the + /// window, `errors` stayed zero and `uploadStalled()` reported "no jam" - and + /// on that basis the buffer watchdog does NOT pause Time Machine. + func testBuddhistCalendarErasedStallDetection() { let now = Date() let log = sample(errors: 5467, successes: 59, minutesAgo: 5, now: now) XCTAssertFalse( DriveBufferService.logShowsUploadStalled( log, now: now, within: 30, locale: Locale(identifier: "th_TH@calendar=buddhist")), - "to jest opis USTERKI, nie oczekiwanie") + "this describes the DEFECT, not an expectation") XCTAssertTrue( DriveBufferService.logShowsUploadStalled(log, now: now, within: 30), - "domyslnie parsujemy ustalonym en_US_POSIX, wiec zator zostaje zatorem") + "by default we parse with a fixed en_US_POSIX, so a jam stays a jam") } - /// Sama stala - zeby "naprawa" polegajaca na cofnieciu jej do - /// `Locale.current` nie przeszla niezauwazona na maszynie, ktora akurat ma - /// kalendarz gregorianski (czyli na tej). - func testParserLoguJestPinowanyNaPosix() { + /// The constant itself - so that a "fix" consisting of reverting it to + /// `Locale.current` does not go unnoticed on a machine that happens to have + /// the Gregorian calendar (i.e. this one). + func testLogParserIsPinnedToPosix() { XCTAssertEqual(DriveBufferService.rcloneLogLocale.identifier, "en_US_POSIX") } - /// Odroczenie wysylki musi isc do rclone z jednej stalej - inaczej zmiana - /// jednego miejsca zostawia drugie z poprzednia wartoscia. + /// The upload delay has to go to rclone from one constant - otherwise changing + /// one place leaves the other with the previous value. func testMountUsesConfiguredWriteBack() { let args = DriveBufferService.mountArguments() guard let index = args.firstIndex(of: "--vfs-write-back") else { - return XCTFail("brak --vfs-write-back w argumentach montowania") + return XCTFail("no --vfs-write-back in the mount arguments") } XCTAssertEqual(args[index + 1], "\(DriveBufferService.writeBackSeconds)s") } } -/// Decyzje dozorcy bufora. Komentarz przy `step()` obiecywal, ze wydzielenie -/// go z petli sluzy testowaniu - a testu nie bylo. Tu jest. +/// Buffer watchdog decisions. The comment at `step()` promised that splitting +/// it out of the loop was for testing - and there was no test. Here it is. final class BufferGuardThresholdTests: XCTestCase { - /// Prog pauzy MUSI lezec PONIZEJ rozmiaru bufora - i to jest odwrocenie - /// wymagania, ktore stalo tu wczesniej. + /// The pause threshold MUST lie BELOW the buffer size - and that is a reversal + /// of the requirement that stood here before. /// - /// Stara wersja zadala progu POWYZEJ rozmiaru cache'a, bo progi odnosily sie - /// do `bytesUsed`, czyli do rozmiaru CALEGO cache'a. Ta wielkosc z definicji - /// stoi przy limicie (`--vfs-cache-max-size 100G` plus `max-age 9999h`), - /// zmierzone: 281 pomiarow, minimum 99 GB. Prog ponizej niej faktycznie - /// wstrzymywalby backup bez przerwy - wiec tamto wymaganie bylo sluszne DLA - /// TAMTEJ MIARY. + /// The old version demanded a threshold ABOVE the cache size, because the + /// thresholds referred to `bytesUsed`, i.e. the size of the WHOLE cache. That + /// quantity by definition sits at the limit (`--vfs-cache-max-size 100G` plus + /// `max-age 9999h`), measured: 281 measurements, minimum 99 GB. A threshold + /// below it really would have paused the backup non-stop - so that requirement + /// was right FOR THAT MEASURE. /// - /// Od 2026-09-25 progi odnosza sie do ZALEGLOSCI NIEWYSLANEJ. Zaleglosc to - /// dokladnie ta czesc cache'a, ktorej rclone NIE MOZE usunac, wiec gdy - /// zrowna sie z `cacheSizeGB`, limit nie ma juz zapasu i kazdy kolejny - /// gigabajt idzie poza niego, w wolne miejsce na dysku. Prog pauzy musi wiec - /// zdazyc ZANIM to nastapi. + /// Since 2026-09-25 the thresholds refer to the UNSENT BACKLOG. The backlog is + /// exactly the part of the cache that rclone CANNOT evict, so when it reaches + /// `cacheSizeGB`, the limit has no headroom left and every further gigabyte + /// goes beyond it, into free disk space. So the pause threshold has to act + /// BEFORE that happens. /// - /// Co kosztowala stara wersja: przy progu wznowienia 40 GB liczonym z miary, - /// ktora nigdy nie spadla ponizej 99 GB, w calym dzienniku jest jedna linia - /// PAUZA i ZERO linii WZNOWIENIE - dozorca stal w pauzie 53 godziny. + /// What the old version cost: with a 40 GB resume threshold computed from a + /// measure that never dropped below 99 GB, the whole journal has one PAUSE line + /// and ZERO RESUME lines - the watchdog sat paused for 53 hours. func testPauseThresholdSitsBelowTheCacheSize() { let t = BufferGuardService.Thresholds() XCTAssertLessThan( t.highGB, DriveBufferService.cacheSizeGB, - "Prog pauzy rowny rozmiarowi bufora znaczy zero zapasu: zaleglosc rowna " - + "pojemnosci cache'a wypycha kazdy kolejny gigabajt w wolne miejsce.") + "A pause threshold equal to the buffer size means zero headroom: a backlog equal to " + + "the cache capacity pushes every further gigabyte into free space.") } - /// Prog wznowienia musi byc wyraznie nizszy od progu pauzy, inaczej dozorca - /// oscylowalby miedzy start i stop przy kazdym tyknieciu. + /// The resume threshold has to be clearly lower than the pause threshold, + /// otherwise the watchdog would oscillate between start and stop on every tick. 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.") + "Too narrow a gap between the thresholds gives a pause-resume-pause cycle.") } - /// Progi nadal WYLICZAJA sie z rozmiaru bufora, a nie sa wpisane z palca - - /// ta wlasnosc zostaje, zmienily sie tylko mnozniki, bo zmienila sie - /// wielkosc, do ktorej progi sie odnosza (zaleglosc zamiast rozmiaru cache'a). - /// Wpisane z palca dzialaly tylko przypadkiem, dla jednej konkretnej wartosci. + /// The thresholds are still DERIVED from the buffer size, not typed in by hand + /// - that property stays; only the multipliers changed, because the quantity + /// the thresholds refer to changed (backlog instead of cache size). Typed in by + /// hand they only worked by accident, for one specific value. func testThresholdsFollowTheCacheSize() { let t = BufferGuardService.Thresholds() XCTAssertEqual(t.highGB, DriveBufferService.cacheSizeGB / 2) XCTAssertEqual(t.lowGB, DriveBufferService.cacheSizeGB / 10) } - /// Prog wznowienia musi byc OSIAGALNY. To jest cala lekcja z 53 godzin pauzy: - /// stare 40 GB odnosilo sie do wielkosci, ktora nigdy nie zeszla ponizej - /// 99 GB, wiec warunek wyjscia z pauzy byl falszywy w 281 obserwacjach na 281. - /// Zaleglosc schodzi do zera, gdy kolejka sie oprozni - ale tylko wtedy, gdy - /// prog lezy w zasiegu tego, co kolejka potrafi oddac. + /// The resume threshold has to be REACHABLE. That is the whole lesson of the + /// 53 hours of pause: the old 40 GB referred to a quantity that never went below + /// 99 GB, so the condition for leaving the pause was false in 281 observations + /// out of 281. The backlog goes down to zero when the queue empties - but only + /// if the threshold lies within reach of what the queue can give back. func testResumeThresholdIsReachable() { let t = BufferGuardService.Thresholds() - XCTAssertGreaterThan(t.lowGB, 0, "Prog wznowienia rowny zeru wymaga pustej kolejki.") + XCTAssertGreaterThan(t.lowGB, 0, "A resume threshold of zero requires an empty queue.") XCTAssertLessThan( t.lowGB, DriveBufferService.cacheSizeGB, - "Prog wznowienia powyzej pojemnosci cache'a jest nieosiagalny z definicji.") + "A resume threshold above the cache capacity is unreachable by definition.") } func testExplicitThresholdsAreRespected() { diff --git a/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift b/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift index 2ed427b..ef510d0 100644 --- a/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift @@ -2,314 +2,368 @@ import XCTest @testable import CloudMachineCore -/// Testy samego ALARMU, a nie czujki. +/// Tests of the ALARM itself, not of the watchdog. /// -/// Do 23.09.2026 `HealthAlert` nie mial ani jednego testu, mimo ze to on -/// decyduje, czy ktokolwiek dowie sie o awarii backupu. Obie naprawione tu -/// usterki sa tego samego rodzaju: alarm uznawal sie za zglosony, choc nikt -/// go nie zobaczyl. +/// Until 23.09.2026 `HealthAlert` did not have a single test, even though it is +/// what decides whether anyone learns about a backup failure. Both defects +/// fixed here are of the same kind: the alarm considered itself reported, +/// although nobody saw it. /// -/// Kazdy test PODSTAWIA dziennik (`log:`) - patrz `zglos(_:now:deliver:)`. -/// Wyjatek jest jeden i celowy: `testPrawdziwyPrzebiegNadalPiszeDoDziennika`, -/// ktory musi uzyc prawdziwego, zeby udowodnic, ze podstawienie nie uciszylo -/// produkcji. +/// Every test SUBSTITUTES the log (`log:`) - see `report(_:now:deliver:)`. +/// There is exactly one exception, and it is deliberate: +/// `testRealRunStillWritesToTheLog`, which has to use the real one to prove +/// that the substitution did not silence production. final class HealthAlertTests: XCTestCase { - private var katalog: URL! - private var plikStanu: URL! - private var dziennik: PrzechwyconyDziennik! + private var directory: URL! + private var stateFile: URL! + private var log: CapturedLog! override func setUpWithError() throws { - katalog = FileManager.default.temporaryDirectory + directory = FileManager.default.temporaryDirectory .appendingPathComponent("cm-health-alert-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - plikStanu = katalog.appendingPathComponent("health-alert.json") - dziennik = PrzechwyconyDziennik() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + stateFile = directory.appendingPathComponent("health-alert.json") + log = CapturedLog() } override func tearDownWithError() throws { - try? FileManager.default.removeItem(at: katalog) + try? FileManager.default.removeItem(at: directory) + L10n.language = .en } - private func raport(_ summaries: [String], lastSuccess: Date? = nil) -> BackupHealth.Report { + private func healthReport(_ summaries: [String], lastSuccess: Date? = nil) + -> BackupHealth.Report + { BackupHealth.Report( - problems: summaries.map { BackupHealth.Problem(summary: $0, detail: "szczegoly") }, + problems: summaries.map { BackupHealth.Problem(summary: $0, detail: "details") }, lastSuccess: lastSuccess, lastAttempt: nil) } - /// `HealthAlert.report` z podstawionym plikiem stanu I podstawionym - /// dziennikiem. Wolamy to zamiast `HealthAlert.report` wprost, zeby nie dalo - /// sie dopisac testu, ktory przez zapomnienie jednego argumentu znow zacznie - /// zasmiecac produkcyjny `cloudmachine.log`. + /// `HealthAlert.report` with a substituted state file AND a substituted log. + /// We call this instead of `HealthAlert.report` directly, so that no test can + /// be added that, by forgetting one argument, starts cluttering the + /// production `cloudmachine.log` again. @discardableResult - private func zglos( + private func report( _ report: BackupHealth.Report, now: Date = Date(), deliver: @escaping @Sendable (String, String) async -> Bool ) async -> Bool { await HealthAlert.report( - report, now: now, stateFile: plikStanu, deliver: deliver, log: dziennik.zapisz) + report, now: now, stateFile: stateFile, deliver: deliver, log: log.write) } - // MARK: - Ustalenie 3: testy nie pisza do produkcyjnego dziennika - - /// TA usterka. `report()` logowalo przez `CMLogger.log(...)` na sztywno, - /// wiec podstawic dalo sie plik stanu i doreczenie, ale nie dziennik. - /// Zmierzone 25.09.2026: `~/Library/Logs/CloudMachine/cloudmachine.log` - /// zawieral 117 linii ze slowem "szczegoly", ktore pochodzi wylacznie - /// z `raport(_:)` powyzej - wszystkie z jednego dnia, czyli z przebiegow - /// `swift test`. Produkcyjny dziennik jest jedynym sladem po awariach - /// backupu i przestal pozwalac odroznic zdarzenia prawdziwe od testowych. - func testZgloszenieZPodstawionymDziennikiemNieDotykaProdukcyjnego() async { - let znacznik = "CM-TEST-\(UUID().uuidString)" + // MARK: - Finding 3: tests do not write to the production log + + /// THAT defect. `report()` logged via a hard-wired `CMLogger.log(...)`, so + /// the state file and the delivery could be substituted, but not the log. + /// Measured 25.09.2026: `~/Library/Logs/CloudMachine/cloudmachine.log` + /// contained 117 lines with the word "szczegoly" (Polish for "details"), + /// which came only from this class's report helper - all from one day, i.e. + /// from `swift test` runs. The production log is the only trace of backup + /// failures and it stopped allowing real events to be told apart from test + /// ones. + func testReportWithSubstitutedLogDoesNotTouchTheProductionOne() async { + let marker = "CM-TEST-\(UUID().uuidString)" XCTAssertEqual( - liniiWProdukcyjnymDzienniku(z: znacznik), 0, - "znacznik jest swiezym UUID - przed testem nie moze go tam byc") + linesInProductionLog(containing: marker), 0, + "the marker is a fresh UUID - it cannot be there before the test") - let doreczone = await zglos( - raport(["Brak udanej kopii od 5 h \(znacznik)"]), deliver: { _, _ in false }) - XCTAssertFalse(doreczone) + let delivered = await report( + healthReport(["No successful backup for 5 h \(marker)"]), deliver: { _, _ in false }) + XCTAssertFalse(delivered) - // Tresc MA powstac - tylko nie w produkcyjnym pliku. Gdyby zniknela, - // "naprawa" polegalaby na uciszeniu alarmu, a nie na przekierowaniu go. + // The content MUST be produced - just not in the production file. If it + // disappeared, the "fix" would consist of silencing the alarm, not of + // redirecting it. XCTAssertTrue( - dziennik.linie.contains { $0.contains(znacznik) }, - "podstawiony dziennik ma dostac tresc zgloszenia: \(dziennik.linie)") + log.lines.contains { $0.contains(marker) }, + "the substituted log must receive the report content: \(log.lines)") XCTAssertTrue( - dziennik.linie.contains { $0.contains("NIE UDALO SIE pokazac powiadomienia") }, - "nieudane doreczenie tez musi byc zapisane - tam, gdzie test je widzi") + log.lines.contains { $0.contains("FAILED to show the backup failure notification") }, + "a failed delivery must also be recorded - where the test can see it") XCTAssertEqual( - liniiWProdukcyjnymDzienniku(z: znacznik), 0, - "test nie moze dopisac ani jednej linii do \(CMPaths.combinedLogFile.path)") + linesInProductionLog(containing: marker), 0, + "the test must not append a single line to \(CMPaths.combinedLogFile.path)") } - /// Druga strona tej samej poprawki - i jedyny test w tej klasie, ktory - /// SWIADOMIE pisze do produkcyjnego dziennika (jedna linia, oznaczona jako - /// kanarka). + /// The other side of the same fix - and the only test in this class that + /// DELIBERATELY writes to the production log (one line, marked as a canary). /// - /// Bez tego testu poprawka mogla uciszyc PRAWDZIWE alarmy i nikt by tego nie - /// zauwazyl: awaria backupu nie przeszkadza w codziennej pracy, a dziennik - /// jest jedynym sladem, po ktorym da sie ja pozniej odtworzyc. Cisza w logu - /// wygladalaby dokladnie tak samo jak dzialajacy backup. - func testPrawdziwyPrzebiegNadalPiszeDoDziennika() async throws { - let znacznik = "KANARKA-TESTU-\(UUID().uuidString)" - let kanarka = BackupHealth.Report( + /// Without this test the fix could have silenced REAL alarms and nobody + /// would have noticed: a backup failure does not get in the way of daily + /// work, and the log is the only trace from which it can be reconstructed + /// later. Silence in the log would look exactly the same as a working + /// backup. + func testRealRunStillWritesToTheLog() async throws { + let marker = "TEST-CANARY-\(UUID().uuidString)" + let canary = BackupHealth.Report( problems: [ BackupHealth.Problem( - summary: "to nie byla awaria, to kanarka testu \(znacznik)", + summary: "this was not a failure, it is a test canary \(marker)", detail: - "linie dopisal HealthAlertTests, zeby dowiesc, ze zgloszenie bez podstawionego dziennika nadal trafia do cloudmachine.log" + "the line was appended by HealthAlertTests to prove that a report without a substituted log still reaches cloudmachine.log" ) ], lastSuccess: nil, lastAttempt: nil) - XCTAssertEqual(liniiWProdukcyjnymDzienniku(z: znacznik), 0) + XCTAssertEqual(linesInProductionLog(containing: marker), 0) - // `log:` NIE jest podstawiane - to sedno testu. `deliver:` jest, i tylko - // dlatego, ze inaczej na ekranie uzytkownika wyskoczyloby powiadomienie - // o awarii, ktorej nie ma; zwracamy `true`, zeby nie doszla do dziennika - // druga linia (o nieudanym doreczeniu). + // `log:` is NOT substituted - that is the core of the test. `deliver:` is, + // and only because otherwise a notification about a failure that does not + // exist would pop up on the user's screen; we return `true` so that a + // second line (about a failed delivery) does not reach the log. await HealthAlert.report( - kanarka, stateFile: plikStanu, deliver: { _, _ in true }) + canary, stateFile: stateFile, deliver: { _, _ in true }) XCTAssertEqual( - liniiWProdukcyjnymDzienniku(z: znacznik), 1, + linesInProductionLog(containing: marker), 1, """ - Prawdziwe zgloszenie MUSI trafic do \(CMPaths.combinedLogFile.path). \ - Jesli tu jest 0, to poprawka uciszyla alarm zamiast go przekierowac. + A real report MUST reach \(CMPaths.combinedLogFile.path). \ + If this is 0, the fix silenced the alarm instead of redirecting it. """) } - /// Ile linii ogona produkcyjnego dziennika zawiera `znacznik`. + /// How many lines of the tail of the production log contain `marker`. /// - /// Czytamy OGON, a nie caly plik: `CMLogger.rotateIfLarge` przycina go - /// dopiero przy 200 MB, wiec wciagniecie calosci do pamieci w tescie to - /// zaproszenie do testu, ktory z czasem zaczyna trwac sekundy. Znacznik jest - /// swiezym UUID, wiec interesuja nas wylacznie linie dopisane w trakcie tego - /// przebiegu - te zawsze sa na koncu. - private func liniiWProdukcyjnymDzienniku(z znacznik: String) -> Int { - let plik = CMPaths.combinedLogFile - guard let handle = try? FileHandle(forReadingFrom: plik) else { return 0 } + /// We read the TAIL, not the whole file: `CMLogger.rotateIfLarge` trims it + /// only at 200 MB, so pulling the whole thing into memory in a test invites a + /// test that over time starts taking seconds. The marker is a fresh UUID, so + /// we are interested only in lines appended during this run - those are + /// always at the end. + private func linesInProductionLog(containing marker: String) -> Int { + let file = CMPaths.combinedLogFile + guard let handle = try? FileHandle(forReadingFrom: file) else { return 0 } defer { try? handle.close() } - let rozmiar = (try? handle.seekToEnd()) ?? 0 - let ogon: UInt64 = 256 * 1024 - try? handle.seek(toOffset: rozmiar > ogon ? rozmiar - ogon : 0) - guard let dane = try? handle.readToEnd(), let tekst = String(data: dane, encoding: .utf8) + let size = (try? handle.seekToEnd()) ?? 0 + let tail: UInt64 = 256 * 1024 + try? handle.seek(toOffset: size > tail ? size - tail : 0) + guard let data = try? handle.readToEnd(), let text = String(data: data, encoding: .utf8) else { return 0 } - return tekst.split(separator: "\n").filter { $0.contains(znacznik) }.count + return text.split(separator: "\n").filter { $0.contains(marker) }.count } - // MARK: - Punkt 6: nieudane powiadomienie nie jest zgloszeniem + // MARK: - Item 6: a failed notification is not a report - /// TA awaria. `osascript` pada (odmowa uprawnien dla procesu launchd, brak - /// sesji Aqua, limit czasu), a `report()` i tak zapisywalo `lastSummary` - /// i `lastAlertAt` oraz zwracalo `true`. Od tej chwili `shouldAlert` - /// blokowalo kolejne proby na 12 godzin - alarm ginal po cichu, czyli - /// nadzor ginal razem z nadzorowanym. - func testNieudanePowiadomienieNieUciszaAlarmu() async { - let problemy = raport(["Brak udanej kopii od 5 h"]) + /// THAT failure. `osascript` fails (permission refused for the launchd + /// process, no Aqua session, time limit), and `report()` still wrote + /// `lastSummary` and `lastAlertAt` and returned `true`. From that moment + /// `shouldAlert` blocked further attempts for 12 hours - the alarm vanished + /// silently, i.e. the supervision died together with the supervised. + func testFailedNotificationDoesNotSilenceTheAlarm() async { + let problems = healthReport(["No successful backup for 5 h"]) - let pierwsze = await zglos(problemy, deliver: { _, _ in false }) - XCTAssertFalse(pierwsze, "Zgloszenie, ktorego nikt nie zobaczyl, nie jest zgloszeniem.") + let first = await report(problems, deliver: { _, _ in false }) + XCTAssertFalse(first, "A report nobody saw is not a report.") - // Piec minut pozniej, ten sam problem: MUSI sprobowac jeszcze raz, - // a nie czekac 12 godzin. - let sprobowanoPonownie = LicznikProb() - let drugie = await zglos( - problemy, now: Date().addingTimeInterval(300), + // Five minutes later, the same problem: it MUST try again, not wait 12 + // hours. + let retried = AttemptCounter() + let second = await report( + problems, now: Date().addingTimeInterval(300), deliver: { _, _ in - sprobowanoPonownie.zwieksz() + retried.increment() return true }) - XCTAssertEqual(sprobowanoPonownie.ile, 1, "Po nieudanej probie alarm ma wrocic.") - XCTAssertTrue(drugie) + XCTAssertEqual(retried.count, 1, "After a failed attempt the alarm must come back.") + XCTAssertTrue(second) } - /// Nieudane doreczenie ma byc WIDOCZNE - alarmu, ktory nie doszedl, nikt - /// nie zauwazy z definicji, wiec musi dac sie go zobaczyc tam, gdzie - /// czlowiek zaglada sam (`drive-status`). - func testNieudaneDoreczenieDaSieOdczytac() async { - let kiedy = Date(timeIntervalSince1970: 1_758_000_000) - await zglos( - raport(["Obraz backupu nie jest podpiety"]), now: kiedy, deliver: { _, _ in false }) - - let awaria = HealthAlert.lastDeliveryFailure(stateFile: plikStanu) - XCTAssertNotNil(awaria) - XCTAssertEqual(awaria?.at, kiedy) - XCTAssertEqual(awaria?.summary, "Obraz backupu nie jest podpiety") - - // Po udanym doreczeniu slad znika - inaczej wisialby tam na zawsze. - await zglos( - raport(["Obraz backupu nie jest podpiety"]), now: kiedy.addingTimeInterval(3600), + /// A failed delivery must be VISIBLE - an alarm that did not arrive will by + /// definition not be noticed, so it must be possible to see it where a + /// person looks on their own (`drive-status`). + func testFailedDeliveryCanBeRead() async { + let when = Date(timeIntervalSince1970: 1_758_000_000) + await report( + healthReport(["The backup image is not attached"]), now: when, deliver: { _, _ in false }) + + let failure = HealthAlert.lastDeliveryFailure(stateFile: stateFile) + XCTAssertNotNil(failure) + XCTAssertEqual(failure?.at, when) + XCTAssertEqual(failure?.summary, "The backup image is not attached") + + // After a successful delivery the trace disappears - otherwise it would + // hang there forever. + await report( + healthReport(["The backup image is not attached"]), now: when.addingTimeInterval(3600), deliver: { _, _ in true }) - XCTAssertNil(HealthAlert.lastDeliveryFailure(stateFile: plikStanu)) + XCTAssertNil(HealthAlert.lastDeliveryFailure(stateFile: stateFile)) } - // MARK: - Punkt 8: odstep miedzy przypomnieniami + // MARK: - Item 8: the gap between reminders - /// TA usterka. Tekst problemu zawiera wiek awarii ("Brak udanej kopii od - /// 3 h"), wiec przy trwajacej awarii zmienial sie CO GODZINE. Warunek - /// "inny tekst = nowy problem" byl wtedy spelniony przy kazdym przebiegu - /// i powiadomienie wracalo co godzine zamiast raz na dwanascie - a alarm - /// bez odstepu zamienia sie w szum i przestaje cokolwiek znaczyc. - func testRosnacyWiekAwariiNieJestNowymProblemem() async { + /// THAT defect. The problem text contains the age of the failure ("No + /// successful backup for 3 h"), so during an ongoing failure it changed + /// EVERY HOUR. The condition "different text = new problem" was then met on + /// every run and the notification came back every hour instead of once + /// every twelve - and an alarm without a gap turns into noise and stops + /// meaning anything. + func testGrowingFailureAgeIsNotANewProblem() async { let start = Date(timeIntervalSince1970: 1_758_000_000) - let doreczone = await zglos( - raport(["Brak udanej kopii od 3 h"]), now: start, deliver: { _, _ in true }) - XCTAssertTrue(doreczone) - - // Godzine pozniej ta sama awaria opisuje sie innym tekstem. - let licznik = LicznikProb() - let znowu = await zglos( - raport(["Brak udanej kopii od 4 h"]), now: start.addingTimeInterval(3600), + let delivered = await report( + healthReport(["No successful backup for 3 h"]), now: start, deliver: { _, _ in true }) + XCTAssertTrue(delivered) + + // An hour later the same failure describes itself with a different text. + let counter = AttemptCounter() + let again = await report( + healthReport(["No successful backup for 4 h"]), now: start.addingTimeInterval(3600), deliver: { _, _ in - licznik.zwieksz() + counter.increment() return true }) - XCTAssertEqual(licznik.ile, 0, "To ta sama awaria, tylko starsza - nie alarmujemy od nowa.") - XCTAssertFalse(znowu) + XCTAssertEqual(counter.count, 0, "It is the same failure, just older - we do not alarm anew.") + XCTAssertFalse(again) } - /// Po okresie przypomnienia ta sama awaria ma sie odezwac ponownie - - /// inaczej alarm zapala sie raz i gasnie na zawsze. - func testPoOkresiePrzypomnieniaTaSamaAwariaWraca() { + /// After the reminder period the same failure must speak up again - + /// otherwise the alarm lights up once and goes out forever. + func testAfterTheReminderPeriodTheSameFailureComesBack() { let start = Date(timeIntervalSince1970: 1_758_000_000) - zapisz( - identity: HealthAlert.identity(of: raport(["Brak udanej kopii od 3 h"]).problems), + save( + identity: HealthAlert.identity(of: healthReport(["No successful backup for 3 h"]).problems), at: start) - let odcisk = HealthAlert.identity(of: raport(["Brak udanej kopii od 15 h"]).problems) + let fingerprint = HealthAlert.identity( + of: healthReport(["No successful backup for 15 h"]).problems) XCTAssertFalse( HealthAlert.shouldAlert( - identity: odcisk, now: start.addingTimeInterval(11 * 3600), stateFile: plikStanu), - "Przed uplywem 12 h milczymy.") + identity: fingerprint, now: start.addingTimeInterval(11 * 3600), stateFile: stateFile), + "Before 12 h have passed we stay silent.") XCTAssertTrue( HealthAlert.shouldAlert( - identity: odcisk, now: start.addingTimeInterval(13 * 3600), stateFile: plikStanu), - "Po 12 h przypominamy - awaria trwa, dopoki ktos jej nie naprawi.") + identity: fingerprint, now: start.addingTimeInterval(13 * 3600), stateFile: stateFile), + "After 12 h we remind - the failure lasts until someone fixes it.") } - /// NOWY problem dolozony do listy musi zaalarmowac od razu, bez czekania na - /// okno przypomnienia. Bez tego testu "naprawa" uciszajaca wszystko na - /// 12 godzin przeszlaby niezauwazona. - func testNowyProblemAlarmujeOdRazu() { + /// A NEW problem added to the list must alarm right away, without waiting + /// for the reminder window. Without this test a "fix" silencing everything + /// for 12 hours would go unnoticed. + func testNewProblemAlarmsRightAway() { let start = Date(timeIntervalSince1970: 1_758_000_000) - zapisz( - identity: HealthAlert.identity(of: raport(["Brak udanej kopii od 3 h"]).problems), + save( + identity: HealthAlert.identity(of: healthReport(["No successful backup for 3 h"]).problems), at: start) - let dwaProblemy = HealthAlert.identity( - of: raport(["Brak udanej kopii od 4 h", "Obraz backupu nie jest podpiety"]).problems) + let twoProblems = HealthAlert.identity( + of: healthReport(["No successful backup for 4 h", "The backup image is not attached"]) + .problems) XCTAssertTrue( HealthAlert.shouldAlert( - identity: dwaProblemy, now: start.addingTimeInterval(600), stateFile: plikStanu)) + identity: twoProblems, now: start.addingTimeInterval(600), stateFile: stateFile)) } - /// Sam odcisk: liczby znikaja, tresc zostaje. - func testOdciskWycinaLiczbyAleNieTresc() { + /// The fingerprint itself: numbers disappear, content stays. + func testFingerprintCutsNumbersButNotContent() { XCTAssertEqual( - HealthAlert.fingerprint("Brak udanej kopii od 3 h"), - HealthAlert.fingerprint("Brak udanej kopii od 27 h")) + HealthAlert.fingerprint("No successful backup for 3 h"), + HealthAlert.fingerprint("No successful backup for 27 h")) XCTAssertNotEqual( - HealthAlert.fingerprint("Brak udanej kopii od 3 h"), - HealthAlert.fingerprint("Obraz backupu nie jest podpiety")) - // Dwa RONE problemy roznia sie tylko liczba w nawiasie - to nadal ten - // sam rodzaj awarii i nie ma powodu alarmowac od nowa przy kazdym GB. + HealthAlert.fingerprint("No successful backup for 3 h"), + HealthAlert.fingerprint("The backup image is not attached")) + // Two DIFFERENT problems differ only by the number in parentheses - it is + // still the same kind of failure and there is no reason to alarm anew at + // every GB. XCTAssertEqual( - HealthAlert.fingerprint("Konczy sie miejsce na Google Drive (28 GB)"), - HealthAlert.fingerprint("Konczy sie miejsce na Google Drive (12 GB)")) + HealthAlert.fingerprint("Google Drive is running out of space (28 GB)"), + HealthAlert.fingerprint("Google Drive is running out of space (12 GB)")) } - /// Wyzdrowienie kasuje stan, zeby nastepna awaria zglosila sie od razu. - func testWyzdrowienieKasujeStan() async { - await zglos(raport(["Brak udanej kopii od 3 h"]), deliver: { _, _ in true }) - XCTAssertTrue(FileManager.default.fileExists(atPath: plikStanu.path)) + /// Recovery deletes the state, so that the next failure is reported right + /// away. + func testRecoveryDeletesTheState() async { + await report(healthReport(["No successful backup for 3 h"]), deliver: { _, _ in true }) + XCTAssertTrue(FileManager.default.fileExists(atPath: stateFile.path)) - await zglos(raport([]), deliver: { _, _ in true }) - XCTAssertFalse(FileManager.default.fileExists(atPath: plikStanu.path)) + await report(healthReport([]), deliver: { _, _ in true }) + XCTAssertFalse(FileManager.default.fileExists(atPath: stateFile.path)) } - // MARK: - Pomocnicze + // MARK: - The UI language does not change identity or persisted state + + /// The same failure seen by a Polish-language run and by an English-language + /// run must have the same identity. Otherwise switching the system language + /// - or the GUI and a launchd agent running with different languages - + /// would look like a new problem and break the 12-hour quiet window. + func testIdentityDoesNotDependOnTheUILanguage() { + let now = Date(timeIntervalSince1970: 1_758_000_000) + func evaluated() -> BackupHealth.Report { + BackupHealth.evaluate( + lastSuccess: now.addingTimeInterval(-5 * 3600), lastAttempt: nil, result: 0, now: now, + mounted: true, attached: false, destinationRegistered: nil, erroredFiles: 3, + outOfSpace: false, queueReadable: true) + } + + L10n.language = .pl + let polish = evaluated() + L10n.language = .en + let english = evaluated() + + XCTAssertNotEqual( + polish.problems.map(\.summary), english.problems.map(\.summary), + "the summaries are translated - otherwise this test checks nothing") + XCTAssertEqual( + HealthAlert.identity(of: polish.problems), HealthAlert.identity(of: english.problems)) + } + + /// The delivery-failure reason is written in a language-independent form + /// and translated only when shown. + func testDeliveryFailureReasonIsPersistedLanguageIndependently() async throws { + L10n.language = .pl + await report(healthReport(["No successful backup for 5 h"]), deliver: { _, _ in false }) + L10n.language = .en + + let state = try XCTUnwrap(HealthAlert.loadState(stateFile)) + XCTAssertEqual(state.deliveryError, HealthAlert.osascriptFailureReason) + XCTAssertEqual( + HealthAlert.lastDeliveryFailure(stateFile: stateFile)?.reason, + "osascript did not show the notification (permissions or no graphical session)") + } + + // MARK: - Helpers - private func zapisz(identity: String, at date: Date) { - let stan = HealthAlert.AlertState( - lastSummary: "nieistotne", lastAlertAt: date, lastIdentity: identity, delivered: true, + private func save(identity: String, at date: Date) { + let state = HealthAlert.AlertState( + lastSummary: "irrelevant", lastAlertAt: date, lastIdentity: identity, delivered: true, deliveryError: nil) - let dane = try! JSONEncoder().encode(stan) - try! dane.write(to: plikStanu) + let data = try! JSONEncoder().encode(state) + try! data.write(to: stateFile) } - /// Dziennik zbierany do pamieci. Klasa, bo domkniecie `log` jest `@Sendable`. - private final class PrzechwyconyDziennik: @unchecked Sendable { + /// A log collected in memory. A class, because the `log` closure is + /// `@Sendable`. + private final class CapturedLog: @unchecked Sendable { private let lock = NSLock() - private var zebrane: [String] = [] + private var collected: [String] = [] - /// Referencja do metody idzie wprost jako argument `log:`. - func zapisz(_ linia: String) { + /// The method reference is passed directly as the `log:` argument. + func write(_ line: String) { lock.lock() - zebrane.append(linia) + collected.append(line) lock.unlock() } - var linie: [String] { + var lines: [String] { lock.lock() defer { lock.unlock() } - return zebrane + return collected } } - /// Licznik prob doreczenia. Klasa, bo domkniecie `deliver` jest `@Sendable`. - private final class LicznikProb: @unchecked Sendable { + /// Counter of delivery attempts. A class, because the `deliver` closure is + /// `@Sendable`. + private final class AttemptCounter: @unchecked Sendable { private let lock = NSLock() - private var licznik = 0 - func zwieksz() { + private var value = 0 + func increment() { lock.lock() - licznik += 1 + value += 1 lock.unlock() } - var ile: Int { + var count: Int { lock.lock() defer { lock.unlock() } - return licznik + return value } } } diff --git a/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift b/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift index 7b1a664..758aee9 100644 --- a/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift @@ -3,79 +3,82 @@ import XCTest @testable import CloudMachineCore -/// Sonda czytelnosci obrazu. Kazdy werdykt ma probke, ktora go wymusza - -/// inaczej sonda mowiaca zawsze "readable" przeszlaby wszystkie testy. +/// The image readability probe. Every verdict has a sample that forces it - +/// otherwise a probe always saying "readable" would pass all the tests. final class ImageProbeTests: XCTestCase { private let manifest = URL(fileURLWithPath: "/Volumes/X/backup_manifest.plist") private let other = URL(fileURLWithPath: "/Volumes/X/other.plist") - func testOdczytBajtuZnaczyZywy() { + func testReadingAByteMeansAlive() { let verdict = ImageProbe.probe(regularFiles: { [manifest] }, readFirstByte: { _ in nil }) XCTAssertEqual(verdict, .readable) } - /// Dokladnie ten przypadek z 22 wrz 2026: listowanie dziala, odczyt daje ENXIO. - func testENXIOZnaczyMartwy() { + /// Exactly the case of 22 Sep 2026: listing works, reading gives ENXIO. + func testENXIOMeansDead() { let verdict = ImageProbe.probe(regularFiles: { [manifest] }, readFirstByte: { _ in ENXIO }) XCTAssertEqual(verdict, .dead(errno: ENXIO)) } - func testEIOTezZnaczyMartwy() { + func testEIOAlsoMeansDead() { let verdict = ImageProbe.probe(regularFiles: { [manifest] }, readFirstByte: { _ in EIO }) XCTAssertEqual(verdict, .dead(errno: EIO)) } - /// Blad wlasciwy dla pliku (brak uprawnien) nie jest awaria wolumenu - - /// sonda ma sprobowac nastepnego pliku, a nie oglosic smierci. - func testEACCESNaJednymPlikuNieZnaczyMartwy() { + /// An error specific to a file (no permission) is not a volume failure - + /// the probe should try the next file, not declare death. + func testEACCESOnOneFileDoesNotMeanDead() { let verdict = ImageProbe.probe( regularFiles: { [manifest, other] }, readFirstByte: { $0 == self.manifest ? EACCES : nil }) XCTAssertEqual(verdict, .readable) } - func testSamePlikiZBledamiPlikuToBrakProbki() { + func testOnlyFilesWithFileErrorsMeansNothingToProbe() { let verdict = ImageProbe.probe(regularFiles: { [manifest] }, readFirstByte: { _ in EACCES }) XCTAssertEqual(verdict, .nothingToProbe) } - /// Swiezy wolumen przed pierwsza kopia - nie ma czego czytac, wiec NIE - /// alarmujemy. Alarm bez dowodu jest gorszy niz brak alarmu. - func testPustyKatalogToBrakProbki() { + /// A fresh volume before the first backup - there is nothing to read, so we + /// do NOT alarm. An alarm without proof is worse than no alarm. + func testEmptyDirectoryMeansNothingToProbe() { let verdict = ImageProbe.probe(regularFiles: { [] }, readFirstByte: { _ in ENXIO }) XCTAssertEqual(verdict, .nothingToProbe) } - // MARK: - Listowanie tez umie pasc - - /// ZMIANA wzgledem poprzedniej wersji tego testu, ktora nazywala sie - /// `testNieczytelneListowanieToBrakProbki` i sprawdzala, ze KAZDY blad - /// listowania daje `.nothingToProbe`. Kodowala stan, ktory okazal sie - /// dziura: `BackupImageService.attachment` mapuje `.nothingToProbe` na - /// `.attached`, wiec martwy obraz uchodzil za zywy, a agent `gdrive-attach` - /// nie podpinal go przez godziny. Rozstrzyga teraz ZRODLO bledu, nie sam - /// fakt bledu: blad bez rozpoznanego errno urzadzenia nadal nie dowodzi - /// niczego o wolumenie i zostaje `.nothingToProbe`. - func testListowanieZBledemBezErrnoUrzadzeniaToBrakProbki() { + // MARK: - Listing can fail too + + /// A CHANGE compared with the previous version of this test, which was + /// called `testNieczytelneListowanieToBrakProbki` and checked that EVERY + /// listing error gives `.nothingToProbe`. It encoded a state that turned out + /// to be a hole: `BackupImageService.attachment` maps `.nothingToProbe` to + /// `.attached`, so a dead image passed for a live one, and the + /// `gdrive-attach` agent did not reattach it for hours. What decides now is + /// the SOURCE of the error, not the mere fact of an error: an error without + /// a recognized device errno still proves nothing about the volume and stays + /// `.nothingToProbe`. + func testListingErrorWithoutDeviceErrnoMeansNothingToProbe() { struct Boom: Error {} let verdict = ImageProbe.probe(regularFiles: { throw Boom() }, readFirstByte: { _ in nil }) XCTAssertEqual(verdict, .nothingToProbe) } - /// Po wygasnieciu `--dir-cache-time 5m` listowanie przestaje chodzic z cache - /// jadra i pada tym samym ENXIO, co odczyt. Wtedy jest juz dowodem smierci. - func testENXIONaListowaniuZnaczyMartwy() { + /// After `--dir-cache-time 5m` expires, listing stops running from the + /// kernel cache and fails with the same ENXIO as reading. Then it is proof + /// of death. + func testENXIOOnListingMeansDead() { let verdict = ImageProbe.probe( regularFiles: { throw POSIXError(.ENXIO) }, readFirstByte: { _ in nil }) XCTAssertEqual(verdict, .dead(errno: ENXIO)) } - /// Tak wyglada ten sam blad, gdy rzuca go Foundation: `contentsOfDirectory` - /// opakowuje errno w `NSCocoaErrorDomain` i chowa oryginal pod - /// `NSUnderlyingErrorKey`. Sonda musi rozpoznac obie postacie, bo zywa - /// sciezka (`regularFiles(in:)`) chodzi wlasnie przez Foundation. - func testENXIOOpakowaneDoNSErrorTezZnaczyMartwy() { + /// This is what the same error looks like when Foundation throws it: + /// `contentsOfDirectory` wraps the errno in `NSCocoaErrorDomain` and hides + /// the original under `NSUnderlyingErrorKey`. The probe must recognize both + /// forms, because the live path (`regularFiles(in:)`) goes exactly through + /// Foundation. + func testENXIOWrappedInNSErrorAlsoMeansDead() { let underlying = NSError(domain: NSPOSIXErrorDomain, code: Int(ENXIO)) let cocoa = NSError( domain: NSCocoaErrorDomain, code: 256, @@ -85,176 +88,183 @@ final class ImageProbeTests: XCTestCase { XCTAssertEqual(verdict, .dead(errno: ENXIO)) } - /// Brak uprawnien do katalogu to wlasciwosc katalogu, nie awaria wolumenu. - func testEACCESNaListowaniuToBrakProbki() { + /// No permission on the directory is a property of the directory, not a + /// volume failure. + func testEACCESOnListingMeansNothingToProbe() { let verdict = ImageProbe.probe( regularFiles: { throw POSIXError(.EACCES) }, readFirstByte: { _ in nil }) XCTAssertEqual(verdict, .nothingToProbe) } - // MARK: - Zywa sciezka na prawdziwym katalogu + // MARK: - Live path on a real directory - func testPrawdziwyOdczytNaKataloguTymczasowym() async throws { + func testRealReadOnATemporaryDirectory() async throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("ImageProbeTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } - var werdykt = await ImageProbe.probe(volume: dir) - XCTAssertEqual(werdykt, .nothingToProbe, "pusty katalog") + var verdict = await ImageProbe.probe(volume: dir) + XCTAssertEqual(verdict, .nothingToProbe, "empty directory") try FileManager.default.createDirectory( - at: dir.appendingPathComponent("podkatalog"), withIntermediateDirectories: true) - werdykt = await ImageProbe.probe(volume: dir) - XCTAssertEqual(werdykt, .nothingToProbe, "sam podkatalog to nie plik") + at: dir.appendingPathComponent("subdirectory"), withIntermediateDirectories: true) + verdict = await ImageProbe.probe(volume: dir) + XCTAssertEqual(verdict, .nothingToProbe, "a subdirectory alone is not a file") - try Data("x".utf8).write(to: dir.appendingPathComponent("plik")) - werdykt = await ImageProbe.probe(volume: dir) - XCTAssertEqual(werdykt, .readable) + try Data("x".utf8).write(to: dir.appendingPathComponent("file")) + verdict = await ImageProbe.probe(volume: dir) + XCTAssertEqual(verdict, .readable) } - func testPustyPlikTezJestCzytelny() async throws { + func testEmptyFileIsAlsoReadable() async throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("ImageProbeTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } - try Data().write(to: dir.appendingPathComponent("pusty")) - let werdykt = await ImageProbe.probe(volume: dir) - XCTAssertEqual(werdykt, .readable) + try Data().write(to: dir.appendingPathComponent("empty")) + let verdict = await ImageProbe.probe(volume: dir) + XCTAssertEqual(verdict, .readable) } - // MARK: - Limit czasu + // MARK: - Time limit - /// Sonda na wolumenie FUSE-T potrafi NIGDY nie wrocic - `read()` zaklinowany - /// w jadrze (stan "U" w `ps`) nie da sie ani anulowac, ani ubic. Te testy - /// podstawiaja dokladnie taka sonde: zamiast czytac, czeka na semafor, ktory - /// puszczamy dopiero na koniec testu. + /// A probe on a FUSE-T volume can NEVER return - a `read()` stuck in the + /// kernel (state "U" in `ps`) can be neither cancelled nor killed. These + /// tests substitute exactly such a probe: instead of reading, it waits on a + /// semaphore that we release only at the end of the test. /// - /// KAZDY z nich ma WLASNY termin, niezalezny od limitu w sondzie. Gdyby - /// limit zniknal z kodu, `await` na sondzie nigdy by nie wrocil i test - /// wisialby do konca calego `swift test` - porazka po dziesiatkach minut - /// i bez jednego zdania o przyczynie. Z terminem porazka jest szybka - /// i czytelna: werdykt `nil` znaczy "sonda nie odpowiedziala nawet tyle". - private func werdykt( + /// EACH of them has its OWN deadline, independent of the limit in the + /// probe. If the limit disappeared from the code, the `await` on the probe + /// would never return and the test would hang until the end of the whole + /// `swift test` - a failure after tens of minutes and without a single + /// sentence about the cause. With a deadline the failure is fast and + /// readable: a `nil` verdict means "the probe did not answer even within + /// this much". + private func verdict( slot: String, timeout: TimeInterval, deadline: TimeInterval = 5, regularFiles: @escaping @Sendable () throws -> [URL], readFirstByte: @escaping @Sendable (URL) -> Int32? = { _ in nil } ) async -> ImageProbe.Verdict? { - let oddany = expectation(description: "sonda \(slot) oddala werdykt") - let pudelko = VerdictBox() + let delivered = expectation(description: "probe \(slot) delivered a verdict") + let box = VerdictBox() Task { - pudelko.set( + box.set( await ImageProbe.probe( slot: slot, timeout: timeout, regularFiles: regularFiles, readFirstByte: readFirstByte)) - oddany.fulfill() + delivered.fulfill() } - // `XCTWaiter`, a nie `await fulfillment(of:)`: ten drugi sam oblewa test - // przy przekroczeniu terminu, a my chcemy oblac go WLASNYM zdaniem - // mowiacym, ze sonda nie ma limitu czasu. - _ = XCTWaiter().wait(for: [oddany], timeout: deadline) - return pudelko.value + // `XCTWaiter`, not `await fulfillment(of:)`: the latter fails the test by + // itself when the deadline passes, and we want to fail it with our OWN + // sentence saying the probe has no time limit. + _ = XCTWaiter().wait(for: [delivered], timeout: deadline) + return box.value } - /// TO JEST TA POPRAWKA: sonda, ktora nie odpowiada, oddaje werdykt - /// w skonczonym czasie, a wolajacy przezywa i dziala dalej. - func testSondaBezOdpowiedziDajeTimedOutAWolajacyIdzieDalej() async throws { - let zablokowana = DispatchSemaphore(value: 0) - // Watek sondy siedzi w "read()" do konca testu - tak jak na prawdziwym - // martwym wolumenie. Puszczamy go na wyjsciu, zeby nie zostal na stale. - defer { zablokowana.signal() } + /// THIS IS THE FIX: a probe that does not answer delivers a verdict in + /// finite time, and the caller survives and keeps working. + func testUnansweredProbeGivesTimedOutAndTheCallerMovesOn() async throws { + let blocked = DispatchSemaphore(value: 0) + // The probe thread sits in "read()" until the end of the test - just like + // on a real dead volume. We release it on exit so it does not stay forever. + defer { blocked.signal() } let start = Date() - let wynik = await werdykt( - slot: "test-brak-odpowiedzi", timeout: 0.5, + let result = await verdict( + slot: "test-no-answer", timeout: 0.5, regularFiles: { - zablokowana.wait() + blocked.wait() return [] }) - let czekanie = Date().timeIntervalSince(start) + let waited = Date().timeIntervalSince(start) XCTAssertEqual( - wynik, .timedOut, - "sonda bez odpowiedzi musi oddac .timedOut - inaczej wolajacy wisi razem z nia") + result, .timedOut, + "a probe without an answer must deliver .timedOut - otherwise the caller hangs along with it" + ) XCTAssertLessThan( - czekanie, 3, "limit 0,5 s ma byc GORNYM ograniczeniem czekania, nie sugestia") + waited, 3, "the 0.5 s limit must be an UPPER bound on waiting, not a suggestion") - // Wolajacy nie tylko wrocil - NADAL DZIALA, mimo ze tamten watek wciaz - // siedzi w jadrze. To jest ta czesc, ktorej brak uciszal czujke na stale. - let katalog = FileManager.default.temporaryDirectory + // The caller not only returned - it STILL WORKS, even though that other + // thread is still sitting in the kernel. This is the part whose absence + // silenced the watchdog permanently. + let directory = FileManager.default.temporaryDirectory .appendingPathComponent("ImageProbeTests-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: katalog) } - try Data("x".utf8).write(to: katalog.appendingPathComponent("plik")) - let potem = await ImageProbe.probe(volume: katalog) - XCTAssertEqual(potem, .readable, "po poddaniu sie na jednym wolumenie sonda musi dalej dzialac") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try Data("x".utf8).write(to: directory.appendingPathComponent("file")) + let afterwards = await ImageProbe.probe(volume: directory) + XCTAssertEqual( + afterwards, .readable, "after giving up on one volume the probe must keep working") } - /// "Nie wiem" to NIE "obraz nieczytelny". Ta druga rzecz wyzwala w - /// `attach-image` odpiecie NA SILE, wiec zlanie ich w jeden werdykt - /// zamienialoby brak wiedzy w operacje nieodwracalna. - func testTimedOutToNieToSamoCoMartwy() { + /// "I do not know" is NOT "the image is unreadable". The latter triggers a + /// FORCED detach in `attach-image`, so merging them into one verdict would + /// turn a lack of knowledge into an irreversible operation. + func testTimedOutIsNotTheSameAsDead() { XCTAssertNotEqual(ImageProbe.Verdict.timedOut, .dead(errno: ENXIO)) XCTAssertNotEqual(ImageProbe.Verdict.timedOut, .dead(errno: EIO)) XCTAssertNotEqual(ImageProbe.Verdict.timedOut, .readable) XCTAssertNotEqual(ImageProbe.Verdict.timedOut, .nothingToProbe) } - /// Limit czasu nie moze polykac werdyktu sondy, ktora odpowiada WOLNO, ale - /// odpowiada - inaczej martwy obraz przestalby byc naprawiany. - func testWolnaAleOdpowiadajacaSondaDajeSwojWerdykt() async { - let wynik = await werdykt( - slot: "test-wolna", timeout: 3, + /// The time limit must not swallow the verdict of a probe that answers + /// SLOWLY, but does answer - otherwise a dead image would stop being fixed. + func testSlowButAnsweringProbeGivesItsVerdict() async { + let result = await verdict( + slot: "test-slow", timeout: 3, regularFiles: { Thread.sleep(forTimeInterval: 0.3) return [self.manifest] }, readFirstByte: { _ in ENXIO }) - XCTAssertEqual(wynik, .dead(errno: ENXIO)) + XCTAssertEqual(result, .dead(errno: ENXIO)) } - /// Jedna sonda na wolumen. Bez tego panel odswiezany co 10 s zostawialby na - /// zaklinowanym wolumenie po jednym wiszacym watku na przebieg. - func testDrugaSondaTegoSamegoWolumenuNieZakladaDrugiegoWatku() async { - let zablokowana = DispatchSemaphore(value: 0) - // Dwa razy, bo gdyby jedno-w-locie przestalo dzialac, zablokowane byly by - // DWA watki i kazdy potrzebuje wlasnego przebudzenia. + /// One probe per volume. Without it, a panel refreshed every 10 s would + /// leave one hanging thread per run on a stuck volume. + func testSecondProbeOfTheSameVolumeDoesNotStartASecondThread() async { + let blocked = DispatchSemaphore(value: 0) + // Twice, because if one-in-flight stopped working, TWO threads would be + // blocked and each needs its own wake-up. defer { - zablokowana.signal() - zablokowana.signal() + blocked.signal() + blocked.signal() } - let slot = "test-jedna-w-locie" - let pierwszy = await werdykt( + let slot = "test-one-in-flight" + let first = await verdict( slot: slot, timeout: 0.5, regularFiles: { - zablokowana.wait() + blocked.wait() return [] }) - XCTAssertEqual(pierwszy, .timedOut) + XCTAssertEqual(first, .timedOut) - // Pierwszy watek wciaz siedzi w jadrze. Drugi wolajacy ma dostac - // "nie wiem" OD RAZU - dlatego limit sondy jest tu absurdalnie dlugi - // (30 s), a termin testu krotki (2 s): jesli czekanie w ogole sie zacznie, - // test oblewa sie szybko, a nie po pol minuty. + // The first thread is still sitting in the kernel. The second caller must + // get "I do not know" RIGHT AWAY - that is why the probe limit here is + // absurdly long (30 s) and the test deadline short (2 s): if waiting + // starts at all, the test fails quickly, not after half a minute. let start = Date() - let drugi = await werdykt( + let second = await verdict( slot: slot, timeout: 30, deadline: 2, regularFiles: { - zablokowana.wait() + blocked.wait() return [] }) XCTAssertEqual( - drugi, .timedOut, - "druga sonda tego samego wolumenu ma oddac 'nie wiem' od razu, a nie czekac ani zakladac watku" + second, .timedOut, + "a second probe of the same volume must return 'I do not know' right away, not wait or start a thread" ) XCTAssertLessThan(Date().timeIntervalSince(start), 1) } } -/// Werdykt przenoszony z `Task`-a do ciala testu. Klasa z zamkiem, a nie -/// zmienna domknieta w zasiegu: zapis i odczyt dzieja sie na roznych watkach. +/// The verdict carried from the `Task` to the test body. A class with a lock +/// rather than a captured variable: the write and the read happen on +/// different threads. private final class VerdictBox: @unchecked Sendable { private let lock = NSLock() private var stored: ImageProbe.Verdict? diff --git a/mac-app/Tests/CloudMachineAppTests/L10nTests.swift b/mac-app/Tests/CloudMachineAppTests/L10nTests.swift new file mode 100644 index 0000000..ecea21d --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/L10nTests.swift @@ -0,0 +1,192 @@ +import XCTest + +@testable import CloudMachineCore + +/// Guards the rules in `L10n`: every key translated, placeholders matching, +/// and no Polish left in the code outside the Polish tables. +/// +/// These tests read the source tree, because the failure they look for is a +/// string that compiles fine and only shows up as English text on a Polish +/// Mac (or Polish text on an English one). +final class L10nTests: XCTestCase { + + // MARK: - Language detection + + func testEnvironmentOverridesSystemLanguage() { + XCTAssertEqual( + L10n.detectLanguage( + environment: ["CM_LANGUAGE": "PL"], preferredLanguages: ["en-US"], isRunningTests: true), + .pl) + XCTAssertEqual( + L10n.detectLanguage( + environment: ["CM_LANGUAGE": "en"], preferredLanguages: ["pl-PL"], isRunningTests: false), + .en) + } + + func testFollowsFirstPreferredLanguage() { + XCTAssertEqual( + L10n.detectLanguage( + environment: [:], preferredLanguages: ["pl-PL", "en-US"], isRunningTests: false), + .pl) + XCTAssertEqual( + L10n.detectLanguage( + environment: [:], preferredLanguages: ["en-PL", "pl-PL"], isRunningTests: false), + .en) + XCTAssertEqual( + L10n.detectLanguage(environment: [:], preferredLanguages: ["de-DE"], isRunningTests: false), + .en) + XCTAssertEqual( + L10n.detectLanguage(environment: [:], preferredLanguages: [], isRunningTests: false), .en) + } + + func testTestsRunInEnglishWhateverTheSystemSays() { + XCTAssertEqual( + L10n.detectLanguage(environment: [:], preferredLanguages: ["pl-PL"], isRunningTests: true), + .en) + } + + func testFormatsPlaceholders() { + XCTAssertEqual(L10n.tr("%@ of %@", "1", "2"), "1 of 2") + XCTAssertEqual(L10n.tr("no placeholders"), "no placeholders") + } + + // MARK: - Source scan + + private static let macAppRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // CloudMachineAppTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // mac-app + + private static func swiftFiles(under directory: String) -> [URL] { + let root = macAppRoot.appendingPathComponent(directory) + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) + else { return [] } + return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } + } + + private static func relative(_ url: URL) -> String { + String(url.path.dropFirst(macAppRoot.path.count + 1)) + } + + /// Keys as the compiler sees them, from every `L10n.tr("...")` in Sources. + private static func usedKeys() throws -> (keys: [String: String], problems: [String]) { + var keys: [String: String] = [:] + var problems: [String] = [] + let call = try NSRegularExpression(pattern: #"L10n\.tr\(\s*("(?:[^"\\]|\\.)*")?"#) + for file in swiftFiles(under: "Sources") { + let text = try String(contentsOf: file, encoding: .utf8) + let range = NSRange(text.startIndex..., in: text) + for match in call.matches(in: text, range: range) { + let line = text[.. String { + var result = "" + var iterator = literal.makeIterator() + while let character = iterator.next() { + guard character == "\\", let next = iterator.next() else { + result.append(character) + continue + } + switch next { + case "n": result.append("\n") + case "t": result.append("\t") + default: result.append(next) + } + } + return result + } + + private static func placeholderCount(_ text: String) -> Int { + text.components(separatedBy: "%@").count - 1 + + (try! NSRegularExpression(pattern: #"%\d+\$@"#)) + .numberOfMatches(in: text, range: NSRange(text.startIndex..., in: text)) + } + + func testEveryKeyIsTranslatedWithMatchingPlaceholders() throws { + let (keys, problems) = try Self.usedKeys() + var failures = problems + for (key, place) in keys.sorted(by: { $0.value < $1.value }) { + guard let polish = L10n.polish[key] else { + failures.append("\(place): no Polish translation for \"\(key)\"") + continue + } + if Self.placeholderCount(polish) != Self.placeholderCount(key) { + failures.append("\(place): placeholder count differs for \"\(key)\" -> \"\(polish)\"") + } + } + XCTAssert(failures.isEmpty, "\n" + failures.joined(separator: "\n")) + } + + func testNoUnusedPolishEntries() throws { + let used = Set(try Self.usedKeys().keys.keys) + let unused = L10n.polish.keys.filter { !used.contains($0) }.sorted() + XCTAssert(unused.isEmpty, "Polish entries no code uses:\n" + unused.joined(separator: "\n")) + } + + func testTablesDoNotDisagree() { + var seen: [String: String] = [:] + var conflicts: [String] = [] + for table in L10n.polishTables { + for (key, value) in table { + if let earlier = seen[key], earlier != value { + conflicts.append("\"\(key)\": \"\(earlier)\" vs \"\(value)\"") + } + seen[key] = value + } + } + XCTAssert(conflicts.isEmpty, "\n" + conflicts.joined(separator: "\n")) + } + + // MARK: - Polish left in the code + + /// Diacritics (as ICU `\uXXXX` escapes), plus common Polish words that have + /// no English homograph. The word lines carry the allow marker, because a + /// list of Polish words is necessarily Polish. + private static let polishPattern = + #"[\u0105\u0107\u0119\u0142\u0144\u00F3\u015B\u017A\u017C\u0104\u0106\u0118\u0141\u0143\u00D3\u015A\u0179\u017B]"# + + #"|\b(?i:"# + + #"nie|sie|jest|oraz|jesli|gdy|zeby|"# // l10n-polish-ok + + #"wiec|juz|moze|tylko|przez|dla|ktory|"# // l10n-polish-ok + + #"ktora|ktore|ktorych|blad|bledu|brak|kopia|"# // l10n-polish-ok + + #"kopii|obraz|obrazu|dysku|zapis|teraz|wszystko|"# // l10n-polish-ok + + #"gotowe|uwaga|przerwano|czujka|dozorca|wysylka|wyslane|"# // l10n-polish-ok + + #"zaleglosc|montowanie|podpiecie|odpiecie|wolne"# // l10n-polish-ok + + #")\b"# + + /// A line that must stay Polish (a test of the Polish translation, say) + /// carries this marker, so every exception is visible in review. + private static let allowMarker = "l10n-polish-ok" + + func testNoPolishOutsideThePolishTables() throws { + let regex = try NSRegularExpression(pattern: Self.polishPattern) + var hits: [String] = [] + for file in Self.swiftFiles(under: "Sources") + Self.swiftFiles(under: "Tests") { + if file.lastPathComponent.hasPrefix("L10nPolish+") { continue } + let lines = try String(contentsOf: file, encoding: .utf8).components(separatedBy: "\n") + for (index, line) in lines.enumerated() where !line.contains(Self.allowMarker) { + if regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil { + hits.append( + "\(Self.relative(file)):\(index + 1): \(line.trimmingCharacters(in: .whitespaces))") + } + } + } + XCTAssert( + hits.isEmpty, + "\(hits.count) lines still in Polish:\n" + hits.prefix(200).joined(separator: "\n")) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/LaunchdInstallerTests.swift b/mac-app/Tests/CloudMachineAppTests/LaunchdInstallerTests.swift index 5fbbcca..38a9fd2 100644 --- a/mac-app/Tests/CloudMachineAppTests/LaunchdInstallerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/LaunchdInstallerTests.swift @@ -2,57 +2,57 @@ import XCTest @testable import CloudMachineCore -/// Ustalenie 9: instalator agentow launchd meldowal sukces przy CZESCIOWEJ -/// porazce. +/// Finding 9: the launchd agent installer reported success on a PARTIAL +/// failure. /// -/// Wystarczyl jeden zaladowany agent, zeby `install()` zwrocilo -/// `succeeded: true` i komunikat "Zainstalowano agentow: ...", wymieniajacy -/// wylacznie te udane. Nieczytelny szablon szedl przez `continue`, nieudany -/// zapis przez `try?` - a po nieudanym zapisie `launchctl load` wczytywal STARY -/// plik `.plist` i konczyl sie kodem 0, czyli zaliczal sie jako sukces. +/// One loaded agent was enough for `install()` to return `succeeded: true` and +/// the message "Installed agents: ...", listing only the successful ones. An +/// unreadable template went through `continue`, a failed write through `try?` - +/// and after a failed write `launchctl load` read the OLD `.plist` file and +/// exited with code 0, i.e. it counted as a success. /// -/// Skutek: `buffer-guard` sie nie ladowal, instalator meldowal sukces, jedyna -/// ochrona dysku nie dzialala i nikt o tym nie wiedzial. Brakujacej nazwy na -/// liscie nie zauwazy nikt, kto nie zna tej listy z pamieci. +/// The result: `buffer-guard` did not load, the installer reported success, the +/// only protection of the disk was not working and nobody knew. Nobody who does +/// not know the list by heart will notice a name missing from it. /// -/// Testy podstawiaja czytanie szablonu, zapis i przeladowanie: instalacja -/// PRAWDZIWA odpina obraz backupu i przeladowuje agentow tej maszyny, wiec nie -/// da sie jej odpalic w tescie bez rozbierania dzialajacego backupu. +/// The tests substitute reading the template, writing and reloading: a REAL +/// installation detaches the backup image and reloads this machine's agents, so +/// it cannot be run in a test without taking the working backup apart. final class LaunchdInstallerTests: XCTestCase { private let agentBin = URL( fileURLWithPath: "/Applications/CloudMachine.app/bin/cloudmachine-agent") - private let logDir = URL(fileURLWithPath: "/Users/kto/Library/Logs/CloudMachine") - private let docelowy = URL(fileURLWithPath: "/Users/kto/Library/LaunchAgents") + private let logDir = URL(fileURLWithPath: "/Users/someone/Library/Logs/CloudMachine") + private let destination = URL(fileURLWithPath: "/Users/someone/Library/LaunchAgents") - private func szablony(_ nazwy: [String]) -> [URL] { - nazwy.map { URL(fileURLWithPath: "/repo/launchd/\($0).plist.template") } + private func templates(_ names: [String]) -> [URL] { + names.map { URL(fileURLWithPath: "/repo/launchd/\($0).plist.template") } } - private struct ZlaProbka: Error {} + private struct BadSample: Error {} - // MARK: - Zbieranie porazek + // MARK: - Collecting failures - /// TA usterka, w calosci. Trzy agenty, jeden wchodzi, dwa padaja na dwa rozne - /// sposoby (`launchctl` odmawia, szablon nieczytelny) - wynik MUSI byc - /// porazka wymieniajaca to, czego NIE ma. - func testJedenUdanyAgentToNieJestUdanaInstalacja() async { - let wszystkie = szablony([ + /// THAT defect, in full. Three agents, one goes in, two fail in two different + /// ways (`launchctl` refuses, the template is unreadable) - the result MUST + /// be a failure listing what is NOT there. + func testOneSuccessfulAgentIsNotASuccessfulInstallation() async { + let all = templates([ "com.renacode.cloudmachine.app", "com.renacode.cloudmachine.buffer-guard", "com.renacode.cloudmachine.backup-health", ]) let outcome = await LaunchdInstaller.installAgents( - templates: wszystkie, into: docelowy, agentBin: agentBin, logDir: logDir, + templates: all, into: destination, agentBin: agentBin, logDir: logDir, read: { url in - // Zepsuty szablon backup-health: wczesniej `guard ... else { continue }` - // wycinal go z instalacji BEZ SLADU. - if url.lastPathComponent.contains("backup-health") { throw ZlaProbka() } + // A broken backup-health template: previously `guard ... else { + // continue }` cut it out of the installation WITHOUT A TRACE. + if url.lastPathComponent.contains("backup-health") { throw BadSample() } return "__CM_AGENT_BIN__ __CM_LOG_DIR__" }, write: { _, _ in }, - // buffer-guard sie nie laduje - dokladnie ten agent z opisu ustalenia. + // buffer-guard does not load - exactly the agent from the finding. reload: { !$0.lastPathComponent.contains("buffer-guard") }, log: { _ in }) @@ -61,154 +61,154 @@ final class LaunchdInstallerTests: XCTestCase { outcome.failed.map(\.label).sorted(), ["com.renacode.cloudmachine.backup-health", "com.renacode.cloudmachine.buffer-guard"]) - let wynik = LaunchdInstaller.installVerdict(outcome) + let result = LaunchdInstaller.installVerdict(outcome) XCTAssertFalse( - wynik.succeeded, - "instalacja bez buffer-guard nie jest udana - dostalem: \(wynik.message)") + result.succeeded, + "an installation without buffer-guard is not successful - got: \(result.message)") XCTAssertTrue( - wynik.message.contains("buffer-guard"), - "komunikat musi NAZWAC agenta, ktorego brakuje: \(wynik.message)") + result.message.contains("buffer-guard"), + "the message must NAME the missing agent: \(result.message)") XCTAssertTrue( - wynik.message.contains("backup-health"), - "i drugiego tez: \(wynik.message)") + result.message.contains("backup-health"), + "and the other one too: \(result.message)") XCTAssertTrue( - wynik.message.contains("launchctl load odmowil"), - "i powiedziec, DLACZEGO nie wszedl: \(wynik.message)") + result.message.contains("launchctl load refused"), + "and say WHY it did not go in: \(result.message)") } - /// Nieudany zapis `.plist` NIE MOZE konczyc sie proba przeladowania. + /// A failed `.plist` write MUST NOT end with an attempt to reload. /// - /// Wczesniej zapis szedl przez `try?`, wiec po jego porazce lecialo - /// `launchctl load` na pliku, ktory nadal lezy w `~/Library/LaunchAgents` ze - /// STAREJ instalacji. `launchctl` konczyl sie wtedy kodem 0 i agent trafial - /// na liste "zainstalowanych", choc launchd chodzil na poprzedniej wersji - - /// byc moze wskazujacej na binarke, ktorej juz nie ma. - func testNieudanyZapisNieProbujePrzeladowac() async { - let przeladowania = Licznik() + /// Previously the write went through `try?`, so after it failed `launchctl + /// load` ran on the file that still lies in `~/Library/LaunchAgents` from the + /// OLD installation. `launchctl` then exited with code 0 and the agent landed + /// on the "installed" list, although launchd was running the previous + /// version - possibly pointing at a binary that no longer exists. + func testFailedWriteDoesNotAttemptReload() async { + let reloads = Counter() let outcome = await LaunchdInstaller.installAgents( - templates: szablony(["com.renacode.cloudmachine.buffer-guard"]), - into: docelowy, agentBin: agentBin, logDir: logDir, + templates: templates(["com.renacode.cloudmachine.buffer-guard"]), + into: destination, agentBin: agentBin, logDir: logDir, read: { _ in "__CM_AGENT_BIN__" }, - write: { _, _ in throw ZlaProbka() }, + write: { _, _ in throw BadSample() }, reload: { _ in - przeladowania.zwieksz() - // Tak zachowywal sie launchctl na starym pliku: kod 0, czyli "sukces". + reloads.increment() + // This is how launchctl behaved on the old file: code 0, i.e. "success". return true }, log: { _ in }) XCTAssertEqual( - przeladowania.ile, 0, - "po nieudanym zapisie launchctl load wczytalby STARY .plist i zaliczyl sie jako sukces") + reloads.count, 0, + "after a failed write launchctl load would read the OLD .plist and count as a success") XCTAssertTrue(outcome.installed.isEmpty) - XCTAssertEqual(outcome.failed.count, 1, "nieudany zapis musi zostac ZAPISANY jako porazka") + XCTAssertEqual(outcome.failed.count, 1, "a failed write must be RECORDED as a failure") XCTAssertTrue( - outcome.failed.first?.reason.contains("nie udalo sie zapisac") == true, - "powod: \(outcome.failed.first?.reason ?? "(brak - porazki nikt nie zapisal)")") + outcome.failed.first?.reason.contains("could not write") == true, + "reason: \(outcome.failed.first?.reason ?? "(none - nobody recorded the failure)")") XCTAssertFalse(LaunchdInstaller.installVerdict(outcome).succeeded) } - /// Komplet agentow - i tylko komplet - jest sukcesem. Bez tego testu - /// "naprawa" zwracajaca `succeeded: false` zawsze przeszlaby niezauwazona, - /// a instalacja, ktora nigdy nie melduje sukcesu, jest tak samo bezuzyteczna - /// jak ta, ktora melduje go zawsze. - func testKompletAgentowToNadalSukces() async { + /// The full set of agents - and only the full set - is a success. Without + /// this test a "fix" that always returns `succeeded: false` would go + /// unnoticed, and an installation that never reports success is just as + /// useless as one that always does. + func testFullSetOfAgentsIsStillASuccess() async { let outcome = await LaunchdInstaller.installAgents( - templates: szablony([ + templates: templates([ "com.renacode.cloudmachine.app", "com.renacode.cloudmachine.buffer-guard", ]), - into: docelowy, agentBin: agentBin, logDir: logDir, + into: destination, agentBin: agentBin, logDir: logDir, read: { _ in "__CM_AGENT_BIN__ __CM_LOG_DIR__" }, write: { _, _ in }, reload: { _ in true }, log: { _ in }) XCTAssertTrue(outcome.failed.isEmpty) - let wynik = LaunchdInstaller.installVerdict(outcome) - XCTAssertTrue(wynik.succeeded, wynik.message) + let result = LaunchdInstaller.installVerdict(outcome) + XCTAssertTrue(result.succeeded, result.message) XCTAssertEqual( - wynik.message, - "Zainstalowano agentow: com.renacode.cloudmachine.app, com.renacode.cloudmachine.buffer-guard" + result.message, + "Installed agents: com.renacode.cloudmachine.app, com.renacode.cloudmachine.buffer-guard" ) } - /// Brak szablonow to nadal porazka - inaczej instalacja, ktora nie zrobila - /// NIC, meldowalaby sukces z pusta lista. - func testBrakSzablonowToPorazka() { + /// No templates is still a failure - otherwise an installation that did + /// NOTHING would report success with an empty list. + func testNoTemplatesIsAFailure() { XCTAssertFalse(LaunchdInstaller.installVerdict(LaunchdInstaller.InstallOutcome()).succeeded) } - // MARK: - Podstawianie sciezek + // MARK: - Path substitution - /// Szablon musi dostac sciezki, po ktore sie zglasza - inaczej agent - /// wystartowalby z literalem `__CM_AGENT_BIN__` jako programem. - func testSciezkiTrafiajaDoZapisanegoPliku() async { - let zapisane = Przechwycone() + /// The template must get the paths it asks for - otherwise the agent would + /// start with the literal `__CM_AGENT_BIN__` as its program. + func testPathsEndUpInTheWrittenFile() async { + let written = Captured() _ = await LaunchdInstaller.installAgents( - templates: szablony(["com.renacode.cloudmachine.app"]), - into: docelowy, agentBin: agentBin, logDir: logDir, + templates: templates(["com.renacode.cloudmachine.app"]), + into: destination, agentBin: agentBin, logDir: logDir, read: { _ in "__CM_AGENT_BIN____CM_LOG_DIR__" }, - write: { tresc, url in zapisane.dodaj(tresc, url) }, + write: { content, url in written.add(content, url) }, reload: { _ in true }, log: { _ in }) XCTAssertEqual( - zapisane.tresci, + written.contents, ["\(agentBin.path)\(logDir.path)"]) XCTAssertEqual( - zapisane.sciezki, - [docelowy.appendingPathComponent("com.renacode.cloudmachine.app.plist").path]) + written.paths, + [destination.appendingPathComponent("com.renacode.cloudmachine.app.plist").path]) } - /// Pliki, ktore nie sa szablonami, nie moga trafic do instalacji - katalog - /// `launchd/` bywa listowany w calosci (README, `.DS_Store`). - func testNieSzablonyZostajaPominiete() async { + /// Files that are not templates must not get into the installation - the + /// `launchd/` directory is sometimes listed in full (README, `.DS_Store`). + func testNonTemplatesAreSkipped() async { let outcome = await LaunchdInstaller.installAgents( templates: [ URL(fileURLWithPath: "/repo/launchd/README.md"), URL(fileURLWithPath: "/repo/launchd/com.renacode.cloudmachine.app.plist.template"), ], - into: docelowy, agentBin: agentBin, logDir: logDir, + into: destination, agentBin: agentBin, logDir: logDir, read: { _ in "x" }, write: { _, _ in }, reload: { _ in true }, log: { _ in }) XCTAssertEqual(outcome.installed, ["com.renacode.cloudmachine.app"]) - XCTAssertTrue(outcome.failed.isEmpty, "README nie jest nieudanym agentem") + XCTAssertTrue(outcome.failed.isEmpty, "a README is not a failed agent") } - // MARK: - Pomocnicze + // MARK: - Helpers - /// Klasy, bo podstawiane domkniecia sa `@Sendable`. - private final class Licznik: @unchecked Sendable { + /// Classes, because the substituted closures are `@Sendable`. + private final class Counter: @unchecked Sendable { private let lock = NSLock() - private var licznik = 0 - func zwieksz() { + private var value = 0 + func increment() { lock.lock() - licznik += 1 + value += 1 lock.unlock() } - var ile: Int { + var count: Int { lock.lock() defer { lock.unlock() } - return licznik + return value } } - private final class Przechwycone: @unchecked Sendable { + private final class Captured: @unchecked Sendable { private let lock = NSLock() - private var zebrane: [(String, String)] = [] - func dodaj(_ tresc: String, _ url: URL) { + private var collected: [(String, String)] = [] + func add(_ content: String, _ url: URL) { lock.lock() - zebrane.append((tresc, url.path)) + collected.append((content, url.path)) lock.unlock() } - var tresci: [String] { + var contents: [String] { lock.lock() defer { lock.unlock() } - return zebrane.map(\.0) + return collected.map(\.0) } - var sciezki: [String] { + var paths: [String] { lock.lock() defer { lock.unlock() } - return zebrane.map(\.1) + return collected.map(\.1) } } } diff --git a/mac-app/Tests/CloudMachineAppTests/MachineIdentityTests.swift b/mac-app/Tests/CloudMachineAppTests/MachineIdentityTests.swift index 3e4f6a9..1d7f547 100644 --- a/mac-app/Tests/CloudMachineAppTests/MachineIdentityTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/MachineIdentityTests.swift @@ -3,31 +3,30 @@ import XCTest @testable import CloudMachineCore final class MachineIdentityTests: XCTestCase { - // Musi zostac zsynchronizowane z tym, co kiedys bylo `cm_machine_key` w - // scripts/common.sh - oba wywodza klucz maszyny z tego samego + // Must stay in sync with what used to be `cm_machine_key` in + // scripts/common.sh - both derive the machine key from the same // `scutil --get ComputerName`. func testNormalizedKey_lowercasesAndReplacesSpaces() { XCTAssertEqual( - MachineIdentity.normalizedKey(fromComputerName: "Marcin Mac Studio"), - "marcin-mac-studio" + MachineIdentity.normalizedKey(fromComputerName: "Alex Mac Studio"), + "alex-mac-studio" ) } func testNormalizedKey_stripsDisallowedCharacters() { XCTAssertEqual( - MachineIdentity.normalizedKey(fromComputerName: "Marcin's MacBook Pro (2)"), - "marcins-macbook-pro-2" + MachineIdentity.normalizedKey(fromComputerName: "Alex's MacBook Pro (2)"), + "alexs-macbook-pro-2" ) } func testNormalizedKey_stripsAccentedCharacters() { - // scutil moze zwrocic nazwe z polskimi znakami - te nie sa w dozwolonym - // zbiorze [a-z0-9-], wiec musza zniknac, a nie np. wywalic caly proces. - XCTAssertEqual( - MachineIdentity.normalizedKey(fromComputerName: "Łukasza-iMac"), - "ukasza-imac" - ) + // scutil may return a name with Polish characters - those are not in the + // allowed set [a-z0-9-], so they must disappear rather than, say, crash + // the whole process. + let name = "Łukasza-iMac" // l10n-polish-ok: test input with a Polish letter + XCTAssertEqual(MachineIdentity.normalizedKey(fromComputerName: name), "ukasza-imac") } func testNormalizedKey_emptyInput() { diff --git a/mac-app/Tests/CloudMachineAppTests/MachinesConfigTests.swift b/mac-app/Tests/CloudMachineAppTests/MachinesConfigTests.swift index 59c268b..b207191 100644 --- a/mac-app/Tests/CloudMachineAppTests/MachinesConfigTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/MachinesConfigTests.swift @@ -37,7 +37,7 @@ final class MachinesConfigTests: XCTestCase { remoteRootFolder: "CloudMachine", machines: [MachineEntry(key: "mac-1", displayName: "Mac 1", limitGB: 1900)] ) - // safeBudgetGB = 1800, allocatedGB = 1900 -> nad budzetem + // safeBudgetGB = 1800, allocatedGB = 1900 -> over budget XCTAssertTrue(config.isOverBudget) } @@ -53,10 +53,10 @@ final class MachinesConfigTests: XCTestCase { } // MARK: - Codable round-trip - // machines.json trzyma maszyny jako SLOWNIK kluczowany po "key" (nie - // tablice), zeby recznie edytowany JSON byl czytelny - kodowanie/dekodowanie - // konwertuje to w obie strony do/z [MachineEntry]. Regresja tutaj oznacza - // ciche gubienie lub duplikowanie wpisow maszyn przy kazdym zapisie configu. + // machines.json keeps the machines as a DICTIONARY keyed by "key" (not an + // array), so that hand-edited JSON is readable - encoding/decoding converts + // it both ways to/from [MachineEntry]. A regression here means silently + // losing or duplicating machine entries on every config save. func testCodable_roundTripPreservesData() throws { let original = MachinesConfig( @@ -77,8 +77,8 @@ final class MachinesConfigTests: XCTestCase { XCTAssertEqual(decoded.safetyMarginPercent, original.safetyMarginPercent) XCTAssertEqual(decoded.remoteName, original.remoteName) XCTAssertEqual(decoded.remoteRootFolder, original.remoteRootFolder) - // Dekodowanie sortuje po kluczu (patrz init(from:)) - deterministyczna - // kolejnosc w UI niezaleznie od kolejnosci kluczy w surowym JSON-ie. + // Decoding sorts by key (see init(from:)) - a deterministic order in the + // UI regardless of the key order in the raw JSON. XCTAssertEqual(decoded.machines, original.machines.sorted { $0.key < $1.key }) XCTAssertEqual(decoded.machines.map(\.key), ["alpha-mac", "zebra-mac"]) } @@ -91,15 +91,15 @@ final class MachinesConfigTests: XCTestCase { "remote_name": "gdrive-cloudmachine", "remote_root_folder": "CloudMachine", "machines": { - "marcin-mac-studio": { "display_name": "Marcin's Mac Studio", "limit_gb": 3000 } + "alex-mac-studio": { "display_name": "Alex's Mac Studio", "limit_gb": 3000 } } } """.data(using: .utf8)! let config = try JSONDecoder().decode(MachinesConfig.self, from: json) XCTAssertEqual(config.machines.count, 1) - XCTAssertEqual(config.machines.first?.key, "marcin-mac-studio") - XCTAssertEqual(config.machines.first?.displayName, "Marcin's Mac Studio") + XCTAssertEqual(config.machines.first?.key, "alex-mac-studio") + XCTAssertEqual(config.machines.first?.displayName, "Alex's Mac Studio") XCTAssertEqual(config.machines.first?.limitGB, 3000) } @@ -110,8 +110,8 @@ final class MachinesConfigTests: XCTestCase { driveTotalGB: 5000, safetyMarginPercent: 10, remoteName: "gdrive-cloudmachine", remoteRootFolder: "CloudMachine", machines: []) XCTAssertEqual( - config.remotePath(forMachineKey: "marcin-mac-studio"), - "gdrive-cloudmachine:CloudMachine/marcin-mac-studio") + config.remotePath(forMachineKey: "alex-mac-studio"), + "gdrive-cloudmachine:CloudMachine/alex-mac-studio") } func testLimitGB_returnsLimitForKnownMachine() { @@ -126,16 +126,16 @@ final class MachinesConfigTests: XCTestCase { let config = MachinesConfig( driveTotalGB: 5000, safetyMarginPercent: 10, remoteName: "gdrive-cloudmachine", remoteRootFolder: "CloudMachine", machines: []) - XCTAssertNil(config.limitGB(forMachineKey: "nieznana-maszyna")) + XCTAssertNil(config.limitGB(forMachineKey: "unknown-machine")) } // MARK: - config/machines.example.json - /// Dekoduje PRAWDZIWY plik z repo (nie fixture w tescie) - regresja tutaj - /// oznaczaloby, ze wlasny przykladowy config projektu przestal sie parsowac - /// (np. po zmianie schematu bez zaktualizowania pliku). Sprawdza tez, ze - /// dodatkowe, nierozpoznane pole `_comment` jest bezpiecznie ignorowane - /// przez niestandardowy `init(from:)`. + /// Decodes the REAL file from the repo (not a fixture in the test) - a + /// regression here would mean the project's own example config stopped + /// parsing (e.g. after a schema change without updating the file). It also + /// checks that the extra, unrecognized `_comment` field is safely ignored by + /// the custom `init(from:)`. func testDecode_realExampleConfigFileParsesCorrectly() throws { let exampleConfigPath = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // CloudMachineAppTests @@ -150,6 +150,6 @@ final class MachinesConfigTests: XCTestCase { XCTAssertEqual(config.remoteName, "gdrive-cloudmachine") XCTAssertEqual(config.remoteRootFolder, "CloudMachine") XCTAssertEqual(config.machines.count, 2) - XCTAssertEqual(config.machines.map(\.key).sorted(), ["imac-domowy", "macbook-pro-marcin"]) + XCTAssertEqual(config.machines.map(\.key).sorted(), ["imac-home", "macbook-pro"]) } } diff --git a/mac-app/Tests/CloudMachineAppTests/PullPlugHarnessTests.swift b/mac-app/Tests/CloudMachineAppTests/PullPlugHarnessTests.swift index 1d47f91..de4e2f4 100644 --- a/mac-app/Tests/CloudMachineAppTests/PullPlugHarnessTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/PullPlugHarnessTests.swift @@ -4,112 +4,115 @@ import XCTest @testable import CloudMachineCore @testable import cloudmachine_poc -/// Czy harness `pullplug` odroznia "nie zmierzylem" od "zmierzylem i jest OK". +/// Whether the `pullplug` harness tells "I did not measure" apart from "I +/// measured and it is OK". /// -/// Harness mierzy zachowanie hdiutil i FUSE-T, ale SPOSOB, w jaki zdaje z tego -/// relacje, jest zwyklym kodem - i psul sie dokladnie tak, jak reszta tego -/// projektu: przez zlanie braku wyniku z wynikiem. Zaden test tutaj nie tworzy -/// obrazu dyskowego; dotykaja wylacznie czystych czesci. +/// The harness measures the behaviour of hdiutil and FUSE-T, but the WAY it +/// reports on it is ordinary code - and it broke exactly like the rest of this +/// project: by merging a missing result with a result. No test here creates a +/// disk image; they touch only the pure parts. final class PullPlugHarnessTests: XCTestCase { - // MARK: - Proba zapisu: "nie zaczalem" to nie "przerwano" + // MARK: - Test write: "never started" is not "interrupted" - /// TA usterka. `writeUntilItBreaks` zwracalo `false` takze wtedy, gdy - /// `createFile`/`FileHandle` padly od razu - a harness drukowal na to "zapis - /// przerwany, zgodnie z oczekiwaniem" i konczyl "Obraz przezyl kazde wyrwanie - /// podlogi", nie napisawszy ani jednego bajtu. - func testZapisKtoryNieMialGdzieSieZaczacNieJestPrzerwanym() { - let nieistniejacy = URL(fileURLWithPath: "/nie/ma/takiego/katalogu/obciazenie.bin") - let wynik = writeUntilItBreaks(to: nieistniejacy, megabytes: 1) + /// THE bug. `writeUntilItBreaks` returned `false` also when + /// `createFile`/`FileHandle` failed immediately - and the harness printed + /// "write interrupted, as expected" for it and ended with "The image survived + /// every floor pull", without having written a single byte. + func testWriteThatHadNowhereToStartIsNotInterrupted() { + let nonexistent = URL(fileURLWithPath: "/no/such/directory/load.bin") + let result = writeUntilItBreaks(to: nonexistent, megabytes: 1) - guard case .neverStarted(let powod) = wynik else { - return XCTFail("zapis, ktory sie nie zaczal, nie moze wygladac jak przerwany: \(wynik)") + guard case .neverStarted(let reason) = result else { + return XCTFail("a write that never started must not look interrupted: \(result)") } XCTAssertTrue( - powod.contains("obciazenie.bin"), "powod ma nazwac plik, o ktory chodzi: \(powod)") - XCTAssertNotEqual(wynik, .interrupted(megabytesWritten: 0)) + reason.contains("load.bin"), "the reason has to name the file in question: \(reason)") + XCTAssertNotEqual(result, .interrupted(megabytesWritten: 0)) } - /// Druga strona tej samej poprawki: zapis, ktory PRZESZEDL cale zamowienie, - /// tez nie jest sukcesem testu - podloga zniknela juz po nim, wiec runda nic - /// nie zmierzyla. To ten sam blad, przed ktorym ostrzega komentarz o - /// `arc4random_buf`, tylko widziany od strony raportu. - func testZapisDoKoncaJestRozpoznawalnyJakoBrakPomiaru() throws { - let katalog = FileManager.default.temporaryDirectory + /// The other side of the same fix: a write that GOT THROUGH the whole order + /// is not a test success either - the floor disappeared only after it, so the + /// round measured nothing. It is the same bug the comment about + /// `arc4random_buf` warns about, only seen from the report's side. + func testWriteToTheEndIsRecognizableAsNoMeasurement() throws { + let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cm-pullplug-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: katalog) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } - let wynik = writeUntilItBreaks( - to: katalog.appendingPathComponent("obciazenie.bin"), megabytes: 1) - XCTAssertEqual(wynik, .completed(megabytesWritten: 1)) + let result = writeUntilItBreaks( + to: directory.appendingPathComponent("load.bin"), megabytes: 1) + XCTAssertEqual(result, .completed(megabytesWritten: 1)) } - // MARK: - fsck: "nie udalo sie sprawdzic" to nie "niespojny" + // MARK: - fsck: "could not check" is not "inconsistent" - func testWynikFsckZeroToSpojny() { + func testFsckExitZeroIsConsistent() { XCTAssertEqual( PullPlugCommand.classify(fsck: ProcessResult(stdout: "ok", stderr: "", exitCode: 0)), .consistent) } - func testNiezerowyKodFsckToNiespojnosc() { + func testNonZeroFsckExitIsInconsistency() { XCTAssertEqual( PullPlugCommand.classify( fsck: ProcessResult(stdout: "", stderr: "corrupt", exitCode: 1)), .inconsistent) } - /// Wzorzec z `BackupImageService.verifyLocked()`: nieuruchomiony `fsck_apfs` - /// (brak binarki, ubity proces, wyrwane urzadzenie) dawal to samo `false`, co - /// `fsck_apfs`, ktory znalazl uszkodzenie - harness meldowal wtedy "backup - /// stracony" i liczyl nieodwracalna strate, nie majac ani jednego wyniku. - func testBrakWynikuFsckToNieNiespojnosc() { - let wynik = PullPlugCommand.classify(fsck: nil) - XCTAssertNotEqual(wynik, .inconsistent, "brak wyniku nie moze udawac uszkodzenia") - guard case .notChecked = wynik else { return XCTFail("dostalem: \(wynik)") } + /// The pattern from `BackupImageService.verifyLocked()`: a `fsck_apfs` that + /// never ran (missing binary, killed process, pulled device) gave the same + /// `false` as a `fsck_apfs` that found damage - the harness then reported + /// "backup lost" and counted an irreversible loss without having a single + /// result. + func testMissingFsckResultIsNotInconsistency() { + let result = PullPlugCommand.classify(fsck: nil) + XCTAssertNotEqual(result, .inconsistent, "a missing result must not pretend to be damage") + guard case .notChecked = result else { return XCTFail("got: \(result)") } } - // MARK: - Ostatnie zdanie przebiegu + // MARK: - The last sentence of the run - func testPrzebiegBezStratIBezDziurOrzekaPrzezycie() { - let linie = PullPlugCommand.summary( + func testRunWithoutLossesOrGapsDeclaresSurvival() { + let lines = PullPlugCommand.summary( requestedRounds: 3, executedRounds: 3, lost: 0, unmeasured: 0) XCTAssertTrue( - linie.contains { $0.contains("przezyl kazde wyrwanie podlogi") }, "dostalem: \(linie)") + lines.contains { $0.contains("survived every floor pull") }, "got: \(lines)") } - /// Sedno punktu 14: przebieg, w ktorym cokolwiek nie zostalo zmierzone, NIE - /// MA PRAWA orzekac, ze obraz przezyl. Harness nie wie, czy probowal go zabic. - func testPrzebiegZRundaBezPomiaruNieOrzekaPrzezycia() { - let linie = PullPlugCommand.summary( + /// The core of point 14: a run in which anything went unmeasured HAS NO + /// RIGHT to declare that the image survived. The harness does not know + /// whether it tried to kill it. + func testRunWithAnUnmeasuredRoundDoesNotDeclareSurvival() { + let lines = PullPlugCommand.summary( requestedRounds: 3, executedRounds: 3, lost: 0, unmeasured: 1) XCTAssertFalse( - linie.contains { $0.contains("przezyl") }, - "runda bez pomiaru nie moze konczyc sie zdaniem o przezyciu: \(linie)") + lines.contains { $0.contains("survived") }, + "a round without a measurement must not end with a sentence about survival: \(lines)") XCTAssertTrue( - linie.contains { $0.contains("NIC NIE DOWODZI") }, "dostalem: \(linie)") + lines.contains { $0.contains("PROVES NOTHING") }, "got: \(lines)") XCTAssertTrue( - linie.contains { $0.contains("rund bez pomiaru: 1") }, "dostalem: \(linie)") + lines.contains { $0.contains("rounds without a measurement: 1") }, "got: \(lines)") } - /// Zero wykonanych rund tez nie jest sukcesem - a przy `rounds: 0` w liczniku - /// dawnego podsumowania wychodzilo "nieodwracalnych strat: 0", czyli - /// "przezyl". - func testPrzebiegBezAniJednejRundyNiczegoNieOrzeka() { - let linie = PullPlugCommand.summary( + /// Zero executed rounds is not a success either - and with `rounds: 0` the + /// counter of the old summary came out as "irreversible losses: 0", i.e. + /// "survived". + func testRunWithoutASingleRoundDeclaresNothing() { + let lines = PullPlugCommand.summary( requestedRounds: 3, executedRounds: 0, lost: 0, unmeasured: 0) - XCTAssertFalse(linie.contains { $0.contains("przezyl") }, "dostalem: \(linie)") - XCTAssertTrue(linie.contains { $0.contains("NIC NIE ZMIERZYL") }, "dostalem: \(linie)") + XCTAssertFalse(lines.contains { $0.contains("survived") }, "got: \(lines)") + XCTAssertTrue(lines.contains { $0.contains("MEASURED NOTHING") }, "got: \(lines)") } - /// Stwierdzona strata jest wynikiem i ma byc widoczna nawet obok dziur - - /// inaczej "poprawka" zamienilaby falszywy sukces na przemilczana awarie. - func testStwierdzonaStrataNieZnikaZaBrakiemPomiaru() { - let linie = PullPlugCommand.summary( + /// A detected loss is a result and has to stay visible even next to gaps - + /// otherwise the "fix" would turn a false success into a hushed-up failure. + func testDetectedLossDoesNotHideBehindMissingMeasurement() { + let lines = PullPlugCommand.summary( requestedRounds: 3, executedRounds: 3, lost: 1, unmeasured: 1) XCTAssertTrue( - linie.contains { $0.contains("architektura gubi backup") }, "dostalem: \(linie)") - XCTAssertFalse(linie.contains { $0.contains("przezyl") }, "dostalem: \(linie)") + lines.contains { $0.contains("architecture loses the backup") }, "got: \(lines)") + XCTAssertFalse(lines.contains { $0.contains("survived") }, "got: \(lines)") } } diff --git a/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift b/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift index 44f6a1f..86f3e17 100644 --- a/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift @@ -2,14 +2,14 @@ import XCTest @testable import CloudMachineCore -/// Parsowanie odpowiedzi interfejsu sterujacego rclone. Kazdy przypadek tutaj -/// to inny sposob, na jaki "nie wiem" zamienialo sie w "zero" - a "zero" -/// czytalo sie na ekranie jako "Wszystko wyslane na Google Drive". +/// Parsing responses of rclone's remote control interface. Each case here is a +/// different way in which "I do not know" turned into "zero" - and "zero" read +/// on screen as "Everything uploaded to Google Drive". final class QueueStatsParsingTests: XCTestCase { // MARK: - vfs/stats - private let pelnaOdpowiedz = """ + private let fullResponse = """ { "diskCache": { "bytesUsed": 12345678, @@ -23,8 +23,8 @@ final class QueueStatsParsingTests: XCTestCase { } """ - func testPelnaOdpowiedzDajeLiczniki() throws { - let stats = try XCTUnwrap(DriveBufferService.parseQueueStats(pelnaOdpowiedz)) + func testFullResponseGivesCounters() throws { + let stats = try XCTUnwrap(DriveBufferService.parseQueueStats(fullResponse)) XCTAssertEqual(stats.uploadsInProgress, 2) XCTAssertEqual(stats.uploadsQueued, 386) XCTAssertEqual(stats.files, 40) @@ -33,19 +33,20 @@ final class QueueStatsParsingTests: XCTestCase { XCTAssertFalse(stats.outOfSpace) } - /// Sedno poprawki. Odpowiedz bez sekcji `diskCache` wpadala na `?? json`, - /// gdzie zadnego z licznikow nie ma, a brakujacy klucz dawal 0 - wychodzil - /// z tego komplet zer, czyli `queueKnown == true` i "Wszystko wyslane". - func testOdpowiedzBezDiskCacheToBrakWiedzy() { - let bezSekcji = """ + /// The heart of the fix. A response without the `diskCache` section fell + /// through to `?? json`, where none of the counters are, and a missing key + /// gave 0 - the result was a full set of zeros, i.e. `queueKnown == true` and + /// "Everything uploaded". + func testResponseWithoutDiskCacheMeansNotKnowing() { + let withoutSection = """ { "metadataCache": { "dirs": 1, "files": 40 } } """ - XCTAssertNil(DriveBufferService.parseQueueStats(bezSekcji)) + XCTAssertNil(DriveBufferService.parseQueueStats(withoutSection)) } - /// Ten sam blad o jeden poziom nizej: sekcja jest, ale licznika w niej nie ma. - func testBrakujacyLicznikToBrakWiedzy() { - let bezBledow = """ + /// The same bug one level down: the section is there, but the counter is not. + func testMissingCounterMeansNotKnowing() { + let withoutErrors = """ { "diskCache": { "bytesUsed": 1, "files": 40, @@ -54,19 +55,19 @@ final class QueueStatsParsingTests: XCTestCase { } """ XCTAssertNil( - DriveBufferService.parseQueueStats(bezBledow), - "brak erroredFiles nie znaczy 'zero bledow'") + DriveBufferService.parseQueueStats(withoutErrors), + "missing erroredFiles does not mean 'zero errors'") } - func testPustaOdpowiedzToBrakWiedzy() { + func testEmptyResponseMeansNotKnowing() { XCTAssertNil(DriveBufferService.parseQueueStats("")) XCTAssertNil(DriveBufferService.parseQueueStats("connection refused")) } - /// `outOfSpace` to jedyne pole, ktorego brak wolno nadrobic domyslna - /// wartoscia - to flaga, a nie licznik. - func testBrakFlagiOutOfSpaceNiePsujeOdczytu() throws { - let bezFlagi = """ + /// `outOfSpace` is the only field whose absence may be made up with a default + /// value - it is a flag, not a counter. + func testMissingOutOfSpaceFlagDoesNotBreakTheReading() throws { + let withoutFlag = """ { "diskCache": { "bytesUsed": 1, "erroredFiles": 0, "files": 2, @@ -74,23 +75,23 @@ final class QueueStatsParsingTests: XCTestCase { } } """ - let stats = try XCTUnwrap(DriveBufferService.parseQueueStats(bezFlagi)) + let stats = try XCTUnwrap(DriveBufferService.parseQueueStats(withoutFlag)) XCTAssertFalse(stats.outOfSpace) } - // MARK: - Cisza kontra bezczynnosc + // MARK: - Quiet versus idle - func testPustaKolejkaZPorzuconymiPasmamiNieJestCisza() { + func testEmptyQueueWithAbandonedBandsIsNotQuiet() { let stats = DriveBufferService.QueueStats( uploadsInProgress: 0, uploadsQueued: 0, files: 40, erroredFiles: 5, bytesUsed: 1024, outOfSpace: false) - XCTAssertTrue(stats.isIdle, "rclone faktycznie nic nie robi - hdiutil moze dzialac") + XCTAssertTrue(stats.isIdle, "rclone really is doing nothing - hdiutil can work") XCTAssertFalse( stats.isQuiet, - "ale 5 pasm nie dolecialo na Dysk, wiec 'wszystko wyslane' byloby klamstwem") + "but 5 bands did not reach Drive, so 'everything uploaded' would be a lie") } - func testPustaKolejkaBezBledowJestCisza() { + func testEmptyQueueWithoutErrorsIsQuiet() { let stats = DriveBufferService.QueueStats( uploadsInProgress: 0, uploadsQueued: 0, files: 40, erroredFiles: 0, bytesUsed: 1024, outOfSpace: false) @@ -98,7 +99,7 @@ final class QueueStatsParsingTests: XCTestCase { XCTAssertTrue(stats.isQuiet) } - func testTrwajacaWysylkaToAniCiszaAniBezczynnosc() { + func testOngoingUploadIsNeitherQuietNorIdle() { let stats = DriveBufferService.QueueStats( uploadsInProgress: 1, uploadsQueued: 12, files: 40, erroredFiles: 0, bytesUsed: 1024, outOfSpace: false) @@ -108,8 +109,8 @@ final class QueueStatsParsingTests: XCTestCase { // MARK: - vfs/queue - func testKolejkaPomijaPozycjeJuzWysylane() throws { - let odpowiedz = """ + func testQueueSkipsItemsAlreadyUploading() throws { + let response = """ { "queue": [ { "id": 1, "name": "bands/0001", "uploading": false, "expiry": 480.2 }, @@ -118,14 +119,14 @@ final class QueueStatsParsingTests: XCTestCase { ] } """ - let ids = try XCTUnwrap(DriveBufferService.parseQueueIDs(odpowiedz)) - XCTAssertEqual(ids, [1, 3], "pozycji juz wysylanej rclone i tak nie przyspieszy") + let ids = try XCTUnwrap(DriveBufferService.parseQueueIDs(response)) + XCTAssertEqual(ids, [1, 3], "rclone will not speed up an item already uploading anyway") } - /// Pusta kolejka i brak odpowiedzi to DWIE ROZNE RZECZY - obie wychodzily - /// wczesniej z `expireQueuedUploads()` jako `0`, wiec log milczal dokladnie - /// wtedy, gdy terminow NIE przesunieto i drenaz mogl potrwac 10 minut. - func testPustaKolejkaToNieToSamoCoBrakOdpowiedzi() { + /// An empty queue and no answer are TWO DIFFERENT THINGS - both used to come + /// out of `expireQueuedUploads()` as `0`, so the log was silent exactly when + /// the deadlines were NOT moved and the drain could take 10 minutes. + func testEmptyQueueIsNotTheSameAsNoAnswer() { XCTAssertEqual(DriveBufferService.parseQueueIDs(#"{ "queue": [] }"#), []) XCTAssertNil(DriveBufferService.parseQueueIDs("")) XCTAssertNil(DriveBufferService.parseQueueIDs("{}")) diff --git a/mac-app/Tests/CloudMachineAppTests/RcloneLogReadabilityTests.swift b/mac-app/Tests/CloudMachineAppTests/RcloneLogReadabilityTests.swift index 6849066..95a6704 100644 --- a/mac-app/Tests/CloudMachineAppTests/RcloneLogReadabilityTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/RcloneLogReadabilityTests.swift @@ -2,117 +2,118 @@ import XCTest @testable import CloudMachineCore -/// Dwa pytania, ktore dozorca zadaje LOGOWI rclone: czy skonczylo sie miejsce -/// na Dysku Google i czy wysylka stoi. Oba mialy do 25.09.2026 tylko dwie -/// odpowiedzi, a plik ma trzy stany. +/// Two questions the watchdog asks the rclone LOG: has Google Drive run out of +/// space, and is the upload stalled. Until 25.09.2026 both had only two +/// answers, while the file has three states. /// -/// `recentLog` oddaje `nil`, gdy pliku nie da sie otworzyc, a obie funkcje -/// zamienialy to na `false` - czyli na "nie ma problemu". Skutki byly dwa -/// i rozne: dozorca nie wstrzymywal backupu przy braku miejsca na Dysku, -/// a `reportStall(false)` USUWAL znacznik zatoru i zapisywal "Wysylka na Google -/// Drive ruszyla z powrotem". Log rclone ma prawa `-rw-r-----`, a przy starcie -/// jest przenoszony na `.1`, wiec nieczytelny log to stan spodziewany. +/// `recentLog` returns `nil` when the file cannot be opened, and both +/// functions turned that into `false` - i.e. into "no problem". The effects +/// were two and different: the watchdog did not pause the backup when Drive +/// was out of space, and `reportStall(false)` DELETED the jam marker and wrote +/// "Upload to Google Drive has resumed". The rclone log has `-rw-r-----` +/// permissions, and at start-up it is moved to `.1`, so an unreadable log is +/// an expected state. /// -/// Testy pisza do wlasnego pliku tymczasowego. Produkcyjnego -/// `~/.cloudmachine/rclone.log` nie dotykaja ani do odczytu, ani do zapisu - -/// dlatego obie funkcje przyjmuja sciezke. +/// The tests write to their own temporary file. They do not touch the +/// production `~/.cloudmachine/rclone.log`, neither for reading nor for +/// writing - that is why both functions take a path. final class RcloneLogReadabilityTests: XCTestCase { - private var katalog: URL! + private var directory: URL! override func setUpWithError() throws { try super.setUpWithError() - katalog = URL(fileURLWithPath: NSTemporaryDirectory()) + directory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("cm-rclone-log-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } override func tearDownWithError() throws { - // Prawa musza wrocic, inaczej katalogu nie da sie usunac. - if let pliki = try? FileManager.default.contentsOfDirectory(atPath: katalog.path) { - for plik in pliki { + // Permissions have to come back, otherwise the directory cannot be removed. + if let files = try? FileManager.default.contentsOfDirectory(atPath: directory.path) { + for file in files { try? FileManager.default.setAttributes( - [.posixPermissions: 0o600], ofItemAtPath: katalog.appendingPathComponent(plik).path) + [.posixPermissions: 0o600], ofItemAtPath: directory.appendingPathComponent(file).path) } } - try? FileManager.default.removeItem(at: katalog) + try? FileManager.default.removeItem(at: directory) try super.tearDownWithError() } - private func stempel(_ przesuniecieMinut: Double = 0) -> String { + private func stamp(_ minutesAgo: Double = 0) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" formatter.timeZone = TimeZone.current - return formatter.string(from: Date().addingTimeInterval(-przesuniecieMinut * 60)) + return formatter.string(from: Date().addingTimeInterval(-minutesAgo * 60)) } - private func zapisz(_ tekst: String, prawa: Int? = nil) throws -> URL { - let plik = katalog.appendingPathComponent("rclone.log") - try tekst.write(to: plik, atomically: true, encoding: .utf8) - if let prawa { + private func write(_ text: String, permissions: Int? = nil) throws -> URL { + let file = directory.appendingPathComponent("rclone.log") + try text.write(to: file, atomically: true, encoding: .utf8) + if let permissions { try FileManager.default.setAttributes( - [.posixPermissions: prawa], ofItemAtPath: plik.path) + [.posixPermissions: permissions], ofItemAtPath: file.path) } - return plik + return file } - // MARK: - Brak miejsca na Google Drive + // MARK: - No space on Google Drive - func testSwiezyWpisOLimicieToJAWNETAK() throws { - let plik = try zapisz( + func testFreshLimitEntryIsAnExplicitYES() throws { + let file = try write( """ - \(stempel(5)) INFO : band-1: Copied (replaced existing) - \(stempel(2)) ERROR : band-2: Failed to copy: googleapi: Error 403: storageQuotaExceeded + \(stamp(5)) INFO : band-1: Copied (replaced existing) + \(stamp(2)) ERROR : band-2: Failed to copy: googleapi: Error 403: storageQuotaExceeded """) - XCTAssertEqual(DriveBufferService.hitStorageQuotaState(logFile: plik), true) + XCTAssertEqual(DriveBufferService.hitStorageQuotaState(logFile: file), true) } - func testLogBezSladuLimituToJAWNENIE() throws { - let plik = try zapisz("\(stempel(2)) INFO : band-1: Copied (new)") - XCTAssertEqual(DriveBufferService.hitStorageQuotaState(logFile: plik), false) + func testLogWithoutATraceOfTheLimitIsAnExplicitNO() throws { + let file = try write("\(stamp(2)) INFO : band-1: Copied (new)") + XCTAssertEqual(DriveBufferService.hitStorageQuotaState(logFile: file), false) } - /// TA awaria. Plik jest, ale nie mamy do niego prawa - i to NIE znaczy, ze - /// na Dysku jest miejsce. - func testNieczytelnyLogToNieWiemAnieBrakProblemu() throws { - let plik = try zapisz( - "\(stempel(2)) ERROR : band-2: googleapi: Error 403: storageQuotaExceeded", prawa: 0o000) + /// THE failure. The file is there, but we have no permission for it - and + /// that does NOT mean there is space on Drive. + func testUnreadableLogIsIDoNotKnowNotNoProblem() throws { + let file = try write( + "\(stamp(2)) ERROR : band-2: googleapi: Error 403: storageQuotaExceeded", permissions: 0o000) XCTAssertNil( - DriveBufferService.hitStorageQuotaState(logFile: plik), - "Nieotwieralny plik zamieniony na `false` udaje odpowiedz 'nie ma problemu'.") + DriveBufferService.hitStorageQuotaState(logFile: file), + "An unopenable file turned into `false` pretends to be the answer 'no problem'.") XCTAssertNil( - DriveBufferService.uploadStalledState(logFile: plik), - "To samo pytanie o zator - ten sam plik i ten sam brak odpowiedzi.") + DriveBufferService.uploadStalledState(logFile: file), + "The same question about the jam - the same file and the same lack of an answer.") } - /// Log przeniesiony na `.1` przy starcie rclone albo jeszcze nieutworzony po - /// swiezej instalacji. Brak pliku to brak danych, nie brak problemu. - func testBrakPlikuLoguToTezNieWiem() { - let plik = katalog.appendingPathComponent("nie-ma-takiego.log") - XCTAssertNil(DriveBufferService.hitStorageQuotaState(logFile: plik)) - XCTAssertNil(DriveBufferService.uploadStalledState(logFile: plik)) + /// The log moved to `.1` at rclone start-up, or not yet created after a + /// fresh install. No file is no data, not no problem. + func testMissingLogFileIsAlsoIDoNotKnow() { + let file = directory.appendingPathComponent("no-such-file.log") + XCTAssertNil(DriveBufferService.hitStorageQuotaState(logFile: file)) + XCTAssertNil(DriveBufferService.uploadStalledState(logFile: file)) } - // MARK: - Zator wysylki + // MARK: - Upload jam - func testZatorRozpoznanyZeStosunkuSukcesowDoBledow() throws { - // Prog `minErrors` to 300, a `maxSuccessRatio` 0,1 - zator to setki bledow - // przy niemal zerowym ruchu. - var linie: [String] = [] - for _ in 0..<400 { linie.append("\(stempel(3)) ERROR : band: Received upload limit error") } - linie.append("\(stempel(3)) INFO : band: Copied (replaced existing)") - let plik = try zapisz(linie.joined(separator: "\n")) - XCTAssertEqual(DriveBufferService.uploadStalledState(logFile: plik), true) + func testJamRecognisedFromTheRatioOfSuccessesToErrors() throws { + // The `minErrors` threshold is 300 and `maxSuccessRatio` 0.1 - a jam is + // hundreds of errors with almost zero traffic. + var lines: [String] = [] + for _ in 0..<400 { lines.append("\(stamp(3)) ERROR : band: Received upload limit error") } + lines.append("\(stamp(3)) INFO : band: Copied (replaced existing)") + let file = try write(lines.joined(separator: "\n")) + XCTAssertEqual(DriveBufferService.uploadStalledState(logFile: file), true) } - func testZwykleDlawienieTempaNieJestZatorem() throws { - // Tyle samo sukcesow, co bledow - zmierzone 1:1 na dlawieniu z 12.09.2026. - var linie: [String] = [] + func testOrdinaryRateThrottlingIsNotAJam() throws { + // As many successes as errors - measured 1:1 on the throttling of 12.09.2026. + var lines: [String] = [] for _ in 0..<400 { - linie.append("\(stempel(3)) ERROR : band: Received upload limit error") - linie.append("\(stempel(3)) INFO : band: Copied (replaced existing)") + lines.append("\(stamp(3)) ERROR : band: Received upload limit error") + lines.append("\(stamp(3)) INFO : band: Copied (replaced existing)") } - let plik = try zapisz(linie.joined(separator: "\n")) - XCTAssertEqual(DriveBufferService.uploadStalledState(logFile: plik), false) + let file = try write(lines.joined(separator: "\n")) + XCTAssertEqual(DriveBufferService.uploadStalledState(logFile: file), false) } } diff --git a/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift b/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift index a90f9ca..03a7451 100644 --- a/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift @@ -13,10 +13,10 @@ final class RemoteConfigurerTests: XCTestCase { } func testExtractToken_missingMarkers() { - XCTAssertNil(RemoteConfigurer.extractToken(from: "cos posz\u{142}o nie tak, brak markerow")) + XCTAssertNil(RemoteConfigurer.extractToken(from: "something went wrong, no markers")) } func testExtractToken_onlyStartMarker() { - XCTAssertNil(RemoteConfigurer.extractToken(from: "tekst ---> reszta bez konca")) + XCTAssertNil(RemoteConfigurer.extractToken(from: "text ---> rest without an end")) } } diff --git a/mac-app/Tests/CloudMachineAppTests/RemoteCredentialsTests.swift b/mac-app/Tests/CloudMachineAppTests/RemoteCredentialsTests.swift index 5a43a0b..00cc1de 100644 --- a/mac-app/Tests/CloudMachineAppTests/RemoteCredentialsTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/RemoteCredentialsTests.swift @@ -2,57 +2,60 @@ import XCTest @testable import CloudMachineCore -/// Testy stanu wlasnych poswiadczen OAuth. +/// Tests for the state of the own OAuth credentials. /// -/// Najwazniejszy przypadek to POLOWICZNA konfiguracja: wyglada na zrobiona, a -/// rclone i tak wraca na wspoldzielony `client_id`. Gdyby interfejs pokazywal -/// wtedy zielone "ustawione", uzytkownik mialby dowod na cos, co nie dziala. +/// The most important case is a HALF-DONE configuration: it looks done, and +/// rclone falls back to the shared `client_id` anyway. If the interface then +/// showed a green "set", the user would have proof of something that does not +/// work. final class RemoteCredentialsTests: XCTestCase { private typealias State = RemoteConfigurer.CredentialsState - func testObaUstawioneToKonfiguracjaKompletna() { + func testBothSetIsACompleteConfiguration() { let state = State(hasClientID: true, hasClientSecret: true) XCTAssertTrue(state.isComplete) XCTAssertFalse(state.isPartial) - XCTAssertTrue(state.summary.contains("ustawione")) + XCTAssertTrue(state.summary.contains("set.")) } - func testZadneNieUstawioneToBrakKonfiguracji() { + func testNeitherSetIsNoConfiguration() { let state = State(hasClientID: false, hasClientSecret: false) XCTAssertFalse(state.isComplete) - XCTAssertFalse(state.isPartial, "Brak obu to nie jest stan polowiczny") - XCTAssertTrue(state.summary.contains("Brak wlasnych poswiadczen")) + XCTAssertFalse(state.isPartial, "Both missing is not a half-done state") + XCTAssertTrue(state.summary.contains("No own credentials")) } - /// ZNANA ZLA PROBKA: sam `client_id`, bez sekretu. - func testSamClientIdToStanPolowicznyANieKompletny() { + /// KNOWN BAD SAMPLE: `client_id` alone, without the secret. + func testClientIdAloneIsHalfDoneNotComplete() { let state = State(hasClientID: true, hasClientSecret: false) - XCTAssertFalse(state.isComplete, "Bez sekretu rclone nie uzyje wlasnego client_id") + XCTAssertFalse(state.isComplete, "Without the secret rclone will not use its own client_id") XCTAssertTrue(state.isPartial) - XCTAssertTrue(state.summary.contains("client_secret"), "Komunikat ma nazwac to, czego brakuje") + XCTAssertTrue( + state.summary.contains("client_secret"), "The message must name what is missing") } - /// I odwrotnie - sam sekret bez identyfikatora. - func testSamSekretToStanPolowiczny() { + /// And the other way round - the secret alone without the identifier. + func testSecretAloneIsHalfDone() { let state = State(hasClientID: false, hasClientSecret: true) XCTAssertFalse(state.isComplete) XCTAssertTrue(state.isPartial) XCTAssertTrue(state.summary.contains("client_id")) } - /// Komunikat przy braku poswiadczen ma mowic, CO Z TEGO WYNIKA - inaczej - /// nikt nie ma powodu tego uzupelniac. Wspoldzielony `client_id` rclone jest - /// limitowany wspolnie i wycofywany w 2026. - func testKomunikatTlumaczySkutekBrakuPoswiadczen() { + /// The message for missing credentials must say WHAT FOLLOWS FROM IT - + /// otherwise nobody has a reason to fill them in. rclone's shared + /// `client_id` is rate-limited jointly and being retired in 2026. + func testMessageExplainsConsequenceOfMissingCredentials() { let summary = State(hasClientID: false, hasClientSecret: false).summary - XCTAssertTrue(summary.contains("wspoldzielonego client_id")) + XCTAssertTrue(summary.contains("shared client_id")) XCTAssertTrue(summary.contains("2026")) } - func testNazwyKontWKeychainieSaStabilne() { - // Zmiana tych nazw rozjezdza zapis z interfejsu i odczyt z agenta, a obie - // strony milcza - agent po prostu wraca na wspoldzielony client_id. + func testKeychainAccountNamesAreStable() { + // Changing these names makes the write from the interface and the read + // from the agent drift apart, and both sides stay silent - the agent + // simply falls back to the shared client_id. XCTAssertEqual(RemoteConfigurer.clientIDAccount, "client_id") XCTAssertEqual(RemoteConfigurer.clientSecretAccount, "client_secret") XCTAssertEqual(RemoteConfigurer.keychainService, "cloudmachine-gdrive") diff --git a/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift b/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift new file mode 100644 index 0000000..bbcbb00 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/SetupPlanTests.swift @@ -0,0 +1,88 @@ +import XCTest + +@testable import CloudMachineApp + +/// The setup card is the first thing someone sees after `brew install`. These +/// pin the order (Google before anything that mounts, because that is where +/// this Mac's Drive folder is chosen) and that unmeasured state never asks +/// for an action. +@MainActor +final class SetupPlanTests: XCTestCase { + + private func inputs() -> SetupPlan.Inputs { + SetupPlan.Inputs( + hasRclone: true, hasFuse: true, remoteConfigured: true, hasFullDiskAccess: true, + agentsInstalled: true, mounted: true, imageExists: true, imageAttached: true, + timeMachineNotPointingHere: false, connectCommand: "connect", + setDestinationCommand: "sudo set") + } + + private func actions(_ input: SetupPlan.Inputs) -> [SetupStep.Action?] { + SetupPlan.steps(input).map(\.action) + } + + func testFinishedSetupHasNoSteps() { + XCTAssertEqual(SetupPlan.steps(inputs()), []) + } + + func testFreshMacStopsAtGoogleBeforeAnythingThatMounts() { + var input = inputs() + input.hasRclone = false + input.hasFuse = false + input.remoteConfigured = false + input.hasFullDiskAccess = false + input.agentsInstalled = false + input.mounted = false + input.imageExists = nil + input.imageAttached = false + let steps = SetupPlan.steps(input) + XCTAssertEqual(steps.map(\.action), [.installRclone, .installFuse, nil]) + XCTAssertEqual(steps.last?.command, "connect") + } + + func testAfterGoogleTheAgentsComeNext() { + var input = inputs() + input.hasFullDiskAccess = false + input.agentsInstalled = false + input.mounted = false + input.imageExists = nil + input.imageAttached = false + XCTAssertEqual(actions(input), [.grantFullDiskAccess, .installAgents]) + } + + func testMountedWithoutImageOffersToCreateIt() { + var input = inputs() + input.imageExists = false + input.imageAttached = false + XCTAssertEqual(actions(input), [.createImage]) + } + + func testExistingImageIsAttachedNotCreated() { + var input = inputs() + input.imageAttached = false + XCTAssertEqual(actions(input), [.attachImage]) + } + + func testUnknownImageStateAsksForNothing() { + var input = inputs() + input.imageExists = nil + input.imageAttached = false + XCTAssertEqual(SetupPlan.steps(input), []) + } + + func testNotMountedAsksForNoImageStep() { + var input = inputs() + input.mounted = false + input.imageExists = false + input.imageAttached = false + XCTAssertEqual(SetupPlan.steps(input), []) + } + + func testTimeMachineStepIsACommandToCopy() { + var input = inputs() + input.timeMachineNotPointingHere = true + let steps = SetupPlan.steps(input) + XCTAssertEqual(steps.map(\.action), [nil]) + XCTAssertEqual(steps.first?.command, "sudo set") + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift b/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift index 94c4bc4..7fd00e9 100644 --- a/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift @@ -2,110 +2,118 @@ import XCTest @testable import CloudMachineCore -/// Wiersze `drive-status`. To jest tekst, ktory czlowiek CZYTA, pytajac "czy -/// backup dziala" - a psul sie dotad dokladnie tam, gdzie brak danych -/// zamieniano na jakas wartosc. +/// The `drive-status` lines. This is the text a person READS when asking "is +/// the backup working" - and until now it broke exactly where missing data was +/// turned into some value. final class StatusLinesTests: XCTestCase { - // MARK: - Montowanie + // MARK: - Mount - func testZamontowaneINiezamontowane() { + func testMountedAndNotMounted() { XCTAssertEqual(StatusLines.mounted(true), "OK") - XCTAssertEqual(StatusLines.mounted(false), "BRAK") + XCTAssertEqual(StatusLines.mounted(false), "MISSING") } - /// „BRAK" znaczy „sprawdzilem i nie ma". Przy nieudanym odczycie tablicy - /// montowan nikt nie ma prawa wyciagnac tego wniosku - a na tej odpowiedzi - /// stoi decyzja o podpieciu obrazu. - func testNieodczytanaTablicaToNieBrakMontowania() { - let linia = StatusLines.mounted(nil) - XCTAssertNotEqual(linia, "BRAK") - XCTAssertNotEqual(linia, "OK") - XCTAssertTrue(linia.contains("NIE WIADOMO"), "dostalem: \(linia)") + /// "MISSING" means "I checked and it is not there". When reading the mount + /// table failed, nobody has the right to draw that conclusion - and the + /// decision to attach the image rests on this answer. + func testUnreadTableIsNotAMissingMount() { + let line = StatusLines.mounted(nil) + XCTAssertNotEqual(line, "MISSING") + XCTAssertNotEqual(line, "OK") + XCTAssertTrue(line.contains("UNKNOWN"), "got: \(line)") } - // MARK: - Wolne miejsce + // MARK: - Free space - func testZmierzoneWolneMiejsceJestLiczba() { + func testMeasuredFreeSpaceIsANumber() { XCTAssertEqual(StatusLines.freeDisk(427), "427 GB") } - /// Regresja z 23 wrzesnia 2026: po zmianie `BufferGuardService.freeGB()` na - /// `Int?` wiersz wypisywal `Wolne na dysku: Optional(427) GB`. Kompilator - /// zglaszal to ostrzezeniem, nie bledem, wiec ani build, ani testy tego nie - /// zatrzymaly. - func testBrakPomiaruNieWypisujeOptional() { - let linia = StatusLines.freeDisk(nil) - XCTAssertFalse(linia.contains("Optional"), "dostalem: \(linia)") - XCTAssertFalse(linia.contains("nil"), "dostalem: \(linia)") + /// Regression from 23 September 2026: after `BufferGuardService.freeGB()` + /// was changed to `Int?` the line printed `Free on disk: Optional(427) GB`. + /// The compiler reported it as a warning, not an error, so neither the build + /// nor the tests stopped it. + func testMissingMeasurementDoesNotPrintOptional() { + let line = StatusLines.freeDisk(nil) + XCTAssertFalse(line.contains("Optional"), "got: \(line)") + XCTAssertFalse(line.contains("nil"), "got: \(line)") } - /// Zero to KONKRETNA liczba, na ktorej dozorca wstrzymuje Time Machine - - /// podstawienie go za brak pomiaru bylo pierwotnym bledem, ktory drugi agent - /// naprawial zmiana typu. Wiersz nie moze go przywrocic tylnymi drzwiami. - func testBrakPomiaruToNieZero() { + /// Zero is a CONCRETE number at which the guard pauses Time Machine - + /// substituting it for a missing measurement was the original bug, which + /// the other agent fixed by changing the type. The line must not bring it + /// back through the back door. + func testMissingMeasurementIsNotZero() { XCTAssertNotEqual(StatusLines.freeDisk(nil), StatusLines.freeDisk(0)) XCTAssertEqual(StatusLines.freeDisk(0), "0 GB") } - func testBrakPomiaruJestNAZWANY() { - let linia = StatusLines.freeDisk(nil) - XCTAssertTrue(linia.contains("NIE ZMIERZONO"), "dostalem: \(linia)") + func testMissingMeasurementIsNAMED() { + let line = StatusLines.freeDisk(nil) + XCTAssertTrue(line.contains("NOT MEASURED"), "got: \(line)") XCTAssertTrue( - linia.contains("dozorca"), - "wiersz ma powiedziec, CO z tego wynika - ze dysk nie jest chroniony. Dostalem: \(linia)") + line.contains("buffer guard"), + "the line must say WHAT follows from it - that the disk is not protected. Got: \(line)") } - // MARK: - Niedoreczony alarm + // MARK: - Undelivered alarm - func testBrakNiedoreczonegoAlarmuNicNieWypisuje() { + func testNoUndeliveredAlarmPrintsNothing() { XCTAssertEqual(StatusLines.undeliveredAlert(nil), []) } - /// Sens poprawki w `HealthAlert`: nieudane powiadomienie ma dac sie ZOBACZYC. - /// Dopoki `drive-status` o tym milczal, alarm istnial tylko w pliku stanu. - func testNiedoreczonyAlarmJestWidoczny() { - let kiedy = Date(timeIntervalSince1970: 1_790_000_000) - let linie = StatusLines.undeliveredAlert( - (at: kiedy, summary: "Backup nie powstal od 30 h", reason: "osascript kod 1: brak uprawnien")) - - let tekst = linie.joined(separator: "\n") - XCTAssertTrue(tekst.contains("NIEDORECZONY ALARM"), tekst) - XCTAssertTrue(tekst.contains("Backup nie powstal od 30 h"), "tresc alarmu ma byc widoczna") - XCTAssertTrue(tekst.contains("brak uprawnien"), "powod niedoreczenia ma byc widoczny") + /// The point of the fix in `HealthAlert`: a failed notification must be + /// VISIBLE. As long as `drive-status` was silent about it, the alarm existed + /// only in the state file. + func testUndeliveredAlarmIsVisible() { + let when = Date(timeIntervalSince1970: 1_790_000_000) + let lines = StatusLines.undeliveredAlert( + ( + at: when, summary: "No backup made for 30 h", + reason: "osascript code 1: permission denied" + )) + + let text = lines.joined(separator: "\n") + XCTAssertTrue(text.contains("UNDELIVERED ALARM"), text) + XCTAssertTrue(text.contains("No backup made for 30 h"), "the alarm content must be visible") XCTAssertTrue( - tekst.contains(BackupHealth.stamp(kiedy)), - "bez daty nie wiadomo, czy alarm jest swiezy, czy sprzed tygodnia") + text.contains("permission denied"), "the reason for non-delivery must be visible") + XCTAssertTrue( + text.contains(BackupHealth.stamp(when)), + "without a date it is unknown whether the alarm is fresh or from a week ago") } - // MARK: - Cache a zaleglosc: DWIE rozne wielkosci + // MARK: - Cache vs backlog: TWO different quantities - /// Jeden wiersz "Bufor: 103 GB z 100G" odpowiadal na pytanie, na ktore nie - /// umial odpowiedziec: czy wysylka nadaza. Cache stoi pod limitem stale, - /// a o zaleglosci mowi dopiero drugi wiersz - dlatego sa dwa. - func testCacheIZaleglocSaOsobnymiWierszami() { - XCTAssertEqual(StatusLines.cacheSize(103, limitGB: 100), "103 GB z 100G") - XCTAssertEqual(StatusLines.backlog(14, items: 462), "~14 GB (462 pozycji)") + /// A single line "Buffer: 103 GB of 100G" answered a question it could not + /// answer: whether the upload keeps up. The cache sits at the limit + /// constantly, and only the second line tells about the backlog - that is + /// why there are two. + func testCacheAndBacklogAreSeparateLines() { + XCTAssertEqual(StatusLines.cacheSize(103, limitGB: 100), "103 GB of 100G") + XCTAssertEqual(StatusLines.backlog(14, items: 462), "~14 GB (462 items)") } - /// "~" nie jest ozdoba: gigabajty zaleglosci sa SZACOWANE z liczby pozycji, - /// a liczba pozycji jest pomiarem. Wiersz podajacy szacunek jako pomiar - /// ukrywa, jak mocna jest podstawa decyzji o wstrzymaniu backupu. - func testZaleglocJestOznaczonaJakoSzacunekIPodajePomiar() { - let linia = StatusLines.backlog(14, items: 462) - XCTAssertTrue(linia.hasPrefix("~"), "dostalem: \(linia)") - XCTAssertTrue(linia.contains("462"), "dostalem: \(linia)") + /// "~" is not decoration: the backlog gigabytes are ESTIMATED from the item + /// count, and the item count is a measurement. A line giving the estimate as + /// a measurement hides how solid the basis for the decision to pause the + /// backup is. + func testBacklogIsMarkedAsEstimateAndGivesTheMeasurement() { + let line = StatusLines.backlog(14, items: 462) + XCTAssertTrue(line.hasPrefix("~"), "got: \(line)") + XCTAssertTrue(line.contains("462"), "got: \(line)") } - /// Brak odpowiedzi rclone nie moze wygladac na zero ani na "Optional(0)". - func testBrakOdpowiedziRcloneJestNazwanyWObuWierszach() { + /// rclone not answering must not look like zero or like "Optional(0)". + func testNoRcloneAnswerIsNamedInBothLines() { let cache = StatusLines.cacheSize(nil, limitGB: 100) - XCTAssertTrue(cache.contains("NIE ZMIERZONO"), "dostalem: \(cache)") - XCTAssertFalse(cache.contains("0 GB"), "dostalem: \(cache)") + XCTAssertTrue(cache.contains("NOT MEASURED"), "got: \(cache)") + XCTAssertFalse(cache.contains("0 GB"), "got: \(cache)") - let zaleglosc = StatusLines.backlog(nil, items: nil) - XCTAssertTrue(zaleglosc.contains("NIE WIADOMO"), "dostalem: \(zaleglosc)") - XCTAssertFalse(zaleglosc.contains("Optional"), "dostalem: \(zaleglosc)") - XCTAssertFalse(zaleglosc.contains("~0"), "dostalem: \(zaleglosc)") + let backlog = StatusLines.backlog(nil, items: nil) + XCTAssertTrue(backlog.contains("UNKNOWN"), "got: \(backlog)") + XCTAssertFalse(backlog.contains("Optional"), "got: \(backlog)") + XCTAssertFalse(backlog.contains("~0"), "got: \(backlog)") } } diff --git a/mac-app/Tests/CloudMachineAppTests/TimeMachineDestinationStateTests.swift b/mac-app/Tests/CloudMachineAppTests/TimeMachineDestinationStateTests.swift index 158ff86..5e2acdd 100644 --- a/mac-app/Tests/CloudMachineAppTests/TimeMachineDestinationStateTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/TimeMachineDestinationStateTests.swift @@ -3,78 +3,88 @@ import XCTest @testable import CloudMachineApp -/// Trzy odpowiedzi `tmutil destinationinfo` MUSZA dac trzy rozne stany panelu. +/// Three answers of `tmutil destinationinfo` MUST give three different panel +/// states. /// -/// Do 25.09.2026 panel pytal `TimeMachineStatus.currentDestinationMountPoint()`, -/// ktora zwraca `nil` i przy braku celu, i przy braku odpowiedzi tmutil - obie -/// sciezki konczyly sie tym samym `.notRegistered`, czyli napisem "Time Machine -/// nie wskazuje na CloudMachine". Kierunek pomylki byl bezpieczny (falszywy -/// alarm), ale zdanie jest falszywe i kaze zrobic zla rzecz: rejestrowac cel, -/// ktory jest caly. Czujka `backup-health` rozroznia te dwa przypadki od -/// 23.09.2026 (`destinationReading()`), panel byl ostatnim miejscem, ktore je -/// zlewalo. +/// Until 25.09.2026 the panel asked +/// `TimeMachineStatus.currentDestinationMountPoint()`, which returns `nil` both +/// when there is no destination and when tmutil does not answer - both paths +/// ended in the same `.notRegistered`, i.e. the text "Time Machine does not +/// point to CloudMachine". The direction of the mistake was safe (a false +/// alarm), but the sentence is false and tells the person to do the wrong +/// thing: register a destination that is intact. The `backup-health` watchdog +/// has told these two cases apart since 23.09.2026 (`destinationReading()`); +/// the panel was the last place that merged them. @MainActor final class TimeMachineDestinationStateTests: XCTestCase { - private let cel = "/Volumes/CloudMachine" + private let target = "/Volumes/CloudMachine" - // MARK: - Przeklad odpowiedzi tmutil na stan panelu + // MARK: - Translating the tmutil answer into a panel state - func testZarejestrowanyCelToNaszObraz() { + func testRegisteredDestinationIsOurImage() { XCTAssertEqual( - TimeMachineState.from(.mountPoint(cel), target: cel), .registered(mountPoint: cel)) + TimeMachineState.from(.mountPoint(target), target: target), + .registered(mountPoint: target)) } - /// Cel istnieje, ale wskazuje gdzie indziej - backupu na Drive NIE MA. - func testCelWskazujacyGdzieIndziejToBrakRejestracji() { + /// A destination exists, but points elsewhere - there is NO backup on the + /// Drive. + func testDestinationPointingElsewhereIsNotRegistered() { XCTAssertEqual( - TimeMachineState.from(.mountPoint("/Volumes/ObcyDysk"), target: cel), .notRegistered) + TimeMachineState.from(.mountPoint("/Volumes/SomeOtherDisk"), target: target), + .notRegistered) } - /// tmutil odpowiedzial i zadnego celu nie ma - TO jest "nie wskazuje". - func testBrakCeluToBrakRejestracji() { - XCTAssertEqual(TimeMachineState.from(.none, target: cel), .notRegistered) + /// tmutil answered and there is no destination - THAT is "does not point". + func testNoDestinationIsNotRegistered() { + XCTAssertEqual(TimeMachineState.from(.none, target: target), .notRegistered) } - /// TA usterka. Brak odpowiedzi tmutil nie ma prawa wygladac jak przestawiony - /// cel. `tmutil destinationinfo` siega na montowanie lezace na Google Drive - /// i przy chorym montowaniu nie odpowiada wcale - a wtedy o celu nie wiemy - /// nic, co jest inna informacja niz "celu nie ma". - func testBrakOdpowiedziTmutilToNieBrakRejestracji() { - let stan = TimeMachineState.from(.noAnswer, target: cel) + /// THAT defect. tmutil not answering has no right to look like a changed + /// destination. `tmutil destinationinfo` reaches the mount living on Google + /// Drive and with a sick mount does not answer at all - and then we know + /// nothing about the destination, which is different information from + /// "there is no destination". + func testNoTmutilAnswerIsNotNotRegistered() { + let state = TimeMachineState.from(.noAnswer, target: target) XCTAssertNotEqual( - stan, .notRegistered, - "brak odpowiedzi tmutil nie moze udawac przestawionego celu") - XCTAssertEqual(stan, .noAnswer) + state, .notRegistered, + "tmutil not answering must not pretend to be a changed destination") + XCTAssertEqual(state, .noAnswer) } - // MARK: - Zdanie, ktore czlowiek CZYTA + // MARK: - The sentence a person READS - /// Naglowek jest jedyna forma, w jakiej ktokolwiek to zobaczy, wiec o tym - /// rozroznieniu musi mowic on, a nie tylko typ wewnetrzny. - func testNaglowekPrzyBrakuOdpowiedziNieOskarzaCelu() { - let status = stanPoza(timeMachine: .noAnswer) + /// The headline is the only form in which anyone will see this, so it is the + /// headline that must express the distinction, not just the internal type. + func testHeadlineOnNoAnswerDoesNotBlameTheDestination() { + let status = statusApartFrom(timeMachine: .noAnswer) XCTAssertNotEqual( - status.headline, "Time Machine nie wskazuje na CloudMachine", - "to zdanie kaze rejestrowac cel na nowo - czynnosc zbedna, gdy cel jest caly") + status.headline, "Time Machine does not point to CloudMachine", + "this sentence tells the person to register the destination again - needless when the destination is intact" + ) XCTAssertTrue( - status.headline.contains("NIE WIADOMO"), "dostalem: \(status.headline)") + status.headline.contains("UNKNOWN"), "got: \(status.headline)") XCTAssertFalse( status.healthy, - "brak wiedzy o celu to NIE zielony znaczek - nikt nie potwierdzil, ze backup dochodzi") + "not knowing about the destination is NOT a green badge - nobody confirmed the backup arrives" + ) } - /// Druga strona tego samego: PRAWDZIWIE przestawiony cel nadal musi to - /// powiedziec wprost. Inaczej "poprawka" polegalaby na uciszeniu komunikatu. - func testNaglowekPrzyPrawdziwiePrzestawionymCeluNieZmiekl() { - let status = stanPoza(timeMachine: .notRegistered) - XCTAssertEqual(status.headline, "Time Machine nie wskazuje na CloudMachine") + /// The other side of the same thing: a TRULY changed destination must still + /// say so plainly. Otherwise the "fix" would consist of silencing the + /// message. + func testHeadlineForATrulyChangedDestinationDidNotSoften() { + let status = statusApartFrom(timeMachine: .notRegistered) + XCTAssertEqual(status.headline, "Time Machine does not point to CloudMachine") XCTAssertFalse(status.healthy) } - /// Stan, w ktorym wszystko poza celem Time Machine jest w porzadku - zeby - /// naglowek mowil wlasnie o celu, a nie o czyms wczesniejszym. - private func stanPoza(timeMachine: TimeMachineState) -> AppStatus { + /// A state in which everything except the Time Machine destination is fine - + /// so that the headline talks about the destination, not about something + /// earlier. + private func statusApartFrom(timeMachine: TimeMachineState) -> AppStatus { let status = AppStatus() status.dependencyState = .ready status.remoteConfigured = true diff --git a/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift b/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift index 1212a85..287e494 100644 --- a/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift @@ -65,12 +65,13 @@ final class TimeMachineStatusTests: XCTestCase { XCTAssertEqual(progress?.totalFiles, 3_022_847) } - // Regresja dla bledu z 2026-07-29: kod wczesniej zgadywal mount point z - // twardo zakodowanej nazwy woluminu zamiast pytac o rzeczywisty - // zarejestrowany cel - po recznej zmianie nazwy woluminu (np. na - // "TimeMachine") GUI/watchdogi mylnie pokazywaly "brak woluminu". Jeden - // wpis w destinationinfo, bo funkcja zaklada dokladnie jeden aktywny cel - // (gwarancja architektury, patrz jej doc-comment) - nie "pierwszy z wielu". + // Regression for the bug of 2026-07-29: the code used to guess the mount + // point from a hard-coded volume name instead of asking for the actual + // registered destination - after a manual volume rename (e.g. to + // "TimeMachine") the GUI/watchdogs wrongly showed "no volume". One entry in + // destinationinfo, because the function assumes exactly one active + // destination (an architectural guarantee, see its doc comment) - not "the + // first of many". func testCurrentDestinationMountPoint_returnsRealMountPoint() { let singleDestinationInfo = """ ==================================================== diff --git a/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift b/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift index 1880cb7..5101921 100644 --- a/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift @@ -2,164 +2,168 @@ import XCTest @testable import CloudMachineCore -/// Co sie dzieje, gdy `tmutil` NIE ODPOWIADA. +/// What happens when `tmutil` DOES NOT ANSWER. /// -/// Do 23.09.2026 odpowiedz brzmiala "nic": wywolania `tmutil` nie mialy -/// limitu czasu, wiec przy martwym montowaniu FUSE-T (incydent ENXIO z 22.09) -/// `destinationinfo` wchodzil w nieprzerywalne I/O i `BackupHealth. -/// currentReport()` nie wracal nigdy. launchd ze `StartInterval` nie -/// uruchamia drugiej instancji, dopoki zyje pierwsza - czujka milkla NA STALE, -/// a naglowek `BackupHealth` deklarowal dokladnie odwrotnie. +/// Until 23.09.2026 the answer was "nothing": `tmutil` calls had no time +/// limit, so with a dead FUSE-T mount (the ENXIO incident of 22.09) +/// `destinationinfo` entered uninterruptible I/O and `BackupHealth. +/// currentReport()` never returned. launchd with `StartInterval` does not +/// start a second instance while the first one is alive - the watchdog went +/// silent PERMANENTLY, while the `BackupHealth` header declared exactly the +/// opposite. final class TimeMachineTimeoutTests: XCTestCase { - /// Limit MUSI istniec i MUSI miescic sie w oknie uruchomienia czujki. + /// The limit MUST exist and MUST fit within the watchdog's run window. /// - /// `ProcessRunner` poddaje sie dopiero `timeout + 10` s (SIGTERM, SIGKILL, - /// a na koniec porzucenie procesu, bo SIGKILL nie dziala na proces - /// zawieszony w jadrze). To ta liczba, nie sam `timeout`, jest realnym - /// czasem czekania i to ona musi zmiescic sie w `StartInterval` czujki - /// (1800 s), inaczej limit tylko przesuwalby zawieszenie, zamiast je - /// przerywac. - func testLimitCzasuTmutilIstniejeIMiesciSieWOknieCzujki() { - XCTAssertGreaterThan(TimeMachineStatus.commandTimeout, 0, "Brak limitu to ta awaria.") - let najdluzszeCzekanie = TimeMachineStatus.commandTimeout + 10 + /// `ProcessRunner` gives up only after `timeout + 10` s (SIGTERM, SIGKILL, + /// and finally abandoning the process, because SIGKILL does not work on a + /// process stuck in the kernel). That number, not `timeout` alone, is the + /// real waiting time, and it is that number that must fit within the + /// watchdog's `StartInterval` (1800 s), otherwise the limit would only + /// postpone the hang instead of breaking it. + func testTmutilTimeLimitExistsAndFitsWithinTheWatchdogWindow() { + XCTAssertGreaterThan(TimeMachineStatus.commandTimeout, 0, "No limit is that very failure.") + let longestWait = TimeMachineStatus.commandTimeout + 10 XCTAssertLessThan( - najdluzszeCzekanie, 1800, - "Czekanie dluzsze niz StartInterval czujki (1800 s) zjada jej wlasne okno uruchomienia.") - // Zapas nad przypadkiem zdrowym (ulamek sekundy) ma byc duzy, bo projekt - // ma juz jedna wpadke z limitem dobranym dla CZYSTEGO startu: 120 s - // wystarczalo po restarcie, a po awarii zabraklo 10 s. + longestWait, 1800, + "Waiting longer than the watchdog's StartInterval (1800 s) eats up its own run window.") + // The margin over the healthy case (a fraction of a second) has to be + // large, because the project already has one slip with a limit chosen for + // a CLEAN start: 120 s was enough after a restart, and after a failure it + // was 10 s short. XCTAssertGreaterThanOrEqual( TimeMachineStatus.commandTimeout, 60, - "Limit dobrany 'na zdrowy system' padnie przy pierwszej powaznej awarii.") + "A limit chosen 'for a healthy system' will fail at the first serious failure.") } - /// Brak odpowiedzi od tmutil jest AWARIA, a nie "cel przestawiony". + /// No answer from tmutil is a FAILURE, not "destination changed". /// - /// To rozroznienie jest calym sensem limitu czasu. Gdyby zawieszony tmutil - /// zglaszal sie jako `destinationRegistered: false`, czujka wysylalaby - /// czlowieka do przestawiania celu, ktory jest ustawiony poprawnie - a - /// prawdziwa przyczyna (martwe montowanie) zostalaby nietknieta. - func testBrakOdpowiedziTmutilJestOsobnaAwaria() { - let teraz = Date(timeIntervalSince1970: 1_758_000_000) - - let nieWiadomo = BackupHealth.evaluate( - lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), - result: 0, now: teraz, mounted: true, attached: true, destinationRegistered: nil, + /// This distinction is the whole point of the time limit. If a hung tmutil + /// reported itself as `destinationRegistered: false`, the watchdog would + /// send the person off to change a destination that is set correctly - and + /// the real cause (a dead mount) would be left untouched. + func testNoTmutilAnswerIsASeparateFailure() { + let now = Date(timeIntervalSince1970: 1_758_000_000) + + let unknown = BackupHealth.evaluate( + lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), + result: 0, now: now, mounted: true, attached: true, destinationRegistered: nil, erroredFiles: 0, outOfSpace: false, queueReadable: true) - XCTAssertFalse(nieWiadomo.healthy, "Nie wiadomo = nie zdrowo.") + XCTAssertFalse(unknown.healthy, "Unknown = not healthy.") XCTAssertTrue( - nieWiadomo.problems.contains { $0.summary.contains("tmutil nie odpowiada") }, - "Zawieszony tmutil musi byc nazwany po imieniu.") + unknown.problems.contains { $0.summary.contains("tmutil is not responding") }, + "A hung tmutil must be called by its name.") XCTAssertFalse( - nieWiadomo.problems.contains { $0.summary == "Time Machine nie wskazuje na CloudMachine" }, - "To NIE jest przestawiony cel - taki komunikat wysyla czlowieka w zla strone.") + unknown.problems.contains { $0.summary == "Time Machine does not point to CloudMachine" }, + "This is NOT a changed destination - such a message sends the person the wrong way.") } - /// Odpowiedz "cel jest przestawiony" nadal ma brzmiec jak dawniej - - /// bez tego testu naprawa mogla zamienic jeden komunikat na drugi. - func testPrzestawionyCelNadalJestZglaszanyOsobno() { - let teraz = Date(timeIntervalSince1970: 1_758_000_000) + /// The answer "the destination is changed" must still read as before - + /// without this test the fix could have swapped one message for the other. + func testChangedDestinationIsStillReportedSeparately() { + let now = Date(timeIntervalSince1970: 1_758_000_000) - let przestawiony = BackupHealth.evaluate( - lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), - result: 0, now: teraz, mounted: true, attached: true, destinationRegistered: false, + let changed = BackupHealth.evaluate( + lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), + result: 0, now: now, mounted: true, attached: true, destinationRegistered: false, erroredFiles: 0, outOfSpace: false, queueReadable: true) XCTAssertTrue( - przestawiony.problems.contains { $0.summary == "Time Machine nie wskazuje na CloudMachine" }) - XCTAssertFalse(przestawiony.problems.contains { $0.summary.contains("tmutil nie odpowiada") }) + changed.problems.contains { $0.summary == "Time Machine does not point to CloudMachine" }) + XCTAssertFalse(changed.problems.contains { $0.summary.contains("tmutil is not responding") }) } - /// Poprawnie ustawiony cel nie zglasza zadnego z tych dwoch problemow. - func testUstawionyCelNieZglaszaNiczego() { - let teraz = Date(timeIntervalSince1970: 1_758_000_000) + /// A correctly set destination reports neither of these two problems. + func testCorrectlySetDestinationReportsNothing() { + let now = Date(timeIntervalSince1970: 1_758_000_000) - let dobrze = BackupHealth.evaluate( - lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), - result: 0, now: teraz, mounted: true, attached: true, destinationRegistered: true, + let fine = BackupHealth.evaluate( + lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), + result: 0, now: now, mounted: true, attached: true, destinationRegistered: true, erroredFiles: 0, outOfSpace: false, queueReadable: true) - XCTAssertTrue(dobrze.healthy) + XCTAssertTrue(fine.healthy) } - // MARK: - Nieodczytana tablica montowan + // MARK: - Unread mount table - /// Punkt odniesienia dla tej grupy: wszystko sprawne. - private func zdrowa( + /// The reference point for this group: everything working. + private func healthy( mounted: Bool? = true, attached: Bool? = true, imageDeadErrno: Int32? = nil ) -> BackupHealth.Report { - let teraz = Date(timeIntervalSince1970: 1_758_000_000) + let now = Date(timeIntervalSince1970: 1_758_000_000) return BackupHealth.evaluate( - lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), - result: 0, now: teraz, mounted: mounted, attached: attached, destinationRegistered: true, + lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), + result: 0, now: now, mounted: mounted, attached: attached, destinationRegistered: true, erroredFiles: 0, outOfSpace: false, queueReadable: true, imageDeadErrno: imageDeadErrno) } - /// TA cisza. `BackupImageService.Attachment` dostal czwarty przypadek - /// `.unknown` ("tablicy montowan nie udalo sie odczytac"), a wolajacy - /// przekazywal do czujki `attachment != .detached` - czyli `.unknown` - /// wchodzilo jako `true`, "podpiety". Czujka, ktorej JEDYNYM zadaniem jest - /// nie twierdzic rzeczy, ktorych nie wie, milczala o stanie, ktorego nie - /// znala. To jedyne miejsce w tej turze, gdzie "nie wiem" szlo w strone - /// ciszy, a nie alarmu. - func testNieodczytanyStanObrazuNieJestCisza() { - let nieWiadomo = zdrowa(attached: nil) - - XCTAssertFalse(nieWiadomo.healthy, "Nie wiadomo = nie zdrowo. Cisza tu nie wolno.") + /// THAT silence. `BackupImageService.Attachment` got a fourth case + /// `.unknown` ("the mount table could not be read"), and the caller passed + /// `attachment != .detached` to the watchdog - i.e. `.unknown` went in as + /// `true`, "attached". The watchdog, whose ONLY job is not to claim things + /// it does not know, stayed silent about a state it did not know. This was + /// the only place in this round where "I do not know" went towards silence + /// rather than an alarm. + func testUnreadImageStateIsNotSilence() { + let unknown = healthy(attached: nil) + + XCTAssertFalse(unknown.healthy, "Unknown = not healthy. Silence is not allowed here.") XCTAssertTrue( - nieWiadomo.problems.contains { $0.summary == "Nie wiadomo, czy obraz backupu jest podpiety" }) + unknown.problems.contains { + $0.summary == "Unknown whether the backup image is attached" + }) } - /// ...i nie jest tym samym, co realne odpiecie. Komunikat "nie jest - /// podpiety" wyslalby czlowieka do podpinania obrazu, ktory moze byc - /// podpiety poprawnie - ta sama zasada, co przy przestawionym celu. - func testNieodczytanyStanObrazuBrzmiInaczejNizOdpiecie() { - let nieWiadomo = zdrowa(attached: nil) - let odpiety = zdrowa(attached: false) + /// ...and it is not the same as a real detachment. The message "is not + /// attached" would send the person off to attach an image that may be + /// attached correctly - the same principle as with a changed destination. + func testUnreadImageStateSoundsDifferentFromDetachment() { + let unknown = healthy(attached: nil) + let detached = healthy(attached: false) XCTAssertFalse( - nieWiadomo.problems.contains { $0.summary == "Obraz backupu nie jest podpiety" }, - "Brak odczytu NIE jest odpieciem.") - XCTAssertTrue(odpiety.problems.contains { $0.summary == "Obraz backupu nie jest podpiety" }) + unknown.problems.contains { $0.summary == "The backup image is not attached" }, + "Not reading is NOT a detachment.") + XCTAssertTrue(detached.problems.contains { $0.summary == "The backup image is not attached" }) XCTAssertFalse( - odpiety.problems.contains { $0.summary.hasPrefix("Nie wiadomo, czy obraz") }, - "Realne odpiecie jest FAKTEM, a nie niewiadoma.") + detached.problems.contains { $0.summary.hasPrefix("Unknown whether the backup image") }, + "A real detachment is a FACT, not an unknown.") } - /// To samo dla montowania. `isMounted` to `mountedState() ?? false`, wiec - /// nieodczytana tablica montowan zglaszala sie jako "montowanie nie dziala" - /// - alarm o stanie, ktorego nikt nie zmierzyl, wysylajacy czlowieka do - /// naprawiania czegos, co moze byc sprawne. - func testNieodczytaneMontowanieBrzmiInaczejNizBrakMontowania() { - let nieWiadomo = zdrowa(mounted: nil) - let brak = zdrowa(mounted: false) + /// The same for the mount. `isMounted` is `mountedState() ?? false`, so an + /// unread mount table reported itself as "mount is not working" - an alarm + /// about a state nobody measured, sending the person off to fix something + /// that may be fine. + func testUnreadMountSoundsDifferentFromMissingMount() { + let unknown = healthy(mounted: nil) + let missing = healthy(mounted: false) - XCTAssertFalse(nieWiadomo.healthy) + XCTAssertFalse(unknown.healthy) XCTAssertTrue( - nieWiadomo.problems.contains { - $0.summary == "Nie wiadomo, czy montowanie Google Drive dziala" + unknown.problems.contains { + $0.summary == "Unknown whether the Google Drive mount is working" }) XCTAssertFalse( - nieWiadomo.problems.contains { $0.summary == "Montowanie Google Drive nie dziala" }) - XCTAssertTrue(brak.problems.contains { $0.summary == "Montowanie Google Drive nie dziala" }) + unknown.problems.contains { $0.summary == "Google Drive mount is not working" }) + XCTAssertTrue(missing.problems.contains { $0.summary == "Google Drive mount is not working" }) } - /// Podpiety, ale MARTWY obraz musi nadal byc zglaszany - przebudowa galezi - /// `attached` na trojstanowa nie moze zgubic tego przypadku, bo kosztowal - /// juz 15 godzin bez kopii. - func testMartwyObrazNadalJestZglaszany() { - let martwy = zdrowa(attached: true, imageDeadErrno: 6) - XCTAssertTrue(martwy.problems.contains { $0.summary.contains("MARTWY (errno 6)") }) - // Przy nieznanym stanie NIE zgadujemy, ze obraz zyje. - XCTAssertFalse(zdrowa(attached: nil).problems.contains { $0.summary.contains("MARTWY") }) + /// An attached but DEAD image must still be reported - rebuilding the + /// `attached` branch into three states must not lose this case, because it + /// has already cost 15 hours without a backup. + func testDeadImageIsStillReported() { + let dead = healthy(attached: true, imageDeadErrno: 6) + XCTAssertTrue(dead.problems.contains { $0.summary.contains("DEAD (errno 6)") }) + // With an unknown state we do NOT guess that the image is alive. + XCTAssertFalse(healthy(attached: nil).problems.contains { $0.summary.contains("DEAD") }) } - /// `runningState()` istnieje po to, zeby "nie trwa" i "nie wiadomo" dalo - /// sie rozroznic. `isRunning()` zostaje jako skrot dla miejsc czysto - /// informacyjnych i tam wolno mu zlewac te dwa przypadki. - func testParsowanieStatusuNieZmieniloSie() { + /// `runningState()` exists so that "not in progress" and "unknown" can be + /// told apart. `isRunning()` stays as a shortcut for purely informational + /// places, and there it may merge the two cases. + func testStatusParsingHasNotChanged() { XCTAssertTrue(TimeMachineStatus.isRunning(statusOutput: "Running = 1;")) XCTAssertFalse(TimeMachineStatus.isRunning(statusOutput: "Running = 0;")) XCTAssertFalse(TimeMachineStatus.isRunning(statusOutput: "")) diff --git a/mac-app/Tests/CloudMachineAppTests/UploadDrainTests.swift b/mac-app/Tests/CloudMachineAppTests/UploadDrainTests.swift index ab261b3..af8f5d5 100644 --- a/mac-app/Tests/CloudMachineAppTests/UploadDrainTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/UploadDrainTests.swift @@ -2,11 +2,11 @@ import XCTest @testable import CloudMachineCore -/// Testy czekania na wysylke przed `hdiutil attach`. +/// Tests of waiting for the upload before `hdiutil attach`. /// -/// Odtwarzaja rozruch z 01.10.2026: ~600 pasm zaleglosci schodzacych przez -/// ~6 min. Stare czekanie (sztywne 120 s) poddawalo sie w polowie i hdiutil -/// ruszal w pelnej wysylce. +/// They replay the start-up of 01.10.2026: a backlog of ~600 bands draining +/// over ~6 min. The old wait (a fixed 120 s) gave up halfway and hdiutil +/// started in the middle of the full upload. final class UploadDrainTests: XCTestCase { private final class FakeClock { @@ -14,9 +14,9 @@ final class UploadDrainTests: XCTestCase { func sleep(_ seconds: TimeInterval) { now.addTimeInterval(seconds) } } - /// ZNANA ZLA PROBKA: 600 pozycji schodzi ~2 na sekunde, czyli ~5 min. - /// Sztywne 120 s puscilo by hdiutil przy ~360 pozycjach w kolejce. - func testCzekaNaZaleglosciKtoraSchodziDluzejNizDwieMinuty() async { + /// KNOWN BAD SAMPLE: 600 items drain at ~2 per second, i.e. ~5 min. + /// A fixed 120 s would have let hdiutil go with ~360 items in the queue. + func testWaitsForABacklogThatTakesLongerThanTwoMinutes() async { let clock = FakeClock() let start = clock.now let outcome = await UploadDrain.wait( @@ -26,9 +26,9 @@ final class UploadDrainTests: XCTestCase { XCTAssertGreaterThanOrEqual(clock.now.timeIntervalSince(start), 300) } - /// Wyczerpany limit dobowy: kolejka stoi. Nie czekamy wtedy pelnych 20 min, - /// tylko `stallTimeout`. - func testPoddajeSieGdyKolejkaStoi() async { + /// Daily limit exhausted: the queue stands still. We then do not wait the full + /// 20 min, only `stallTimeout`. + func testGivesUpWhenTheQueueStandsStill() async { let clock = FakeClock() let start = clock.now let outcome = await UploadDrain.wait( @@ -39,8 +39,8 @@ final class UploadDrainTests: XCTestCase { XCTAssertLessThan(elapsed, UploadDrain.defaultStallTimeout + UploadDrain.defaultPoll * 2) } - /// Wahania w gore (TM dopisuje) nie sa postepem - liczy sie nowe minimum. - func testWahaniaBezNowegoMinimumToNiePostep() async { + /// Upward swings (TM adding writes) are not progress - only a new minimum counts. + func testSwingsWithoutANewMinimumAreNotProgress() async { let clock = FakeClock() var flip = false let outcome = await UploadDrain.wait( @@ -52,8 +52,8 @@ final class UploadDrainTests: XCTestCase { XCTAssertEqual(outcome, .stalled(unsent: 50)) } - /// Twardy sufit: kolejka schodzi, ale wolniej, niz trzeba. - func testTwardySufitPrzyPowolnymPostepie() async { + /// Hard ceiling: the queue drains, but more slowly than needed. + func testHardCeilingOnSlowProgress() async { let clock = FakeClock() let start = clock.now let outcome = await UploadDrain.wait( @@ -64,17 +64,17 @@ final class UploadDrainTests: XCTestCase { clock.now.timeIntervalSince(start), UploadDrain.defaultMaxTotal + UploadDrain.defaultPoll * 2) } - /// rclone milczy - to nie postep i nie pusta kolejka. - func testBrakOdpowiedziNieJestPustaKolejka() async { + /// rclone is silent - that is neither progress nor an empty queue. + func testNoAnswerIsNotAnEmptyQueue() async { let clock = FakeClock() let outcome = await UploadDrain.wait( now: { clock.now }, sleep: { clock.sleep($0) }, expire: {}, unsent: { nil }) XCTAssertEqual(outcome, .noAnswer) } - /// Pozycje wczytane z brudnego cache PO pierwszym przesunieciu terminow - /// dostaja pelne 10 min - przesuniecie trzeba ponawiac. - func testPonawiaPrzesuniecieTerminow() async { + /// Items read from the dirty cache AFTER the first deadline move get the + /// full 10 min - the move has to be repeated. + func testRepeatsMovingTheDeadlines() async { let clock = FakeClock() var expiries = 0 _ = await UploadDrain.wait( diff --git a/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift b/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift index 68d9bc7..4cce5d7 100644 --- a/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift @@ -2,11 +2,11 @@ import XCTest @testable import CloudMachineCore -/// Stan wysylki pokazywany uzytkownikowi. +/// The upload state shown to the user. /// -/// Sedno tych testow to jedno rozroznienie: **limit dobowy mija sam, brak -/// miejsca nie**. Oba wygladaja tak samo w kazdym liczniku i znacza cos -/// zupelnie innego dla czlowieka, ktory patrzy na ekran. +/// The heart of these tests is one distinction: **the daily limit passes by +/// itself, lack of space does not**. Both look the same in every counter and +/// mean something entirely different to the person looking at the screen. final class UploadStateTests: XCTestCase { private func state( @@ -20,38 +20,39 @@ final class UploadStateTests: XCTestCase { dailyQuotaExhausted: dailyQuotaExhausted) } - /// TO jest ta roznica. Limit dobowy: nie rob nic, ale nie udawaj, ze jest - /// dobrze. Brak miejsca: zrob cos. - func testLimitDobowyNieWymagaReakcjiAleNieJestNominalny() { + /// THIS is the difference. Daily limit: do nothing, but do not pretend all is + /// well. No space: do something. + func testDailyLimitNeedsNoActionButIsNotNominal() { let s = state(queued: 800, dailyQuotaExhausted: true) XCTAssertEqual(s, .dailyQuotaExhausted) - XCTAssertFalse(s.needsAttention, "limit dobowy mija sam - nie ma o co prosic uzytkownika") - XCTAssertFalse(s.isNominal, "ale pasma leza tylko lokalnie, wiec nie jest to stan nominalny") + XCTAssertFalse( + s.needsAttention, "the daily limit passes by itself - nothing to ask the user for") + XCTAssertFalse(s.isNominal, "but the bands sit only locally, so this is not a nominal state") } - func testBrakMiejscaWymagaReakcji() { + func testNoSpaceNeedsAction() { let s = state(queued: 800, driveFull: true) XCTAssertEqual(s, .driveFull) XCTAssertTrue(s.needsAttention) XCTAssertFalse(s.isNominal) } - /// "rclone odpuscil" znaczy, ze kopia jest niekompletna TERAZ. Limit znaczy - /// tylko, ze poczeka. Dlatego bledy wyprzedzaja limit. - func testPlikiPorzuconeWyprzedzajaLimitDobowy() { + /// "rclone gave up" means the backup is incomplete NOW. The limit only means + /// it will wait. That is why errors come before the limit. + func testAbandonedFilesComeBeforeTheDailyLimit() { let s = state(queued: 800, failedFiles: 3, dailyQuotaExhausted: true) XCTAssertEqual(s, .failedFiles(3)) XCTAssertTrue(s.needsAttention) } - /// Brak montowania przykrywa wszystko - bez niego pozostale liczniki nie - /// opisuja niczego sensownego. - func testBrakMontowaniaPrzykrywaWszystko() { + /// No mount overrides everything - without it the other counters describe + /// nothing meaningful. + func testNoMountOverridesEverything() { let s = state(mounted: false, queued: 800, failedFiles: 3, driveFull: true) XCTAssertEqual(s, .mountDown) } - func testTrwajacaWysylkaJestNominalna() { + func testOngoingUploadIsNominal() { let s = state(queued: 120, inProgress: 8) XCTAssertEqual(s, .flowing(queued: 128)) XCTAssertTrue(s.isNominal) @@ -59,76 +60,79 @@ final class UploadStateTests: XCTestCase { XCTAssertFalse(s.needsAttention) } - func testPustaKolejkaToWszystkoWyslane() { + func testEmptyQueueMeansEverythingUploaded() { let s = state() XCTAssertEqual(s, .upToDate) XCTAssertTrue(s.isNominal) XCTAssertFalse(s.isMovingData) } - /// REGRESJA 23.09.2026. `rclone rc vfs/stats` przekroczyl limit czasu, wiec - /// `queueStats()` oddal `nil`, a wolajacy podstawil zera - i przy 386 pasmach - /// w kolejce `drive-status` oraz karta w interfejsie oglosily "Wszystko - /// wyslane na Google Drive". Brak odpowiedzi ma wygladac jak brak odpowiedzi. - func testBrakOdczytuKolejkiNieUdajePustejKolejki() { + /// REGRESSION 23.09.2026. `rclone rc vfs/stats` exceeded the time limit, so + /// `queueStats()` returned `nil`, and the caller substituted zeros - and with + /// 386 bands in the queue `drive-status` and the card in the interface + /// announced "Everything uploaded to Google Drive". No answer has to look + /// like no answer. + func testNoQueueReadingDoesNotPretendToBeAnEmptyQueue() { let s = state(queueKnown: false) XCTAssertEqual(s, .queueUnknown) XCTAssertNotEqual(s, .upToDate) - XCTAssertFalse(s.isNominal, "nieznany stan nie ma prawa swiecic na zielono") + XCTAssertFalse(s.isNominal, "an unknown state has no right to glow green") XCTAssertFalse(s.isMovingData) - XCTAssertFalse(s.needsAttention, "od trwalosci problemu jest backup-health, nie kolor karty") XCTAssertFalse( - s.headline.contains("Wszystko wysłane"), "to zdanie wlasnie bylo klamstwem") + s.needsAttention, "persistence of the problem is backup-health's job, not the card colour's") + XCTAssertFalse( + s.headline.contains("Everything uploaded"), "that very sentence was the lie") } - /// Twarde fakty, ktore nie pochodza z kolejki, wyprzedzaja niewiedze o niej: - /// brak montowania i brak miejsca na Dysku wiadomo bez `vfs/stats`. - func testFaktySpozaKolejkiWyprzedzajaNiewiedze() { + /// Hard facts that do not come from the queue come before not knowing about + /// it: no mount and no space on Drive are known without `vfs/stats`. + func testFactsFromOutsideTheQueueComeBeforeNotKnowing() { XCTAssertEqual(state(mounted: false, queueKnown: false), .mountDown) XCTAssertEqual(state(queueKnown: false, driveFull: true), .driveFull) } - /// Limit dobowy przepada za to celowo: skoro nie wiadomo, czy rclone czegos - /// nie porzucil, "poczekaj, minie samo" nie jest uczciwa odpowiedzia. - func testLimitDobowyNiePrzykrywaNiewiedzyOKolejce() { + /// The daily limit, on the other hand, is lost on purpose: when it is unknown + /// whether rclone abandoned something, "wait, it will pass" is not an honest + /// answer. + func testDailyLimitDoesNotHideNotKnowingAboutTheQueue() { XCTAssertEqual(state(queueKnown: false, dailyQuotaExhausted: true), .queueUnknown) } - func testKazdyStanMaEtykieteDlaCzlowieka() { - let wszystkie: [UploadState] = [ + func testEveryStateHasALabelForAPerson() { + let all: [UploadState] = [ .mountDown, .driveFull, .failedFiles(2), .bufferFull, .dailyQuotaExhausted, .flowing(queued: 5), .upToDate, .queueUnknown, ] - for s in wszystkie { - XCTAssertFalse(s.badge.isEmpty, "brak etykiety dla \(s)") + for s in all { + XCTAssertFalse(s.badge.isEmpty, "no label for \(s)") } XCTAssertNotEqual( UploadState.queueUnknown.badge, UploadState.upToDate.badge, - "niewiedza i porzadek nie moga wygladac tak samo") + "not knowing and all-good must not look the same") XCTAssertNotEqual( UploadState.queueUnknown.badge, UploadState.dailyQuotaExhausted.badge, - "niewiedza to nie jest 'minie samo'") + "not knowing is not 'will pass by itself'") } - /// Kazdy stan musi umiec sie wytlumaczyc. Pusty tekst na karcie to dokladnie - /// ten rodzaj cichej awarii, ktory ten projekt juz raz mial. - func testKazdyStanMaTrescDlaCzlowieka() { - let wszystkie: [UploadState] = [ + /// Every state has to be able to explain itself. Empty text on the card is + /// exactly the kind of silent failure this project has already had once. + func testEveryStateHasTextForAPerson() { + let all: [UploadState] = [ .mountDown, .driveFull, .failedFiles(2), .bufferFull, .dailyQuotaExhausted, .flowing(queued: 5), .upToDate, .queueUnknown, ] - for s in wszystkie { - XCTAssertFalse(s.headline.isEmpty, "brak naglowka dla \(s)") - XCTAssertGreaterThan(s.explanation.count, 20, "wyjasnienie za krotkie dla \(s)") + for s in all { + XCTAssertFalse(s.headline.isEmpty, "no headline for \(s)") + XCTAssertGreaterThan(s.explanation.count, 20, "explanation too short for \(s)") } } - /// Przy wyczerpanym limicie uzytkownik ma zobaczyc, ze NIE musi nic robic - - /// inaczej bedzie szukal awarii tam, gdzie jej nie ma. - func testWyjasnienieLimituUspokajaZamiastStraszyc() { - let tekst = UploadState.dailyQuotaExhausted.explanation - XCTAssertTrue(tekst.contains("750 GB"), "ma podac, o jaki limit chodzi") - XCTAssertTrue(tekst.contains("sam"), "ma powiedziec, ze mija sam") - XCTAssertTrue(tekst.contains("Nie trzeba nic robić"), "ma wprost zwolnic z dzialania") + /// With the limit exhausted the user has to see that they DO NOT have to do + /// anything - otherwise they will look for a failure where there is none. + func testLimitExplanationReassuresInsteadOfAlarming() { + let text = UploadState.dailyQuotaExhausted.explanation + XCTAssertTrue(text.contains("750 GB"), "has to say which limit it is about") + XCTAssertTrue(text.contains("by itself"), "has to say that it passes by itself") + XCTAssertTrue(text.contains("There is nothing to do"), "has to release from action outright") } } diff --git a/mac-app/Tests/CloudMachineAppTests/WatchdogHeartbeatTests.swift b/mac-app/Tests/CloudMachineAppTests/WatchdogHeartbeatTests.swift index d2e9137..c9db923 100644 --- a/mac-app/Tests/CloudMachineAppTests/WatchdogHeartbeatTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/WatchdogHeartbeatTests.swift @@ -3,129 +3,129 @@ import XCTest @testable import CloudMachineApp -/// Nadzor nad samym nadzorem: czy da sie odroznic "czujka przebiegla i nie -/// miala o czym donosic" od "czujki nie ma". +/// Supervision of the supervisor itself: whether "the watchdog ran and had +/// nothing to report" can be told apart from "there is no watchdog". /// -/// `backup-health` chodzi ze `StartInterval 1800` i BEZ `KeepAlive`, a jedynym -/// objawem wyladowanego albo zawieszonego agenta jest cisza - przy czym cisza -/// jest tu stanem NORMALNYM (README: "Empty logs after a fresh install are -/// normal - the agents only write when something happens"). Do 25.09.2026 -/// czujka nie zostawiala po sobie zadnego sladu, wiec te dwa stany wygladaly -/// identycznie. +/// `backup-health` runs with `StartInterval 1800` and WITHOUT `KeepAlive`, and +/// the only symptom of an unloaded or hung agent is silence - while silence is +/// the NORMAL state here (README: "Empty logs after a fresh install are +/// normal - the agents only write when something happens"). Until 25.09.2026 +/// the watchdog left no trace behind, so these two states looked identical. @MainActor final class WatchdogHeartbeatTests: XCTestCase { - private var katalog: URL! - private var znacznik: URL! + private var directory: URL! + private var marker: URL! override func setUpWithError() throws { - katalog = FileManager.default.temporaryDirectory + directory = FileManager.default.temporaryDirectory .appendingPathComponent("cm-heartbeat-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) - // KAZDY test podstawia plik - zaden nie ma prawa dotknac prawdziwego - // znacznika w `~/Library/Application Support/CloudMachine/`, bo wtedy - // przebieg `swift test` meldowalby czujke, ktora nie chodzila. - znacznik = katalog.appendingPathComponent("backup-health-last-run") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + // EVERY test substitutes the file - none has the right to touch the real + // marker in `~/Library/Application Support/CloudMachine/`, because then a + // `swift test` run would report a watchdog that did not run. + marker = directory.appendingPathComponent("backup-health-last-run") } override func tearDownWithError() throws { - try? FileManager.default.removeItem(at: katalog) + try? FileManager.default.removeItem(at: directory) } - // MARK: - Sam znacznik + // MARK: - The marker itself - /// TA usterka: przed poprawka nie bylo CZEGO odczytac. - func testPrzebiegZostawiaSladDoOdczytania() throws { - let teraz = Date(timeIntervalSince1970: 1_790_000_000) - XCTAssertTrue(WatchdogHeartbeat.record(now: teraz, file: znacznik)) - let odczytane = try XCTUnwrap( - WatchdogHeartbeat.lastRun(file: znacznik), - "znacznik ma sie dac odczytac z powrotem - inaczej nie mowi nic") + /// THAT defect: before the fix there was NOTHING to read. + func testRunLeavesATraceThatCanBeRead() throws { + let now = Date(timeIntervalSince1970: 1_790_000_000) + XCTAssertTrue(WatchdogHeartbeat.record(now: now, file: marker)) + let read = try XCTUnwrap( + WatchdogHeartbeat.lastRun(file: marker), + "the marker must be readable back - otherwise it says nothing") XCTAssertEqual( - odczytane.timeIntervalSince1970, teraz.timeIntervalSince1970, accuracy: 1) + read.timeIntervalSince1970, now.timeIntervalSince1970, accuracy: 1) } - /// Plik ma byc czytelny dla czlowieka w trakcie diagnozy, nie tylko dla nas. - func testZnacznikJestCzytelnymTekstem() throws { - WatchdogHeartbeat.record(now: Date(timeIntervalSince1970: 1_790_000_000), file: znacznik) - let tresc = try String(contentsOf: znacznik, encoding: .utf8) - XCTAssertTrue(tresc.hasPrefix("2026-"), "dostalem: \(tresc)") + /// The file must be readable by a person during diagnosis, not just by us. + func testMarkerIsReadableText() throws { + WatchdogHeartbeat.record(now: Date(timeIntervalSince1970: 1_790_000_000), file: marker) + let content = try String(contentsOf: marker, encoding: .utf8) + XCTAssertTrue(content.hasPrefix("2026-"), "got: \(content)") } - /// Brak znacznika to NIE "czujka nie chodzi od zera sekund" i nie awaria - /// odczytu - to trzeci, osobny stan. Zdarza sie na swiezej instalacji. - func testBrakZnacznikaToOsobnyStan() { - XCTAssertNil(WatchdogHeartbeat.lastRun(file: znacznik)) - XCTAssertEqual(WatchdogHeartbeat.current(file: znacznik), .never) + /// A missing marker is NOT "the watchdog has not run for zero seconds" and + /// not a read failure - it is a third, separate state. It happens on a fresh + /// installation. + func testMissingMarkerIsASeparateState() { + XCTAssertNil(WatchdogHeartbeat.lastRun(file: marker)) + XCTAssertEqual(WatchdogHeartbeat.current(file: marker), .never) } - // MARK: - Ocena wieku + // MARK: - Age assessment - func testSwiezyPrzebiegJestSwiezy() { - let teraz = Date() - let ocena = WatchdogHeartbeat.freshness( - lastRun: teraz.addingTimeInterval(-600), now: teraz) - guard case .fresh = ocena else { return XCTFail("dostalem: \(ocena)") } + func testFreshRunIsFresh() { + let now = Date() + let assessment = WatchdogHeartbeat.freshness( + lastRun: now.addingTimeInterval(-600), now: now) + guard case .fresh = assessment else { return XCTFail("got: \(assessment)") } } - /// Dwa pominiete przebiegi z rzedu (StartInterval 1800) to juz nie przypadek. - func testCiszaDluzszaNizLimitToNieSwiezosc() { - let teraz = Date() - let ocena = WatchdogHeartbeat.freshness( - lastRun: teraz.addingTimeInterval(-3 * 3600), now: teraz) - guard case .stale(_, let wiek) = ocena else { return XCTFail("dostalem: \(ocena)") } - XCTAssertEqual(wiek, 3 * 3600, accuracy: 1) + /// Two missed runs in a row (StartInterval 1800) are no longer chance. + func testSilenceLongerThanTheLimitIsNotFreshness() { + let now = Date() + let assessment = WatchdogHeartbeat.freshness( + lastRun: now.addingTimeInterval(-3 * 3600), now: now) + guard case .stale(_, let age) = assessment else { return XCTFail("got: \(assessment)") } + XCTAssertEqual(age, 3 * 3600, accuracy: 1) } - /// Znacznik z przyszlosci (przestawiony zegar, plik przeniesiony z innej - /// maszyny) NIE jest swiezoscia: nie wiemy, kiedy czujka chodzila. Mylimy sie - /// w strone ostrzezenia, nie w strone spokoju. - func testZnacznikZPrzyszlosciNieUchodziZaSwiezy() { - let teraz = Date() - let ocena = WatchdogHeartbeat.freshness( - lastRun: teraz.addingTimeInterval(3600), now: teraz) - guard case .stale = ocena else { return XCTFail("dostalem: \(ocena)") } + /// A marker from the future (a clock that was changed, a file moved from + /// another machine) is NOT freshness: we do not know when the watchdog ran. + /// We err on the side of a warning, not on the side of calm. + func testMarkerFromTheFutureDoesNotPassAsFresh() { + let now = Date() + let assessment = WatchdogHeartbeat.freshness( + lastRun: now.addingTimeInterval(3600), now: now) + guard case .stale = assessment else { return XCTFail("got: \(assessment)") } } - // MARK: - Wiersz, ktory czlowiek CZYTA (drive-status i panel) + // MARK: - The line a person READS (drive-status and the panel) - func testWierszDlaSwiezegoPrzebieguPodajeDateIWiek() { - let teraz = Date() - let linia = StatusLines.watchdogRun( - WatchdogHeartbeat.freshness(lastRun: teraz.addingTimeInterval(-720), now: teraz)) - XCTAssertTrue(linia.contains("12 min temu"), "dostalem: \(linia)") - XCTAssertFalse(linia.contains("MOZE NIE CHODZIC"), "dostalem: \(linia)") + func testLineForAFreshRunGivesDateAndAge() { + let now = Date() + let line = StatusLines.watchdogRun( + WatchdogHeartbeat.freshness(lastRun: now.addingTimeInterval(-720), now: now)) + XCTAssertTrue(line.contains("12 min ago"), "got: \(line)") + XCTAssertFalse(line.contains("MAY NOT BE RUNNING"), "got: \(line)") } - /// Sedno punktu 13: wiersz musi POWIEDZIEC, ze czujka mogla przestac chodzic. - /// Sama data bez tego zdania niczego nie zaklóca - czlowiek przesuwa po niej - /// wzrokiem tak samo jak po dacie sprzed dwoch minut. - func testWierszDlaMilczacejCzujkiOstrzega() { - let teraz = Date() - let linia = StatusLines.watchdogRun( - WatchdogHeartbeat.freshness(lastRun: teraz.addingTimeInterval(-3 * 24 * 3600), now: teraz)) - XCTAssertTrue(linia.contains("CZUJKA MOZE NIE CHODZIC"), "dostalem: \(linia)") - XCTAssertTrue(linia.contains("3 dni temu"), "dostalem: \(linia)") + /// The core of item 13: the line must SAY that the watchdog may have stopped + /// running. A date alone without that sentence disturbs nothing - a person's + /// eyes slide over it just as over a date from two minutes ago. + func testLineForASilentWatchdogWarns() { + let now = Date() + let line = StatusLines.watchdogRun( + WatchdogHeartbeat.freshness(lastRun: now.addingTimeInterval(-3 * 24 * 3600), now: now)) + XCTAssertTrue(line.contains("THE WATCHDOG MAY NOT BE RUNNING"), "got: \(line)") + XCTAssertTrue(line.contains("3 days ago"), "got: \(line)") } - func testWierszBezZnacznikaMowiWprost() { - let linia = StatusLines.watchdogRun(.never) - XCTAssertTrue(linia.contains("NIGDY"), "dostalem: \(linia)") - XCTAssertFalse(linia.contains("Optional"), "dostalem: \(linia)") + func testLineWithoutMarkerSaysSoPlainly() { + let line = StatusLines.watchdogRun(.never) + XCTAssertTrue(line.contains("NEVER"), "got: \(line)") + XCTAssertFalse(line.contains("Optional"), "got: \(line)") } // MARK: - Panel - /// Niesprawdzone nie ma prawa swiecic na zielono - tak samo jak `queueKnown` - /// i `BackupCycleStatus.known`. - func testPanelNieUznajeNiesprawdzonejCzujkiZaDzialajaca() { + /// Something unchecked has no right to shine green - just like `queueKnown` + /// and `BackupCycleStatus.known`. + func testPanelDoesNotTreatAnUncheckedWatchdogAsRunning() { let status = AppStatus() XCTAssertNil(status.watchdog) XCTAssertFalse(status.watchdogRunning) status.watchdog = WatchdogHeartbeat.freshness( lastRun: Date().addingTimeInterval(-3 * 3600)) - XCTAssertFalse(status.watchdogRunning, "czujka milczaca 3 h to nie czujka dzialajaca") + XCTAssertFalse(status.watchdogRunning, "a watchdog silent for 3 h is not a running watchdog") status.watchdog = WatchdogHeartbeat.freshness(lastRun: Date().addingTimeInterval(-300)) XCTAssertTrue(status.watchdogRunning) diff --git a/mac-app/VERSION b/mac-app/VERSION index 26aaba0..f0bb29e 100644 --- a/mac-app/VERSION +++ b/mac-app/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/packaging/homebrew/cloudmachine.rb.in b/packaging/homebrew/cloudmachine.rb.in index 110af3d..0889349 100644 --- a/packaging/homebrew/cloudmachine.rb.in +++ b/packaging/homebrew/cloudmachine.rb.in @@ -1,7 +1,7 @@ -# Szablon caska. Workflow `release.yml` wstawia wersje i sha256 w miejsce -# znacznikow i wypycha wynik do RenaCode/homebrew-tap jako -# Casks/cloudmachine.rb. Edytuj TEN plik - kopie w tapie nadpisze nastepne -# wydanie. +# Cask template. The `release.yml` workflow substitutes the version and sha256 +# for the placeholders and pushes the result to RenaCode/homebrew-tap as +# Casks/cloudmachine.rb. Edit THIS file - the next release overwrites the copy +# in the tap. cask "cloudmachine" do version "__VERSION__" sha256 "__SHA256__" @@ -20,37 +20,40 @@ cask "cloudmachine" do app "CloudMachine.app" - # Wydanie nie jest notaryzowane (brak konta Apple Developer), wiec Gatekeeper - # zablokowalby pobrana kopie. Cask pochodzi z tapu autora, a plik zgadza sie - # z sha256 powyzej. + # The release is not notarized (no Apple Developer account), so Gatekeeper + # would block the downloaded copy. The cask comes from the author's tap, and + # the file matches the sha256 above. # - # Agentow launchd cask NIE przeladowuje: kroki dzialaja w sandboxie - # Homebrew bez dostepu do ~/Library/LaunchAgents. Nie jest to potrzebne - - # upgrade podmienia bundle na nowe pliki (nowe inode'y), a to wlasnie ta - # procedura, po ktorej agenci startuja poprawnie. Gdyby czujka jednak - # stanela, `drive-status` i okno appki pokaza "CZUJKA MOZE NIE CHODZIC". + # The cask does NOT reload the launchd agents: the steps run in the Homebrew + # sandbox without access to ~/Library/LaunchAgents. It is not needed - + # the upgrade replaces the bundle with new files (new inodes), and that is + # exactly the procedure after which the agents start correctly. Should the + # watchdog stop anyway, `drive-status` and the app window will show a + # "THE WATCHDOG MAY NOT BE RUNNING" warning. postflight_steps do run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{appdir}}/CloudMachine.app"] end - # Celowo BEZ `uninstall launchctl:` - Homebrew wykonuje dyrektywy - # `uninstall` takze przy `brew upgrade`, a zatrzymanie - # com.renacode.cloudmachine.gdrive-buffer zabija rclone, ktory trzyma - # montowanie: obraz wraca dopiero po ~20 min, a przerwana wysylka potrafi - # kosztowac katalog glowny wolumenu. Agentow zdejmuje sie recznie (caveats). + # Deliberately WITHOUT `uninstall launchctl:` - Homebrew runs `uninstall` + # directives on `brew upgrade` too, and stopping + # com.renacode.cloudmachine.gdrive-buffer kills the rclone that holds the + # mount: the image only comes back after ~20 min, and an interrupted upload + # can cost the volume's root directory. The agents are removed manually + # (caveats). uninstall quit: "com.renacode.cloudmachine" - # Celowo bez ~/.cloudmachine: tam lezy bufor wysylki, czyli kopie jeszcze - # niewyslane na Google Drive. Skasowanie go niszczy backup. + # Deliberately without ~/.cloudmachine: that is where the upload buffer + # lives, i.e. backups not yet sent to Google Drive. Deleting it destroys the + # backup. zap trash: [ "~/Library/Logs/CloudMachine", "~/Library/Preferences/com.renacode.cloudmachine.plist", ] caveats <<~EOS - First-time setup (the agent lives inside the app bundle): - /Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help - See https://github.com/RenaCode/CloudMachine#setup for the full sequence. + Open CloudMachine (`open -a CloudMachine`): its window lists the + remaining setup steps for this Mac, with a button for each. + See https://github.com/RenaCode/CloudMachine#getting-started `brew upgrade` keeps the Google Drive mount running. Afterwards, check that the backup watchdog still runs: `cloudmachine-agent drive-status`.