Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

## v2.4.1 - 2026-07-29

- Mark automatic-backup failure alerts as time-sensitive and preserve the required signing entitlement across source installs, local builds, and release packages, so Focus may present the warning without the app taking focus or opening a window.
- Treat the fail-closed `destination_unreadable` NAS codec preflight as eligible for the same single delayed automatic retry as transient mount-readiness failures, while continuing to exclude permissions, damaged manifests, name collisions, unsupported tooling, and unclassified exit codes.
- Tell the user when this transient NAS read failure will be retried and verify the notification entitlement in both isolated app builds and packaged release artifacts.

## v2.4.0 - 2026-07-29

- Offer a previously unknown directly attached physical disk once per attachment through a passive macOS notification, without opening a window, taking focus, writing, formatting, changing settings, or starting a backup; setup begins only after an explicit, revalidated action and preserves the active NAS target and schedule until Save.
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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
APP_ENTITLEMENTS := macos/GDriveBackupTiger/GDriveBackupTiger.entitlements

.PHONY: build install dry-run pkg test clean

Expand All @@ -39,7 +40,7 @@ build:
test -s "$(APP_DIR)/Contents/Resources/Assets.car"; \
./scripts/trash-path.sh "$$ICON_WORK"
xattr -cr "$(APP_DIR)"
codesign --force --deep --sign - "$(APP_DIR)"
codesign --force --deep --entitlements "$(APP_ENTITLEMENTS)" --sign - "$(APP_DIR)"

install:
./install.sh
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.0` with persistent automatic-failure alerts and a safe retry, 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.1` with time-sensitive 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.

It backs up:

Expand All @@ -26,7 +26,7 @@ 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 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.
- 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.
- 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.
Expand Down Expand Up @@ -87,7 +87,7 @@ rclone lsd gdrive:
For most users, download the latest installer from the GitHub releases page:

1. Open <https://github.com/AlexanderSmyslowski/gdrive-tiger-backup/releases/latest>
2. Download `GDrive-Backup-Tiger-2.4.0.pkg` from `Assets`.
2. Download `GDrive-Backup-Tiger-2.4.1.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.

Expand All @@ -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.0.pkg`.
3. Scroll to `Security` and click `Open Anyway` for `GDrive-Backup-Tiger-2.4.1.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.0.pkg"
xattr -d com.apple.quarantine "$HOME/Downloads/GDrive-Backup-Tiger-2.4.1.pkg"
```

### Install from source
Expand Down Expand Up @@ -240,7 +240,7 @@ Legacy profiles without the key retain exact-path behavior. Merely opening setup
Saved schedules run unattended after the script verifies the configured destination. `GDRIVE_BACKUP_CONFIRM=1` still protects mount-triggered runs with a prompt. Set it to `0` only if you also deliberately want those mount-triggered backups to start unattended whenever the configured volume is mounted.
Set `GDRIVE_BACKUP_PAUSED=1` to silence schedule and mount-triggered runs without changing the saved schedule. The menu bar toggles this setting; **Backup now** always remains manual and available.
Set `GDRIVE_BACKUP_NOTIFY_FAILURES=0` to disable macOS alerts for automatic failures and missed daily runs. The menu bar and overview continue to show backup status even when alerts are disabled or macOS notification permission is denied.
To keep a failure visible until a person dismisses it or a later automatic backup succeeds, set the macOS notification style for **GDrive Backup Tiger** to **Persistent** (`System Settings` → `Notifications` → `GDrive Backup Tiger`). macOS controls this presentation setting; the app never opens a modal window or takes foreground focus for an automatic failure. A manual success deliberately leaves the alert in place.
To keep a failure visible until a person dismisses it or a later automatic backup succeeds, set the macOS notification style for **GDrive Backup Tiger** to **Persistent** (`System Settings` → `Notifications` → `GDrive Backup Tiger`). Leave **Time Sensitive Notifications** enabled there so macOS may present backup failures during Focus. macOS controls both presentation settings; the app never opens a modal window or takes foreground focus for an automatic failure. A manual success deliberately leaves the alert in place.
For NAS targets, `GDRIVE_BACKUP_NAS_MOUNT_TIMEOUT_SECONDS` bounds the macOS mount request (default `90`, range `1`–`300`) and `GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS` bounds the subsequent verified-writable readiness wait (default `60`, range `0`–`300`).
Set `GDRIVE_BACKUP_AUTO_CREATE_VOLUME=0` if you want to create the backup volume yourself.
Set `GDRIVE_BACKUP_NAS_START_ON_MOUNT=1` only if mount events should also start the configured NAS backup; the default `0` reserves mount-triggered runs for the external APFS target.
Expand Down
3 changes: 2 additions & 1 deletion docs/version-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ records are restored transparently as historical source milestones.
| v2.3.0 | 21 | `0714564` | Historical source milestone published retrospectively |
| v2.3.1 | 22 | `c3c94b2` | Historical source milestone published retrospectively |
| v2.3.2 | 23 | `7ecda45` | Historical source milestone published retrospectively |
| v2.4.0 | 24 | tag `v2.4.0` | Current tested release |
| v2.4.0 | 24 | tag `v2.4.0` | Published tested release |
| v2.4.1 | 25 | tag `v2.4.1` | 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
Expand Down
4 changes: 3 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,9 @@ test -s "$APP_CONTENTS/Resources/Assets.car"
"$ROOT/scripts/trash-path.sh" "$ICON_WORK"

/usr/bin/xattr -cr "$APP_DIR"
codesign --force --deep --sign - "$APP_DIR" >/dev/null
codesign --force --deep \
--entitlements "$ROOT/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements" \
--sign - "$APP_DIR" >/dev/null

sudo install -m 755 "$ROOT/bin/backup-google-drive.sh" /usr/local/bin/backup-google-drive.sh
install -m 644 "$AGENT_SRC" "$AGENT_DST"
Expand Down
8 changes: 8 additions & 0 deletions macos/GDriveBackupTiger/GDriveBackupTiger.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
</dict>
</plist>
4 changes: 2 additions & 2 deletions macos/GDriveBackupTiger/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>24</string>
<string>25</string>
<key>CFBundleShortVersionString</key>
<string>2.4.0</string>
<string>2.4.1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSPrincipalClass</key>
Expand Down
20 changes: 15 additions & 5 deletions macos/GDriveBackupTiger/NotificationSupport.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ static NSTimeInterval GDTTimestamp(NSString *value) {
return GDTSafeNotificationProfileID(value);
}

static BOOL GDTIsRetryableNASReason(NSString *reason) {
return [@[
@"nas_mount_unavailable",
@"nas_mount_not_ready",
// The shell emits this only when its read-only NAS codec preflight
// cannot safely inspect the destination. A later retry repeats every
// fail-closed check before any copy can start.
@"destination_unreadable"
] containsObject:reason ?: @""];
}

static NSDate *GDTWatchdogDateForNow(NSDate *now, NSCalendar *calendar) {
NSDate *watchdog = [calendar dateBySettingHour:21 minute:0 second:0 ofDate:now options:0];
if ([watchdog compare:now] == NSOrderedDescending) {
Expand Down Expand Up @@ -92,8 +103,7 @@ @implementation GDTBackupNotificationPolicy
NSString *reason = summary[@"reason"] ?: @"";
if (retryFailure) {
bodyKey = @"backupNotificationRetryFailureBody";
} else if ([@[@"nas_mount_unavailable", @"nas_mount_not_ready"]
containsObject:reason]) {
} else if (GDTIsRetryableNASReason(reason)) {
bodyKey = @"backupNotificationNASRetryBody";
} else if ([reason isEqualToString:@"destination_permission_denied"]) {
bodyKey = @"failedPermissionHint";
Expand Down Expand Up @@ -164,10 +174,10 @@ @implementation GDTAutomaticRetryPolicy
return nil;
}

// Exit 69 also covers permanent safety failures. Only mount-readiness
// outcomes are transient enough to repeat without user intervention.
// Exit 69 also covers permanent safety failures, so retry only the
// explicitly fail-closed NAS outcomes above.
NSString *reason = summary[@"reason"] ?: @"";
if (![@[@"nas_mount_unavailable", @"nas_mount_not_ready"] containsObject:reason]) {
if (!GDTIsRetryableNASReason(reason)) {
return nil;
}

Expand Down
5 changes: 5 additions & 0 deletions macos/GDriveBackupTiger/main.m
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,11 @@ - (UNMutableNotificationContent *)backupNotificationContentForDecision:
content.body = T(self.language ?: @"en", decision[@"bodyKey"]);
content.sound = UNNotificationSound.defaultSound;
content.categoryIdentifier = @"GDT_BACKUP_ALERT";
if (@available(macOS 12.0, *)) {
// Backup failures must be noticeable during Focus without activating
// the app or stealing a full-screen workspace.
content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive;
}
return content;
}

Expand Down
1 change: 1 addition & 0 deletions packaging/build-pkg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ if [[ -n "$APP_SIGN_IDENTITY" ]]; then
--force \
--options runtime \
--timestamp \
--entitlements "$ROOT/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements" \
--sign "$APP_SIGN_IDENTITY" \
"$APP_PATH" >&2
fi
Expand Down
10 changes: 10 additions & 0 deletions packaging/verify-pkg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ if ! /usr/bin/grep -Fq "/bin/chmod 600 \"\$config_file\"" "$POSTINSTALL"; then
exit 1
fi
/usr/bin/codesign --verify --deep --strict "$APP_PATH"
APP_ENTITLEMENTS="$VERIFY_ROOT/app-entitlements.plist"
if ! /usr/bin/codesign --display --entitlements :- "$APP_PATH" \
>"$APP_ENTITLEMENTS" 2>/dev/null ||
[[ "$(/usr/libexec/PlistBuddy \
-c 'Print :com.apple.developer.usernotifications.time-sensitive' \
"$APP_ENTITLEMENTS" 2>/dev/null || true)" != "true" ]]; then
printf '%s\n' \
'Packaged app is missing the time-sensitive notification entitlement.' >&2
exit 1
fi

if [[ "$SIGNATURE_EXPECTATION" == "signed" ]]; then
app_signature="$(/usr/bin/codesign --display --verbose=4 "$APP_PATH" 2>&1)"
Expand Down
20 changes: 19 additions & 1 deletion tests/app-build-artifacts-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/gdrive-app-build-test.XXXXXX")"
APP="$STAGE/GDrive Backup Tiger.app"
ENTITLEMENTS="$STAGE/app-entitlements.plist"

cleanup() {
if [[ -e "$STAGE" ]]; then
Expand Down Expand Up @@ -31,4 +32,21 @@ done
/usr/bin/file "$APP/Contents/MacOS/GDriveBackupTiger" \
| /usr/bin/grep -Fq 'Mach-O universal binary with 2 architectures'

printf '%s\n' 'ok - isolated app build produces signed universal binary and icon assets'
if ! /usr/bin/codesign --display --entitlements :- "$APP" >"$ENTITLEMENTS" 2>/dev/null ||
[[ "$(/usr/libexec/PlistBuddy \
-c 'Print :com.apple.developer.usernotifications.time-sensitive' \
"$ENTITLEMENTS" 2>/dev/null || true)" != "true" ]]; then
printf '%s\n' 'not ok - isolated app build preserves the time-sensitive notification entitlement'
exit 1
fi

for signing_entrypoint in Makefile install.sh packaging/build-pkg.sh; do
if ! /usr/bin/grep -Fq 'GDriveBackupTiger.entitlements' "$ROOT/$signing_entrypoint"; then
printf 'not ok - %s preserves notification entitlements while signing\n' \
"$signing_entrypoint"
exit 1
fi
done

printf '%s\n' \
'ok - isolated app build produces signed universal binary, icon assets, and notification entitlement'
25 changes: 25 additions & 0 deletions tests/automatic-retry-support-test.m
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,38 @@ int main(void) {
[NSDate dateWithTimeIntervalSince1970:2840]) != nil,
@"an unavailable configured NAS mount is retryable");

NSMutableDictionary<NSString *, NSString *> *destinationUnreadable =
[mountNotReady mutableCopy];
destinationUnreadable[@"reason"] = @"destination_unreadable";
NSDictionary<NSString *, NSString *> *unreadableRetry = Decision(
policyClass, dailyNAS, destinationUnreadable, @"failure",
[NSDate dateWithTimeIntervalSince1970:2840]);
Assert([unreadableRetry[@"identifier"]
isEqualToString:@"office.automatic-retry.1000"] &&
[unreadableRetry[@"trigger"] isEqualToString:@"schedule-retry"],
@"a fail-closed transient NAS read failure is retried once");

NSMutableDictionary<NSString *, NSString *> *permissionFailure =
[mountNotReady mutableCopy];
permissionFailure[@"reason"] = @"destination_permission_denied";
Assert(Decision(policyClass, dailyNAS, permissionFailure, @"failure",
[NSDate dateWithTimeIntervalSince1970:2840]) == nil,
@"a permanent destination permission failure is not retried");

for (NSString *reason in @[
@"invalid_name_codec",
@"name_codec_collision",
@"unsupported_rclone"
]) {
NSMutableDictionary<NSString *, NSString *> *unsafeFailure =
[mountNotReady mutableCopy];
unsafeFailure[@"reason"] = reason;
Assert(Decision(policyClass, dailyNAS, unsafeFailure, @"failure",
[NSDate dateWithTimeIntervalSince1970:2840]) == nil,
[NSString stringWithFormat:
@"the permanent safety failure %@ is never retried", reason]);
}

NSMutableDictionary<NSString *, NSString *> *unclassified =
[mountNotReady mutableCopy];
[unclassified removeObjectForKey:@"reason"];
Expand Down
10 changes: 8 additions & 2 deletions tests/notification-integration-test.m
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,15 @@ int main(void) {
ContentMethod contentMethod = (ContentMethod)[delegate methodForSelector:contentSelector];
content = contentMethod(delegate, contentSelector, first);
}
BOOL timeSensitiveLevel = NO;
if (@available(macOS 12.0, *)) {
timeSensitiveLevel =
content.interruptionLevel == UNNotificationInterruptionLevelTimeSensitive;
}
Assert(content.sound != nil &&
[content.categoryIdentifier isEqualToString:@"GDT_BACKUP_ALERT"],
@"automatic backup alerts remain audible without opening a window");
[content.categoryIdentifier isEqualToString:@"GDT_BACKUP_ALERT"] &&
timeSensitiveLevel,
@"automatic backup alerts are time-sensitive and audible without opening a window");

NSSet<UNNotificationCategory *> *categories = nil;
SEL categoriesSelector = NSSelectorFromString(@"appNotificationCategories");
Expand Down
11 changes: 11 additions & 0 deletions tests/notification-support-test.m
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ int main(void) {
[retryPlanned[@"bodyKey"] isEqualToString:@"backupNotificationNASRetryBody"],
@"a transient NAS readiness failure announces the later automatic retry");

NSMutableDictionary<NSString *, NSString *> *destinationUnreadable =
[failedSummary mutableCopy];
destinationUnreadable[@"reason"] = @"destination_unreadable";
NSDictionary<NSString *, NSString *> *readRetryPlanned = Decision(
policyClass, daily, destinationUnreadable, @"failure",
Date(calendar, 21, 20, 26), calendar);
Assert([readRetryPlanned[@"kind"] isEqualToString:@"failure"] &&
[readRetryPlanned[@"bodyKey"]
isEqualToString:@"backupNotificationNASRetryBody"],
@"a transient NAS read failure announces the later automatic retry");

NSMutableDictionary<NSString *, NSString *> *retryFailed = [nasNotReady mutableCopy];
retryFailed[@"trigger"] = @"schedule-retry";
retryFailed[@"started_at"] = [NSString stringWithFormat:@"%.0f",
Expand Down
Loading
Loading