Add macOS guide: auditing & cleaning Background App Activity (sfltool dumpbtm)
This commit is contained in:
parent
69d60b7753
commit
623f04720c
2 changed files with 155 additions and 0 deletions
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
title: "Auditing & Cleaning macOS Background App Activity (sfltool dumpbtm)"
|
||||
domain: troubleshooting
|
||||
category: general
|
||||
tags: [macos, background-tasks, btm, sfltool, login-items, system-extensions, uninstall, little-snitch]
|
||||
status: published
|
||||
created: 2026-06-21
|
||||
updated: 2026-06-21
|
||||
---
|
||||
# Auditing & Cleaning macOS Background App Activity (`sfltool dumpbtm`)
|
||||
|
||||
## Overview
|
||||
macOS tracks every login item, agent, daemon, helper, and extension that may run in the background in its **Background Task Management (BTM)** database. The GUI shows this under **System Settings → General → Login Items & Extensions** ("Allow in the Background"), but the GUI is summarised and hides paths, identifiers, and orphans.
|
||||
|
||||
`sfltool dumpbtm` prints the full BTM database from the command line — and the per-user records need **no `sudo`**. This is the fastest way to answer "what is allowed to run in the background, and does each entry still map to an installed app?"
|
||||
|
||||
## List what's registered
|
||||
|
||||
```bash
|
||||
sfltool dumpbtm # per-user records, no sudo required
|
||||
```
|
||||
|
||||
Each record looks like:
|
||||
|
||||
```
|
||||
Name: CleanMyMac Menu
|
||||
Type: login item (0x4)
|
||||
Disposition: [enabled, allowed, notified] (0xb)
|
||||
Identifier: 4.com.macpaw.CleanMyMac-mas.Menu
|
||||
URL: Contents/Library/LoginItems/CleanMyMac_5_MAS_Menu.app
|
||||
Bundle Identifier: com.macpaw.CleanMyMac-mas.Menu
|
||||
Parent Identifier: 2.com.macpaw.CleanMyMac-mas
|
||||
```
|
||||
|
||||
### Reading the fields
|
||||
- **Disposition** — `enabled` = actively allowed to run in the background. `disabled` = present but off.
|
||||
- **Type** — what kind of item it is:
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
| `app (0x2)` | A normal application entry |
|
||||
| `login item (0x4)` | Launches at login (menu-bar apps, helpers) |
|
||||
| `agent (0x8)` / `legacy agent` | Per-user background agent |
|
||||
| `legacy daemon (0x10010)` | System-wide background daemon |
|
||||
| `background tasks (0x2000)` | Abstract background-task registration owned by a parent app — **has no file path of its own** |
|
||||
| `developer (0x20)` | A per-developer grouping header (the collapsible row in Settings), **not an app** |
|
||||
| `quicklook` / `spotlight` / `dock tile` | Plugins/extensions — not really "background apps" |
|
||||
|
||||
## Map entries to installed apps (find orphans)
|
||||
|
||||
Two gotchas make naïve path-checking fail:
|
||||
|
||||
1. **Absolute paths are stored as `file://` URLs**, not plain `/…`. Strip the `file://` prefix and URL-decode (`%20` → space).
|
||||
2. **Child items store a *relative* `URL`** (e.g. `Contents/Library/LoginItems/…`) that must be joined to the **parent record's** absolute path, found via `Parent Identifier`.
|
||||
|
||||
A small parser that resolves each record to a real path and flags true orphans:
|
||||
|
||||
```python
|
||||
import sys, re, os, urllib.parse
|
||||
items, cur = [], None
|
||||
def push():
|
||||
global cur
|
||||
if cur is not None: items.append(cur)
|
||||
for line in sys.stdin:
|
||||
s = line.strip()
|
||||
if re.match(r"^#\d+:$", s): push(); cur = {}; continue
|
||||
if cur is None: continue
|
||||
m = re.match(r"^([A-Za-z][A-Za-z /]+):\s*(.*)$", s)
|
||||
if m: cur[m.group(1).strip()] = m.group(2).strip()
|
||||
push()
|
||||
byid = {it["Identifier"]: it for it in items if it.get("Identifier")}
|
||||
def abspath(it, d=0):
|
||||
if d > 8: return None
|
||||
u = it.get("URL", "")
|
||||
if u and u != "(null)":
|
||||
if u.startswith("file://"): return urllib.parse.unquote(u[7:]).rstrip("/")
|
||||
if u.startswith("/"): return u.rstrip("/")
|
||||
par = byid.get(it.get("Parent Identifier", ""))
|
||||
if par:
|
||||
b = abspath(par, d + 1)
|
||||
if b: return os.path.join(b, urllib.parse.unquote(u)).rstrip("/")
|
||||
return None
|
||||
for it in items:
|
||||
if not it.get("Name"): continue
|
||||
p = abspath(it)
|
||||
if p and not os.path.exists(p):
|
||||
print("ORPHAN:", it["Name"], "->", p)
|
||||
```
|
||||
|
||||
```bash
|
||||
sfltool dumpbtm | python3 btm_check.py
|
||||
```
|
||||
|
||||
> **Expected non-orphans:** `background tasks (0x2000)` and `developer (0x20)` rows legitimately store no path — they are not missing apps. Helpers/daemons that resolve *inside* a parent bundle (e.g. `/Applications/Foo.app/Contents/Library/LoginItems/…`) or in `/Library/…` are also fine; they just don't appear as a top-level `.app`. That is usually why an entry "has no application you can find."
|
||||
|
||||
## Disable background for an app
|
||||
|
||||
This **cannot be scripted** — Apple deliberately gates the toggle behind the GUI:
|
||||
|
||||
**System Settings → General → Login Items & Extensions → "Allow in the Background"** → switch the app off.
|
||||
|
||||
Disabling a `developer (0x20)` grouping header turns off all of that developer's sub-items at once.
|
||||
|
||||
## Uninstall cleanly — the system-extension trap
|
||||
|
||||
**Dragging an app to the Trash is not a full uninstall.** Apps that install a **network/system extension** plus a privileged daemon (firewalls and VPNs especially — Little Snitch, Mullvad, etc.) leave their `/Library` daemon **still loaded and running** after the app is trashed. The BTM entry persists and the background service keeps working.
|
||||
|
||||
### 1. Prefer the app's own uninstaller
|
||||
- **Bundled uninstall script** (Mullvad): runs cleanly, deactivates the system extension, resets the firewall.
|
||||
```bash
|
||||
sudo "/Applications/Mullvad VPN.app/Contents/Resources/uninstall.sh"
|
||||
```
|
||||
- Some apps ship an uninstaller in their DMG or a CLI tool. **Note:** Little Snitch 6.x has **no DMG uninstaller and no `littlesnitch uninstall` subcommand** — manual removal is the supported route there.
|
||||
|
||||
### 2. Check whether a system extension is still active
|
||||
```bash
|
||||
systemextensionsctl list
|
||||
```
|
||||
If the app's extension is **not** listed (only unrelated ones like Tailscale/Canon remain), the extension is already deactivated and a manual file removal is now complete and safe.
|
||||
|
||||
### 3. Manual removal (when no uninstaller exists)
|
||||
Find every component first:
|
||||
```bash
|
||||
ls /Library/LaunchDaemons/<id>* /Library/LaunchAgents/<id>* 2>/dev/null
|
||||
ls -d "/Library/Application Support/<Vendor>" 2>/dev/null
|
||||
ls ~/Library/Preferences/<id>* 2>/dev/null
|
||||
```
|
||||
Then boot out the daemon and remove the files:
|
||||
```bash
|
||||
sudo launchctl bootout system /Library/LaunchDaemons/<id>.daemon.plist 2>/dev/null
|
||||
sudo rm -f /Library/LaunchDaemons/<id>.daemon.plist /Library/LaunchAgents/<id>.agent.plist
|
||||
sudo rm -rf "/Library/Application Support/<Vendor>" "$HOME/.Trash/<App>.app"
|
||||
rm -f ~/Library/Preferences/<id>*.plist # user-owned, no sudo
|
||||
```
|
||||
|
||||
> **Shared-container caution:** before deleting `~/Library/Group Containers/*`, check it isn't shared. Microsoft apps share `UBF8T346G9.com.microsoft.oneauth`, `…entrabroker`, and `…teams` across Office/Teams/RDP — delete only the app-specific container (e.g. `…com.microsoft.rdc`), never the shared auth ones.
|
||||
|
||||
## Stale BTM "ghost" entries
|
||||
|
||||
After a manual uninstall, `sfltool dumpbtm` may still list the removed app, pointing at now-deleted paths. These are harmless orphans (nothing left to load). **BTM reconciles them on the next reboot / login cycle** — a reboot also finalises any system-extension teardown.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
sfltool dumpbtm # full per-user BTM dump (no sudo)
|
||||
sfltool dumpbtm | grep -A6 'Name:' # browse records
|
||||
systemextensionsctl list # active network/system extensions
|
||||
# Verify a removal:
|
||||
sfltool dumpbtm | grep -i <vendor> # should be empty after a reboot
|
||||
```
|
||||
|
||||
## See also
|
||||
- Apple gates "Allow in the Background" behind System Settings — there is no supported CLI toggle for BTM dispositions.
|
||||
- For VPN/firewall apps, always reach for the vendor uninstaller first; manual `rm` alone can leave a registered system extension behind.
|
||||
|
|
@ -127,6 +127,7 @@ updated: 2026-06-21T11:46
|
|||
* [rsync over Tailscale: Hung in TCP Teardown After Transfer Completes](05-troubleshooting/networking/rsync-tailscale-teardown-stall.md)
|
||||
* [iOS Tailscale Clients Report HostName="localhost" — Breaks /etc/hosts Generators](05-troubleshooting/networking/tailscale-status-json-hostname-localhost-ios.md)
|
||||
* [macOS: Repeating Alert Tone from Mirrored iPhone Notification](05-troubleshooting/macos-mirrored-notification-alert-loop.md)
|
||||
* [Auditing & Cleaning macOS Background App Activity (sfltool dumpbtm)](05-troubleshooting/macos-background-app-activity-audit-sfltool.md)
|
||||
* [Time Machine: Orphaned APFS `.previous` Folder Blocks All Backups](05-troubleshooting/time-machine-apfs-orphaned-previous-blocks-backup.md)
|
||||
* [OBS Studio: Stale Script Paths After Windows Profile Rename](05-troubleshooting/obs-stale-script-paths-after-windows-profile-rename.md)
|
||||
* [ClamAV CPU Spike: Safe Scheduling with nice/ionice](05-troubleshooting/security/clamscan-cpu-spike-nice-ionice.md)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue