fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync

- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
  in a child process of the app (`pstr-app::verify`), confined to
  https://*.proton.me, hands the token back and the sign-in is retried. Linux
  packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
  WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
  so a Proton session with many key salts survives Windows' 1280-unit cap.
  Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
  end against an in-memory Drive: two devices, newer wins, no re-upload of an
  unchanged history, a missing file written again, an unreadable own file
  never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
  to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
  other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
  Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.
This commit is contained in:
nvrl committed 2026-10-06 14:30:15 +02:00
1 parent 26cccd5c63
commit d9ca8f8d60
28 files changed
+2475 -186

No files matched your search

+39 -9
View File
@@ -513,19 +513,17 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed, watch positions included.
/// Drop everything a share contributed except where it was watched.
///
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
/// state alone — this is the viewer saying they are done with the share.
/// Keeping positions for files they can no longer reach would only leave
/// the "continue watching" row pointing at nothing.
/// Watch state stays, as through a recrawl. It is history, and not only
/// this device's: with an account signed in, the next sync would bring it
/// straight back from the other devices' files, so deleting it here would
/// only make it flicker. Nothing shows it — the library pairs positions
/// with files, and the files are gone — until the share is added again,
/// when it picks up where it left off.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM watch_state WHERE share_id = ?1",
params![share_id],
)?;
tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1",
params![share_id],
@@ -2430,6 +2428,38 @@ mod tests {
assert_eq!(rows[0].link_id, "film");
}
#[test]
fn removing_a_share_keeps_its_watch_state_out_of_sight() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("crawl");
let state = WatchState {
position_secs: 120.0,
duration_secs: Some(1440.0),
watched: false,
updated_at: 10,
};
catalog.set_watch_state("s1", "a", &state).expect("set");
catalog.remove_share("s1").expect("remove");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert!(library.titles.is_empty());
assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state));
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("re-add");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
}
#[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open");