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.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.
- Keep automatic-backup failures audible and compatible with macOS's persistent alert style, and elevate them to time-sensitive only when the running app can prove that its signature actually carries the authorized entitlement.
- Add a real isolated menu-bar launch smoke test and a mutated-package regression test, so both macOS execution and release verification reject the exact launch-blocking v2.4.1 failure shape.

## 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.
Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ 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
APP_ENTITLEMENTS := macos/GDriveBackupTiger/GDriveBackupTiger.entitlements
USER_NOTIFICATIONS_FRAMEWORK := -framework UserNotifications -framework Security

.PHONY: build install dry-run pkg test clean

Expand All @@ -40,7 +39,10 @@ build:
test -s "$(APP_DIR)/Contents/Resources/Assets.car"; \
./scripts/trash-path.sh "$$ICON_WORK"
xattr -cr "$(APP_DIR)"
codesign --force --deep --entitlements "$(APP_ENTITLEMENTS)" --sign - "$(APP_DIR)"
# Restricted notification entitlements require an Apple-issued signing
# identity. Embedding one in an ad-hoc signature passes codesign verification
# but macOS rejects the app at exec time.
codesign --force --deep --sign - "$(APP_DIR)"

install:
./install.sh
Expand All @@ -65,6 +67,7 @@ test:
bash tests/launch-agent-safety-test.sh
bash tests/release-metadata-test.sh
bash tests/release-workflow-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")"; \
Expand Down
10 changes: 5 additions & 5 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.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.
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.

It backs up:

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.1.pkg` from `Assets`.
2. Download `GDrive-Backup-Tiger-2.4.2.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.1.pkg`.
3. Scroll to `Security` and click `Open Anyway` for `GDrive-Backup-Tiger-2.4.2.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.1.pkg"
xattr -d com.apple.quarantine "$HOME/Downloads/GDrive-Backup-Tiger-2.4.2.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`). 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.
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. The public unsigned package uses normal active notification delivery and therefore does not bypass Focus. A future properly provisioned signed build may elevate the same alert to time-sensitive delivery only when macOS confirms the required protected entitlement.
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 @@ -32,7 +32,8 @@ records are restored transparently as historical source milestones.
| 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` | Published tested release |
| v2.4.1 | 25 | tag `v2.4.1` | Current 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 |

No historical binary installer is reconstructed and presented as an original
artifact. Retrospective release pages expose GitHub's source archives and state
Expand Down
7 changes: 4 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ 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 \
"$ROOT/macos/GDriveBackupTiger/main.m" \
"$ROOT/macos/GDriveBackupTiger/ConfigSupport.m" \
"$ROOT/macos/GDriveBackupTiger/ProfileSupport.m" \
Expand Down Expand Up @@ -396,9 +397,9 @@ test -s "$APP_CONTENTS/Resources/Assets.car"
"$ROOT/scripts/trash-path.sh" "$ICON_WORK"

/usr/bin/xattr -cr "$APP_DIR"
codesign --force --deep \
--entitlements "$ROOT/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements" \
--sign - "$APP_DIR" >/dev/null
# The source installer uses an ad-hoc signature, which cannot carry protected
# Apple notification entitlements without being rejected at exec time.
codesign --force --deep --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
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>25</string>
<string>26</string>
<key>CFBundleShortVersionString</key>
<string>2.4.1</string>
<string>2.4.2</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSPrincipalClass</key>
Expand Down
27 changes: 24 additions & 3 deletions macos/GDriveBackupTiger/main.m
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import <Cocoa/Cocoa.h>
#import <Security/SecTask.h>
#import <UserNotifications/UserNotifications.h>
#include <errno.h>
#include <signal.h>
Expand Down Expand Up @@ -1792,6 +1793,24 @@ - (void)forgetUnknownExternalAttachmentsExceptDiskIDs:
}
}

- (BOOL)timeSensitiveBackupNotificationsEnabled {
SecTaskRef task = SecTaskCreateFromSelf(kCFAllocatorDefault);
if (!task) return NO;

CFErrorRef error = NULL;
CFTypeRef value = SecTaskCopyValueForEntitlement(
task,
CFSTR("com.apple.developer.usernotifications.time-sensitive"),
&error);
BOOL enabled = value &&
CFGetTypeID(value) == CFBooleanGetTypeID() &&
CFBooleanGetValue((CFBooleanRef)value);
if (value) CFRelease(value);
if (error) CFRelease(error);
CFRelease(task);
return enabled;
}

- (UNMutableNotificationContent *)backupNotificationContentForDecision:
(NSDictionary<NSString *, NSString *> *)decision {
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
Expand All @@ -1800,9 +1819,11 @@ - (UNMutableNotificationContent *)backupNotificationContentForDecision:
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;
// A protected level without its signed entitlement can make delivery
// fail. Ad-hoc builds keep the durable alert at the normal active level.
content.interruptionLevel = [self timeSensitiveBackupNotificationsEnabled]
? UNNotificationInterruptionLevelTimeSensitive
: UNNotificationInterruptionLevelActive;
}
return content;
}
Expand Down
6 changes: 4 additions & 2 deletions packaging/build-pkg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ fi

BUILD_DIR="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/gdrive-pkg-build.XXXXXX")"
PKG_ROOT="$BUILD_DIR/root"
DIST_DIR="$ROOT/dist"
DIST_DIR="${DIST_DIR:-$ROOT/dist}"
COMPONENT_PKG="$BUILD_DIR/${PKG_NAME}"
FINAL_PKG="$DIST_DIR/${PKG_NAME}"
APP_PATH="$PKG_ROOT/Applications/GDrive Backup Tiger.app"
Expand All @@ -42,6 +42,9 @@ mkdir -p "$PKG_ROOT/usr/local/bin" \
/usr/bin/make -C "$ROOT" build APP_DIR="$APP_PATH" >&2

if [[ -n "$APP_SIGN_IDENTITY" ]]; then
# A Developer ID signature alone does not authorize protected notification
# capabilities. Keep this build launchable until a verified provisioning
# profile flow exists.
/usr/bin/codesign \
--force \
--options runtime \
Expand All @@ -52,7 +55,6 @@ 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
23 changes: 15 additions & 8 deletions packaging/verify-pkg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ cleanup() {
trap cleanup EXIT

archive_listing="$(/usr/bin/xar -tf "$PKG_PATH")"
has_signature=0
pkg_has_signature=0
if /usr/bin/grep -qx 'Signature' <<<"$archive_listing"; then
has_signature=1
pkg_has_signature=1
fi

if [[ "$SIGNATURE_EXPECTATION" == "unsigned" && $has_signature -ne 0 ]]; then
if [[ "$SIGNATURE_EXPECTATION" == "unsigned" && $pkg_has_signature -ne 0 ]]; then
printf 'Expected an unsigned package, but a signature is present: %s\n' "$PKG_PATH" >&2
exit 1
fi
if [[ "$SIGNATURE_EXPECTATION" == "signed" && $has_signature -eq 0 ]]; then
if [[ "$SIGNATURE_EXPECTATION" == "signed" && $pkg_has_signature -eq 0 ]]; then
printf 'Expected a signed package, but no signature is present: %s\n' "$PKG_PATH" >&2
exit 1
fi
Expand Down Expand Up @@ -129,17 +129,24 @@ if ! /usr/bin/grep -Fq "/bin/chmod 600 \"\$config_file\"" "$POSTINSTALL"; then
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 ||
has_time_sensitive_entitlement=0
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
"$APP_ENTITLEMENTS" 2>/dev/null || true)" == "true" ]]; then
has_time_sensitive_entitlement=1
fi

if [[ $has_time_sensitive_entitlement -ne 0 ]]; then
printf '%s\n' \
'Packaged app is missing the time-sensitive notification entitlement.' >&2
'Packaged app must not carry the restricted time-sensitive notification entitlement.' >&2
exit 1
fi

if [[ "$SIGNATURE_EXPECTATION" == "signed" ]]; then
# The installer and embedded app have independent signatures. Validate the
# app authority directly instead of inferring it from the outer package.
app_signature="$(/usr/bin/codesign --display --verbose=4 "$APP_PATH" 2>&1)"
if ! /usr/bin/grep -Fq 'Authority=Developer ID Application:' <<<"$app_signature"; then
printf '%s\n' 'Signed package does not contain a Developer ID Application-signed app.' >&2
Expand Down
48 changes: 40 additions & 8 deletions tests/app-build-artifacts-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ 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"
SMOKE_PID=""

cleanup() {
if [[ -n "$SMOKE_PID" ]] && /bin/kill -0 "$SMOKE_PID" 2>/dev/null; then
/bin/kill -TERM "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
fi
if [[ -e "$STAGE" ]]; then
"$ROOT/scripts/trash-path.sh" "$STAGE"
fi
Expand All @@ -32,21 +37,48 @@ done
/usr/bin/file "$APP/Contents/MacOS/GDriveBackupTiger" \
| /usr/bin/grep -Fq 'Mach-O universal binary with 2 architectures'

if ! /usr/bin/codesign --display --entitlements :- "$APP" >"$ENTITLEMENTS" 2>/dev/null ||
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'
"$ENTITLEMENTS" 2>/dev/null || true)" == "true" ]]; then
printf '%s\n' \
'not ok - an ad-hoc app carries a restricted entitlement and macOS rejects it at launch'
exit 1
fi

if /usr/bin/grep -Fq 'GDriveBackupTiger.entitlements' "$ROOT/install.sh"; then
printf '%s\n' \
'not ok - the source installer embeds a restricted entitlement in an ad-hoc signature'
exit 1
fi

if /usr/bin/grep -Fq 'GDriveBackupTiger.entitlements' \
"$ROOT/packaging/build-pkg.sh"; then
printf '%s\n' \
'not ok - package builds embed a protected entitlement without a provisioning profile'
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"
# Give the isolated copy its own Launch Services identity so this test can run
# beside an installed controller with the production bundle identifier.
/usr/libexec/PlistBuddy \
-c 'Set :CFBundleIdentifier com.commcats.gdrivebackup.smoketest' \
"$APP/Contents/Info.plist"
/usr/bin/codesign --force --deep --sign - "$APP" >/dev/null
"$APP/Contents/MacOS/GDriveBackupTiger" --menubar >/dev/null 2>&1 &
SMOKE_PID=$!
for _ in {1..20}; do
if ! /bin/kill -0 "$SMOKE_PID" 2>/dev/null; then
wait "$SMOKE_PID" 2>/dev/null || true
printf '%s\n' \
'not ok - macOS rejected or immediately terminated the isolated menu bar app'
exit 1
fi
/bin/sleep 0.1
done
/bin/kill -TERM "$SMOKE_PID"
wait "$SMOKE_PID" 2>/dev/null || true
SMOKE_PID=""

printf '%s\n' \
'ok - isolated app build produces signed universal binary, icon assets, and notification entitlement'
'ok - isolated app build is Universal 2, signed, launchable, and free of restricted entitlements'
Loading
Loading