breadpad: save note before window can close, add network timeouts, lock the JSONL store, fix monthly recurrence, restrict config permissions

- main.rs: hold the gtk4::Application (RAII guard from app.hold()) across
  the idle_add_local_once save callback, so closing the capture window
  can no longer let the process quit before the note is actually written
  to disk
- breadpad-shared/src/ai.rs: 10s timeout on the Ollama HTTP call (ureq had
  none, so a hung local endpoint could stall indefinitely)
- breadpad-shared/src/calendar.rs: 15s timeout on the CalDAV reqwest
  client (applies to every CalDAV request made through it)
- breadpad-shared/src/store.rs: exclusive flock (std::fs::File::lock, no
  new dependency needed at this Rust version) on a sidecar lock file
  around every read-modify-write span, guarding against breadpad/breadman/
  reminder-fire processes racing each other on notes.jsonl
- breadpad-shared/src/scheduler.rs: parse_next_from_rrule now handles
  FREQ=MONTHLY (previously fell into the catch-all None arm, so a monthly
  reminder fired once and never rescheduled); reachable today via
  breadman's free-text RRULE editor field
- breadpad-shared/src/config.rs: Config::save() chmods breadpad.toml to
  0600 after writing, since it can hold the CalDAV password in plaintext

Pre-existing, unrelated test failure noted: theme::tests::css_defines_bg_color
fails identically on the original commit (depends on this machine's live
pywal cache) — confirmed via git stash, not touched.
This commit is contained in:
Breadway 2026-07-17 08:28:19 +08:00
parent b7aed8a37c
commit 6e5448e853
6 changed files with 168 additions and 14 deletions

View file

@ -206,6 +206,42 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
return Some(local_naive_to_utc(target_date));
}
"MONTHLY" => {
use chrono::Datelike;
// BYMONTHDAY isn't guaranteed to be present — breadman's note
// editor lets a user type an arbitrary RRULE by hand, and
// "FREQ=MONTHLY" alone is a perfectly valid (if under-specified)
// one. Fall back to today's day-of-month, mirroring how WEEKLY
// above defaults BYDAY to "MO" when absent.
let day: u32 = parts
.get("BYMONTHDAY")
.and_then(|v| v.parse().ok())
.filter(|d: &u32| (1..=31).contains(d))
.unwrap_or_else(|| now.day());
let mut year = now.year();
let mut month = now.month();
// Walk forward month by month for the next calendar date that
// (a) actually has this day-of-month (a 31st skips e.g. April)
// and (b) is still in the future. Bounded to 24 months as a
// defensive cap — every valid day (1-31) recurs well within a
// year, so this should never come close to firing.
for _ in 0..24 {
if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
let candidate = date.and_time(fire_time);
if now.naive_local() < candidate {
return Some(local_naive_to_utc(candidate));
}
}
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
None
}
_ => None,
}
}
@ -404,7 +440,40 @@ mod tests {
#[test]
fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
assert!(parse_next_from_rrule("RRULE:FREQ=YEARLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
}
#[test]
fn monthly_reschedules_instead_of_returning_none() {
// This used to be the exact bug: MONTHLY fell into the `_ => None`
// arm, so a monthly reminder fired once and never rescheduled.
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=9;BYMINUTE=0", "08:00");
assert!(t.is_some(), "MONTHLY must produce a next occurrence, not None");
let local: chrono::DateTime<Local> = t.unwrap().into();
assert_eq!(local.day(), 15);
assert_eq!(local.hour(), 9);
assert_eq!(local.minute(), 0);
assert!(local > Local::now());
}
#[test]
fn monthly_without_bymonthday_uses_todays_day_of_month() {
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=23;BYMINUTE=59", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.day(), Local::now().day());
}
#[test]
fn monthly_on_the_31st_skips_shorter_months() {
// Every candidate month/day combination this walks must actually
// exist (from_ymd_opt returning None for e.g. April 31 is skipped
// internally) — this mostly guards against a panic/infinite loop
// regression rather than a specific date, since "next Feb 31" et al
// must fall through to a month that really has a 31st.
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0", "08:00");
assert!(t.is_some());
let local: chrono::DateTime<Local> = t.unwrap().into();
assert_eq!(local.day(), 31);
}
#[test]