One-way sync from Firefox on macOS to Safari — bookmarks and open tabs.
Runs as a macOS LaunchAgent every 5 minutes. Note: iCloud does not reliably propagate external writes to Safari bookmarks — see Known limitations.
- macOS 13 (Ventura) or later
- Python 3.10+
- Firefox with at least one profile
- iCloud Drive enabled with Safari sync active
Clone the repository:
git clone https://github.com/Pierrci/firefox-safari-sync.git
cd firefox-safari-syncRun the installer:
bash install.shThe installer will:
- Detect Python 3.10+ (prefers Homebrew)
- Create a venv and install
lz4 - Write the LaunchAgent plist to
~/Library/LaunchAgents/ - Bootstrap the agent via
launchctl - Print the exact Python interpreter path for Full Disk Access
The daemon reads Safari's files, which macOS protects behind Full Disk Access (FDA). The installer prints the exact path to grant. Follow these steps:
- Open System Settings → Privacy & Security → Full Disk Access
- Click +
- Press Cmd+Shift+G and paste the path printed by
install.sh - Click Open and enable the toggle
Security note: FDA is granted to the Python interpreter binary, not the script. Any script run by that binary inherits FDA. This is an acceptable tradeoff for a personal, single-purpose venv.
Tail the daemon log to confirm it is running:
tail -f ~/Library/Logs/firefox-safari-sync/stdout.logA successful cycle ends with:
2026-03-21 10:00:00 [INFO] Sync cycle completed successfully.
| Data | Destination in Safari | Cadence |
|---|---|---|
| Firefox open tabs | "Firefox Tabs" bookmarks folder | Every cycle |
| Firefox bookmarks | "Firefox" bookmarks folder (full hierarchy) | Every cycle |
Tabs are only synced while Firefox is running. If Firefox is closed, the cycle completes silently without them.
Each 5-minute cycle, sync.py:
- Reads open tabs from Firefox's
recovery.jsonlz4session file - Reads bookmarks from
places.sqlite(read-only, no lock contention) - Writes tabs and bookmarks into
~/Library/Safari/Bookmarks.plistatomically
Bookmark nodes use deterministic UUIDs derived from Firefox GUIDs. Unchanged nodes produce the same UUID every cycle, preventing iCloud sync churn.
iCloud propagation to iOS requires a manual trigger — see Known limitations.
firefox-safari-sync/
├── sync.py # daemon script
├── install.sh # setup
├── uninstall.sh # teardown
├── requirements.txt # lz4
└── com.user.firefox-safari-sync.plist # LaunchAgent template
Runtime paths (outside the repo):
~/.config/firefox-safari-sync/state.json # cached Firefox profile path
~/Library/Logs/firefox-safari-sync/stdout.log
~/Library/Logs/firefox-safari-sync/stderr.log
~/Library/LaunchAgents/com.user.firefox-safari-sync.plist
Safari history sync uses CloudKit — a record-oriented push protocol — mediated by a private system daemon (SafariCloudHistoryPushAgent) and an XPC service (com.apple.Safari.History) that requires a private Apple entitlement. Writing rows directly into History.db bypasses this stack entirely: no CloudKit event is emitted, and the rows never propagate to iOS. Apple's own forensic research has confirmed that iCloud can actively overwrite a locally modified History.db with the server copy, discarding external writes.
Bookmarks.plist is file-based, so direct writes are visible to Safari locally. However, cloudd does not reliably pick up external writes for iCloud upload — iOS propagation requires a manual iCloud Safari toggle. History does not even have this property, making it entirely unviable.
- iCloud bookmark sync requires a manual trigger. The daemon writes
Bookmarks.plistcorrectly, butcloudddoes not reliably upload external writes to iCloud. Changes may not appear on iOS for hours or at all. The only reliable way to force propagation is to manually toggle Safari sync off and back on in System Settings → Apple ID → iCloud → Safari. There is no programmatic way to trigger this: restartingbirdis blocked by SIP, and writing toMobileMeAccounts.plisthas no effect becauseaccountsdmanages service state via XPC, not file-watching. - Safari in-memory state can overwrite a daemon write on quit. The next 5-minute cycle self-heals.
- iCloud race condition: a remote change arriving from iOS can overwrite
Bookmarks.plist. The next cycle re-applies Firefox data.
The LaunchAgent stores an absolute path to the Python interpreter. If Homebrew replaces it, the daemon stops. Re-run the installer to update the path:
bash install.shbash uninstall.shThis stops the LaunchAgent and removes the plist. The following are not removed — delete manually if desired:
~/.config/firefox-safari-sync/(state)~/Library/Logs/firefox-safari-sync/(logs)./venv/(Python environment)
Also remove the Full Disk Access entry from System Settings → Privacy & Security → Full Disk Access.
Bug fixes and improvements are welcome. Open an issue before starting large changes.