Skip to content

Commit 18f8d62

Browse files
committed
feat(sync): replicate history via copy instead of running SQLite live in iCloud
Looper now always reads and writes a fast local DB at the platform data directory. When a sync folder is configured, it pulls from there at startup (if the cloud copy has more recent activity by max(last_played_at)) and pushes back on quit via WAL checkpoint + atomic temp+rename. Previously the cloud folder hosted the live DB, which led to: - "authorization denied" crashes when macOS TCC blocked iCloud mid-session - WAL/SHM corruption from iCloud syncing live SQLite sidecars - Hangs while iCloud rehydrated an evicted database file iCloud Drive is no longer auto-detected; opt in explicitly via `looper config set sync-folder`. Failed pulls surface a SyncWarning banner; failed pushes log a stderr warning instead of crashing on quit. Drops the now-dead merge_old_db_into + its tests; replaces with three tests covering pull-skip / pull-replace / push-create semantics. README's sync section is rewritten to match, including the macOS TCC requirement for iCloud Drive replication.
1 parent 2469c02 commit 18f8d62

4 files changed

Lines changed: 254 additions & 275 deletions

File tree

README.md

Lines changed: 28 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -141,94 +141,64 @@ Playback history and favorites live in a SQLite database (`looper.sqlite3`). Whe
141141

142142
## Cross-Device Sync
143143

144-
looper keeps history in sync across your computers by storing `looper.sqlite3` in a shared folder (iCloud Drive, Dropbox, or any folder you choose). No account, no server — just a file in a folder you already sync.
144+
looper always reads and writes a fast local copy of `looper.sqlite3`. If you point it at a cloud folder (iCloud Drive, Dropbox, anything that syncs files), looper will pull from that folder at startup and push to it on quit. The cloud folder never holds the live database — it's just a passive copy that the cloud provider replicates between your machines on its own.
145145

146-
### How it picks where to store the database
146+
This avoids the long-standing footguns of running SQLite directly on a cloud-synced filesystem (corrupted WAL/SHM sidecars, surprise permission denials, evicted files).
147147

148-
On every launch, looper resolves the database location in this order:
148+
### Where the live database lives
149149

150-
1. **Configured sync folder** — if you've run `looper config set sync-folder`, that path wins
151-
2. **iCloud Drive (macOS only)** — if iCloud Drive is active, looper automatically uses `~/Library/Mobile Documents/com~apple~CloudDocs/looper/looper.sqlite3`
152-
3. **Platform default** — fallback when neither of the above applies
153-
154-
| Platform | Default database path |
155-
|----------|-----------------------|
156-
| macOS (no iCloud) | `~/Library/Application Support/sh.kbr.looper/looper.sqlite3` |
150+
| Platform | Live database path |
151+
|----------|--------------------|
152+
| macOS | `~/Library/Application Support/sh.kbr.looper/looper.sqlite3` |
157153
| Linux | `~/.local/share/looper/looper.sqlite3` |
158154

159-
### iCloud Drive (zero config on macOS)
160-
161-
If you use iCloud Drive, nothing extra is needed. On first launch after installing (or upgrading to) this version, looper:
162-
163-
1. Detects iCloud Drive is active
164-
2. Creates `~/Library/Mobile Documents/com~apple~CloudDocs/looper/looper.sqlite3`
165-
3. Merges any existing local history into the iCloud database automatically
166-
4. Archives the old local database to `.sqlite3.bak` so it's never run twice
155+
By default no replication runs. Looper just uses the local path above.
167156

168-
Once the iCloud file syncs to your other Macs (usually within a minute), every machine shares the same history. No commands needed.
169-
170-
**Verify it's working:**
171-
172-
```shell
173-
looper config show
174-
# sync_folder = (auto — iCloud Drive if available, otherwise platform default)
175-
176-
ls ~/Library/Mobile\ Documents/com~apple~CloudDocs/looper/
177-
# looper.sqlite3
178-
```
179-
180-
### Dropbox, OneDrive, or any synced folder
157+
### Replicate via a cloud folder
181158

182159
Point looper at any folder your cloud provider keeps in sync:
183160

184161
```shell
185-
looper config set sync-folder ~/Dropbox/looper
186-
# Sync folder set to: /Users/you/Dropbox/looper
187-
# looper will use this folder for looper.sqlite3 on next launch.
162+
looper config set sync-folder "$HOME/Library/Mobile Documents/com~apple~CloudDocs/looper"
163+
# Replication folder set to: ...
164+
# looper will pull from this folder at startup and push to it on quit.
165+
# The live DB stays at the platform data directory.
188166
```
189167

190-
Run this once on each computer. On the next launch, looper moves (with merge) to that folder.
168+
Run this once on each computer that should share history. The cloud provider takes care of moving `looper.sqlite3` between machines in the background.
191169

192170
**Verify it's working:**
193171

194172
```shell
195173
looper config show
196-
# sync_folder = /Users/you/Dropbox/looper
174+
# sync_folder = /Users/you/Library/Mobile Documents/.../looper (replicated on startup/quit)
197175

198-
ls ~/Dropbox/looper/
176+
ls "$HOME/Library/Mobile Documents/com~apple~CloudDocs/looper/"
199177
# looper.sqlite3
200178
```
201179

202-
### Check current config at any time
180+
### macOS: iCloud Drive needs Files-and-Folders permission
203181

204-
```shell
205-
looper config show
206-
```
182+
The first time looper tries to read or write inside `~/Library/Mobile Documents/...`, macOS will silently deny access until you grant the terminal app that launches looper permission. Open **System Settings → Privacy & Security → Files and Folders** (or **Full Disk Access**) and toggle on **iCloud Drive** for your terminal (Terminal, iTerm, Ghostty, etc.). Restart the terminal so the new entitlement takes effect.
207183

208-
### Two-computer upgrade scenario
184+
If permission isn't granted, looper still runs against the local DB, surfaces a `History sync disabled` banner at startup, and prints a one-line warning to stderr on quit. Nothing crashes.
209185

210-
If both computers already have local history from an older version of looper:
186+
### Sync semantics: last-quitter wins
211187

212-
1. **Computer A** upgrades → detects iCloud (or configured folder) → merges its old local history in → archives old file
213-
2. iCloud syncs to Computer B (the database now has A's history)
214-
3. **Computer B** upgrades → opens the iCloud file (already has A's data) → merges its own old local history in
188+
Replication is a file copy in both directions:
215189

216-
Result: the shared database has all plays from both computers, tagged with the machine that played each track. No data is lost.
190+
- **At startup**: if the cloud copy has a more recent `MAX(last_played_at)` than the local copy, looper replaces local with the cloud copy.
191+
- **At quit**: looper checkpoints the WAL, then atomically replaces the cloud copy with the local one.
217192

218-
### Merge rules (when two histories combine)
193+
This is enough for one-human-at-a-time use across multiple Macs (typical single-user setup). It is **not** a general-purpose multi-master merge: if you play on two machines simultaneously, whichever quits last overwrites the other's session, and your cloud provider may produce conflict copies (e.g. `looper.sqlite3 conflicted-copy 2`). Resolve by closing one, picking the version you want to keep, and deleting the rest.
219194

220-
| Field | Result |
221-
|-------|--------|
222-
| Play count | Sum of both |
223-
| Time played | Sum of both |
224-
| First played | Earliest of both |
225-
| Last played | Latest of both |
226-
| Favorite | `true` if either copy is favorited |
227-
| Last played on | Computer with the more recent play |
195+
### Disable replication
228196

229-
### A note on concurrent access
197+
```shell
198+
rm "$HOME/.config/looper/sync_folder"
199+
```
230200

231-
looper enables WAL mode on the database, which makes it safe for one machine to read while another writes. Playing on two machines simultaneously and writing to the same file is unusual and could cause conflicts — for typical single-user use (one active machine at a time) this is not an issue.
201+
Or just don't set it. Looper falls back to local-only without complaint.
232202

233203
## Keys
234204

src/main.rs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,11 @@ enum ConfigCmd {
125125

126126
#[derive(StructOpt, Debug)]
127127
enum ConfigKey {
128-
/// Set the folder where looper.sqlite3 is stored (e.g. a Dropbox or custom path).
129-
/// Overrides iCloud auto-detection. Run once per machine.
128+
/// Set a folder to replicate looper.sqlite3 to (e.g. iCloud Drive, Dropbox).
129+
/// looper always reads and writes a fast local copy in the platform data
130+
/// directory; on startup it pulls from this folder if it has newer
131+
/// activity, and on quit it pushes the local copy back. By default no
132+
/// replication runs. Run once per machine.
130133
SyncFolder { path: String },
131134
}
132135

@@ -207,20 +210,39 @@ fn run_app(opt: Opt) -> Result<()> {
207210
fn cmd_config(cmd: ConfigCmd) -> Result<()> {
208211
match cmd {
209212
ConfigCmd::Set { key: ConfigKey::SyncFolder { path } } => {
210-
storage::write_sync_folder_config(std::path::Path::new(&path))?;
211-
println!("Sync folder set to: {path}");
212-
println!("looper will use this folder for looper.sqlite3 on next launch.");
213+
let folder = std::path::Path::new(&path);
214+
storage::write_sync_folder_config(folder)?;
215+
println!("Replication folder set to: {path}");
216+
println!(
217+
"looper will pull from this folder at startup and push to it on quit.\n\
218+
The live DB stays at the platform data directory."
219+
);
220+
if is_under_icloud_drive(folder) {
221+
println!();
222+
println!(
223+
"note: iCloud Drive uploads are async, so a quit on one machine isn't\n\
224+
visible on another until iCloud syncs. Running looper on two machines\n\
225+
at the same time can produce iCloud conflict copies — close one before\n\
226+
opening the other."
227+
);
228+
}
213229
}
214230
ConfigCmd::Show => match storage::read_sync_folder_config() {
215-
Some(folder) => println!("sync_folder = {}", folder.display()),
216-
None => println!(
217-
"sync_folder = (auto — iCloud Drive if available, otherwise platform default)"
218-
),
231+
Some(folder) => println!("sync_folder = {} (replicated on startup/quit)", folder.display()),
232+
None => println!("sync_folder = (none — local DB only, no replication)"),
219233
},
220234
}
221235
Ok(())
222236
}
223237

238+
fn is_under_icloud_drive(path: &std::path::Path) -> bool {
239+
let Some(home) = directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()) else {
240+
return false;
241+
};
242+
let icloud = home.join("Library/Mobile Documents/com~apple~CloudDocs");
243+
path.starts_with(&icloud)
244+
}
245+
224246
#[cfg(test)]
225247
mod tests {
226248
use super::*;

src/play_loop.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ fn browse_history_session(
112112
if event::poll(Duration::from_millis(30))? {
113113
if let Event::Key(key) = event::read()? {
114114
match handle_history_browser_key_event(key, &panel) {
115-
KeyCommand::Quit => return Ok(()),
115+
KeyCommand::Quit => {
116+
push_replica_best_effort(&storage);
117+
return Ok(());
118+
}
116119
KeyCommand::HistoryNext => {
117120
if panel.selected + 1 < panel.rows.len() {
118121
panel.selected += 1;
@@ -227,7 +230,10 @@ fn play_file_session(
227230

228231
match next {
229232
Some(replay_target) => current_url = replay_target,
230-
None => return Ok(()),
233+
None => {
234+
push_replica_best_effort(&storage);
235+
return Ok(());
236+
}
231237
}
232238
}
233239
}
@@ -912,10 +918,21 @@ fn play_single_track(
912918
title_state,
913919
ctx,
914920
)?;
915-
persist_played_time(&storage, &record.track_key, played_seconds(&state))?;
921+
if let Err(err) = persist_played_time(&storage, &record.track_key, played_seconds(&state)) {
922+
eprintln!("looper: warning — could not record final play time on quit: {err}");
923+
}
916924
Ok(result)
917925
}
918926

927+
/// Pushes the local DB to the configured replica (e.g. iCloud Drive). Best
928+
/// effort: any failure is logged and swallowed so quit can't fail.
929+
fn push_replica_best_effort(storage: &SharedStorage) {
930+
let storage = storage.lock().unwrap();
931+
if let Err(err) = storage.push_replica() {
932+
eprintln!("looper: warning — could not replicate history on quit: {err}");
933+
}
934+
}
935+
919936
struct PrefetchWorker {
920937
tracks: Arc<Mutex<Vec<TrackInfo>>>,
921938
sender: SyncSender<PrefetchTask>,

0 commit comments

Comments
 (0)