diff --git a/.gitignore b/.gitignore index 1661893..c783794 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.tmp build/ dist/ +.worktrees/ *.app/ .config/ rclone.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc38c0..5b7fa2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +## v2.4.4 - 2026-08-01 + +- Automatic retries now replace the stale waiting alert with a truthful running state. +- The overview and menu bar show private, per-phase progress without opening a foreground window. +- Progress is explicitly current-phase progress; scheduled and retry runs remain passive in full-screen Spaces. +- Restore accountless/guest SMB remounting without Keychain lookup or UI while leaving authenticated SMB behavior unchanged. +- Limit delayed success cleanup to issue origins older than or equal to that success, so it can never erase a newer persistent same-profile failure alert. +- Keep unknown-total progress indeterminate, remove stale or invented percentages, and show completion only after durable terminal status publication. + +## v2.4.3 - 2026-07-30 + +- Mount a configured SMB backup target through a bounded native NetFS helper before scheduled work starts, using the Finder-saved password only in memory and explicitly prohibiting Finder, AppleScript, Keychain, or mount authentication UI during automatic runs. +- Preserve the authenticated SMB account when setup learns a mounted share, discard any password from legacy mount metadata, and provide an explicit one-time Keychain authorization mode for unattended access. +- Replace the preliminary “retry in 30 minutes” alert after a failed automatic retry has been accepted by macOS, while retaining the final alert until a newer automatic success or a human dismissal and healing an interrupted cleanup after controller restart. +- Make NAS mount tests inject every UI-capable executable and verify the native helper path, preventing test hostnames from reaching Finder. + ## v2.4.2 - 2026-07-29 - Remove the protected time-sensitive notification entitlement from ad-hoc, unsigned-package, and Developer ID build paths until a provisioning profile can authorize it; this fixes the macOS `OS_REASON_EXEC` launch rejection introduced in v2.4.1. diff --git a/Makefile b/Makefile index 72d745f..ff7a208 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ APP_DIR := /Applications/GDrive Backup Tiger.app +PROGRESS_SUPPORT_SOURCE := macos/GDriveBackupTiger/BackupProgressSupport.m APP_SOURCES := \ macos/GDriveBackupTiger/main.m \ macos/GDriveBackupTiger/ConfigSupport.m \ macos/GDriveBackupTiger/ProfileSupport.m \ macos/GDriveBackupTiger/BackupStatusSupport.m \ + $(PROGRESS_SUPPORT_SOURCE) \ macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m \ @@ -11,12 +13,15 @@ APP_SOURCES := \ macos/GDriveBackupTiger/DiagnosticsSupport.m \ macos/GDriveBackupTiger/DiagnosticsView.m \ macos/GDriveBackupTiger/UpdateSupport.m \ + macos/GDriveBackupTiger/NetworkMountSupport.m \ macos/GDriveBackupTiger/Localization.m OBJC_FLAGS := -fobjc-arc -Wall -Wextra -Werror MACOS_DEPLOYMENT_TARGET ?= 13.0 APP_ARCH_FLAGS ?= -arch arm64 -arch x86_64 APP_OBJC_FLAGS := $(OBJC_FLAGS) -mmacosx-version-min=$(MACOS_DEPLOYMENT_TARGET) $(APP_ARCH_FLAGS) -USER_NOTIFICATIONS_FRAMEWORK := -framework UserNotifications -framework Security +USER_NOTIFICATIONS_FRAMEWORK := -framework UserNotifications -framework Security \ + -framework NetFS +NETWORK_MOUNT_SOURCE := macos/GDriveBackupTiger/NetworkMountSupport.m .PHONY: build install dry-run pkg test clean @@ -67,14 +72,16 @@ test: bash tests/launch-agent-safety-test.sh bash tests/release-metadata-test.sh bash tests/release-workflow-test.sh + bash tests/release-install-runbook-test.sh bash tests/package-entitlement-safety-test.sh bash tests/app-build-artifacts-test.sh bash tests/update-flow-safety-test.sh @set -e; RUN_STATE_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-run-state-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/run-state-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/run-state-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -84,9 +91,10 @@ test: ./scripts/trash-path.sh "$$RUN_STATE_TEST_BIN" @set -e; ACCESSIBILITY_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-accessibility-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/tiger-accessibility-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/tiger-accessibility-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -112,11 +120,25 @@ test: -o "$$AUTOMATIC_RETRY_TEST_BIN"; \ "$$AUTOMATIC_RETRY_TEST_BIN"; \ ./scripts/trash-path.sh "$$AUTOMATIC_RETRY_TEST_BIN" + @set -e; PROGRESS_SUPPORT_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-progress-support-test.XXXXXX")"; \ + PROGRESS_SUPPORT_TEST_DIR="$$(/usr/bin/mktemp -d "$${TMPDIR:-/tmp}/gdrive-progress-support-fixtures.XXXXXX")"; \ + COMPILE_STATUS=0; clang $(OBJC_FLAGS) -framework Foundation -I macos/GDriveBackupTiger \ + tests/progress-support-test.m macos/GDriveBackupTiger/BackupProgressSupport.m \ + -o "$$PROGRESS_SUPPORT_TEST_BIN" || COMPILE_STATUS=$$?; \ + TEST_STATUS="$$COMPILE_STATUS"; \ + if [ "$$COMPILE_STATUS" -eq 0 ]; then \ + GDRIVE_PROGRESS_TEST_DIR="$$PROGRESS_SUPPORT_TEST_DIR" \ + "$$PROGRESS_SUPPORT_TEST_BIN" || TEST_STATUS=$$?; \ + fi; \ + ./scripts/trash-path.sh "$$PROGRESS_SUPPORT_TEST_BIN" \ + "$$PROGRESS_SUPPORT_TEST_DIR"; \ + exit "$$TEST_STATUS" @set -e; NOTIFICATION_INTEGRATION_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-notification-integration-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/notification-integration-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/notification-integration-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m macos/GDriveBackupTiger/RestoreSupport.m \ macos/GDriveBackupTiger/RestoreBrowserView.m macos/GDriveBackupTiger/DiagnosticsSupport.m \ macos/GDriveBackupTiger/DiagnosticsView.m macos/GDriveBackupTiger/UpdateSupport.m \ @@ -175,9 +197,10 @@ test: ./scripts/trash-path.sh "$$DIAGNOSTICS_UI_TEST_BIN" @set -e; DIAGNOSTICS_INTEGRATION_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-diagnostics-integration-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/diagnostics-integration-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/diagnostics-integration-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -187,9 +210,10 @@ test: ./scripts/trash-path.sh "$$DIAGNOSTICS_INTEGRATION_TEST_BIN" @set -e; SETUP_HEALTH_UI_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-setup-health-ui-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/setup-health-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/setup-health-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -199,9 +223,10 @@ test: ./scripts/trash-path.sh "$$SETUP_HEALTH_UI_TEST_BIN" @set -e; OVERVIEW_UI_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-overview-ui-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/overview-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/overview-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -212,9 +237,10 @@ test: ./scripts/trash-path.sh "$$OVERVIEW_UI_TEST_BIN" @set -e; SETUP_SAFETY_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-setup-safety-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/setup-safety-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/setup-safety-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -225,9 +251,10 @@ test: ./scripts/trash-path.sh "$$SETUP_SAFETY_TEST_BIN" @set -e; MOUNT_TRIGGER_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-mount-trigger-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/mount-trigger-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/mount-trigger-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m \ - macos/GDriveBackupTiger/BackupStatusSupport.m macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/BackupStatusSupport.m $(PROGRESS_SUPPORT_SOURCE) \ + macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m \ macos/GDriveBackupTiger/RestoreSupport.m macos/GDriveBackupTiger/RestoreBrowserView.m \ macos/GDriveBackupTiger/DiagnosticsSupport.m macos/GDriveBackupTiger/DiagnosticsView.m \ @@ -238,8 +265,9 @@ test: ./scripts/trash-path.sh "$$MOUNT_TRIGGER_TEST_BIN" @set -e; PROFILE_UI_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-profile-ui-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/profile-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/profile-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m macos/GDriveBackupTiger/BackupStatusSupport.m \ + $(PROGRESS_SUPPORT_SOURCE) \ macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m macos/GDriveBackupTiger/RestoreSupport.m \ macos/GDriveBackupTiger/RestoreBrowserView.m macos/GDriveBackupTiger/DiagnosticsSupport.m \ @@ -250,8 +278,9 @@ test: ./scripts/trash-path.sh "$$PROFILE_UI_TEST_BIN" @set -e; UPDATE_UI_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-update-ui-test.XXXXXX")"; \ clang $(OBJC_FLAGS) -framework Cocoa $(USER_NOTIFICATIONS_FRAMEWORK) -I macos/GDriveBackupTiger \ - tests/update-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + tests/update-ui-test.m macos/GDriveBackupTiger/ConfigSupport.m $(NETWORK_MOUNT_SOURCE) \ macos/GDriveBackupTiger/ProfileSupport.m macos/GDriveBackupTiger/BackupStatusSupport.m \ + $(PROGRESS_SUPPORT_SOURCE) \ macos/GDriveBackupTiger/NotificationSupport.m \ macos/GDriveBackupTiger/SetupHealthSupport.m macos/GDriveBackupTiger/RestoreSupport.m \ macos/GDriveBackupTiger/RestoreBrowserView.m macos/GDriveBackupTiger/DiagnosticsSupport.m \ @@ -267,6 +296,19 @@ test: tests/config-support-test.m macos/GDriveBackupTiger/ConfigSupport.m -o "$$CONFIG_TEST_BIN"; \ "$$CONFIG_TEST_BIN"; \ ./scripts/trash-path.sh "$$CONFIG_TEST_BIN" + @set -e; NAS_MOUNT_URL_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-nas-mount-url-test.XXXXXX")"; \ + clang $(OBJC_FLAGS) -framework Foundation -I macos/GDriveBackupTiger \ + tests/nas-mount-url-test.m macos/GDriveBackupTiger/ConfigSupport.m \ + -o "$$NAS_MOUNT_URL_TEST_BIN"; \ + "$$NAS_MOUNT_URL_TEST_BIN"; \ + ./scripts/trash-path.sh "$$NAS_MOUNT_URL_TEST_BIN" + @set -e; NETWORK_MOUNT_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-network-mount-test.XXXXXX")"; \ + clang $(OBJC_FLAGS) -framework Foundation -framework Security -framework NetFS \ + -I macos/GDriveBackupTiger tests/network-mount-support-test.m \ + macos/GDriveBackupTiger/NetworkMountSupport.m \ + -o "$$NETWORK_MOUNT_TEST_BIN"; \ + "$$NETWORK_MOUNT_TEST_BIN"; \ + ./scripts/trash-path.sh "$$NETWORK_MOUNT_TEST_BIN" bash -n bin/backup-google-drive.sh install.sh packaging/build-pkg.sh packaging/verify-pkg.sh packaging/scripts/postinstall scripts/*.sh tests/*.sh plutil -lint launchd/com.commcats.gdrivebackup.plist macos/GDriveBackupTiger/Info.plist shellcheck -x bin/backup-google-drive.sh install.sh packaging/build-pkg.sh packaging/verify-pkg.sh packaging/scripts/postinstall scripts/*.sh tests/*.sh diff --git a/README.md b/README.md index 04d8411..32ed8d1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ macOS launchd backup setup for Google Drive, powered by `rclone`, with a tiny Mac OS X Tiger-inspired status window. “Tiger” describes the visual style; the app requires macOS 13 Ventura or later and does not run on Mac OS X 10.4 Tiger. -Current release: `v2.4.2` with launch-safe, audible persistent automatic-failure alerts and a safe retry for transient NAS read failures, passive handling of unknown external disks, verified APFS and NAS identity, one coherent Dock presence, optional end-to-end `rclone crypt` backups, retained versions, verified recovery, named profiles, diagnostics, and a persistent menu bar overview. +Current release: `v2.4.4` with a truthful running state for automatic retries, private current-phase progress in the overview and menu bar, silent authenticated and guest SMB mounting, one current persistent automatic-failure alert, a safe retry for transient NAS read failures, passive handling of unknown external disks, verified APFS and NAS identity, one coherent Dock presence, optional end-to-end `rclone crypt` backups, retained versions, verified recovery, named profiles, diagnostics, and a persistent menu bar overview. It backs up: @@ -26,8 +26,8 @@ the backup does not preserve Google Drive's native document revision history. - The controller observes macOS mount events. When an APFS volume UUID is saved, a changed `/Volumes/… 2` suffix is resolved automatically and a merely same-name disk cannot trigger a backup. Older path-only profiles remain available for manual and scheduled use, but a mount event is treated as unknown until a human explicitly binds the disk's UUID. - A previously unknown directly attached physical disk produces at most one passive notification per attachment. Mounting it never opens a window, takes focus, formats or writes to the disk, or starts a backup. **Set up as backup destination** revalidates the same disk and only stages it in setup; **Save** registers the disk while leaving the currently selected primary target and schedule as shown, so a NAS target is never replaced silently. **Ignore** makes no change. A dismissal remains remembered if the controller restarts during the same attachment, while fully unplugging the disk clears the notice and makes a later attachment eligible again. UUIDs retained by any named profile suppress the unknown-disk notice for that whole physical disk. - The overview shows the last verified run, configured schedule, exact local destination, and available destination capacity. -- With notifications enabled, macOS reports a failed automatic run immediately and a daily 20:00 run that is still missing at 21:00. A transient NAS mount/readiness or fail-closed destination-read failure gets exactly one controller-managed retry after 30 minutes; after sleep it remains eligible until the next wake within 24 hours, and a failed retry creates a separate alert. The controller restarts after a crash, alerts are deduplicated per profile and run, and only a newer successful automatic backup removes still-delivered failure alerts for that profile. -- Scheduled, mount-triggered, and menu-bar-only runs stay headless. Their live and final state remains available through the menu bar, with a macOS notification for automatic failures. +- With notifications enabled, macOS reports a failed automatic run immediately and a daily 20:00 run that is still missing at 21:00. A transient NAS mount/readiness or fail-closed destination-read failure gets exactly one controller-managed retry after 30 minutes; after sleep it remains eligible until the next wake within 24 hours. When that retry starts, its truthful running state replaces the stale “retry in 30 minutes” alert. If the retry fails, its final alert replaces the running alert. The controller restarts after a crash, alerts are deduplicated per profile and run, and only a newer successful automatic backup removes the current delivered failure alert for that profile. Delayed cleanup removes only alerts whose issue origin is older than or equal to that success, so a newer persistent failure for the same profile cannot be erased. +- Scheduled, retry, mount-triggered, and menu-bar-only runs stay headless and passive, including in full-screen Spaces. The overview and menu bar show private live progress for the current copy phase without opening a foreground window. The percentage describes only that phase, not the whole backup. When rclone has not reported a trustworthy total, the progress bar remains indeterminate and no stale or invented percentage is shown. Completion appears only after the durable terminal status has been published. Final state remains available through the menu bar, with a macOS notification for automatic failures. - Named profiles keep distinct destinations, schedules, encryption policies, and last-run histories while making the one active profile explicit in setup, the overview, and the menu bar. - On first use, if the backup volume does not exist yet, the helper can ask to create a dedicated APFS volume on the newly attached external APFS disk. - In parallel, the setup window can configure a mounted NAS share, for example SMB, AFP, or NFS under `/Volumes`. A writable directory alone never counts as a NAS: the setup check and backup engine both require a verified network file-system mount. @@ -39,7 +39,7 @@ the backup does not preserve Google Drive's native document revision history. - A direct manual start from the visible overview or setup can open the native AppKit helper. It stays on its original Space, never joins another app's fullscreen Space, and hides when another app becomes active. -- During each `rclone copy`, the helper shows live progress, percent, transferred size, speed, and ETA when rclone reports it. +- During each `rclone copy`, the helper shows live progress, transferred size, and—when rclone reports trustworthy values—percent, speed, and ETA. Without a trustworthy total, progress remains indeterminate. - Native close and minimize controls behave like standard macOS controls; closing the overview leaves its menu bar status available. - The overview and menu bar open a native restore browser that combines the live backup with every actually available retained per-file version. - A restored file is copied to a user-selected folder outside the backup, never silently overwrites an existing file, and is published only after its SHA-256 digest matches the selected backup copy. @@ -87,7 +87,7 @@ rclone lsd gdrive: For most users, download the latest installer from the GitHub releases page: 1. Open -2. Download `GDrive-Backup-Tiger-2.4.2.pkg` from `Assets`. +2. Download `GDrive-Backup-Tiger-2.4.4.pkg` from `Assets`. 3. Double-click the package and follow the macOS Installer. 4. Open `/Applications/GDrive Backup Tiger.app` to choose language, external disk, NAS, and schedule settings. @@ -104,13 +104,13 @@ The package is currently unsigned because the project does not yet have an Apple 1. Click `Done`, not `Move to Trash`. 2. Open `System Settings > Privacy & Security`. -3. Scroll to `Security` and click `Open Anyway` for `GDrive-Backup-Tiger-2.4.2.pkg`. +3. Scroll to `Security` and click `Open Anyway` for `GDrive-Backup-Tiger-2.4.4.pkg`. 4. Confirm with `Open Anyway`, then install the package. Advanced users can also remove the download quarantine flag before opening: ```bash -xattr -d com.apple.quarantine "$HOME/Downloads/GDrive-Backup-Tiger-2.4.2.pkg" +xattr -d com.apple.quarantine "$HOME/Downloads/GDrive-Backup-Tiger-2.4.4.pkg" ``` ### Install from source @@ -159,7 +159,7 @@ INSTALL_DEPS=1 BACKUP_VOLUME="/Volumes/GoogleDrive-Backup" ./install.sh ### Install for a NAS -Mount the NAS share once in Finder and save the credentials in Keychain. Then install with a NAS target: +For an authenticated NAS share, mount it once in Finder and save the credentials in Keychain. Then install with a NAS target: ```bash BACKUP_TARGET=nas \ @@ -168,7 +168,26 @@ NAS_SUBDIR="GoogleDrive-Backup" \ ./install.sh ``` -You can also let the script ask macOS to mount the share when it is not already mounted: +You can also let the native helper mount an authenticated SMB share silently when it is not already mounted. Include the SMB account, but never a password: + +```bash +BACKUP_TARGET=nas \ +NAS_URL="smb://backup-user@nas.local/Backups" \ +NAS_MOUNT="/Volumes/Backups" \ +NAS_SUBDIR="GoogleDrive-Backup" \ +./install.sh +``` + +For authenticated SMB, the account name is stored in `NAS_URL`; the password remains only in the macOS login Keychain and is never put in configuration, arguments, environment, or logs. After Finder has saved the SMB password, authorize the installed helper once: + +```bash +"/Applications/GDrive Backup Tiger.app/Contents/MacOS/GDriveBackupTiger" \ + --authorize-network-url "smb://backup-user@nas.local/Backups" +``` + +Enter the login-Keychain password and choose **Always Allow**. The next no-UI check must succeed before the authenticated schedule is considered unattended. Because unsigned release builds have no stable Developer ID identity, macOS may require this one-time authorization again after an app update. Automatic runs themselves disable all Keychain and mount UI and fail safely instead of opening a dialog. + +For an accountless guest share, omit the account entirely: ```bash BACKUP_TARGET=nas \ @@ -178,7 +197,7 @@ NAS_SUBDIR="GoogleDrive-Backup" \ ./install.sh ``` -The tool does not ask for or store NAS usernames or passwords; use Finder or Keychain for credentials and do not embed them in `NAS_URL`. The config file is kept at owner-only mode `0600` because mount URLs and paths may still be private. +Guest SMB URLs such as `smb://nas.local/Backups` bypass Keychain and authentication commands and remain no-UI during automatic runs. Existing authenticated SMB behavior is unchanged. After installation, open the setup UI from `/Applications/GDrive Backup Tiger.app` or run: @@ -226,7 +245,7 @@ For NAS backups, the config looks like this: ```bash GDRIVE_BACKUP_TARGET=nas GDRIVE_BACKUP_NAS_MOUNT=/Volumes/Backups -GDRIVE_BACKUP_NAS_URL=smb://nas.local/Backups +GDRIVE_BACKUP_NAS_URL=smb://backup-user@nas.local/Backups GDRIVE_BACKUP_NAS_SUBDIR=GoogleDrive-Backup GDRIVE_BACKUP_SCHEDULE=manual ``` @@ -413,7 +432,7 @@ Run manually: /usr/local/bin/backup-google-drive.sh --run ``` -The progress bar reflects the currently active copy phase, for example `My Drive`, `Shared with me`, or one Shared Drive. It also shows the phase count, such as `3/5`. A single global percentage across all Drive areas would require an expensive pre-scan of every source. +The progress bar reflects the currently active copy phase, for example `My Drive`, `Shared with me`, or one Shared Drive. It also shows the phase count, such as `3/5`. A single global percentage across all Drive areas would require an expensive pre-scan of every source. When the current phase has no trustworthy total, the indicator stays indeterminate instead of reusing an old percentage or inventing one. A completed state appears only after the terminal result has been durably published. Watch logs: diff --git a/bin/backup-google-drive.sh b/bin/backup-google-drive.sh index e9929b4..ae88a6d 100755 --- a/bin/backup-google-drive.sh +++ b/bin/backup-google-drive.sh @@ -154,6 +154,7 @@ ANIMATION_APP="${GDRIVE_BACKUP_ANIMATION_APP:-/Applications/GDrive Backup Tiger. if [[ ! -d "$ANIMATION_APP" && -d "$HOME/Applications/GDrive Backup Tiger.app" ]]; then ANIMATION_APP="$HOME/Applications/GDrive Backup Tiger.app" fi +NAS_MOUNT_HELPER="${GDRIVE_BACKUP_NAS_MOUNT_HELPER:-$ANIMATION_APP/Contents/MacOS/GDriveBackupTiger}" ANIMATION_SENTINEL="" PROGRESS_FILE="" RUN_STATE_FILE="${GDRIVE_BACKUP_RUN_STATE_FILE:-}" @@ -175,6 +176,9 @@ elif [[ -n "$ACTIVE_PROFILE_ID" ]]; then else SUMMARY_STATE_FILE="$HOME/Library/Application Support/GDrive Backup Tiger/last-run.status" fi +DURABLE_PROGRESS_FILE="${GDRIVE_BACKUP_PROGRESS_STATE_FILE:-}" +PROGRESS_PROFILE_ID="${GDRIVE_BACKUP_PROFILE_ID:-${ACTIVE_PROFILE_ID:-legacy}}" +DURABLE_PROGRESS_OWNED=0 RUN_STARTED_AT=0 OPEN_BIN="${GDRIVE_BACKUP_OPEN_BIN:-/usr/bin/open}" CONFIRM_BACKUP="${GDRIVE_BACKUP_CONFIRM:-1}" @@ -1762,9 +1766,144 @@ write_progress() { [[ -n "$phase" ]] && printf 'phase=%s\n' "$(progress_escape "$phase")" [[ -n "$percent" ]] && printf 'percent=%s\n' "$(progress_escape "$percent")" [[ -n "$detail" ]] && printf 'detail=%s\n' "$(progress_escape "$detail")" + : } >"$tmp" && mv -f "$tmp" "$PROGRESS_FILE" } +public_progress_label() { + case "$1" in + "My Drive") printf 'My Drive' ;; + "Shared with me") printf 'Shared with me' ;; + *) printf 'Shared Drive' ;; + esac +} + +parse_rclone_progress_fields() { + local line="$1" + local pattern='^Transferred:[[:space:]]*([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B)[[:space:]]+/[[:space:]]+([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B),[[:space:]]+([0-9]{1,3})%,[[:space:]]+([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B/s),[[:space:]]+ETA[[:space:]]+(-|([0-9]+[dhms])+)$' + local transferred total percent speed eta + RCLONE_PROGRESS_TRANSFERRED="" + RCLONE_PROGRESS_TOTAL="" + RCLONE_PROGRESS_PERCENT="" + RCLONE_PROGRESS_SPEED="" + RCLONE_PROGRESS_ETA="" + RCLONE_PROGRESS_DETAIL="" + [[ "$line" =~ $pattern ]] || return 1 + transferred="${BASH_REMATCH[1]}" + total="${BASH_REMATCH[3]}" + percent="${BASH_REMATCH[5]}" + speed="${BASH_REMATCH[6]}" + eta="${BASH_REMATCH[8]}" + [[ "${transferred%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "${total%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "${speed%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "$percent" -le 100 ]] || return 1 + RCLONE_PROGRESS_TRANSFERRED="$transferred" + RCLONE_PROGRESS_TOTAL="$total" + RCLONE_PROGRESS_PERCENT="$percent" + RCLONE_PROGRESS_SPEED="$speed" + RCLONE_PROGRESS_ETA="$eta" + RCLONE_PROGRESS_DETAIL="$RCLONE_PROGRESS_TRANSFERRED / $RCLONE_PROGRESS_TOTAL, $RCLONE_PROGRESS_SPEED, ETA $RCLONE_PROGRESS_ETA" +} + +parse_rclone_unknown_total_progress() { + local line="$1" + local pattern='^Transferred:[[:space:]]*([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B)[[:space:]]+/[[:space:]]+(0[[:space:]]+B|off),[[:space:]]+-,[[:space:]]+([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B/s),[[:space:]]+ETA[[:space:]]+-$' + local transferred speed + [[ "$line" =~ $pattern ]] || return 1 + transferred="${BASH_REMATCH[1]}" + speed="${BASH_REMATCH[4]}" + [[ "${transferred%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "${speed%% *}" =~ ^[0-9]+([.][0-9]+)?$ ]] || return 1 +} + +write_durable_progress() { + local label="${1:-preparing}" percent="${2:-}" detail="${3:-}" phase="${4:-}" + local directory temporary existing_owner phase_current phase_total + [[ -n "$DURABLE_PROGRESS_FILE" ]] || return 0 + case "$label" in preparing|"My Drive"|"Shared with me"|"Shared Drive") ;; *) return 1 ;; esac + [[ -z "$phase" || "$phase" =~ ^[1-9][0-9]*/[1-9][0-9]*$ ]] || return 1 + if [[ -n "$phase" ]]; then + phase_current="${phase%/*}" + phase_total="${phase#*/}" + [[ "$phase_current" -le "$phase_total" && "$phase_total" -le 9999 ]] || return 1 + fi + [[ -z "$percent" || ( "$percent" =~ ^[0-9]+$ && "$percent" -le 100 ) ]] || return 1 + if [[ -n "$detail" && "$detail" != "${RCLONE_PROGRESS_DETAIL:-}" ]]; then return 1; fi + [[ ! -L "$DURABLE_PROGRESS_FILE" ]] || return 1 + [[ ! -e "$DURABLE_PROGRESS_FILE" || -f "$DURABLE_PROGRESS_FILE" ]] || return 1 + if [[ -e "$DURABLE_PROGRESS_FILE" ]]; then + existing_owner="$(stat -f '%u' "$DURABLE_PROGRESS_FILE")" || return 1 + [[ "$existing_owner" == "$(id -u)" ]] || return 1 + fi + directory="${DURABLE_PROGRESS_FILE%/*}" + (umask 077 && mkdir -p "$directory") || return 1 + temporary="$(umask 077; mktemp "${DURABLE_PROGRESS_FILE}.tmp.XXXXXX")" || return 1 + if ! (umask 077; { + printf 'protocol=1\nprofile_id=%s\npid=%s\nstarted_at=%s\ntrigger=%s\n' \ + "$PROGRESS_PROFILE_ID" "$$" "$RUN_STARTED_AT" "$BACKUP_TRIGGER" + [[ -n "$RETRY_ATTEMPT" ]] && printf 'retry_attempt=%s\n' "$RETRY_ATTEMPT" + printf 'label=%s\n' "$label" + [[ -n "$phase" ]] && printf 'phase=%s\n' "$phase" + [[ -n "$percent" ]] && printf 'percent=%s\n' "$percent" + [[ -n "$detail" ]] && printf 'detail=%s\n' "$detail" + printf 'updated_at=%s\n' "$(date +%s)" + } >"$temporary" && chmod 600 "$temporary"); then + cleanup_temp_file "$temporary" + return 1 + fi + mv -f "$temporary" "$DURABLE_PROGRESS_FILE" +} + +initialize_durable_progress() { + [[ "$DRY_RUN" == "0" && "$SETUP_UI" == "0" ]] || return 0 + [[ "$PROGRESS_PROFILE_ID" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || return 0 + if [[ -z "$DURABLE_PROGRESS_FILE" ]]; then + DURABLE_PROGRESS_FILE="${SUMMARY_STATE_FILE%/*}/current-progress.status" + fi + write_durable_progress "preparing" "" "" "" +} + +finish_durable_progress() { + local directory temporary existing_owner + [[ "$DRY_RUN" == "0" && "$SETUP_UI" == "0" ]] || return 0 + [[ -n "$DURABLE_PROGRESS_FILE" ]] || return 0 + [[ "$PROGRESS_PROFILE_ID" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || return 0 + [[ ! -L "$DURABLE_PROGRESS_FILE" ]] || return 1 + [[ ! -e "$DURABLE_PROGRESS_FILE" || -f "$DURABLE_PROGRESS_FILE" ]] || return 1 + if [[ -e "$DURABLE_PROGRESS_FILE" ]]; then + existing_owner="$(stat -f '%u' "$DURABLE_PROGRESS_FILE")" || return 1 + [[ "$existing_owner" == "$(id -u)" ]] || return 1 + fi + directory="${DURABLE_PROGRESS_FILE%/*}" + (umask 077 && mkdir -p "$directory") || return 1 + temporary="$(umask 077; mktemp "${DURABLE_PROGRESS_FILE}.tmp.XXXXXX")" || return 1 + if ! (umask 077; { + printf 'protocol=1\nprofile_id=%s\npid=%s\nstarted_at=%s\ntrigger=%s\n' \ + "$PROGRESS_PROFILE_ID" "$$" "$RUN_STARTED_AT" "$BACKUP_TRIGGER" + [[ -n "$RETRY_ATTEMPT" ]] && printf 'retry_attempt=%s\n' "$RETRY_ATTEMPT" + printf 'status=finished\nupdated_at=%s\n' "$(date +%s)" + } >"$temporary" && chmod 600 "$temporary"); then + cleanup_temp_file "$temporary" + return 1 + fi + mv -f "$temporary" "$DURABLE_PROGRESS_FILE" +} + +warn_progress_unavailable() { + if [[ "${DURABLE_PROGRESS_WARNING_LOGGED:-0}" != "1" ]]; then + log "WARNUNG: Backup-Fortschritt konnte nicht sicher aktualisiert werden." + DURABLE_PROGRESS_WARNING_LOGGED=1 + fi +} + +warn_state_publication_unavailable() { + if [[ "${RUN_STATE_WARNING_LOGGED:-0}" != "1" ]]; then + log "WARNUNG: Backup-Status konnte nicht sicher aktualisiert werden." + RUN_STATE_WARNING_LOGGED=1 + fi +} + write_run_state() { [[ -n "$RUN_STATE_FILE" ]] || return 0 @@ -1789,15 +1928,15 @@ write_last_run_summary() { local exit_code="${2:-}" local summary_dir tmp finished_at last_success_at="" - [[ "$DRY_RUN" == "0" && "$SETUP_UI" == "0" ]] || return 0 + [[ "$DRY_RUN" == "0" && "$SETUP_UI" == "0" ]] || return 2 # A lock-contended process did not perform a backup and must not replace the # status of the process that actually owns the destination. - [[ "$status" != "skipped" ]] || return 0 - [[ -n "$SUMMARY_STATE_FILE" ]] || return 0 + [[ "$status" != "skipped" ]] || return 2 + [[ -n "$SUMMARY_STATE_FILE" ]] || return 2 summary_dir="${SUMMARY_STATE_FILE%/*}" [[ "$summary_dir" != "$SUMMARY_STATE_FILE" ]] || summary_dir="." - (umask 077 && mkdir -p "$summary_dir") || return 0 + (umask 077 && mkdir -p "$summary_dir") || return 1 tmp="${SUMMARY_STATE_FILE}.$$" finished_at="$(date +%s 2>/dev/null || printf '0')" if [[ -f "$SUMMARY_STATE_FILE" ]]; then @@ -1833,7 +1972,7 @@ write_last_run_summary() { fi cleanup_temp_file "$tmp" - return 0 + return 1 } finish_run_state() { @@ -1851,15 +1990,18 @@ update_progress_from_rclone_line() { local phase="$2" local line="$3" - if [[ "$line" =~ Transferred:[[:space:]]*(.*) ]]; then - [[ "$line" == *"B /"* ]] || return 0 - local detail="${BASH_REMATCH[1]}" - local percent="" - if [[ "$detail" =~ ([0-9]+)% ]]; then - percent="${BASH_REMATCH[1]}" + if parse_rclone_progress_fields "$line"; then + write_progress "$label" "$RCLONE_PROGRESS_PERCENT" "$RCLONE_PROGRESS_DETAIL" "$phase" + if ! write_durable_progress "$(public_progress_label "$label")" \ + "$RCLONE_PROGRESS_PERCENT" "$RCLONE_PROGRESS_DETAIL" "$phase"; then + warn_progress_unavailable + write_progress "$label" "" "" "$phase" + write_durable_progress "$(public_progress_label "$label")" "" "" "$phase" || true fi - if [[ -n "$percent" ]]; then - write_progress "$label" "$percent" "$detail" "$phase" + elif parse_rclone_unknown_total_progress "$line"; then + write_progress "$label" "" "" "$phase" + if ! write_durable_progress "$(public_progress_label "$label")" "" "" "$phase"; then + warn_progress_unavailable fi fi } @@ -2033,9 +2175,20 @@ stop_animation() { cleanup() { local exit_status="$1" + local summary_publish_status=0 # Publish the terminal result before the sentinel disappears, so the UI can # never infer success merely from process cleanup. finish_run_state "$exit_status" + summary_publish_status=$? + if [[ "$summary_publish_status" == "0" && "$DURABLE_PROGRESS_OWNED" == "1" ]]; then + if ! finish_durable_progress; then + warn_progress_unavailable + fi + elif [[ "$summary_publish_status" == "1" ]]; then + # Keep the last live record intact. Its PID check makes it invalid as soon + # as this process exits, without ever claiming a terminal backup result. + warn_state_publication_unavailable + fi stop_animation } @@ -2473,20 +2626,33 @@ ensure_backup_volume() { return 1 } +nas_url_has_embedded_password() { + local authority userinfo + [[ "$NAS_URL" == smb://* ]] || return 1 + authority="${NAS_URL#smb://}" + authority="${authority%%/*}" + [[ "$authority" == *"@"* ]] || return 1 + userinfo="${authority%@*}" + [[ "$userinfo" == *":"* ]] +} + mount_nas_url() { [[ -n "$NAS_URL" ]] || return 1 - log "NAS-Freigabe ist noch nicht gemountet; versuche zu mounten: $NAS_URL" - if [[ -x "$OSASCRIPT_BIN" ]]; then - run_with_timeout "$NAS_MOUNT_TIMEOUT_SECONDS" "$OSASCRIPT_BIN" - "$NAS_URL" <<'OSA' -on run argv - mount volume (item 1 of argv) -end run -OSA + if nas_url_has_embedded_password; then + log "FEHLER: Die NAS-URL darf kein eingebettetes Passwort enthalten." + return 64 + fi + + log "NAS-Freigabe ist noch nicht gemountet; versuche stillen System-Mount." + if [[ -x "$NAS_MOUNT_HELPER" ]]; then + run_with_timeout "$NAS_MOUNT_TIMEOUT_SECONDS" \ + "$NAS_MOUNT_HELPER" --mount-network-url "$NAS_URL" return $? fi - /usr/bin/open "$NAS_URL" + log "WARNUNG: Der nichtinteraktive NAS-Mount-Helfer ist nicht verfügbar." + return 1 } ensure_nas_destination() { @@ -2505,7 +2671,7 @@ ensure_nas_destination() { if ! nas_mount_is_verified && [[ -n "$NAS_URL" ]]; then if [[ "$DRY_RUN" == "1" ]]; then RUN_STATE_REASON="nas_mount_unavailable" - log "DRY-RUN: NAS-Freigabe wuerde bei Bedarf gemountet: $NAS_URL" + log "DRY-RUN: NAS-Freigabe wuerde bei Bedarf still gemountet." return 1 else mount_requested=1 @@ -2680,6 +2846,11 @@ if ! flock -n 9; then RUN_STATE_REASON="already_running" exit 0 fi +DURABLE_PROGRESS_OWNED=1 + +if ! initialize_durable_progress; then + warn_progress_unavailable +fi # Owning the lock is the first reliable point at which this process is the # actual backup run. Publish that fact before network and destination checks, @@ -3986,7 +4157,10 @@ copy_one() { fi log "Kopiere $label -> $dest" - write_progress "$label" "0" "$(t progress_preparing)" "$phase" + if ! write_durable_progress "$(public_progress_label "$label")" "" "" "$phase"; then + warn_progress_unavailable + fi + write_progress "$label" "" "" "$phase" if [[ "$VERSIONING" == "1" ]]; then run_rclone_with_progress "$label" "$phase" rclone copy "$source" "$dest" \ --backup-dir "$backup_dir" "$@" "${RCLONE_OPTS[@]}" || copy_status=$? diff --git a/docs/superpowers/plans/2026-08-01-automatic-retry-progress.md b/docs/superpowers/plans/2026-08-01-automatic-retry-progress.md new file mode 100644 index 0000000..94f5671 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-automatic-retry-progress.md @@ -0,0 +1,2601 @@ +# Automatic Retry Progress Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a truthful, passive, live per-phase progress state for automatic GDrive retries while replacing the stale “retry in 30 minutes” notification and never opening a foreground window. + +**Architecture:** The backup shell writes a private atomic `current-progress.status` beside the active profile's `last-run.status` for every real lock-owning run. A small Objective-C support module validates that telemetry against the current run summary, and the persistent controller feeds one shared snapshot to the overview, menu bar, and retry-running notification. Foreground presentation remains a separate decision, so scheduled work stays headless. + +**Tech Stack:** Bash 3.2-compatible shell, Objective-C with Cocoa/Foundation/UserNotifications, launchd, rclone statistics, Make, shell and Objective-C executable tests, Universal 2 macOS build. + +## Global Constraints + +- Scheduled and retry runs remain headless and never activate the app. +- No global percentage across all Google Drive areas; every percentage is labeled as the current phase. +- No notification is emitted for each percentage update. +- Progress telemetry contains no file names, source directory names, credentials, remote configuration, Shared Drive names, or raw log lines. +- Every persistent write is private (`0600`) and atomic. +- Invalid, stale, cross-profile, mismatched-PID, or terminal progress can only fall back to an indeterminate state. +- The unresolved failure remains latched until a newer successful automatic run or explicit human dismissal. +- All seven supported languages and VoiceOver receive complete strings and semantics. +- No installed app, installed script, controller, LaunchAgent, profile, schedule, credential, NAS data, or active backup process may be changed while a backup is running. +- The existing untracked `AGENTS.md` and `tests/package-entitlement-safety-test 2.sh` are not product inputs and must not be staged by this plan. + +--- + +### Task 1: Preserve the reviewed v2.4.3 baseline and isolate v2.4.4 work + +**Files:** +- Review and commit only the existing v2.4.3 product changes in `CHANGELOG.md`, `Makefile`, `README.md`, `bin/backup-google-drive.sh`, `docs/version-history.md`, `install.sh`, `macos/GDriveBackupTiger/ConfigSupport.h`, `macos/GDriveBackupTiger/ConfigSupport.m`, `macos/GDriveBackupTiger/Info.plist`, `macos/GDriveBackupTiger/NetworkMountSupport.h`, `macos/GDriveBackupTiger/NetworkMountSupport.m`, `macos/GDriveBackupTiger/NotificationSupport.m`, `macos/GDriveBackupTiger/main.m`, `tests/backup-control-test.sh`, `tests/backup-outcome-test.sh`, `tests/nas-mount-url-test.m`, `tests/network-mount-support-test.m`, `tests/notification-integration-test.m`, `tests/notification-support-test.m`, and `tests/release-metadata-test.sh`. +- Preserve without staging: `AGENTS.md`, `tests/package-entitlement-safety-test 2.sh`. + +**Interfaces:** +- Consumes: the current dirty v2.4.3 Build 27 working tree and commit `2074718` containing the approved design. +- Produces: a clean, reviewable v2.4.3 baseline commit and branch `codex/automatic-retry-progress-v2-4-4` for the feature tasks. + +- [ ] **Step 1: Load reviewed project memory before implementation** + +```bash +/Users/alexandersmyslowski/Projects/central-agent-data-hub/scripts/agent_start.sh \ + --project gdrive-tiger-backup \ + --query "implement passive automatic retry progress and persistent dismissal semantics" \ + --review +``` + +Expected: reviewed, non-sensitive project context is available. If the Hub is +unavailable, record that limitation and continue from the committed design and +plan only; do not guess, import another project's memory, or write unreviewed +claims. + +- [ ] **Step 2: Prove the installed backup is still isolated from repository work** + +```bash +backup_process_snapshot() { + local excluded="," pid + pid="$(/bin/sh -c 'printf "%s" "$PPID"')" + while [[ "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 ]]; do + excluded="${excluded}${pid}," + pid="$(ps -p "$pid" -o ppid= | tr -d ' ')" + done + ps -axo pid=,ppid=,command= | awk -v excluded="$excluded" ' + index(excluded, "," $1 ",") == 0 && + /backup-google-drive|\/rclone( |$)/ && $0 !~ /awk/ {print}' +} +sed -n -E '/^(status|pid|started_at|finished_at|exit_code|trigger)=/p' \ + "$HOME/Library/Application Support/GDrive Backup Tiger/profiles/default/last-run.status" +backup_process_snapshot +``` + +Expected: if `status=running` or a matching process exists, continue only with repository edits and tests; do not run `make install`, `install.sh`, `launchctl bootout`, or copy to `/Applications` or `/usr/local/bin`. + +- [ ] **Step 3: Review the exact baseline scope** + +```bash +git status --short +git diff --check +git diff --stat -- \ + CHANGELOG.md Makefile README.md bin/backup-google-drive.sh docs/version-history.md \ + install.sh macos/GDriveBackupTiger tests +``` + +Expected: no conflict markers or whitespace errors; `AGENTS.md` and the duplicate `* 2.sh` file remain outside the staged set. + +- [ ] **Step 4: Run the existing v2.4.3 regression suite before recording the baseline** + +```bash +make test +``` + +Expected: exit 0. A failure must be diagnosed as a baseline defect before any retry-progress production code is written. + +- [ ] **Step 5: Commit only the reviewed v2.4.3 product state** + +```bash +git add CHANGELOG.md Makefile README.md bin/backup-google-drive.sh \ + docs/version-history.md install.sh \ + macos/GDriveBackupTiger/ConfigSupport.h \ + macos/GDriveBackupTiger/ConfigSupport.m \ + macos/GDriveBackupTiger/Info.plist \ + macos/GDriveBackupTiger/NetworkMountSupport.h \ + macos/GDriveBackupTiger/NetworkMountSupport.m \ + macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/main.m \ + tests/backup-control-test.sh tests/backup-outcome-test.sh \ + tests/nas-mount-url-test.m tests/network-mount-support-test.m \ + tests/notification-integration-test.m tests/notification-support-test.m \ + tests/release-metadata-test.sh +git diff --cached --check +git commit -m "feat: mount NAS backups silently in v2.4.3" +``` + +Expected: one baseline commit; unrelated untracked files remain untracked. + +- [ ] **Step 6: Create the feature branch** + +```bash +git switch -c codex/automatic-retry-progress-v2-4-4 +``` + +Expected: the current branch is `codex/automatic-retry-progress-v2-4-4` and the baseline product files are clean. + +--- + +### Task 2: Add a private profile-scoped progress protocol + +**Files:** +- Create: `macos/GDriveBackupTiger/BackupProgressSupport.h` +- Create: `macos/GDriveBackupTiger/BackupProgressSupport.m` +- Create: `tests/progress-support-test.m` +- Modify: `Makefile` +- Modify: `tests/backup-outcome-test.sh` +- Modify: `bin/backup-google-drive.sh` + +**Interfaces:** +- Consumes: `last-run.status` fields `protocol`, `status`, `pid`, `started_at`, `trigger`, and `retry_attempt`; existing rclone progress lines; existing `cleanup_temp_file` and atomic-write patterns. +- Produces: `GDTBackupProgressPathForSummaryPath`, `GDTReadBackupProgressAtPath`, and `GDTValidatedBackupProgressForValues`; an atomic `current-progress.status` protocol for every real backup owner. + +- [ ] **Step 1: Declare the wished-for Objective-C API in the failing test** + +Create `tests/progress-support-test.m` with real parser and validator expectations: + +```objc +#import +#include +#import "BackupProgressSupport.h" + +static int failures = 0; +static void Assert(BOOL condition, NSString *message) { + if (condition) printf("ok - %s\n", message.UTF8String); + else { printf("not ok - %s\n", message.UTF8String); failures++; } +} + +int main(void) { + @autoreleasepool { + NSTimeInterval now = NSDate.date.timeIntervalSince1970; + NSString *pid = [NSString stringWithFormat:@"%d", getpid()]; + NSString *started = [NSString stringWithFormat:@"%.0f", now - 10]; + NSDictionary *summary = @{ + @"protocol": @"1", @"status": @"running", @"pid": pid, + @"started_at": started, @"trigger": @"schedule-retry", + @"retry_attempt": @"1" + }; + NSDictionary *progress = @{ + @"protocol": @"1", @"profile_id": @"default", @"pid": pid, + @"started_at": started, @"trigger": @"schedule-retry", + @"retry_attempt": @"1", @"label": @"Shared Drive", + @"phase": @"3/5", @"percent": @"63", + @"detail": @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s", + @"updated_at": [NSString stringWithFormat:@"%.0f", now] + }; + NSDictionary *accepted = GDTValidatedBackupProgressForValues( + progress, summary, @"running", @"default", now); + Assert([accepted[@"percent"] isEqualToString:@"63"] && + [accepted[@"phase"] isEqualToString:@"3/5"], + @"matching live progress is accepted"); + Assert([GDTBackupProgressPathForSummaryPath(@"/tmp/default/last-run.status") + isEqualToString:@"/tmp/default/current-progress.status"], + @"progress path stays beside the profile summary"); + + NSMutableDictionary *crossProfile = [progress mutableCopy]; + crossProfile[@"profile_id"] = @"archive"; + Assert(GDTValidatedBackupProgressForValues( + crossProfile, summary, @"running", @"default", now) == nil, + @"cross-profile progress is rejected"); + + NSMutableDictionary *wrongProtocol = [progress mutableCopy]; + wrongProtocol[@"protocol"] = @"2"; + Assert(GDTValidatedBackupProgressForValues( + wrongProtocol, summary, @"running", @"default", now) == nil, + @"unknown progress protocols are rejected"); + + NSMutableDictionary *stale = [progress mutableCopy]; + stale[@"updated_at"] = [NSString stringWithFormat:@"%.0f", now - 61]; + Assert(GDTValidatedBackupProgressForValues( + stale, summary, @"running", @"default", now) == nil, + @"stale progress is rejected"); + + NSMutableDictionary *wrongPID = [progress mutableCopy]; + wrongPID[@"pid"] = @"99999999"; + Assert(GDTValidatedBackupProgressForValues( + wrongPID, summary, @"running", @"default", now) == nil, + @"mismatched process progress is rejected"); + + NSMutableDictionary *deadSummary = [summary mutableCopy]; + deadSummary[@"pid"] = @"99999999"; + Assert(GDTValidatedBackupProgressForValues( + wrongPID, deadSummary, @"running", @"default", now) == nil, + @"matching telemetry for a dead process is rejected"); + + NSMutableDictionary *wrongStart = [progress mutableCopy]; + wrongStart[@"started_at"] = [NSString stringWithFormat:@"%lld", + started.longLongValue - 1]; + Assert(GDTValidatedBackupProgressForValues( + wrongStart, summary, @"running", @"default", now) == nil, + @"mismatched start times are rejected"); + + NSMutableDictionary *wrongTrigger = [progress mutableCopy]; + wrongTrigger[@"trigger"] = @"schedule"; + Assert(GDTValidatedBackupProgressForValues( + wrongTrigger, summary, @"running", @"default", now) == nil, + @"mismatched triggers are rejected"); + + NSMutableDictionary *wrongRetry = [progress mutableCopy]; + wrongRetry[@"retry_attempt"] = @"2"; + Assert(GDTValidatedBackupProgressForValues( + wrongRetry, summary, @"running", @"default", now) == nil, + @"mismatched retry attempts are rejected"); + + NSMutableDictionary *future = [progress mutableCopy]; + future[@"updated_at"] = [NSString stringWithFormat:@"%.0f", now + 2]; + Assert(GDTValidatedBackupProgressForValues( + future, summary, @"running", @"default", now) == nil, + @"future progress is rejected"); + + NSMutableDictionary *missingIdentity = [progress mutableCopy]; + [missingIdentity removeObjectForKey:@"started_at"]; + Assert(GDTValidatedBackupProgressForValues( + missingIdentity, summary, @"running", @"default", now) == nil, + @"missing identity fields are rejected"); + + NSMutableDictionary *unsafe = [progress mutableCopy]; + unsafe[@"detail"] = @"file-name.pdf\nsecret"; + Assert(GDTValidatedBackupProgressForValues( + unsafe, summary, @"running", @"default", now) == nil, + @"multiline detail is rejected"); + + NSMutableDictionary *rawLogDetail = [progress mutableCopy]; + rawLogDetail[@"detail"] = @"secret-file.pdf: Failed to copy"; + Assert(GDTValidatedBackupProgressForValues( + rawLogDetail, summary, @"running", @"default", now) == nil, + @"arbitrary rclone log text is rejected"); + + NSMutableDictionary *outOfRange = [progress mutableCopy]; + outOfRange[@"percent"] = @"101"; + Assert(GDTValidatedBackupProgressForValues( + outOfRange, summary, @"running", @"default", now) == nil, + @"out-of-range percentages are rejected"); + + NSMutableDictionary *impossiblePhase = [progress mutableCopy]; + impossiblePhase[@"phase"] = @"6/5"; + Assert(GDTValidatedBackupProgressForValues( + impossiblePhase, summary, @"running", @"default", now) == nil, + @"impossible phases are rejected"); + + NSMutableDictionary *unknownLabel = [progress mutableCopy]; + unknownLabel[@"label"] = @"THE ONE"; + Assert(GDTValidatedBackupProgressForValues( + unknownLabel, summary, @"running", @"default", now) == nil, + @"source names cannot become public progress labels"); + + NSMutableDictionary *preparing = [progress mutableCopy]; + preparing[@"label"] = @"preparing"; + [preparing removeObjectForKey:@"phase"]; + [preparing removeObjectForKey:@"percent"]; + [preparing removeObjectForKey:@"detail"]; + Assert(GDTValidatedBackupProgressForValues( + preparing, summary, @"running", @"default", now) != nil, + @"a valid preparation record remains indeterminate"); + + NSString *fixtureRoot = NSProcessInfo.processInfo.environment[ + @"GDRIVE_PROGRESS_TEST_DIR"]; + NSString *validPath = [fixtureRoot + stringByAppendingPathComponent:@"valid.status"]; + NSMutableString *validContent = [NSMutableString string]; + for (NSString *key in @[@"protocol", @"profile_id", @"pid", + @"started_at", @"trigger", @"retry_attempt", + @"label", @"phase", @"percent", @"detail", + @"updated_at"]) { + [validContent appendFormat:@"%@=%@\n", key, progress[key]]; + } + [validContent writeToFile:validPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} ofItemAtPath:validPath error:nil]; + Assert([GDTReadBackupProgressAtPath(validPath)[@"percent"] + isEqualToString:@"63"], + @"a private valid progress record is parsed"); + + NSString *duplicatePath = [fixtureRoot + stringByAppendingPathComponent:@"duplicate.status"]; + NSString *duplicateContent = [validContent + stringByAppendingString:@"protocol=1\n"]; + [duplicateContent writeToFile:duplicatePath + atomically:YES encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} ofItemAtPath:duplicatePath error:nil]; + Assert(GDTReadBackupProgressAtPath(duplicatePath) == nil, + @"duplicate keys are rejected while parsing"); + + NSString *missingPath = [fixtureRoot + stringByAppendingPathComponent:@"missing.status"]; + NSString *missingContent = [validContent + stringByReplacingOccurrencesOfString: + [NSString stringWithFormat:@"updated_at=%@\n", progress[@"updated_at"]] + withString:@""]; + [missingContent writeToFile:missingPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} ofItemAtPath:missingPath error:nil]; + Assert(GDTReadBackupProgressAtPath(missingPath) == nil, + @"missing required parser keys are rejected"); + + NSString *publicPath = [fixtureRoot + stringByAppendingPathComponent:@"public.status"]; + [validContent writeToFile:publicPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0644} ofItemAtPath:publicPath error:nil]; + Assert(GDTReadBackupProgressAtPath(publicPath) == nil, + @"group/world-readable progress is rejected"); + + NSString *symlinkPath = [fixtureRoot + stringByAppendingPathComponent:@"linked.status"]; + [NSFileManager.defaultManager createSymbolicLinkAtPath:symlinkPath + withDestinationPath:validPath error:nil]; + Assert(GDTReadBackupProgressAtPath(symlinkPath) == nil, + @"symlinked progress is rejected without following it"); + + Assert(GDTValidatedBackupProgressForValues( + progress, summary, @"success", @"default", now) == nil, + @"terminal summaries cannot expose live progress"); + } + return failures ? 1 : 0; +} +``` + +- [ ] **Step 2: Add the test build command and verify RED** + +Add this exact target body to `Makefile`'s `test` recipe before UI integration tests: + +```make + @set -e; PROGRESS_SUPPORT_TEST_BIN="$$(/usr/bin/mktemp "$${TMPDIR:-/tmp}/gdrive-progress-support-test.XXXXXX")"; \ + PROGRESS_SUPPORT_TEST_DIR="$$(/usr/bin/mktemp -d "$${TMPDIR:-/tmp}/gdrive-progress-support-fixtures.XXXXXX")"; \ + COMPILE_STATUS=0; clang $(OBJC_FLAGS) -framework Foundation -I macos/GDriveBackupTiger \ + tests/progress-support-test.m macos/GDriveBackupTiger/BackupProgressSupport.m \ + -o "$$PROGRESS_SUPPORT_TEST_BIN" || COMPILE_STATUS=$$?; \ + TEST_STATUS="$$COMPILE_STATUS"; \ + if [ "$$COMPILE_STATUS" -eq 0 ]; then \ + GDRIVE_PROGRESS_TEST_DIR="$$PROGRESS_SUPPORT_TEST_DIR" \ + "$$PROGRESS_SUPPORT_TEST_BIN" || TEST_STATUS=$$?; \ + fi; \ + ./scripts/trash-path.sh "$$PROGRESS_SUPPORT_TEST_BIN" \ + "$$PROGRESS_SUPPORT_TEST_DIR"; \ + exit "$$TEST_STATUS" +``` + +Run: + +```bash +make test +``` + +Expected RED: compilation fails because `BackupProgressSupport.h/.m` and the declared API do not exist. + +- [ ] **Step 3: Implement the minimal parser and validator** + +Create `macos/GDriveBackupTiger/BackupProgressSupport.h`: + +```objc +#import + +FOUNDATION_EXPORT NSString *GDTBackupProgressPathForSummaryPath(NSString *summaryPath); +FOUNDATION_EXPORT NSDictionary * _Nullable + GDTReadBackupProgressAtPath(NSString *path); +FOUNDATION_EXPORT NSDictionary * _Nullable + GDTValidatedBackupProgressForValues( + NSDictionary *progress, + NSDictionary *summary, + NSString *summaryStatus, + NSString *profileID, + NSTimeInterval nowTimestamp); +``` + +Implement `BackupProgressSupport.m` with these exact acceptance rules: + +```objc +// Accept protocol 1 only; parse the same first '=' key/value format as the +// run summary; reject duplicate keys, missing required keys, CR/LF/NUL, and +// values longer than 512 UTF-16 code units. +// Open with O_RDONLY | O_NOFOLLOW, then use fstat and read from that same file +// descriptor to avoid a path-swap race. Accept only a regular file owned by +// the current user with no group/world permission bits. A live record requires protocol, profile_id, +// pid, started_at, trigger, label, and updated_at. retry_attempt is required +// exactly when the matching summary has it. A preparing label may omit phase, +// percent, and detail; other live labels require a valid phase. +// Require summaryStatus == "running". +// Require progress.profile_id == profileID. +// Require progress.pid == summary.pid and progress.started_at == summary.started_at. +// Require progress.trigger == summary.trigger and retry_attempt equality when present. +// Require kill(pid, 0) == 0 or errno == EPERM. +// Require updated_at <= now + 1 and now - updated_at <= 60. +// Accept label only from preparing, My Drive, Shared with me, Shared Drive. +// Accept phase only as ^[1-9][0-9]*/[1-9][0-9]*$ with current <= total <= 9999. +// Accept percent only as an integer from 0 through 100; it may be absent while preparing. +// Accept detail only when single-line, <= 256 characters, and an exact match +// for the reconstructed aggregate grammar below. Never accept an arbitrary +// rclone or log line: +// ^[0-9]+([.][0-9]+)? ([KMGTPE]i)?B / [0-9]+([.][0-9]+)? ([KMGTPE]i)?B, [0-9]+([.][0-9]+)? ([KMGTPE]i)?B/s, ETA (-|[0-9]+[dhms]([0-9]+[dhms])*)$ +``` + +The shell must parse only a matching rclone `Transferred:` statistics line and +reconstruct `transferred / total, speed, ETA value`. It never persists the raw +line. + +- [ ] **Step 4: Verify the Objective-C protocol test is GREEN** + +```bash +make test +``` + +Expected: the new progress support test passes; any later failure is an existing regression to diagnose before continuing. + +- [ ] **Step 5: Add failing shell integration cases for headless telemetry** + +Extend `tests/backup-outcome-test.sh` with these executable tests and a fake +`mv` spy that records only state-file basenames, never file contents: + +```bash +enable_state_publish_order_spy() { + cat >"$FAKE_BIN/mv" <<'SH' +#!/bin/bash +destination="" +for argument in "$@"; do destination="$argument"; done +case "$destination" in + */last-run.status|*/current-progress.status) + if [[ -n "${GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG:-}" ]]; then + printf '%s\n' "${destination##*/}" >>"$GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG" + fi + ;; +esac +exec /bin/mv "$@" +SH + chmod +x "$FAKE_BIN/mv" +} + +last_terminal_publish_order() { + tail -n 2 "$1" 2>/dev/null | paste -sd, - +} + +test_headless_retry_publishes_private_progress() { + local name="headless retry publishes private aggregate progress" + local progress content summary mode status backup_pid attempt + local summary_pid summary_started progress_updated + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + + FAKE_RCLONE_COPY_OUTPUT=$'INFO : secret-file.pdf: Copied\nTransferred: 1.200 GiB / 1.900 GiB, 63%, 12.400 MiB/s, ETA 58s' \ + FAKE_RCLONE_SLEEP_SECONDS=3 \ + run_backup \ + "GDRIVE_BACKUP_TRIGGER=schedule-retry" \ + "GDRIVE_BACKUP_RETRY_ORIGIN_STARTED_AT=1785520805" \ + "GDRIVE_BACKUP_RETRY_ATTEMPT=1" \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" \ + "BACKUP_PROGRESS_FOREGROUND=0" & + backup_pid=$! + + for attempt in {1..100}; do + content="$(cat "$progress" 2>/dev/null || true)" + [[ "$content" == *$'percent=63\n'* ]] && break + sleep 0.05 + done + mode="$(stat -f '%Lp' "$progress" 2>/dev/null || true)" + content="$(cat "$progress" 2>/dev/null || true)" + summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + summary_pid="$(awk -F= '$1 == "pid" {print $2}' "$SUMMARY_STATE_FILE")" + summary_started="$(awk -F= '$1 == "started_at" {print $2}' "$SUMMARY_STATE_FILE")" + progress_updated="$(awk -F= '$1 == "updated_at" {print $2}' "$progress")" + wait "$backup_pid" + status=$? + + if [[ "$status" == "0" && "$mode" == "600" && + "$content" == *$'protocol=1\n'* && + "$content" == *$'profile_id=default\n'* && + "$summary" == *$'status=running\n'* && + "$summary_pid" =~ ^[0-9]+$ && + "$summary_started" =~ ^[0-9]+$ && + "$content" == *"pid=$summary_pid"* && + "$content" == *"started_at=$summary_started"* && + "$content" == *$'trigger=schedule-retry\n'* && + "$content" == *$'retry_attempt=1\n'* && + "$content" == *$'label=My Drive\n'* && + "$content" == *$'phase=1/2\n'* && + "$content" == *$'percent=63\n'* && + "$content" == *$'detail=1.200 GiB / 1.900 GiB, 12.400 MiB/s, ETA 58s\n'* && + "$progress_updated" =~ ^[0-9]+$ && + "$content" != *'secret-file.pdf'* ]]; then + pass "$name" + else + fail "$name (exit=$status mode=$mode progress=${content//$'\n'/,})" + fi +} + +test_headless_retry_never_opens_progress_window() { + local name="headless retry telemetry remains passive" + local progress status open_args terminal + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" "$TEST_HOME/GDrive Backup Tiger.app" + + BACKUP_DISABLE_ANIMATION=0 \ + run_backup \ + "GDRIVE_BACKUP_TRIGGER=schedule-retry" \ + "GDRIVE_BACKUP_RETRY_ORIGIN_STARTED_AT=1785520805" \ + "GDRIVE_BACKUP_RETRY_ATTEMPT=1" \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" \ + "GDRIVE_BACKUP_ANIMATION_APP=$TEST_HOME/GDrive Backup Tiger.app" \ + "GDRIVE_BACKUP_OPEN_BIN=$FAKE_BIN/open" \ + "BACKUP_PROGRESS_FOREGROUND=0" + status=$? + open_args="$(cat "$OPEN_LOG" 2>/dev/null || true)" + terminal="$(cat "$progress" 2>/dev/null || true)" + + if [[ "$status" == "0" && -z "$open_args" && + "$terminal" == *$'status=finished\n'* && + "$terminal" != *$'percent='* ]]; then + pass "$name" + else + fail "$name (exit=$status open=${open_args//$'\n'/,} progress=${terminal//$'\n'/,})" + fi +} + +test_terminal_outcomes_invalidate_durable_progress() { + local name="terminal summaries invalidate durable progress" + local progress status success_summary success_progress + local failure_summary failure_progress success_order failure_order + local order_log success_ok=0 + + prepare_test_environment + enable_state_publish_order_spy + order_log="$TEST_HOME/state-publish-order.log" + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG=$order_log" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" + status=$? + success_summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + success_progress="$(cat "$progress" 2>/dev/null || true)" + success_order="$(last_terminal_publish_order "$order_log")" + if [[ "$status" == "0" && + "$success_summary" == *$'status=success\n'* && + "$success_summary" == *$'exit_code=0\n'* && + "$success_progress" == *$'status=finished\n'* && + "$success_progress" != *$'percent='* && + "$success_order" == "last-run.status,current-progress.status" ]]; then + success_ok=1 + fi + + prepare_test_environment + enable_state_publish_order_spy + order_log="$TEST_HOME/state-publish-order.log" + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + FAKE_RCLONE_COPY_STATUS=23 run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG=$order_log" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" + status=$? + failure_summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + failure_progress="$(cat "$progress" 2>/dev/null || true)" + failure_order="$(last_terminal_publish_order "$order_log")" + + if [[ "$success_ok" == "1" && "$status" == "1" && + "$failure_summary" == *$'status=failure\n'* && + "$failure_summary" == *$'exit_code=1\n'* && + "$failure_progress" == *$'status=finished\n'* && + "$failure_progress" != *$'percent='* && + "$failure_order" == "last-run.status,current-progress.status" ]]; then + pass "$name" + else + fail "$name (success=${success_summary//$'\n'/,}; failure=${failure_summary//$'\n'/,}; progress=${failure_progress//$'\n'/,})" + fi +} + +# Add all three calls immediately before the final `if (( failures > 0 ))` block. +test_headless_retry_publishes_private_progress +test_headless_retry_never_opens_progress_window +test_terminal_outcomes_invalidate_durable_progress +``` + +Also pass these variables through `start_backup_async`'s `env` block: + +```bash +GDRIVE_BACKUP_PROFILE_ID="${GDRIVE_BACKUP_PROFILE_ID:-default}" \ +GDRIVE_BACKUP_PROGRESS_STATE_FILE="${GDRIVE_BACKUP_PROGRESS_STATE_FILE:-}" \ +GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG="${GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG:-}" \ +``` + +Replace the existing TERM test with the same test plus durable-summary checks: + +```bash +test_term_signal_publishes_cancellation() { + local name="TERM publishes cancellation and invalidates live progress" + local backup_pid status state summary terminal started_file progress + local order_log terminal_order + prepare_test_environment + enable_state_publish_order_spy + started_file="$TEST_HOME/rclone-started" + progress="$TEST_HOME/profiles/default/current-progress.status" + order_log="$TEST_HOME/state-publish-order.log" + mkdir -p "${progress%/*}" + + GDRIVE_BACKUP_PROGRESS_STATE_FILE="$progress" \ + GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG="$order_log" \ + start_backup_async "$started_file" + backup_pid="$ASYNC_BACKUP_PID" + for _ in {1..60}; do + [[ -e "$started_file" ]] && break + /bin/sleep 0.05 + done + kill -TERM "$backup_pid" 2>/dev/null || true + wait "$backup_pid" + status=$? + state="$(cat "$RUN_STATE_FILE" 2>/dev/null || true)" + summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + terminal="$(cat "$progress" 2>/dev/null || true)" + terminal_order="$(last_terminal_publish_order "$order_log")" + + if [[ "$status" == "143" && "$state" == *$'status=cancelled\n'* && + "$state" == *$'signal=TERM\n'* && "$state" == *'exit_code=143'* && + "$summary" == *$'status=cancelled\n'* && + "$terminal" == *$'status=finished\n'* && + "$terminal" != *$'percent='* && + "$terminal_order" == "last-run.status,current-progress.status" ]]; then + pass "$name" + else + fail "$name (exit=$status state=${state//$'\n'/,} summary=${summary//$'\n'/,} progress=${terminal//$'\n'/,})" + fi +} +``` + +Run: + +```bash +bash tests/backup-outcome-test.sh +``` + +Expected RED: the headless run creates no progress file. + +- [ ] **Step 6: Separate telemetry creation from foreground presentation in the shell** + +Modify `bin/backup-google-drive.sh` with these concrete responsibilities: + +```bash +DURABLE_PROGRESS_FILE="${GDRIVE_BACKUP_PROGRESS_STATE_FILE:-}" +PROGRESS_PROFILE_ID="${GDRIVE_BACKUP_PROFILE_ID:-${ACTIVE_PROFILE_ID:-legacy}}" + +initialize_durable_progress() { + [[ "$DRY_RUN" == "0" && "$SETUP_UI" == "0" ]] || return 0 + [[ "$PROGRESS_PROFILE_ID" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || return 0 + if [[ -z "$DURABLE_PROGRESS_FILE" ]]; then + DURABLE_PROGRESS_FILE="${SUMMARY_STATE_FILE%/*}/current-progress.status" + fi + write_durable_progress "preparing" "" "" "" +} + +public_progress_label() { + case "$1" in + "My Drive") printf 'My Drive' ;; + "Shared with me") printf 'Shared with me' ;; + *) printf 'Shared Drive' ;; + esac +} + +parse_rclone_progress_fields() { + local line="$1" + local pattern='^Transferred:[[:space:]]*([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B)[[:space:]]+/[[:space:]]+([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B),[[:space:]]+([0-9]{1,3})%,[[:space:]]+([0-9][0-9.]*[[:space:]]+([KMGTPE]i)?B/s),[[:space:]]+ETA[[:space:]]+(-|([0-9]+[dhms])+)$' + [[ "$line" =~ $pattern ]] || return 1 + RCLONE_PROGRESS_TRANSFERRED="${BASH_REMATCH[1]}" + RCLONE_PROGRESS_TOTAL="${BASH_REMATCH[3]}" + RCLONE_PROGRESS_PERCENT="${BASH_REMATCH[5]}" + RCLONE_PROGRESS_SPEED="${BASH_REMATCH[6]}" + RCLONE_PROGRESS_ETA="${BASH_REMATCH[8]}" + [[ "${RCLONE_PROGRESS_TRANSFERRED%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "${RCLONE_PROGRESS_TOTAL%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "${RCLONE_PROGRESS_SPEED%% *}" =~ ^[0-9]+([.][0-9]+)?$ && + "$RCLONE_PROGRESS_PERCENT" -le 100 ]] || return 1 + RCLONE_PROGRESS_DETAIL="$RCLONE_PROGRESS_TRANSFERRED / $RCLONE_PROGRESS_TOTAL, $RCLONE_PROGRESS_SPEED, ETA $RCLONE_PROGRESS_ETA" +} + +write_durable_progress() { + local label="${1:-preparing}" percent="${2:-}" detail="${3:-}" phase="${4:-}" + local directory temporary existing_owner phase_current phase_total + [[ -n "$DURABLE_PROGRESS_FILE" ]] || return 0 + case "$label" in preparing|"My Drive"|"Shared with me"|"Shared Drive") ;; *) return 1 ;; esac + [[ -z "$phase" || "$phase" =~ ^[1-9][0-9]*/[1-9][0-9]*$ ]] || return 1 + if [[ -n "$phase" ]]; then + phase_current="${phase%/*}" + phase_total="${phase#*/}" + [[ "$phase_current" -le "$phase_total" && "$phase_total" -le 9999 ]] || return 1 + fi + [[ -z "$percent" || ( "$percent" =~ ^[0-9]+$ && "$percent" -le 100 ) ]] || return 1 + if [[ -n "$detail" && "$detail" != "${RCLONE_PROGRESS_DETAIL:-}" ]]; then return 1; fi + [[ ! -L "$DURABLE_PROGRESS_FILE" ]] || return 1 + [[ ! -e "$DURABLE_PROGRESS_FILE" || -f "$DURABLE_PROGRESS_FILE" ]] || return 1 + if [[ -e "$DURABLE_PROGRESS_FILE" ]]; then + existing_owner="$(stat -f '%u' "$DURABLE_PROGRESS_FILE")" || return 1 + [[ "$existing_owner" == "$(id -u)" ]] || return 1 + fi + directory="${DURABLE_PROGRESS_FILE%/*}" + (umask 077 && mkdir -p "$directory") || return 1 + temporary="$(umask 077; mktemp "${DURABLE_PROGRESS_FILE}.tmp.XXXXXX")" || return 1 + if ! (umask 077; { + printf 'protocol=1\nprofile_id=%s\npid=%s\nstarted_at=%s\ntrigger=%s\n' \ + "$PROGRESS_PROFILE_ID" "$$" "$RUN_STARTED_AT" "$BACKUP_TRIGGER" + [[ -n "$RETRY_ATTEMPT" ]] && printf 'retry_attempt=%s\n' "$RETRY_ATTEMPT" + printf 'label=%s\n' "$label" + [[ -n "$phase" ]] && printf 'phase=%s\n' "$phase" + [[ -n "$percent" ]] && printf 'percent=%s\n' "$percent" + [[ -n "$detail" ]] && printf 'detail=%s\n' "$detail" + printf 'updated_at=%s\n' "$(date +%s)" + } >"$temporary" && chmod 600 "$temporary"); then + cleanup_temp_file "$temporary" + return 1 + fi + mv -f "$temporary" "$DURABLE_PROGRESS_FILE" +} +``` + +`write_durable_progress` must use `umask 077`, an adjacent +`mktemp "${DURABLE_PROGRESS_FILE}.tmp.XXXXXX"` file, `chmod 600`, and +`mv -f`. It writes protocol, profile ID, PID, start time, +trigger, retry attempt, public label, phase, bounded percent, reconstructed +aggregate detail, and `updated_at`. It rejects symlinks, non-regular existing +paths, and files not owned by the current user. + +Call `initialize_durable_progress` immediately after lock ownership and after +`RUN_STARTED_AT`, trigger, profile, and retry identity are fixed, but before +slow NAS/remote preflight. Keep `start_animation` unchanged as the foreground +window gate. Keep `write_progress` for its current foreground file only. In +`update_progress_from_rclone_line`, call +`parse_rclone_progress_fields "$line"`; only on success call +`write_durable_progress "$(public_progress_label "$label")" +"$RCLONE_PROGRESS_PERCENT" "$RCLONE_PROGRESS_DETAIL" "$phase"`. At the +start of each copy phase publish its public label and phase without a detail; +never pass localized foreground text or a raw rclone line to the durable +writer. A telemetry-write failure logs one generic warning and degrades the UI +to indeterminate without changing the backup outcome. + +In `cleanup`, first call `finish_run_state` and only after it returns atomically +replace the durable record with `protocol=1`, matching profile/PID/start/ +trigger/retry identity, `status=finished`, and `updated_at`; omit label, phase, +percent, and detail, and do not unlink the file. + +- [ ] **Step 7: Verify GREEN and commit the protocol** + +```bash +bash -n bin/backup-google-drive.sh +bash tests/backup-outcome-test.sh +make test +git add Makefile bin/backup-google-drive.sh \ + macos/GDriveBackupTiger/BackupProgressSupport.h \ + macos/GDriveBackupTiger/BackupProgressSupport.m \ + tests/progress-support-test.m tests/backup-outcome-test.sh +git diff --cached --check +git commit -m "feat: persist private backup progress telemetry" +``` + +Expected: all tests pass and one focused protocol commit is created. + +--- + +### Task 3: Render retry-specific progress in the overview and menu bar + +**Files:** +- Modify: `Makefile` +- Modify: `install.sh` +- Modify: `macos/GDriveBackupTiger/main.m` +- Modify: `macos/GDriveBackupTiger/Localization.m` +- Modify: `tests/release-metadata-test.sh` +- Modify: `tests/overview-ui-test.m` +- Modify: `tests/tiger-accessibility-test.m` + +**Interfaces:** +- Consumes: Task 2's `GDTBackupProgressPathForSummaryPath`, `GDTReadBackupProgressAtPath`, and `GDTValidatedBackupProgressForValues`. +- Produces: snapshot keys `trigger`, `retryRunning`, `progressVisible`, `progressLabel`, `progressPhase`, `progressPercent`, and `progressDetail`; one native overview progress indicator; one compact menu status row. + +- [ ] **Step 1: Write failing snapshot and menu assertions** + +In `tests/overview-ui-test.m`, add a running retry summary and validated progress dictionary: + +```objc +NSDictionary *dailyNAS = @{ + @"GDRIVE_BACKUP_PROFILE_ID": @"default", + @"GDRIVE_BACKUP_TARGET": @"nas", + @"GDRIVE_BACKUP_NAS_MOUNT": @"/Volumes/alexander", + @"GDRIVE_BACKUP_NAS_SUBDIR": @"GoogleDrive-Backup", + @"GDRIVE_BACKUP_SCHEDULE": @"daily" +}; +NSDictionary *retrySummary = @{ + @"protocol": @"1", @"status": @"running", @"pid": @"123", + @"started_at": @"1785522633", @"trigger": @"schedule-retry", + @"retry_origin_started_at": @"1785520805", @"retry_attempt": @"1" +}; +NSDictionary *retryProgress = @{ + @"label": @"Shared Drive", @"phase": @"3/5", @"percent": @"63", + @"detail": @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s" +}; +NSDictionary *retrySnapshot = [delegate overviewSnapshotForConfig:dailyNAS + summary:retrySummary status:@"running" progress:retryProgress + now:now calendar:calendar]; +NSString *phaseText = [NSString stringWithFormat:T(@"en", @"progressAreaFormat"), + @"3", @"5"]; +NSString *retryStart = [[delegate overviewDateFormatterWithCalendar:calendar] + stringFromDate:[NSDate dateWithTimeIntervalSince1970:1785522633]]; +Assert([retrySnapshot[@"retryRunning"] isEqualToString:@"1"] && + [retrySnapshot[@"lastRun"] isEqualToString:T(@"en", @"automaticRetryRunning")] && + [retrySnapshot[@"lastRunDetail"] isEqualToString:retryStart] && + [retrySnapshot[@"progressPhase"] isEqualToString:phaseText] && + [retrySnapshot[@"progressPercent"] isEqualToString:@"63"] && + [retrySnapshot[@"progressDetail"] isEqualToString: + @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s"], + @"running automatic retry has explicit phase progress"); + +NSMenu *retryMenu = [delegate statusMenuForSnapshot:retrySnapshot]; +NSString *retryMenuText = [[retryMenu.itemArray valueForKey:@"title"] + componentsJoinedByString:@" "]; +NSMenuItem *retryProgressItem = nil; +for (NSMenuItem *item in retryMenu.itemArray) { + if ([item.title containsString:T(@"en", @"automaticRetryRunningShort")]) { + retryProgressItem = item; + break; + } +} +NSMenuItem *retryBackup = [retryMenu itemWithTitle:T(@"en", @"backupNow")]; +NSMenuItem *retryOpen = [retryMenu itemWithTitle:T(@"en", @"overviewOpen")]; +Assert([retryMenuText containsString:T(@"en", @"automaticRetryRunningShort")] && + [retryMenuText containsString:phaseText] && + [retryMenuText containsString:@"63 %"] && + retryProgressItem != nil && !retryProgressItem.enabled && + retryBackup != nil && !retryBackup.enabled && + retryOpen != nil && retryOpen.enabled, + @"menu bar exposes compact retry progress"); +``` + +Add a second assertion with `progress:nil` requiring `progressVisible=1`, an +empty percentage, `progressDetail=T(@"en", @"progressPreparing")`, and no +invented phase or transfer detail. + +- [ ] **Step 2: Write failing native progress and accessibility assertions** + +In `tests/tiger-accessibility-test.m`, after creating its `AppDelegate`, +instantiate a separate `TigerOverviewView`, apply a retry snapshot, and assert: + +```objc +TigerOverviewView *overviewView = [[TigerOverviewView alloc] + initWithFrame:NSMakeRect(0, 0, 620, 420)]; +[delegate applyOverviewSnapshot:@{ + @"status": @"running", @"retryRunning": @"1", + @"progressVisible": @"1", + @"progressLabel": T(@"en", @"automaticRetryRunning"), + @"progressPhase": @"Area 3 of 5", @"progressPercent": @"63", + @"progressDetail": @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s" +} toView:overviewView]; +Assert(overviewView.progressIndicator != nil && + !overviewView.progressIndicator.hidden && + !overviewView.progressIndicator.indeterminate && + overviewView.progressIndicator.doubleValue == 63 && + [overviewView.progressPercentLabel.stringValue isEqualToString:@"63 %"] && + [overviewView.progressDetailLabel.stringValue isEqualToString: + @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s"] && + overviewView.progressDetailLabel.frame.size.width >= 400 && + !overviewView.backupButton.enabled, + @"retry overview exposes visible percent and full aggregate detail"); +Assert([overviewView.progressIndicator.accessibilityRole + isEqualToString:NSAccessibilityProgressIndicatorRole] && + [overviewView.progressIndicator.accessibilityLabel + isEqualToString:T(@"en", @"backupProgressCurrentPhase")], + @"retry progress is announced as current-phase progress"); +``` + +Repeat for no percentage and require `indeterminate == YES`. + +- [ ] **Step 3: Run the UI tests and verify RED** + +```bash +make test +``` + +Expected RED: the extended snapshot selector, overview progress properties, +and localization keys do not exist. + +- [ ] **Step 4: Add the progress support module to every app and UI-test link** + +Define `PROGRESS_SUPPORT_SOURCE := macos/GDriveBackupTiger/BackupProgressSupport.m` +in `Makefile`, add `$(PROGRESS_SUPPORT_SOURCE)` to `APP_SOURCES`, and append it +to each of the ten inline test links that includes `main.m`: run-state, +accessibility, notification integration, diagnostics integration, setup-health +UI, overview UI, setup safety, mount trigger, profile UI, and update UI. Verify +the count with: + +```bash +test "$(rg -n '\$\(PROGRESS_SUPPORT_SOURCE\)' Makefile | wc -l | tr -d ' ')" = "11" +``` + +Add `"$ROOT/macos/GDriveBackupTiger/BackupProgressSupport.m"` to the explicit +source-installer `clang` list in `install.sh`, and add +`BackupProgressSupport.m` to the source-linkage loop in +`tests/release-metadata-test.sh`. Import the header from `main.m`: + +```objc +#import "BackupProgressSupport.h" +``` + +- [ ] **Step 5: Extend the overview view with a passive native indicator** + +Add these properties to `TigerOverviewView`: + +```objc +@property(nonatomic) BOOL progressVisible; +@property(nonatomic) CGFloat progressPercent; +@property(nonatomic, copy) NSString *progressSummary; +@property(nonatomic, copy) NSString *progressDetail; +@property(nonatomic, strong) NSProgressIndicator *progressIndicator; +@property(nonatomic, strong) NSTextField *progressSummaryLabel; +@property(nonatomic, strong) NSTextField *progressPhaseLabel; +@property(nonatomic, strong) NSTextField *progressPercentLabel; +@property(nonatomic, strong) NSTextField *progressDetailLabel; +``` + +Place the summary at `NSMakeRect(116, 174, 440, 18)`, the localized phase at +`NSMakeRect(116, 194, 110, 18)`, the bar at +`NSMakeRect(232, 196, 200, 14)`, the visible percentage at +`NSMakeRect(440, 193, 52, 18)`, and the full aggregate detail at +`NSMakeRect(116, 216, 440, 18)`. Hide all five outside a running state. The +detail label is single-line but must be wide enough for the tested aggregate +string without truncation. A negative `progressPercent` selects indeterminate +animation and an empty percent label; `0...100` selects determinate mode and +formats the visible label as `N %`. Set the accessibility role to +`NSAccessibilityProgressIndicatorRole` and label to +`T(language, @"backupProgressCurrentPhase")`. + +- [ ] **Step 6: Build a single retry-aware snapshot** + +Add the overload: + +```objc +- (NSDictionary *)overviewSnapshotForConfig: + (NSDictionary *)config + summary:(NSDictionary *)summary + status:(NSString *)status + progress:(NSDictionary * _Nullable)progress + now:(NSDate *)now + calendar:(NSCalendar *)calendar; +``` + +Keep the existing selector as a compatibility wrapper that passes `nil`. +When `status=running` and `trigger=schedule-retry`, emit: + +```objc +@"trigger": @"schedule-retry", +@"retryRunning": @"1", +@"progressVisible": @"1", +@"progressLabel": T(language, @"automaticRetryRunning"), +@"progressPhase": GDTLocalizedProgressPhase(progress[@"phase"], language), +@"progressPercent": progress[@"percent"] ?: @"", +@"progressDetail": progress[@"detail"] ?: T(language, @"progressPreparing") +``` + +Set `lastRun` to `automaticRetryRunning` and continue using `started_at` for +`lastRunDetail`, so the retry start time remains visible. Implement +`GDTLocalizedProgressPhase` by splitting only the already-validated `N/M` and +formatting `progressAreaFormat`; never display the raw label as a Shared Drive +name. `applyOverviewSnapshot` updates the view, exposes the aggregate detail, +and keeps the Backup button disabled. `statusMenuForSnapshot` adds one disabled +compact row only while `retryRunning=1`. + +In `refreshOverviewStatus`, read the progress path beside the captured profile +summary on the utility queue, validate it against the already-read summary and +status, and pass the validated dictionary to the new overload. Do not perform +file or process checks on the main thread. + +- [ ] **Step 7: Localize all new user-visible strings** + +Add complete values in `Localization.m` for German, English, French, Spanish, +Japanese, Cantonese, and Korean: + +```text +automaticRetryRunning +automaticRetryRunningShort +backupProgressCurrentPhase +progressAreaFormat +progressPreparing +``` + +Use these reviewed values in the key order above: + +```text +de: Automatischer Wiederholungsversuch läuft | Retry läuft | Fortschritt des aktuellen Bereichs | Bereich %1$@ von %2$@ | Wird vorbereitet … +en: Automatic retry is running | Retry running | Current area progress | Area %1$@ of %2$@ | Preparing … +fr: Nouvelle tentative automatique en cours | Nouvelle tentative en cours | Progression de la zone actuelle | Zone %1$@ sur %2$@ | Préparation… +es: Reintento automático en curso | Reintento en curso | Progreso del área actual | Área %1$@ de %2$@ | Preparando… +ja: 自動再試行を実行中 | 再試行中 | 現在の領域の進行状況 | 領域 %1$@ / %2$@ | 準備中… +yue: 自動重試進行中 | 重試中 | 目前區域嘅進度 | 區域 %1$@ / %2$@ | 準備中… +ko: 자동 재시도 실행 중 | 재시도 중 | 현재 영역 진행률 | 영역 %1$@/%2$@ | 준비 중… +``` + +Append the five keys to `overviewKeys` in `tests/overview-ui-test.m`, and in +the existing `SupportedLanguageCodes()` loop assert that every value is +nonempty, differs from its key, and that formatting `progressAreaFormat` with +`@"3", @"5"` contains both numbers. + +- [ ] **Step 8: Verify GREEN and commit the passive UI** + +```bash +make test +git add Makefile macos/GDriveBackupTiger/main.m \ + macos/GDriveBackupTiger/Localization.m \ + install.sh tests/release-metadata-test.sh \ + tests/overview-ui-test.m tests/tiger-accessibility-test.m +git diff --cached --check +git commit -m "feat: show automatic retry progress in the overview" +``` + +Expected: overview, menu, accessibility, and full-screen regression tests pass. + +--- + +### Task 4: Replace the stale notification when the retry starts + +**Files:** +- Modify: `macos/GDriveBackupTiger/NotificationSupport.m` +- Modify: `macos/GDriveBackupTiger/main.m` +- Modify: `macos/GDriveBackupTiger/Localization.m` +- Modify: `tests/notification-support-test.m` +- Modify: `tests/notification-integration-test.m` + +**Interfaces:** +- Consumes: a validated summary with `status=running`, `trigger=schedule-retry`, `retry_origin_started_at`, and `started_at`; the existing failure notification identifier and active-issue latch. +- Produces: one `retry-running` notification decision with the same persistent identifier and a distinct delivery revision; a retry-running menu bar alert state that does not clear the failure latch. + +- [ ] **Step 1: Write the failing notification policy test** + +Add to `tests/notification-support-test.m`: + +```objc +NSMutableDictionary *retryRunningSummary = [nasNotReady mutableCopy]; +retryRunningSummary[@"status"] = @"running"; +retryRunningSummary[@"trigger"] = @"schedule-retry"; +retryRunningSummary[@"retry_origin_started_at"] = failedSummary[@"started_at"]; +retryRunningSummary[@"retry_attempt"] = @"1"; +retryRunningSummary[@"started_at"] = [NSString stringWithFormat:@"%.0f", + Date(calendar, 21, 20, 56).timeIntervalSince1970]; +[retryRunningSummary removeObjectForKey:@"finished_at"]; +[retryRunningSummary removeObjectForKey:@"exit_code"]; +NSDictionary *retryRunning = Decision( + policyClass, daily, retryRunningSummary, @"running", + Date(calendar, 21, 20, 57), calendar); +Assert([retryRunning[@"kind"] isEqualToString:@"retry-running"] && + [retryRunning[@"identifier"] isEqualToString:retryPlanned[@"identifier"]] && + [retryRunning[@"revision"] hasSuffix:retryRunningSummary[@"started_at"]] && + [retryRunning[@"titleKey"] isEqualToString:@"backupNotificationRetryRunningTitle"] && + [retryRunning[@"bodyKey"] isEqualToString:@"backupNotificationRetryRunningBody"], + @"a running retry updates the preliminary alert in place"); + +NSTimeInterval originStart = + Date(calendar, 21, 20, 0).timeIntervalSince1970; +NSTimeInterval originFinish = + Date(calendar, 21, 20, 5).timeIntervalSince1970; +NSMutableDictionary *differentTimes = [failedSummary mutableCopy]; +differentTimes[@"trigger"] = @"schedule"; +differentTimes[@"started_at"] = [NSString stringWithFormat:@"%.0f", originStart]; +differentTimes[@"finished_at"] = [NSString stringWithFormat:@"%.0f", originFinish]; +NSDictionary *originalFailure = Decision( + policyClass, daily, differentTimes, @"failure", + Date(calendar, 21, 20, 6), calendar); +Assert([originalFailure[@"issueTimestamp"] doubleValue] == originFinish && + [originalFailure[@"issueOriginTimestamp"] doubleValue] == originStart && + [originalFailure[@"identifier"] hasSuffix: + [NSString stringWithFormat:@".%.0f", originStart]], + @"an original failure uses run start as canonical origin, not finish time"); + +NSMutableDictionary *finalRetry = [differentTimes mutableCopy]; +finalRetry[@"trigger"] = @"schedule-retry"; +finalRetry[@"retry_origin_started_at"] = + [NSString stringWithFormat:@"%.0f", originStart]; +finalRetry[@"started_at"] = [NSString stringWithFormat:@"%.0f", + Date(calendar, 21, 20, 40).timeIntervalSince1970]; +finalRetry[@"finished_at"] = [NSString stringWithFormat:@"%.0f", + Date(calendar, 21, 20, 45).timeIntervalSince1970]; +NSDictionary *finalRetryDecision = Decision( + policyClass, daily, finalRetry, @"failure", + Date(calendar, 21, 20, 46), calendar); +Assert([finalRetryDecision[@"issueOriginTimestamp"] doubleValue] == originStart, + @"the final retry inherits the original run-start origin"); +``` + +- [ ] **Step 2: Write failing integration assertions for one-time replacement** + +Extend `tests/notification-integration-test.m` with a revision-bearing running +decision that reuses the preliminary identifier: + +```objc +static void HandleBackupAction(id delegate, NSString *action, + NSString *category, NSDictionary *userInfo, + NSString *identifier) { + SEL selector = NSSelectorFromString( + @"handleBackupNotificationActionIdentifier:categoryIdentifier:userInfo:notificationIdentifier:"); + typedef void (*ActionMethod)(id, SEL, NSString *, NSString *, + NSDictionary *, NSString *); + ActionMethod method = [delegate respondsToSelector:selector] + ? (ActionMethod)[delegate methodForSelector:selector] : NULL; + if (method) method(delegate, selector, action, category, userInfo, identifier); +} + +NSMutableDictionary *retryRunningDecision = [preliminary mutableCopy]; +retryRunningDecision[@"kind"] = @"retry-running"; +retryRunningDecision[@"revision"] = @"retry-running.430"; +retryRunningDecision[@"issueOriginTimestamp"] = @"400"; +retryRunningDecision[@"titleKey"] = @"backupNotificationRetryRunningTitle"; +retryRunningDecision[@"bodyKey"] = @"backupNotificationRetryRunningBody"; +Process(replacement, preliminary); +Process(replacement, retryRunningDecision); +Process(replacement, retryRunningDecision); +Assert(replacement.deliveryCalls == 2 && + replacement.removedNotificationIdentifiers == nil, + @"retry start updates one identifier once without a duplicate alert"); + +NotificationTestDelegate *restarted = [[NotificationTestDelegate alloc] init]; +restarted.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName stringByAppendingString:@".replacement"]]; +restarted.deliverySucceeds = YES; +Process(restarted, retryRunningDecision); +Assert(restarted.deliveryCalls == 0, + @"a controller restart does not repeat the running revision"); +``` + +Then process final retry failure and assert the running/preliminary identifier +is retired only after macOS accepts the final notification. Add a rejected +delivery case proving `lastDeliveredRevision` is not advanced on failure. + +Assert that the `GDT_BACKUP_ALERT` category carries +`UNNotificationCategoryOptionCustomDismissAction`. Add an overridable +`handleBackupNotificationActionIdentifier:categoryIdentifier:userInfo:notificationIdentifier:` +seam and invoke it with `UNNotificationDismissActionIdentifier`, +`GDT_BACKUP_ALERT`, +`profileID=office`, `issueOriginTimestamp=400`, and the preliminary request ID. +Assert that this explicit action records `dismissedIssueAt=400`, clears the +active issue latch, and removes that exact delivered and pending request. A +final retry failure carrying `issueOriginTimestamp=400` must remain suppressed; +a later independent failure with `issueOriginTimestamp=500` must still deliver. +Merely returning an empty delivered-notification list must *not* acknowledge an +issue, because macOS expiry or state lag is not proof of a human dismissal. + +Finally, invoke the same action seam with `GDT_OPEN_BACKUP_OVERVIEW` and the +same content metadata. Assert that it records the same explicit +acknowledgement, removes that exact request, opens the normal overview once, +and does not launch a backup. Also assert that an unrelated category or missing +validated issue metadata cannot mutate acknowledgement state. + +Add this explicit stale-response race after the matching-dismiss case: + +```objc +NSString *activeAtKey = @"GDTBackupNotification.office.activeIssueAt"; +NSString *activeIDKey = @"GDTBackupNotification.office.activeIssueIdentifier"; +NSString *dismissedKey = @"GDTBackupNotification.office.dismissedIssueAt"; +NSString *oldID = @"com.commcats.gdrivebackup.office.failure.400"; +NSString *newID = @"com.commcats.gdrivebackup.office.failure.500"; +[replacement.testDefaults setDouble:400 forKey:activeAtKey]; +[replacement.testDefaults setObject:oldID forKey:activeIDKey]; +HandleBackupAction(replacement, UNNotificationDismissActionIdentifier, + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"400"}, oldID); +Assert([replacement.testDefaults doubleForKey:dismissedKey] == 400 && + [replacement.testDefaults objectForKey:activeAtKey] == nil && + [replacement.testDefaults objectForKey:activeIDKey] == nil && + [replacement.removedNotificationIdentifiers containsObject:oldID], + @"an explicit matching dismiss acknowledges and retires one issue"); + +[replacement.testDefaults setDouble:500 forKey:activeAtKey]; +[replacement.testDefaults setObject:newID forKey:activeIDKey]; +HandleBackupAction(replacement, UNNotificationDismissActionIdentifier, + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"400"}, oldID); +Assert([replacement.testDefaults doubleForKey:dismissedKey] == 400 && + [replacement.testDefaults doubleForKey:activeAtKey] == 500 && + [[replacement.testDefaults stringForKey:activeIDKey] isEqualToString:newID], + @"a late dismissal retires only its old issue and cannot clear a newer latch"); + +SEL refreshAlertSelector = NSSelectorFromString( + @"backupAlertStatusForConfig:summary:rawStatus:decision:"); +typedef NSString *(*RefreshAlertStatusMethod)(id, SEL, NSDictionary *, NSDictionary *, + NSString *, NSDictionary *); +RefreshAlertStatusMethod refreshAlertStatus = + [replacement respondsToSelector:refreshAlertSelector] + ? (RefreshAlertStatusMethod)[replacement methodForSelector:refreshAlertSelector] + : NULL; +[replacement.testDefaults removeObjectForKey:activeAtKey]; +[replacement.testDefaults removeObjectForKey:activeIDKey]; + +NSInteger deliveriesBeforeRefresh = replacement.deliveryCalls; +NSMutableDictionary *sameIssueFinal = [finalRetryFailure mutableCopy]; +sameIssueFinal[@"issueTimestamp"] = @"430"; +sameIssueFinal[@"issueOriginTimestamp"] = @"400"; +if (refreshAlertStatus) { + (void)refreshAlertStatus(replacement, refreshAlertSelector, + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"}, + @{@"started_at": @"410", @"finished_at": @"430", + @"trigger": @"schedule-retry"}, @"failure", sameIssueFinal); +} +Assert([replacement.testDefaults objectForKey:activeAtKey] == nil && + [replacement.testDefaults objectForKey:activeIDKey] == nil, + @"refresh cannot relatch a human-acknowledged origin"); +Process(replacement, sameIssueFinal); +Assert(replacement.deliveryCalls == deliveriesBeforeRefresh, + @"refresh cannot resurrect a dismissed origin under a later finish time"); + +NSMutableDictionary *newFailure = [finalRetryFailure mutableCopy]; +newFailure[@"identifier"] = newID; +newFailure[@"issueTimestamp"] = @"500"; +newFailure[@"issueOriginTimestamp"] = @"500"; +if (refreshAlertStatus) { + (void)refreshAlertStatus(replacement, refreshAlertSelector, + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"}, + @{@"started_at": @"500", @"finished_at": @"530", + @"trigger": @"schedule"}, @"failure", newFailure); +} +Process(replacement, newFailure); +Assert(replacement.deliveryCalls == deliveriesBeforeRefresh + 1 && + [replacement.testDefaults doubleForKey:activeAtKey] == 500 && + [[replacement.testDefaults stringForKey:activeIDKey] isEqualToString:newID], + @"a later independent issue still delivers and remains latched"); +``` + +Add the equivalent `GDT_OPEN_BACKUP_OVERVIEW` call with a matching active issue +and assert `overviewShowCalls == 1`, acknowledgement persisted, the exact +request retired, and `backupLaunchCalls == 0`. Repeat with +`categoryIdentifier=@"GDT_UNKNOWN_EXTERNAL_VOLUME"` and with missing origin +metadata; neither may mutate acknowledgement or latch state. These assertions +prevent timestamp drift or an unrelated action from resurrecting or clearing +the wrong issue. + +- [ ] **Step 3: Run notification tests and verify RED** + +```bash +make test +``` + +Expected RED: running status currently produces no decision and identifier-only dedup prevents an in-place content update. + +- [ ] **Step 4: Add the retry-running policy decision** + +In `GDTBackupNotificationPolicy`, before terminal failure handling, accept only +a structurally valid running retry: + +```objc +BOOL retryRunning = [status isEqualToString:@"running"] && + [trigger isEqualToString:@"schedule-retry"]; +NSTimeInterval retryOrigin = GDTTimestamp(summary[@"retry_origin_started_at"]); +NSTimeInterval retryStarted = GDTTimestamp(summary[@"started_at"]); +if (retryRunning && retryOrigin > 0 && retryStarted > retryOrigin && + [summary[@"retry_attempt"] isEqualToString:@"1"]) { + return @{ + @"identifier": [NSString stringWithFormat: + @"com.commcats.gdrivebackup.%@.failure.%.0f", profileID, retryOrigin], + @"revision": [NSString stringWithFormat:@"retry-running.%.0f", retryStarted], + @"kind": @"retry-running", + @"profileID": profileID, + @"issueTimestamp": [NSString stringWithFormat:@"%.0f", retryOrigin], + @"issueOriginTimestamp": [NSString stringWithFormat:@"%.0f", retryOrigin], + @"titleKey": @"backupNotificationRetryRunningTitle", + @"bodyKey": @"backupNotificationRetryRunningBody" + }; +} +``` + +- [ ] **Step 5: Make notification dedup revision-aware** + +In `appNotificationCategories`, register `GDT_BACKUP_ALERT` with +`UNNotificationCategoryOptionCustomDismissAction`. In +`processBackupNotificationDecision`, add a per-profile +`lastDeliveredRevision` defaults key. Existing decisions without `revision` +retain identifier-only dedup. A decision with `revision` is suppressed only +when both identifier and revision match. Key pending work by +`identifier + "\n" + revision` instead of identifier alone. Persist the new +revision only after `UNUserNotificationCenter` accepts delivery; rejected +delivery remains retryable. When a later non-revision decision is accepted, +or a newer automatic success clears the issue, remove the stored revision. +Keep the original failure identifier in `deliveredFailureIdentifiers` so +automatic success still clears it. Do not alter the active issue timestamp or +kind merely because `retry-running` was delivered. + +Put `profileID` and `issueOriginTimestamp` into `GDT_BACKUP_ALERT` content +`userInfo`. Route both `UNNotificationDismissActionIdentifier` and +`GDT_OPEN_BACKUP_OVERVIEW` from `didReceiveNotificationResponse` through one +validated acknowledgement helper that also receives and requires the exact +`GDT_BACKUP_ALERT` category. Only those explicit responses may persist +`dismissedIssueAt=issueOriginTimestamp` and remove the exact safe +delivered/pending request. Clear `activeIssueAt`, `activeIssueKind`, and the new +`activeIssueIdentifier` only when both the stored active origin and identifier +equal the response metadata; a late response for origin 400 must not mutate an +active origin 500. Missing delivered notifications remain a non-authoritative +observation and must never acknowledge an issue. + +Canonicalize every notification policy result: original failure, missed run, +retry-planned, retry-running, and final retry-failure all carry +`issueOriginTimestamp`. For an original failure it equals the `runTimestamp` +derived from `summary.started_at` and used in the failure identifier, while +`issueTimestamp` may remain the later completion time. For a missed run the +origin is its due timestamp. Every retry state inherits +`retry_origin_started_at`, even when its own start or finish timestamp differs. +`backupAlertStatusForConfig` and delivery bookkeeping +must compare and persist only this canonical origin, check it against +`dismissedIssueAt` *before* creating or advancing a latch, and persist the +matching notification identifier beside `activeIssueAt`. Suppress every later +decision whose origin is `<= dismissedIssueAt`; a genuinely newer independent +failure uses its own start time and remains visible. Newer automatic success +clears identifier, revision, dismissal, and latch keys together. + +In `backupAlertStatusForConfig`, return `retry-running` for a validated raw +running retry while leaving the stored failure latch intact. Add a non-red +running symbol and spoken status in `updateStatusItemPresentationForSnapshot`. + +- [ ] **Step 6: Localize the updated notification** + +Add all seven translations for: + +```text +backupNotificationRetryRunningTitle +backupNotificationRetryRunningBody +``` + +Use these title/body pairs: + +```text +de: Automatischer Wiederholungsversuch läuft | GDrive wird erneut gesichert. Öffne GDrive Backup Tiger, um den Fortschritt zu sehen. +en: Automatic backup retry is running | GDrive is being backed up again. Open GDrive Backup Tiger to view progress. +fr: Nouvelle tentative de sauvegarde automatique en cours | Une nouvelle sauvegarde de GDrive est en cours. Ouvrez GDrive Backup Tiger pour suivre la progression. +es: Reintento automático de copia de seguridad en curso | Se está realizando de nuevo la copia de seguridad de GDrive. Abre GDrive Backup Tiger para ver el progreso. +ja: 自動バックアップを再試行中 | GDrive をもう一度バックアップしています。進行状況を確認するには GDrive Backup Tiger を開いてください。 +yue: 自動備份重試進行中 | GDrive 正在再次備份。請開啟 GDrive Backup Tiger 查看進度。 +ko: 자동 백업 재시도 실행 중 | GDrive를 다시 백업하고 있습니다. 진행 상황을 보려면 GDrive Backup Tiger를 여십시오. +``` + +Append both keys to the existing all-language notification-key loop and require +every translation to be nonempty and unequal to its key. + +- [ ] **Step 7: Verify GREEN and commit notification state** + +```bash +make test +git add macos/GDriveBackupTiger/NotificationSupport.m \ + macos/GDriveBackupTiger/main.m macos/GDriveBackupTiger/Localization.m \ + tests/notification-support-test.m tests/notification-integration-test.m +git diff --cached --check +git commit -m "fix: update the alert when an automatic retry starts" +``` + +Expected: retry notification updates once, restart dedup works, final failure +still supersedes it, and newer automatic success still clears it. + +--- + +### Task 5: Version, document, and verify v2.4.4 Build 28 + +**Files:** +- Modify: `macos/GDriveBackupTiger/Info.plist` +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `docs/version-history.md` +- Modify: `tests/release-metadata-test.sh` + +**Interfaces:** +- Consumes: the completed protocol, UI, and notification commits. +- Produces: v2.4.4 Build 28 release metadata and user documentation that accurately describes passive automatic retry progress. + +- [ ] **Step 1: Write the failing release metadata expectation** + +Update `tests/release-metadata-test.sh` to require: + +```bash +EXPECTED_VERSION="2.4.4" +EXPECTED_BUILD="28" + +if [[ "$version" != "$EXPECTED_VERSION" || "$build" != "$EXPECTED_BUILD" ]]; then + printf 'not ok - expected app version %s build %s, got %s build %s\n' \ + "$EXPECTED_VERSION" "$EXPECTED_BUILD" "$version" "$build" + failures=$((failures + 1)) +else + printf 'ok - app version and build match the release plan\n' +fi + +check_contains "$ROOT/README.md" "GDrive-Backup-Tiger-${EXPECTED_VERSION}.pkg" \ + "README names the exact release installer" +``` + +Run: + +```bash +bash tests/release-metadata-test.sh +``` + +Expected RED: `Info.plist` and documentation still report v2.4.3 Build 27. + +- [ ] **Step 2: Record the exact release behavior** + +Set `CFBundleShortVersionString` to `2.4.4` and `CFBundleVersion` to `28`. +Add release notes stating: + +```text +- Automatic retries now replace the stale waiting alert with a truthful running state. +- The overview and menu bar show private, per-phase progress without opening a foreground window. +- Progress is explicitly current-phase progress; scheduled and retry runs remain passive in full-screen Spaces. +``` + +Update README status behavior and version history with the same contract; do +not claim a global percentage or notification-embedded progress bar. Set +`Current release: \`v2.4.4\`` and name +`GDrive-Backup-Tiger-2.4.4.pkg` exactly, move all v2.4.4 entries out of +`Unreleased`, add `## v2.4.4 - 2026-08-01` to `CHANGELOG.md`, and add +`| v2.4.4 | 28 | ... |` to `docs/version-history.md` so +`scripts/validate-release.sh v2.4.4` can pass. + +- [ ] **Step 3: Run focused and complete verification** + +```bash +bash -n bin/backup-google-drive.sh +git diff --check +make test +scripts/validate-release.sh v2.4.4 +``` + +Expected: all commands exit 0 with no warnings treated as errors. + +- [ ] **Step 4: Build an isolated Universal 2 app without touching `/Applications`** + +This implementation-time check was completed before the release-safety review. +Do not rerun the obsolete mutable-worktree staging snippet: Task 6 now performs +the authoritative build from a verified `git archive` of `v2.4.4^{commit}` and +uses strict, parsed entitlement extraction. Its retained stage and printed hash +replace any shell-local artifact from this earlier checkpoint. + +- [ ] **Step 5: Commit the release metadata** + +```bash +git add macos/GDriveBackupTiger/Info.plist README.md CHANGELOG.md \ + docs/version-history.md tests/release-metadata-test.sh +git diff --cached --check +git commit -m "chore: prepare v2.4.4 retry progress release" +``` + +Expected: one release-preparation commit and a clean tracked worktree. + +--- + +### Task 6: Review, publish, and install only after a successful terminal backup + +**Files:** +- Review: every commit in `e948ec29910210a53d587f0a8b9c309ea6238cef...HEAD` +- Publish: Git branch, pull request, CI, tag `v2.4.4`, installer, and checksum manifest +- Install after terminal state: a fresh build from the merged `v2.4.4` commit and `bin/backup-google-drive.sh` + +**Interfaces:** +- Consumes: reviewed v2.4.4 Build 28 source, successful GitHub CI/release, a successful terminal live backup state, and user-granted `/Applications` and `/usr/local/bin` installation authority. +- Produces: merged and published history, verified installed v2.4.4, loaded controller and unchanged 20:00 schedule, and unchanged profile/NAS configuration. + +- [ ] **Step 1: Invoke the required review skills** + +Use `superpowers:requesting-code-review` for the complete branch diff and +`superpowers:verification-before-completion` before any success claim. Resolve +each validated finding with a failing regression test, the minimal fix, and a +focused commit. + +- [ ] **Step 2: Re-run final branch verification** + +```bash +git status --short --branch +git log --oneline --decorate --max-count=12 +git diff --check origin/main...HEAD +bash -n bin/backup-google-drive.sh +make test +scripts/validate-release.sh v2.4.4 +``` + +Expected: all feature commits are present, tests pass, release metadata is +consistent, and the isolated publication worktree is completely clean. The +untracked `AGENTS.md` and duplicate +`tests/package-entitlement-safety-test 2.sh` belong only to the original user +checkout and are not present in this publication worktree. + +- [ ] **Step 3: Push, review, merge, tag, and verify the published release** + +The block deliberately publishes the missing v2.4.3 history first. Its +installer is a retrospective exact-tag build and is verified before v2.4.4 is +allowed to become the latest release. Run it once from the reviewed, clean +feature branch. + + +```bash +#!/bin/bash +set -euo pipefail +readonly BASE_SHA="e948ec29910210a53d587f0a8b9c309ea6238cef" +readonly V243_SHA="ddbfe24250149e4da177d23d8d1476dbbc3873eb" +BRANCH="codex/automatic-retry-progress-v2-4-4" +REVIEWED_HEAD="$(git rev-parse 'HEAD^{commit}')" +readonly REVIEWED_HEAD +REPOSITORY="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +readonly REPOSITORY + +test -z "$(git status --porcelain)" +test "$(git rev-parse 'origin/main^{commit}')" = "$BASE_SHA" +git merge-base --is-ancestor "$BASE_SHA" "$REVIEWED_HEAD" +git merge-base --is-ancestor "$V243_SHA" "$REVIEWED_HEAD" +test "$REVIEWED_HEAD" != "$BASE_SHA" +for tag in v2.4.3 v2.4.4; do + if git show-ref --verify --quiet "refs/tags/$tag" || + git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + printf 'Refusing to replace existing tag %s.\n' "$tag" >&2 + exit 1 + fi +done + +# Push the reviewed object, not whatever the branch name might resolve to +# after review. +git push origin "${REVIEWED_HEAD}:refs/heads/${BRANCH}" +# An object-to-ref push does not materialize a local remote-tracking ref. Fetch +# that exact ref explicitly, then pin both views. Do not rely on +# `--set-upstream-to`: repositories with a deliberately narrow +# remote.origin.fetch reject it even when this exact ref exists locally. +git fetch --no-tags origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" +test "$(git rev-parse "refs/remotes/origin/${BRANCH}^{commit}")" = \ + "$REVIEWED_HEAD" +test "$(git ls-remote origin "refs/heads/${BRANCH}" | /usr/bin/awk '{print $1}')" = \ + "$REVIEWED_HEAD" +PR_URL="$(gh pr create --base main --head "$BRANCH" \ + --title "Show passive progress for automatic backup retries" \ + --body $'## Summary\n- persist private per-phase backup progress\n- show passive retry progress in the overview and menu bar\n- preserve guest SMB remounts and newer failure alerts\n- keep progress publication fail closed\n\n## Validation\n- bash -n bin/backup-google-drive.sh\n- make test\n- scripts/validate-release.sh v2.4.4')" +PR_NUMBER="${PR_URL##*/}" +[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] +PR_BASE="$(gh pr view "$PR_NUMBER" --json baseRefOid --jq '.baseRefOid')" +PR_HEAD="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')" +test "$PR_BASE" = "$BASE_SHA" +test "$PR_HEAD" = "$REVIEWED_HEAD" +gh pr checks "$PR_NUMBER" --watch + +# Re-read both immutable PR ends after CI, then make GitHub reject a merge if +# the reviewed head changed between the check and this command. +PR_BASE="$(gh pr view "$PR_NUMBER" --json baseRefOid --jq '.baseRefOid')" +PR_HEAD="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')" +test "$PR_BASE" = "$BASE_SHA" +test "$PR_HEAD" = "$REVIEWED_HEAD" +gh pr merge "$PR_NUMBER" --merge --match-head-commit "$REVIEWED_HEAD" +MERGED_SHA="$(gh pr view "$PR_NUMBER" \ + --json mergeCommit --jq '.mergeCommit.oid')" +[[ "$MERGED_SHA" =~ ^[0-9a-f]{40}$ ]] +git fetch origin main +test "$(git rev-parse 'origin/main^{commit}')" = "$MERGED_SHA" +test "$(git rev-parse "$MERGED_SHA^1")" = "$BASE_SHA" +test "$(git rev-parse "$MERGED_SHA^2")" = "$REVIEWED_HEAD" +test "$(git rev-list --parents -n 1 "$MERGED_SHA" | /usr/bin/awk '{print NF}')" = "3" +git merge-base --is-ancestor "$REVIEWED_HEAD" "$MERGED_SHA" + +# Wait for the push-triggered main run by immutable commit, not merely PR checks. +MAIN_CI_RUN="" +for _ in {1..60}; do + MAIN_CI_RUN="$(gh run list --workflow ci.yml --commit "$MERGED_SHA" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty')" + [[ -n "$MAIN_CI_RUN" ]] && break + /bin/sleep 2 +done +test -n "$MAIN_CI_RUN" +gh run watch "$MAIN_CI_RUN" --exit-status + +RELEASE_EVIDENCE_ROOT="$(/usr/bin/mktemp -d \ + "${TMPDIR:-/tmp}/gdrive-release-evidence.XXXXXX")" +test -d "$RELEASE_EVIDENCE_ROOT" && test ! -L "$RELEASE_EVIDENCE_ROOT" +report_publish_evidence() { + local status=$? + trap - EXIT + printf 'LEFTOVER_RELEASE_EVIDENCE=%s\n' "$RELEASE_EVIDENCE_ROOT" >&2 + exit "$status" +} +trap report_publish_evidence EXIT + +publish_release() { + local tag="$1" + local commit="$2" + local version="${tag#v}" + local release_dir="$RELEASE_EVIDENCE_ROOT/$tag" + local source_dir="$release_dir/source" + local archive="$release_dir/source.tar" + local release_run="" + local assets expected_assets latest_tag="" remote_tag + + test ! -e "$release_dir" && test ! -L "$release_dir" + /bin/mkdir "$release_dir" + test ! -e "$source_dir" && test ! -L "$source_dir" + /bin/mkdir "$source_dir" + test ! -e "$archive" && test ! -L "$archive" + git archive --format=tar --output="$archive" "$commit" + test "$(git get-tar-commit-id <"$archive")" = "$commit" + /usr/bin/tar -xf "$archive" -C "$source_dir" + make -C "$source_dir" test + "$source_dir/scripts/validate-release.sh" "$tag" + + git tag -a "$tag" "$commit" -m "GDrive Backup Tiger $tag" + test "$(git cat-file -t "refs/tags/$tag")" = "tag" + test "$(git rev-parse "$tag^{commit}")" = "$commit" + git push origin "refs/tags/$tag:refs/tags/$tag" + + for _ in {1..60}; do + release_run="$(gh run list --workflow release.yml --branch "$tag" \ + --commit "$commit" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty')" + [[ -n "$release_run" ]] && break + /bin/sleep 2 + done + test -n "$release_run" + gh run watch "$release_run" --exit-status + + remote_tag="$(git ls-remote origin "refs/tags/$tag^{}" | + /usr/bin/awk '{print $1}')" + test "$remote_tag" = "$commit" + gh release download "$tag" --dir "$release_dir" \ + --pattern "GDrive-Backup-Tiger-${version}.pkg" \ + --pattern 'SHA256SUMS.txt' + ( + cd "$release_dir" + /usr/bin/shasum -a 256 -c SHA256SUMS.txt + ) + "$source_dir/packaging/verify-pkg.sh" --expect-unsigned \ + "$release_dir/GDrive-Backup-Tiger-${version}.pkg" + assets="$(gh release view "$tag" --json assets --jq '.assets[].name' | + LC_ALL=C /usr/bin/sort)" + expected_assets="$(printf '%s\n%s\n' \ + "GDrive-Backup-Tiger-${version}.pkg" 'SHA256SUMS.txt' | + LC_ALL=C /usr/bin/sort)" + test "$assets" = "$expected_assets" + test "$(gh release view "$tag" --json tagName --jq '.tagName')" = "$tag" + for _ in {1..60}; do + latest_tag="$(gh api "repos/$REPOSITORY/releases/latest" --jq '.tag_name')" + [[ "$latest_tag" = "$tag" ]] && break + /bin/sleep 2 + done + test "$latest_tag" = "$tag" +} + +publish_release v2.4.3 "$V243_SHA" +publish_release v2.4.4 "$MERGED_SHA" +printf 'Published merge=%s reviewed_head=%s\n' "$MERGED_SHA" "$REVIEWED_HEAD" +``` + + +Expected: PR checks and merged-main CI pass; the merge has exactly the pinned +base and reviewed head as parents; v2.4.3 is an annotated tag of `ddbfe24…` +with verified assets and is temporarily `latest`; only then v2.4.4 is tagged +at the merge commit, published, checksum-verified, package-verified, and becomes +`latest`. The printed evidence directory is intentionally retained. + +- [ ] **Step 4: Enforce the successful terminal-backup installation gate** + +Run this one block only after both releases above are verified. It performs the +pre-build gate, immutable export and build, locked transaction, rollback, and +all final checks. It intentionally retains and prints every stage, rollback, +previous-version, or quarantine path instead of depending on a Trash binary. + + +```bash +#!/bin/bash +set -euo pipefail + +readonly RELEASE_TAG="v2.4.4" +readonly EXPECTED_VERSION="2.4.4" +readonly EXPECTED_BUILD="28" +readonly SAFE_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +DOMAIN="gui/$(/usr/bin/id -u)" +readonly DOMAIN +readonly CONTROLLER_SERVICE="$DOMAIN/com.commcats.gdrivebackup" +readonly SCHEDULE_SERVICE="$DOMAIN/com.commcats.gdrivebackup.schedule" + +REPO_ROOT="$(git rev-parse --show-toplevel)" +RELEASE_COMMIT="$(git rev-parse "$RELEASE_TAG^{commit}")" +REPOSITORY="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +CURRENT_UID="$(/usr/bin/id -u)" +CURRENT_GID="$(/usr/bin/id -g)" +APP_FINAL="/Applications/GDrive Backup Tiger.app" +SCRIPT_FINAL="/usr/local/bin/backup-google-drive.sh" +CONTROLLER_PLIST="$HOME/Library/LaunchAgents/com.commcats.gdrivebackup.plist" +SCHEDULE_PLIST="$HOME/Library/LaunchAgents/com.commcats.gdrivebackup.schedule.plist" +CONFIG_DIR="$HOME/.config/gdrive-tiger-backup" +ACTIVE_PROFILE_FILE="$CONFIG_DIR/active-profile" +PROFILES_DIR="$CONFIG_DIR/profiles" +PROFILE_CONFIG="$PROFILES_DIR/default.conf" +STATE_ROOT="$HOME/Library/Application Support/GDrive Backup Tiger" +ROLLBACK_PARENT="$STATE_ROOT/rollbacks" +STAGE_PARENT="$STATE_ROOT/install-staging" + +STAGE="" +ROLLBACK="" +APP_TXN="" +SCRIPT_TXN="" +APP_INCOMING="" +SCRIPT_INCOMING="" +APP_PREVIOUS="" +SCRIPT_PREVIOUS="" +APP_QUARANTINE="" +SCRIPT_QUARANTINE="" +OLD_APP_HASH="" +OLD_SCRIPT_HASH="" +APP_HASH="" +SCRIPT_HASH="" +EFFECTIVE_STATUS="" +RECOVERY_ARMED=0 + +assert_absolute_single_line_path() { + local value="$1" + [[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] +} + +assert_user_regular_file() { + local path="$1" + local expected_mode="$2" + test -f "$path" && test ! -L "$path" + test "$(/usr/bin/stat -f '%u' "$path")" = "$CURRENT_UID" + test "$(/usr/bin/stat -f '%Lp' "$path")" = "$expected_mode" +} + +assert_default_profile_selection() { + local profile_id profile_config profile_id_matches=0 profile_line + assert_user_regular_file "$ACTIVE_PROFILE_FILE" "600" + profile_id="$(<"$ACTIVE_PROFILE_FILE")" + [[ "$profile_id" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] + test "$profile_id" = "default" + test -d "$PROFILES_DIR" && test ! -L "$PROFILES_DIR" + profile_config="$PROFILES_DIR/$profile_id.conf" + test "$profile_config" = "$PROFILE_CONFIG" + assert_user_regular_file "$profile_config" "600" + while IFS= read -r profile_line || [[ -n "$profile_line" ]]; do + case "$profile_line" in + "GDRIVE_BACKUP_PROFILE_ID=$profile_id"|"GDRIVE_BACKUP_PROFILE_ID='$profile_id'"|"GDRIVE_BACKUP_PROFILE_ID=\"$profile_id\"") + profile_id_matches=1 + break + ;; + esac + done <"$profile_config" + test "$profile_id_matches" = "1" +} + +assert_safe_user_plist() { + local path="$1" + local mode mode_value + test -f "$path" && test ! -L "$path" + test "$(/usr/bin/stat -f '%u' "$path")" = "$CURRENT_UID" + mode="$(/usr/bin/stat -f '%Lp' "$path")" + [[ "$mode" =~ ^[0-7]{3,4}$ ]] + mode_value=$((8#$mode)) + (( (mode_value & 8#022) == 0 )) + /usr/bin/plutil -lint "$path" >/dev/null +} + +reject_service_path_overrides() { + local plist key + for plist in "$CONTROLLER_PLIST" "$SCHEDULE_PLIST"; do + for key in \ + GDRIVE_BACKUP_CONFIG \ + GDRIVE_BACKUP_CONFIG_DIR \ + GDRIVE_BACKUP_LOCK \ + GDRIVE_BACKUP_SUMMARY_STATE_FILE \ + GDRIVE_BACKUP_PROGRESS_STATE_FILE; do + if /usr/bin/plutil -extract "EnvironmentVariables.$key" raw -o - \ + "$plist" >/dev/null 2>&1; then + printf 'Refusing service override %s in %s.\n' "$key" "$plist" >&2 + return 1 + fi + done + done +} + +assert_manager_environment_clean() { + # The point-query command reports success even for an unset variable on + # supported macOS versions. Parse the domain stream and never retain values. + if ! /bin/launchctl print "$DOMAIN" | /usr/bin/awk ' + /^[[:space:]]*(GDRIVE_BACKUP_CONFIG|GDRIVE_BACKUP_CONFIG_DIR|GDRIVE_BACKUP_LOCK|GDRIVE_BACKUP_SUMMARY_STATE_FILE|GDRIVE_BACKUP_PROGRESS_STATE_FILE)[[:space:]]*=>/ { + forbidden = 1 + } + END { exit(forbidden ? 1 : 0) } + '; then + printf '%s\n' 'Refusing a launchd manager path override.' >&2 + return 1 + fi +} + +assert_loaded_service_contract() { + local service="$1" + local expected_plist="$2" + local expected_program="$3" + local expected_arg_count="$4" + local expected_arg0="$5" + local expected_arg1="$6" + local expected_arg2="$7" + local expected_home="$8" + local expected_path="$9" + local expected_trigger="${10}" + local expected_assume="${11}" + + # Only the parser sees launchctl's complete output. It compares approved + # fields in-stream and emits neither environment values nor a persisted copy. + /bin/launchctl print "$service" | /usr/bin/awk \ + -v expected_plist="$expected_plist" \ + -v expected_program="$expected_program" \ + -v expected_arg_count="$expected_arg_count" \ + -v expected_arg0="$expected_arg0" \ + -v expected_arg1="$expected_arg1" \ + -v expected_arg2="$expected_arg2" \ + -v expected_home="$expected_home" \ + -v expected_path="$expected_path" \ + -v expected_trigger="$expected_trigger" \ + -v expected_assume="$expected_assume" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + { + normalized = trim($0) + normalized_separator = index(normalized, " => ") + if (normalized_separator) { + normalized_key = substr(normalized, 1, normalized_separator - 1) + if (normalized_key == "GDRIVE_BACKUP_CONFIG" || + normalized_key == "GDRIVE_BACKUP_CONFIG_DIR" || + normalized_key == "GDRIVE_BACKUP_LOCK" || + normalized_key == "GDRIVE_BACKUP_SUMMARY_STATE_FILE" || + normalized_key == "GDRIVE_BACKUP_PROGRESS_STATE_FILE") forbidden++ + } + } + /^[[:space:]]*path = / { + path_count++ + value = $0 + sub(/^[[:space:]]*path = /, "", value) + if (value == expected_plist) path_ok++ + else bad = 1 + next + } + /^[[:space:]]*program = / { + program_count++ + value = $0 + sub(/^[[:space:]]*program = /, "", value) + if (value == expected_program) program_ok++ + else bad = 1 + next + } + /^[[:space:]]*arguments = \{/ { + argument_blocks++ + in_arguments = 1 + next + } + in_arguments && /^[[:space:]]*\}/ { in_arguments = 0; next } + in_arguments { arguments[argument_count++] = trim($0); next } + /^[[:space:]]*environment = \{/ { + environment_blocks++ + in_environment = 1 + next + } + in_environment && /^[[:space:]]*\}/ { in_environment = 0; next } + in_environment { + line = trim($0) + separator = index(line, " => ") + if (!separator) next + key = substr(line, 1, separator - 1) + value = substr(line, separator + 4) + if (key == "HOME") { + home_count++ + if (value == expected_home) home_ok++ + } + if (key == "PATH") { + path_environment_count++ + if (value == expected_path) path_environment_ok++ + } + if (key == "GDRIVE_BACKUP_TRIGGER") { + trigger_count++ + if (value == expected_trigger) trigger_ok++ + } + if (key == "BACKUP_ASSUME_YES") { + assume_count++ + if (value == expected_assume) assume_ok++ + } + } + END { + ok = !bad && !in_arguments && !in_environment && + path_count == 1 && path_ok == 1 && + program_count == 1 && program_ok == 1 && + argument_blocks == 1 && environment_blocks == 1 && forbidden == 0 && + argument_count == expected_arg_count && + arguments[0] == expected_arg0 && arguments[1] == expected_arg1 && + home_count == 1 && home_ok == 1 && + path_environment_count == 1 && path_environment_ok == 1 + if (expected_arg_count == 3) ok = ok && arguments[2] == expected_arg2 + if (expected_trigger == "") ok = ok && trigger_count == 0 + else ok = ok && trigger_count == 1 && trigger_ok == 1 + if (expected_assume == "") ok = ok && assume_count == 0 + else ok = ok && assume_count == 1 && assume_ok == 1 + exit(ok ? 0 : 1) + } + ' +} + +derive_effective_state() { + local output="$1" + test ! -e "$output" && test ! -L "$output" + # The quoted program is evaluated by the sanitized child Bash, not this + # shell. Scheduler variables match the already verified launchd contract. + # shellcheck disable=SC2016 + /usr/bin/env -i \ + HOME="$HOME" \ + PATH="$SAFE_PATH" \ + GDRIVE_BACKUP_TRIGGER="$SCHEDULE_TRIGGER" \ + BACKUP_ASSUME_YES="$SCHEDULE_ASSUME_YES" \ + /bin/bash -c ' + set -euo pipefail + # shellcheck source=/dev/null + source "$1" >/dev/null + test "$HOME" = "$2" + test "${GDRIVE_BACKUP_PROFILE_ID:-}" = "default" + test "${GDRIVE_BACKUP_TRIGGER:-}" = "$3" + test "${BACKUP_ASSUME_YES:-}" = "$4" + effective_lock="${GDRIVE_BACKUP_LOCK:-$HOME/Library/Logs/gdrive-backup.lock}" + if [[ -n "${GDRIVE_BACKUP_SUMMARY_STATE_FILE:-}" ]]; then + effective_status="$GDRIVE_BACKUP_SUMMARY_STATE_FILE" + else + effective_status="$HOME/Library/Application Support/GDrive Backup Tiger/profiles/default/last-run.status" + fi + effective_progress="${GDRIVE_BACKUP_PROGRESS_STATE_FILE:-${effective_status%/*}/current-progress.status}" + printf "EFFECTIVE_CONFIG=%q\n" "$1" + printf "EFFECTIVE_LOCK=%q\n" "$effective_lock" + printf "EFFECTIVE_STATUS=%q\n" "$effective_status" + printf "EFFECTIVE_PROGRESS=%q\n" "$effective_progress" + printf "PROFILE_TARGET=%q\n" "${GDRIVE_BACKUP_TARGET:-}" + printf "PROFILE_SCHEDULE=%q\n" "${GDRIVE_BACKUP_SCHEDULE:-}" + printf "PROFILE_NOTIFY_FAILURES=%q\n" "${GDRIVE_BACKUP_NOTIFY_FAILURES:-}" + printf "PROFILE_NAS_MOUNT=%q\n" "${GDRIVE_BACKUP_NAS_MOUNT:-}" + printf "PROFILE_NAS_URL=%q\n" "${GDRIVE_BACKUP_NAS_URL:-}" + ' _ "$PROFILE_CONFIG" "$HOME" "$SCHEDULE_TRIGGER" \ + "$SCHEDULE_ASSUME_YES" >"$output" + /bin/chmod 600 "$output" +} + +status_value_from_file() { + local key="$1" + test "$(/usr/bin/awk -F= -v key="$key" \ + '$1 == key {count++} END {print count + 0}' "$EFFECTIVE_STATUS")" = "1" + /usr/bin/awk -F= -v key="$key" \ + '$1 == key {print substr($0, index($0, "=") + 1)}' "$EFFECTIVE_STATUS" +} + +backup_process_snapshot() { + local excluded="," pid + pid="$(/bin/sh -c 'printf "%s" "$PPID"')" + while [[ "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 ]]; do + excluded="${excluded}${pid}," + pid="$(/bin/ps -p "$pid" -o ppid= | /usr/bin/tr -d ' ')" + done + /bin/ps -axo pid=,ppid=,command= | /usr/bin/awk -v excluded="$excluded" ' + index(excluded, "," $1 ",") == 0 && + /backup-google-drive|(^|[[:space:]/])rclone([[:space:]]|$)/ && + $0 !~ /awk/ {print}' +} + +assert_successful_terminal_backup_and_no_processes() { + local status started_at finished_at last_success_at exit_code active + assert_user_regular_file "$EFFECTIVE_STATUS" "600" + test "$(status_value_from_file protocol)" = "1" + status="$(status_value_from_file status)" + started_at="$(status_value_from_file started_at)" + finished_at="$(status_value_from_file finished_at)" + last_success_at="$(status_value_from_file last_success_at)" + exit_code="$(status_value_from_file exit_code)" + test "$status" = "success" && test "$exit_code" = "0" + [[ "$started_at" =~ ^[0-9]+$ && "$finished_at" =~ ^[0-9]+$ && + "$last_success_at" =~ ^[0-9]+$ ]] + (( finished_at >= started_at && last_success_at >= started_at )) + active="$(backup_process_snapshot)" + test -z "$active" +} + +canonical_config_manifest() { + local root="$1" + local output="$2" + local paths="${output}.paths" + local path relative type uid gid mode size digest + test -d "$root" && test ! -L "$root" + test ! -e "$output" && test ! -L "$output" + test ! -e "$paths" && test ! -L "$paths" + /usr/bin/find "$root" -print0 >"$paths" + : >"$output" + /bin/chmod 600 "$output" "$paths" + while IFS= read -r -d '' path; do + if [[ "$path" = "$root" ]]; then + relative="." + else + relative="${path#"$root"/}" + fi + if [[ "$relative" == *'|'* || "$relative" == *$'\n'* || + "$relative" == *$'\r'* ]]; then + printf 'Refusing unsafe manifest path: %s\n' "$relative" >&2 + return 1 + fi + if [[ -L "$path" ]]; then + printf 'Refusing unexpected symbolic link in configuration: %s\n' "$relative" >&2 + return 1 + elif [[ -f "$path" ]]; then + type="file" + digest="$(/usr/bin/shasum -a 256 "$path" | /usr/bin/awk '{print $1}')" + elif [[ -d "$path" ]]; then + type="directory" + digest="-" + else + printf 'Refusing special configuration entry: %s\n' "$relative" >&2 + return 1 + fi + uid="$(/usr/bin/stat -f '%u' "$path")" + gid="$(/usr/bin/stat -f '%g' "$path")" + mode="$(/usr/bin/stat -f '%Lp' "$path")" + size="$(/usr/bin/stat -f '%z' "$path")" + printf 'type=%s|path=%s|uid=%s|gid=%s|mode=%s|size=%s|sha256=%s\n' \ + "$type" "$relative" "$uid" "$gid" "$mode" "$size" "$digest" + done < <(LC_ALL=C /usr/bin/sort -z "$paths") >"${output}.unsorted" + LC_ALL=C /usr/bin/sort "${output}.unsorted" >"$output" + /bin/chmod 600 "$output" +} + +assert_runtime_snapshot_matches_prebuild() { + local manifest="$1" + local effective_state="$2" + canonical_config_manifest "$CONFIG_DIR" "$manifest" + /usr/bin/cmp -s "$PREBUILD_CONFIG_MANIFEST" "$manifest" + assert_default_profile_selection + assert_safe_user_plist "$CONTROLLER_PLIST" + assert_safe_user_plist "$SCHEDULE_PLIST" + test "$(/usr/bin/shasum -a 256 "$CONTROLLER_PLIST" | + /usr/bin/awk '{print $1}')" = "$CONTROLLER_PLIST_HASH" + test "$(/usr/bin/shasum -a 256 "$SCHEDULE_PLIST" | + /usr/bin/awk '{print $1}')" = "$SCHEDULE_PLIST_HASH" + reject_service_path_overrides + assert_loaded_service_contract \ + "$CONTROLLER_SERVICE" "$CONTROLLER_PLIST" "$CONTROLLER_PROGRAM" 2 \ + "$CONTROLLER_PROGRAM" "$CONTROLLER_ARGUMENT" "" \ + "$CONTROLLER_HOME" "$CONTROLLER_PATH" "" "" + assert_loaded_service_contract \ + "$SCHEDULE_SERVICE" "$SCHEDULE_PLIST" "$SCHEDULE_PROGRAM" 3 \ + "$SCHEDULE_PROGRAM" "$SCHEDULE_SCRIPT" "$SCHEDULE_ARGUMENT" \ + "$SCHEDULE_HOME" "$SCHEDULE_PATH" "$SCHEDULE_TRIGGER" \ + "$SCHEDULE_ASSUME_YES" + assert_manager_environment_clean + derive_effective_state "$effective_state" + /usr/bin/cmp -s "$PREBUILD_EFFECTIVE_STATE" "$effective_state" + # shellcheck source=/dev/null + source "$effective_state" + test "$EFFECTIVE_CONFIG" = "$PREBUILD_EFFECTIVE_CONFIG" + test "$EFFECTIVE_LOCK" = "$PREBUILD_EFFECTIVE_LOCK" + test "$EFFECTIVE_STATUS" = "$PREBUILD_EFFECTIVE_STATUS" + test "$EFFECTIVE_PROGRESS" = "$PREBUILD_EFFECTIVE_PROGRESS" + test "$PROFILE_TARGET" = "$PREBUILD_PROFILE_TARGET" + test "$PROFILE_SCHEDULE" = "$PREBUILD_PROFILE_SCHEDULE" + test "$PROFILE_NOTIFY_FAILURES" = "$PREBUILD_PROFILE_NOTIFY_FAILURES" + test "$PROFILE_NAS_MOUNT" = "$PREBUILD_PROFILE_NAS_MOUNT" + test "$PROFILE_NAS_URL" = "$PREBUILD_PROFILE_NAS_URL" +} + +extract_entitlements() { + local app="$1" + local output="$2" + local stderr_file="${output}.stderr" + test ! -e "$output" && test ! -L "$output" + test ! -e "$stderr_file" && test ! -L "$stderr_file" + /usr/bin/codesign -d --entitlements :- "$app" >"$output" 2>"$stderr_file" + if [[ ! -s "$output" ]]; then + printf '%s\n' \ + '' \ + '' \ + '' >"$output" + fi + /usr/bin/plutil -lint "$output" >/dev/null + if /usr/bin/plutil -extract \ + com.apple.developer.usernotifications.time-sensitive raw -o - \ + "$output" >/dev/null 2>&1; then + printf '%s\n' 'Refusing restricted time-sensitive entitlement.' >&2 + return 1 + fi +} + +reload_services() { + /bin/launchctl bootstrap "$DOMAIN" "$CONTROLLER_PLIST" + /bin/launchctl enable "$CONTROLLER_SERVICE" + /bin/launchctl bootstrap "$DOMAIN" "$SCHEDULE_PLIST" + /bin/launchctl enable "$SCHEDULE_SERVICE" + /bin/launchctl print "$CONTROLLER_SERVICE" >/dev/null + /bin/launchctl print "$SCHEDULE_SERVICE" >/dev/null +} + +recover_previous_install() { + local recovery_status=0 current_hash="" + + # These best-effort bootouts are recovery-only: they prevent either service + # from observing a partially restored pair. + /bin/launchctl bootout "$DOMAIN" "$SCHEDULE_PLIST" >/dev/null 2>&1 || true + /bin/launchctl bootout "$DOMAIN" "$CONTROLLER_PLIST" >/dev/null 2>&1 || true + + if [[ -d "$APP_FINAL" && ! -L "$APP_FINAL" ]]; then + current_hash="$(/usr/bin/shasum -a 256 \ + "$APP_FINAL/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" + if [[ "$current_hash" = "$APP_HASH" ]]; then + test ! -e "$APP_QUARANTINE" && test ! -L "$APP_QUARANTINE" || recovery_status=1 + if (( recovery_status == 0 )); then + /usr/bin/sudo /bin/mv "$APP_FINAL" "$APP_QUARANTINE" || recovery_status=1 + fi + elif [[ "$current_hash" != "$OLD_APP_HASH" ]]; then + recovery_status=1 + fi + elif [[ -e "$APP_FINAL" || -L "$APP_FINAL" ]]; then + recovery_status=1 + fi + if [[ ! -d "$APP_FINAL" && -d "$APP_PREVIOUS" && ! -L "$APP_PREVIOUS" ]]; then + test "$(/usr/bin/shasum -a 256 \ + "$APP_PREVIOUS/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" = \ + "$OLD_APP_HASH" || recovery_status=1 + if (( recovery_status == 0 )); then + /usr/bin/sudo /bin/mv "$APP_PREVIOUS" "$APP_FINAL" || recovery_status=1 + fi + elif [[ ! -d "$APP_FINAL" && $recovery_status -eq 0 ]]; then + /usr/bin/sudo /usr/bin/ditto "$ROLLBACK/GDrive Backup Tiger.app" \ + "$APP_FINAL" || recovery_status=1 + fi + + current_hash="" + if [[ -f "$SCRIPT_FINAL" && ! -L "$SCRIPT_FINAL" ]]; then + current_hash="$(/usr/bin/shasum -a 256 "$SCRIPT_FINAL" | /usr/bin/awk '{print $1}')" + if [[ "$current_hash" = "$SCRIPT_HASH" ]]; then + test ! -e "$SCRIPT_QUARANTINE" && test ! -L "$SCRIPT_QUARANTINE" || recovery_status=1 + if (( recovery_status == 0 )); then + /usr/bin/sudo /bin/mv "$SCRIPT_FINAL" "$SCRIPT_QUARANTINE" || recovery_status=1 + fi + elif [[ "$current_hash" != "$OLD_SCRIPT_HASH" ]]; then + recovery_status=1 + fi + elif [[ -e "$SCRIPT_FINAL" || -L "$SCRIPT_FINAL" ]]; then + recovery_status=1 + fi + if [[ ! -f "$SCRIPT_FINAL" && -f "$SCRIPT_PREVIOUS" && ! -L "$SCRIPT_PREVIOUS" ]]; then + test "$(/usr/bin/shasum -a 256 "$SCRIPT_PREVIOUS" | /usr/bin/awk '{print $1}')" = \ + "$OLD_SCRIPT_HASH" || recovery_status=1 + if (( recovery_status == 0 )); then + /usr/bin/sudo /bin/mv "$SCRIPT_PREVIOUS" "$SCRIPT_FINAL" || recovery_status=1 + fi + elif [[ ! -f "$SCRIPT_FINAL" && $recovery_status -eq 0 ]]; then + /usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 755 \ + "$ROLLBACK/backup-google-drive.sh" \ + "$SCRIPT_FINAL" || recovery_status=1 + fi + + if (( recovery_status == 0 )); then + test "$(/usr/bin/shasum -a 256 \ + "$APP_FINAL/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" = \ + "$OLD_APP_HASH" || recovery_status=1 + test "$(/usr/bin/shasum -a 256 "$SCRIPT_FINAL" | /usr/bin/awk '{print $1}')" = \ + "$OLD_SCRIPT_HASH" || recovery_status=1 + test "$(/usr/bin/stat -f '%u:%g:%Lp' "$SCRIPT_FINAL")" = \ + "0:0:755" || recovery_status=1 + fi + if (( recovery_status == 0 )); then + reload_services || recovery_status=1 + fi + if (( recovery_status != 0 )); then + /bin/launchctl bootout "$DOMAIN" "$SCHEDULE_PLIST" >/dev/null 2>&1 || true + /bin/launchctl bootout "$DOMAIN" "$CONTROLLER_PLIST" >/dev/null 2>&1 || true + fi + return "$recovery_status" +} + +report_install_leftovers() { + [[ -z "$STAGE" ]] || printf 'LEFTOVER_STAGE=%s\n' "$STAGE" >&2 + [[ -z "$ROLLBACK" ]] || printf 'LEFTOVER_ROLLBACK=%s\n' "$ROLLBACK" >&2 + [[ -z "$APP_TXN" ]] || printf 'LEFTOVER_APP_TRANSACTION=%s\n' "$APP_TXN" >&2 + [[ -z "$SCRIPT_TXN" ]] || printf 'LEFTOVER_SCRIPT_TRANSACTION=%s\n' "$SCRIPT_TXN" >&2 +} + +on_install_exit() { + local status=$? + trap - EXIT + # RECOVERY_SIGNAL_GUARD + trap '' HUP INT TERM + if (( status != 0 && RECOVERY_ARMED == 1 )); then + if ! recover_previous_install; then + printf '%s\n' \ + 'CRITICAL: hash-aware rollback failed; both services remain unloaded.' >&2 + status=1 + fi + fi + report_install_leftovers + exit "$status" +} + +handle_install_signal() { + printf '%s\n' 'Installation interrupted; entering the guarded exit path.' >&2 + exit 130 +} + +assert_final_install_state() { + local architectures new_controller_pid final_manifest final_entitlements + local final_effective_state + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$APP_FINAL/Contents/Info.plist")" = "$EXPECTED_VERSION" + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$APP_FINAL/Contents/Info.plist")" = "$EXPECTED_BUILD" + architectures="$(/usr/bin/lipo -archs \ + "$APP_FINAL/Contents/MacOS/GDriveBackupTiger")" + [[ " $architectures " == *" arm64 "* && " $architectures " == *" x86_64 "* ]] + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_FINAL" + test "$(/usr/bin/shasum -a 256 \ + "$APP_FINAL/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" = \ + "$APP_HASH" + test "$(/usr/bin/shasum -a 256 "$SCRIPT_FINAL" | /usr/bin/awk '{print $1}')" = \ + "$SCRIPT_HASH" + test "$(/usr/bin/stat -f '%u:%g:%Lp' "$SCRIPT_FINAL")" = "0:0:755" + /bin/bash -n "$SCRIPT_FINAL" + + final_entitlements="$STAGE/final-entitlements.plist" + extract_entitlements "$APP_FINAL" "$final_entitlements" + + # FINAL_RUNTIME_REVALIDATION + final_manifest="$STAGE/config-after.manifest" + canonical_config_manifest "$CONFIG_DIR" "$final_manifest" + /usr/bin/cmp -s "$PREBUILD_CONFIG_MANIFEST" "$final_manifest" + /usr/bin/cmp -s "$ROLLBACK/config-before.manifest" "$final_manifest" + assert_default_profile_selection + assert_safe_user_plist "$CONTROLLER_PLIST" + assert_safe_user_plist "$SCHEDULE_PLIST" + test "$(/usr/bin/shasum -a 256 "$CONTROLLER_PLIST" | /usr/bin/awk '{print $1}')" = \ + "$CONTROLLER_PLIST_HASH" + test "$(/usr/bin/shasum -a 256 "$SCHEDULE_PLIST" | /usr/bin/awk '{print $1}')" = \ + "$SCHEDULE_PLIST_HASH" + reject_service_path_overrides + assert_loaded_service_contract \ + "$CONTROLLER_SERVICE" "$CONTROLLER_PLIST" "$CONTROLLER_PROGRAM" 2 \ + "$CONTROLLER_PROGRAM" "$CONTROLLER_ARGUMENT" "" \ + "$CONTROLLER_HOME" "$CONTROLLER_PATH" "" "" + assert_loaded_service_contract \ + "$SCHEDULE_SERVICE" "$SCHEDULE_PLIST" "$SCHEDULE_PROGRAM" 3 \ + "$SCHEDULE_PROGRAM" "$SCHEDULE_SCRIPT" "$SCHEDULE_ARGUMENT" \ + "$SCHEDULE_HOME" "$SCHEDULE_PATH" "$SCHEDULE_TRIGGER" \ + "$SCHEDULE_ASSUME_YES" + assert_manager_environment_clean + final_effective_state="$STAGE/final-effective-state.sh" + derive_effective_state "$final_effective_state" + /usr/bin/cmp -s "$PREBUILD_EFFECTIVE_STATE" "$final_effective_state" + # This refresh makes every remaining assertion use post-reload state rather + # than values retained from before the build. + # shellcheck source=/dev/null + source "$final_effective_state" + test "$EFFECTIVE_CONFIG" = "$PREBUILD_EFFECTIVE_CONFIG" + test "$EFFECTIVE_LOCK" = "$PREBUILD_EFFECTIVE_LOCK" + test "$EFFECTIVE_STATUS" = "$PREBUILD_EFFECTIVE_STATUS" + test "$EFFECTIVE_PROGRESS" = "$PREBUILD_EFFECTIVE_PROGRESS" + test "$PROFILE_TARGET" = "$PREBUILD_PROFILE_TARGET" + test "$PROFILE_SCHEDULE" = "$PREBUILD_PROFILE_SCHEDULE" + test "$PROFILE_NOTIFY_FAILURES" = "$PREBUILD_PROFILE_NOTIFY_FAILURES" + test "$PROFILE_NAS_MOUNT" = "$PREBUILD_PROFILE_NAS_MOUNT" + test "$PROFILE_NAS_URL" = "$PREBUILD_PROFILE_NAS_URL" + assert_user_regular_file "$ACTIVE_PROFILE_FILE" "600" + test "$(/usr/bin/stat -f '%z' "$ACTIVE_PROFILE_FILE")" = "$ACTIVE_PROFILE_SIZE" + test "$(/usr/bin/od -An -tx1 -v "$ACTIVE_PROFILE_FILE" | + /usr/bin/tr -d ' \n')" = "64656661756c740a" + test "$PROFILE_TARGET" = "nas" + test "$PROFILE_SCHEDULE" = "daily" + test "$PROFILE_NOTIFY_FAILURES" = "1" + test -n "$PROFILE_NAS_MOUNT$PROFILE_NAS_URL" + test "$(/usr/bin/plutil -extract StartCalendarInterval.Hour raw -o - \ + "$SCHEDULE_PLIST")" = "20" + test "$(/usr/bin/plutil -extract StartCalendarInterval.Minute raw -o - \ + "$SCHEDULE_PLIST")" = "0" + new_controller_pid="$(/bin/launchctl print "$CONTROLLER_SERVICE" | + /usr/bin/awk '/pid =/ {print $3; exit}')" + [[ "$new_controller_pid" =~ ^[0-9]+$ ]] + test "$new_controller_pid" != "$OLD_CONTROLLER_PID" + # FINAL_STATUS_PROCESS_GATE + assert_successful_terminal_backup_and_no_processes +} + +test "$REPO_ROOT" = "$(pwd -P)" +test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = "tag" +test "$(git ls-remote origin "refs/tags/$RELEASE_TAG^{}" | + /usr/bin/awk '{print $1}')" = "$RELEASE_COMMIT" +test "$(gh release view "$RELEASE_TAG" --json tagName --jq '.tagName')" = \ + "$RELEASE_TAG" +test "$(gh api "repos/$REPOSITORY/releases/latest" --jq '.tag_name')" = \ + "$RELEASE_TAG" +test "$(gh run list --workflow release.yml --branch "$RELEASE_TAG" \ + --commit "$RELEASE_COMMIT" --status completed --limit 1 \ + --json conclusion --jq '.[0].conclusion // empty')" = "success" +assert_absolute_single_line_path "$HOME" +test -d "$STATE_ROOT" && test ! -L "$STATE_ROOT" +test "$(/usr/bin/stat -f '%u' "$STATE_ROOT")" = "$CURRENT_UID" +for parent in "$ROLLBACK_PARENT" "$STAGE_PARENT"; do + if [[ ! -e "$parent" && ! -L "$parent" ]]; then + /bin/mkdir "$parent" + fi + test -d "$parent" && test ! -L "$parent" + test "$(/usr/bin/stat -f '%u' "$parent")" = "$CURRENT_UID" +done + +STAGE="$(/usr/bin/mktemp -d "$STAGE_PARENT/v2.4.4.XXXXXX")" +test -d "$STAGE" && test ! -L "$STAGE" +test "$(/usr/bin/stat -f '%u' "$STAGE")" = "$CURRENT_UID" +trap on_install_exit EXIT +trap 'handle_install_signal' HUP INT TERM + +test -d "$CONFIG_DIR" && test ! -L "$CONFIG_DIR" +test "$(/usr/bin/stat -f '%u' "$CONFIG_DIR")" = "$CURRENT_UID" +assert_user_regular_file "$ACTIVE_PROFILE_FILE" "600" +ACTIVE_PROFILE_SIZE="$(/usr/bin/stat -f '%z' "$ACTIVE_PROFILE_FILE")" +test "$ACTIVE_PROFILE_SIZE" = "8" +ACTIVE_PROFILE_HEX="$(/usr/bin/od -An -tx1 -v "$ACTIVE_PROFILE_FILE" | + /usr/bin/tr -d ' \n')" +test "$ACTIVE_PROFILE_HEX" = "64656661756c740a" +test -d "$PROFILES_DIR" && test ! -L "$PROFILES_DIR" +test "$(/usr/bin/stat -f '%u' "$PROFILES_DIR")" = "$CURRENT_UID" +assert_user_regular_file "$PROFILE_CONFIG" "600" +assert_default_profile_selection + +assert_safe_user_plist "$CONTROLLER_PLIST" +assert_safe_user_plist "$SCHEDULE_PLIST" +CONTROLLER_LABEL="$(/usr/bin/plutil -extract Label raw -o - "$CONTROLLER_PLIST")" +CONTROLLER_PROGRAM="$(/usr/bin/plutil -extract ProgramArguments.0 raw -o - \ + "$CONTROLLER_PLIST")" +CONTROLLER_ARGUMENT="$(/usr/bin/plutil -extract ProgramArguments.1 raw -o - \ + "$CONTROLLER_PLIST")" +CONTROLLER_HOME="$(/usr/bin/plutil -extract EnvironmentVariables.HOME raw -o - \ + "$CONTROLLER_PLIST")" +CONTROLLER_PATH="$(/usr/bin/plutil -extract EnvironmentVariables.PATH raw -o - \ + "$CONTROLLER_PLIST")" +SCHEDULE_LABEL="$(/usr/bin/plutil -extract Label raw -o - "$SCHEDULE_PLIST")" +SCHEDULE_PROGRAM="$(/usr/bin/plutil -extract ProgramArguments.0 raw -o - \ + "$SCHEDULE_PLIST")" +SCHEDULE_SCRIPT="$(/usr/bin/plutil -extract ProgramArguments.1 raw -o - \ + "$SCHEDULE_PLIST")" +SCHEDULE_ARGUMENT="$(/usr/bin/plutil -extract ProgramArguments.2 raw -o - \ + "$SCHEDULE_PLIST")" +SCHEDULE_HOME="$(/usr/bin/plutil -extract EnvironmentVariables.HOME raw -o - \ + "$SCHEDULE_PLIST")" +SCHEDULE_PATH="$(/usr/bin/plutil -extract EnvironmentVariables.PATH raw -o - \ + "$SCHEDULE_PLIST")" +SCHEDULE_TRIGGER="$(/usr/bin/plutil -extract \ + EnvironmentVariables.GDRIVE_BACKUP_TRIGGER raw -o - "$SCHEDULE_PLIST")" +SCHEDULE_ASSUME_YES="$(/usr/bin/plutil -extract \ + EnvironmentVariables.BACKUP_ASSUME_YES raw -o - "$SCHEDULE_PLIST")" +test "$CONTROLLER_LABEL" = \ + "com.commcats.gdrivebackup" +test "$CONTROLLER_PROGRAM" = "$APP_FINAL/Contents/MacOS/GDriveBackupTiger" +test "$CONTROLLER_ARGUMENT" = "--menubar" +test "$CONTROLLER_HOME" = "$HOME" +test "$CONTROLLER_PATH" = "$SAFE_PATH" +test "$SCHEDULE_LABEL" = \ + "com.commcats.gdrivebackup.schedule" +test "$SCHEDULE_PROGRAM" = "/bin/bash" +test "$SCHEDULE_SCRIPT" = "$SCRIPT_FINAL" +test "$SCHEDULE_ARGUMENT" = "--run" +test "$SCHEDULE_HOME" = "$HOME" +test "$SCHEDULE_PATH" = "$SAFE_PATH" +test "$SCHEDULE_TRIGGER" = "schedule" +test "$SCHEDULE_ASSUME_YES" = "1" +reject_service_path_overrides +assert_loaded_service_contract \ + "$CONTROLLER_SERVICE" "$CONTROLLER_PLIST" "$CONTROLLER_PROGRAM" 2 \ + "$CONTROLLER_PROGRAM" "$CONTROLLER_ARGUMENT" "" \ + "$CONTROLLER_HOME" "$CONTROLLER_PATH" "" "" +assert_loaded_service_contract \ + "$SCHEDULE_SERVICE" "$SCHEDULE_PLIST" "$SCHEDULE_PROGRAM" 3 \ + "$SCHEDULE_PROGRAM" "$SCHEDULE_SCRIPT" "$SCHEDULE_ARGUMENT" \ + "$SCHEDULE_HOME" "$SCHEDULE_PATH" "$SCHEDULE_TRIGGER" \ + "$SCHEDULE_ASSUME_YES" +assert_manager_environment_clean +OLD_CONTROLLER_PID="$(/bin/launchctl print "$CONTROLLER_SERVICE" | + /usr/bin/awk '/pid =/ {print $3; exit}')" +[[ "$OLD_CONTROLLER_PID" =~ ^[0-9]+$ ]] + +# PREBUILD_SNAPSHOT +PREBUILD_EFFECTIVE_STATE="$STAGE/prebuild-effective-state.sh" +PREBUILD_CONFIG_MANIFEST="$STAGE/config-prebuild.manifest" +derive_effective_state "$PREBUILD_EFFECTIVE_STATE" +# shellcheck source=/dev/null +source "$PREBUILD_EFFECTIVE_STATE" +test "$EFFECTIVE_CONFIG" = "$PROFILE_CONFIG" +for effective_path in \ + "$EFFECTIVE_CONFIG" "$EFFECTIVE_LOCK" "$EFFECTIVE_STATUS" "$EFFECTIVE_PROGRESS"; do + assert_absolute_single_line_path "$effective_path" +done +test "$PROFILE_TARGET" = "nas" +test "$PROFILE_SCHEDULE" = "daily" +test "$PROFILE_NOTIFY_FAILURES" = "1" +test -n "$PROFILE_NAS_MOUNT$PROFILE_NAS_URL" +PREBUILD_EFFECTIVE_CONFIG="$EFFECTIVE_CONFIG" +PREBUILD_EFFECTIVE_LOCK="$EFFECTIVE_LOCK" +PREBUILD_EFFECTIVE_STATUS="$EFFECTIVE_STATUS" +PREBUILD_EFFECTIVE_PROGRESS="$EFFECTIVE_PROGRESS" +PREBUILD_PROFILE_TARGET="$PROFILE_TARGET" +PREBUILD_PROFILE_SCHEDULE="$PROFILE_SCHEDULE" +PREBUILD_PROFILE_NOTIFY_FAILURES="$PROFILE_NOTIFY_FAILURES" +PREBUILD_PROFILE_NAS_MOUNT="$PROFILE_NAS_MOUNT" +PREBUILD_PROFILE_NAS_URL="$PROFILE_NAS_URL" +canonical_config_manifest "$CONFIG_DIR" "$PREBUILD_CONFIG_MANIFEST" +CONTROLLER_PLIST_HASH="$(/usr/bin/shasum -a 256 "$CONTROLLER_PLIST" | + /usr/bin/awk '{print $1}')" +SCHEDULE_PLIST_HASH="$(/usr/bin/shasum -a 256 "$SCHEDULE_PLIST" | + /usr/bin/awk '{print $1}')" + +# A terminal/no-process gate before archive extraction keeps even the build +# outside an active backup window. The definitive race-free checks follow +# under the exact effective lock. +# PREBUILD_GATE +assert_successful_terminal_backup_and_no_processes + +SOURCE_ARCHIVE="$STAGE/source.tar" +SOURCE_EXPORT="$STAGE/source" +STAGED_APP="$STAGE/build/GDrive Backup Tiger.app" +STAGED_BINARY="$STAGED_APP/Contents/MacOS/GDriveBackupTiger" +STAGED_SCRIPT="$SOURCE_EXPORT/bin/backup-google-drive.sh" +ENTITLEMENTS_PLIST="$STAGE/staged-entitlements.plist" +test ! -e "$SOURCE_ARCHIVE" && test ! -L "$SOURCE_ARCHIVE" +test ! -e "$SOURCE_EXPORT" && test ! -L "$SOURCE_EXPORT" +test ! -e "${STAGED_APP%/*}" && test ! -L "${STAGED_APP%/*}" +/bin/mkdir "$SOURCE_EXPORT" "${STAGED_APP%/*}" +git archive --format=tar --output="$SOURCE_ARCHIVE" "$RELEASE_TAG^{commit}" +test "$(git get-tar-commit-id <"$SOURCE_ARCHIVE")" = "$RELEASE_COMMIT" +/usr/bin/tar -xf "$SOURCE_ARCHIVE" -C "$SOURCE_EXPORT" +make -C "$SOURCE_EXPORT" test +"$SOURCE_EXPORT/scripts/validate-release.sh" "$RELEASE_TAG" +make -C "$SOURCE_EXPORT" APP_DIR="$STAGED_APP" build +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$STAGED_APP/Contents/Info.plist")" = "$EXPECTED_VERSION" +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$STAGED_APP/Contents/Info.plist")" = "$EXPECTED_BUILD" +architectures="$(/usr/bin/lipo -archs "$STAGED_BINARY")" +[[ " $architectures " == *" arm64 "* && " $architectures " == *" x86_64 "* ]] +/usr/bin/codesign --verify --deep --strict --verbose=2 "$STAGED_APP" +extract_entitlements "$STAGED_APP" "$ENTITLEMENTS_PLIST" +/usr/bin/plutil -lint "$ENTITLEMENTS_PLIST" >/dev/null +/bin/bash -n "$STAGED_SCRIPT" +APP_HASH="$(/usr/bin/shasum -a 256 "$STAGED_BINARY" | /usr/bin/awk '{print $1}')" +SCRIPT_HASH="$(/usr/bin/shasum -a 256 "$STAGED_SCRIPT" | /usr/bin/awk '{print $1}')" +[[ "$APP_HASH" =~ ^[0-9a-f]{64}$ && "$SCRIPT_HASH" =~ ^[0-9a-f]{64}$ ]] + +FLOCK_BIN="$(command -v flock)" +test -x "$FLOCK_BIN" +LOCK_PARENT="${PREBUILD_EFFECTIVE_LOCK%/*}" +test -d "$LOCK_PARENT" && test ! -L "$LOCK_PARENT" +test "$(/usr/bin/stat -f '%u' "$LOCK_PARENT")" = "$CURRENT_UID" +if [[ -e "$PREBUILD_EFFECTIVE_LOCK" || -L "$PREBUILD_EFFECTIVE_LOCK" ]]; then + test -f "$PREBUILD_EFFECTIVE_LOCK" && test ! -L "$PREBUILD_EFFECTIVE_LOCK" + test "$(/usr/bin/stat -f '%u' "$PREBUILD_EFFECTIVE_LOCK")" = "$CURRENT_UID" +fi +exec 8>"$PREBUILD_EFFECTIVE_LOCK" +test "$(/usr/bin/stat -f '%u' "$PREBUILD_EFFECTIVE_LOCK")" = "$CURRENT_UID" +/bin/chmod 600 "$PREBUILD_EFFECTIVE_LOCK" +"$FLOCK_BIN" -n 8 + +test -d "/Applications" && test ! -L "/Applications" +test -d "/usr/local/bin" && test ! -L "/usr/local/bin" +test -d "$APP_FINAL" && test ! -L "$APP_FINAL" +test -f "$SCRIPT_FINAL" && test ! -L "$SCRIPT_FINAL" && test -x "$SCRIPT_FINAL" +test "$(/usr/bin/stat -f '%u:%g:%Lp' "$SCRIPT_FINAL")" = "0:0:755" +OLD_APP_HASH="$(/usr/bin/shasum -a 256 \ + "$APP_FINAL/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" +OLD_SCRIPT_HASH="$(/usr/bin/shasum -a 256 "$SCRIPT_FINAL" | /usr/bin/awk '{print $1}')" + +STAMP="$(/bin/date -u '+%Y%m%dT%H%M%SZ')" +ROLLBACK="$(/usr/bin/mktemp -d "$ROLLBACK_PARENT/v2.4.4-$STAMP.XXXXXX")" +test -d "$ROLLBACK" && test ! -L "$ROLLBACK" +test "$(/usr/bin/stat -f '%u' "$ROLLBACK")" = "$CURRENT_UID" +canonical_config_manifest "$CONFIG_DIR" "$ROLLBACK/config-before.manifest" +/usr/bin/cmp -s "$PREBUILD_CONFIG_MANIFEST" \ + "$ROLLBACK/config-before.manifest" +/usr/bin/shasum -a 256 "$ROLLBACK/config-before.manifest" > \ + "$ROLLBACK/config-before.manifest.sha256" +/usr/bin/ditto "$APP_FINAL" "$ROLLBACK/GDrive Backup Tiger.app" +/usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 755 "$SCRIPT_FINAL" \ + "$ROLLBACK/backup-google-drive.sh" +test "$(/usr/bin/shasum -a 256 \ + "$ROLLBACK/GDrive Backup Tiger.app/Contents/MacOS/GDriveBackupTiger" | + /usr/bin/awk '{print $1}')" = "$OLD_APP_HASH" +test "$(/usr/bin/shasum -a 256 "$ROLLBACK/backup-google-drive.sh" | + /usr/bin/awk '{print $1}')" = "$OLD_SCRIPT_HASH" +test "$(/usr/bin/stat -f '%u:%g:%Lp' "$ROLLBACK/backup-google-drive.sh")" = \ + "0:0:755" + +APP_TXN="$(/usr/bin/sudo /usr/bin/mktemp -d "/Applications/.gdrive-v2.4.4-txn.XXXXXX")" +SCRIPT_TXN="$(/usr/bin/sudo /usr/bin/mktemp -d "/usr/local/bin/.gdrive-v2.4.4-txn.XXXXXX")" +/usr/bin/sudo /usr/sbin/chown "$CURRENT_UID:$CURRENT_GID" "$APP_TXN" +/bin/chmod 700 "$APP_TXN" +/usr/bin/sudo /usr/sbin/chown 0:0 "$SCRIPT_TXN" +/usr/bin/sudo /bin/chmod 755 "$SCRIPT_TXN" +test -d "$APP_TXN" && test ! -L "$APP_TXN" +test -d "$SCRIPT_TXN" && test ! -L "$SCRIPT_TXN" +test "$(/usr/bin/stat -f '%u' "$APP_TXN")" = "$CURRENT_UID" +test "$(/usr/bin/stat -f '%Lp' "$APP_TXN")" = "700" +test "$(/usr/bin/stat -f '%u:%g:%Lp' "$SCRIPT_TXN")" = "0:0:755" +test "$(/usr/bin/stat -f '%d' "$APP_TXN")" = "$(/usr/bin/stat -f '%d' /Applications)" +test "$(/usr/bin/stat -f '%d' "$SCRIPT_TXN")" = "$(/usr/bin/stat -f '%d' /usr/local/bin)" +APP_INCOMING="$APP_TXN/incoming.app" +SCRIPT_INCOMING="$SCRIPT_TXN/incoming" +APP_PREVIOUS="$APP_TXN/previous.app" +SCRIPT_PREVIOUS="$SCRIPT_TXN/previous" +APP_QUARANTINE="$APP_TXN/quarantine-new.app" +SCRIPT_QUARANTINE="$SCRIPT_TXN/quarantine-new" +test ! -e "$APP_INCOMING" && test ! -L "$APP_INCOMING" +test ! -e "$SCRIPT_INCOMING" && test ! -L "$SCRIPT_INCOMING" +test ! -e "$APP_PREVIOUS" && test ! -L "$APP_PREVIOUS" +test ! -e "$SCRIPT_PREVIOUS" && test ! -L "$SCRIPT_PREVIOUS" +test ! -e "$APP_QUARANTINE" && test ! -L "$APP_QUARANTINE" +test ! -e "$SCRIPT_QUARANTINE" && test ! -L "$SCRIPT_QUARANTINE" +/usr/bin/ditto "$STAGED_APP" "$APP_INCOMING" +/usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 755 "$STAGED_SCRIPT" "$SCRIPT_INCOMING" +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_INCOMING" +/bin/bash -n "$SCRIPT_INCOMING" +test "$(/usr/bin/shasum -a 256 \ + "$APP_INCOMING/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" = \ + "$APP_HASH" +test "$(/usr/bin/shasum -a 256 "$SCRIPT_INCOMING" | /usr/bin/awk '{print $1}')" = \ + "$SCRIPT_HASH" +test "$(/usr/bin/stat -f '%u:%g:%Lp' "$SCRIPT_INCOMING")" = "0:0:755" + +# The EXIT and signal traps already see RECOVERY_ARMED before the first +# service mutation. Any signal from here enters hash-aware recovery. +# LOCKED_SNAPSHOT_REVALIDATION +assert_runtime_snapshot_matches_prebuild "$STAGE/config-locked.manifest" "$STAGE/locked-effective-state.sh" +# LOCKED_PRE_BOOTOUT_GATE +assert_successful_terminal_backup_and_no_processes +RECOVERY_ARMED=1 +# FIRST_SERVICE_MUTATION +/bin/launchctl bootout "$DOMAIN" "$SCHEDULE_PLIST" +/bin/launchctl bootout "$DOMAIN" "$CONTROLLER_PLIST" +# POST_QUIESCE_GATE +assert_successful_terminal_backup_and_no_processes + +/usr/bin/sudo /bin/mv "$APP_FINAL" "$APP_PREVIOUS" +/usr/bin/sudo /bin/mv "$APP_INCOMING" "$APP_FINAL" +/usr/bin/sudo /bin/mv "$SCRIPT_FINAL" "$SCRIPT_PREVIOUS" +/usr/bin/sudo /bin/mv "$SCRIPT_INCOMING" "$SCRIPT_FINAL" +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_FINAL" +/bin/bash -n "$SCRIPT_FINAL" +test "$(/usr/bin/shasum -a 256 \ + "$APP_FINAL/Contents/MacOS/GDriveBackupTiger" | /usr/bin/awk '{print $1}')" = \ + "$APP_HASH" +test "$(/usr/bin/shasum -a 256 "$SCRIPT_FINAL" | /usr/bin/awk '{print $1}')" = \ + "$SCRIPT_HASH" +reload_services + +# FINAL_VERIFICATION_UNDER_LOCK +assert_final_install_state +RECOVERY_ARMED=0 +"$FLOCK_BIN" -u 8 +exec 8>&- +trap - EXIT HUP INT TERM +report_install_leftovers +printf 'Installed %s build %s from %s.\n' \ + "$EXPECTED_VERSION" "$EXPECTED_BUILD" "$RELEASE_COMMIT" +``` + + +Expected: the immutable tagged export passes its complete test suite and +release validator before build. The effective scheduled-profile lock remains +held from the last pre-mutation gate through service, configuration, version, +entitlement, and process verification. On failure or interruption, only known +hashes are moved and the old pair is restored before services reload; an +ambiguous rollback leaves both services unloaded and reports a critical state. +All printed `LEFTOVER_*` paths remain recoverable for inspection. + +- [ ] **Step 5: Finish project memory and report recoverable artifacts** + +```bash +/Users/alexandersmyslowski/Projects/central-agent-data-hub/scripts/agent_finish.sh \ + --project gdrive-tiger-backup --review +git status --short --branch +git log --oneline --decorate --max-count=12 +gh release view v2.4.4 --json tagName,url,assets +``` + +If the Hub remains unavailable, report that separately and do not claim memory +writeback. Do not delete rollback copies, NAS data, profiles, or unrelated user +files. diff --git a/docs/superpowers/specs/2026-08-01-automatic-retry-progress-design.md b/docs/superpowers/specs/2026-08-01-automatic-retry-progress-design.md new file mode 100644 index 0000000..08b8cee --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-automatic-retry-progress-design.md @@ -0,0 +1,176 @@ +# Automatic Retry Progress Design + +## Goal + +Make an automatic retry observable without opening or activating a window. A +user must be able to distinguish a retry that is waiting, running, successful, +or finally failed, and must be able to inspect live per-phase progress from the +normal overview and menu bar. + +## Problem + +The current `BACKUP_PROGRESS_FOREGROUND` flag controls two unrelated concerns: +whether a progress window is shown and whether a progress file is created. +Automatic runs correctly disable the foreground window, but consequently emit +no progress data. The durable run summary identifies `schedule-retry` as +running, yet it contains no phase or percentage. The preliminary notification +therefore remains visible with the stale promise that a retry will start in 30 +minutes even after that retry has started. + +## User Experience + +### Waiting for the retry + +The existing persistent notification remains visible and states that the NAS +is not ready and that one automatic retry will start in 30 minutes. The normal +overview shows the failed run and the planned retry time. + +### Retry running + +When the retry process is accepted and publishes a matching running state, the +existing notification is updated once in place: + +- Title: `Automatischer Wiederholungsversuch läuft` +- Body: `GDrive wird erneut gesichert. Öffne GDrive Backup Tiger, um den Fortschritt zu sehen.` +- Action: open the normal GDrive Backup Tiger overview + +No window is opened or activated automatically. Full-screen applications and +the user's current Space remain undisturbed. + +The normal overview displays: + +- `Automatischer Wiederholungsversuch läuft` instead of the generic running + label; +- the retry start time; +- a native progress bar; +- the current phase as `Bereich N von M`; +- the current phase percentage, transferred size, speed, and ETA when rclone + provides them; +- an indeterminate bar and `Wird vorbereitet …` during preflight or between + measurable copy phases. + +The percentage is explicitly the percentage of the current phase. The product +must not present it as a global backup percentage because calculating that +would require an expensive complete pre-scan. + +The menu bar uses the same snapshot and shows a compact disabled status row, +for example `Retry läuft · Bereich 3 von 5 · 63 %`. Selecting `GDrive Backup +Tiger öffnen` reveals the full overview. + +### Retry success + +A terminal `success` with exit code 0 and a matching automatic trigger clears +the persistent warning and the live-progress record. The overview returns to +the existing completed state. + +### Retry failure or interruption + +A terminal failure replaces the running notification with the existing final +retry-failure notification and remains visible until a newer automatic success +or explicit human dismissal. An interrupted process is treated as a terminal +problem, never as indefinitely running progress. + +## Progress Data Contract + +Every real backup owner creates a private, profile-scoped progress file after +acquiring the backup lock, regardless of whether a foreground progress window +is allowed. The file lives next to the profile's `last-run.status`, is mode +`0600`, and is replaced atomically. + +The protocol contains only aggregate status data: + +```text +protocol=1 +profile_id=default +pid=12345 +started_at=1785522633 +trigger=schedule-retry +retry_attempt=1 +label=Shared Drive +phase=3/5 +percent=63 +detail=1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s +updated_at=1785523000 +``` + +It must not contain file names, directory names from individual transfers, +credentials, remote configuration, or raw log lines. Shared Drive names are +also omitted from background progress; only the generic area type and phase +count are exposed. + +Readers accept the progress record only when all of these conditions hold: + +- protocol, PID, start time, trigger, and profile ID are valid; +- PID and start time match the current validated `running` summary; +- the process still exists; +- `updated_at` is not in the future and is recent enough for a live run; +- phase and percentage pass strict bounds checks. + +Invalid or stale progress falls back to an indeterminate running state. It can +never turn a terminal summary back into a running state. + +## Architecture + +The shell script separates progress telemetry from foreground presentation: + +1. After lock ownership, it derives the profile progress path and creates the + first atomic `preparing` record. +2. Existing rclone statistics parsing writes sanitized aggregate updates to + that durable file. +3. A manual foreground helper may additionally read the same data; automatic + runs never open the helper. +4. Terminal cleanup removes or invalidates the live record only after the + terminal run summary is safely written. + +The persistent controller already refreshes every two seconds while a run is +active. It reads and validates the progress record alongside `last-run.status`, +adds the fields to one overview snapshot, and feeds both the overview and menu +bar from that snapshot. + +Notification policy gains an explicit `retry-running` state. It updates the +preliminary failure notification once after a matching retry is running. The +unresolved issue latch remains active, so the warning is still cleared only by +a newer automatic success or human dismissal. + +## Safety and Compatibility + +- Scheduled and retry runs remain headless and never activate the app. +- The current foreground and full-screen nonintrusion contracts remain intact. +- The existing profile isolation and atomic summary protocol remain intact. +- Legacy or missing progress files degrade to the current generic running UI. +- No change may restart the installed controller, replace the installed app or + script, or alter configuration while a backup is running. +- The installed update is staged and verified first, then applied only after + the active backup reaches a terminal state. +- All seven supported languages receive complete retry-running and progress + copy, including accessibility labels. + +## Test Strategy + +Tests are written before production changes and must first fail for the missing +behavior. Coverage includes: + +- shell tests proving headless automatic runs create atomic, private, + aggregate-only progress records without opening a window; +- parser tests for matching, stale, malformed, cross-profile, mismatched-PID, + out-of-range, and terminal progress records; +- overview tests for indeterminate preparation, determinate current-phase + progress, retry-specific copy, disabled concurrent backup action, and menu + bar parity; +- notification policy and integration tests proving the preliminary alert is + updated once when the retry runs, is cleared on success, and is replaced on + final failure; +- accessibility tests for the native progress indicator and localized labels; +- regression tests preserving headless automatic execution, passive window + behavior, full-screen safety, failure persistence, and deduplication. + +The complete existing test suite, syntax checks, Universal 2 build, code-sign +verification, and isolated app-launch check must pass before installation. + +## Non-Goals + +- No global percentage across all Google Drive areas. +- No periodic notification for every percentage change. +- No automatic foreground window, Dock activation, or Space switching. +- No change to backup contents, version retention, NAS mounting, credentials, + schedule, or retry count. diff --git a/docs/version-history.md b/docs/version-history.md index 806925f..aecaa1c 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -33,12 +33,14 @@ records are restored transparently as historical source milestones. | v2.3.2 | 23 | `7ecda45` | Historical source milestone published retrospectively | | v2.4.0 | 24 | tag `v2.4.0` | Published tested release | | v2.4.1 | 25 | tag `v2.4.1` | Superseded; its protected entitlement caused macOS to reject the unsigned app at launch | -| v2.4.2 | 26 | tag `v2.4.2` | Current tested release | +| v2.4.2 | 26 | tag `v2.4.2` | Superseded tested release | +| v2.4.3 | 27 | tag `v2.4.3` | Superseded tested release | +| v2.4.4 | 28 | tag `v2.4.4` | Current tested release | -No historical binary installer is reconstructed and presented as an original -artifact. Retrospective release pages expose GitHub's source archives and state -their later publication date. The current release alone receives the installer -built and verified from its exact tag. +No retrospectively built installer is presented as an original historical artifact. +Retrospective release pages expose GitHub's source archives and state +their later publication date. The v2.4.3 and v2.4.4 installers are built and verified from their exact tags: +v2.4.3 during the transparent publication repair and v2.4.4 as the current release. Future version tags trigger the release workflow. It refuses a tag that does not match the app version, positive build number, README, and dated changelog diff --git a/install.sh b/install.sh index 1ee7417..9d90573 100755 --- a/install.sh +++ b/install.sh @@ -365,11 +365,12 @@ fi install -m 644 "$ROOT/macos/GDriveBackupTiger/Info.plist" "$APP_CONTENTS/Info.plist" clang -fobjc-arc -Wall -Wextra -mmacosx-version-min=13.0 \ -arch arm64 -arch x86_64 -framework Cocoa -framework UserNotifications \ - -framework Security \ + -framework Security -framework NetFS \ "$ROOT/macos/GDriveBackupTiger/main.m" \ "$ROOT/macos/GDriveBackupTiger/ConfigSupport.m" \ "$ROOT/macos/GDriveBackupTiger/ProfileSupport.m" \ "$ROOT/macos/GDriveBackupTiger/BackupStatusSupport.m" \ + "$ROOT/macos/GDriveBackupTiger/BackupProgressSupport.m" \ "$ROOT/macos/GDriveBackupTiger/NotificationSupport.m" \ "$ROOT/macos/GDriveBackupTiger/SetupHealthSupport.m" \ "$ROOT/macos/GDriveBackupTiger/RestoreSupport.m" \ @@ -377,6 +378,7 @@ clang -fobjc-arc -Wall -Wextra -mmacosx-version-min=13.0 \ "$ROOT/macos/GDriveBackupTiger/DiagnosticsSupport.m" \ "$ROOT/macos/GDriveBackupTiger/DiagnosticsView.m" \ "$ROOT/macos/GDriveBackupTiger/UpdateSupport.m" \ + "$ROOT/macos/GDriveBackupTiger/NetworkMountSupport.m" \ "$ROOT/macos/GDriveBackupTiger/Localization.m" \ -o "$APP_CONTENTS/MacOS/GDriveBackupTiger" diff --git a/macos/GDriveBackupTiger/BackupProgressSupport.h b/macos/GDriveBackupTiger/BackupProgressSupport.h new file mode 100644 index 0000000..d7e20f4 --- /dev/null +++ b/macos/GDriveBackupTiger/BackupProgressSupport.h @@ -0,0 +1,16 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString *GDTBackupProgressPathForSummaryPath(NSString *summaryPath); +FOUNDATION_EXPORT NSDictionary * _Nullable + GDTReadBackupProgressAtPath(NSString *path); +FOUNDATION_EXPORT NSDictionary * _Nullable + GDTValidatedBackupProgressForValues( + NSDictionary *progress, + NSDictionary *summary, + NSString *summaryStatus, + NSString *profileID, + NSTimeInterval nowTimestamp); + +NS_ASSUME_NONNULL_END diff --git a/macos/GDriveBackupTiger/BackupProgressSupport.m b/macos/GDriveBackupTiger/BackupProgressSupport.m new file mode 100644 index 0000000..a361b78 --- /dev/null +++ b/macos/GDriveBackupTiger/BackupProgressSupport.m @@ -0,0 +1,211 @@ +#import "BackupProgressSupport.h" + +#include +#include +#include +#include +#include +#include + +static BOOL GDTProgressValueIsSafe(NSString *value, NSUInteger maximumLength) { + if (![value isKindOfClass:NSString.class] || value.length > maximumLength) { + return NO; + } + for (NSUInteger index = 0; index < value.length; index++) { + unichar character = [value characterAtIndex:index]; + if (character == 0 || character == '\r' || character == '\n') return NO; + } + return YES; +} + +static BOOL GDTParseUnsignedInteger(NSString *value, unsigned long long *result) { + if (!value.length || + [value rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString: + @"0123456789"].invertedSet].location != NSNotFound) { + return NO; + } + errno = 0; + char *end = NULL; + unsigned long long number = strtoull(value.UTF8String, &end, 10); + if (errno == ERANGE || !end || *end != '\0') { + return NO; + } + if (result) *result = number; + return YES; +} + +static BOOL GDTMatchesPattern(NSString *value, NSString *pattern) { + NSRegularExpression *expression = [NSRegularExpression + regularExpressionWithPattern:pattern options:0 error:nil]; + NSRange entireValue = NSMakeRange(0, value.length); + return [expression firstMatchInString:value options:0 range:entireValue] != nil; +} + +static BOOL GDTValidProgressPhase(NSString *phase) { + if (!GDTMatchesPattern(phase, @"^[1-9][0-9]*/[1-9][0-9]*$")) return NO; + NSArray *parts = [phase componentsSeparatedByString:@"/"]; + unsigned long long current = 0; + unsigned long long total = 0; + return parts.count == 2 && + GDTParseUnsignedInteger(parts[0], ¤t) && + GDTParseUnsignedInteger(parts[1], &total) && + current <= total && total <= 9999; +} + +static BOOL GDTValidProgressDetail(NSString *detail) { + if (!GDTProgressValueIsSafe(detail, 256)) return NO; + return GDTMatchesPattern(detail, + @"^[0-9]+([.][0-9]+)? ([KMGTPE]i)?B / [0-9]+([.][0-9]+)? ([KMGTPE]i)?B, [0-9]+([.][0-9]+)? ([KMGTPE]i)?B/s, ETA (-|[0-9]+[dhms]([0-9]+[dhms])*)$"); +} + +static NSData *GDTReadPrivateProgressData(NSString *path) { + if (!path.length) return nil; + int descriptor = open(path.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW); + if (descriptor < 0) return nil; + + struct stat attributes; + if (fstat(descriptor, &attributes) != 0 || + !S_ISREG(attributes.st_mode) || + attributes.st_uid != getuid() || + (attributes.st_mode & (S_IRWXG | S_IRWXO)) != 0) { + close(descriptor); + return nil; + } + + NSMutableData *data = [NSMutableData data]; + uint8_t buffer[4096]; + for (;;) { + ssize_t count = read(descriptor, buffer, sizeof(buffer)); + if (count > 0) { + [data appendBytes:buffer length:(NSUInteger)count]; + continue; + } + if (count < 0 && errno == EINTR) continue; + if (count < 0) { + close(descriptor); + return nil; + } + break; + } + close(descriptor); + return data; +} + +NSString *GDTBackupProgressPathForSummaryPath(NSString *summaryPath) { + return [[summaryPath stringByDeletingLastPathComponent] + stringByAppendingPathComponent:@"current-progress.status"]; +} + +NSDictionary *GDTReadBackupProgressAtPath(NSString *path) { + NSData *data = GDTReadPrivateProgressData(path); + if (!data.length) return nil; + NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (!content.length || [content rangeOfString:@"\r"].location != NSNotFound) { + return nil; + } + for (NSUInteger index = 0; index < content.length; index++) { + if ([content characterAtIndex:index] == 0) return nil; + } + + NSMutableDictionary *values = [NSMutableDictionary dictionary]; + NSArray *lines = [content componentsSeparatedByString:@"\n"]; + for (NSUInteger index = 0; index < lines.count; index++) { + NSString *line = lines[index]; + if (!line.length && index == lines.count - 1) continue; + NSRange separator = [line rangeOfString:@"="]; + if (!line.length || separator.location == NSNotFound || separator.location == 0) { + return nil; + } + NSString *key = [line substringToIndex:separator.location]; + NSString *value = [line substringFromIndex:NSMaxRange(separator)]; + if (values[key] || !GDTProgressValueIsSafe(value, 512)) return nil; + values[key] = value; + } + + for (NSString *key in @[@"protocol", @"profile_id", @"pid", @"started_at", + @"trigger", @"updated_at"]) { + if (!values[key]) return nil; + } + NSString *status = values[@"status"]; + if (status) { + if (![status isEqualToString:@"finished"] || values[@"label"] || + values[@"phase"] || values[@"percent"] || values[@"detail"]) { + return nil; + } + } else if (!values[@"label"]) { + return nil; + } + return values; +} + +NSDictionary *GDTValidatedBackupProgressForValues( + NSDictionary *progress, + NSDictionary *summary, + NSString *summaryStatus, + NSString *profileID, + NSTimeInterval nowTimestamp) { + for (NSString *key in @[@"protocol", @"profile_id", @"pid", @"started_at", + @"trigger", @"label", @"updated_at"]) { + if (!GDTProgressValueIsSafe(progress[key], 512)) return nil; + } + for (NSString *key in @[@"protocol", @"pid", @"started_at", @"trigger"]) { + if (!GDTProgressValueIsSafe(summary[key], 512)) return nil; + } + if (progress[@"status"] || + ![summaryStatus isEqualToString:@"running"] || + ![progress[@"protocol"] isEqualToString:@"1"] || + ![summary[@"protocol"] isEqualToString:@"1"] || + ![progress[@"profile_id"] isEqualToString:profileID] || + ![progress[@"pid"] isEqualToString:summary[@"pid"]] || + ![progress[@"started_at"] isEqualToString:summary[@"started_at"]] || + ![progress[@"trigger"] isEqualToString:summary[@"trigger"]]) { + return nil; + } + + NSString *summaryRetry = summary[@"retry_attempt"]; + NSString *progressRetry = progress[@"retry_attempt"]; + if ((summaryRetry || progressRetry) && + (!GDTProgressValueIsSafe(summaryRetry, 512) || + !GDTProgressValueIsSafe(progressRetry, 512) || + ![summaryRetry isEqualToString:progressRetry])) { + return nil; + } + + unsigned long long pid = 0; + unsigned long long startedAt = 0; + unsigned long long updatedAt = 0; + if (!GDTParseUnsignedInteger(progress[@"pid"], &pid) || pid == 0 || pid > INT_MAX || + !GDTParseUnsignedInteger(progress[@"started_at"], &startedAt) || startedAt == 0 || + !GDTParseUnsignedInteger(progress[@"updated_at"], &updatedAt)) { + return nil; + } + errno = 0; + if (kill((pid_t)pid, 0) != 0 && errno != EPERM) return nil; + NSTimeInterval updateTimestamp = (NSTimeInterval)updatedAt; + if (updateTimestamp > nowTimestamp || nowTimestamp - updateTimestamp > 60) { + return nil; + } + + NSString *label = progress[@"label"]; + NSSet *labels = [NSSet setWithArray: + @[@"preparing", @"My Drive", @"Shared with me", @"Shared Drive"]]; + if (![labels containsObject:label]) return nil; + NSString *phase = progress[@"phase"]; + if ([label isEqualToString:@"preparing"]) { + if (phase && !GDTValidProgressPhase(phase)) return nil; + } else if (!GDTProgressValueIsSafe(phase, 512) || !GDTValidProgressPhase(phase)) { + return nil; + } + + NSString *percent = progress[@"percent"]; + if (percent) { + unsigned long long number = 0; + if (!GDTProgressValueIsSafe(percent, 512) || + !GDTParseUnsignedInteger(percent, &number) || number > 100) { + return nil; + } + } + NSString *detail = progress[@"detail"]; + if (detail && !GDTValidProgressDetail(detail)) return nil; + return progress; +} diff --git a/macos/GDriveBackupTiger/ConfigSupport.h b/macos/GDriveBackupTiger/ConfigSupport.h index 7622171..d41fbca 100644 --- a/macos/GDriveBackupTiger/ConfigSupport.h +++ b/macos/GDriveBackupTiger/ConfigSupport.h @@ -7,6 +7,11 @@ FOUNDATION_EXPORT NSString *GDTConfigPathForConfigDirectory(NSString *configDire FOUNDATION_EXPORT NSString *GDTDecodeConfigValue(NSString *value); FOUNDATION_EXPORT NSMutableDictionary *GDTReadConfigDictionary(void); FOUNDATION_EXPORT NSMutableDictionary *GDTReadConfigDictionaryAtPath(NSString *path); +FOUNDATION_EXPORT NSString *GDTNASRemountURLForMountedSMBSource(NSString *source); +FOUNDATION_EXPORT NSString *GDTPreferredNASRemountURL( + NSString *resourceURLString, + NSString *mountedSource, + BOOL isSMBMount); FOUNDATION_EXPORT NSString *GDTShellQuote(NSString * _Nullable value); FOUNDATION_EXPORT BOOL GDTWriteConfigUpdates(NSDictionary *updates, NSError * _Nullable * _Nullable error); diff --git a/macos/GDriveBackupTiger/ConfigSupport.m b/macos/GDriveBackupTiger/ConfigSupport.m index e8b0ec9..166f484 100644 --- a/macos/GDriveBackupTiger/ConfigSupport.m +++ b/macos/GDriveBackupTiger/ConfigSupport.m @@ -1,5 +1,73 @@ #import "ConfigSupport.h" +NSString *GDTNASRemountURLForMountedSMBSource(NSString *source) { + if (![source hasPrefix:@"//"] || source.length <= 2) { + return @""; + } + + NSString *authorityAndPath = [source substringFromIndex:2]; + NSRange accountSeparator = [authorityAndPath rangeOfString:@"@" + options:NSBackwardsSearch]; + if (accountSeparator.location == NSNotFound) { + return [@"smb://" stringByAppendingString:authorityAndPath]; + } + + NSString *account = [authorityAndPath substringToIndex:accountSeparator.location]; + NSString *hostAndPath = [authorityAndPath substringFromIndex:NSMaxRange(accountSeparator)]; + NSRange passwordSeparator = [account rangeOfString:@":"]; + if (passwordSeparator.location != NSNotFound) { + account = [account substringToIndex:passwordSeparator.location]; + } + + NSCharacterSet *unsafeAccountCharacters = [NSCharacterSet + characterSetWithCharactersInString:@"/@\r\n"]; + if (!account.length || + [account rangeOfCharacterFromSet:unsafeAccountCharacters].location != NSNotFound) { + return [@"smb://" stringByAppendingString:hostAndPath]; + } + + // The account lets Finder choose the matching Keychain item silently. + // Passwords are deliberately discarded even if an unusual mount source + // happens to expose userinfo beyond the account name. + return [NSString stringWithFormat:@"smb://%@@%@", account, hostAndPath]; +} + +static NSString *GDTRemountURLWithoutPassword(NSString *urlString) { + if (!urlString.length) { + return @""; + } + NSURLComponents *components = + [NSURLComponents componentsWithString:urlString]; + if (!components.scheme.length || !components.host.length) { + return @""; + } + components.password = nil; + return components.string ?: @""; +} + +NSString *GDTPreferredNASRemountURL( + NSString *resourceURLString, + NSString *mountedSource, + BOOL isSMBMount) { + NSString *safeResourceURL = + GDTRemountURLWithoutPassword(resourceURLString); + if (!isSMBMount) { + return safeResourceURL; + } + NSString *sourceURL = + GDTNASRemountURLForMountedSMBSource(mountedSource); + NSURLComponents *sourceComponents = + [NSURLComponents componentsWithString:sourceURL]; + + // NSURLVolumeURLForRemountingKey can omit the SMB account even though + // /sbin/mount still identifies it. Prefer that account-qualified source + // because the unattended helper needs it to select the exact Keychain item. + if (sourceComponents.user.length) { + return sourceURL; + } + return safeResourceURL.length ? safeResourceURL : sourceURL; +} + typedef NS_ENUM(NSUInteger, GDTConfigQuoteState) { GDTConfigQuoteStateUnquoted, GDTConfigQuoteStateSingleQuoted, diff --git a/macos/GDriveBackupTiger/Info.plist b/macos/GDriveBackupTiger/Info.plist index 2e11b6b..25ec222 100644 --- a/macos/GDriveBackupTiger/Info.plist +++ b/macos/GDriveBackupTiger/Info.plist @@ -30,9 +30,9 @@ CFBundlePackageType APPL CFBundleVersion - 26 + 28 CFBundleShortVersionString - 2.4.2 + 2.4.4 LSMinimumSystemVersion 13.0 NSPrincipalClass diff --git a/macos/GDriveBackupTiger/Localization.m b/macos/GDriveBackupTiger/Localization.m index dc92abd..71a724f 100644 --- a/macos/GDriveBackupTiger/Localization.m +++ b/macos/GDriveBackupTiger/Localization.m @@ -48,6 +48,8 @@ @"backupNotificationMissedBody": @"Das für 20:00 Uhr geplante Backup wurde bis 21:00 Uhr nicht erfolgreich abgeschlossen.", @"backupNotificationTargetUnavailable": @"Das Backup-Ziel ist nicht verfügbar. Bitte verbinden und Backup erneut starten.", @"backupNotificationNASRetryBody": @"Das NAS ist noch nicht vollständig bereit. Ein neuer Versuch startet automatisch in 30 Minuten.", + @"backupNotificationRetryRunningTitle": @"Automatischer Wiederholungsversuch läuft", + @"backupNotificationRetryRunningBody": @"GDrive wird erneut gesichert. Öffne GDrive Backup Tiger, um den Fortschritt zu sehen.", @"backupNotificationRetryFailureBody": @"Auch der automatische Wiederholungsversuch ist fehlgeschlagen. Bitte NAS-Verbindung prüfen und das Backup erneut starten.", @"unknownExternalVolumeTitle": @"Neue Festplatte erkannt", @"unknownExternalVolumeBody": @"„%@“ wird nicht verwendet. Kein Backup wurde gestartet.", @@ -168,6 +170,11 @@ @"automaticBackupsPaused": @"Automatische Backups pausiert", @"pauseAutomaticBackups": @"Automatische Backups pausieren", @"resumeAutomaticBackups": @"Automatische Backups fortsetzen", + @"automaticRetryRunning": @"Automatischer Wiederholungsversuch läuft", + @"automaticRetryRunningShort": @"Retry läuft", + @"backupProgressCurrentPhase": @"Fortschritt des aktuellen Bereichs", + @"progressAreaFormat": @"Bereich %1$@ von %2$@", + @"progressPreparing": @"Wird vorbereitet …", @"profileLabel": @"Profil", @"profileDefault": @"Standard", @"profileCreate": @"Neues Profil", @@ -238,6 +245,8 @@ @"backupNotificationMissedBody": @"The backup scheduled for 20:00 was not completed successfully by 21:00.", @"backupNotificationTargetUnavailable": @"The backup destination is unavailable. Connect it and run the backup again.", @"backupNotificationNASRetryBody": @"The NAS is not fully ready yet. Another attempt will start automatically in 30 minutes.", + @"backupNotificationRetryRunningTitle": @"Automatic backup retry is running", + @"backupNotificationRetryRunningBody": @"GDrive is being backed up again. Open GDrive Backup Tiger to view progress.", @"backupNotificationRetryFailureBody": @"The automatic retry also failed. Check the NAS connection and run the backup again.", @"unknownExternalVolumeTitle": @"New external disk detected", @"unknownExternalVolumeBody": @"“%@” is not being used. No backup was started.", @@ -358,6 +367,11 @@ @"automaticBackupsPaused": @"Automatic backups paused", @"pauseAutomaticBackups": @"Pause automatic backups", @"resumeAutomaticBackups": @"Resume automatic backups", + @"automaticRetryRunning": @"Automatic retry is running", + @"automaticRetryRunningShort": @"Retry running", + @"backupProgressCurrentPhase": @"Current area progress", + @"progressAreaFormat": @"Area %1$@ of %2$@", + @"progressPreparing": @"Preparing …", @"profileLabel": @"Profile", @"profileDefault": @"Default", @"profileCreate": @"New profile", @@ -428,6 +442,8 @@ @"backupNotificationMissedBody": @"La sauvegarde prévue à 20:00 ne s’est pas terminée correctement avant 21:00.", @"backupNotificationTargetUnavailable": @"La destination de sauvegarde est indisponible. Reconnectez-la et relancez la sauvegarde.", @"backupNotificationNASRetryBody": @"Le NAS n’est pas encore entièrement prêt. Une nouvelle tentative démarrera automatiquement dans 30 minutes.", + @"backupNotificationRetryRunningTitle": @"Nouvelle tentative de sauvegarde automatique en cours", + @"backupNotificationRetryRunningBody": @"Une nouvelle sauvegarde de GDrive est en cours. Ouvrez GDrive Backup Tiger pour suivre la progression.", @"backupNotificationRetryFailureBody": @"La nouvelle tentative automatique a également échoué. Vérifiez la connexion NAS et relancez la sauvegarde.", @"unknownExternalVolumeTitle": @"Nouveau disque externe détecté", @"unknownExternalVolumeBody": @"« %@ » n’est pas utilisé. Aucune sauvegarde n’a démarré.", @@ -548,6 +564,11 @@ @"automaticBackupsPaused": @"Sauvegardes automatiques en pause", @"pauseAutomaticBackups": @"Suspendre les sauvegardes automatiques", @"resumeAutomaticBackups": @"Reprendre les sauvegardes automatiques", + @"automaticRetryRunning": @"Nouvelle tentative automatique en cours", + @"automaticRetryRunningShort": @"Nouvelle tentative en cours", + @"backupProgressCurrentPhase": @"Progression de la zone actuelle", + @"progressAreaFormat": @"Zone %1$@ sur %2$@", + @"progressPreparing": @"Préparation…", @"profileLabel": @"Profil", @"profileDefault": @"Par défaut", @"profileCreate": @"Nouveau profil", @@ -618,6 +639,8 @@ @"backupNotificationMissedBody": @"La copia programada para las 20:00 no terminó correctamente antes de las 21:00.", @"backupNotificationTargetUnavailable": @"El destino de la copia no está disponible. Conéctalo y vuelve a ejecutar la copia.", @"backupNotificationNASRetryBody": @"El NAS aún no está completamente listo. Se iniciará otro intento automáticamente en 30 minutos.", + @"backupNotificationRetryRunningTitle": @"Reintento automático de copia de seguridad en curso", + @"backupNotificationRetryRunningBody": @"Se está realizando de nuevo la copia de seguridad de GDrive. Abre GDrive Backup Tiger para ver el progreso.", @"backupNotificationRetryFailureBody": @"El reintento automático también falló. Revisa la conexión del NAS y vuelve a ejecutar la copia.", @"unknownExternalVolumeTitle": @"Nuevo disco externo detectado", @"unknownExternalVolumeBody": @"«%@» no se está usando. No se inició ninguna copia.", @@ -738,6 +761,11 @@ @"automaticBackupsPaused": @"Copias automáticas en pausa", @"pauseAutomaticBackups": @"Pausar copias automáticas", @"resumeAutomaticBackups": @"Reanudar copias automáticas", + @"automaticRetryRunning": @"Reintento automático en curso", + @"automaticRetryRunningShort": @"Reintento en curso", + @"backupProgressCurrentPhase": @"Progreso del área actual", + @"progressAreaFormat": @"Área %1$@ de %2$@", + @"progressPreparing": @"Preparando…", @"profileLabel": @"Perfil", @"profileDefault": @"Predeterminado", @"profileCreate": @"Nuevo perfil", @@ -808,6 +836,8 @@ @"backupNotificationMissedBody": @"20:00に予定されたバックアップは21:00までに正常完了しませんでした。", @"backupNotificationTargetUnavailable": @"バックアップ先を利用できません。接続してバックアップを再実行してください。", @"backupNotificationNASRetryBody": @"NASの準備がまだ完了していません。30分後に自動で再試行します。", + @"backupNotificationRetryRunningTitle": @"自動バックアップを再試行中", + @"backupNotificationRetryRunningBody": @"GDrive をもう一度バックアップしています。進行状況を確認するには GDrive Backup Tiger を開いてください。", @"backupNotificationRetryFailureBody": @"自動再試行にも失敗しました。NAS接続を確認してバックアップを再実行してください。", @"unknownExternalVolumeTitle": @"新しい外付けディスクを検出しました", @"unknownExternalVolumeBody": @"「%@」は使用されていません。バックアップは開始されませんでした。", @@ -928,6 +958,11 @@ @"automaticBackupsPaused": @"自動バックアップは一時停止中", @"pauseAutomaticBackups": @"自動バックアップを一時停止", @"resumeAutomaticBackups": @"自動バックアップを再開", + @"automaticRetryRunning": @"自動再試行を実行中", + @"automaticRetryRunningShort": @"再試行中", + @"backupProgressCurrentPhase": @"現在の領域の進行状況", + @"progressAreaFormat": @"領域 %1$@ / %2$@", + @"progressPreparing": @"準備中…", @"profileLabel": @"プロファイル", @"profileDefault": @"デフォルト", @"profileCreate": @"新規プロファイル", @@ -998,6 +1033,8 @@ @"backupNotificationMissedBody": @"原定 20:00 執行的備份在 21:00 前仍未成功完成。", @"backupNotificationTargetUnavailable": @"備份目的地目前無法使用。請重新連接後再執行備份。", @"backupNotificationNASRetryBody": @"NAS 尚未完全就緒,系統將在 30 分鐘後自動重試。", + @"backupNotificationRetryRunningTitle": @"自動備份重試進行中", + @"backupNotificationRetryRunningBody": @"GDrive 正在再次備份。請開啟 GDrive Backup Tiger 查看進度。", @"backupNotificationRetryFailureBody": @"自動重試仍然失敗。請檢查 NAS 連線後再次執行備份。", @"unknownExternalVolumeTitle": @"偵測到新的外置磁碟", @"unknownExternalVolumeBody": @"「%@」未被使用,亦未開始備份。", @@ -1118,6 +1155,11 @@ @"automaticBackupsPaused": @"自動備份已暫停", @"pauseAutomaticBackups": @"暫停自動備份", @"resumeAutomaticBackups": @"繼續自動備份", + @"automaticRetryRunning": @"自動重試進行中", + @"automaticRetryRunningShort": @"重試中", + @"backupProgressCurrentPhase": @"目前區域嘅進度", + @"progressAreaFormat": @"區域 %1$@ / %2$@", + @"progressPreparing": @"準備中…", @"profileLabel": @"設定檔", @"profileDefault": @"預設", @"profileCreate": @"新增設定檔", @@ -1188,6 +1230,8 @@ @"backupNotificationMissedBody": @"20:00에 예약된 백업이 21:00까지 성공적으로 완료되지 않았습니다.", @"backupNotificationTargetUnavailable": @"백업 대상에 연결할 수 없습니다. 다시 연결한 뒤 백업을 실행하세요.", @"backupNotificationNASRetryBody": @"NAS가 아직 완전히 준비되지 않았습니다. 30분 후 자동으로 다시 시도합니다.", + @"backupNotificationRetryRunningTitle": @"자동 백업 재시도 실행 중", + @"backupNotificationRetryRunningBody": @"GDrive를 다시 백업하고 있습니다. 진행 상황을 보려면 GDrive Backup Tiger를 여십시오.", @"backupNotificationRetryFailureBody": @"자동 재시도도 실패했습니다. NAS 연결을 확인한 뒤 백업을 다시 실행하세요.", @"unknownExternalVolumeTitle": @"새 외장 디스크 감지됨", @"unknownExternalVolumeBody": @"‘%@’은(는) 사용되지 않습니다. 백업을 시작하지 않았습니다.", @@ -1308,6 +1352,11 @@ @"automaticBackupsPaused": @"자동 백업 일시 정지됨", @"pauseAutomaticBackups": @"자동 백업 일시 정지", @"resumeAutomaticBackups": @"자동 백업 재개", + @"automaticRetryRunning": @"자동 재시도 실행 중", + @"automaticRetryRunningShort": @"재시도 중", + @"backupProgressCurrentPhase": @"현재 영역 진행률", + @"progressAreaFormat": @"영역 %1$@/%2$@", + @"progressPreparing": @"준비 중…", @"profileLabel": @"프로필", @"profileDefault": @"기본값", @"profileCreate": @"새 프로필", diff --git a/macos/GDriveBackupTiger/NetworkMountSupport.h b/macos/GDriveBackupTiger/NetworkMountSupport.h new file mode 100644 index 0000000..116cb0c --- /dev/null +++ b/macos/GDriveBackupTiger/NetworkMountSupport.h @@ -0,0 +1,35 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NSData * _Nullable (^GDTNASCredentialLookup)( + NSString *host, + NSString *account, + NSString *path, + BOOL allowUserInteraction); +typedef int (^GDTNASMountOperation)( + NSURL *url, + NSString *account, + NSData *passwordData); +typedef int (^GDTNetworkMountCLIHandler)( + NSString *urlString, + BOOL authorizeCredential); +typedef int (^GDTLegacyKeychainInteractionSetter)(BOOL allowInteraction); + +FOUNDATION_EXPORT int GDTHandleSMBURLWithHandlers( + NSString *urlString, + BOOL allowCredentialUI, + BOOL performMount, + GDTNASCredentialLookup credentialLookup, + GDTNASMountOperation _Nullable mountOperation); +FOUNDATION_EXPORT int GDTAuthorizeSMBCredentialForURL(NSString *urlString); +FOUNDATION_EXPORT int GDTMountSMBURLFromKeychain(NSString *urlString); +FOUNDATION_EXPORT int GDTHandleNetworkMountCLIArguments( + NSArray *arguments, + GDTNetworkMountCLIHandler handler, + BOOL * _Nullable handled); +FOUNDATION_EXPORT BOOL GDTConfigureLegacyKeychainInteraction( + BOOL allowInteraction, + GDTLegacyKeychainInteractionSetter setter); + +NS_ASSUME_NONNULL_END diff --git a/macos/GDriveBackupTiger/NetworkMountSupport.m b/macos/GDriveBackupTiger/NetworkMountSupport.m new file mode 100644 index 0000000..c461b75 --- /dev/null +++ b/macos/GDriveBackupTiger/NetworkMountSupport.m @@ -0,0 +1,224 @@ +#import "NetworkMountSupport.h" + +#import +#import +#include +#include + +static void GDTZeroCredentialData(NSMutableData *data) { + if (data.length > 0) { + (void)memset_s(data.mutableBytes, data.length, 0, data.length); + } +} + +BOOL GDTConfigureLegacyKeychainInteraction( + BOOL allowInteraction, + GDTLegacyKeychainInteractionSetter setter) { + return setter && setter(allowInteraction) == errSecSuccess; +} + +int GDTHandleSMBURLWithHandlers( + NSString *urlString, + BOOL allowCredentialUI, + BOOL performMount, + GDTNASCredentialLookup credentialLookup, + GDTNASMountOperation mountOperation) { + NSURLComponents *components = [NSURLComponents componentsWithString:urlString ?: @""]; + NSString *scheme = components.scheme.lowercaseString; + NSString *host = components.host; + NSString *account = components.user ?: @""; + NSString *path = components.path; + BOOL hasEmptyUserInfo = components.percentEncodedUser != nil && !account.length; + BOOL hasNoShare = path.length <= 1 || [path characterAtIndex:1] == '/'; + if (![scheme isEqualToString:@"smb"] || !host.length || hasEmptyUserInfo || + hasNoShare || components.password != nil || + (account.length && !credentialLookup) || + (performMount && !mountOperation)) { + return 64; + } + + if (!account.length) { + // An absent user means an explicit guest profile. It must bypass the + // Keychain entirely so neither authorization nor an automatic retry + // can provoke credential UI. + if (!performMount) { + return 0; + } + int result = mountOperation(components.URL, @"", [NSMutableData data]); + return result == 0 ? 0 : 69; + } + + NSData *credential = credentialLookup( + host, account, path, allowCredentialUI); + if (!credential.length) { + return 69; + } + + NSMutableData *workingCredential = + [credential isKindOfClass:NSMutableData.class] + ? (NSMutableData *)credential + : [credential mutableCopy]; + int result = 0; + if (performMount) { + result = mountOperation(components.URL, account, workingCredential); + } + GDTZeroCredentialData(workingCredential); + return result == 0 ? 0 : 69; +} + +static NSData *GDTLookupSMBPassword( + NSString *host, + NSString *account, + NSString *path, + BOOL allowUserInteraction) { + BOOL interactionConfigured = GDTConfigureLegacyKeychainInteraction( + allowUserInteraction, + ^int(BOOL allowInteraction) { + // This legacy API is intentionally paired with Finder's legacy + // Internet-password items. Modern LAContext flags do not suppress + // their ACL prompt on macOS. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return SecKeychainSetUserInteractionAllowed(allowInteraction); +#pragma clang diagnostic pop + }); + if (!interactionConfigured) { + return nil; + } + + NSArray *candidatePaths = [path hasPrefix:@"/"] && path.length > 1 + ? @[path, [path substringFromIndex:1]] + : @[path]; + + for (NSString *candidatePath in candidatePaths) { + NSMutableDictionary *query = [@{ + (__bridge id)kSecClass: (__bridge id)kSecClassInternetPassword, + (__bridge id)kSecAttrServer: host, + (__bridge id)kSecAttrAccount: account, + (__bridge id)kSecAttrPath: candidatePath, + (__bridge id)kSecAttrProtocol: (__bridge id)kSecAttrProtocolSMB, + (__bridge id)kSecMatchLimit: (__bridge id)kSecMatchLimitOne, + (__bridge id)kSecReturnData: @YES + } mutableCopy]; + if (!allowUserInteraction) { + // LAContext.interactionNotAllowed applies only to Data Protection + // items on macOS. Finder's SMB passwords use the legacy keychain, + // where UISkip is the only query option that cannot spawn a prompt. + query[(__bridge id)kSecUseAuthenticationUI] = + (__bridge id)kSecUseAuthenticationUISkip; + } + + CFTypeRef result = NULL; + OSStatus status = SecItemCopyMatching( + (__bridge CFDictionaryRef)query, &result); + if (status == errSecSuccess && result) { + return CFBridgingRelease(result); + } + if (result) { + CFRelease(result); + } + if (status != errSecItemNotFound) { + return nil; + } + } + return nil; +} + +static int GDTNetFSMount( + NSURL *url, + NSString *account, + NSData *passwordData) { + BOOL useGuest = !account.length; + CFStringRef password = NULL; + if (!useGuest) { + password = CFStringCreateWithBytesNoCopy( + kCFAllocatorDefault, + passwordData.bytes, + passwordData.length, + kCFStringEncodingUTF8, + false, + kCFAllocatorNull); + if (!password) { + return EINVAL; + } + } + + NSMutableDictionary *openOptions = [@{ + (__bridge id)kNAUIOptionKey: (__bridge id)kNAUIOptionNoUI + } mutableCopy]; + if (useGuest) { + openOptions[(__bridge id)kNetFSUseGuestKey] = @YES; + } + CFArrayRef mountpoints = NULL; + int result = NetFSMountURLSync( + (__bridge CFURLRef)url, + NULL, + useGuest ? NULL : (__bridge CFStringRef)account, + password, + (__bridge CFMutableDictionaryRef)openOptions, + NULL, + &mountpoints); + if (mountpoints) { + CFRelease(mountpoints); + } + if (password) { + CFRelease(password); + } + return result; +} + +int GDTAuthorizeSMBCredentialForURL(NSString *urlString) { + return GDTHandleSMBURLWithHandlers( + urlString, + YES, + NO, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + return GDTLookupSMBPassword( + host, account, path, allowUserInteraction); + }, + nil); +} + +int GDTMountSMBURLFromKeychain(NSString *urlString) { + return GDTHandleSMBURLWithHandlers( + urlString, + NO, + YES, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + return GDTLookupSMBPassword( + host, account, path, allowUserInteraction); + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + return GDTNetFSMount(url, account, passwordData); + }); +} + +int GDTHandleNetworkMountCLIArguments( + NSArray *arguments, + GDTNetworkMountCLIHandler handler, + BOOL *handled) { + if (handled) { + *handled = NO; + } + if (arguments.count < 2) { + return 0; + } + + NSString *command = arguments[1]; + BOOL authorizeCredential = + [command isEqualToString:@"--authorize-network-url"]; + BOOL mountNetworkURL = + [command isEqualToString:@"--mount-network-url"]; + if (!authorizeCredential && !mountNetworkURL) { + return 0; + } + if (handled) { + *handled = YES; + } + if (arguments.count != 3 || ![arguments[2] length] || !handler) { + return 64; + } + return handler(arguments[2], authorizeCredential); +} diff --git a/macos/GDriveBackupTiger/NotificationSupport.h b/macos/GDriveBackupTiger/NotificationSupport.h index 8ccde20..745a53a 100644 --- a/macos/GDriveBackupTiger/NotificationSupport.h +++ b/macos/GDriveBackupTiger/NotificationSupport.h @@ -15,6 +15,11 @@ NS_ASSUME_NONNULL_BEGIN candidateIdentifiers: (NSArray *)candidateIdentifiers; ++ (NSArray *)failureNotificationIdentifiersForProfileID:(NSString *)profileID + throughIssueOriginTimestamp:(NSTimeInterval)cutoff + candidateNotifications: + (NSArray *> *)candidates; + @end @interface GDTAutomaticRetryPolicy : NSObject diff --git a/macos/GDriveBackupTiger/NotificationSupport.m b/macos/GDriveBackupTiger/NotificationSupport.m index 3e6a4ba..75850d0 100644 --- a/macos/GDriveBackupTiger/NotificationSupport.m +++ b/macos/GDriveBackupTiger/NotificationSupport.m @@ -21,6 +21,14 @@ static NSTimeInterval GDTTimestamp(NSString *value) { return (NSTimeInterval)timestamp; } +static NSTimeInterval GDTCanonicalTimestamp(id value) { + if (![value isKindOfClass:NSString.class]) return 0; + NSTimeInterval timestamp = GDTTimestamp(value); + if (timestamp <= 0) return 0; + NSString *canonical = [NSString stringWithFormat:@"%.0f", timestamp]; + return [(NSString *)value isEqualToString:canonical] ? timestamp : 0; +} + static NSString *GDTSafeRetryProfileID(NSString *value) { return GDTSafeNotificationProfileID(value); } @@ -62,7 +70,7 @@ @implementation GDTBackupNotificationPolicy NSArray *parts = [suffix componentsSeparatedByString:@"."]; if (parts.count != 2 || ![@[@"failure", @"missed"] containsObject:parts[0]] || - GDTTimestamp(parts[1]) <= 0 || + GDTCanonicalTimestamp(parts[1]) <= 0 || [accepted containsObject:candidate]) { continue; } @@ -71,6 +79,57 @@ @implementation GDTBackupNotificationPolicy return accepted; } ++ (NSArray *)failureNotificationIdentifiersForProfileID:(NSString *)profileID + throughIssueOriginTimestamp:(NSTimeInterval)cutoff + candidateNotifications: + (NSArray *> *)candidates { + if (cutoff <= 0) return @[]; + NSString *safeProfileID = GDTSafeNotificationProfileID(profileID); + NSMutableArray *accepted = [NSMutableArray array]; + for (id value in candidates ?: @[]) { + if (![value isKindOfClass:NSDictionary.class]) continue; + NSDictionary *candidate = value; + NSString *identifier = [candidate[@"identifier"] isKindOfClass:NSString.class] + ? candidate[@"identifier"] : @""; + NSArray *safeIdentifiers = + [self failureNotificationIdentifiersForProfileID:safeProfileID + candidateIdentifiers:identifier.length + ? @[identifier] : @[]]; + if (safeIdentifiers.count != 1) continue; + + NSArray *identifierParts = + [identifier componentsSeparatedByString:@"."]; + NSTimeInterval issueOrigin = + GDTCanonicalTimestamp(identifierParts.lastObject); + BOOL hasNotificationMetadata = + candidate[@"categoryIdentifier"] != nil || candidate[@"userInfo"] != nil; + if (hasNotificationMetadata) { + NSString *category = + [candidate[@"categoryIdentifier"] isKindOfClass:NSString.class] + ? candidate[@"categoryIdentifier"] : @""; + NSDictionary *userInfo = [candidate[@"userInfo"] + isKindOfClass:NSDictionary.class] ? candidate[@"userInfo"] : nil; + NSString *metadataProfileID = + [userInfo[@"profileID"] isKindOfClass:NSString.class] + ? userInfo[@"profileID"] : @""; + NSTimeInterval metadataOrigin = + GDTCanonicalTimestamp(userInfo[@"issueOriginTimestamp"]); + // A delivered request with partial or conflicting metadata is not + // legacy. Falling back to its identifier could retire another issue. + if (![category isEqualToString:@"GDT_BACKUP_ALERT"] || + ![metadataProfileID isEqualToString:safeProfileID] || + metadataOrigin <= 0) { + continue; + } + issueOrigin = metadataOrigin; + } + if (issueOrigin <= cutoff && ![accepted containsObject:identifier]) { + [accepted addObject:identifier]; + } + } + return accepted; +} + + (NSDictionary * _Nullable) decisionForConfig:(NSDictionary *)config summary:(NSDictionary *)summary @@ -92,9 +151,42 @@ @implementation GDTBackupNotificationPolicy NSTimeInterval eventTimestamp = GDTTimestamp(summary[@"finished_at"]); if (eventTimestamp <= 0) eventTimestamp = GDTTimestamp(summary[@"started_at"]); NSString *trigger = summary[@"trigger"] ?: @""; + BOOL retryRunning = [status isEqualToString:@"running"] && + [trigger isEqualToString:@"schedule-retry"]; + NSTimeInterval retryOrigin = GDTTimestamp(summary[@"retry_origin_started_at"]); + NSTimeInterval retryStarted = GDTTimestamp(summary[@"started_at"]); + if (retryRunning && retryOrigin > 0 && retryStarted > retryOrigin && + [summary[@"status"] isEqualToString:@"running"] && + [summary[@"retry_attempt"] isEqualToString:@"1"]) { + NSString *origin = [NSString stringWithFormat:@"%.0f", retryOrigin]; + return @{ + @"identifier": [NSString stringWithFormat: + @"com.commcats.gdrivebackup.%@.failure.%@", profileID, origin], + @"revision": [NSString stringWithFormat:@"retry-running.%.0f", retryStarted], + @"kind": @"retry-running", + @"profileID": profileID, + @"issueTimestamp": origin, + @"issueOriginTimestamp": origin, + @"titleKey": @"backupNotificationRetryRunningTitle", + @"bodyKey": @"backupNotificationRetryRunningBody" + }; + } + BOOL retryFailure = [trigger isEqualToString:@"schedule-retry"]; BOOL scheduledFailure = [@[@"schedule", @"schedule-retry"] containsObject:trigger] && ([@[@"failure", @"interrupted", @"cancelled"] containsObject:status]); + NSTimeInterval retryFinished = GDTTimestamp(summary[@"finished_at"]); + BOOL interruptedRunningRetry = [status isEqualToString:@"interrupted"] && + [summary[@"status"] isEqualToString:@"running"] && + ![summary[@"finished_at"] length] && ![summary[@"exit_code"] length]; + if (retryFailure && + ((![summary[@"status"] isEqualToString:status] && + !interruptedRunningRetry) || + ![summary[@"retry_attempt"] isEqualToString:@"1"] || + retryOrigin <= 0 || retryStarted <= retryOrigin || + (!interruptedRunningRetry && retryFinished < retryStarted))) { + return nil; + } BOOL eventIsFresh = eventTimestamp > 0 && eventTimestamp <= nowTimestamp + 1 && nowTimestamp - eventTimestamp <= 24 * 60 * 60; BOOL eventWasMonitored = monitorStartedAt <= 0 || eventTimestamp >= monitorStartedAt; @@ -114,15 +206,29 @@ @implementation GDTBackupNotificationPolicy } NSTimeInterval runTimestamp = GDTTimestamp(summary[@"started_at"]); if (runTimestamp <= 0) runTimestamp = eventTimestamp; - return @{ + NSTimeInterval retryOriginTimestamp = + GDTTimestamp(summary[@"retry_origin_started_at"]); + NSTimeInterval issueOriginTimestamp = + retryFailure && retryOriginTimestamp > 0 + ? retryOriginTimestamp : runTimestamp; + NSMutableDictionary *decision = [@{ @"identifier": [NSString stringWithFormat: @"com.commcats.gdrivebackup.%@.failure.%.0f", profileID, runTimestamp], @"kind": @"failure", @"profileID": profileID, @"issueTimestamp": [NSString stringWithFormat:@"%.0f", eventTimestamp], + @"issueOriginTimestamp": + [NSString stringWithFormat:@"%.0f", issueOriginTimestamp], @"titleKey": @"backupNotificationFailureTitle", @"bodyKey": bodyKey - }; + } mutableCopy]; + if (retryFailure && retryOriginTimestamp > 0 && + retryOriginTimestamp < runTimestamp) { + decision[@"supersedesIdentifier"] = [NSString stringWithFormat: + @"com.commcats.gdrivebackup.%@.failure.%.0f", + profileID, retryOriginTimestamp]; + } + return decision; } if (![schedule isEqualToString:@"daily"]) return nil; @@ -150,6 +256,7 @@ @implementation GDTBackupNotificationPolicy @"kind": @"missed", @"profileID": profileID, @"issueTimestamp": [NSString stringWithFormat:@"%.0f", dueTimestamp], + @"issueOriginTimestamp": [NSString stringWithFormat:@"%.0f", dueTimestamp], @"titleKey": @"backupNotificationMissedTitle", @"bodyKey": @"backupNotificationMissedBody" }; diff --git a/macos/GDriveBackupTiger/main.m b/macos/GDriveBackupTiger/main.m index 2132bb5..20396e7 100644 --- a/macos/GDriveBackupTiger/main.m +++ b/macos/GDriveBackupTiger/main.m @@ -8,6 +8,7 @@ #import "ConfigSupport.h" #import "ProfileSupport.h" #import "BackupStatusSupport.h" +#import "BackupProgressSupport.h" #import "NotificationSupport.h" #import "SetupHealthSupport.h" #import "RestoreSupport.h" @@ -16,6 +17,7 @@ #import "DiagnosticsView.h" #import "UpdateSupport.h" #import "Localization.h" +#import "NetworkMountSupport.h" static NSImage *CreateApplicationIcon(void) { NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(128, 128)]; @@ -155,19 +157,14 @@ NSString *source = [line substringToIndex:onRange.location]; NSString *path = [line substringWithRange:NSMakeRange(onRange.location + 4, typeRange.location - (onRange.location + 4))]; NSString *name = path.lastPathComponent; - NSString *url = @""; - if ([source hasPrefix:@"//"]) { - NSString *withoutSlashes = [source substringFromIndex:2]; - NSRange atRange = [withoutSlashes rangeOfString:@"@" options:NSBackwardsSearch]; - if (atRange.location != NSNotFound) { - withoutSlashes = [withoutSlashes substringFromIndex:atRange.location + 1]; - } - url = [@"smb://" stringByAppendingString:withoutSlashes]; - } - NSMutableDictionary *volume = byPath[path]; + NSString *url = GDTPreferredNASRemountURL( + volume[@"url"] ?: @"", + source, + [line containsString:@" (smbfs,"]); + if (volume) { - if (url.length && !volume[@"url"].length) { + if (url.length) { volume[@"url"] = url; } continue; @@ -1138,6 +1135,10 @@ @interface TigerOverviewView : NSView @property(nonatomic, copy) NSString *nextRunText; @property(nonatomic, copy) NSString *targetText; @property(nonatomic, copy) NSString *storageText; +@property(nonatomic) BOOL progressVisible; +@property(nonatomic) CGFloat progressPercent; +@property(nonatomic, copy) NSString *progressSummary; +@property(nonatomic, copy) NSString *progressDetail; @property(nonatomic, copy) void (^backupHandler)(void); @property(nonatomic, copy) void (^settingsHandler)(void); @property(nonatomic, copy) void (^restoreHandler)(void); @@ -1152,6 +1153,11 @@ @interface TigerOverviewView : NSView @property(nonatomic, strong) NSTextField *targetValueLabel; @property(nonatomic, strong) NSTextField *storageCaptionLabel; @property(nonatomic, strong) NSTextField *storageValueLabel; +@property(nonatomic, strong) NSProgressIndicator *progressIndicator; +@property(nonatomic, strong) NSTextField *progressSummaryLabel; +@property(nonatomic, strong) NSTextField *progressPhaseLabel; +@property(nonatomic, strong) NSTextField *progressPercentLabel; +@property(nonatomic, strong) NSTextField *progressDetailLabel; @property(nonatomic, strong) NSButton *backupButton; @property(nonatomic, strong) NSButton *settingsButton; @property(nonatomic, strong) NSButton *restoreButton; @@ -1219,6 +1225,27 @@ - (instancetype)initWithFrame:(NSRect)frameRect { font:valueFont color:muted]; [self addSubview:self.lastRunDetailLabel]; + self.progressSummaryLabel = [self overviewLabelWithFrame:NSMakeRect(116, 174, 440, 18) + font:captionFont color:ink]; + [self addSubview:self.progressSummaryLabel]; + self.progressPhaseLabel = [self overviewLabelWithFrame:NSMakeRect(116, 194, 110, 18) + font:valueFont color:muted]; + [self addSubview:self.progressPhaseLabel]; + self.progressIndicator = [[NSProgressIndicator alloc] + initWithFrame:NSMakeRect(232, 196, 200, 14)]; + self.progressIndicator.style = NSProgressIndicatorStyleBar; + self.progressIndicator.minValue = 0; + self.progressIndicator.maxValue = 100; + self.progressIndicator.accessibilityRole = NSAccessibilityProgressIndicatorRole; + [self addSubview:self.progressIndicator]; + self.progressPercentLabel = [self overviewLabelWithFrame:NSMakeRect(440, 193, 52, 18) + font:valueFont color:ink]; + [self addSubview:self.progressPercentLabel]; + self.progressDetailLabel = [self overviewLabelWithFrame:NSMakeRect(116, 216, 440, 18) + font:valueFont color:muted]; + self.progressDetailLabel.lineBreakMode = NSLineBreakByTruncatingTail; + [self addSubview:self.progressDetailLabel]; + self.nextRunCaptionLabel = [self overviewLabelWithFrame:NSMakeRect(48, 232, 132, 19) font:captionFont color:muted]; [self addSubview:self.nextRunCaptionLabel]; @@ -1266,6 +1293,10 @@ - (instancetype)initWithFrame:(NSRect)frameRect { self.backupButton.nextKeyView = self.settingsButton; self.language = @"en"; + self.progressVisible = NO; + self.progressPercent = -1.0; + self.progressSummary = @""; + self.progressDetail = @""; self.status = @"unknown"; return self; } @@ -1284,8 +1315,10 @@ - (void)setLanguage:(NSString *)language { self.settingsButton.accessibilityLabel = self.settingsButton.title; self.restoreButton.accessibilityLabel = self.restoreButton.title; self.backupButton.accessibilityLabel = self.backupButton.title; + self.progressIndicator.accessibilityLabel = T(_language, @"backupProgressCurrentPhase"); [self layoutActionButtons]; [self updateValueAccessibilityLabels]; + [self updateProgressPresentation]; [self setStatus:self.status ?: @"unknown"]; } @@ -1319,6 +1352,7 @@ - (void)setStatus:(NSString *)status { self.statusSymbolLabel.stringValue = presentation[0]; self.statusSymbolLabel.textColor = presentation[1]; self.statusSymbolLabel.accessibilityLabel = T(self.language ?: @"en", presentation[2]); + [self updateProgressPresentation]; } - (void)setLastRunText:(NSString *)lastRunText { @@ -1351,6 +1385,53 @@ - (void)setStorageText:(NSString *)storageText { [self updateValueAccessibilityLabels]; } +- (void)setProgressVisible:(BOOL)progressVisible { + _progressVisible = progressVisible; + [self updateProgressPresentation]; +} + +- (void)setProgressPercent:(CGFloat)progressPercent { + _progressPercent = progressPercent; + [self updateProgressPresentation]; +} + +- (void)setProgressSummary:(NSString *)progressSummary { + _progressSummary = [progressSummary copy] ?: @""; + self.progressSummaryLabel.stringValue = _progressSummary; + self.progressSummaryLabel.accessibilityLabel = _progressSummary; +} + +- (void)setProgressDetail:(NSString *)progressDetail { + _progressDetail = [progressDetail copy] ?: @""; + self.progressDetailLabel.stringValue = _progressDetail; + self.progressDetailLabel.accessibilityLabel = _progressDetail; +} + +- (void)updateProgressPresentation { + BOOL visible = self.progressVisible && [self.status isEqualToString:@"running"]; + for (NSView *progressView in @[ + self.progressSummaryLabel, self.progressPhaseLabel, self.progressIndicator, + self.progressPercentLabel, self.progressDetailLabel + ]) { + progressView.hidden = !visible; + } + if (!visible) { + [self.progressIndicator stopAnimation:nil]; + return; + } + BOOL indeterminate = self.progressPercent < 0.0; + self.progressIndicator.indeterminate = indeterminate; + if (indeterminate) { + self.progressPercentLabel.stringValue = @""; + [self.progressIndicator startAnimation:nil]; + } else { + [self.progressIndicator stopAnimation:nil]; + CGFloat percent = MAX(0.0, MIN(100.0, self.progressPercent)); + self.progressIndicator.doubleValue = percent; + self.progressPercentLabel.stringValue = [NSString stringWithFormat:@"%.0f %%", percent]; + } +} + - (void)updateValueAccessibilityLabels { NSString *language = self.language ?: @"en"; self.lastRunValueLabel.accessibilityLabel = [NSString stringWithFormat:@"%@: %@", @@ -1501,7 +1582,21 @@ @interface AppDelegate : NSObject *pendingBackupNotificationIdentifiers; +@property(nonatomic, strong) NSMutableDictionary * + pendingBackupNotificationKeysByProfileID; +@property(nonatomic, strong) NSMutableDictionary *> * + inFlightBackupNotificationDecisionsByProfileID; +@property(nonatomic, strong) NSMutableDictionary *> * + queuedBackupNotificationDecisionsByProfileID; +@property(nonatomic, strong) NSMutableDictionary * + backupNotificationGenerationsByProfileID; - (BOOL)launchAutomaticRetryDecision:(NSDictionary *)decision; +- (void)removeExactBackupNotificationIdentifiers:(NSArray *)identifiers + forProfileID:(NSString *)profileID; +- (void)removeDeliveredBackupNotificationIdentifiers:(NSArray *)identifiers; +- (void)removePendingBackupNotificationIdentifiers:(NSArray *)identifiers; +- (void)retireSupersededBackupNotificationForDecision: + (NSDictionary *)decision; - (void)requestCancelBackup:(id)sender; - (BOOL)cancelRunningBackup; - (void)completeMountedNetworkVolumeDiscovery: @@ -1584,6 +1679,138 @@ - (void)completeMountedNetworkVolumeDiscovery: return parts.count ? [parts componentsJoinedByString:@" · "] : T(language, @"overviewUnavailable"); } +static NSString *GDTLocalizedProgressPhase(NSString *phase, NSString *language) { + NSArray *parts = [phase componentsSeparatedByString:@"/"]; + if (parts.count != 2 || ![parts[0] length] || ![parts[1] length]) { + return @""; + } + return [NSString stringWithFormat:T(language, @"progressAreaFormat"), + parts[0], parts[1]]; +} + +static NSTimeInterval GDTBackupNotificationTimestamp(id value) { + if (![value isKindOfClass:NSString.class] || ![value length]) return 0; + NSScanner *scanner = [NSScanner scannerWithString:value]; + long long timestamp = 0; + if (![scanner scanLongLong:×tamp] || !scanner.isAtEnd || timestamp <= 0) { + return 0; + } + return (NSTimeInterval)timestamp; +} + +static NSUInteger GDTBackupNotificationDecisionStage( + NSDictionary *decision) { + if (decision[@"supersedesIdentifier"].length) return 3; + if ([decision[@"kind"] isEqualToString:@"retry-running"]) return 2; + return 1; +} + +static BOOL GDTBackupNotificationDecisionCanReplaceQueued( + NSDictionary *candidate, + NSDictionary *queued) { + NSTimeInterval candidateOrigin = + GDTBackupNotificationTimestamp(candidate[@"issueOriginTimestamp"]); + NSTimeInterval queuedOrigin = + GDTBackupNotificationTimestamp(queued[@"issueOriginTimestamp"]); + if (candidateOrigin != queuedOrigin) return candidateOrigin > queuedOrigin; + + // A delayed refresh may observe retry-running after the terminal result. + // Preserve the furthest known lifecycle stage for one canonical origin. + return GDTBackupNotificationDecisionStage(candidate) >= + GDTBackupNotificationDecisionStage(queued); +} + +static BOOL GDTBackupNotificationAcceptedState( + NSUserDefaults *defaults, NSString *stateKey, NSString *originKey, + NSTimeInterval *acceptedOrigin, NSUInteger *acceptedStage) { + NSDictionary *state = [defaults dictionaryForKey:stateKey]; + NSNumber *originValue = [state[@"origin"] isKindOfClass:NSNumber.class] + ? state[@"origin"] : nil; + NSNumber *stageValue = [state[@"stage"] isKindOfClass:NSNumber.class] + ? state[@"stage"] : nil; + NSTimeInterval origin = originValue.doubleValue; + NSUInteger stage = stageValue.unsignedIntegerValue; + BOOL hasAuthoritativeState = origin > 0 && stage >= 1 && stage <= 3; + if (!hasAuthoritativeState) { + NSTimeInterval legacyOrigin = [defaults doubleForKey:originKey]; + origin = MAX(origin > 0 ? origin : 0, legacyOrigin); + // Without an atomic pair, a stage can belong to an older origin. + // Its identifier and revision must establish the stage instead. + stage = 0; + } + *acceptedOrigin = origin; + *acceptedStage = stage; + return hasAuthoritativeState; +} + +static void GDTPersistBackupNotificationAcceptedState( + NSUserDefaults *defaults, NSString *stateKey, NSString *originKey, + NSString *stageKey, NSTimeInterval origin, NSUInteger stage) { + [defaults setObject:@{ + @"origin": @(origin), + @"stage": @(stage) + } forKey:stateKey]; + [defaults setDouble:origin forKey:originKey]; + [defaults setInteger:(NSInteger)stage forKey:stageKey]; +} + +static NSUInteger GDTBackupNotificationLegacyAcceptedStage( + NSUserDefaults *defaults, NSString *profileID, NSString *identifierKey, + NSString *revisionKey, NSTimeInterval acceptedOrigin) { + NSString *identifier = [defaults stringForKey:identifierKey]; + NSArray *safeIdentifiers = [GDTBackupNotificationPolicy + failureNotificationIdentifiersForProfileID:profileID + candidateIdentifiers:identifier.length + ? @[identifier] : @[]]; + if (safeIdentifiers.count != 1 || acceptedOrigin <= 0) return 3; + + NSString *failureSuffix = [NSString stringWithFormat: + @".failure.%.0f", acceptedOrigin]; + if ([identifier hasSuffix:failureSuffix]) { + NSString *revision = [defaults stringForKey:revisionKey]; + if (!revision.length) return 1; + NSString *runningPrefix = @"retry-running."; + NSTimeInterval runningTimestamp = [revision hasPrefix:runningPrefix] + ? GDTBackupNotificationTimestamp( + [revision substringFromIndex:runningPrefix.length]) : 0; + NSString *canonicalRevision = [NSString stringWithFormat: + @"retry-running.%.0f", runningTimestamp]; + if (runningTimestamp > acceptedOrigin && + [revision isEqualToString:canonicalRevision]) { + return 2; + } + return 3; + } + + NSString *missedSuffix = [NSString stringWithFormat: + @".missed.%.0f", acceptedOrigin]; + if ([identifier hasSuffix:missedSuffix] && + ![defaults stringForKey:revisionKey].length) { + return 1; + } + return 3; +} + +static void GDTAdvanceBackupNotificationAcceptedState( + NSUserDefaults *defaults, NSString *stateKey, NSString *originKey, + NSString *stageKey, NSTimeInterval candidateOrigin, + NSUInteger candidateStage) { + NSTimeInterval acceptedOrigin = 0; + NSUInteger acceptedStage = 0; + GDTBackupNotificationAcceptedState( + defaults, stateKey, originKey, &acceptedOrigin, &acceptedStage); + BOOL advances = candidateOrigin > acceptedOrigin || + (candidateOrigin == acceptedOrigin && candidateStage > acceptedStage); + BOOL matches = candidateOrigin == acceptedOrigin && + candidateStage == acceptedStage; + if (!advances && !matches) return; + + NSTimeInterval nextOrigin = advances ? candidateOrigin : acceptedOrigin; + NSUInteger nextStage = advances ? candidateStage : acceptedStage; + GDTPersistBackupNotificationAcceptedState( + defaults, stateKey, originKey, stageKey, nextOrigin, nextStage); +} + @implementation AppDelegate - (NSUserDefaults *)backupNotificationDefaultsStore { @@ -1629,7 +1856,7 @@ - (NSString *)backupNotificationDefaultsKeyForProfileID:(NSString *)profileID su categoryWithIdentifier:@"GDT_BACKUP_ALERT" actions:@[openAction] intentIdentifiers:@[] - options:UNNotificationCategoryOptionNone]; + options:UNNotificationCategoryOptionCustomDismissAction]; UNNotificationAction *setupExternalVolume = [UNNotificationAction actionWithIdentifier:@"GDT_UNKNOWN_EXTERNAL_VOLUME_SETUP" @@ -1818,6 +2045,10 @@ - (UNMutableNotificationContent *)backupNotificationContentForDecision: content.body = T(self.language ?: @"en", decision[@"bodyKey"]); content.sound = UNNotificationSound.defaultSound; content.categoryIdentifier = @"GDT_BACKUP_ALERT"; + content.userInfo = @{ + @"profileID": decision[@"profileID"] ?: @"", + @"issueOriginTimestamp": decision[@"issueOriginTimestamp"] ?: @"" + }; if (@available(macOS 12.0, *)) { // A protected level without its signed entitlement can make delivery // fail. Ad-hoc builds keep the durable alert at the normal active level. @@ -1988,47 +2219,212 @@ - (void)deliverBackupNotificationDecision:(NSDictionary } - (void)processBackupNotificationDecision:(NSDictionary *)decision { - NSString *identifier = decision[@"identifier"]; - NSString *profileID = decision[@"profileID"]; - if (!identifier.length || !profileID.length || !decision[@"titleKey"].length || - !decision[@"bodyKey"].length) { + NSDictionary *deliveryDecision = [decision copy]; + NSString *identifier = deliveryDecision[@"identifier"]; + NSString *profileID = deliveryDecision[@"profileID"]; + NSTimeInterval issueOriginTimestamp = + GDTBackupNotificationTimestamp(deliveryDecision[@"issueOriginTimestamp"]); + if (!identifier.length || !profileID.length || + !deliveryDecision[@"titleKey"].length || + !deliveryDecision[@"bodyKey"].length || issueOriginTimestamp <= 0 || + [GDTBackupNotificationPolicy + failureNotificationIdentifiersForProfileID:profileID + candidateIdentifiers:@[identifier]].count != 1) { return; } - NSString *key = [self backupNotificationDefaultsKeyForProfileID:profileID - suffix:@"lastDeliveredIdentifier"]; + NSString *identifierKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"lastDeliveredIdentifier"]; + NSString *revisionKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"lastDeliveredRevision"]; NSString *deliveredKey = [self backupNotificationDefaultsKeyForProfileID:profileID suffix:@"deliveredFailureIdentifiers"]; + NSString *dismissedKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"dismissedIssueAt"]; + NSString *acceptedOriginKey = [self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"latestDeliveredIssueAt"]; + NSString *acceptedStageKey = [self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"latestDeliveredIssueStage"]; + NSString *acceptedStateKey = [self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"latestDeliveredIssueState"]; NSUserDefaults *defaults = [self backupNotificationDefaultsStore]; - if ([[defaults stringForKey:key] isEqualToString:identifier]) return; + if (issueOriginTimestamp <= [defaults doubleForKey:dismissedKey]) { + return; + } + NSUInteger decisionStage = + GDTBackupNotificationDecisionStage(deliveryDecision); + NSTimeInterval acceptedOriginTimestamp = 0; + NSUInteger acceptedStage = 0; + BOOL hasAuthoritativeAcceptedState = GDTBackupNotificationAcceptedState( + defaults, acceptedStateKey, acceptedOriginKey, + &acceptedOriginTimestamp, &acceptedStage); + if (!hasAuthoritativeAcceptedState && acceptedOriginTimestamp > 0) { + // Legacy fields distinguish an in-place preliminary/running update + // from a terminal replacement. Ambiguous partial state stays terminal. + NSUInteger legacyStage = GDTBackupNotificationLegacyAcceptedStage( + defaults, profileID, identifierKey, revisionKey, + acceptedOriginTimestamp); + acceptedStage = legacyStage; + GDTPersistBackupNotificationAcceptedState( + defaults, acceptedStateKey, acceptedOriginKey, acceptedStageKey, + acceptedOriginTimestamp, acceptedStage); + } + if (acceptedOriginTimestamp > 0 && + (issueOriginTimestamp < acceptedOriginTimestamp || + (issueOriginTimestamp == acceptedOriginTimestamp && + decisionStage < acceptedStage))) { + return; + } + + NSString *revision = [deliveryDecision[@"revision"] isKindOfClass:NSString.class] + ? deliveryDecision[@"revision"] : @""; + BOOL sameIdentifier = + [[defaults stringForKey:identifierKey] isEqualToString:identifier]; + BOOL alreadyDelivered = revision.length + ? sameIdentifier && + [[defaults stringForKey:revisionKey] isEqualToString:revision] + : sameIdentifier; + if (alreadyDelivered) { + // Replay repairs a lifecycle watermark if acceptance was interrupted + // after its identifier was saved but before the stage was advanced. + GDTAdvanceBackupNotificationAcceptedState( + defaults, acceptedStateKey, acceptedOriginKey, acceptedStageKey, + issueOriginTimestamp, decisionStage); + [self retireSupersededBackupNotificationForDecision:deliveryDecision]; + return; + } if (!self.pendingBackupNotificationIdentifiers) { self.pendingBackupNotificationIdentifiers = [NSMutableSet set]; } - if ([self.pendingBackupNotificationIdentifiers containsObject:identifier]) return; - [self.pendingBackupNotificationIdentifiers addObject:identifier]; + NSString *pendingKey = [NSString stringWithFormat:@"%@\n%@", identifier, revision]; + if ([self.pendingBackupNotificationIdentifiers containsObject:pendingKey]) return; + + if (!self.pendingBackupNotificationKeysByProfileID) { + self.pendingBackupNotificationKeysByProfileID = [NSMutableDictionary dictionary]; + } + NSString *inFlightKey = + self.pendingBackupNotificationKeysByProfileID[profileID]; + if (inFlightKey.length) { + NSDictionary *inFlightDecision = + self.inFlightBackupNotificationDecisionsByProfileID[profileID]; + if (inFlightDecision && + !GDTBackupNotificationDecisionCanReplaceQueued( + deliveryDecision, inFlightDecision)) { + return; + } + if (!self.queuedBackupNotificationDecisionsByProfileID) { + self.queuedBackupNotificationDecisionsByProfileID = + [NSMutableDictionary dictionary]; + } + NSDictionary *previousQueued = + self.queuedBackupNotificationDecisionsByProfileID[profileID]; + if (previousQueued && + !GDTBackupNotificationDecisionCanReplaceQueued( + deliveryDecision, previousQueued)) { + return; + } + if (previousQueued) { + NSString *previousRevision = + [previousQueued[@"revision"] isKindOfClass:NSString.class] + ? previousQueued[@"revision"] : @""; + NSString *previousPendingKey = [NSString stringWithFormat:@"%@\n%@", + previousQueued[@"identifier"] ?: @"", previousRevision]; + [self.pendingBackupNotificationIdentifiers + removeObject:previousPendingKey]; + } + // Notification Center may accept same-identifier updates out of order. + // One in-flight request per profile makes visible content ordering match + // the controller's durable revision ordering. + self.queuedBackupNotificationDecisionsByProfileID[profileID] = + deliveryDecision; + [self.pendingBackupNotificationIdentifiers addObject:pendingKey]; + return; + } + + [self.pendingBackupNotificationIdentifiers addObject:pendingKey]; + self.pendingBackupNotificationKeysByProfileID[profileID] = pendingKey; + if (!self.inFlightBackupNotificationDecisionsByProfileID) { + self.inFlightBackupNotificationDecisionsByProfileID = + [NSMutableDictionary dictionary]; + } + self.inFlightBackupNotificationDecisionsByProfileID[profileID] = + deliveryDecision; + NSUInteger deliveryGeneration = + self.backupNotificationGenerationsByProfileID[profileID] + .unsignedIntegerValue; __weak typeof(self) weakSelf = self; - [self deliverBackupNotificationDecision:decision completion:^(BOOL delivered) { + [self deliverBackupNotificationDecision:deliveryDecision + completion:^(BOOL delivered) { void (^finish)(void) = ^{ typeof(self) strongSelf = weakSelf; if (!strongSelf) return; - [strongSelf.pendingBackupNotificationIdentifiers removeObject:identifier]; + [strongSelf.pendingBackupNotificationIdentifiers removeObject:pendingKey]; + if ([strongSelf.pendingBackupNotificationKeysByProfileID[profileID] + isEqualToString:pendingKey]) { + [strongSelf.pendingBackupNotificationKeysByProfileID + removeObjectForKey:profileID]; + [strongSelf.inFlightBackupNotificationDecisionsByProfileID + removeObjectForKey:profileID]; + } if (delivered) { NSUserDefaults *store = [strongSelf backupNotificationDefaultsStore]; - [store setObject:identifier forKey:key]; - NSMutableArray *identifiers = - [[store stringArrayForKey:deliveredKey] mutableCopy] ?: [NSMutableArray array]; - if (![identifiers containsObject:identifier]) { - [identifiers addObject:identifier]; - [store setObject:identifiers forKey:deliveredKey]; - } - NSTimeInterval issueTimestamp = [decision[@"issueTimestamp"] doubleValue]; - NSString *issueKey = [strongSelf - backupNotificationDefaultsKeyForProfileID:profileID - suffix:@"latestDeliveredIssueAt"]; - if (issueTimestamp > [store doubleForKey:issueKey]) { - [store setDouble:issueTimestamp forKey:issueKey]; + NSUInteger currentGeneration = + strongSelf.backupNotificationGenerationsByProfileID[profileID] + .unsignedIntegerValue; + if (currentGeneration != deliveryGeneration || + issueOriginTimestamp <= [store doubleForKey:dismissedKey]) { + [strongSelf removeExactBackupNotificationIdentifiers:@[identifier] + forProfileID:profileID]; + } else { + // The monotonic watermark goes first so a replay cannot + // deduplicate an identifier whose accepted stage was lost. + GDTAdvanceBackupNotificationAcceptedState( + store, acceptedStateKey, acceptedOriginKey, + acceptedStageKey, + issueOriginTimestamp, decisionStage); + [store setObject:identifier forKey:identifierKey]; + if (revision.length) { + [store setObject:revision forKey:revisionKey]; + } else { + [store removeObjectForKey:revisionKey]; + } + NSMutableArray *identifiers = + [[store stringArrayForKey:deliveredKey] mutableCopy] ?: + [NSMutableArray array]; + if (![identifiers containsObject:identifier]) { + [identifiers addObject:identifier]; + [store setObject:identifiers forKey:deliveredKey]; + } + [strongSelf retireSupersededBackupNotificationForDecision: + deliveryDecision]; + NSString *activeAtKey = [strongSelf + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueAt"]; + if ([store doubleForKey:activeAtKey] == issueOriginTimestamp) { + [store setObject:identifier forKey:[strongSelf + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueIdentifier"]]; + } } } + + NSDictionary *queuedDecision = + strongSelf.queuedBackupNotificationDecisionsByProfileID[profileID]; + if (queuedDecision) { + [strongSelf.queuedBackupNotificationDecisionsByProfileID + removeObjectForKey:profileID]; + NSString *queuedRevision = + [queuedDecision[@"revision"] isKindOfClass:NSString.class] + ? queuedDecision[@"revision"] : @""; + NSString *queuedKey = [NSString stringWithFormat:@"%@\n%@", + queuedDecision[@"identifier"] ?: @"", queuedRevision]; + [strongSelf.pendingBackupNotificationIdentifiers + removeObject:queuedKey]; + [strongSelf processBackupNotificationDecision:queuedDecision]; + } }; if (NSThread.isMainThread) { finish(); @@ -2038,24 +2434,113 @@ - (void)processBackupNotificationDecision:(NSDictionary }]; } +- (void)removeExactBackupNotificationIdentifiers:(NSArray *)identifiers + forProfileID:(NSString *)profileID { + NSArray *safeIdentifiers = + [GDTBackupNotificationPolicy + failureNotificationIdentifiersForProfileID:profileID + candidateIdentifiers:identifiers]; + if (!safeIdentifiers.count) return; + [self removeDeliveredBackupNotificationIdentifiers:safeIdentifiers]; + [self removePendingBackupNotificationIdentifiers:safeIdentifiers]; +} + +- (void)removeDeliveredBackupNotificationIdentifiers: + (NSArray *)identifiers { + [UNUserNotificationCenter.currentNotificationCenter + removeDeliveredNotificationsWithIdentifiers:identifiers]; +} + +- (void)removePendingBackupNotificationIdentifiers: + (NSArray *)identifiers { + [UNUserNotificationCenter.currentNotificationCenter + removePendingNotificationRequestsWithIdentifiers:identifiers]; +} + +- (void)retireSupersededBackupNotificationForDecision: + (NSDictionary *)decision { + NSString *profileID = decision[@"profileID"]; + NSString *identifier = decision[@"identifier"]; + NSString *supersededIdentifier = decision[@"supersedesIdentifier"]; + if (!profileID.length || !identifier.length || + !supersededIdentifier.length || + [identifier isEqualToString:supersededIdentifier]) { + return; + } + NSArray *safeSuperseded = + [GDTBackupNotificationPolicy + failureNotificationIdentifiersForProfileID:profileID + candidateIdentifiers:@[supersededIdentifier]]; + if (safeSuperseded.count != 1) return; + + NSUserDefaults *defaults = [self backupNotificationDefaultsStore]; + NSString *deliveredKey = + [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"deliveredFailureIdentifiers"]; + NSMutableArray *identifiers = + [[defaults stringArrayForKey:deliveredKey] mutableCopy] ?: + [NSMutableArray array]; + [identifiers removeObject:supersededIdentifier]; + if (![identifiers containsObject:identifier]) { + [identifiers addObject:identifier]; + } + [defaults setObject:identifiers forKey:deliveredKey]; + [self removeExactBackupNotificationIdentifiers:safeSuperseded + forProfileID:profileID]; +} + +- (void)enumerateDeliveredBackupNotificationsWithCompletion: + (void (^)(NSArray *notifications))completion { + [UNUserNotificationCenter.currentNotificationCenter + getDeliveredNotificationsWithCompletionHandler:completion]; +} + - (void)removeBackupNotificationIdentifiers:(NSArray *)identifiers - forProfileID:(NSString *)profileID { - UNUserNotificationCenter *center = UNUserNotificationCenter.currentNotificationCenter; - [center getDeliveredNotificationsWithCompletionHandler: + forProfileID:(NSString *)profileID + throughIssueOriginTimestamp:(NSTimeInterval)cutoff { + [self enumerateDeliveredBackupNotificationsWithCompletion: ^(NSArray *notifications) { - NSMutableArray *candidates = - [identifiers mutableCopy] ?: [NSMutableArray array]; - for (UNNotification *notification in notifications) { + NSMutableOrderedSet *candidateOrder = + [NSMutableOrderedSet orderedSet]; + NSMutableDictionary *> *candidatesByID = + [NSMutableDictionary dictionary]; + for (id value in identifiers ?: @[]) { + if (![value isKindOfClass:NSString.class] || ![value length]) continue; + NSString *identifier = value; + [candidateOrder addObject:identifier]; + candidatesByID[identifier] = @{@"identifier": identifier}; + } + for (UNNotification *notification in notifications ?: @[]) { NSString *identifier = notification.request.identifier; - if (identifier.length) [candidates addObject:identifier]; + if (!identifier.length) continue; + UNNotificationContent *content = notification.request.content; + NSString *category = content.categoryIdentifier ?: @""; + NSDictionary *userInfo = [content.userInfo isKindOfClass:NSDictionary.class] + ? content.userInfo : @{}; + NSMutableDictionary *candidate = + [@{@"identifier": identifier} mutableCopy]; + if (category.length || userInfo.count) { + candidate[@"categoryIdentifier"] = category; + candidate[@"userInfo"] = userInfo; + } + [candidateOrder addObject:identifier]; + // Notification Center is authoritative for a currently delivered + // identifier; its metadata must not be bypassed by a legacy entry. + candidatesByID[identifier] = candidate; + } + NSMutableArray *> *candidates = + [NSMutableArray arrayWithCapacity:candidateOrder.count]; + for (NSString *identifier in candidateOrder) { + [candidates addObject:candidatesByID[identifier]]; } NSArray *safeIdentifiers = [GDTBackupNotificationPolicy failureNotificationIdentifiersForProfileID:profileID - candidateIdentifiers:candidates]; + throughIssueOriginTimestamp:cutoff + candidateNotifications:candidates]; if (!safeIdentifiers.count) return; - [center removeDeliveredNotificationsWithIdentifiers:safeIdentifiers]; - [center removePendingNotificationRequestsWithIdentifiers:safeIdentifiers]; + [self removeDeliveredBackupNotificationIdentifiers:safeIdentifiers]; + [self removePendingBackupNotificationIdentifiers:safeIdentifiers]; }]; } @@ -2076,13 +2561,49 @@ - (void)clearBackupFailureNotificationsForConfig: suffix:@"deliveredFailureIdentifiers"]; NSString *latestIssueKey = [self backupNotificationDefaultsKeyForProfileID:profileID suffix:@"latestDeliveredIssueAt"]; + NSString *latestIssueStageKey = [self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"latestDeliveredIssueStage"]; + NSString *latestIssueStateKey = [self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"latestDeliveredIssueState"]; NSString *activeIssueKey = [self backupNotificationDefaultsKeyForProfileID:profileID suffix:@"activeIssueAt"]; - NSTimeInterval latestIssueAt = MAX([defaults doubleForKey:latestIssueKey], - [defaults doubleForKey:activeIssueKey]); + NSString *dismissedIssueKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"dismissedIssueAt"]; + NSTimeInterval latestAcceptedIssueAt = 0; + NSUInteger latestAcceptedIssueStage = 0; + GDTBackupNotificationAcceptedState( + defaults, latestIssueStateKey, latestIssueKey, + &latestAcceptedIssueAt, &latestAcceptedIssueStage); + NSTimeInterval latestIssueAt = MAX( + latestAcceptedIssueAt, + MAX([defaults doubleForKey:activeIssueKey], + [defaults doubleForKey:dismissedIssueKey])); if (latestIssueAt > 0 && finishedAt <= latestIssueAt) { return; } + if (!self.backupNotificationGenerationsByProfileID) { + self.backupNotificationGenerationsByProfileID = + [NSMutableDictionary dictionary]; + } + NSUInteger generation = + self.backupNotificationGenerationsByProfileID[profileID] + .unsignedIntegerValue; + self.backupNotificationGenerationsByProfileID[profileID] = @(generation + 1); + + NSDictionary *queuedDecision = + self.queuedBackupNotificationDecisionsByProfileID[profileID]; + if (queuedDecision) { + NSString *queuedRevision = + [queuedDecision[@"revision"] isKindOfClass:NSString.class] + ? queuedDecision[@"revision"] : @""; + NSString *queuedKey = [NSString stringWithFormat:@"%@\n%@", + queuedDecision[@"identifier"] ?: @"", queuedRevision]; + [self.pendingBackupNotificationIdentifiers removeObject:queuedKey]; + [self.queuedBackupNotificationDecisionsByProfileID + removeObjectForKey:profileID]; + } NSMutableArray *identifiers = [[defaults stringArrayForKey:deliveredKey] mutableCopy] ?: [NSMutableArray array]; NSString *lastKey = [self backupNotificationDefaultsKeyForProfileID:profileID @@ -2091,13 +2612,22 @@ - (void)clearBackupFailureNotificationsForConfig: if (legacyIdentifier.length && ![identifiers containsObject:legacyIdentifier]) { [identifiers addObject:legacyIdentifier]; } - [self removeBackupNotificationIdentifiers:identifiers forProfileID:profileID]; + [self removeBackupNotificationIdentifiers:identifiers + forProfileID:profileID + throughIssueOriginTimestamp:finishedAt]; [defaults removeObjectForKey:deliveredKey]; [defaults removeObjectForKey:lastKey]; + [defaults removeObjectForKey:[self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"lastDeliveredRevision"]]; [defaults removeObjectForKey:latestIssueKey]; + [defaults removeObjectForKey:latestIssueStageKey]; + [defaults removeObjectForKey:latestIssueStateKey]; + [defaults removeObjectForKey:dismissedIssueKey]; [defaults removeObjectForKey:activeIssueKey]; [defaults removeObjectForKey:[self backupNotificationDefaultsKeyForProfileID:profileID suffix:@"activeIssueKind"]]; + [defaults removeObjectForKey:[self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueIdentifier"]]; } - (NSDictionary *)currentAutomaticRetryDecision { @@ -2155,15 +2685,50 @@ - (NSString *)backupAlertStatusForConfig:(NSDictionary * suffix:@"activeIssueKind"]; NSString *timeKey = [self backupNotificationDefaultsKeyForProfileID:profileID suffix:@"activeIssueAt"]; + NSString *identifierKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueIdentifier"]; + NSString *dismissedKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"dismissedIssueAt"]; + NSTimeInterval dismissedAt = [defaults doubleForKey:dismissedKey]; + NSTimeInterval activeSince = [defaults doubleForKey:timeKey]; + if (activeSince > 0 && activeSince <= dismissedAt) { + [defaults removeObjectForKey:kindKey]; + [defaults removeObjectForKey:timeKey]; + [defaults removeObjectForKey:identifierKey]; + activeSince = 0; + } + + NSTimeInterval decisionOrigin = + GDTBackupNotificationTimestamp(decision[@"issueOriginTimestamp"]); if ([decision[@"profileID"] isEqualToString:profileID] && [@[@"failure", @"missed"] containsObject:decision[@"kind"]] && - [decision[@"issueTimestamp"] doubleValue] > 0) { - [defaults setObject:decision[@"kind"] forKey:kindKey]; - [defaults setDouble:[decision[@"issueTimestamp"] doubleValue] forKey:timeKey]; + decisionOrigin > dismissedAt && decision[@"identifier"].length && + decisionOrigin >= activeSince) { + if (decisionOrigin > activeSince || activeSince <= 0) { + [defaults setObject:decision[@"kind"] forKey:kindKey]; + [defaults setDouble:decisionOrigin forKey:timeKey]; + [defaults setObject:decision[@"identifier"] forKey:identifierKey]; + activeSince = decisionOrigin; + } else if (![defaults stringForKey:identifierKey].length) { + [defaults setObject:decision[@"identifier"] forKey:identifierKey]; + } } + NSTimeInterval retryOrigin = + GDTBackupNotificationTimestamp(summary[@"retry_origin_started_at"]); + NSTimeInterval retryStarted = + GDTBackupNotificationTimestamp(summary[@"started_at"]); + BOOL retryRunning = [rawStatus isEqualToString:@"running"] && + [summary[@"trigger"] isEqualToString:@"schedule-retry"] && + [summary[@"retry_attempt"] isEqualToString:@"1"] && + retryOrigin > 0 && retryStarted > retryOrigin && + [decision[@"profileID"] isEqualToString:profileID] && + [decision[@"kind"] isEqualToString:@"retry-running"] && + decisionOrigin == retryOrigin && decision[@"identifier"].length; + if (retryRunning) return @"retry-running"; + NSString *activeKind = [defaults stringForKey:kindKey]; - NSTimeInterval activeSince = [defaults doubleForKey:timeKey]; + activeSince = [defaults doubleForKey:timeKey]; if (![@[@"failure", @"missed"] containsObject:activeKind] || activeSince <= 0) { return rawStatus; } @@ -2171,9 +2736,10 @@ - (NSString *)backupAlertStatusForConfig:(NSDictionary * BOOL automaticSuccess = [@[@"schedule", @"schedule-retry"] containsObject:(summary[@"trigger"] ?: @"")]; if ([rawStatus isEqualToString:@"success"] && automaticSuccess && - finishedAt >= activeSince) { + finishedAt > activeSince) { [defaults removeObjectForKey:kindKey]; [defaults removeObjectForKey:timeKey]; + [defaults removeObjectForKey:identifierKey]; return rawStatus; } return activeKind; @@ -2619,6 +3185,97 @@ - (void)handleUnknownExternalVolumeActionIdentifier:(NSString *)actionIdentifier } } +- (void)handleBackupNotificationActionIdentifier:(NSString *)actionIdentifier + categoryIdentifier:(NSString *)categoryIdentifier + userInfo:(NSDictionary *)userInfo + notificationIdentifier:(NSString *)notificationIdentifier { + BOOL dismissAction = + [actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]; + BOOL openAction = [actionIdentifier isEqualToString:@"GDT_OPEN_BACKUP_OVERVIEW"]; + if (![categoryIdentifier isEqualToString:@"GDT_BACKUP_ALERT"] || + (!dismissAction && !openAction)) { + return; + } + + if (openAction) { + void (^showOverview)(void) = ^{ + [self showOverviewWindow]; + [self refreshOverviewStatus:nil]; + }; + if (NSThread.isMainThread) { + showOverview(); + } else { + dispatch_async(dispatch_get_main_queue(), showOverview); + } + } + + NSString *profileID = [userInfo[@"profileID"] isKindOfClass:NSString.class] + ? userInfo[@"profileID"] : @""; + NSString *originValue = + [userInfo[@"issueOriginTimestamp"] isKindOfClass:NSString.class] + ? userInfo[@"issueOriginTimestamp"] : @""; + NSTimeInterval issueOriginTimestamp = + GDTBackupNotificationTimestamp(originValue); + NSString *profilePrefix = [NSString stringWithFormat: + @"com.commcats.gdrivebackup.%@.", profileID]; + NSArray *safeIdentifiers = + [GDTBackupNotificationPolicy + failureNotificationIdentifiersForProfileID:profileID + candidateIdentifiers:@[notificationIdentifier ?: @""]]; + if (!profileID.length || issueOriginTimestamp <= 0 || + ![originValue isEqualToString: + [NSString stringWithFormat:@"%.0f", issueOriginTimestamp]] || + ![notificationIdentifier hasPrefix:profilePrefix] || + safeIdentifiers.count != 1) { + return; + } + + NSUserDefaults *defaults = [self backupNotificationDefaultsStore]; + NSString *dismissedKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"dismissedIssueAt"]; + [defaults setDouble:MAX(issueOriginTimestamp, + [defaults doubleForKey:dismissedKey]) + forKey:dismissedKey]; + + NSString *activeAtKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueAt"]; + NSString *activeIDKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueIdentifier"]; + if ([defaults doubleForKey:activeAtKey] == issueOriginTimestamp && + [[defaults stringForKey:activeIDKey] + isEqualToString:notificationIdentifier]) { + [defaults removeObjectForKey:activeAtKey]; + [defaults removeObjectForKey:activeIDKey]; + [defaults removeObjectForKey:[self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"activeIssueKind"]]; + } + + NSString *deliveredKey = [self backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"deliveredFailureIdentifiers"]; + NSMutableArray *delivered = + [[defaults stringArrayForKey:deliveredKey] mutableCopy] ?: + [NSMutableArray array]; + [delivered removeObject:notificationIdentifier]; + if (delivered.count) { + [defaults setObject:delivered forKey:deliveredKey]; + } else { + [defaults removeObjectForKey:deliveredKey]; + } + NSString *lastIdentifierKey = [self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"lastDeliveredIdentifier"]; + if ([[defaults stringForKey:lastIdentifierKey] + isEqualToString:notificationIdentifier]) { + [defaults removeObjectForKey:lastIdentifierKey]; + [defaults removeObjectForKey:[self + backupNotificationDefaultsKeyForProfileID:profileID + suffix:@"lastDeliveredRevision"]]; + } + [self removeExactBackupNotificationIdentifiers:safeIdentifiers + forProfileID:profileID]; +} + - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { @@ -2636,6 +3293,20 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center completionHandler(); return; } + if ([action isEqualToString:UNNotificationDismissActionIdentifier] || + [action isEqualToString:@"GDT_OPEN_BACKUP_OVERVIEW"]) { + [self handleBackupNotificationActionIdentifier:action + categoryIdentifier:category + userInfo: + response.notification.request.content.userInfo ?: @{} + notificationIdentifier: + response.notification.request.identifier ?: @""]; + if ([action isEqualToString:UNNotificationDismissActionIdentifier] || + [category isEqualToString:@"GDT_BACKUP_ALERT"]) { + completionHandler(); + return; + } + } if ([action isEqualToString:UNNotificationDefaultActionIdentifier] || [action isEqualToString:@"GDT_OPEN_BACKUP_OVERVIEW"]) { dispatch_async(dispatch_get_main_queue(), ^{ @@ -2710,6 +3381,7 @@ - (NSDateFormatter *)overviewDateFormatterWithCalendar:(NSCalendar *)calendar { return [self overviewSnapshotForConfig:config summary:summary status:status + progress:nil now:now calendar:calendar]; } @@ -2719,7 +3391,24 @@ - (NSDateFormatter *)overviewDateFormatterWithCalendar:(NSCalendar *)calendar { status:(NSString *)status now:(NSDate *)now calendar:(NSCalendar *)calendar { + return [self overviewSnapshotForConfig:config + summary:summary + status:status + progress:nil + now:now + calendar:calendar]; +} + +- (NSDictionary *)overviewSnapshotForConfig:(NSDictionary *)config + summary:(NSDictionary *)summary + status:(NSString *)status + progress:(NSDictionary *)progress + now:(NSDate *)now + calendar:(NSCalendar *)calendar { NSString *language = self.language ?: @"en"; + NSString *trigger = summary[@"trigger"] ?: @""; + BOOL retryRunning = [status isEqualToString:@"running"] && + [trigger isEqualToString:@"schedule-retry"]; NSDictionary *statusKeys = @{ @"success": @"completed", @"failure": @"failed", @@ -2733,6 +3422,9 @@ - (NSDateFormatter *)overviewDateFormatterWithCalendar:(NSCalendar *)calendar { } else { lastRun = T(language, statusKeys[status] ?: @"overviewStatusUnknown"); } + if (retryRunning) { + lastRun = T(language, @"automaticRetryRunning"); + } NSString *timestamp = [status isEqualToString:@"running"] || [status isEqualToString:@"interrupted"] ? summary[@"started_at"] : summary[@"finished_at"]; @@ -2775,6 +3467,15 @@ - (NSDateFormatter *)overviewDateFormatterWithCalendar:(NSCalendar *)calendar { return @{ @"status": status, + @"trigger": trigger, + @"retryRunning": retryRunning ? @"1" : @"0", + @"progressVisible": retryRunning ? @"1" : @"0", + @"progressLabel": retryRunning ? T(language, @"automaticRetryRunning") : @"", + @"progressPhase": retryRunning + ? GDTLocalizedProgressPhase(progress[@"phase"], language) : @"", + @"progressPercent": retryRunning ? (progress[@"percent"] ?: @"") : @"", + @"progressDetail": retryRunning + ? (progress[@"detail"] ?: T(language, @"progressPreparing")) : @"", @"lastRun": lastRun ?: @"", @"lastRunDetail": lastRunDetail, @"nextRun": nextRun ?: T(language, @"overviewUnavailable"), @@ -2797,6 +3498,13 @@ - (void)applyOverviewSnapshot:(NSDictionary *)snapshot view.targetText = snapshot[@"target"] ?: @""; view.storageText = snapshot[@"storage"] ?: @""; view.status = status; + view.progressSummary = snapshot[@"progressLabel"] ?: @""; + view.progressPhaseLabel.stringValue = snapshot[@"progressPhase"] ?: @""; + view.progressPhaseLabel.accessibilityLabel = view.progressPhaseLabel.stringValue; + view.progressDetail = snapshot[@"progressDetail"] ?: @""; + NSString *progressPercent = snapshot[@"progressPercent"] ?: @""; + view.progressPercent = progressPercent.length ? progressPercent.doubleValue : -1.0; + view.progressVisible = [snapshot[@"progressVisible"] isEqualToString:@"1"]; view.backupButton.enabled = !self.overviewLaunchPending && ![status isEqualToString:@"running"]; } @@ -2824,6 +3532,23 @@ - (NSMenu *)statusMenuForSnapshot:(NSDictionary *)snapsh lastRun = [NSString stringWithFormat:@"%@ · %@", lastRun, detail]; } [menu addItem:[self statusValueItemWithTitle:T(language, @"overviewLastRun") value:lastRun]]; + if ([snapshot[@"retryRunning"] isEqualToString:@"1"]) { + NSMutableArray *progressParts = [NSMutableArray arrayWithObject: + T(language, @"automaticRetryRunningShort")]; + if ([snapshot[@"progressPhase"] length]) { + [progressParts addObject:snapshot[@"progressPhase"]]; + } + if ([snapshot[@"progressPercent"] length]) { + [progressParts addObject:[NSString stringWithFormat:@"%@ %%", + snapshot[@"progressPercent"]]]; + } + NSMenuItem *retryProgressItem = [[NSMenuItem alloc] + initWithTitle:[progressParts componentsJoinedByString:@" · "] + action:nil + keyEquivalent:@""]; + retryProgressItem.enabled = NO; + [menu addItem:retryProgressItem]; + } [menu addItem:[self statusValueItemWithTitle:T(language, @"overviewNextRun") value:snapshot[@"nextRun"]]]; [menu addItem:[self statusValueItemWithTitle:T(language, @"overviewTarget") value:snapshot[@"target"]]]; [menu addItem:[self statusValueItemWithTitle:T(language, @"overviewStorage") value:snapshot[@"storage"]]]; @@ -2891,6 +3616,7 @@ - (void)updateStatusItemPresentationForSnapshot:(NSDictionary *summary = GDTReadBackupSummaryAtPath(summaryPath); NSString *status = GDTBackupSummaryStatusForValues(summary); + NSString *progressPath = GDTBackupProgressPathForSummaryPath(summaryPath); + NSDictionary *rawProgress = + GDTReadBackupProgressAtPath(progressPath); + NSString *profileID = config[@"GDRIVE_BACKUP_PROFILE_ID"] ?: @"legacy"; + NSDictionary *progress = rawProgress + ? GDTValidatedBackupProgressForValues(rawProgress, summary, status, + profileID, now.timeIntervalSince1970) + : nil; NSDictionary *snapshot = [strongSelf overviewSnapshotForConfig:config summary:summary status:status - now:now calendar:calendar]; + progress:progress now:now calendar:calendar]; NSDictionary *notificationDecision = [GDTBackupNotificationPolicy decisionForConfig:config summary:summary status:status now:now calendar:calendar]; @@ -6088,17 +6824,15 @@ - (void)readProgressFile { contentView.progressTitle = [NSString stringWithFormat:@"%@ · %@", phase, label]; } else if (label.length) { contentView.progressTitle = label; + } else { + contentView.progressTitle = nil; } - NSString *detail = values[@"detail"]; - if (detail.length) { - contentView.progressDetail = detail; - } + contentView.progressDetail = values[@"detail"]; NSString *percent = values[@"percent"]; - if (percent.length) { - contentView.progressPercent = MAX(0.0, MIN(100.0, percent.doubleValue)); - } + contentView.progressPercent = percent.length + ? MAX(0.0, MIN(100.0, percent.doubleValue)) : -1.0; contentView.needsDisplay = YES; } @@ -6225,6 +6959,23 @@ int main(int argc, const char *argv[]) { return TrashPathsFromArguments(argc, argv); } + BOOL handledNetworkMountCommand = NO; + int networkMountResult = GDTHandleNetworkMountCLIArguments( + NSProcessInfo.processInfo.arguments, + ^int(NSString *urlString, BOOL authorizeCredential) { + return authorizeCredential + ? GDTAuthorizeSMBCredentialForURL(urlString) + : GDTMountSMBURLFromKeychain(urlString); + }, + &handledNetworkMountCommand); + if (handledNetworkMountCommand) { + if (networkMountResult != 0) { + fprintf(stderr, "Network mount command failed (%d).\n", + networkMountResult); + } + return networkMountResult; + } + NSApplication *app = NSApplication.sharedApplication; AppDelegate *delegate = [[AppDelegate alloc] init]; app.delegate = delegate; diff --git a/tests/TestApplicationSupport.h b/tests/TestApplicationSupport.h new file mode 100644 index 0000000..bce52f4 --- /dev/null +++ b/tests/TestApplicationSupport.h @@ -0,0 +1,14 @@ +#ifndef GDT_TEST_APPLICATION_SUPPORT_H +#define GDT_TEST_APPLICATION_SUPPORT_H + +#import + +static inline NSApplication *GDTInitializeAccessoryTestApplication(void) { + // Unbundled test executables never enter the product delegate lifecycle, + // so they must opt out of Dock presence as soon as AppKit is initialized. + NSApplication *application = NSApplication.sharedApplication; + [application setActivationPolicy:NSApplicationActivationPolicyAccessory]; + return application; +} + +#endif diff --git a/tests/backup-control-test.sh b/tests/backup-control-test.sh index 67124ff..2df0993 100644 --- a/tests/backup-control-test.sh +++ b/tests/backup-control-test.sh @@ -161,6 +161,139 @@ test_plain_directory_cannot_impersonate_a_nas_mount() { fi } +test_scheduled_nas_mount_uses_noninteractive_helper() { + local name="scheduled NAS mount uses only the noninteractive native helper" + local test_home fake_bin fake_mount marker helper_log forbidden_ui_log log_file + test_home="$(new_test_home)" + fake_bin="$(prepare_fake_tools "$test_home")" + fake_mount="$test_home/Volumes/Backups" + marker="$test_home/nas-mounted" + helper_log="$test_home/helper.log" + forbidden_ui_log="$test_home/forbidden-ui.log" + log_file="$test_home/Library/Logs/gdrive-backup.log" + mkdir -p "$fake_mount" + + cat >"$fake_bin/mount" <<'SH' +#!/bin/bash +if [[ -f "$FAKE_NAS_MOUNT_MARKER" ]]; then + printf '//backup-user@nas.local/Backups on %s (smbfs, nodev, nosuid)\n' \ + "$FAKE_NAS_MOUNT" +fi +SH + cat >"$fake_bin/mount-helper" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$FAKE_MOUNT_HELPER_LOG" +touch "$FAKE_NAS_MOUNT_MARKER" +SH + cat >"$fake_bin/open" <<'SH' +#!/bin/bash +printf 'open %s\n' "$*" >>"$FAKE_FORBIDDEN_UI_LOG" +exit 92 +SH + cat >"$fake_bin/osascript" <<'SH' +#!/bin/bash +printf 'osascript %s\n' "$*" >>"$FAKE_FORBIDDEN_UI_LOG" +exit 93 +SH + chmod +x "$fake_bin/mount" "$fake_bin/mount-helper" \ + "$fake_bin/open" "$fake_bin/osascript" + + HOME="$test_home" \ + FAKE_NAS_MOUNT="$fake_mount" \ + FAKE_NAS_MOUNT_MARKER="$marker" \ + FAKE_MOUNT_HELPER_LOG="$helper_log" \ + FAKE_FORBIDDEN_UI_LOG="$forbidden_ui_log" \ + GDRIVE_BACKUP_PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + GDRIVE_BACKUP_MOUNT_BIN="$fake_bin/mount" \ + GDRIVE_BACKUP_NAS_MOUNT_HELPER="$fake_bin/mount-helper" \ + GDRIVE_BACKUP_OPEN_BIN="$fake_bin/open" \ + GDRIVE_BACKUP_OSASCRIPT="$fake_bin/osascript" \ + GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS=1 \ + GDRIVE_BACKUP_NAS_MOUNT_TIMEOUT_SECONDS=1 \ + MOUNT_SETTLE_SECONDS=0 \ + GDRIVE_BACKUP_TRIGGER=schedule \ + GDRIVE_BACKUP_TARGET=nas \ + GDRIVE_BACKUP_NAS_URL='smb://backup-user@nas.local/Backups' \ + GDRIVE_BACKUP_NAS_MOUNT="$fake_mount" \ + GDRIVE_BACKUP_CONFIRM=0 \ + GDRIVE_BACKUP_VERSIONING=0 \ + GDRIVE_BACKUP_RETENTION=0 \ + BACKUP_DISABLE_ANIMATION=1 \ + "$BACKUP_SCRIPT" --run + + if [[ -f "$marker" ]] && + grep -Fq -- '--mount-network-url smb://backup-user@nas.local/Backups' "$helper_log" && + [[ ! -e "$forbidden_ui_log" ]] && + grep -Fq 'NAS-Ziel bereit:' "$log_file"; then + pass "$name" + else + fail "$name" + fi +} + +test_dry_run_never_logs_nas_credentials() { + local name="NAS dry-run never logs URL credentials" + local test_home log_file output_file + test_home="$(new_test_home)" + log_file="$test_home/Library/Logs/gdrive-backup.log" + output_file="$test_home/dry-run.out" + + HOME="$test_home" \ + MOUNT_SETTLE_SECONDS=0 \ + GDRIVE_BACKUP_TRIGGER=manual \ + GDRIVE_BACKUP_TARGET=nas \ + GDRIVE_BACKUP_NAS_URL='smb://backup-user:LEAK-MARKER@nas.local/Backups' \ + GDRIVE_BACKUP_NAS_MOUNT='' \ + "$BACKUP_SCRIPT" --dry-run >"$output_file" 2>&1 + + if ! grep -Fq 'LEAK-MARKER' "$log_file" "$output_file" && + grep -Fq 'NAS-Freigabe wuerde bei Bedarf still gemountet' "$log_file"; then + pass "$name" + else + fail "$name" + fi +} + +test_embedded_nas_password_never_reaches_helper_or_log() { + local name="embedded NAS password never reaches helper arguments or logs" + local test_home fake_bin helper_log log_file output_file + test_home="$(new_test_home)" + fake_bin="$(prepare_fake_tools "$test_home")" + helper_log="$test_home/helper.log" + log_file="$test_home/Library/Logs/gdrive-backup.log" + output_file="$test_home/run.out" + + cat >"$fake_bin/mount-helper" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$FAKE_MOUNT_HELPER_LOG" +exit 69 +SH + chmod +x "$fake_bin/mount-helper" + + HOME="$test_home" \ + FAKE_MOUNT_HELPER_LOG="$helper_log" \ + GDRIVE_BACKUP_PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + GDRIVE_BACKUP_MOUNT_BIN="$fake_bin/mount" \ + GDRIVE_BACKUP_NAS_MOUNT_HELPER="$fake_bin/mount-helper" \ + GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS=1 \ + GDRIVE_BACKUP_NAS_MOUNT_TIMEOUT_SECONDS=1 \ + MOUNT_SETTLE_SECONDS=0 \ + GDRIVE_BACKUP_TRIGGER=schedule \ + GDRIVE_BACKUP_TARGET=nas \ + GDRIVE_BACKUP_NAS_URL='smb://backup-user:LEAK-MARKER@nas.local/Backups' \ + GDRIVE_BACKUP_NAS_MOUNT='/Volumes/Backups' \ + GDRIVE_BACKUP_CONFIRM=0 \ + BACKUP_DISABLE_ANIMATION=1 \ + "$BACKUP_SCRIPT" --run >"$output_file" 2>&1 + + if [[ ! -e "$helper_log" ]] && + ! grep -Fq 'LEAK-MARKER' "$log_file" "$output_file"; then + pass "$name" + else + fail "$name" + fi +} + test_invalid_pause_setting_fails_closed() { local name="invalid automatic-backup pause setting fails closed" local test_home status @@ -188,6 +321,9 @@ test_manual_missing_target_is_an_error test_scheduled_missing_target_is_an_error test_nas_url_derives_mount_and_destination test_plain_directory_cannot_impersonate_a_nas_mount +test_scheduled_nas_mount_uses_noninteractive_helper +test_dry_run_never_logs_nas_credentials +test_embedded_nas_password_never_reaches_helper_or_log test_invalid_pause_setting_fails_closed if (( failures > 0 )); then diff --git a/tests/backup-outcome-test.sh b/tests/backup-outcome-test.sh index 145defe..62008c6 100644 --- a/tests/backup-outcome-test.sh +++ b/tests/backup-outcome-test.sh @@ -115,6 +115,10 @@ case "${1:-}" in fi previous="$argument" done + if [[ -n "${FAKE_RCLONE_ADVANCE_EPOCH_TO:-}" && + -n "${FAKE_DATE_EPOCH_FILE:-}" ]]; then + printf '%s\n' "$FAKE_RCLONE_ADVANCE_EPOCH_TO" >"$FAKE_DATE_EPOCH_FILE" + fi if [[ -n "${FAKE_RCLONE_COPY_OUTPUT:-}" && " $* " != *" --drive-root-folder-id "* ]]; then if [[ "${FAKE_RCLONE_COPY_OUTPUT_SHARED_ONLY:-0}" != "1" || @@ -142,6 +146,15 @@ case "${1:-}" in ;; esac exit 64 +SH + + cat >"$FAKE_BIN/date" <<'SH' +#!/bin/bash +if [[ "${1:-}" == "+%s" && -n "${FAKE_DATE_EPOCH_FILE:-}" ]]; then + /bin/cat "$FAKE_DATE_EPOCH_FILE" + exit 0 +fi +exec /bin/date "$@" SH cat >"$FAKE_BIN/jq" <<'SH' @@ -190,22 +203,24 @@ SH cat >"$FAKE_BIN/osascript" <<'SH' #!/bin/bash -if [[ "${FAKE_OSASCRIPT_REQUIRE_MOUNT_SCRIPT:-0}" == "1" ]]; then - script="$(/bin/cat)" - if [[ "$script" != *"mount volume (item 1 of argv)"* ]]; then - exit 91 - fi +exit 90 +SH + + cat >"$FAKE_BIN/mount-helper" <<'SH' +#!/bin/bash +if [[ "${1:-}" != "--mount-network-url" || -z "${2:-}" ]]; then + exit 64 fi -if [[ "${FAKE_OSASCRIPT_SLEEP_SECONDS:-0}" != "0" ]]; then - /bin/sleep "$FAKE_OSASCRIPT_SLEEP_SECONDS" +if [[ "${FAKE_MOUNT_HELPER_SLEEP_SECONDS:-0}" != "0" ]]; then + /bin/sleep "$FAKE_MOUNT_HELPER_SLEEP_SECONDS" fi -if [[ -n "${FAKE_OSASCRIPT_MOUNT_VISIBLE_FILE:-}" ]]; then - : >"$FAKE_OSASCRIPT_MOUNT_VISIBLE_FILE" +if [[ -n "${FAKE_MOUNT_HELPER_VISIBLE_FILE:-}" ]]; then + : >"$FAKE_MOUNT_HELPER_VISIBLE_FILE" fi -if [[ "${FAKE_OSASCRIPT_MAKE_WRITABLE:-0}" == "1" ]]; then +if [[ "${FAKE_MOUNT_HELPER_MAKE_WRITABLE:-0}" == "1" ]]; then chmod 700 "${FAKE_NAS_MOUNT:?}" fi -exit "${FAKE_OSASCRIPT_STATUS:-0}" +exit "${FAKE_MOUNT_HELPER_STATUS:-0}" SH cat >"$FAKE_BIN/cmp" <<'SH' @@ -247,10 +262,56 @@ exit 0 SH done chmod +x "$FAKE_BIN/rclone" "$FAKE_BIN/jq" "$FAKE_BIN/open" "$FAKE_BIN/flock" \ - "$FAKE_BIN/mount" "$FAKE_BIN/osascript" "$FAKE_BIN/cmp" "$FAKE_BIN/trash" \ + "$FAKE_BIN/mount" "$FAKE_BIN/osascript" "$FAKE_BIN/mount-helper" \ + "$FAKE_BIN/cmp" "$FAKE_BIN/trash" "$FAKE_BIN/date" \ "$FAKE_BIN/diskutil" "$FAKE_BIN/plutil" } +enable_state_publish_order_spy() { + cat >"$FAKE_BIN/mv" <<'SH' +#!/bin/bash +destination="" +for argument in "$@"; do destination="$argument"; done +source_path="${@: -2:1}" +case "$destination" in + */last-run.status|*/current-progress.status) + if [[ -n "${GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG:-}" ]]; then + printf '%s\n' "${destination##*/}" >>"$GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG" + fi + ;; +esac +if [[ "$destination" == */last-run.status && + "${GDRIVE_BACKUP_FAIL_TERMINAL_SUMMARY:-0}" == "1" ]] && + /usr/bin/grep -Eq '^status=(success|failure|cancelled)$' "$source_path"; then + exit 91 +fi +if [[ "$destination" == */current-progress.status && + "${GDRIVE_BACKUP_FAIL_RICH_PROGRESS_AT:-0}" =~ ^[1-9][0-9]*$ ]] && + /usr/bin/grep -q '^percent=' "$source_path"; then + count_file="${GDRIVE_BACKUP_RICH_PROGRESS_COUNT_FILE:?}" + count="$(/bin/cat "$count_file" 2>/dev/null || printf '0')" + count=$((count + 1)) + printf '%s\n' "$count" >"$count_file" + if [[ "$count" == "$GDRIVE_BACKUP_FAIL_RICH_PROGRESS_AT" ]]; then + exit 92 + fi +fi +if [[ -n "${GDRIVE_BACKUP_FOREGROUND_PROGRESS_SNAPSHOT_FILE:-}" && + ! -e "$GDRIVE_BACKUP_FOREGROUND_PROGRESS_SNAPSHOT_FILE" && + "$destination" == */gdrive-backup-progress.* ]] && + /usr/bin/grep -Fxq 'label=My Drive' "$source_path" && + /usr/bin/grep -Fxq 'phase=1/2' "$source_path"; then + /bin/cat "$source_path" >"$GDRIVE_BACKUP_FOREGROUND_PROGRESS_SNAPSHOT_FILE" +fi +exec /bin/mv "$@" +SH + chmod +x "$FAKE_BIN/mv" +} + +last_terminal_publish_order() { + tail -n 2 "$1" 2>/dev/null | paste -sd, - +} + run_backup_with_mode() { local mode="$1" shift @@ -261,6 +322,8 @@ run_backup_with_mode() { GDRIVE_BACKUP_TARGET=nas \ GDRIVE_BACKUP_NAS_MOUNT="$NAS_MOUNT" \ GDRIVE_BACKUP_MOUNT_BIN="$FAKE_BIN/mount" \ + GDRIVE_BACKUP_NAS_MOUNT_HELPER="$FAKE_BIN/mount-helper" \ + GDRIVE_BACKUP_OPEN_BIN="$FAKE_BIN/open" \ GDRIVE_BACKUP_OSASCRIPT="${GDRIVE_BACKUP_OSASCRIPT:-$FAKE_BIN/osascript}" \ GDRIVE_BACKUP_CMP_BIN="$FAKE_BIN/cmp" \ GDRIVE_BACKUP_DEST_ROOT="$NAS_MOUNT/backup" \ @@ -295,6 +358,8 @@ run_backup_with_mode() { FAKE_RCLONE_REJECT_SHARED_FOR_ID="${FAKE_RCLONE_REJECT_SHARED_FOR_ID:-0}" \ FAKE_RCLONE_COPY_OUTPUT_SHARED_ONLY="${FAKE_RCLONE_COPY_OUTPUT_SHARED_ONLY:-0}" \ FAKE_RCLONE_COPY_OUTPUT_TEAM_DRIVE_ONLY="${FAKE_RCLONE_COPY_OUTPUT_TEAM_DRIVE_ONLY:-0}" \ + FAKE_RCLONE_ADVANCE_EPOCH_TO="${FAKE_RCLONE_ADVANCE_EPOCH_TO:-}" \ + FAKE_DATE_EPOCH_FILE="${FAKE_DATE_EPOCH_FILE:-}" \ FAKE_JQ_USE_SYSTEM="${FAKE_JQ_USE_SYSTEM:-0}" \ FAKE_RCLONE_ARGS_FILE="${FAKE_RCLONE_ARGS_FILE:-}" \ FAKE_RCLONE_SLEEP_SECONDS="${FAKE_RCLONE_SLEEP_SECONDS:-0}" \ @@ -308,15 +373,18 @@ run_backup_with_mode() { FAKE_NAS_MOUNT="$NAS_MOUNT" \ FAKE_NAS_MOUNT_VISIBLE="${FAKE_NAS_MOUNT_VISIBLE:-1}" \ FAKE_NAS_MOUNT_VISIBLE_FILE="${FAKE_NAS_MOUNT_VISIBLE_FILE:-}" \ - FAKE_OSASCRIPT_MOUNT_VISIBLE_FILE="${FAKE_OSASCRIPT_MOUNT_VISIBLE_FILE:-}" \ - FAKE_OSASCRIPT_MAKE_WRITABLE="${FAKE_OSASCRIPT_MAKE_WRITABLE:-0}" \ - FAKE_OSASCRIPT_REQUIRE_MOUNT_SCRIPT="${FAKE_OSASCRIPT_REQUIRE_MOUNT_SCRIPT:-0}" \ - FAKE_OSASCRIPT_SLEEP_SECONDS="${FAKE_OSASCRIPT_SLEEP_SECONDS:-0}" \ - FAKE_OSASCRIPT_STATUS="${FAKE_OSASCRIPT_STATUS:-0}" \ + FAKE_MOUNT_HELPER_VISIBLE_FILE="${FAKE_MOUNT_HELPER_VISIBLE_FILE:-}" \ + FAKE_MOUNT_HELPER_MAKE_WRITABLE="${FAKE_MOUNT_HELPER_MAKE_WRITABLE:-0}" \ + FAKE_MOUNT_HELPER_SLEEP_SECONDS="${FAKE_MOUNT_HELPER_SLEEP_SECONDS:-0}" \ + FAKE_MOUNT_HELPER_STATUS="${FAKE_MOUNT_HELPER_STATUS:-0}" \ FAKE_TEMP_TRASH_DIR="$TEMP_TRASH_DIR" \ FAKE_OPEN_LOG="$OPEN_LOG" \ FAKE_OPEN_STATUS="${FAKE_OPEN_STATUS:-0}" \ FAKE_CONFIRM_DECISION="${FAKE_CONFIRM_DECISION:-}" \ + GDRIVE_BACKUP_FAIL_TERMINAL_SUMMARY="${GDRIVE_BACKUP_FAIL_TERMINAL_SUMMARY:-0}" \ + GDRIVE_BACKUP_FAIL_RICH_PROGRESS_AT="${GDRIVE_BACKUP_FAIL_RICH_PROGRESS_AT:-0}" \ + GDRIVE_BACKUP_RICH_PROGRESS_COUNT_FILE="${GDRIVE_BACKUP_RICH_PROGRESS_COUNT_FILE:-}" \ + GDRIVE_BACKUP_FOREGROUND_PROGRESS_SNAPSHOT_FILE="${GDRIVE_BACKUP_FOREGROUND_PROGRESS_SNAPSHOT_FILE:-}" \ RCLONE_REMOTE=tdd-remote \ "$@" \ "$BACKUP_SCRIPT" "$mode" @@ -369,6 +437,9 @@ start_backup_async() { GDRIVE_BACKUP_RETENTION=0 \ GDRIVE_BACKUP_RUN_STATE_FILE="$RUN_STATE_FILE" \ GDRIVE_BACKUP_SUMMARY_STATE_FILE="$SUMMARY_STATE_FILE" \ + GDRIVE_BACKUP_PROFILE_ID="${GDRIVE_BACKUP_PROFILE_ID:-default}" \ + GDRIVE_BACKUP_PROGRESS_STATE_FILE="${GDRIVE_BACKUP_PROGRESS_STATE_FILE:-}" \ + GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG="${GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG:-}" \ GDRIVE_BACKUP_TEMP_TRASH_BIN="$FAKE_BIN/trash" \ BACKUP_DISABLE_ANIMATION="${BACKUP_DISABLE_ANIMATION:-1}" \ GDRIVE_BACKUP_ANIMATION_APP="${GDRIVE_BACKUP_ANIMATION_APP:-/Applications/GDrive Backup Tiger.app}" \ @@ -558,12 +629,19 @@ test_state_is_versioned_and_identifies_the_process() { } test_term_signal_publishes_cancellation() { - local name="TERM publishes cancellation instead of failure" - local backup_pid status state started_file + local name="TERM publishes cancellation and invalidates live progress" + local backup_pid status state summary terminal started_file progress + local order_log terminal_order prepare_test_environment + enable_state_publish_order_spy started_file="$TEST_HOME/rclone-started" + progress="$TEST_HOME/profiles/default/current-progress.status" + order_log="$TEST_HOME/state-publish-order.log" + mkdir -p "${progress%/*}" - start_backup_async "$started_file" + GDRIVE_BACKUP_PROGRESS_STATE_FILE="$progress" \ + GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG="$order_log" \ + start_backup_async "$started_file" backup_pid="$ASYNC_BACKUP_PID" for _ in {1..60}; do [[ -e "$started_file" ]] && break @@ -573,12 +651,19 @@ test_term_signal_publishes_cancellation() { wait "$backup_pid" status=$? state="$(cat "$RUN_STATE_FILE" 2>/dev/null || true)" + summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + terminal="$(cat "$progress" 2>/dev/null || true)" + terminal_order="$(last_terminal_publish_order "$order_log")" if [[ "$status" == "143" && "$state" == *$'status=cancelled\n'* && - "$state" == *$'signal=TERM\n'* && "$state" == *'exit_code=143'* ]]; then + "$state" == *$'signal=TERM\n'* && "$state" == *'exit_code=143'* && + "$summary" == *$'status=cancelled\n'* && + "$terminal" == *$'status=finished\n'* && + "$terminal" != *$'percent='* && + "$terminal_order" == "last-run.status,current-progress.status" ]]; then pass "$name" else - fail "$name (exit=$status state=${state//$'\n'/,})" + fail "$name (exit=$status state=${state//$'\n'/,} summary=${summary//$'\n'/,} progress=${terminal//$'\n'/,})" fi } @@ -1504,9 +1589,8 @@ test_nas_auto_mount_transitions_to_a_writable_share() { FAKE_NAS_MOUNT_VISIBLE=0 \ FAKE_NAS_MOUNT_VISIBLE_FILE="$visible_file" \ - FAKE_OSASCRIPT_MOUNT_VISIBLE_FILE="$visible_file" \ - FAKE_OSASCRIPT_MAKE_WRITABLE=1 \ - FAKE_OSASCRIPT_REQUIRE_MOUNT_SCRIPT=1 \ + FAKE_MOUNT_HELPER_VISIBLE_FILE="$visible_file" \ + FAKE_MOUNT_HELPER_MAKE_WRITABLE=1 \ GDRIVE_BACKUP_NAS_URL="smb://backup.test/share" \ GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS=2 \ run_backup @@ -1530,7 +1614,7 @@ test_nas_auto_mount_not_ready_is_retryable() { FAKE_NAS_MOUNT_VISIBLE=0 \ FAKE_NAS_MOUNT_VISIBLE_FILE="$visible_file" \ - FAKE_OSASCRIPT_MOUNT_VISIBLE_FILE="$visible_file" \ + FAKE_MOUNT_HELPER_VISIBLE_FILE="$visible_file" \ GDRIVE_BACKUP_NAS_URL="smb://backup.test/share" \ GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS=1 \ run_backup @@ -1553,7 +1637,7 @@ test_hung_nas_mount_command_is_bounded() { started="$(date +%s)" FAKE_NAS_MOUNT_VISIBLE=0 \ - FAKE_OSASCRIPT_SLEEP_SECONDS=4 \ + FAKE_MOUNT_HELPER_SLEEP_SECONDS=4 \ GDRIVE_BACKUP_NAS_URL="smb://backup.test/share" \ GDRIVE_BACKUP_NAS_MOUNT_TIMEOUT_SECONDS=1 \ GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS=0 \ @@ -1779,6 +1863,350 @@ test_concurrent_start_preserves_previous_summary() { fi } +test_lock_loser_preserves_owner_progress() { + local name="lock loser cannot invalidate the owner's live progress" + local progress before after status + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + before=$'protocol=1\nprofile_id=default\npid=4242\nstarted_at=1783790000\ntrigger=schedule\nlabel=My Drive\nphase=1/2\npercent=42\ndetail=42 MiB / 100 MiB, 1 MiB/s, ETA 58s\nupdated_at=1783790010' + printf '%s\n' "$before" >"$progress" + chmod 600 "$progress" + + FAKE_FLOCK_STATUS=1 run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" + status=$? + after="$(cat "$progress" 2>/dev/null || true)" + + if [[ "$status" == "0" && "$after" == "$before" ]]; then + pass "$name" + else + fail "$name (exit=$status progress=${after//$'\n'/,})" + fi +} + +test_terminal_summary_publish_failure_keeps_progress_live() { + local name="failed terminal summary publication cannot finish durable progress" + local progress summary status warning_count log_file + prepare_test_environment + enable_state_publish_order_spy + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + + GDRIVE_BACKUP_FAIL_TERMINAL_SUMMARY=1 run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" + status=$? + summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + progress="$(cat "$progress" 2>/dev/null || true)" + log_file="$TEST_HOME/Library/Logs/gdrive-backup.log" + warning_count="$(grep -Fc 'Backup-Status konnte nicht sicher aktualisiert werden.' \ + "$log_file" 2>/dev/null || true)" + + if [[ "$status" == "0" && "$summary" == *$'status=running\n'* && + "$summary" != *$'finished_at='* && + "$progress" != *$'status=finished\n'* && + "$warning_count" == "1" ]]; then + pass "$name" + else + fail "$name (exit=$status warning_count=$warning_count summary=${summary//$'\n'/,} progress=${progress//$'\n'/,})" + fi +} + +test_unknown_total_refreshes_indeterminate_progress() { + local name="unknown-total aggregate refreshes private phase-only progress" + local progress epoch_file content status backup_pid attempt + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + epoch_file="$TEST_HOME/epoch" + mkdir -p "${progress%/*}" + printf '1783790000\n' >"$epoch_file" + + FAKE_RCLONE_COPY_OUTPUT='Transferred: 12.000 MiB / 0 B, -, 1.500 MiB/s, ETA -' \ + FAKE_RCLONE_ADVANCE_EPOCH_TO=1783790121 \ + FAKE_RCLONE_SLEEP_SECONDS=2 \ + run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" \ + "FAKE_DATE_EPOCH_FILE=$epoch_file" & + backup_pid=$! + + content="" + for attempt in {1..200}; do + : "$attempt" + content="$(cat "$progress" 2>/dev/null || true)" + [[ "$content" == *'updated_at=1783790121'* ]] && break + /bin/sleep 0.02 + done + kill -TERM "$backup_pid" 2>/dev/null || true + wait "$backup_pid" 2>/dev/null + status=$? + + if [[ "$status" == "143" && + "$content" == *$'label=My Drive\n'* && + "$content" == *$'phase=1/2\n'* && + "$content" == *'updated_at=1783790121'* && + "$content" != *$'percent='* && + "$content" != *$'detail='* && + "$content" != *'secret'* ]]; then + pass "$name" + else + fail "$name (exit=$status progress=${content//$'\n'/,})" + fi +} + +test_off_total_refreshes_indeterminate_progress() { + local name="canonical off-total aggregate refreshes phase-only progress" + local progress epoch_file content status backup_pid attempt + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + epoch_file="$TEST_HOME/epoch" + mkdir -p "${progress%/*}" + printf '1783790200\n' >"$epoch_file" + + FAKE_RCLONE_COPY_OUTPUT='Transferred: 12.000 MiB / off, -, 1.500 MiB/s, ETA -' \ + FAKE_RCLONE_ADVANCE_EPOCH_TO=1783790321 \ + FAKE_RCLONE_SLEEP_SECONDS=2 \ + run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" \ + "FAKE_DATE_EPOCH_FILE=$epoch_file" & + backup_pid=$! + + content="" + for attempt in {1..200}; do + : "$attempt" + content="$(cat "$progress" 2>/dev/null || true)" + [[ "$content" == *'updated_at=1783790321'* ]] && break + /bin/sleep 0.02 + done + kill -TERM "$backup_pid" 2>/dev/null || true + wait "$backup_pid" 2>/dev/null + status=$? + + if [[ "$status" == "143" && + "$content" == *$'label=My Drive\n'* && + "$content" == *$'phase=1/2\n'* && + "$content" == *'updated_at=1783790321'* && + "$content" != *$'percent='* && + "$content" != *$'detail='* ]]; then + pass "$name" + else + fail "$name (exit=$status progress=${content//$'\n'/,})" + fi +} + +test_rich_progress_failure_falls_back_to_phase_only() { + local name="failed rich publication replaces prior percent with phase-only progress" + local progress count_file content status backup_pid attempt count + prepare_test_environment + enable_state_publish_order_spy + progress="$TEST_HOME/profiles/default/current-progress.status" + count_file="$TEST_HOME/rich-progress-count" + mkdir -p "${progress%/*}" + + GDRIVE_BACKUP_FAIL_RICH_PROGRESS_AT=2 \ + GDRIVE_BACKUP_RICH_PROGRESS_COUNT_FILE="$count_file" \ + FAKE_RCLONE_COPY_OUTPUT=$'Transferred: 250.000 MiB / 1.000 GiB, 25%, 10.000 MiB/s, ETA 1m\nTransferred: 630.000 MiB / 1.000 GiB, 63%, 10.000 MiB/s, ETA 37s' \ + FAKE_RCLONE_SLEEP_SECONDS=5 \ + run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" & + backup_pid=$! + + content="" + count="0" + for attempt in {1..200}; do + : "$attempt" + count="$(cat "$count_file" 2>/dev/null || printf '0')" + content="$(cat "$progress" 2>/dev/null || true)" + if [[ "$count" == "2" && "$content" != *$'percent='* && + "$content" != *$'detail='* ]]; then + break + fi + /bin/sleep 0.02 + done + kill -TERM "$backup_pid" 2>/dev/null || true + wait "$backup_pid" 2>/dev/null + status=$? + + if [[ "$status" == "143" && "$count" == "2" && + "$content" == *$'label=My Drive\n'* && + "$content" == *$'phase=1/2\n'* && + "$content" != *$'percent='* && + "$content" != *$'detail='* ]]; then + pass "$name" + else + fail "$name (exit=$status rich_attempts=$count progress=${content//$'\n'/,})" + fi +} + +test_foreground_copy_starts_indeterminate() { + local name="foreground copy phase starts indeterminate without invented zero" + local content snapshot_file status + prepare_test_environment + enable_state_publish_order_spy + mkdir -p "$TEST_HOME/GDrive Backup Tiger.app" + snapshot_file="$TEST_HOME/copy-start-progress.status" + + BACKUP_DISABLE_ANIMATION=0 \ + GDRIVE_BACKUP_FOREGROUND_PROGRESS_SNAPSHOT_FILE="$snapshot_file" \ + run_backup \ + "GDRIVE_BACKUP_ANIMATION_APP=$TEST_HOME/GDrive Backup Tiger.app" \ + "GDRIVE_BACKUP_OPEN_BIN=$FAKE_BIN/open" \ + "BACKUP_PROGRESS_FOREGROUND=1" + status=$? + content="$(cat "$snapshot_file" 2>/dev/null || true)" + + if [[ "$status" == "0" && + "$content" == $'label=My Drive\nphase=1/2' ]]; then + pass "$name" + else + fail "$name (exit=$status progress=${content//$'\n'/,})" + fi +} + +test_headless_retry_publishes_private_progress() { + local name="headless retry publishes private aggregate progress" + local progress content summary mode status backup_pid attempt + local summary_pid summary_started progress_updated + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + + FAKE_RCLONE_COPY_OUTPUT=$'INFO : secret-file.pdf: Copied\nTransferred: 1.200 GiB / 1.900 GiB, 63%, 12.400 MiB/s, ETA 58s' \ + FAKE_RCLONE_SLEEP_SECONDS=3 \ + run_backup \ + "GDRIVE_BACKUP_TRIGGER=schedule-retry" \ + "GDRIVE_BACKUP_RETRY_ORIGIN_STARTED_AT=1785520805" \ + "GDRIVE_BACKUP_RETRY_ATTEMPT=1" \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" \ + "BACKUP_PROGRESS_FOREGROUND=0" & + backup_pid=$! + + for attempt in {1..100}; do + : "$attempt" + content="$(cat "$progress" 2>/dev/null || true)" + [[ "$content" == *$'percent=63\n'* ]] && break + sleep 0.05 + done + mode="$(stat -f '%Lp' "$progress" 2>/dev/null || true)" + content="$(cat "$progress" 2>/dev/null || true)" + summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + summary_pid="$(awk -F= '$1 == "pid" {print $2}' "$SUMMARY_STATE_FILE")" + summary_started="$(awk -F= '$1 == "started_at" {print $2}' "$SUMMARY_STATE_FILE")" + progress_updated="$(awk -F= '$1 == "updated_at" {print $2}' "$progress")" + wait "$backup_pid" + status=$? + + if [[ "$status" == "0" && "$mode" == "600" && + "$content" == *$'protocol=1\n'* && + "$content" == *$'profile_id=default\n'* && + "$summary" == *$'status=running\n'* && + "$summary_pid" =~ ^[0-9]+$ && + "$summary_started" =~ ^[0-9]+$ && + "$content" == *"pid=$summary_pid"* && + "$content" == *"started_at=$summary_started"* && + "$content" == *$'trigger=schedule-retry\n'* && + "$content" == *$'retry_attempt=1\n'* && + "$content" == *$'label=My Drive\n'* && + "$content" == *$'phase=1/2\n'* && + "$content" == *$'percent=63\n'* && + "$content" == *$'detail=1.200 GiB / 1.900 GiB, 12.400 MiB/s, ETA 58s\n'* && + "$progress_updated" =~ ^[0-9]+$ && + "$content" != *'secret-file.pdf'* ]]; then + pass "$name" + else + fail "$name (exit=$status mode=$mode progress=${content//$'\n'/,})" + fi +} + +test_headless_retry_never_opens_progress_window() { + local name="headless retry telemetry remains passive" + local progress status open_args terminal + prepare_test_environment + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" "$TEST_HOME/GDrive Backup Tiger.app" + + BACKUP_DISABLE_ANIMATION=0 \ + run_backup \ + "GDRIVE_BACKUP_TRIGGER=schedule-retry" \ + "GDRIVE_BACKUP_RETRY_ORIGIN_STARTED_AT=1785520805" \ + "GDRIVE_BACKUP_RETRY_ATTEMPT=1" \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" \ + "GDRIVE_BACKUP_ANIMATION_APP=$TEST_HOME/GDrive Backup Tiger.app" \ + "GDRIVE_BACKUP_OPEN_BIN=$FAKE_BIN/open" \ + "BACKUP_PROGRESS_FOREGROUND=0" + status=$? + open_args="$(cat "$OPEN_LOG" 2>/dev/null || true)" + terminal="$(cat "$progress" 2>/dev/null || true)" + + if [[ "$status" == "0" && -z "$open_args" && + "$terminal" == *$'status=finished\n'* && + "$terminal" != *$'percent='* ]]; then + pass "$name" + else + fail "$name (exit=$status open=${open_args//$'\n'/,} progress=${terminal//$'\n'/,})" + fi +} + +test_terminal_outcomes_invalidate_durable_progress() { + local name="terminal summaries invalidate durable progress" + local progress status success_summary success_progress + local failure_summary failure_progress success_order failure_order + local order_log success_ok=0 + + prepare_test_environment + enable_state_publish_order_spy + order_log="$TEST_HOME/state-publish-order.log" + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG=$order_log" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" + status=$? + success_summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + success_progress="$(cat "$progress" 2>/dev/null || true)" + success_order="$(last_terminal_publish_order "$order_log")" + if [[ "$status" == "0" && + "$success_summary" == *$'status=success\n'* && + "$success_summary" == *$'exit_code=0\n'* && + "$success_progress" == *$'status=finished\n'* && + "$success_progress" != *$'percent='* && + "$success_order" == "last-run.status,current-progress.status" ]]; then + success_ok=1 + fi + + prepare_test_environment + enable_state_publish_order_spy + order_log="$TEST_HOME/state-publish-order.log" + progress="$TEST_HOME/profiles/default/current-progress.status" + mkdir -p "${progress%/*}" + FAKE_RCLONE_COPY_STATUS=23 run_backup \ + "GDRIVE_BACKUP_PROFILE_ID=default" \ + "GDRIVE_BACKUP_STATE_PUBLISH_TEST_LOG=$order_log" \ + "GDRIVE_BACKUP_PROGRESS_STATE_FILE=$progress" + status=$? + failure_summary="$(cat "$SUMMARY_STATE_FILE" 2>/dev/null || true)" + failure_progress="$(cat "$progress" 2>/dev/null || true)" + failure_order="$(last_terminal_publish_order "$order_log")" + + if [[ "$success_ok" == "1" && "$status" == "1" && + "$failure_summary" == *$'status=failure\n'* && + "$failure_summary" == *$'exit_code=1\n'* && + "$failure_progress" == *$'status=finished\n'* && + "$failure_progress" != *$'percent='* && + "$failure_order" == "last-run.status,current-progress.status" ]]; then + pass "$name" + else + fail "$name (success=${success_summary//$'\n'/,}; failure=${failure_summary//$'\n'/,}; progress=${failure_progress//$'\n'/,})" + fi +} + test_failed_backup_publishes_failure test_successful_backup_publishes_success test_animation_receives_run_state_file @@ -1838,7 +2266,16 @@ test_success_persists_private_summary test_failure_persists_failed_summary test_later_failure_preserves_last_success_marker test_concurrent_start_preserves_previous_summary +test_lock_loser_preserves_owner_progress +test_terminal_summary_publish_failure_keeps_progress_live +test_unknown_total_refreshes_indeterminate_progress +test_off_total_refreshes_indeterminate_progress +test_rich_progress_failure_falls_back_to_phase_only +test_foreground_copy_starts_indeterminate test_paused_automatic_run_is_silent_and_preserves_history +test_headless_retry_publishes_private_progress +test_headless_retry_never_opens_progress_window +test_terminal_outcomes_invalidate_durable_progress if (( failures > 0 )); then printf '%s backup outcome test(s) failed.\n' "$failures" diff --git a/tests/diagnostics-integration-test.m b/tests/diagnostics-integration-test.m index 84349bc..14135cb 100644 --- a/tests/diagnostics-integration-test.m +++ b/tests/diagnostics-integration-test.m @@ -1,5 +1,7 @@ #import +#import "TestApplicationSupport.h" + #define main GDTApplicationMain #import "../macos/GDriveBackupTiger/main.m" #undef main @@ -29,7 +31,11 @@ static void Assert(BOOL condition, NSString *name) { int main(void) { @autoreleasepool { - [NSApplication sharedApplication]; + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the diagnostics integration harness stays out of the Dock"); DiagnosticsLaunchDelegate *delegate = [[DiagnosticsLaunchDelegate alloc] init]; delegate.language = @"de"; diff --git a/tests/diagnostics-ui-test.m b/tests/diagnostics-ui-test.m index c71f636..db1384c 100644 --- a/tests/diagnostics-ui-test.m +++ b/tests/diagnostics-ui-test.m @@ -1,6 +1,7 @@ #import #import "Localization.h" +#import "TestApplicationSupport.h" static int failures = 0; @@ -23,7 +24,11 @@ static void ApplySnapshot(NSView *view, NSDictionary *snapshot) int main(void) { @autoreleasepool { - [NSApplication sharedApplication]; + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the diagnostics UI harness stays out of the Dock"); Class viewClass = NSClassFromString(@"GDTDiagnosticsView"); Assert(viewClass != Nil, @"native diagnostics view is available"); if (viewClass) { diff --git a/tests/nas-mount-url-test.m b/tests/nas-mount-url-test.m new file mode 100644 index 0000000..cd60334 --- /dev/null +++ b/tests/nas-mount-url-test.m @@ -0,0 +1,62 @@ +#import + +extern NSString *GDTNASRemountURLForMountedSMBSource(NSString *source); +extern NSString *GDTPreferredNASRemountURL( + NSString *resourceURLString, + NSString *mountedSource, + BOOL isSMBMount); + +static void AssertEqual(NSString *actual, NSString *expected, NSString *message) { + if (![actual isEqualToString:expected]) { + NSLog(@"FAIL: %@\nexpected: <%@>\nactual: <%@>", message, expected, actual); + exit(1); + } +} + +int main(void) { + @autoreleasepool { + AssertEqual( + GDTNASRemountURLForMountedSMBSource(@"//backup-user@nas.local/Backups"), + @"smb://backup-user@nas.local/Backups", + @"the remount URL must retain the SMB account so Keychain can select credentials"); + AssertEqual( + GDTNASRemountURLForMountedSMBSource( + @"//backup-user:must-not-be-stored@nas.local/Backups"), + @"smb://backup-user@nas.local/Backups", + @"the remount URL must never retain a password"); + AssertEqual( + GDTNASRemountURLForMountedSMBSource(@"//nas.local/Backups"), + @"smb://nas.local/Backups", + @"guest mounts remain valid without user information"); + AssertEqual( + GDTPreferredNASRemountURL( + @"smb://nas.local/Backups", + @"//backup-user@nas.local/Backups", + YES), + @"smb://backup-user@nas.local/Backups", + @"an account-qualified mount source must repair an accountless resource URL"); + AssertEqual( + GDTPreferredNASRemountURL( + @"smb://saved-user@nas.local/Backups", + @"//nas.local/Backups", + YES), + @"smb://saved-user@nas.local/Backups", + @"an accountless mount source must not discard a saved account"); + AssertEqual( + GDTPreferredNASRemountURL( + @"smb://saved-user:must-not-be-stored@nas.local/Backups", + @"//nas.local/Backups", + YES), + @"smb://saved-user@nas.local/Backups", + @"the resource URL fallback must never retain a password"); + AssertEqual( + GDTPreferredNASRemountURL( + @"afp://nas.local/Backups", + @"//backup-user@nas.local/Backups", + NO), + @"afp://nas.local/Backups", + @"an AFP mount source must never be rewritten as SMB"); + } + NSLog(@"NAS mount URL tests passed."); + return 0; +} diff --git a/tests/network-mount-support-test.m b/tests/network-mount-support-test.m new file mode 100644 index 0000000..30c2c0f --- /dev/null +++ b/tests/network-mount-support-test.m @@ -0,0 +1,350 @@ +#import +#import +#include + +typedef NSData * _Nullable (^GDTTestCredentialLookup)( + NSString *host, NSString *account, NSString *path, BOOL allowUserInteraction); +typedef int (^GDTTestMountOperation)( + NSURL *url, NSString *account, NSData *passwordData); +typedef int (^GDTTestCLIHandler)(NSString *urlString, BOOL authorizeCredential); +typedef int (^GDTTestKeychainInteractionSetter)(BOOL allowInteraction); + +extern int GDTHandleSMBURLWithHandlers( + NSString *urlString, + BOOL allowCredentialUI, + BOOL performMount, + GDTTestCredentialLookup credentialLookup, + GDTTestMountOperation mountOperation); +extern int GDTHandleNetworkMountCLIArguments( + NSArray *arguments, + GDTTestCLIHandler handler, + BOOL *handled); +extern BOOL GDTConfigureLegacyKeychainInteraction( + BOOL allowInteraction, + GDTTestKeychainInteractionSetter setter); +extern int GDTMountSMBURLFromKeychain(NSString *urlString); + +static BOOL NetFSGuestModeWasSet = NO; +static BOOL NetFSNoUIWasSet = NO; +static BOOL NetFSReceivedEmptyCredentials = NO; +static NSUInteger NetFSMountCallCount = 0; + +int NetFSMountURLSync( + CFURLRef url, + CFURLRef mountpath, + CFStringRef user, + CFStringRef passwd, + CFMutableDictionaryRef openOptions, + CFMutableDictionaryRef mountOptions, + CFArrayRef *mountpoints) { + (void)url; + (void)mountpath; + (void)mountOptions; + (void)mountpoints; + NSDictionary *options = (__bridge NSDictionary *)openOptions; + NetFSGuestModeWasSet = [options[(__bridge id)kNetFSUseGuestKey] boolValue]; + NetFSNoUIWasSet = [options[(__bridge id)kNAUIOptionKey] + isEqual:(__bridge id)kNAUIOptionNoUI]; + NSString *account = (__bridge NSString *)user; + NSString *password = (__bridge NSString *)passwd; + NetFSReceivedEmptyCredentials = account.length == 0 && password.length == 0; + NetFSMountCallCount++; + return 0; +} + +static void Assert(BOOL condition, NSString *message) { + if (!condition) { + NSLog(@"FAIL: %@", message); + exit(1); + } +} + +static BOOL DataIsZeroed(NSData *data) { + const uint8_t *bytes = data.bytes; + for (NSUInteger index = 0; index < data.length; index++) { + if (bytes[index] != 0) { + return NO; + } + } + return YES; +} + +int main(void) { + @autoreleasepool { + __block BOOL lookupCalled = NO; + __block BOOL mountCalled = NO; + NSMutableData *password = [[@"test-secret" + dataUsingEncoding:NSUTF8StringEncoding] mutableCopy]; + int result = GDTHandleSMBURLWithHandlers( + @"smb://backup-user@nas.local/Backups", + NO, + YES, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + lookupCalled = YES; + Assert([host isEqualToString:@"nas.local"], @"host reaches credential lookup"); + Assert([account isEqualToString:@"backup-user"], @"account reaches credential lookup"); + Assert([path isEqualToString:@"/Backups"], @"share path reaches credential lookup"); + Assert(!allowUserInteraction, @"automatic mounts must forbid credential UI"); + return password; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + mountCalled = YES; + Assert([url.absoluteString isEqualToString: + @"smb://backup-user@nas.local/Backups"], + @"the validated URL reaches the mount operation"); + Assert([account isEqualToString:@"backup-user"], + @"the validated account reaches the mount operation"); + Assert([passwordData isEqualToData: + [@"test-secret" dataUsingEncoding:NSUTF8StringEncoding]], + @"the credential reaches the mount operation only in memory"); + return 0; + }); + Assert(result == 0 && lookupCalled && mountCalled, + @"a complete account-qualified SMB request mounts successfully"); + Assert(DataIsZeroed(password), + @"credential bytes are cleared after the mount operation"); + + __block BOOL guestLookupCalled = NO; + __block BOOL guestMountCalled = NO; + result = GDTHandleSMBURLWithHandlers( + @"smb://nas.local/Backups", + NO, + YES, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + (void)host; + (void)account; + (void)path; + (void)allowUserInteraction; + guestLookupCalled = YES; + return [[@"must-not-be-used" + dataUsingEncoding:NSUTF8StringEncoding] mutableCopy]; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + guestMountCalled = YES; + Assert([url.absoluteString isEqualToString: + @"smb://nas.local/Backups"], + @"the accountless URL reaches the guest mount operation"); + Assert(account.length == 0, + @"a guest mount receives an empty account"); + Assert(passwordData.length == 0, + @"a guest mount receives an empty password"); + return 0; + }); + Assert(result == 0 && !guestLookupCalled && guestMountCalled, + @"an accountless SMB request mounts as guest without Keychain access"); + + guestLookupCalled = NO; + result = GDTHandleSMBURLWithHandlers( + @"smb://nas.local/Backups", + NO, + YES, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + (void)host; + (void)account; + (void)path; + (void)allowUserInteraction; + guestLookupCalled = YES; + return [NSData data]; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + (void)url; + (void)account; + (void)passwordData; + return EACCES; + }); + Assert(result == 69 && !guestLookupCalled, + @"a failed guest attempt remains a safe mount failure without Keychain fallback"); + + __block BOOL guestAuthorizationLookupCalled = NO; + __block BOOL guestAuthorizationMountCalled = NO; + result = GDTHandleSMBURLWithHandlers( + @"smb://nas.local/Backups", + YES, + NO, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + (void)host; + (void)account; + (void)path; + (void)allowUserInteraction; + guestAuthorizationLookupCalled = YES; + return [NSData data]; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + (void)url; + (void)account; + (void)passwordData; + guestAuthorizationMountCalled = YES; + return 0; + }); + Assert(result == 0 && !guestAuthorizationLookupCalled && + !guestAuthorizationMountCalled, + @"explicit authorization is a no-op for an accountless guest URL"); + + NSArray *malformedGuestURLs = @[ + @"smb:///Backups", + @"smb://nas.local", + @"smb://nas.local//", + @"smb://@nas.local/Backups", + @"https://nas.local/Backups" + ]; + for (NSString *malformedURL in malformedGuestURLs) { + __block BOOL malformedLookupCalled = NO; + __block BOOL malformedMountCalled = NO; + result = GDTHandleSMBURLWithHandlers( + malformedURL, + NO, + YES, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + (void)host; + (void)account; + (void)path; + (void)allowUserInteraction; + malformedLookupCalled = YES; + return [NSData data]; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + (void)url; + (void)account; + (void)passwordData; + malformedMountCalled = YES; + return 0; + }); + Assert(result == 64 && !malformedLookupCalled && !malformedMountCalled, + [NSString stringWithFormat: + @"malformed guest URL is rejected before credentials or mount: %@", + malformedURL]); + } + + result = GDTMountSMBURLFromKeychain(@"smb://nas.local/Backups"); + Assert(result == 0 && NetFSMountCallCount == 1, + @"the production accountless path reaches NetFS exactly once"); + Assert(NetFSGuestModeWasSet, + @"the production accountless path enables NetFS guest mode"); + Assert(NetFSNoUIWasSet, + @"the production accountless path retains the NetFS no-UI policy"); + Assert(NetFSReceivedEmptyCredentials, + @"the production accountless path gives NetFS empty credentials"); + + __block BOOL unsafeLookupCalled = NO; + result = GDTHandleSMBURLWithHandlers( + @"smb://backup-user:must-not-be-accepted@nas.local/Backups", + NO, + YES, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + (void)host; + (void)account; + (void)path; + (void)allowUserInteraction; + unsafeLookupCalled = YES; + return [NSData data]; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + (void)url; + (void)account; + (void)passwordData; + return 0; + }); + Assert(result == 64 && !unsafeLookupCalled, + @"passwords embedded in NAS URLs fail before Keychain access"); + + __block BOOL authorizationMountCalled = NO; + result = GDTHandleSMBURLWithHandlers( + @"smb://backup-user@nas.local/Backups", + YES, + NO, + ^NSData *(NSString *host, NSString *account, NSString *path, + BOOL allowUserInteraction) { + (void)host; + (void)account; + (void)path; + Assert(allowUserInteraction, + @"explicit setup authorization may show the Keychain prompt"); + return [[@"test-secret" dataUsingEncoding:NSUTF8StringEncoding] mutableCopy]; + }, + ^int(NSURL *url, NSString *account, NSData *passwordData) { + (void)url; + (void)account; + (void)passwordData; + authorizationMountCalled = YES; + return 0; + }); + Assert(result == 0 && !authorizationMountCalled, + @"credential authorization never mounts the share"); + + __block BOOL cliCalled = NO; + __block BOOL cliAuthorized = YES; + result = GDTHandleNetworkMountCLIArguments( + @[@"GDriveBackupTiger", @"--mount-network-url", + @"smb://backup-user@nas.local/Backups"], + ^int(NSString *urlString, BOOL authorizeCredential) { + cliCalled = YES; + cliAuthorized = authorizeCredential; + Assert([urlString isEqualToString: + @"smb://backup-user@nas.local/Backups"], + @"the CLI passes the configured SMB URL to the native helper"); + return 0; + }, + NULL); + Assert(result == 0 && cliCalled && !cliAuthorized, + @"the automatic CLI selects a no-UI mount"); + + BOOL handled = NO; + cliCalled = NO; + result = GDTHandleNetworkMountCLIArguments( + @[@"GDriveBackupTiger", @"--authorize-network-url", + @"smb://backup-user@nas.local/Backups"], + ^int(NSString *urlString, BOOL authorizeCredential) { + (void)urlString; + cliCalled = YES; + cliAuthorized = authorizeCredential; + return 0; + }, + &handled); + Assert(result == 0 && handled && cliCalled && cliAuthorized, + @"explicit setup can request one credential authorization"); + + handled = NO; + cliCalled = NO; + result = GDTHandleNetworkMountCLIArguments( + @[@"GDriveBackupTiger", @"--menubar"], + ^int(NSString *urlString, BOOL authorizeCredential) { + (void)urlString; + (void)authorizeCredential; + cliCalled = YES; + return 0; + }, + &handled); + Assert(result == 0 && !handled && !cliCalled, + @"ordinary app modes continue to the graphical application"); + + handled = NO; + result = GDTHandleNetworkMountCLIArguments( + @[@"GDriveBackupTiger", @"--mount-network-url"], + ^int(NSString *urlString, BOOL authorizeCredential) { + (void)urlString; + (void)authorizeCredential; + return 0; + }, + &handled); + Assert(result == 64 && handled, + @"an incomplete mount command fails before the graphical app starts"); + + __block BOOL interactionAllowed = YES; + BOOL interactionConfigured = GDTConfigureLegacyKeychainInteraction( + NO, + ^int(BOOL allowInteraction) { + interactionAllowed = allowInteraction; + return 0; + }); + Assert(interactionConfigured && !interactionAllowed, + @"automatic lookup disables legacy Keychain UI before reading credentials"); + } + NSLog(@"Network mount support tests passed."); + return 0; +} diff --git a/tests/notification-integration-test.m b/tests/notification-integration-test.m index 8d8bb7d..161c65b 100644 --- a/tests/notification-integration-test.m +++ b/tests/notification-integration-test.m @@ -1,5 +1,7 @@ #import +#import "TestApplicationSupport.h" + #define main GDTApplicationMain #import "../macos/GDriveBackupTiger/main.m" #undef main @@ -13,6 +15,12 @@ - (void)deliverBackupNotificationDecision:(NSDictionary - (UNMutableNotificationContent *)backupNotificationContentForDecision: (NSDictionary *)decision; - (BOOL)timeSensitiveBackupNotificationsEnabled; +- (void)removeExactBackupNotificationIdentifiers:(NSArray *)identifiers + forProfileID:(NSString *)profileID; +- (void)removeDeliveredBackupNotificationIdentifiers:(NSArray *)identifiers; +- (void)removePendingBackupNotificationIdentifiers:(NSArray *)identifiers; +- (void)enumerateDeliveredBackupNotificationsWithCompletion: + (void (^)(NSArray *notifications))completion; - (void)clearBackupFailureNotificationsForConfig: (NSDictionary *)config summary:(NSDictionary *)summary @@ -23,6 +31,10 @@ - (NSString *)backupAlertStatusForConfig:(NSDictionary * rawStatus:(NSString *)rawStatus decision:(NSDictionary *)decision; - (NSSet *)appNotificationCategories; +- (void)handleBackupNotificationActionIdentifier:(NSString *)actionIdentifier + categoryIdentifier:(NSString *)categoryIdentifier + userInfo:(NSDictionary *)userInfo + notificationIdentifier:(NSString *)notificationIdentifier; - (UNMutableNotificationContent *)unknownExternalVolumeNotificationContentForDescriptor: (NSDictionary *)descriptor; - (UNNotificationPresentationOptions)presentationOptionsForNotificationCategoryIdentifier: @@ -37,15 +49,49 @@ - (void)rememberUnknownExternalAttachmentForDiskID:(NSString *)diskID - (void)forgetUnknownExternalAttachmentForDiskID:(NSString *)diskID; @end +@interface TestNotificationEnvelope : NSObject +@property(nonatomic, strong) UNNotificationRequest *request; +@end + +@implementation TestNotificationEnvelope +@end + +@interface TestNotificationResponse : NSObject +@property(nonatomic, copy) NSString *actionIdentifier; +@property(nonatomic, strong) TestNotificationEnvelope *notification; +@end + +@implementation TestNotificationResponse +@end + @interface NotificationTestDelegate : AppDelegate @property(nonatomic, strong) NSUserDefaults *testDefaults; @property(nonatomic) NSInteger deliveryCalls; @property(nonatomic) BOOL deliverySucceeds; @property(nonatomic) BOOL testTimeSensitiveNotificationsEnabled; +@property(nonatomic) BOOL deferBackupDelivery; +@property(nonatomic, strong) NSMutableArray *deferredBackupDeliveryCompletions; +@property(nonatomic, strong) NSMutableDictionary *> * + acceptedBackupDecisionsByIdentifier; @property(nonatomic, copy) NSArray *removedNotificationIdentifiers; +@property(nonatomic, copy) NSArray *removedDeliveredNotificationIdentifiers; +@property(nonatomic, copy) NSArray *removedPendingNotificationIdentifiers; @property(nonatomic, copy) NSArray *extraDeliveredNotificationIdentifiers; @end +@interface BackupRemovalSeamTestDelegate : AppDelegate +@property(nonatomic, copy) NSArray *removedDeliveredNotificationIdentifiers; +@property(nonatomic, copy) NSArray *removedPendingNotificationIdentifiers; +@end + +@interface BackupCleanupRaceTestDelegate : AppDelegate +@property(nonatomic, strong) NSUserDefaults *testDefaults; +@property(nonatomic, copy) NSArray *removedDeliveredNotificationIdentifiers; +@property(nonatomic, copy) NSArray *removedPendingNotificationIdentifiers; +@property(nonatomic, copy) void (^deferredDeliveredEnumeration)( + NSArray *notifications); +@end + @interface UnknownVolumeNotificationTestDelegate : NotificationTestDelegate @property(nonatomic, copy) NSDictionary *revalidatedDescriptor; @property(nonatomic) NSInteger revalidationCalls; @@ -216,19 +262,118 @@ - (BOOL)timeSensitiveBackupNotificationsEnabled { - (void)deliverBackupNotificationDecision:(NSDictionary *)decision completion:(void (^)(BOOL delivered))completion { - (void)decision; self.deliveryCalls++; - completion(self.deliverySucceeds); + NSDictionary *capturedDecision = [decision copy]; + __weak typeof(self) weakSelf = self; + void (^finish)(BOOL) = ^(BOOL delivered) { + typeof(self) strongSelf = weakSelf; + if (delivered && capturedDecision[@"identifier"].length) { + if (!strongSelf.acceptedBackupDecisionsByIdentifier) { + strongSelf.acceptedBackupDecisionsByIdentifier = + [NSMutableDictionary dictionary]; + } + strongSelf.acceptedBackupDecisionsByIdentifier[ + capturedDecision[@"identifier"]] = capturedDecision; + } + completion(delivered); + }; + if (self.deferBackupDelivery) { + if (!self.deferredBackupDeliveryCompletions) { + self.deferredBackupDeliveryCompletions = [NSMutableArray array]; + } + [self.deferredBackupDeliveryCompletions addObject:[finish copy]]; + return; + } + finish(self.deliverySucceeds); } -- (void)removeBackupNotificationIdentifiers:(NSArray *)identifiers - forProfileID:(NSString *)profileID { - (void)profileID; - NSMutableArray *all = [identifiers mutableCopy] ?: [NSMutableArray array]; +- (void)enumerateDeliveredBackupNotificationsWithCompletion: + (void (^)(NSArray *notifications))completion { + NSMutableArray *notifications = [NSMutableArray array]; for (NSString *identifier in self.extraDeliveredNotificationIdentifiers ?: @[]) { - if (![all containsObject:identifier]) [all addObject:identifier]; + UNMutableNotificationContent *content = + [[UNMutableNotificationContent alloc] init]; + TestNotificationEnvelope *envelope = [[TestNotificationEnvelope alloc] init]; + envelope.request = [UNNotificationRequest requestWithIdentifier:identifier + content:content + trigger:nil]; + [notifications addObject:(UNNotification *)envelope]; } - self.removedNotificationIdentifiers = all; + completion(notifications); +} + +- (void)removeDeliveredBackupNotificationIdentifiers:(NSArray *)identifiers { + self.removedDeliveredNotificationIdentifiers = identifiers; + self.removedNotificationIdentifiers = identifiers; + [self.acceptedBackupDecisionsByIdentifier removeObjectsForKeys:identifiers]; +} + +- (void)removePendingBackupNotificationIdentifiers:(NSArray *)identifiers { + self.removedPendingNotificationIdentifiers = identifiers; + self.removedNotificationIdentifiers = identifiers; +} + +@end + + +@implementation BackupRemovalSeamTestDelegate + +- (void)removeDeliveredBackupNotificationIdentifiers:(NSArray *)identifiers { + self.removedDeliveredNotificationIdentifiers = identifiers; +} + +- (void)removePendingBackupNotificationIdentifiers:(NSArray *)identifiers { + self.removedPendingNotificationIdentifiers = identifiers; +} + +@end + +@implementation BackupCleanupRaceTestDelegate + +- (NSUserDefaults *)backupNotificationDefaultsStore { + return self.testDefaults; +} + +- (void)enumerateDeliveredBackupNotificationsWithCompletion: + (void (^)(NSArray *notifications))completion { + self.deferredDeliveredEnumeration = completion; +} + +- (void)removeDeliveredBackupNotificationIdentifiers:(NSArray *)identifiers { + self.removedDeliveredNotificationIdentifiers = identifiers; +} + +- (void)removePendingBackupNotificationIdentifiers:(NSArray *)identifiers { + self.removedPendingNotificationIdentifiers = identifiers; +} + +@end + +@interface BackupActionTestDelegate : NotificationTestDelegate +@property(nonatomic) NSInteger overviewShowCalls; +@property(nonatomic) NSInteger overviewRefreshCalls; +@property(nonatomic) NSInteger backupLaunchCalls; +@end + +@implementation BackupActionTestDelegate + +- (void)showOverviewWindow { + self.overviewShowCalls++; +} + +- (void)refreshOverviewStatus:(id)sender { + (void)sender; + self.overviewRefreshCalls++; +} + +- (BOOL)launchBackupWithArgument:(NSString *)argument + trigger:(NSString *)trigger + assumeYes:(BOOL)assumeYes { + (void)argument; + (void)trigger; + (void)assumeYes; + self.backupLaunchCalls++; + return YES; } @end @@ -301,6 +446,40 @@ static void ProcessRetry(RetryTestDelegate *delegate, method(delegate, selector, decision); } +static void HandleBackupAction(id delegate, NSString *action, + NSString *category, NSDictionary *userInfo, + NSString *identifier) { + SEL selector = NSSelectorFromString( + @"handleBackupNotificationActionIdentifier:categoryIdentifier:userInfo:notificationIdentifier:"); + typedef void (*ActionMethod)(id, SEL, NSString *, NSString *, + NSDictionary *, NSString *); + ActionMethod method = [delegate respondsToSelector:selector] + ? (ActionMethod)[delegate methodForSelector:selector] : NULL; + if (method) method(delegate, selector, action, category, userInfo, identifier); +} + +static BOOL RouteBackupResponse(AppDelegate *delegate, NSString *action, + NSString *category, NSDictionary *userInfo, + NSString *identifier) { + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.categoryIdentifier = category; + content.userInfo = userInfo; + TestNotificationEnvelope *notification = [[TestNotificationEnvelope alloc] init]; + notification.request = [UNNotificationRequest requestWithIdentifier:identifier + content:content + trigger:nil]; + TestNotificationResponse *response = [[TestNotificationResponse alloc] init]; + response.actionIdentifier = action; + response.notification = notification; + __block BOOL completed = NO; + UNUserNotificationCenter *unusedCenter = + (UNUserNotificationCenter *)(id)[NSObject new]; + [delegate userNotificationCenter:unusedCenter + didReceiveNotificationResponse:(UNNotificationResponse *)(id)response + withCompletionHandler:^{ completed = YES; }]; + return completed; +} + static void ProcessUnknownVolumeAction(AppDelegate *delegate, NSString *action, NSDictionary *userInfo) { @@ -346,6 +525,18 @@ static void PrepareUnknownVolumeDeliveryState( delegate.notifiedUnknownExternalDiskIDs = [NSMutableSet set]; } +static UNNotification *DeliveredBackupNotification( + NSString *identifier, NSString *categoryIdentifier, NSDictionary *userInfo) { + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.categoryIdentifier = categoryIdentifier ?: @""; + content.userInfo = userInfo ?: @{}; + TestNotificationEnvelope *envelope = [[TestNotificationEnvelope alloc] init]; + envelope.request = [UNNotificationRequest requestWithIdentifier:identifier + content:content + trigger:nil]; + return (UNNotification *)envelope; +} + int main(void) { @autoreleasepool { NotificationTestDelegate *delegate = [[NotificationTestDelegate alloc] init]; @@ -363,6 +554,7 @@ int main(void) { @"profileID": @"office", @"kind": @"failure", @"issueTimestamp": @"100", + @"issueOriginTimestamp": @"100", @"titleKey": @"backupNotificationFailureTitle", @"bodyKey": @"failedHint" }; @@ -374,12 +566,14 @@ int main(void) { NSMutableDictionary *nextRun = [first mutableCopy]; nextRun[@"identifier"] = @"com.commcats.gdrivebackup.office.failure.200"; nextRun[@"issueTimestamp"] = @"200"; + nextRun[@"issueOriginTimestamp"] = @"200"; Process(delegate, nextRun); Assert(delegate.deliveryCalls == 2, @"a later failed run remains eligible for its own notification"); NSMutableDictionary *otherProfile = [first mutableCopy]; otherProfile[@"profileID"] = @"archive"; + otherProfile[@"identifier"] = @"com.commcats.gdrivebackup.archive.failure.100"; Process(delegate, otherProfile); Assert(delegate.deliveryCalls == 3, @"notification deduplication is isolated per backup profile"); @@ -387,6 +581,8 @@ int main(void) { delegate.deliverySucceeds = NO; NSMutableDictionary *notDelivered = [first mutableCopy]; notDelivered[@"identifier"] = @"com.commcats.gdrivebackup.office.failure.300"; + notDelivered[@"issueTimestamp"] = @"300"; + notDelivered[@"issueOriginTimestamp"] = @"300"; Process(delegate, notDelivered); Process(delegate, notDelivered); Assert(delegate.deliveryCalls == 5, @@ -396,6 +592,739 @@ int main(void) { Assert(delegate.deliveryCalls == 5, @"incomplete policy output cannot create a notification"); + BackupRemovalSeamTestDelegate *removalProbe = + [[BackupRemovalSeamTestDelegate alloc] init]; + BOOL exactRemovalSeamsAvailable = + [AppDelegate instancesRespondToSelector: + @selector(removeDeliveredBackupNotificationIdentifiers:)] && + [AppDelegate instancesRespondToSelector: + @selector(removePendingBackupNotificationIdentifiers:)]; + if (exactRemovalSeamsAvailable) { + [removalProbe removeExactBackupNotificationIdentifiers:@[ + @"com.commcats.gdrivebackup.office.failure.100", + @"com.commcats.gdrivebackup.archive.failure.100", + @"not-a-backup-notification" + ] forProfileID:@"office"]; + } + NSArray *oneSafeRemoval = + @[@"com.commcats.gdrivebackup.office.failure.100"]; + Assert(exactRemovalSeamsAvailable && + [removalProbe.removedDeliveredNotificationIdentifiers + isEqualToArray:oneSafeRemoval] && + [removalProbe.removedPendingNotificationIdentifiers + isEqualToArray:oneSafeRemoval], + @"exact retirement filters identifiers and removes both delivered and pending requests"); + + NotificationTestDelegate *replacement = + [[NotificationTestDelegate alloc] init]; + replacement.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".replacement"]]; + replacement.deliverySucceeds = YES; + NSDictionary *preliminary = @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.400", + @"profileID": @"office", + @"kind": @"failure", + @"issueTimestamp": @"405", + @"issueOriginTimestamp": @"400", + @"titleKey": @"backupNotificationFailureTitle", + @"bodyKey": @"backupNotificationNASRetryBody" + }; + NSMutableDictionary *retryRunningDecision = [preliminary mutableCopy]; + retryRunningDecision[@"kind"] = @"retry-running"; + retryRunningDecision[@"revision"] = @"retry-running.430"; + retryRunningDecision[@"issueTimestamp"] = @"400"; + retryRunningDecision[@"titleKey"] = @"backupNotificationRetryRunningTitle"; + retryRunningDecision[@"bodyKey"] = @"backupNotificationRetryRunningBody"; + NSDictionary *finalRetryFailure = @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.430", + @"supersedesIdentifier": + @"com.commcats.gdrivebackup.office.failure.400", + @"profileID": @"office", + @"kind": @"failure", + @"issueTimestamp": @"435", + @"issueOriginTimestamp": @"400", + @"titleKey": @"backupNotificationFailureTitle", + @"bodyKey": @"backupNotificationRetryFailureBody" + }; + Process(replacement, preliminary); + Process(replacement, retryRunningDecision); + Process(replacement, retryRunningDecision); + Assert(replacement.deliveryCalls == 2 && + replacement.removedNotificationIdentifiers == nil, + @"retry start updates one identifier once without a duplicate alert"); + + NotificationTestDelegate *restarted = [[NotificationTestDelegate alloc] init]; + restarted.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName stringByAppendingString:@".replacement"]]; + restarted.deliverySucceeds = YES; + Process(restarted, retryRunningDecision); + Assert(restarted.deliveryCalls == 0, + @"a controller restart does not repeat the running revision"); + + NotificationTestDelegate *inFlightReplacement = + [[NotificationTestDelegate alloc] init]; + inFlightReplacement.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".in-flight-replacement"]]; + inFlightReplacement.deliverySucceeds = YES; + inFlightReplacement.deferBackupDelivery = YES; + Process(inFlightReplacement, preliminary); + Process(inFlightReplacement, retryRunningDecision); + Process(inFlightReplacement, retryRunningDecision); + Assert(inFlightReplacement.deliveryCalls == 1 && + inFlightReplacement.deferredBackupDeliveryCompletions.count == 1, + @"a running revision waits behind one in-flight preliminary request without duplicating itself"); + void (^finishPreliminary)(BOOL) = + inFlightReplacement.deferredBackupDeliveryCompletions[0]; + finishPreliminary(YES); + Assert(inFlightReplacement.deliveryCalls == 2 && + inFlightReplacement.deferredBackupDeliveryCompletions.count == 2, + @"accepting the preliminary request immediately submits its queued running replacement"); + Process(inFlightReplacement, retryRunningDecision); + Assert(inFlightReplacement.deliveryCalls == 2, + @"the same running revision is suppressed while its delivery is in flight"); + void (^finishRunning)(BOOL) = + inFlightReplacement.deferredBackupDeliveryCompletions[1]; + finishRunning(YES); + inFlightReplacement.deferBackupDelivery = NO; + Process(inFlightReplacement, retryRunningDecision); + NSDictionary *visibleReplacement = + inFlightReplacement.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]]; + Assert(inFlightReplacement.deliveryCalls == 2 && + [visibleReplacement[@"kind"] isEqualToString:@"retry-running"] && + [[inFlightReplacement.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.430"], + @"serialized acceptance leaves the running revision visible and durable"); + + NotificationTestDelegate *latchedRunningDelivery = + [[NotificationTestDelegate alloc] init]; + latchedRunningDelivery.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".latched-running-delivery"]]; + latchedRunningDelivery.deliverySucceeds = YES; + [latchedRunningDelivery.testDefaults setDouble:400 + forKey:@"GDTBackupNotification.office.activeIssueAt"]; + [latchedRunningDelivery.testDefaults setObject:@"failure" + forKey:@"GDTBackupNotification.office.activeIssueKind"]; + [latchedRunningDelivery.testDefaults setObject:preliminary[@"identifier"] + forKey:@"GDTBackupNotification.office.activeIssueIdentifier"]; + Process(latchedRunningDelivery, retryRunningDecision); + Assert(latchedRunningDelivery.deliveryCalls == 1 && + [latchedRunningDelivery.testDefaults doubleForKey: + @"GDTBackupNotification.office.activeIssueAt"] == 400 && + [[latchedRunningDelivery.testDefaults stringForKey: + @"GDTBackupNotification.office.activeIssueKind"] + isEqualToString:@"failure"] && + [[latchedRunningDelivery.testDefaults stringForKey: + @"GDTBackupNotification.office.activeIssueIdentifier"] + isEqualToString:preliminary[@"identifier"]], + @"delivering retry progress never changes the canonical failure latch"); + + NotificationTestDelegate *successDuringDelivery = + [[NotificationTestDelegate alloc] init]; + successDuringDelivery.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".success-during-delivery"]]; + successDuringDelivery.deliverySucceeds = YES; + successDuringDelivery.deferBackupDelivery = YES; + Process(successDuringDelivery, preliminary); + Process(successDuringDelivery, retryRunningDecision); + Assert(successDuringDelivery.deferredBackupDeliveryCompletions.count == 1, + @"the success race captures one in-flight callback with running work queued"); + [successDuringDelivery clearBackupFailureNotificationsForConfig: + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"} + summary:@{@"status": @"success", @"finished_at": @"500", + @"trigger": @"schedule-retry"} + status:@"success"]; + void (^finishAfterSuccess)(BOOL) = + successDuringDelivery.deferredBackupDeliveryCompletions[0]; + finishAfterSuccess(YES); + Assert(successDuringDelivery.deliveryCalls == 1 && + successDuringDelivery.deferredBackupDeliveryCompletions.count == 1 && + successDuringDelivery.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [successDuringDelivery.testDefaults objectForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] == nil && + [successDuringDelivery.testDefaults objectForKey: + @"GDTBackupNotification.office.deliveredFailureIdentifiers"] == nil && + [successDuringDelivery.testDefaults objectForKey: + @"GDTBackupNotification.office.latestDeliveredIssueAt"] == nil && + [successDuringDelivery.testDefaults objectForKey: + @"GDTBackupNotification.office.latestDeliveredIssueStage"] == nil && + [successDuringDelivery.testDefaults objectForKey: + @"GDTBackupNotification.office.latestDeliveredIssueState"] == nil && + [successDuringDelivery.removedDeliveredNotificationIdentifiers + isEqualToArray:@[preliminary[@"identifier"]]] && + [successDuringDelivery.removedPendingNotificationIdentifiers + isEqualToArray:@[preliminary[@"identifier"]]], + @"a success invalidates and retires an alert accepted by a late delivery callback"); + + NotificationTestDelegate *terminalSupersessionOrdering = + [[NotificationTestDelegate alloc] init]; + terminalSupersessionOrdering.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName + stringByAppendingString:@".terminal-supersession-ordering"]]; + terminalSupersessionOrdering.deliverySucceeds = YES; + terminalSupersessionOrdering.deferBackupDelivery = YES; + Process(terminalSupersessionOrdering, preliminary); + Process(terminalSupersessionOrdering, retryRunningDecision); + Process(terminalSupersessionOrdering, finalRetryFailure); + Process(terminalSupersessionOrdering, retryRunningDecision); + Assert(terminalSupersessionOrdering.deliveryCalls == 1 && + terminalSupersessionOrdering.deferredBackupDeliveryCompletions.count == 1, + @"one in-flight request coalesces retry lifecycle updates for its profile"); + void (^finishBeforeTerminal)(BOOL) = + terminalSupersessionOrdering.deferredBackupDeliveryCompletions[0]; + finishBeforeTerminal(YES); + Assert(terminalSupersessionOrdering.deliveryCalls == 2 && + terminalSupersessionOrdering.deferredBackupDeliveryCompletions.count == 2, + @"the terminal retry result is the only queued request submitted next"); + void (^finishTerminal)(BOOL) = + terminalSupersessionOrdering.deferredBackupDeliveryCompletions[1]; + finishTerminal(YES); + Assert(terminalSupersessionOrdering.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [terminalSupersessionOrdering.acceptedBackupDecisionsByIdentifier[ + finalRetryFailure[@"identifier"]][@"bodyKey"] + isEqualToString:@"backupNotificationRetryFailureBody"] && + [[terminalSupersessionOrdering.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:finalRetryFailure[@"identifier"]], + @"a stale running observation cannot displace a queued terminal retry result"); + + NotificationTestDelegate *staleOriginOrdering = + [[NotificationTestDelegate alloc] init]; + staleOriginOrdering.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName + stringByAppendingString:@".stale-origin-ordering"]]; + staleOriginOrdering.deliverySucceeds = YES; + staleOriginOrdering.deferBackupDelivery = YES; + NSMutableDictionary *newerIndependentFailure = + [preliminary mutableCopy]; + newerIndependentFailure[@"identifier"] = + @"com.commcats.gdrivebackup.office.failure.500"; + newerIndependentFailure[@"issueTimestamp"] = @"505"; + newerIndependentFailure[@"issueOriginTimestamp"] = @"500"; + Process(staleOriginOrdering, newerIndependentFailure); + Process(staleOriginOrdering, preliminary); + Assert(staleOriginOrdering.deliveryCalls == 1 && + staleOriginOrdering.deferredBackupDeliveryCompletions.count == 1, + @"an older origin waits on neither a second request nor a queued rollback"); + void (^finishNewerOrigin)(BOOL) = + staleOriginOrdering.deferredBackupDeliveryCompletions[0]; + finishNewerOrigin(YES); + Assert(staleOriginOrdering.deliveryCalls == 1 && + staleOriginOrdering.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [staleOriginOrdering.acceptedBackupDecisionsByIdentifier[ + newerIndependentFailure[@"identifier"]][@"issueOriginTimestamp"] + isEqualToString:@"500"], + @"a stale origin cannot queue behind and roll back a newer in-flight issue"); + + NSString *acceptedOriginKey = + @"GDTBackupNotification.office.latestDeliveredIssueAt"; + NSString *acceptedStageKey = + @"GDTBackupNotification.office.latestDeliveredIssueStage"; + NSString *acceptedStateKey = + @"GDTBackupNotification.office.latestDeliveredIssueState"; + + NotificationTestDelegate *acceptedTerminalOrdering = + [[NotificationTestDelegate alloc] init]; + acceptedTerminalOrdering.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName + stringByAppendingString:@".accepted-terminal-ordering"]]; + acceptedTerminalOrdering.deliverySucceeds = YES; + Process(acceptedTerminalOrdering, preliminary); + Process(acceptedTerminalOrdering, finalRetryFailure); + Process(acceptedTerminalOrdering, retryRunningDecision); + Assert(acceptedTerminalOrdering.deliveryCalls == 2 && + acceptedTerminalOrdering.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [acceptedTerminalOrdering.acceptedBackupDecisionsByIdentifier[ + finalRetryFailure[@"identifier"]][@"bodyKey"] + isEqualToString:@"backupNotificationRetryFailureBody"] && + [acceptedTerminalOrdering.testDefaults doubleForKey: + acceptedOriginKey] == 400 && + [acceptedTerminalOrdering.testDefaults integerForKey: + acceptedStageKey] == 3 && + [[acceptedTerminalOrdering.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:finalRetryFailure[@"identifier"]], + @"an accepted terminal retry failure cannot be replaced by a later stale running observation"); + + NSString *terminalRestartSuite = + [suiteName stringByAppendingString:@".accepted-terminal-restart"]; + NotificationTestDelegate *terminalRestartSource = + [[NotificationTestDelegate alloc] init]; + terminalRestartSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:terminalRestartSuite]; + terminalRestartSource.deliverySucceeds = YES; + Process(terminalRestartSource, preliminary); + Process(terminalRestartSource, finalRetryFailure); + NotificationTestDelegate *terminalAfterRestart = + [[NotificationTestDelegate alloc] init]; + terminalAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:terminalRestartSuite]; + terminalAfterRestart.deliverySucceeds = YES; + terminalAfterRestart.acceptedBackupDecisionsByIdentifier = + terminalRestartSource.acceptedBackupDecisionsByIdentifier; + Process(terminalAfterRestart, retryRunningDecision); + Assert(terminalAfterRestart.deliveryCalls == 0 && + terminalAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [terminalAfterRestart.acceptedBackupDecisionsByIdentifier[ + finalRetryFailure[@"identifier"]][@"bodyKey"] + isEqualToString:@"backupNotificationRetryFailureBody"] && + [terminalAfterRestart.testDefaults doubleForKey: + acceptedOriginKey] == 400 && + [terminalAfterRestart.testDefaults integerForKey: + acceptedStageKey] == 3 && + [[terminalAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:finalRetryFailure[@"identifier"]], + @"a restart preserves terminal retry ordering against a stale running observation"); + + NSString *missingTerminalStateSuite = + [suiteName stringByAppendingString:@".missing-terminal-state"]; + NotificationTestDelegate *missingTerminalStateSource = + [[NotificationTestDelegate alloc] init]; + missingTerminalStateSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:missingTerminalStateSuite]; + missingTerminalStateSource.deliverySucceeds = YES; + Process(missingTerminalStateSource, preliminary); + Process(missingTerminalStateSource, finalRetryFailure); + [missingTerminalStateSource.testDefaults removeObjectForKey: + acceptedStateKey]; + [missingTerminalStateSource.testDefaults removeObjectForKey: + acceptedStageKey]; + NotificationTestDelegate *missingTerminalStateAfterRestart = + [[NotificationTestDelegate alloc] init]; + missingTerminalStateAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:missingTerminalStateSuite]; + missingTerminalStateAfterRestart.deliverySucceeds = YES; + missingTerminalStateAfterRestart.acceptedBackupDecisionsByIdentifier = + missingTerminalStateSource.acceptedBackupDecisionsByIdentifier; + Process(missingTerminalStateAfterRestart, retryRunningDecision); + NSDictionary *migratedMissingTerminalState = + [missingTerminalStateAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(missingTerminalStateAfterRestart.deliveryCalls == 0 && + missingTerminalStateAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [missingTerminalStateAfterRestart.acceptedBackupDecisionsByIdentifier[ + finalRetryFailure[@"identifier"]][@"bodyKey"] + isEqualToString:@"backupNotificationRetryFailureBody"] && + [[missingTerminalStateAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:finalRetryFailure[@"identifier"]] && + [missingTerminalStateAfterRestart.testDefaults objectForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] == nil && + [migratedMissingTerminalState[@"origin"] doubleValue] == 400 && + [migratedMissingTerminalState[@"stage"] integerValue] == 3, + @"a restart infers an accepted terminal stage before any stale running replay"); + + NSString *incompleteTerminalStateSuite = + [suiteName stringByAppendingString:@".incomplete-terminal-state"]; + NotificationTestDelegate *incompleteTerminalStateSource = + [[NotificationTestDelegate alloc] init]; + incompleteTerminalStateSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:incompleteTerminalStateSuite]; + incompleteTerminalStateSource.deliverySucceeds = YES; + incompleteTerminalStateSource.acceptedBackupDecisionsByIdentifier = + [@{finalRetryFailure[@"identifier"]: finalRetryFailure} mutableCopy]; + [incompleteTerminalStateSource.testDefaults setDouble:400 + forKey:acceptedOriginKey]; + [incompleteTerminalStateSource.testDefaults setObject:@{@"origin": @400} + forKey:acceptedStateKey]; + [incompleteTerminalStateSource.testDefaults + setObject:finalRetryFailure[@"identifier"] + forKey:@"GDTBackupNotification.office.lastDeliveredIdentifier"]; + NotificationTestDelegate *incompleteTerminalStateAfterRestart = + [[NotificationTestDelegate alloc] init]; + incompleteTerminalStateAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:incompleteTerminalStateSuite]; + incompleteTerminalStateAfterRestart.deliverySucceeds = YES; + incompleteTerminalStateAfterRestart.acceptedBackupDecisionsByIdentifier = + incompleteTerminalStateSource.acceptedBackupDecisionsByIdentifier; + Process(incompleteTerminalStateAfterRestart, retryRunningDecision); + NSDictionary *migratedIncompleteTerminalState = + [incompleteTerminalStateAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(incompleteTerminalStateAfterRestart.deliveryCalls == 0 && + incompleteTerminalStateAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [incompleteTerminalStateAfterRestart.acceptedBackupDecisionsByIdentifier[ + finalRetryFailure[@"identifier"]][@"bodyKey"] + isEqualToString:@"backupNotificationRetryFailureBody"] && + [[incompleteTerminalStateAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:finalRetryFailure[@"identifier"]] && + [incompleteTerminalStateAfterRestart.testDefaults objectForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] == nil && + [migratedIncompleteTerminalState[@"origin"] doubleValue] == 400 && + [migratedIncompleteTerminalState[@"stage"] integerValue] == 3, + @"an incomplete accepted terminal state fails closed before stale running delivery"); + + NSString *legacyPreliminarySuite = + [suiteName stringByAppendingString:@".legacy-preliminary-state"]; + NotificationTestDelegate *legacyPreliminarySource = + [[NotificationTestDelegate alloc] init]; + legacyPreliminarySource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:legacyPreliminarySuite]; + legacyPreliminarySource.deliverySucceeds = YES; + Process(legacyPreliminarySource, preliminary); + [legacyPreliminarySource.testDefaults removeObjectForKey:acceptedStateKey]; + [legacyPreliminarySource.testDefaults removeObjectForKey:acceptedStageKey]; + NotificationTestDelegate *legacyPreliminaryAfterRestart = + [[NotificationTestDelegate alloc] init]; + legacyPreliminaryAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:legacyPreliminarySuite]; + legacyPreliminaryAfterRestart.deliverySucceeds = YES; + legacyPreliminaryAfterRestart.acceptedBackupDecisionsByIdentifier = + legacyPreliminarySource.acceptedBackupDecisionsByIdentifier; + Process(legacyPreliminaryAfterRestart, retryRunningDecision); + NSDictionary *migratedLegacyPreliminaryState = + [legacyPreliminaryAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(legacyPreliminaryAfterRestart.deliveryCalls == 1 && + [legacyPreliminaryAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]][@"kind"] + isEqualToString:@"retry-running"] && + [[legacyPreliminaryAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:preliminary[@"identifier"]] && + [[legacyPreliminaryAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.430"] && + [migratedLegacyPreliminaryState[@"origin"] doubleValue] == 400 && + [migratedLegacyPreliminaryState[@"stage"] integerValue] == 2, + @"a legacy preliminary warning still accepts its running update during migration"); + + NSString *tornLegacyOriginSuite = + [suiteName stringByAppendingString:@".torn-legacy-origin-stage"]; + NotificationTestDelegate *tornLegacyOriginSource = + [[NotificationTestDelegate alloc] init]; + tornLegacyOriginSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:tornLegacyOriginSuite]; + tornLegacyOriginSource.deliverySucceeds = YES; + tornLegacyOriginSource.acceptedBackupDecisionsByIdentifier = + [@{newerIndependentFailure[@"identifier"]: newerIndependentFailure} + mutableCopy]; + [tornLegacyOriginSource.testDefaults setObject:@{@"origin": @500} + forKey:acceptedStateKey]; + [tornLegacyOriginSource.testDefaults setDouble:400 + forKey:acceptedOriginKey]; + [tornLegacyOriginSource.testDefaults setInteger:3 + forKey:acceptedStageKey]; + [tornLegacyOriginSource.testDefaults + setObject:newerIndependentFailure[@"identifier"] + forKey:@"GDTBackupNotification.office.lastDeliveredIdentifier"]; + NSMutableDictionary *newerLegacyRetryRunning = + [newerIndependentFailure mutableCopy]; + newerLegacyRetryRunning[@"kind"] = @"retry-running"; + newerLegacyRetryRunning[@"revision"] = @"retry-running.510"; + newerLegacyRetryRunning[@"titleKey"] = + @"backupNotificationRetryRunningTitle"; + newerLegacyRetryRunning[@"bodyKey"] = + @"backupNotificationRetryRunningBody"; + NotificationTestDelegate *tornLegacyOriginAfterRestart = + [[NotificationTestDelegate alloc] init]; + tornLegacyOriginAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:tornLegacyOriginSuite]; + tornLegacyOriginAfterRestart.deliverySucceeds = YES; + tornLegacyOriginAfterRestart.acceptedBackupDecisionsByIdentifier = + tornLegacyOriginSource.acceptedBackupDecisionsByIdentifier; + Process(tornLegacyOriginAfterRestart, newerLegacyRetryRunning); + NSDictionary *migratedTornLegacyOriginState = + [tornLegacyOriginAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(tornLegacyOriginAfterRestart.deliveryCalls == 1 && + [tornLegacyOriginAfterRestart.acceptedBackupDecisionsByIdentifier[ + newerIndependentFailure[@"identifier"]][@"kind"] + isEqualToString:@"retry-running"] && + [[tornLegacyOriginAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:newerIndependentFailure[@"identifier"]] && + [[tornLegacyOriginAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.510"] && + [migratedTornLegacyOriginState[@"origin"] doubleValue] == 500 && + [migratedTornLegacyOriginState[@"stage"] integerValue] == 2, + @"an incomplete newer origin discards an unpaired older terminal stage during migration"); + + NSString *legacyRunningSuite = + [suiteName stringByAppendingString:@".legacy-running-state"]; + NotificationTestDelegate *legacyRunningSource = + [[NotificationTestDelegate alloc] init]; + legacyRunningSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:legacyRunningSuite]; + legacyRunningSource.deliverySucceeds = YES; + Process(legacyRunningSource, preliminary); + Process(legacyRunningSource, retryRunningDecision); + [legacyRunningSource.testDefaults removeObjectForKey:acceptedStateKey]; + [legacyRunningSource.testDefaults removeObjectForKey:acceptedStageKey]; + NSMutableDictionary *laterRetryRunning = + [retryRunningDecision mutableCopy]; + laterRetryRunning[@"revision"] = @"retry-running.440"; + NotificationTestDelegate *legacyRunningAfterRestart = + [[NotificationTestDelegate alloc] init]; + legacyRunningAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:legacyRunningSuite]; + legacyRunningAfterRestart.deliverySucceeds = YES; + legacyRunningAfterRestart.acceptedBackupDecisionsByIdentifier = + legacyRunningSource.acceptedBackupDecisionsByIdentifier; + Process(legacyRunningAfterRestart, preliminary); + NSDictionary *migratedLegacyRunningState = + [legacyRunningAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(legacyRunningAfterRestart.deliveryCalls == 0 && + [legacyRunningAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]][@"kind"] + isEqualToString:@"retry-running"] && + [[legacyRunningAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.430"] && + [migratedLegacyRunningState[@"origin"] doubleValue] == 400 && + [migratedLegacyRunningState[@"stage"] integerValue] == 2, + @"a legacy running warning migrates to stage two before rejecting preliminary rollback"); + Process(legacyRunningAfterRestart, laterRetryRunning); + NSDictionary *advancedLegacyRunningState = + [legacyRunningAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(legacyRunningAfterRestart.deliveryCalls == 1 && + [legacyRunningAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]][@"kind"] + isEqualToString:@"retry-running"] && + [[legacyRunningAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.440"] && + [advancedLegacyRunningState[@"origin"] doubleValue] == 400 && + [advancedLegacyRunningState[@"stage"] integerValue] == 2, + @"a legacy running warning rejects preliminary rollback but accepts a later running revision"); + + NSString *malformedLegacyRevisionSuite = + [suiteName stringByAppendingString:@".malformed-legacy-revision"]; + NotificationTestDelegate *malformedLegacyRevisionSource = + [[NotificationTestDelegate alloc] init]; + malformedLegacyRevisionSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:malformedLegacyRevisionSuite]; + malformedLegacyRevisionSource.deliverySucceeds = YES; + malformedLegacyRevisionSource.acceptedBackupDecisionsByIdentifier = + [@{preliminary[@"identifier"]: preliminary} mutableCopy]; + [malformedLegacyRevisionSource.testDefaults setDouble:400 + forKey:acceptedOriginKey]; + [malformedLegacyRevisionSource.testDefaults + setObject:preliminary[@"identifier"] + forKey:@"GDTBackupNotification.office.lastDeliveredIdentifier"]; + [malformedLegacyRevisionSource.testDefaults + setObject:@"retry-running.+430" + forKey:@"GDTBackupNotification.office.lastDeliveredRevision"]; + NotificationTestDelegate *malformedLegacyRevisionAfterRestart = + [[NotificationTestDelegate alloc] init]; + malformedLegacyRevisionAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:malformedLegacyRevisionSuite]; + malformedLegacyRevisionAfterRestart.deliverySucceeds = YES; + malformedLegacyRevisionAfterRestart.acceptedBackupDecisionsByIdentifier = + malformedLegacyRevisionSource.acceptedBackupDecisionsByIdentifier; + Process(malformedLegacyRevisionAfterRestart, laterRetryRunning); + NSDictionary *migratedMalformedLegacyState = + [malformedLegacyRevisionAfterRestart.testDefaults dictionaryForKey: + acceptedStateKey]; + Assert(malformedLegacyRevisionAfterRestart.deliveryCalls == 0 && + [malformedLegacyRevisionAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]][@"kind"] + isEqualToString:@"failure"] && + [[malformedLegacyRevisionAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:preliminary[@"identifier"]] && + [[malformedLegacyRevisionAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.+430"] && + [migratedMalformedLegacyState[@"origin"] doubleValue] == 400 && + [migratedMalformedLegacyState[@"stage"] integerValue] == 3, + @"a malformed legacy running revision fails closed instead of authorizing an update"); + + NotificationTestDelegate *interruptedTerminalPersistence = + [[NotificationTestDelegate alloc] init]; + interruptedTerminalPersistence.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName + stringByAppendingString:@".interrupted-terminal-persistence"]]; + interruptedTerminalPersistence.deliverySucceeds = YES; + [interruptedTerminalPersistence.testDefaults + setObject:finalRetryFailure[@"identifier"] + forKey:@"GDTBackupNotification.office.lastDeliveredIdentifier"]; + [interruptedTerminalPersistence.testDefaults + setObject:@[preliminary[@"identifier"]] + forKey:@"GDTBackupNotification.office.deliveredFailureIdentifiers"]; + [interruptedTerminalPersistence.testDefaults setDouble:400 + forKey:acceptedOriginKey]; + [interruptedTerminalPersistence.testDefaults setInteger:2 + forKey:acceptedStageKey]; + Process(interruptedTerminalPersistence, finalRetryFailure); + Process(interruptedTerminalPersistence, retryRunningDecision); + Assert(interruptedTerminalPersistence.deliveryCalls == 0 && + [interruptedTerminalPersistence.testDefaults integerForKey: + acceptedStageKey] == 3 && + [[interruptedTerminalPersistence.testDefaults stringArrayForKey: + @"GDTBackupNotification.office.deliveredFailureIdentifiers"] + isEqualToArray:@[finalRetryFailure[@"identifier"]]] && + [interruptedTerminalPersistence.removedNotificationIdentifiers + isEqualToArray:@[preliminary[@"identifier"]]], + @"replaying an interrupted accepted terminal update repairs its durable stage before stale running work"); + + NotificationTestDelegate *acceptedNewerOriginOrdering = + [[NotificationTestDelegate alloc] init]; + acceptedNewerOriginOrdering.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName + stringByAppendingString:@".accepted-newer-origin-ordering"]]; + acceptedNewerOriginOrdering.deliverySucceeds = YES; + Process(acceptedNewerOriginOrdering, newerIndependentFailure); + Process(acceptedNewerOriginOrdering, preliminary); + Assert(acceptedNewerOriginOrdering.deliveryCalls == 1 && + acceptedNewerOriginOrdering.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [acceptedNewerOriginOrdering.acceptedBackupDecisionsByIdentifier[ + newerIndependentFailure[@"identifier"]][@"issueOriginTimestamp"] + isEqualToString:@"500"] && + [acceptedNewerOriginOrdering.testDefaults doubleForKey: + acceptedOriginKey] == 500 && + [acceptedNewerOriginOrdering.testDefaults integerForKey: + acceptedStageKey] == 1 && + [[acceptedNewerOriginOrdering.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:newerIndependentFailure[@"identifier"]], + @"an accepted newer issue origin cannot be rolled back by an older origin"); + + NSString *newerOriginRestartSuite = + [suiteName stringByAppendingString:@".accepted-newer-origin-restart"]; + NotificationTestDelegate *newerOriginRestartSource = + [[NotificationTestDelegate alloc] init]; + newerOriginRestartSource.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:newerOriginRestartSuite]; + newerOriginRestartSource.deliverySucceeds = YES; + Process(newerOriginRestartSource, newerIndependentFailure); + NotificationTestDelegate *newerOriginAfterRestart = + [[NotificationTestDelegate alloc] init]; + newerOriginAfterRestart.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:newerOriginRestartSuite]; + newerOriginAfterRestart.deliverySucceeds = YES; + newerOriginAfterRestart.acceptedBackupDecisionsByIdentifier = + newerOriginRestartSource.acceptedBackupDecisionsByIdentifier; + Process(newerOriginAfterRestart, preliminary); + Assert(newerOriginAfterRestart.deliveryCalls == 0 && + newerOriginAfterRestart.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]] == nil && + [newerOriginAfterRestart.acceptedBackupDecisionsByIdentifier[ + newerIndependentFailure[@"identifier"]][@"issueOriginTimestamp"] + isEqualToString:@"500"] && + [newerOriginAfterRestart.testDefaults doubleForKey: + acceptedOriginKey] == 500 && + [newerOriginAfterRestart.testDefaults integerForKey: + acceptedStageKey] == 1 && + [[newerOriginAfterRestart.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] + isEqualToString:newerIndependentFailure[@"identifier"]], + @"a restart preserves a newer accepted origin against an older observation"); + + NotificationTestDelegate *tornNewerOriginMirrors = + [[NotificationTestDelegate alloc] init]; + tornNewerOriginMirrors.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:[suiteName + stringByAppendingString:@".torn-newer-origin-mirrors"]]; + tornNewerOriginMirrors.deliverySucceeds = YES; + tornNewerOriginMirrors.acceptedBackupDecisionsByIdentifier = + [@{newerIndependentFailure[@"identifier"]: newerIndependentFailure} + mutableCopy]; + [tornNewerOriginMirrors.testDefaults setObject:@{ + @"origin": @500, + @"stage": @1 + } forKey:acceptedStateKey]; + [tornNewerOriginMirrors.testDefaults setDouble:500 + forKey:acceptedOriginKey]; + [tornNewerOriginMirrors.testDefaults setInteger:3 + forKey:acceptedStageKey]; + NSMutableDictionary *newerRetryRunning = + [newerIndependentFailure mutableCopy]; + newerRetryRunning[@"kind"] = @"retry-running"; + newerRetryRunning[@"revision"] = @"retry-running.510"; + newerRetryRunning[@"titleKey"] = @"backupNotificationRetryRunningTitle"; + newerRetryRunning[@"bodyKey"] = @"backupNotificationRetryRunningBody"; + Process(tornNewerOriginMirrors, newerRetryRunning); + NSDictionary *repairedNewerState = + [tornNewerOriginMirrors.testDefaults dictionaryForKey:acceptedStateKey]; + Assert(tornNewerOriginMirrors.deliveryCalls == 1 && + [tornNewerOriginMirrors.acceptedBackupDecisionsByIdentifier[ + newerIndependentFailure[@"identifier"]][@"kind"] + isEqualToString:@"retry-running"] && + [repairedNewerState[@"origin"] doubleValue] == 500 && + [repairedNewerState[@"stage"] integerValue] == 2 && + [tornNewerOriginMirrors.testDefaults integerForKey: + acceptedStageKey] == 2, + @"one accepted-state record prevents a torn newer origin from inheriting an older terminal stage"); + + Process(replacement, finalRetryFailure); + NSArray *remainingFailureIdentifiers = + [replacement.testDefaults stringArrayForKey: + @"GDTBackupNotification.office.deliveredFailureIdentifiers"]; + Assert(replacement.deliveryCalls == 3 && + [replacement.removedNotificationIdentifiers isEqualToArray: + @[@"com.commcats.gdrivebackup.office.failure.400"]] && + [remainingFailureIdentifiers isEqualToArray: + @[@"com.commcats.gdrivebackup.office.failure.430"]], + @"an accepted final retry failure replaces its preliminary alert"); + + replacement.removedNotificationIdentifiers = nil; + Process(replacement, finalRetryFailure); + Assert(replacement.deliveryCalls == 3 && + [replacement.removedNotificationIdentifiers isEqualToArray: + @[@"com.commcats.gdrivebackup.office.failure.400"]], + @"a restart safely finishes an interrupted preliminary-alert cleanup"); + + NotificationTestDelegate *refusedReplacement = + [[NotificationTestDelegate alloc] init]; + refusedReplacement.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".refused-replacement"]]; + refusedReplacement.deliverySucceeds = YES; + Process(refusedReplacement, preliminary); + Process(refusedReplacement, retryRunningDecision); + refusedReplacement.deliverySucceeds = NO; + Process(refusedReplacement, finalRetryFailure); + Process(refusedReplacement, finalRetryFailure); + NSArray *refusedIdentifiers = + [refusedReplacement.testDefaults stringArrayForKey: + @"GDTBackupNotification.office.deliveredFailureIdentifiers"]; + Assert(refusedReplacement.removedNotificationIdentifiers == nil && + refusedReplacement.deliveryCalls == 4 && + [refusedIdentifiers isEqualToArray: + @[@"com.commcats.gdrivebackup.office.failure.400"]] && + [[refusedReplacement.testDefaults stringForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] + isEqualToString:@"retry-running.430"] && + [refusedReplacement.testDefaults integerForKey: + acceptedStageKey] == 2 && + [refusedReplacement.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]][@"kind"] + isEqualToString:@"retry-running"], + @"a rejected replacement leaves the existing visible warning intact"); + + NotificationTestDelegate *refusedRevision = + [[NotificationTestDelegate alloc] init]; + refusedRevision.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".refused-revision"]]; + refusedRevision.deliverySucceeds = YES; + Process(refusedRevision, preliminary); + refusedRevision.deliverySucceeds = NO; + Process(refusedRevision, retryRunningDecision); + Process(refusedRevision, retryRunningDecision); + Assert(refusedRevision.deliveryCalls == 3 && + [refusedRevision.testDefaults objectForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] == nil && + [refusedRevision.testDefaults integerForKey: + acceptedStageKey] == 1 && + [refusedRevision.acceptedBackupDecisionsByIdentifier[ + preliminary[@"identifier"]][@"kind"] + isEqualToString:@"failure"], + @"a rejected running revision remains retryable and is never persisted"); + RetryTestDelegate *retryDelegate = [[RetryTestDelegate alloc] init]; retryDelegate.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: [suiteName stringByAppendingString:@".retry"]]; @@ -448,8 +1377,10 @@ int main(void) { } Assert(content.sound != nil && [content.categoryIdentifier isEqualToString:@"GDT_BACKUP_ALERT"] && + [content.userInfo[@"profileID"] isEqualToString:@"office"] && + [content.userInfo[@"issueOriginTimestamp"] isEqualToString:@"100"] && activeLevel, - @"ad-hoc builds keep backup alerts audible without requesting a protected level"); + @"backup alerts carry acknowledgement metadata and stay audible at an allowed level"); delegate.testTimeSensitiveNotificationsEnabled = YES; UNMutableNotificationContent *entitledContent = contentMethod @@ -487,13 +1418,15 @@ int main(void) { unknownActionsByID = actions; } Assert(categoriesByID[@"GDT_BACKUP_ALERT"] != nil && + (categoriesByID[@"GDT_BACKUP_ALERT"].options & + UNNotificationCategoryOptionCustomDismissAction) != 0 && unknownCategory != nil && unknownCategory.actions.count == 2 && (unknownActionsByID[@"GDT_UNKNOWN_EXTERNAL_VOLUME_SETUP"].options & UNNotificationActionOptionForeground) != 0 && unknownActionsByID[@"GDT_UNKNOWN_EXTERNAL_VOLUME_IGNORE"].options == UNNotificationActionOptionNone, - @"backup alerts and passive unknown-disk actions are registered together"); + @"backup alerts register explicit dismiss handling beside passive unknown-disk actions"); NSDictionary *unknownDescriptor = @{ @"path": @"/Volumes/Private Customer Folder", @@ -605,7 +1538,9 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( @"unknownExternalVolumeSetupAction", @"unknownExternalVolumeIgnoreAction", @"unknownExternalVolumeReviewSetup", - @"unknownExternalVolumeUnavailable" + @"unknownExternalVolumeUnavailable", + @"backupNotificationRetryRunningTitle", + @"backupNotificationRetryRunningBody" ]) { NSString *localized = T(code, key); if (!localized.length || [localized isEqualToString:key]) { @@ -616,6 +1551,33 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( Assert(unknownVolumeLocalized, @"unknown external-volume notification and setup text is localized in every language"); + NSDictionary *> *retryRunningTranslations = @{ + @"de": @[@"Automatischer Wiederholungsversuch läuft", + @"GDrive wird erneut gesichert. Öffne GDrive Backup Tiger, um den Fortschritt zu sehen."], + @"en": @[@"Automatic backup retry is running", + @"GDrive is being backed up again. Open GDrive Backup Tiger to view progress."], + @"fr": @[@"Nouvelle tentative de sauvegarde automatique en cours", + @"Une nouvelle sauvegarde de GDrive est en cours. Ouvrez GDrive Backup Tiger pour suivre la progression."], + @"es": @[@"Reintento automático de copia de seguridad en curso", + @"Se está realizando de nuevo la copia de seguridad de GDrive. Abre GDrive Backup Tiger para ver el progreso."], + @"ja": @[@"自動バックアップを再試行中", + @"GDrive をもう一度バックアップしています。進行状況を確認するには GDrive Backup Tiger を開いてください。"], + @"yue": @[@"自動備份重試進行中", + @"GDrive 正在再次備份。請開啟 GDrive Backup Tiger 查看進度。"], + @"ko": @[@"자동 백업 재시도 실행 중", + @"GDrive를 다시 백업하고 있습니다. 진행 상황을 보려면 GDrive Backup Tiger를 여십시오."] + }; + BOOL exactRetryRunningTranslations = YES; + for (NSString *code in SupportedLanguageCodes()) { + NSArray *expected = retryRunningTranslations[code]; + exactRetryRunningTranslations = exactRetryRunningTranslations && + expected.count == 2 && + [T(code, @"backupNotificationRetryRunningTitle") isEqualToString:expected[0]] && + [T(code, @"backupNotificationRetryRunningBody") isEqualToString:expected[1]]; + } + Assert(exactRetryRunningTranslations, + @"the retry-running alert uses the reviewed copy in every language"); + UnknownVolumeNotificationTestDelegate *unknownActionDelegate = [[UnknownVolumeNotificationTestDelegate alloc] init]; NSDictionary *unknownUserInfo = @{ @@ -898,11 +1860,156 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( containsObject:@"disk20"], @"a delayed delivery cannot latch a different disk that reused the same disk identifier"); + BackupCleanupRaceTestDelegate *cleanupRace = + [[BackupCleanupRaceTestDelegate alloc] init]; + NSString *cleanupSuiteName = + [suiteName stringByAppendingString:@".cleanup-race"]; + cleanupRace.testDefaults = [[NSUserDefaults alloc] + initWithSuiteName:cleanupSuiteName]; + NSString *cleanupPrefix = @"GDTBackupNotification.office."; + NSString *oldFailureID = + @"com.commcats.gdrivebackup.office.failure.100"; + NSString *oldMissedID = + @"com.commcats.gdrivebackup.office.missed.200"; + NSString *newFailureID = + @"com.commcats.gdrivebackup.office.failure.600"; + [cleanupRace.testDefaults setObject:@[oldFailureID] + forKey:[cleanupPrefix stringByAppendingString: + @"deliveredFailureIdentifiers"]]; + [cleanupRace.testDefaults setObject:oldFailureID + forKey:[cleanupPrefix stringByAppendingString: + @"lastDeliveredIdentifier"]]; + [cleanupRace.testDefaults setObject:@{@"origin": @400, @"stage": @1} + forKey:[cleanupPrefix stringByAppendingString: + @"latestDeliveredIssueState"]]; + [cleanupRace.testDefaults setDouble:400 + forKey:[cleanupPrefix stringByAppendingString: + @"latestDeliveredIssueAt"]]; + [cleanupRace.testDefaults setDouble:400 + forKey:[cleanupPrefix stringByAppendingString:@"activeIssueAt"]]; + + [cleanupRace clearBackupFailureNotificationsForConfig: + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"} + summary:@{@"status": @"success", @"finished_at": @"500", + @"trigger": @"schedule-retry"} + status:@"success"]; + Assert(cleanupRace.deferredDeliveredEnumeration != nil && + cleanupRace.removedDeliveredNotificationIdentifiers == nil && + cleanupRace.removedPendingNotificationIdentifiers == nil, + @"automatic-success cleanup waits for delivered-notification enumeration"); + + [cleanupRace.testDefaults setObject:@[newFailureID] + forKey:[cleanupPrefix stringByAppendingString: + @"deliveredFailureIdentifiers"]]; + [cleanupRace.testDefaults setObject:newFailureID + forKey:[cleanupPrefix stringByAppendingString: + @"lastDeliveredIdentifier"]]; + [cleanupRace.testDefaults setObject:@{@"origin": @600, @"stage": @1} + forKey:[cleanupPrefix stringByAppendingString: + @"latestDeliveredIssueState"]]; + [cleanupRace.testDefaults setDouble:600 + forKey:[cleanupPrefix stringByAppendingString: + @"latestDeliveredIssueAt"]]; + [cleanupRace.testDefaults setInteger:1 + forKey:[cleanupPrefix stringByAppendingString: + @"latestDeliveredIssueStage"]]; + [cleanupRace.testDefaults setDouble:600 + forKey:[cleanupPrefix stringByAppendingString:@"activeIssueAt"]]; + [cleanupRace.testDefaults setObject:@"failure" + forKey:[cleanupPrefix stringByAppendingString:@"activeIssueKind"]]; + [cleanupRace.testDefaults setObject:newFailureID + forKey:[cleanupPrefix stringByAppendingString: + @"activeIssueIdentifier"]]; + + NSString *trustedOldOriginID = + @"com.commcats.gdrivebackup.office.failure.700"; + NSString *trustedNewOriginID = + @"com.commcats.gdrivebackup.office.failure.300"; + NSArray *deliveredDuringCleanup = @[ + DeliveredBackupNotification(oldFailureID, @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"100"}), + DeliveredBackupNotification(oldMissedID, @"", @{}), + DeliveredBackupNotification(newFailureID, @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"600"}), + DeliveredBackupNotification(trustedOldOriginID, @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"400"}), + DeliveredBackupNotification(trustedNewOriginID, @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"600"}), + DeliveredBackupNotification( + @"com.commcats.gdrivebackup.office.failure.not-a-time", + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"100"}), + DeliveredBackupNotification( + @"com.commcats.gdrivebackup.office.missed.0200", @"", @{}), + DeliveredBackupNotification( + @"com.commcats.gdrivebackup.archive.failure.100", + @"GDT_BACKUP_ALERT", + @{@"profileID": @"archive", @"issueOriginTimestamp": @"100"}), + DeliveredBackupNotification( + @"com.commcats.gdrivebackup.office.failure.250", + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"0400"}), + DeliveredBackupNotification( + @"com.commcats.gdrivebackup.office.failure.230", + @"GDT_BACKUP_ALERT", + @{@"profileID": @"archive", @"issueOriginTimestamp": @"230"}), + DeliveredBackupNotification( + @"com.commcats.gdrivebackup.office.failure.240", + @"GDT_UNKNOWN_EXTERNAL_VOLUME", + @{@"profileID": @"office", @"issueOriginTimestamp": @"240"}) + ]; + cleanupRace.deferredDeliveredEnumeration(deliveredDuringCleanup); + cleanupRace.deferredDeliveredEnumeration = nil; + + NSSet *expectedCleanupIDs = [NSSet setWithArray:@[ + oldFailureID, oldMissedID, trustedOldOriginID + ]]; + Assert(cleanupRace.removedDeliveredNotificationIdentifiers.count == 3 && + [[NSSet setWithArray: + cleanupRace.removedDeliveredNotificationIdentifiers] + isEqualToSet:expectedCleanupIDs] && + cleanupRace.removedPendingNotificationIdentifiers.count == 3 && + [[NSSet setWithArray: + cleanupRace.removedPendingNotificationIdentifiers] + isEqualToSet:expectedCleanupIDs], + @"a delayed success cleanup removes only canonical issue origins at or before its cutoff"); + NSDictionary *newAcceptedState = [cleanupRace.testDefaults + dictionaryForKey:[cleanupPrefix stringByAppendingString: + @"latestDeliveredIssueState"]]; + Assert([newAcceptedState[@"origin"] doubleValue] == 600 && + [cleanupRace.testDefaults doubleForKey: + [cleanupPrefix stringByAppendingString:@"activeIssueAt"]] == 600 && + [[cleanupRace.testDefaults stringForKey: + [cleanupPrefix stringByAppendingString: + @"activeIssueIdentifier"]] + isEqualToString:newFailureID] && + [[cleanupRace.testDefaults stringArrayForKey: + [cleanupPrefix stringByAppendingString: + @"deliveredFailureIdentifiers"]] + isEqualToArray:@[newFailureID]], + @"a failure introduced during cleanup stays latched and deduplicated"); + [cleanupRace.testDefaults removePersistentDomainForName:cleanupSuiteName]; + delegate.extraDeliveredNotificationIdentifiers = @[ @"com.commcats.gdrivebackup.office.missed.50" ]; [delegate.testDefaults setDouble:200 forKey:@"GDTBackupNotification.office.activeIssueAt"]; + [delegate.testDefaults setObject:@"failure" + forKey:@"GDTBackupNotification.office.activeIssueKind"]; + [delegate.testDefaults + setObject:@"com.commcats.gdrivebackup.office.failure.200" + forKey:@"GDTBackupNotification.office.activeIssueIdentifier"]; + [delegate.testDefaults setDouble:150 + forKey:@"GDTBackupNotification.office.dismissedIssueAt"]; + [delegate.testDefaults setObject:@"retry-running.190" + forKey:@"GDTBackupNotification.office.lastDeliveredRevision"]; + [delegate.testDefaults setInteger:3 + forKey:@"GDTBackupNotification.office.latestDeliveredIssueStage"]; + [delegate.testDefaults setObject:@{ + @"origin": @200, + @"stage": @3 + } forKey:@"GDTBackupNotification.office.latestDeliveredIssueState"]; SEL clearSelector = NSSelectorFromString( @"clearBackupFailureNotificationsForConfig:summary:status:"); if ([delegate respondsToSelector:clearSelector]) { @@ -934,7 +2041,23 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( [delegate.removedNotificationIdentifiers containsObject:@"com.commcats.gdrivebackup.office.failure.200"] && [delegate.removedNotificationIdentifiers - containsObject:@"com.commcats.gdrivebackup.office.missed.50"], + containsObject:@"com.commcats.gdrivebackup.office.missed.50"] && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.lastDeliveredIdentifier"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.lastDeliveredRevision"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.latestDeliveredIssueStage"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.latestDeliveredIssueState"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.dismissedIssueAt"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.activeIssueAt"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.activeIssueKind"] == nil && + [delegate.testDefaults objectForKey: + @"GDTBackupNotification.office.activeIssueIdentifier"] == nil, @"a later automatic success removes every delivered failure alert for that profile"); SEL alertStatusSelector = NSSelectorFromString( @@ -945,8 +2068,9 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( ? (AlertStatusMethod)[delegate methodForSelector:alertStatusSelector] : NULL; NSDictionary *profileConfig = @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"}; NSDictionary *missedDecision = @{ - @"identifier": @"missed.100", @"profileID": @"office", - @"kind": @"missed", @"issueTimestamp": @"100" + @"identifier": @"com.commcats.gdrivebackup.office.missed.100", + @"profileID": @"office", @"kind": @"missed", + @"issueTimestamp": @"100", @"issueOriginTimestamp": @"100" }; NSString *missedStatus = alertStatus ? alertStatus( delegate, alertStatusSelector, profileConfig, @{}, @"unknown", missedDecision) : nil; @@ -967,11 +2091,32 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( @"a missed-run warning stays active until a later automatic success"); NSDictionary *failureDecision = @{ - @"identifier": @"failure.200", @"profileID": @"office", - @"kind": @"failure", @"issueTimestamp": @"200" + @"identifier": @"com.commcats.gdrivebackup.office.failure.200", + @"profileID": @"office", @"kind": @"failure", + @"issueTimestamp": @"230", @"issueOriginTimestamp": @"200" }; NSString *failureStatus = alertStatus ? alertStatus( delegate, alertStatusSelector, profileConfig, @{}, @"failure", failureDecision) : nil; + NSDictionary *runningRetryStatusDecision = @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.200", + @"profileID": @"office", @"kind": @"retry-running", + @"revision": @"retry-running.240", @"issueTimestamp": @"200", + @"issueOriginTimestamp": @"200" + }; + NSString *retryRunningStatus = alertStatus ? alertStatus( + delegate, alertStatusSelector, profileConfig, + @{@"started_at": @"240", @"trigger": @"schedule-retry", + @"retry_origin_started_at": @"200", @"retry_attempt": @"1"}, + @"running", runningRetryStatusDecision) : nil; + BOOL failureLatchSurvivedRetryStart = + [delegate.testDefaults doubleForKey: + @"GDTBackupNotification.office.activeIssueAt"] == 200 && + [[delegate.testDefaults stringForKey: + @"GDTBackupNotification.office.activeIssueKind"] + isEqualToString:@"failure"] && + [[delegate.testDefaults stringForKey: + @"GDTBackupNotification.office.activeIssueIdentifier"] + isEqualToString:@"com.commcats.gdrivebackup.office.failure.200"]; NSString *oldSuccessDoesNotClear = alertStatus ? alertStatus( delegate, alertStatusSelector, profileConfig, @{@"finished_at": @"199", @"trigger": @"schedule"}, @@ -985,10 +2130,259 @@ typedef UNNotificationPresentationOptions (*PresentationMethod)( @{@"finished_at": @"250", @"trigger": @"schedule-retry"}, @"success", nil) : nil; Assert([failureStatus isEqualToString:@"failure"] && + [retryRunningStatus isEqualToString:@"retry-running"] && + failureLatchSurvivedRetryStart && [oldSuccessDoesNotClear isEqualToString:@"failure"] && [manualNewSuccessDoesNotClear isEqualToString:@"failure"] && [newSuccessClears isEqualToString:@"success"], - @"only a newer automatic success clears the red status latch"); + @"a running retry is non-red without clearing the failure latch, which only a newer automatic success clears"); + + BackupActionTestDelegate *actionDelegate = + [[BackupActionTestDelegate alloc] init]; + actionDelegate.testDefaults = [[NSUserDefaults alloc] initWithSuiteName: + [suiteName stringByAppendingString:@".backup-actions"]]; + actionDelegate.deliverySucceeds = YES; + NSString *activeAtKey = @"GDTBackupNotification.office.activeIssueAt"; + NSString *activeKindKey = @"GDTBackupNotification.office.activeIssueKind"; + NSString *activeIDKey = @"GDTBackupNotification.office.activeIssueIdentifier"; + NSString *dismissedKey = @"GDTBackupNotification.office.dismissedIssueAt"; + NSString *oldID = @"com.commcats.gdrivebackup.office.failure.400"; + NSString *newID = @"com.commcats.gdrivebackup.office.failure.500"; + [actionDelegate.testDefaults setDouble:400 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:oldID forKey:activeIDKey]; + HandleBackupAction(actionDelegate, UNNotificationDismissActionIdentifier, + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"400"}, oldID); + Assert([actionDelegate.testDefaults doubleForKey:dismissedKey] == 400 && + [actionDelegate.testDefaults objectForKey:activeAtKey] == nil && + [actionDelegate.testDefaults objectForKey:activeKindKey] == nil && + [actionDelegate.testDefaults objectForKey:activeIDKey] == nil && + [actionDelegate.removedNotificationIdentifiers containsObject:oldID], + @"an explicit matching dismiss acknowledges and retires one issue"); + + [actionDelegate.testDefaults setDouble:500 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:newID forKey:activeIDKey]; + actionDelegate.removedDeliveredNotificationIdentifiers = nil; + actionDelegate.removedPendingNotificationIdentifiers = nil; + HandleBackupAction(actionDelegate, UNNotificationDismissActionIdentifier, + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"400"}, oldID); + Assert([actionDelegate.testDefaults doubleForKey:dismissedKey] == 400 && + [actionDelegate.testDefaults doubleForKey:activeAtKey] == 500 && + [[actionDelegate.testDefaults stringForKey:activeKindKey] + isEqualToString:@"failure"] && + [[actionDelegate.testDefaults stringForKey:activeIDKey] + isEqualToString:newID] && + [actionDelegate.removedDeliveredNotificationIdentifiers + isEqualToArray:@[oldID]] && + [actionDelegate.removedPendingNotificationIdentifiers + isEqualToArray:@[oldID]], + @"a late dismissal retires only its old issue and cannot clear a newer latch"); + + [actionDelegate.testDefaults removeObjectForKey:activeAtKey]; + [actionDelegate.testDefaults removeObjectForKey:activeKindKey]; + [actionDelegate.testDefaults removeObjectForKey:activeIDKey]; + NSInteger deliveriesBeforeRefresh = actionDelegate.deliveryCalls; + NSMutableDictionary *sameIssueFinal = [finalRetryFailure mutableCopy]; + sameIssueFinal[@"issueTimestamp"] = @"430"; + sameIssueFinal[@"issueOriginTimestamp"] = @"400"; + if (alertStatus) { + (void)alertStatus(actionDelegate, alertStatusSelector, + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"}, + @{@"started_at": @"410", @"finished_at": @"430", + @"trigger": @"schedule-retry"}, @"failure", sameIssueFinal); + } + Assert([actionDelegate.testDefaults objectForKey:activeAtKey] == nil && + [actionDelegate.testDefaults objectForKey:activeKindKey] == nil && + [actionDelegate.testDefaults objectForKey:activeIDKey] == nil, + @"refresh cannot relatch a human-acknowledged origin"); + Process(actionDelegate, sameIssueFinal); + Assert(actionDelegate.deliveryCalls == deliveriesBeforeRefresh, + @"refresh cannot resurrect a dismissed origin under a later finish time"); + + NSMutableDictionary *newFailure = [finalRetryFailure mutableCopy]; + newFailure[@"identifier"] = newID; + [newFailure removeObjectForKey:@"supersedesIdentifier"]; + newFailure[@"issueTimestamp"] = @"530"; + newFailure[@"issueOriginTimestamp"] = @"500"; + NSString *newFailureStatus = alertStatus ? alertStatus( + actionDelegate, alertStatusSelector, + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"}, + @{@"started_at": @"500", @"finished_at": @"530", + @"trigger": @"schedule"}, @"failure", newFailure) : nil; + Process(actionDelegate, newFailure); + Assert([newFailureStatus isEqualToString:@"failure"] && + actionDelegate.deliveryCalls == deliveriesBeforeRefresh + 1 && + [actionDelegate.testDefaults doubleForKey:activeAtKey] == 500 && + [[actionDelegate.testDefaults stringForKey:activeIDKey] + isEqualToString:newID], + @"a later independent issue still delivers and remains latched"); + + NSString *statusWithNoDeliveredRequests = alertStatus ? alertStatus( + actionDelegate, alertStatusSelector, + @{@"GDRIVE_BACKUP_PROFILE_ID": @"office"}, @{}, @"unknown", nil) : nil; + Assert([statusWithNoDeliveredRequests isEqualToString:@"failure"] && + [actionDelegate.testDefaults doubleForKey:dismissedKey] == 400 && + [actionDelegate.testDefaults doubleForKey:activeAtKey] == 500 && + [[actionDelegate.testDefaults stringForKey:activeIDKey] + isEqualToString:newID], + @"an empty delivered-notification observation never counts as human acknowledgement"); + + NSString *openID = @"com.commcats.gdrivebackup.office.failure.600"; + [actionDelegate.testDefaults setDouble:600 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:openID forKey:activeIDKey]; + actionDelegate.removedNotificationIdentifiers = nil; + HandleBackupAction(actionDelegate, @"GDT_OPEN_BACKUP_OVERVIEW", + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"600"}, openID); + Assert([actionDelegate.testDefaults doubleForKey:dismissedKey] == 600 && + [actionDelegate.testDefaults objectForKey:activeAtKey] == nil && + [actionDelegate.testDefaults objectForKey:activeKindKey] == nil && + [actionDelegate.testDefaults objectForKey:activeIDKey] == nil && + [actionDelegate.removedNotificationIdentifiers containsObject:openID] && + actionDelegate.overviewShowCalls == 1 && + actionDelegate.overviewRefreshCalls == 1 && + actionDelegate.backupLaunchCalls == 0, + @"the explicit Open action acknowledges its issue and opens the overview without launching a backup"); + + NSString *protectedID = @"com.commcats.gdrivebackup.office.failure.700"; + [actionDelegate.testDefaults setDouble:700 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:protectedID forKey:activeIDKey]; + actionDelegate.removedNotificationIdentifiers = nil; + HandleBackupAction(actionDelegate, UNNotificationDismissActionIdentifier, + @"GDT_UNKNOWN_EXTERNAL_VOLUME", + @{@"profileID": @"office", @"issueOriginTimestamp": @"700"}, protectedID); + HandleBackupAction(actionDelegate, UNNotificationDismissActionIdentifier, + @"GDT_BACKUP_ALERT", @{@"profileID": @"office"}, protectedID); + HandleBackupAction(actionDelegate, UNNotificationDefaultActionIdentifier, + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"700"}, protectedID); + Assert([actionDelegate.testDefaults doubleForKey:dismissedKey] == 600 && + [actionDelegate.testDefaults doubleForKey:activeAtKey] == 700 && + [[actionDelegate.testDefaults stringForKey:activeKindKey] + isEqualToString:@"failure"] && + [[actionDelegate.testDefaults stringForKey:activeIDKey] + isEqualToString:protectedID] && + actionDelegate.removedNotificationIdentifiers == nil && + actionDelegate.overviewShowCalls == 1 && + actionDelegate.backupLaunchCalls == 0, + @"unrelated, malformed, and implicit actions cannot acknowledge or clear an issue"); + + NSString *routedDismissID = @"com.commcats.gdrivebackup.office.failure.800"; + [actionDelegate.testDefaults setDouble:800 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:routedDismissID forKey:activeIDKey]; + actionDelegate.removedNotificationIdentifiers = nil; + BOOL dismissResponseCompleted = RouteBackupResponse( + actionDelegate, UNNotificationDismissActionIdentifier, @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"800"}, + routedDismissID); + Assert(dismissResponseCompleted && + [actionDelegate.testDefaults doubleForKey:dismissedKey] == 800 && + [actionDelegate.testDefaults objectForKey:activeAtKey] == nil && + [actionDelegate.testDefaults objectForKey:activeKindKey] == nil && + [actionDelegate.testDefaults objectForKey:activeIDKey] == nil && + [actionDelegate.removedDeliveredNotificationIdentifiers + isEqualToArray:@[routedDismissID]] && + [actionDelegate.removedPendingNotificationIdentifiers + isEqualToArray:@[routedDismissID]] && + actionDelegate.overviewShowCalls == 1, + @"the notification-center delegate immediately acknowledges a routed dismiss"); + + NSString *routedOpenID = @"com.commcats.gdrivebackup.office.failure.900"; + [actionDelegate.testDefaults setDouble:900 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:routedOpenID forKey:activeIDKey]; + actionDelegate.removedNotificationIdentifiers = nil; + BOOL openResponseCompleted = RouteBackupResponse( + actionDelegate, @"GDT_OPEN_BACKUP_OVERVIEW", @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"900"}, + routedOpenID); + Assert(openResponseCompleted && + [actionDelegate.testDefaults doubleForKey:dismissedKey] == 900 && + [actionDelegate.testDefaults objectForKey:activeAtKey] == nil && + [actionDelegate.testDefaults objectForKey:activeKindKey] == nil && + [actionDelegate.testDefaults objectForKey:activeIDKey] == nil && + [actionDelegate.removedNotificationIdentifiers containsObject:routedOpenID] && + actionDelegate.overviewShowCalls == 2 && + actionDelegate.overviewRefreshCalls == 2 && + actionDelegate.backupLaunchCalls == 0, + @"the notification-center delegate routes Open through acknowledgement and overview navigation"); + + NSString *defaultActionID = + @"com.commcats.gdrivebackup.office.failure.950"; + [actionDelegate.testDefaults setDouble:950 forKey:activeAtKey]; + [actionDelegate.testDefaults setObject:@"failure" forKey:activeKindKey]; + [actionDelegate.testDefaults setObject:defaultActionID forKey:activeIDKey]; + actionDelegate.removedDeliveredNotificationIdentifiers = nil; + actionDelegate.removedPendingNotificationIdentifiers = nil; + BOOL defaultResponseCompleted = RouteBackupResponse( + actionDelegate, UNNotificationDefaultActionIdentifier, + @"GDT_BACKUP_ALERT", + @{@"profileID": @"office", @"issueOriginTimestamp": @"950"}, + defaultActionID); + BOOL defaultNavigationCompleted = WaitForCondition(^BOOL{ + return actionDelegate.overviewShowCalls == 3 && + actionDelegate.overviewRefreshCalls == 3; + }, 1.0); + Assert(defaultResponseCompleted && defaultNavigationCompleted && + [actionDelegate.testDefaults doubleForKey:dismissedKey] == 900 && + [actionDelegate.testDefaults doubleForKey:activeAtKey] == 950 && + [[actionDelegate.testDefaults stringForKey:activeIDKey] + isEqualToString:defaultActionID] && + actionDelegate.removedDeliveredNotificationIdentifiers == nil && + actionDelegate.removedPendingNotificationIdentifiers == nil, + @"the default click opens the overview without acknowledging the persistent issue"); + + BOOL legacyOpenCompleted = RouteBackupResponse( + actionDelegate, @"GDT_OPEN_BACKUP_OVERVIEW", @"GDT_BACKUP_ALERT", + @{}, @"com.commcats.gdrivebackup.office.failure.300"); + BOOL legacyNavigationCompleted = WaitForCondition(^BOOL{ + return actionDelegate.overviewShowCalls == 4 && + actionDelegate.overviewRefreshCalls == 4; + }, 1.0); + Assert(legacyOpenCompleted && legacyNavigationCompleted && + [actionDelegate.testDefaults doubleForKey:dismissedKey] == 900 && + [actionDelegate.testDefaults doubleForKey:activeAtKey] == 950 && + [[actionDelegate.testDefaults stringForKey:activeIDKey] + isEqualToString:defaultActionID] && + actionDelegate.removedDeliveredNotificationIdentifiers == nil && + actionDelegate.removedPendingNotificationIdentifiers == nil, + @"a legacy Open action keeps overview navigation while refusing untrusted acknowledgement metadata"); + + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the notification integration harness stays out of the Dock"); + NSStatusItem *retryStatusItem = [NSStatusBar.systemStatusBar + statusItemWithLength:NSSquareStatusItemLength]; + actionDelegate.statusItem = retryStatusItem; + SEL statusPresentationSelector = + NSSelectorFromString(@"updateStatusItemPresentationForSnapshot:"); + if ([actionDelegate respondsToSelector:statusPresentationSelector]) { + typedef void (*StatusPresentationMethod)(id, SEL, NSDictionary *); + StatusPresentationMethod presentStatus = + (StatusPresentationMethod)[actionDelegate + methodForSelector:statusPresentationSelector]; + presentStatus(actionDelegate, statusPresentationSelector, + @{@"alertStatus": @"retry-running", @"lastRun": @"stale failure"}); + } + NSImage *expectedRetrySymbol = [NSImage + imageWithSystemSymbolName:@"arrow.triangle.2.circlepath" + accessibilityDescription:nil]; + Assert(retryStatusItem.button.image.template && + [retryStatusItem.button.image.TIFFRepresentation + isEqualToData:expectedRetrySymbol.TIFFRepresentation] && + [retryStatusItem.button.accessibilityLabel + containsString:T(@"en", @"automaticRetryRunning")], + @"retry-running uses a non-red running symbol and a localized spoken status"); + [NSStatusBar.systemStatusBar removeStatusItem:retryStatusItem]; + actionDelegate.statusItem = nil; NSDictionary *enabledConfig = @{ @"GDRIVE_BACKUP_PROFILE_ID": @"reactivate", diff --git a/tests/notification-support-test.m b/tests/notification-support-test.m index 3ef8d32..d5fefd1 100644 --- a/tests/notification-support-test.m +++ b/tests/notification-support-test.m @@ -48,6 +48,20 @@ static void Assert(BOOL condition, NSString *name) { return method(policyClass, selector, profileID, candidates); } +static NSArray *ProfileFailureIdentifiersThroughOrigin( + Class policyClass, + NSString *profileID, + NSTimeInterval cutoff, + NSArray *> *candidates) { + SEL selector = NSSelectorFromString( + @"failureNotificationIdentifiersForProfileID:throughIssueOriginTimestamp:candidateNotifications:"); + if (!policyClass || ![policyClass respondsToSelector:selector]) return nil; + typedef NSArray *(*FilterMethod)( + id, SEL, NSString *, NSTimeInterval, NSArray *> *); + FilterMethod method = (FilterMethod)[policyClass methodForSelector:selector]; + return method(policyClass, selector, profileID, cutoff, candidates); +} + int main(void) { @autoreleasepool { Class policyClass = NSClassFromString(@"GDTBackupNotificationPolicy"); @@ -82,6 +96,8 @@ int main(void) { Assert([failure[@"kind"] isEqualToString:@"failure"] && [failure[@"identifier"] containsString:@"office"] && [failure[@"identifier"] containsString:failedSummary[@"started_at"]] && + [failure[@"issueTimestamp"] isEqualToString:failedSummary[@"finished_at"]] && + [failure[@"issueOriginTimestamp"] isEqualToString:failedSummary[@"started_at"]] && [failure[@"bodyKey"] isEqualToString:@"failedPermissionHint"], @"a fresh scheduled failure creates one stable, reason-specific alert"); @@ -90,9 +106,143 @@ int main(void) { NSDictionary *retryPlanned = Decision( policyClass, daily, nasNotReady, @"failure", Date(calendar, 21, 20, 26), calendar); Assert([retryPlanned[@"kind"] isEqualToString:@"failure"] && + [retryPlanned[@"issueOriginTimestamp"] + isEqualToString:failedSummary[@"started_at"]] && [retryPlanned[@"bodyKey"] isEqualToString:@"backupNotificationNASRetryBody"], @"a transient NAS readiness failure announces the later automatic retry"); + NSMutableDictionary *retryRunningSummary = [nasNotReady mutableCopy]; + retryRunningSummary[@"status"] = @"running"; + retryRunningSummary[@"trigger"] = @"schedule-retry"; + retryRunningSummary[@"retry_origin_started_at"] = failedSummary[@"started_at"]; + retryRunningSummary[@"retry_attempt"] = @"1"; + retryRunningSummary[@"started_at"] = [NSString stringWithFormat:@"%.0f", + Date(calendar, 21, 20, 56).timeIntervalSince1970]; + [retryRunningSummary removeObjectForKey:@"finished_at"]; + [retryRunningSummary removeObjectForKey:@"exit_code"]; + NSDictionary *retryRunning = Decision( + policyClass, daily, retryRunningSummary, @"running", + Date(calendar, 21, 20, 57), calendar); + Assert([retryRunning[@"kind"] isEqualToString:@"retry-running"] && + [retryRunning[@"identifier"] isEqualToString:retryPlanned[@"identifier"]] && + [retryRunning[@"revision"] hasSuffix:retryRunningSummary[@"started_at"]] && + [retryRunning[@"issueTimestamp"] + isEqualToString:failedSummary[@"started_at"]] && + [retryRunning[@"issueOriginTimestamp"] + isEqualToString:failedSummary[@"started_at"]] && + [retryRunning[@"titleKey"] + isEqualToString:@"backupNotificationRetryRunningTitle"] && + [retryRunning[@"bodyKey"] + isEqualToString:@"backupNotificationRetryRunningBody"], + @"a running retry updates the preliminary alert in place"); + + NSMutableDictionary *interruptedRetrySummary = + [retryRunningSummary mutableCopy]; + interruptedRetrySummary[@"pid"] = @"99999999"; + NSDictionary *interruptedRetry = Decision( + policyClass, daily, interruptedRetrySummary, @"interrupted", + Date(calendar, 21, 20, 57), calendar); + Assert([interruptedRetry[@"kind"] isEqualToString:@"failure"] && + [interruptedRetry[@"issueOriginTimestamp"] + isEqualToString:failedSummary[@"started_at"]] && + [interruptedRetry[@"supersedesIdentifier"] + isEqualToString:retryPlanned[@"identifier"]] && + [interruptedRetry[@"bodyKey"] + isEqualToString:@"backupNotificationRetryFailureBody"], + @"a dead retry process replaces progress with a terminal failure alert"); + + NSMutableDictionary *missingAttempt = [retryRunningSummary mutableCopy]; + [missingAttempt removeObjectForKey:@"retry_attempt"]; + NSMutableDictionary *wrongAttempt = [retryRunningSummary mutableCopy]; + wrongAttempt[@"retry_attempt"] = @"2"; + NSMutableDictionary *wrongRunningStatus = [retryRunningSummary mutableCopy]; + NSMutableDictionary *wrongRunningTrigger = [retryRunningSummary mutableCopy]; + wrongRunningTrigger[@"trigger"] = @"schedule"; + NSMutableDictionary *invalidOrigin = [retryRunningSummary mutableCopy]; + invalidOrigin[@"retry_origin_started_at"] = @"not-a-time"; + NSMutableDictionary *missingStartedAt = [retryRunningSummary mutableCopy]; + [missingStartedAt removeObjectForKey:@"started_at"]; + NSMutableDictionary *invalidStartedAt = [retryRunningSummary mutableCopy]; + invalidStartedAt[@"started_at"] = @"not-a-time"; + NSMutableDictionary *nonAdvancingRetry = [retryRunningSummary mutableCopy]; + nonAdvancingRetry[@"started_at"] = failedSummary[@"started_at"]; + Assert(Decision(policyClass, daily, missingAttempt, @"running", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, wrongAttempt, @"running", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, wrongRunningStatus, @"failure", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, wrongRunningTrigger, @"running", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, invalidOrigin, @"running", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, missingStartedAt, @"running", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, invalidStartedAt, @"running", + Date(calendar, 21, 20, 57), calendar) == nil && + Decision(policyClass, daily, nonAdvancingRetry, @"running", + Date(calendar, 21, 20, 57), calendar) == nil, + @"only a structurally valid first retry can replace an alert"); + + NSTimeInterval originStart = + Date(calendar, 21, 20, 0).timeIntervalSince1970; + NSTimeInterval originFinish = + Date(calendar, 21, 20, 5).timeIntervalSince1970; + NSMutableDictionary *differentTimes = [failedSummary mutableCopy]; + differentTimes[@"trigger"] = @"schedule"; + differentTimes[@"started_at"] = [NSString stringWithFormat:@"%.0f", originStart]; + differentTimes[@"finished_at"] = [NSString stringWithFormat:@"%.0f", originFinish]; + NSDictionary *originalFailure = Decision( + policyClass, daily, differentTimes, @"failure", + Date(calendar, 21, 20, 6), calendar); + Assert([originalFailure[@"issueTimestamp"] doubleValue] == originFinish && + [originalFailure[@"issueOriginTimestamp"] doubleValue] == originStart && + [originalFailure[@"identifier"] hasSuffix: + [NSString stringWithFormat:@".%.0f", originStart]], + @"an original failure uses run start as canonical origin, not finish time"); + + NSMutableDictionary *finalRetry = [differentTimes mutableCopy]; + finalRetry[@"trigger"] = @"schedule-retry"; + finalRetry[@"retry_origin_started_at"] = + [NSString stringWithFormat:@"%.0f", originStart]; + finalRetry[@"retry_attempt"] = @"1"; + finalRetry[@"started_at"] = [NSString stringWithFormat:@"%.0f", + Date(calendar, 21, 20, 40).timeIntervalSince1970]; + finalRetry[@"finished_at"] = [NSString stringWithFormat:@"%.0f", + Date(calendar, 21, 20, 45).timeIntervalSince1970]; + NSDictionary *finalRetryDecision = Decision( + policyClass, daily, finalRetry, @"failure", + Date(calendar, 21, 20, 46), calendar); + Assert([finalRetryDecision[@"issueOriginTimestamp"] doubleValue] == originStart, + @"the final retry inherits the original run-start origin"); + + NSMutableDictionary *missingRetryOrigin = [finalRetry mutableCopy]; + [missingRetryOrigin removeObjectForKey:@"retry_origin_started_at"]; + NSMutableDictionary *invalidRetryOrigin = [finalRetry mutableCopy]; + invalidRetryOrigin[@"retry_origin_started_at"] = @"not-a-time"; + NSMutableDictionary *nonAdvancingRetryOrigin = [finalRetry mutableCopy]; + nonAdvancingRetryOrigin[@"retry_origin_started_at"] = + nonAdvancingRetryOrigin[@"started_at"]; + NSMutableDictionary *futureRetryOrigin = [finalRetry mutableCopy]; + futureRetryOrigin[@"retry_origin_started_at"] = futureRetryOrigin[@"finished_at"]; + NSMutableDictionary *missingFinalAttempt = [finalRetry mutableCopy]; + [missingFinalAttempt removeObjectForKey:@"retry_attempt"]; + NSMutableDictionary *wrongFinalAttempt = [finalRetry mutableCopy]; + wrongFinalAttempt[@"retry_attempt"] = @"2"; + Assert(Decision(policyClass, daily, missingRetryOrigin, @"failure", + Date(calendar, 21, 20, 46), calendar) == nil && + Decision(policyClass, daily, invalidRetryOrigin, @"failure", + Date(calendar, 21, 20, 46), calendar) == nil && + Decision(policyClass, daily, nonAdvancingRetryOrigin, @"failure", + Date(calendar, 21, 20, 46), calendar) == nil && + Decision(policyClass, daily, futureRetryOrigin, @"failure", + Date(calendar, 21, 20, 46), calendar) == nil && + Decision(policyClass, daily, missingFinalAttempt, @"failure", + Date(calendar, 21, 20, 46), calendar) == nil && + Decision(policyClass, daily, wrongFinalAttempt, @"failure", + Date(calendar, 21, 20, 46), calendar) == nil, + @"a malformed terminal retry cannot create a second issue origin"); + NSMutableDictionary *destinationUnreadable = [failedSummary mutableCopy]; destinationUnreadable[@"reason"] = @"destination_unreadable"; @@ -106,6 +256,8 @@ int main(void) { NSMutableDictionary *retryFailed = [nasNotReady mutableCopy]; retryFailed[@"trigger"] = @"schedule-retry"; + retryFailed[@"retry_origin_started_at"] = failedSummary[@"started_at"]; + retryFailed[@"retry_attempt"] = @"1"; retryFailed[@"started_at"] = [NSString stringWithFormat:@"%.0f", Date(calendar, 21, 20, 56).timeIntervalSince1970]; retryFailed[@"finished_at"] = [NSString stringWithFormat:@"%.0f", @@ -114,8 +266,14 @@ int main(void) { policyClass, daily, retryFailed, @"failure", Date(calendar, 21, 21, 2), calendar); Assert([finalFailure[@"kind"] isEqualToString:@"failure"] && [finalFailure[@"identifier"] containsString:retryFailed[@"started_at"]] && + [finalFailure[@"issueTimestamp"] + isEqualToString:retryFailed[@"finished_at"]] && + [finalFailure[@"issueOriginTimestamp"] + isEqualToString:failedSummary[@"started_at"]] && + [finalFailure[@"supersedesIdentifier"] + isEqualToString:retryPlanned[@"identifier"]] && [finalFailure[@"bodyKey"] isEqualToString:@"backupNotificationRetryFailureBody"], - @"a failed automatic retry creates one distinct final failure alert"); + @"a failed automatic retry replaces the preliminary retry alert"); NSMutableDictionary *cancelledSummary = [failedSummary mutableCopy]; cancelledSummary[@"status"] = @"cancelled"; @@ -148,6 +306,7 @@ int main(void) { policyClass, daily, @{}, @"unknown", Date(calendar, 21, 21, 5), calendar); Assert([missed[@"kind"] isEqualToString:@"missed"] && [missed[@"identifier"] containsString:@"office"] && + [missed[@"issueOriginTimestamp"] isEqualToString:missed[@"issueTimestamp"]] && [missed[@"bodyKey"] isEqualToString:@"backupNotificationMissedBody"], @"the daily watchdog reports a run still missing after 21:00"); @@ -238,6 +397,60 @@ int main(void) { @"com.commcats.gdrivebackup.office.missed.200" ]], @"notification cleanup accepts only exact safe failure IDs for one profile"); + + NSArray *cutoffFailures = + ProfileFailureIdentifiersThroughOrigin(policyClass, @"office", 500, @[ + @{@"identifier": @"com.commcats.gdrivebackup.office.failure.100"}, + @{@"identifier": @"com.commcats.gdrivebackup.office.missed.200"}, + @{@"identifier": @"com.commcats.gdrivebackup.office.failure.600"}, + @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.700", + @"categoryIdentifier": @"GDT_BACKUP_ALERT", + @"userInfo": @{ + @"profileID": @"office", + @"issueOriginTimestamp": @"400" + } + }, + @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.300", + @"categoryIdentifier": @"GDT_BACKUP_ALERT", + @"userInfo": @{ + @"profileID": @"office", + @"issueOriginTimestamp": @"600" + } + }, + @{@"identifier": @"com.commcats.gdrivebackup.office.missed.0200"}, + @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.250", + @"categoryIdentifier": @"GDT_BACKUP_ALERT", + @"userInfo": @{ + @"profileID": @"office", + @"issueOriginTimestamp": @"0400" + } + }, + @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.230", + @"categoryIdentifier": @"GDT_BACKUP_ALERT", + @"userInfo": @{ + @"profileID": @"archive", + @"issueOriginTimestamp": @"230" + } + }, + @{ + @"identifier": @"com.commcats.gdrivebackup.office.failure.240", + @"categoryIdentifier": @"GDT_UNKNOWN_EXTERNAL_VOLUME", + @"userInfo": @{ + @"profileID": @"office", + @"issueOriginTimestamp": @"240" + } + } + ]); + Assert([cutoffFailures isEqualToArray:@[ + @"com.commcats.gdrivebackup.office.failure.100", + @"com.commcats.gdrivebackup.office.missed.200", + @"com.commcats.gdrivebackup.office.failure.700" + ]], + @"success cleanup trusts exact metadata and keeps newer or malformed origins"); } if (failures > 0) { diff --git a/tests/overview-ui-test.m b/tests/overview-ui-test.m index 0f4cae8..9b8b5fb 100644 --- a/tests/overview-ui-test.m +++ b/tests/overview-ui-test.m @@ -542,17 +542,24 @@ int main(void) { @"overviewTarget", @"overviewStorage", @"overviewSettings", @"overviewOpen", @"overviewNeverRun", @"overviewUnavailable", @"overviewFreeOf", @"overviewStatusInterrupted", @"overviewStatusUnknown", - @"automaticBackupsPaused", @"pauseAutomaticBackups", @"resumeAutomaticBackups" + @"automaticBackupsPaused", @"pauseAutomaticBackups", @"resumeAutomaticBackups", + @"automaticRetryRunning", @"automaticRetryRunningShort", + @"backupProgressCurrentPhase", @"progressAreaFormat", @"progressPreparing" ]; BOOL allOverviewTextLocalized = YES; + BOOL allProgressAreaFormatsLocalized = YES; for (NSString *language in SupportedLanguageCodes()) { for (NSString *key in overviewKeys) { NSString *value = T(language, key); allOverviewTextLocalized = allOverviewTextLocalized && value.length > 0 && ![value isEqualToString:key]; } + NSString *formattedArea = [NSString stringWithFormat: + T(language, @"progressAreaFormat"), @"3", @"5"]; + allProgressAreaFormatsLocalized = allProgressAreaFormatsLocalized && + [formattedArea containsString:@"3"] && [formattedArea containsString:@"5"]; } - Assert(allOverviewTextLocalized, + Assert(allOverviewTextLocalized && allProgressAreaFormatsLocalized, @"overview and menu bar text is localized in all supported languages"); BOOL actionTitlesFit = YES; @@ -588,6 +595,83 @@ int main(void) { nowParts.hour = 19; NSDate *now = [calendar dateFromComponents:nowParts]; delegate.language = @"en"; + NSDictionary *dailyNAS = @{ + @"GDRIVE_BACKUP_PROFILE_ID": @"default", + @"GDRIVE_BACKUP_TARGET": @"nas", + @"GDRIVE_BACKUP_NAS_MOUNT": @"/Volumes/alexander", + @"GDRIVE_BACKUP_NAS_SUBDIR": @"GoogleDrive-Backup", + @"GDRIVE_BACKUP_SCHEDULE": @"daily" + }; + NSDictionary *retrySummary = @{ + @"protocol": @"1", @"status": @"running", @"pid": @"123", + @"started_at": @"1785522633", @"trigger": @"schedule-retry", + @"retry_origin_started_at": @"1785520805", @"retry_attempt": @"1" + }; + NSDictionary *retryProgress = @{ + @"label": @"Shared Drive", @"phase": @"3/5", @"percent": @"63", + @"detail": @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s" + }; + NSDictionary *retrySnapshot = [delegate overviewSnapshotForConfig:dailyNAS + summary:retrySummary status:@"running" progress:retryProgress + now:now calendar:calendar]; + NSString *phaseText = [NSString stringWithFormat:T(@"en", @"progressAreaFormat"), + @"3", @"5"]; + NSString *retryStart = [[delegate overviewDateFormatterWithCalendar:calendar] + stringFromDate:[NSDate dateWithTimeIntervalSince1970:1785522633]]; + Assert([retrySnapshot[@"retryRunning"] isEqualToString:@"1"] && + [retrySnapshot[@"lastRun"] isEqualToString:T(@"en", @"automaticRetryRunning")] && + [retrySnapshot[@"lastRunDetail"] isEqualToString:retryStart] && + [retrySnapshot[@"progressPhase"] isEqualToString:phaseText] && + [retrySnapshot[@"progressPercent"] isEqualToString:@"63"] && + [retrySnapshot[@"progressDetail"] isEqualToString: + @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s"], + @"running automatic retry has explicit phase progress"); + + NSMenu *retryMenu = [delegate statusMenuForSnapshot:retrySnapshot]; + NSString *retryMenuText = [[retryMenu.itemArray valueForKey:@"title"] + componentsJoinedByString:@" "]; + NSMenuItem *retryProgressItem = nil; + for (NSMenuItem *item in retryMenu.itemArray) { + if ([item.title containsString:T(@"en", @"automaticRetryRunningShort")]) { + retryProgressItem = item; + break; + } + } + NSMenuItem *retryBackup = [retryMenu itemWithTitle:T(@"en", @"backupNow")]; + NSMenuItem *retryOpen = [retryMenu itemWithTitle:T(@"en", @"overviewOpen")]; + Assert([retryMenuText containsString:T(@"en", @"automaticRetryRunningShort")] && + [retryMenuText containsString:phaseText] && + [retryMenuText containsString:@"63 %"] && + retryProgressItem != nil && !retryProgressItem.enabled && + retryBackup != nil && !retryBackup.enabled && + retryOpen != nil && retryOpen.enabled, + @"menu bar exposes compact retry progress"); + + NSDictionary *preparingRetrySnapshot = [delegate overviewSnapshotForConfig:dailyNAS + summary:retrySummary status:@"running" progress:nil + now:now calendar:calendar]; + Assert([preparingRetrySnapshot[@"progressVisible"] isEqualToString:@"1"] && + [preparingRetrySnapshot[@"progressPercent"] isEqualToString:@""] && + [preparingRetrySnapshot[@"progressDetail"] isEqualToString: + T(@"en", @"progressPreparing")] && + [preparingRetrySnapshot[@"progressPhase"] isEqualToString:@""] && + ![preparingRetrySnapshot[@"progressDetail"] containsString:@"Shared Drive"] && + ![preparingRetrySnapshot[@"progressDetail"] containsString:@"MiB/s"], + @"retry without telemetry stays visibly indeterminate without invented detail"); + + NSDictionary *phaseOnlyRetrySnapshot = [delegate overviewSnapshotForConfig:dailyNAS + summary:retrySummary status:@"running" progress:@{ + @"label": @"Shared Drive", @"phase": @"4/5" + } now:now calendar:calendar]; + Assert([phaseOnlyRetrySnapshot[@"progressVisible"] isEqualToString:@"1"] && + [phaseOnlyRetrySnapshot[@"progressPhase"] + isEqualToString:[NSString stringWithFormat:T(@"en", @"progressAreaFormat"), + @"4", @"5"]] && + [phaseOnlyRetrySnapshot[@"progressPercent"] isEqualToString:@""] && + [phaseOnlyRetrySnapshot[@"progressDetail"] + isEqualToString:T(@"en", @"progressPreparing")], + @"fresh phase-only retry telemetry renders indeterminate"); + NSDictionary *snapshot = [delegate overviewSnapshotForConfig:config summaryPath:summaryPath now:now calendar:calendar]; Assert([snapshot[@"status"] isEqualToString:@"success"] && diff --git a/tests/profile-ui-test.m b/tests/profile-ui-test.m index ec50ac0..7d93f97 100644 --- a/tests/profile-ui-test.m +++ b/tests/profile-ui-test.m @@ -1,6 +1,7 @@ #import #import "ProfileSupport.h" +#import "TestApplicationSupport.h" #define main GDTApplicationMain #import "../macos/GDriveBackupTiger/main.m" @@ -46,7 +47,11 @@ static void Assert(BOOL condition, NSString *name) { int main(void) { @autoreleasepool { - [NSApplication sharedApplication]; + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the profile UI harness stays out of the Dock"); NSString *root = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"gdrive-profile-ui-%@", NSUUID.UUID.UUIDString]]; NSString *legacy = [root stringByAppendingPathComponent:@"config"]; diff --git a/tests/progress-support-test.m b/tests/progress-support-test.m new file mode 100644 index 0000000..a60b7ae --- /dev/null +++ b/tests/progress-support-test.m @@ -0,0 +1,256 @@ +#import +#include +#include +#import "BackupProgressSupport.h" + +static int failures = 0; +static void Assert(BOOL condition, NSString *message) { + if (condition) printf("ok - %s\n", message.UTF8String); + else { printf("not ok - %s\n", message.UTF8String); failures++; } +} + +int main(void) { + @autoreleasepool { + NSTimeInterval now = floor(NSDate.date.timeIntervalSince1970); + NSString *pid = [NSString stringWithFormat:@"%d", getpid()]; + NSString *started = [NSString stringWithFormat:@"%.0f", now - 10]; + NSDictionary *summary = @{ + @"protocol": @"1", @"status": @"running", @"pid": pid, + @"started_at": started, @"trigger": @"schedule-retry", + @"retry_attempt": @"1" + }; + NSDictionary *progress = @{ + @"protocol": @"1", @"profile_id": @"default", @"pid": pid, + @"started_at": started, @"trigger": @"schedule-retry", + @"retry_attempt": @"1", @"label": @"Shared Drive", + @"phase": @"3/5", @"percent": @"63", + @"detail": @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s", + @"updated_at": [NSString stringWithFormat:@"%.0f", now] + }; + NSDictionary *accepted = GDTValidatedBackupProgressForValues( + progress, summary, @"running", @"default", now); + Assert([accepted[@"percent"] isEqualToString:@"63"] && + [accepted[@"phase"] isEqualToString:@"3/5"], + @"matching live progress is accepted"); + Assert([GDTBackupProgressPathForSummaryPath(@"/tmp/default/last-run.status") + isEqualToString:@"/tmp/default/current-progress.status"], + @"progress path stays beside the profile summary"); + + NSMutableDictionary *crossProfile = [progress mutableCopy]; + crossProfile[@"profile_id"] = @"archive"; + Assert(GDTValidatedBackupProgressForValues( + crossProfile, summary, @"running", @"default", now) == nil, + @"cross-profile progress is rejected"); + + NSMutableDictionary *wrongProtocol = [progress mutableCopy]; + wrongProtocol[@"protocol"] = @"2"; + Assert(GDTValidatedBackupProgressForValues( + wrongProtocol, summary, @"running", @"default", now) == nil, + @"unknown progress protocols are rejected"); + + NSMutableDictionary *stale = [progress mutableCopy]; + stale[@"updated_at"] = [NSString stringWithFormat:@"%.0f", now - 61]; + Assert(GDTValidatedBackupProgressForValues( + stale, summary, @"running", @"default", now) == nil, + @"stale progress is rejected"); + + NSMutableDictionary *wrongPID = [progress mutableCopy]; + wrongPID[@"pid"] = @"99999999"; + Assert(GDTValidatedBackupProgressForValues( + wrongPID, summary, @"running", @"default", now) == nil, + @"mismatched process progress is rejected"); + + NSMutableDictionary *deadSummary = [summary mutableCopy]; + deadSummary[@"pid"] = @"99999999"; + Assert(GDTValidatedBackupProgressForValues( + wrongPID, deadSummary, @"running", @"default", now) == nil, + @"matching telemetry for a dead process is rejected"); + + NSMutableDictionary *wrongStart = [progress mutableCopy]; + wrongStart[@"started_at"] = [NSString stringWithFormat:@"%lld", + started.longLongValue - 1]; + Assert(GDTValidatedBackupProgressForValues( + wrongStart, summary, @"running", @"default", now) == nil, + @"mismatched start times are rejected"); + + NSMutableDictionary *wrongTrigger = [progress mutableCopy]; + wrongTrigger[@"trigger"] = @"schedule"; + Assert(GDTValidatedBackupProgressForValues( + wrongTrigger, summary, @"running", @"default", now) == nil, + @"mismatched triggers are rejected"); + + NSMutableDictionary *wrongRetry = [progress mutableCopy]; + wrongRetry[@"retry_attempt"] = @"2"; + Assert(GDTValidatedBackupProgressForValues( + wrongRetry, summary, @"running", @"default", now) == nil, + @"mismatched retry attempts are rejected"); + + NSMutableDictionary *future = [progress mutableCopy]; + future[@"updated_at"] = [NSString stringWithFormat:@"%.0f", now + 2]; + Assert(GDTValidatedBackupProgressForValues( + future, summary, @"running", @"default", now) == nil, + @"future progress is rejected"); + + NSMutableDictionary *oneSecondFuture = [progress mutableCopy]; + oneSecondFuture[@"updated_at"] = @"2000000001"; + Assert(GDTValidatedBackupProgressForValues( + oneSecondFuture, summary, @"running", @"default", + 2000000000) == nil, + @"the smallest representable future timestamp is rejected"); + + NSMutableDictionary *mixedTerminal = [progress mutableCopy]; + mixedTerminal[@"status"] = @"finished"; + Assert(GDTValidatedBackupProgressForValues( + mixedTerminal, summary, @"running", @"default", now) == nil, + @"terminal progress cannot be validated as live"); + + NSMutableDictionary *unknownState = [progress mutableCopy]; + unknownState[@"status"] = @"paused"; + Assert(GDTValidatedBackupProgressForValues( + unknownState, summary, @"running", @"default", now) == nil, + @"unknown progress states are rejected while validating"); + + NSMutableDictionary *missingIdentity = [progress mutableCopy]; + [missingIdentity removeObjectForKey:@"started_at"]; + Assert(GDTValidatedBackupProgressForValues( + missingIdentity, summary, @"running", @"default", now) == nil, + @"missing identity fields are rejected"); + + NSMutableDictionary *unsafe = [progress mutableCopy]; + unsafe[@"detail"] = @"file-name.pdf\nsecret"; + Assert(GDTValidatedBackupProgressForValues( + unsafe, summary, @"running", @"default", now) == nil, + @"multiline detail is rejected"); + + NSMutableDictionary *rawLogDetail = [progress mutableCopy]; + rawLogDetail[@"detail"] = @"secret-file.pdf: Failed to copy"; + Assert(GDTValidatedBackupProgressForValues( + rawLogDetail, summary, @"running", @"default", now) == nil, + @"arbitrary rclone log text is rejected"); + + NSMutableDictionary *outOfRange = [progress mutableCopy]; + outOfRange[@"percent"] = @"101"; + Assert(GDTValidatedBackupProgressForValues( + outOfRange, summary, @"running", @"default", now) == nil, + @"out-of-range percentages are rejected"); + + NSMutableDictionary *impossiblePhase = [progress mutableCopy]; + impossiblePhase[@"phase"] = @"6/5"; + Assert(GDTValidatedBackupProgressForValues( + impossiblePhase, summary, @"running", @"default", now) == nil, + @"impossible phases are rejected"); + + NSMutableDictionary *unknownLabel = [progress mutableCopy]; + unknownLabel[@"label"] = @"THE ONE"; + Assert(GDTValidatedBackupProgressForValues( + unknownLabel, summary, @"running", @"default", now) == nil, + @"source names cannot become public progress labels"); + + NSMutableDictionary *preparing = [progress mutableCopy]; + preparing[@"label"] = @"preparing"; + [preparing removeObjectForKey:@"phase"]; + [preparing removeObjectForKey:@"percent"]; + [preparing removeObjectForKey:@"detail"]; + Assert(GDTValidatedBackupProgressForValues( + preparing, summary, @"running", @"default", now) != nil, + @"a valid preparation record remains indeterminate"); + + NSMutableDictionary *phaseOnly = [progress mutableCopy]; + [phaseOnly removeObjectForKey:@"percent"]; + [phaseOnly removeObjectForKey:@"detail"]; + NSDictionary *acceptedPhaseOnly = GDTValidatedBackupProgressForValues( + phaseOnly, summary, @"running", @"default", now); + Assert([acceptedPhaseOnly[@"phase"] isEqualToString:@"3/5"] && + acceptedPhaseOnly[@"percent"] == nil && + acceptedPhaseOnly[@"detail"] == nil, + @"a fresh copy-phase heartbeat remains valid and indeterminate"); + + NSString *fixtureRoot = NSProcessInfo.processInfo.environment[ + @"GDRIVE_PROGRESS_TEST_DIR"]; + NSString *validPath = [fixtureRoot + stringByAppendingPathComponent:@"valid.status"]; + NSMutableString *validContent = [NSMutableString string]; + for (NSString *key in @[@"protocol", @"profile_id", @"pid", + @"started_at", @"trigger", @"retry_attempt", + @"label", @"phase", @"percent", @"detail", + @"updated_at"]) { + [validContent appendFormat:@"%@=%@\n", key, progress[key]]; + } + [validContent writeToFile:validPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} ofItemAtPath:validPath error:nil]; + Assert([GDTReadBackupProgressAtPath(validPath)[@"percent"] + isEqualToString:@"63"], + @"a private valid progress record is parsed"); + + NSString *mixedTerminalPath = [fixtureRoot + stringByAppendingPathComponent:@"mixed-terminal.status"]; + NSString *mixedTerminalContent = [validContent + stringByAppendingString:@"status=finished\n"]; + [mixedTerminalContent writeToFile:mixedTerminalPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} + ofItemAtPath:mixedTerminalPath error:nil]; + Assert(GDTReadBackupProgressAtPath(mixedTerminalPath) == nil, + @"mixed terminal and live records are rejected while parsing"); + + NSString *unknownStatePath = [fixtureRoot + stringByAppendingPathComponent:@"unknown-state.status"]; + NSString *unknownStateContent = [validContent + stringByAppendingString:@"status=paused\n"]; + [unknownStateContent writeToFile:unknownStatePath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} + ofItemAtPath:unknownStatePath error:nil]; + Assert(GDTReadBackupProgressAtPath(unknownStatePath) == nil, + @"unknown progress states are rejected while parsing"); + + NSString *duplicatePath = [fixtureRoot + stringByAppendingPathComponent:@"duplicate.status"]; + NSString *duplicateContent = [validContent + stringByAppendingString:@"protocol=1\n"]; + [duplicateContent writeToFile:duplicatePath + atomically:YES encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} ofItemAtPath:duplicatePath error:nil]; + Assert(GDTReadBackupProgressAtPath(duplicatePath) == nil, + @"duplicate keys are rejected while parsing"); + + NSString *missingPath = [fixtureRoot + stringByAppendingPathComponent:@"missing.status"]; + NSString *missingContent = [validContent + stringByReplacingOccurrencesOfString: + [NSString stringWithFormat:@"updated_at=%@\n", progress[@"updated_at"]] + withString:@""]; + [missingContent writeToFile:missingPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0600} ofItemAtPath:missingPath error:nil]; + Assert(GDTReadBackupProgressAtPath(missingPath) == nil, + @"missing required parser keys are rejected"); + + NSString *publicPath = [fixtureRoot + stringByAppendingPathComponent:@"public.status"]; + [validContent writeToFile:publicPath atomically:YES + encoding:NSUTF8StringEncoding error:nil]; + [NSFileManager.defaultManager setAttributes: + @{NSFilePosixPermissions: @0644} ofItemAtPath:publicPath error:nil]; + Assert(GDTReadBackupProgressAtPath(publicPath) == nil, + @"group/world-readable progress is rejected"); + + NSString *symlinkPath = [fixtureRoot + stringByAppendingPathComponent:@"linked.status"]; + [NSFileManager.defaultManager createSymbolicLinkAtPath:symlinkPath + withDestinationPath:validPath error:nil]; + Assert(GDTReadBackupProgressAtPath(symlinkPath) == nil, + @"symlinked progress is rejected without following it"); + + Assert(GDTValidatedBackupProgressForValues( + progress, summary, @"success", @"default", now) == nil, + @"terminal summaries cannot expose live progress"); + } + return failures ? 1 : 0; +} diff --git a/tests/release-install-runbook-test.sh b/tests/release-install-runbook-test.sh new file mode 100644 index 0000000..7c742f9 --- /dev/null +++ b/tests/release-install-runbook-test.sh @@ -0,0 +1,393 @@ +#!/bin/bash +# The assertions intentionally compare literal shell fragments from the +# extracted runbook; dollar signs and trailing backslashes must not expand. +# shellcheck disable=SC1003,SC2016 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLAN="$ROOT/docs/superpowers/plans/2026-08-01-automatic-retry-progress.md" +WORK_DIR="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/gdrive-runbook-test.XXXXXX")" +PUBLISH_BLOCK="$WORK_DIR/publish.sh" +INSTALL_BLOCK="$WORK_DIR/install.sh" + +cleanup() { + local status=$? + trap - EXIT + if [[ -d "$WORK_DIR" ]] && ! "$ROOT/scripts/trash-path.sh" "$WORK_DIR"; then + printf 'not ok - unable to move runbook-test workspace to Trash: %s\n' "$WORK_DIR" >&2 + status=1 + fi + exit "$status" +} +trap cleanup EXIT + +extract_shell_block() { + local begin_marker="$1" + local end_marker="$2" + local destination="$3" + /usr/bin/awk -v begin_marker="$begin_marker" -v end_marker="$end_marker" ' + $0 == begin_marker { + begin_count++ + inside = 1 + next + } + $0 == end_marker { + end_count++ + inside = 0 + next + } + inside && $0 == "```bash" { + fence_count++ + in_fence = 1 + next + } + inside && $0 == "```" { + in_fence = 0 + next + } + inside && in_fence { print } + END { + if (begin_count != 1 || end_count != 1 || fence_count != 1 || in_fence) { + exit 65 + } + } + ' "$PLAN" >"$destination" +} + +assert_contains() { + local file="$1" + local expected="$2" + local description="$3" + if /usr/bin/grep -Fq -- "$expected" "$file"; then + printf 'ok - %s\n' "$description" + else + printf 'not ok - %s\n' "$description" >&2 + exit 1 + fi +} + +assert_not_contains() { + local file="$1" + local rejected="$2" + local description="$3" + if /usr/bin/grep -Fq -- "$rejected" "$file"; then + printf 'not ok - %s\n' "$description" >&2 + exit 1 + fi + printf 'ok - %s\n' "$description" +} + +line_number() { + local file="$1" + local literal="$2" + /usr/bin/awk -v literal="$literal" 'index($0, literal) { print NR; exit }' "$file" +} + +assert_before() { + local file="$1" + local first="$2" + local second="$3" + local description="$4" + local first_line second_line + first_line="$(line_number "$file" "$first")" + second_line="$(line_number "$file" "$second")" + if [[ "$first_line" =~ ^[0-9]+$ && "$second_line" =~ ^[0-9]+$ ]] && + (( first_line < second_line )); then + printf 'ok - %s\n' "$description" + else + printf 'not ok - %s\n' "$description" >&2 + exit 1 + fi +} + +assert_next_line() { + local file="$1" + local marker="$2" + local expected="$3" + local description="$4" + local marker_line actual + marker_line="$(line_number "$file" "$marker")" + if [[ ! "$marker_line" =~ ^[0-9]+$ ]]; then + printf 'not ok - %s\n' "$description" >&2 + exit 1 + fi + actual="$(/usr/bin/sed -n "$((marker_line + 1))p" "$file")" + if [[ "$actual" == "$expected" ]]; then + printf 'ok - %s\n' "$description" + else + printf 'not ok - %s\n' "$description" >&2 + exit 1 + fi +} + +if ! extract_shell_block '' \ + '' "$PUBLISH_BLOCK"; then + printf '%s\n' 'not ok - publication runbook has one extractable shell block' >&2 + exit 1 +fi +if ! extract_shell_block '' \ + '' "$INSTALL_BLOCK"; then + printf '%s\n' 'not ok - installation runbook has one extractable shell block' >&2 + exit 1 +fi +printf '%s\n' 'ok - runbook shell blocks are uniquely extractable' + +/bin/bash -n "$PUBLISH_BLOCK" "$INSTALL_BLOCK" +SHELLCHECK_BIN="$(command -v shellcheck)" +test -n "$SHELLCHECK_BIN" && test -x "$SHELLCHECK_BIN" +"$SHELLCHECK_BIN" -x "$PUBLISH_BLOCK" "$INSTALL_BLOCK" +printf '%s\n' 'ok - extracted runbook shell blocks pass bash -n and shellcheck' + +assert_contains "$PUBLISH_BLOCK" 'readonly BASE_SHA=' \ + 'publication pins the reviewed base commit' +assert_contains "$PUBLISH_BLOCK" \ + 'readonly V243_SHA="ddbfe24250149e4da177d23d8d1476dbbc3873eb"' \ + 'publication pins v2.4.3 to its reviewed historical commit' +assert_contains "$PUBLISH_BLOCK" \ + 'git merge-base --is-ancestor "$V243_SHA" "$REVIEWED_HEAD"' \ + 'publication proves v2.4.3 is in the reviewed branch history' +assert_contains "$PUBLISH_BLOCK" 'readonly REVIEWED_HEAD' \ + 'publication captures an immutable reviewed head' +assert_contains "$PUBLISH_BLOCK" '"${REVIEWED_HEAD}:refs/heads/${BRANCH}"' \ + 'publication pushes the reviewed object explicitly' +assert_contains "$PUBLISH_BLOCK" \ + 'git fetch --no-tags origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}"' \ + 'publication materializes the exact remote-tracking ref after an object push' +assert_before "$PUBLISH_BLOCK" 'git push origin' 'git fetch --no-tags origin' \ + 'publication fetches tracking state only after the immutable object push' +assert_not_contains "$PUBLISH_BLOCK" 'git branch --set-upstream-to=' \ + 'publication does not depend on the repository-wide fetch refspec' +assert_contains "$PUBLISH_BLOCK" \ + 'git rev-parse "refs/remotes/origin/${BRANCH}^{commit}"' \ + 'publication verifies the fetched remote-tracking object' +assert_contains "$PUBLISH_BLOCK" 'headRefOid' \ + 'publication verifies the pull-request head object' +assert_contains "$PUBLISH_BLOCK" 'baseRefOid' \ + 'publication verifies the pull-request base object' +assert_contains "$PUBLISH_BLOCK" '--match-head-commit "$REVIEWED_HEAD"' \ + 'merge is pinned to the reviewed head object' +assert_contains "$PUBLISH_BLOCK" '"$MERGED_SHA^1"' \ + 'publication verifies the first merge parent' +assert_contains "$PUBLISH_BLOCK" '"$MERGED_SHA^2"' \ + 'publication verifies the second merge parent' +assert_before "$PUBLISH_BLOCK" 'publish_release v2.4.3' 'publish_release v2.4.4' \ + 'v2.4.3 is published and verified before v2.4.4' +assert_contains "$PUBLISH_BLOCK" '/releases/latest' \ + 'publication verifies GitHub latest-release state' +assert_contains "$PUBLISH_BLOCK" 'GDrive-Backup-Tiger-${version}.pkg' \ + 'each release verifies its exact installer asset' +assert_contains "$PUBLISH_BLOCK" 'SHA256SUMS.txt' \ + 'each release verifies its checksum manifest' +assert_contains "$PUBLISH_BLOCK" 'make -C "$source_dir" test' \ + 'each exact-tag export passes the complete test suite' +assert_contains "$PUBLISH_BLOCK" 'latest_tag=' \ + 'latest-release verification is propagation-aware' +assert_before "$PUBLISH_BLOCK" 'publish_release v2.4.3' 'publish_release v2.4.4' \ + 'release assets and latest state are checked sequentially' + +assert_contains "$INSTALL_BLOCK" 'git archive --format=tar' \ + 'installation exports immutable tagged source' +assert_contains "$INSTALL_BLOCK" 'git get-tar-commit-id' \ + 'installation proves the export commit identity' +assert_before "$INSTALL_BLOCK" 'git get-tar-commit-id' 'APP_DIR="$STAGED_APP" build' \ + 'tag identity is proven before the app build' +assert_contains "$INSTALL_BLOCK" 'ACTIVE_PROFILE_FILE' \ + 'installation validates the active-profile selector' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%u'\''' \ + 'installation validates active-profile ownership' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%Lp'\''' \ + 'installation validates active-profile mode' +assert_contains "$INSTALL_BLOCK" 'ACTIVE_PROFILE_SIZE' \ + 'installation enforces the exact default profile selector bytes' +assert_contains "$INSTALL_BLOCK" '64656661756c740a' \ + 'installation requires the exact default profile value and trailing newline' +assert_contains "$INSTALL_BLOCK" 'GDRIVE_BACKUP_CONFIG_DIR' \ + 'installation rejects a config-directory service override' +assert_contains "$INSTALL_BLOCK" 'GDRIVE_BACKUP_CONFIG \' \ + 'installation rejects an explicit config-file service override' +assert_contains "$INSTALL_BLOCK" 'GDRIVE_BACKUP_LOCK \' \ + 'installation rejects a lock-file service override' +assert_contains "$INSTALL_BLOCK" 'GDRIVE_BACKUP_SUMMARY_STATE_FILE' \ + 'installation resolves or rejects summary-state overrides' +assert_contains "$INSTALL_BLOCK" 'GDRIVE_BACKUP_PROGRESS_STATE_FILE' \ + 'installation resolves or rejects progress-state overrides' +assert_contains "$INSTALL_BLOCK" '/usr/bin/env -i' \ + 'profile configuration is evaluated in a sanitized environment' +assert_contains "$INSTALL_BLOCK" 'canonical_config_manifest' \ + 'installation records a canonical configuration manifest' +assert_contains "$INSTALL_BLOCK" 'unexpected symbolic link' \ + 'canonical configuration manifests reject symbolic links' +assert_contains "$INSTALL_BLOCK" 'type=%s|path=%s|uid=%s|gid=%s|mode=%s|size=%s|sha256=%s' \ + 'canonical manifest records type, path, ownership, mode, size, and content hash' +assert_contains "$INSTALL_BLOCK" '/usr/bin/find "$root" -print0' \ + 'canonical manifest includes the configuration root itself' +assert_not_contains "$INSTALL_BLOCK" 'find "$root" -mindepth 1' \ + 'canonical manifest does not omit configuration-root metadata' +assert_contains "$INSTALL_BLOCK" 'test ! -e "$APP_INCOMING" && test ! -L "$APP_INCOMING"' \ + 'incoming app path rejects existing and dangling links' +assert_contains "$INSTALL_BLOCK" 'test ! -e "$SCRIPT_INCOMING" && test ! -L "$SCRIPT_INCOMING"' \ + 'incoming script path rejects existing and dangling links' +assert_contains "$INSTALL_BLOCK" 'mktemp -d "$STAGE_PARENT/' \ + 'installation creates an exclusive validated staging directory' +assert_contains "$INSTALL_BLOCK" 'mktemp -d "$ROLLBACK_PARENT/' \ + 'installation creates an exclusive persistent rollback directory' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%d'\'' "$APP_TXN"' \ + 'the app transaction is proven to share the destination filesystem' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%d'\'' "$SCRIPT_TXN"' \ + 'the script transaction is proven to share the destination filesystem' +assert_contains "$INSTALL_BLOCK" \ + '/usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 755 "$STAGED_SCRIPT" "$SCRIPT_INCOMING"' \ + 'the incoming privileged script is installed root-owned' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%u:%g:%Lp'\'' "$SCRIPT_INCOMING"' \ + 'incoming script ownership and mode are verified' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%u:%g:%Lp'\'' "$SCRIPT_FINAL"' \ + 'final script ownership and mode are verified' +assert_contains "$INSTALL_BLOCK" 'stat -f '\''%u:%g:%Lp'\'' "$ROLLBACK/backup-google-drive.sh"' \ + 'rollback script ownership and mode are verified' +assert_contains "$INSTALL_BLOCK" 'RECOVERY_ARMED=1' \ + 'recovery is armed before service mutation' +assert_before "$INSTALL_BLOCK" 'RECOVERY_ARMED=1' '# FIRST_SERVICE_MUTATION' \ + 'recovery state is trap-visible before the first bootout' +assert_next_line "$INSTALL_BLOCK" '# FIRST_SERVICE_MUTATION' \ + '/bin/launchctl bootout "$DOMAIN" "$SCHEDULE_PLIST"' \ + 'the marked first service mutation is the schedule bootout' +assert_before "$INSTALL_BLOCK" '"$FLOCK_BIN" -n 8' '# FIRST_SERVICE_MUTATION' \ + 'the effective backup lock is acquired before service mutation' +assert_contains "$INSTALL_BLOCK" 'assert_successful_terminal_backup_and_no_processes' \ + 'terminal state and process absence share one fail-closed gate' +gate_count="$(/usr/bin/grep -Fc -- 'assert_successful_terminal_backup_and_no_processes' "$INSTALL_BLOCK")" +if (( gate_count >= 4 )); then + printf '%s\n' 'ok - process/status gate covers pre-build, locked pre-bootout, post-quiesce, and final state' +else + printf '%s\n' 'not ok - process/status gate covers pre-build, locked pre-bootout, post-quiesce, and final state' >&2 + exit 1 +fi +assert_next_line "$INSTALL_BLOCK" '# PREBUILD_GATE' \ + 'assert_successful_terminal_backup_and_no_processes' \ + 'the pre-build gate runs before immutable source export' +assert_before "$INSTALL_BLOCK" '# PREBUILD_GATE' 'git archive --format=tar' \ + 'the pre-build gate precedes archive extraction and build' +assert_next_line "$INSTALL_BLOCK" '# LOCKED_PRE_BOOTOUT_GATE' \ + 'assert_successful_terminal_backup_and_no_processes' \ + 'the locked terminal/process gate runs before service mutation' +assert_before "$INSTALL_BLOCK" '# LOCKED_PRE_BOOTOUT_GATE' '# FIRST_SERVICE_MUTATION' \ + 'the locked gate precedes the first service bootout' +assert_next_line "$INSTALL_BLOCK" '# POST_QUIESCE_GATE' \ + 'assert_successful_terminal_backup_and_no_processes' \ + 'the terminal/process gate repeats after both services quiesce' +assert_before "$INSTALL_BLOCK" '# FIRST_SERVICE_MUTATION' '# POST_QUIESCE_GATE' \ + 'post-quiesce verification follows the service bootouts' +assert_contains "$INSTALL_BLOCK" 'derive_effective_state' \ + 'effective scheduled configuration is derived by one reusable contract' +derive_count="$(/usr/bin/grep -Fc -- 'derive_effective_state' "$INSTALL_BLOCK")" +if (( derive_count >= 4 )); then + printf '%s\n' 'ok - effective state is derived before build, under lock, and after reload' +else + printf '%s\n' 'not ok - effective state is derived before build, under lock, and after reload' >&2 + exit 1 +fi +assert_contains "$INSTALL_BLOCK" '# PREBUILD_SNAPSHOT' \ + 'configuration and plists are bound before the long build' +assert_before "$INSTALL_BLOCK" '# PREBUILD_SNAPSHOT' 'git archive --format=tar' \ + 'the immutable pre-build snapshot precedes archive tests and build' +assert_contains "$INSTALL_BLOCK" '# LOCKED_SNAPSHOT_REVALIDATION' \ + 'configuration, plists, loaded jobs, and effective paths are rebound under lock' +assert_before "$INSTALL_BLOCK" '# LOCKED_SNAPSHOT_REVALIDATION' '# FIRST_SERVICE_MUTATION' \ + 'locked snapshot validation completes before service mutation' +assert_next_line "$INSTALL_BLOCK" '# LOCKED_SNAPSHOT_REVALIDATION' \ + 'assert_runtime_snapshot_matches_prebuild "$STAGE/config-locked.manifest" "$STAGE/locked-effective-state.sh"' \ + 'the complete runtime binding runs at the final pre-mutation snapshot' +locked_snapshot_line="$(line_number "$INSTALL_BLOCK" '# LOCKED_SNAPSHOT_REVALIDATION')" +recovery_armed_line="$(line_number "$INSTALL_BLOCK" 'RECOVERY_ARMED=1')" +first_mutation_line="$(line_number "$INSTALL_BLOCK" '# FIRST_SERVICE_MUTATION')" +if (( recovery_armed_line == locked_snapshot_line + 4 && + first_mutation_line == locked_snapshot_line + 5 )); then + printf '%s\n' 'ok - only the terminal gate and recovery arm separate binding from bootout' +else + printf '%s\n' 'not ok - only the terminal gate and recovery arm separate binding from bootout' >&2 + exit 1 +fi +assert_contains "$INSTALL_BLOCK" 'assert_loaded_service_contract' \ + 'loaded launchd definitions are checked without trusting disk plists alone' +loaded_contract_count="$(/usr/bin/grep -Fc -- 'assert_loaded_service_contract' "$INSTALL_BLOCK")" +if (( loaded_contract_count >= 7 )); then + printf '%s\n' 'ok - both loaded jobs are checked before build, under lock, and after reload' +else + printf '%s\n' 'not ok - both loaded jobs are checked before build, under lock, and after reload' >&2 + exit 1 +fi +assert_contains "$INSTALL_BLOCK" 'assert_manager_environment_clean' \ + 'launchd manager environment overrides are checked without reading values' +assert_not_contains "$INSTALL_BLOCK" 'launchctl getenv' \ + 'manager environment validation does not trust launchctl getenv exit status' +manager_environment_count="$(/usr/bin/grep -Fc -- \ + 'assert_manager_environment_clean' "$INSTALL_BLOCK")" +if (( manager_environment_count >= 4 )); then + printf '%s\n' 'ok - manager environment is checked before build, under lock, and after reload' +else + printf '%s\n' 'not ok - manager environment is checked before build, under lock, and after reload' >&2 + exit 1 +fi +assert_contains "$INSTALL_BLOCK" '# FINAL_RUNTIME_REVALIDATION' \ + 'reloaded services and effective state are rebound during final verification' +assert_before "$INSTALL_BLOCK" '# FINAL_RUNTIME_REVALIDATION' \ + '# FINAL_STATUS_PROCESS_GATE' \ + 'final runtime rebinding precedes the final status and process gate' +assert_next_line "$INSTALL_BLOCK" '# FINAL_STATUS_PROCESS_GATE' \ + ' assert_successful_terminal_backup_and_no_processes' \ + 'the final process gate uses the refreshed effective status path' +assert_contains "$INSTALL_BLOCK" 'GDRIVE_BACKUP_TRIGGER="$SCHEDULE_TRIGGER"' \ + 'sanitized evaluation receives the verified schedule trigger' +assert_contains "$INSTALL_BLOCK" 'BACKUP_ASSUME_YES="$SCHEDULE_ASSUME_YES"' \ + 'sanitized evaluation receives the verified assume-yes value' +assert_not_contains "$INSTALL_BLOCK" \ + "/usr/bin/grep -Fxq 'GDRIVE_BACKUP_PROFILE_ID=default'" \ + 'valid shell-quoted default profile ids are not rejected textually' +assert_contains "$INSTALL_BLOCK" 'assert_default_profile_selection' \ + 'the runbook mirrors the backup script profile-selection contract' +profile_selection_count="$(/usr/bin/grep -Fc -- \ + 'assert_default_profile_selection' "$INSTALL_BLOCK")" +if (( profile_selection_count >= 4 )); then + printf '%s\n' 'ok - profile selection is rechecked before build, under lock, and after reload' +else + printf '%s\n' 'not ok - profile selection is rechecked before build, under lock, and after reload' >&2 + exit 1 +fi +assert_contains "$INSTALL_BLOCK" '"GDRIVE_BACKUP_PROFILE_ID=$profile_id"' \ + 'profile selection accepts the exact unquoted generated form' +assert_contains "$INSTALL_BLOCK" "\"GDRIVE_BACKUP_PROFILE_ID='\$profile_id'\"" \ + 'profile selection accepts the exact single-quoted generated form' +assert_contains "$INSTALL_BLOCK" '"GDRIVE_BACKUP_PROFILE_ID=\"$profile_id\""' \ + 'profile selection accepts the exact double-quoted generated form' +assert_contains "$INSTALL_BLOCK" '(^|[[:space:]/])rclone([[:space:]]|$)' \ + 'process gate detects bare and path-qualified rclone commands' +assert_contains "$INSTALL_BLOCK" 'codesign -d --entitlements :-' \ + 'entitlements are extracted explicitly' +assert_contains "$INSTALL_BLOCK" 'plutil -lint "$ENTITLEMENTS_PLIST"' \ + 'the extracted entitlement plist is parsed' +assert_contains "$INSTALL_BLOCK" 'LEFTOVER' \ + 'recoverable transaction artifacts are reported' +assert_not_contains "$INSTALL_BLOCK" '/usr/bin/trash' \ + 'installation and recovery do not depend on /usr/bin/trash' +entitlement_line="$(line_number "$INSTALL_BLOCK" 'codesign -d --entitlements :-')" +if /usr/bin/sed -n "${entitlement_line}p" "$INSTALL_BLOCK" | /usr/bin/grep -Fq -- '|| true'; then + printf '%s\n' 'not ok - entitlement extraction fails closed' >&2 + exit 1 +fi +printf '%s\n' 'ok - entitlement extraction fails closed' +assert_before "$INSTALL_BLOCK" '# FINAL_VERIFICATION_UNDER_LOCK' '"$FLOCK_BIN" -u 8' \ + 'the effective backup lock remains held through final verification' +assert_next_line "$INSTALL_BLOCK" '# FINAL_VERIFICATION_UNDER_LOCK' \ + 'assert_final_install_state' \ + 'the marked under-lock step executes the complete final verifier' +assert_before "$INSTALL_BLOCK" 'trap '\''handle_install_signal'\'' HUP INT TERM' 'RECOVERY_ARMED=1' \ + 'signal recovery is installed before it is armed' +assert_next_line "$INSTALL_BLOCK" '# RECOVERY_SIGNAL_GUARD' \ + " trap '' HUP INT TERM" \ + 'recovery ignores follow-up termination signals until rollback completes' + +assert_contains "$PLAN" \ + 'Review: every commit in `e948ec29910210a53d587f0a8b9c309ea6238cef...HEAD`' \ + 'Task 6 states the complete reviewed diff truthfully' + +printf '%s\n' 'All release/install runbook checks passed.' diff --git a/tests/release-metadata-test.sh b/tests/release-metadata-test.sh index 73d3262..0ae35b9 100644 --- a/tests/release-metadata-test.sh +++ b/tests/release-metadata-test.sh @@ -3,6 +3,8 @@ set -u ROOT="$(cd "$(dirname "$0")/.." && pwd)" INFO_PLIST="$ROOT/macos/GDriveBackupTiger/Info.plist" +EXPECTED_VERSION="2.4.4" +EXPECTED_BUILD="28" failures=0 check_contains() { @@ -17,16 +19,75 @@ check_contains() { fi } +check_not_contains() { + local file="$1" + local rejected="$2" + local description="$3" + if /usr/bin/grep -Fq -- "$rejected" "$file"; then + printf 'not ok - %s\n' "$description" + failures=$((failures + 1)) + else + printf 'ok - %s\n' "$description" + fi +} + version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$INFO_PLIST")" build="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$INFO_PLIST")" minimum_macos="$(/usr/bin/plutil -extract LSMinimumSystemVersion raw -o - "$INFO_PLIST")" +if [[ "$version" != "$EXPECTED_VERSION" || "$build" != "$EXPECTED_BUILD" ]]; then + printf 'not ok - expected app version %s build %s, got %s build %s\n' \ + "$EXPECTED_VERSION" "$EXPECTED_BUILD" "$version" "$build" + failures=$((failures + 1)) +else + printf 'ok - app version and build match the release plan\n' +fi + check_contains "$ROOT/README.md" "Current release: \`v${version}\`" \ "README release matches the app version" +check_contains "$ROOT/README.md" "GDrive-Backup-Tiger-${EXPECTED_VERSION}.pkg" \ + "README names the exact release installer" +installer_names="$( + /usr/bin/grep -Eo 'GDrive-Backup-Tiger-[0-9]+\.[0-9]+\.[0-9]+\.pkg' \ + "$ROOT/README.md" | /usr/bin/sort -u +)" +if [[ "$installer_names" == "GDrive-Backup-Tiger-${EXPECTED_VERSION}.pkg" ]]; then + printf 'ok - README contains no stale versioned installer name\n' +else + printf 'not ok - README contains no stale versioned installer name\n' + failures=$((failures + 1)) +fi +check_contains "$ROOT/README.md" \ + "Scheduled, retry, mount-triggered, and menu-bar-only runs stay headless and passive, including in full-screen Spaces." \ + "README explicitly keeps automatic retries passive in full-screen Spaces" +check_contains "$ROOT/README.md" \ + "silent authenticated and guest SMB mounting" \ + "README release summary covers authenticated and guest SMB mounting" +check_contains "$ROOT/README.md" \ + "Guest SMB URLs such as \`smb://nas.local/Backups\` bypass Keychain and authentication commands and remain no-UI during automatic runs." \ + "README documents guest SMB without Keychain or UI" +check_contains "$ROOT/README.md" \ + "newer persistent failure for the same profile cannot be erased" \ + "README documents the persistent notification cleanup boundary" +check_contains "$ROOT/README.md" \ + "the progress bar remains indeterminate and no stale or invented percentage is shown" \ + "README documents truthful unknown-total progress" +check_contains "$ROOT/README.md" \ + "Completion appears only after the durable terminal status has been published." \ + "README ties completion to durable terminal status" check_contains "$ROOT/CHANGELOG.md" "## v${version} " \ "changelog contains the app version" check_contains "$ROOT/docs/version-history.md" "| v${version} | ${build} |" \ "publication history contains the app version and build" +check_contains "$ROOT/docs/version-history.md" \ + "The v2.4.3 and v2.4.4 installers are built and verified from their exact tags" \ + "publication history explains both exact-tag installer builds" +check_contains "$ROOT/docs/version-history.md" \ + "No retrospectively built installer is presented as an original historical artifact." \ + "publication history labels retrospectively built installers honestly" +check_not_contains "$ROOT/docs/version-history.md" \ + "The current release alone receives the installer" \ + "publication history does not claim only the current release receives an installer" check_contains "$ROOT/README.md" "macOS ${minimum_macos%%.*}" \ "README states the minimum macOS generation" check_contains "$ROOT/install.sh" "GDRIVE_BACKUP_RETENTION=1" \ @@ -57,6 +118,8 @@ check_contains "$ROOT/install.sh" "MOUNTED_WRITABLE_MEDIA\" != \"true\"" \ "source installer rejects a read-only APFS target" check_contains "$ROOT/install.sh" "-framework UserNotifications" \ "source installer links the macOS notification framework" +check_contains "$ROOT/install.sh" "-framework NetFS" \ + "source installer links the native network mount framework" GDRIVE_BACKUP_VOLUME_UUID=not-a-uuid \ BACKUP_TARGET=apfs \ @@ -80,13 +143,15 @@ check_contains "$ROOT/packaging/scripts/postinstall" "GDRIVE_BACKUP_NOTIFY_FAILU for source in \ ProfileSupport.m \ + BackupProgressSupport.m \ NotificationSupport.m \ SetupHealthSupport.m \ RestoreSupport.m \ RestoreBrowserView.m \ DiagnosticsSupport.m \ DiagnosticsView.m \ - UpdateSupport.m; do + UpdateSupport.m \ + NetworkMountSupport.m; do check_contains "$ROOT/install.sh" "macos/GDriveBackupTiger/$source" \ "source installer links $source" done diff --git a/tests/release-workflow-test.sh b/tests/release-workflow-test.sh index 00b6e8e..71a94fb 100755 --- a/tests/release-workflow-test.sh +++ b/tests/release-workflow-test.sh @@ -63,12 +63,35 @@ fi if [[ -x "$NOTES_EXTRACTOR" ]]; then notes="$("$NOTES_EXTRACTOR" "$tag" 2>/dev/null)" - if [[ "$notes" == *"## v${version} "* && "$notes" != *"## v2.3.1 "* ]]; then + if [[ "$notes" == *"## v${version} "* && "$notes" != *"## v2.4.3 "* ]]; then printf 'ok - extractor returns only the requested changelog section\n' else printf 'not ok - extractor returns only the requested changelog section\n' failures=$((failures + 1)) fi + + if [[ "$notes" == *"accountless/guest SMB remounting without Keychain lookup or UI"* ]]; then + printf 'ok - v2.4.4 notes describe guest SMB remounting without Keychain or UI\n' + else + printf 'not ok - v2.4.4 notes describe guest SMB remounting without Keychain or UI\n' + failures=$((failures + 1)) + fi + + if [[ "$notes" == *"older than or equal to that success"* && + "$notes" == *"newer persistent same-profile failure alert"* ]]; then + printf 'ok - v2.4.4 notes preserve newer persistent failure alerts\n' + else + printf 'not ok - v2.4.4 notes preserve newer persistent failure alerts\n' + failures=$((failures + 1)) + fi + + if [[ "$notes" == *"unknown-total progress indeterminate"* && + "$notes" == *"durable terminal status publication"* ]]; then + printf 'ok - v2.4.4 notes describe truthful unknown-total progress\n' + else + printf 'not ok - v2.4.4 notes describe truthful unknown-total progress\n' + failures=$((failures + 1)) + fi fi check_contains "$WORKFLOW" "tags:" "release workflow is triggered by version tags" diff --git a/tests/restore-ui-test.m b/tests/restore-ui-test.m index 61dfe7e..6663010 100644 --- a/tests/restore-ui-test.m +++ b/tests/restore-ui-test.m @@ -1,6 +1,7 @@ #import #import "Localization.h" +#import "TestApplicationSupport.h" static int failures = 0; @@ -15,7 +16,11 @@ static void Assert(BOOL condition, NSString *name) { int main(void) { @autoreleasepool { - [NSApplication sharedApplication]; + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the restore UI harness stays out of the Dock"); Class viewClass = NSClassFromString(@"GDTRestoreBrowserView"); Assert(viewClass != Nil, @"restore browser view is available"); if (viewClass) { diff --git a/tests/run-state-ui-test.m b/tests/run-state-ui-test.m index 578cd04..5b50062 100644 --- a/tests/run-state-ui-test.m +++ b/tests/run-state-ui-test.m @@ -41,6 +41,17 @@ - (void)showTerminalStateAndQuit:(NSString *)status { return method(delegate, selector, path); } +static void ReadProgressFile(AppDelegate *delegate) { + SEL selector = NSSelectorFromString(@"readProgressFile"); + if (![delegate respondsToSelector:selector]) { + failures++; + return; + } + typedef void (*ReadProgressMethod)(id, SEL); + ReadProgressMethod method = (ReadProgressMethod)[delegate methodForSelector:selector]; + method(delegate, selector); +} + static NSData *RenderedTerminalState(TigerBackupView *view, NSString *status) { SEL selector = NSSelectorFromString(@"setTerminalStatus:"); if (![view respondsToSelector:selector]) { @@ -326,6 +337,42 @@ int main(void) { resultingItemURL:nil error:nil]; + NSString *progressPath = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat: + @"gdrive-foreground-progress-%@", NSUUID.UUID.UUIDString]]; + AppDelegate *progressDelegate = [[AppDelegate alloc] init]; + TigerBackupView *progressView = [[TigerBackupView alloc] + initWithFrame:NSMakeRect(0, 0, 392, 162)]; + progressDelegate.window = [[NSWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 392, 162) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + progressDelegate.window.contentView = progressView; + progressDelegate.progressPath = progressPath; + [@"label=Shared Drive\nphase=3/5\npercent=63\ndetail=630.000 MiB / 1.000 GiB, 10.000 MiB/s, ETA 37s\n" + writeToFile:progressPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + ReadProgressFile(progressDelegate); + BOOL richSnapshotApplied = progressView.progressPercent == 63.0 && + [progressView.progressTitle isEqualToString:@"3/5 · Shared Drive"] && + [progressView.progressDetail isEqualToString: + @"630.000 MiB / 1.000 GiB, 10.000 MiB/s, ETA 37s"]; + + [@"label=My Drive\nphase=1/5\n" + writeToFile:progressPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + ReadProgressFile(progressDelegate); + if (richSnapshotApplied && progressView.progressPercent < 0.0 && + [progressView.progressTitle isEqualToString:@"1/5 · My Drive"] && + progressView.progressDetail.length == 0 && + progressView.progressIndicator.indeterminate) { + printf("ok - phase-only foreground snapshot clears stale rich progress\n"); + } else { + printf("not ok - phase-only foreground snapshot retained stale rich progress\n"); + failures++; + } + [NSFileManager.defaultManager trashItemAtURL:[NSURL fileURLWithPath:progressPath] + resultingItemURL:nil error:nil]; + TigerBackupView *view = [[TigerBackupView alloc] initWithFrame:NSMakeRect(0, 0, 392, 162)]; NSData *successImage = RenderedTerminalState(view, @"success"); NSData *failureImage = RenderedTerminalState(view, @"failure"); diff --git a/tests/setup-health-ui-test.m b/tests/setup-health-ui-test.m index 7cd627d..e5b1db4 100644 --- a/tests/setup-health-ui-test.m +++ b/tests/setup-health-ui-test.m @@ -1,5 +1,7 @@ #import +#import "TestApplicationSupport.h" + #define main GDTApplicationMain #import "../macos/GDriveBackupTiger/main.m" #undef main @@ -74,7 +76,11 @@ static void InstallHealthView(AppDelegate *delegate, NSView *contentView) { int main(void) { @autoreleasepool { - [NSApplication sharedApplication]; + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the setup health harness stays out of the Dock"); Class healthViewClass = NSClassFromString(@"TigerSetupHealthView"); NSView *healthView = healthViewClass ? [[healthViewClass alloc] initWithFrame:NSMakeRect(0, 0, 580, 116)] diff --git a/tests/tiger-accessibility-test.m b/tests/tiger-accessibility-test.m index af4bb40..8a319ff 100644 --- a/tests/tiger-accessibility-test.m +++ b/tests/tiger-accessibility-test.m @@ -165,6 +165,46 @@ int main(void) { @"animation can resume when Reduce Motion is disabled"); AppDelegate *delegate = [[AppDelegate alloc] init]; + delegate.language = @"en"; + TigerOverviewView *overviewView = [[TigerOverviewView alloc] + initWithFrame:NSMakeRect(0, 0, 620, 420)]; + [delegate applyOverviewSnapshot:@{ + @"status": @"running", @"retryRunning": @"1", + @"progressVisible": @"1", + @"progressLabel": T(@"en", @"automaticRetryRunning"), + @"progressPhase": @"Area 3 of 5", @"progressPercent": @"63", + @"progressDetail": @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s" + } toView:overviewView]; + Assert(overviewView.progressIndicator != nil && + !overviewView.progressIndicator.hidden && + !overviewView.progressIndicator.indeterminate && + overviewView.progressIndicator.doubleValue == 63 && + [overviewView.progressPercentLabel.stringValue isEqualToString:@"63 %"] && + [overviewView.progressDetailLabel.stringValue isEqualToString: + @"1.2 GiB / 1.9 GiB, 12.4 MiB/s, ETA 58s"] && + overviewView.progressDetailLabel.frame.size.width >= 400 && + !overviewView.backupButton.enabled, + @"retry overview exposes visible percent and full aggregate detail"); + Assert([overviewView.progressIndicator.accessibilityRole + isEqualToString:NSAccessibilityProgressIndicatorRole] && + [overviewView.progressIndicator.accessibilityLabel + isEqualToString:T(@"en", @"backupProgressCurrentPhase")], + @"retry progress is announced as current-phase progress"); + + [delegate applyOverviewSnapshot:@{ + @"status": @"running", @"retryRunning": @"1", + @"progressVisible": @"1", + @"progressLabel": T(@"en", @"automaticRetryRunning"), + @"progressPhase": @"", @"progressPercent": @"", + @"progressDetail": T(@"en", @"progressPreparing") + } toView:overviewView]; + Assert(!overviewView.progressIndicator.hidden && + overviewView.progressIndicator.indeterminate && + [overviewView.progressPercentLabel.stringValue isEqualToString:@""] && + [overviewView.progressDetailLabel.stringValue isEqualToString: + T(@"en", @"progressPreparing")], + @"retry preparation uses an indeterminate native progress indicator"); + SEL delegateReduceSelector = NSSelectorFromString(@"setReduceMotion:"); SEL durationSelector = NSSelectorFromString(@"animationDuration:"); CGFloat reducedDuration = -1; diff --git a/tests/update-ui-test.m b/tests/update-ui-test.m index d96e64c..42d0710 100644 --- a/tests/update-ui-test.m +++ b/tests/update-ui-test.m @@ -1,6 +1,7 @@ #import #import "UpdateSupport.h" +#import "TestApplicationSupport.h" #define main GDTApplicationMain #import "../macos/GDriveBackupTiger/main.m" @@ -53,7 +54,11 @@ static void Assert(BOOL condition, NSString *name) { int main(void) { @autoreleasepool { - [NSApplication sharedApplication]; + NSApplication *testApplication = + GDTInitializeAccessoryTestApplication(); + Assert(testApplication.activationPolicy == + NSApplicationActivationPolicyAccessory, + @"the update UI harness stays out of the Dock"); UpdateTestDelegate *delegate = [[UpdateTestDelegate alloc] init]; delegate.language = @"de"; DeferredUpdateChecker *checker = [[DeferredUpdateChecker alloc] init];