From d362b1dbf998f05e87439fe0f7b2b08e311841a7 Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Mon, 10 Aug 2026 09:56:47 +0900 Subject: [PATCH] fix(cli): ask before macOS pops "Background Items Added" at init (#139) --- cmd/bdrive/autostart_notice_test.go | 57 +++++++++++++++++++++++ cmd/bdrive/init.go | 36 ++++++++++++-- web/docs/src/content/docs/manual/hooks.md | 9 ++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 cmd/bdrive/autostart_notice_test.go diff --git a/cmd/bdrive/autostart_notice_test.go b/cmd/bdrive/autostart_notice_test.go new file mode 100644 index 0000000..a1679ee --- /dev/null +++ b/cmd/bdrive/autostart_notice_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestInstallAutostartWarnsBeforeTheFirstMacOSWrite +// +// Writing ~/Library/LaunchAgents/*.plist is what makes macOS pop "Background +// Items Added". Nothing suppresses that notice, so the only thing keeping it +// from reading as malware is our own line arriving first — and it must arrive +// only when a write is actually about to happen, or it becomes noise every +// init prints and nobody reads. +func TestInstallAutostartWarnsBeforeTheFirstMacOSWrite(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the notice exists only on macOS") + } + home := t.TempDir() + t.Setenv("HOME", home) + + // Non-interactive: no prompt, just the heads-up. (The TTY branch needs a + // terminal survey can drive, which a test does not have.) + say := func() string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + installAutostart(false) + os.Stdout = old + w.Close() + out, _ := io.ReadAll(r) + r.Close() + return string(out) + } + + first := say() + if !strings.Contains(first, "Background Items Added") { + t.Errorf("first install said nothing about the macOS notice:\n%s", first) + } + plist := filepath.Join(home, "Library", "LaunchAgents", "ai.beardrive.daemon.plist") + if _, err := os.Stat(plist); err != nil { + t.Fatalf("no agent written: %v", err) + } + + // Already registered: no second write, so no notice to explain. + if second := say(); strings.Contains(second, "Background Items Added") { + t.Errorf("re-running warned again about a notice that will not fire:\n%s", second) + } +} diff --git a/cmd/bdrive/init.go b/cmd/bdrive/init.go index 1c001ec..9d5f274 100644 --- a/cmd/bdrive/init.go +++ b/cmd/bdrive/init.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "time" @@ -241,7 +242,7 @@ the folder was renamed or moved.`, installAgentHooks(folder) } if !noAutostart { - installAutostart() + installAutostart(stdinIsTTY() && !yes) } return startSync(cmd.Context(), folder, proj, foreground, 3*time.Second, 10*time.Second) } @@ -387,7 +388,7 @@ the folder was renamed or moved.`, installAgentHooks(folder) } if !noAutostart { - installAutostart() + installAutostart(interactive) } if len(scope) > 0 { dirs := make([]string, len(scope)) @@ -493,7 +494,36 @@ func installAgentHooks(folder string) { // Linux without systemd) or an unwritable config dir is not a reason to fail // an init that otherwise worked — the folder syncs, it just won't come back by // itself. -func installAutostart() { +// +// On macOS the write itself is what users see: the moment anything lands in +// ~/Library/LaunchAgents, Ventura+ pops "Background Items Added", naming a +// binary they just installed. Nothing suppresses that notice — SMAppService, a +// System Settings login item and a real crontab all trigger it (cron also +// wants Full Disk Access on top) — so the fix is to make it expected rather +// than to dodge it: say what is about to happen before it happens, and on a +// TTY ask first. Linux and Windows show nothing, so they are not asked. +func installAutostart(interactive bool) { + if runtime.GOOS == "darwin" && !autostart.Installed() { + const notice = `macOS will show a "Background Items Added" notice for it` + if interactive { + ok := true + // An interrupt reads as "no": autostart is a convenience, and the + // caller is one step from starting the daemon anyway. + // + // ponytail: a decline is not remembered, so re-running `bdrive init` + // interactively asks again. Persist it in settings.json if that ever + // nags — agents and scripts have no TTY and never see the prompt. + if err := survey.AskOne(&survey.Confirm{ + Message: "Restart syncing at login? (" + notice + ")", + Default: true, + }, &ok); err != nil || !ok { + fmt.Println(" login: autostart skipped — `bdrive autostart install` enables it later") + return + } + } else { + fmt.Println(" login: registering sync to restart at login — " + notice) + } + } res, err := autostart.Install() if err != nil { if !errors.Is(err, autostart.ErrUnsupported) { diff --git a/web/docs/src/content/docs/manual/hooks.md b/web/docs/src/content/docs/manual/hooks.md index 4cc1e20..bde2a79 100644 --- a/web/docs/src/content/docs/manual/hooks.md +++ b/web/docs/src/content/docs/manual/hooks.md @@ -110,6 +110,15 @@ Linux needs systemd as the init system. Without it — Alpine or another runit/OpenRC distro, WSL1, a slim container — `bdrive autostart` says so rather than writing a unit nothing would read. +On macOS, the moment that file is written you get a **"Background Items Added"** +notification, and `bdrive` appears in System Settings → General → Login Items. +That notice is macOS reporting the registration above — every way of starting +at login triggers it, including Apple's own `SMAppService` and a plain +`crontab` — so `bdrive init` asks first on a terminal and says what is about to +happen when it can't ask. Answer no, or run `bdrive autostart uninstall`, and +the item goes away; sync then resumes on the next `bdrive resume`, `bdrive +init`, or agent turn instead of at login. + On Windows you may see a console window flicker at logon: bdrive is a console program and `resume` exits in milliseconds. Nothing is wrong.