aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src')
-rw-r--r--crates/dinggerd/src/main.rs25
-rw-r--r--crates/dinggerd/src/notify.rs13
-rw-r--r--crates/dinggerd/src/platform.rs19
-rw-r--r--crates/dinggerd/src/restore.rs243
-rw-r--r--crates/dinggerd/src/watcher.rs3
5 files changed, 278 insertions, 25 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 7cf3513..fced7c2 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -72,6 +72,12 @@ enum Command {
/// Restore only these sessions.
#[arg(long = "session")]
sessions: Vec<String>,
+ /// Write the report to restore.log and notify a summary.
+ #[arg(long)]
+ report: bool,
+ /// Stop offering the saved layout (it can still be restored).
+ #[arg(long)]
+ dismiss: bool,
},
/// Install integrations: an agent's hook extension (state and session
/// reports), or `zsh` (command lines for restore to retype).
@@ -90,11 +96,16 @@ enum ManifestCommand {
}
fn main() {
+ platform::default_sigpipe();
+ let cli = Cli::parse();
+ // The daemon logs its work; commands print their own output and only
+ // log problems.
+ let default_level = if matches!(cli.command, Command::Watch { .. }) { "info" } else { "warn" };
tracing_subscriber::fmt()
- .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
+ .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default_level.into()))
.init();
- match Cli::parse().command {
+ match cli.command {
Command::Watch {
interval_secs,
no_sounds,
@@ -134,8 +145,14 @@ fn main() {
// Best effort: a stopped watcher just means nothing to wake.
let _ = hooks::poke();
}
- Command::Restore { dry_run, list, from, sessions } => {
- let result = if list { restore::list() } else { restore::run(from.as_deref(), &sessions, dry_run) };
+ Command::Restore { dry_run, list, from, sessions, report, dismiss } => {
+ let result = if list {
+ restore::list()
+ } else if dismiss {
+ restore::dismiss()
+ } else {
+ restore::run(from.as_deref(), &sessions, dry_run, report)
+ };
if let Err(err) = result {
eprintln!("dingger: {err}");
std::process::exit(1);
diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs
index e619d6d..555299d 100644
--- a/crates/dinggerd/src/notify.rs
+++ b/crates/dinggerd/src/notify.rs
@@ -63,6 +63,19 @@ pub fn play_sound(kind: &NotificationKind) {
}
}
+/// Deliver a notification and wait for it: for short-lived commands, whose
+/// exit would cut a background delivery short.
+pub fn send_message_blocking(title: &str, body: &str) {
+ info!(title, body, "notifying");
+ if let Ok(command) = std::env::var("DINGGER_NOTIFIER") {
+ let _ = std::process::Command::new("sh").args(["-c", &command, title, body]).status();
+ return;
+ }
+ if let Err(err) = mac_notification_sys::Notification::new().title(title).message(body).send() {
+ warn!(%err, "failed to deliver macOS notification");
+ }
+}
+
/// Deliver a macOS notification without blocking the caller: the delivery
/// call waits on Notification Center for seconds, and the watcher must keep
/// updating the UI meanwhile.
diff --git a/crates/dinggerd/src/platform.rs b/crates/dinggerd/src/platform.rs
index 8504a83..6c78817 100644
--- a/crates/dinggerd/src/platform.rs
+++ b/crates/dinggerd/src/platform.rs
@@ -1,4 +1,21 @@
-//! macOS process inspection.
+//! macOS process inspection and process setup.
+
+/// Exit quietly when stdout closes early (`dingger restore --list | head`),
+/// like other command-line tools, instead of panicking: Rust ignores
+/// SIGPIPE by default.
+#[cfg(unix)]
+pub fn default_sigpipe() {
+ extern "C" {
+ fn signal(signum: std::os::raw::c_int, handler: usize) -> usize;
+ }
+ const SIGPIPE: std::os::raw::c_int = 13;
+ const SIG_DFL: usize = 0;
+ // SAFETY: resets one signal to its default disposition before any
+ // threads start.
+ unsafe {
+ signal(SIGPIPE, SIG_DFL);
+ }
+}
/// A process's exact argument vector, from the kernel (`KERN_PROCARGS2`):
/// unlike `ps`, it keeps arguments that contain spaces intact.
diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs
index 2332865..623cdcd 100644
--- a/crates/dinggerd/src/restore.rs
+++ b/crates/dinggerd/src/restore.rs
@@ -18,6 +18,27 @@ use std::time::{Duration, Instant};
use crate::snapshot::{self, shell_quote, PaneSnap, SessionSnap, Snapshot, WindowSnap};
use crate::tmux;
+thread_local! {
+ static REPORT: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
+}
+
+/// Print a report line, and keep it for `--report`.
+fn say(line: String) {
+ println!("{line}");
+ REPORT.with(|report| report.borrow_mut().push(line));
+}
+
+/// What a restore did, for the summary notification.
+#[derive(Debug, Default)]
+pub struct Summary {
+ pub sessions: usize,
+ pub failed: usize,
+ pub agents: usize,
+ pub resumed: usize,
+ pub interrupted: usize,
+ pub typed: usize,
+}
+
/// What restore types into a rebuilt pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Typed {
@@ -239,7 +260,7 @@ fn replace_session(session: &SessionSnap, rebuilt: &mut Vec<Rebuilt>) -> Result<
/// Wait for each new shell's prompt, then resume agents (spaced out) and
/// type the other command lines without running them; report which agents
/// did not start.
-fn type_commands(rebuilt: &[Rebuilt]) {
+fn type_commands(rebuilt: &[Rebuilt], summary: &mut Summary) {
let work: Vec<(&Rebuilt, Typed)> =
rebuilt.iter().filter_map(|r| typed_for(&r.pane).map(|typed| (r, typed))).collect();
if work.is_empty() {
@@ -266,21 +287,23 @@ fn type_commands(rebuilt: &[Rebuilt]) {
Typed::Resume(command) => {
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]);
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "Enter"]);
- println!("resume {} {command}", r.label);
+ say(format!("resume {} {command}", r.label));
resumed.push(*r);
// Agents start one at a time.
std::thread::sleep(Duration::from_millis(500));
}
Typed::Retype(command) => {
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]);
- println!("type {} {command} (press Enter to run)", r.label);
+ say(format!("type {} {command} (press Enter to run)", r.label));
+ summary.typed += 1;
}
}
}
for (r, _) in &work {
if let Some(agent) = r.pane.agent.as_ref().filter(|agent| agent.state == "working") {
let name = if agent.name.is_empty() { &agent.agent } else { &agent.name };
- println!("interrupted {} {name}: it was working, its turn was cut short", r.label);
+ say(format!("interrupted {} {name}: it was working, its turn was cut short", r.label));
+ summary.interrupted += 1;
}
}
if resumed.is_empty() {
@@ -297,9 +320,11 @@ fn type_commands(rebuilt: &[Rebuilt]) {
.unwrap_or(true)
});
}
- println!("resumed {} of {} agents", resumed.len() - not_running.len(), resumed.len());
+ summary.agents = resumed.len();
+ summary.resumed = resumed.len() - not_running.len();
+ say(format!("resumed {} of {} agents", summary.resumed, summary.agents));
for r in not_running {
- println!("not running {}: check the pane for the error", r.label);
+ say(format!("not running {}: check the pane for the error", r.label));
}
}
@@ -348,9 +373,59 @@ fn format_time(ms: u64) -> String {
.unwrap_or_else(|_| ms.to_string())
}
-/// Restore missing sessions (all, or only `only`).
-pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), String> {
+/// Restore missing sessions (all, or only `only`). With `report`, also
+/// write the report to `restore.log` and notify a summary.
+pub fn run(from: Option<&str>, only: &[String], dry_run: bool, report: bool) -> Result<(), String> {
+ let result = restore(from, only, dry_run);
+ if report && !dry_run {
+ let lines = REPORT.with(|report| report.borrow().join("\n"));
+ let _ = std::fs::write(snapshot::state_dir().join("restore.log"), format!("{lines}\n"));
+ let (title, body) = match &result {
+ Ok(summary) => summary_message(summary),
+ Err(err) => ("tmux restore failed".to_string(), err.clone()),
+ };
+ crate::notify::send_message_blocking(&title, &body);
+ }
+ let _ = crate::hooks::poke();
+ result.map(|_| ())
+}
+
+fn plural(n: usize, one: &str, many: &str) -> String {
+ format!("{n} {}", if n == 1 { one } else { many })
+}
+
+pub fn summary_message(summary: &Summary) -> (String, String) {
+ let title = format!("Restored {}", plural(summary.sessions, "tmux session", "tmux sessions"));
+ let mut parts = Vec::new();
+ if summary.agents > 0 {
+ parts.push(format!("resumed {} of {}", summary.resumed, plural(summary.agents, "agent", "agents")));
+ }
+ if summary.interrupted > 0 {
+ parts.push(format!("{} mid-turn", plural(summary.interrupted, "was", "were")));
+ }
+ if summary.typed > 0 {
+ parts.push(format!("{} typed, not run", plural(summary.typed, "command", "commands")));
+ }
+ if summary.failed > 0 {
+ parts.push(format!("{} failed", plural(summary.failed, "session", "sessions")));
+ }
+ if parts.is_empty() {
+ parts.push("nothing else to resume".into());
+ }
+ (title, parts.join(" · "))
+}
+
+/// Mark the restore candidate dismissed: no more offers for it.
+pub fn dismiss() -> Result<(), String> {
+ snapshot::set_candidate_status("dismissed")?;
+ let _ = crate::hooks::poke();
+ println!("dismissed; `dingger restore` still restores it");
+ Ok(())
+}
+
+fn restore(from: Option<&str>, only: &[String], dry_run: bool) -> Result<Summary, String> {
let snapshot = source(from)?;
+ let mut summary = Summary::default();
let existing = existing_sessions();
let mut failures = 0;
let mut rebuilt = Vec::new();
@@ -364,19 +439,19 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
let what = windows_and_panes(session);
let verb = match plan {
Plan::Keep => {
- println!("keep {name}: it exists");
+ say(format!("keep {name}: it exists"));
continue;
}
Plan::Create => "create",
Plan::Replace => "replace",
};
if dry_run {
- println!("{verb:<8} {name}: {what}");
+ say(format!("{verb:<8} {name}: {what}"));
for window in &session.windows {
for pane in &window.panes {
match typed_for(pane) {
- Some(Typed::Resume(command)) => println!(" resume {} {command}", window.name),
- Some(Typed::Retype(command)) => println!(" type {} {command}", window.name),
+ Some(Typed::Resume(command)) => say(format!(" resume {} {command}", window.name)),
+ Some(Typed::Retype(command)) => say(format!(" type {} {command}", window.name)),
None => {}
}
}
@@ -388,23 +463,124 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
_ => build_session(session, &name, &mut rebuilt).map(|_| ()),
};
match result {
- Ok(()) => println!("{verb:<8} {name}: {what}"),
+ Ok(()) => {
+ summary.sessions += 1;
+ say(format!("{verb:<8} {name}: {what}"));
+ }
Err(err) => {
failures += 1;
- println!("FAILED {name}: {err}");
+ say(format!("FAILED {name}: {err}"));
}
}
}
if !rebuilt.is_empty() {
- type_commands(&rebuilt);
+ type_commands(&rebuilt, &mut summary);
}
- if dry_run || from.is_some() {
- return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) };
- }
- if missing(&snapshot, &existing_sessions()).is_empty() {
+ summary.failed = failures;
+ if !dry_run && from.is_none() && missing(&snapshot, &existing_sessions()).is_empty() {
let _ = snapshot::set_candidate_status("restored");
}
- if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) }
+ if failures == 0 { Ok(summary) } else { Err(format!("{failures} sessions failed")) }
+}
+
+/// One session restore would bring back, as offered to the user.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct OfferedSession {
+ pub name: String,
+ pub windows: usize,
+ pub panes: usize,
+ pub agents: usize,
+}
+
+/// The restore offer the menu bar app shows (`offer.json`).
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct Offer {
+ pub saved_at: u64,
+ pub sessions: Vec<OfferedSession>,
+ pub agents: usize,
+ /// Agents that were mid-turn when tmux stopped.
+ pub interrupted: usize,
+}
+
+pub fn offer(snapshot: &Snapshot, existing: &[Existing]) -> Option<Offer> {
+ let missing = missing(snapshot, existing);
+ let sessions: Vec<&SessionSnap> =
+ snapshot.sessions.iter().filter(|session| missing.contains(&session.name)).collect();
+ if sessions.is_empty() {
+ return None;
+ }
+ let panes = |session: &&SessionSnap| session.windows.iter().flat_map(|w| &w.panes).cloned().collect::<Vec<_>>();
+ let agents = sessions.iter().flat_map(panes).filter(|pane| pane.agent.is_some()).count();
+ let interrupted = sessions
+ .iter()
+ .flat_map(panes)
+ .filter(|pane| pane.agent.as_ref().is_some_and(|agent| agent.state == "working"))
+ .count();
+ Some(Offer {
+ saved_at: snapshot.saved_at,
+ sessions: sessions
+ .iter()
+ .map(|session| OfferedSession {
+ name: session.name.clone(),
+ windows: session.windows.len(),
+ panes: session.windows.iter().map(|w| w.panes.len()).sum(),
+ agents: panes(session).iter().filter(|pane| pane.agent.is_some()).count(),
+ })
+ .collect(),
+ agents,
+ interrupted,
+ })
+}
+
+/// Keeps `offer.json` and the `@dingger_restore` option current; called by
+/// the watcher every loop.
+#[derive(Default)]
+pub struct OfferPublisher {
+ candidate: Option<(std::time::SystemTime, Option<snapshot::Candidate>)>,
+ published: Option<Option<Offer>>,
+}
+
+impl OfferPublisher {
+ pub fn tick(&mut self) {
+ let path = snapshot::state_dir().join("restore.json");
+ let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
+ let stale = match (&self.candidate, modified) {
+ (Some((at, _)), Some(modified)) => *at != modified,
+ (None, _) => true,
+ (Some(_), None) => true,
+ };
+ if stale {
+ self.candidate = Some((modified.unwrap_or(std::time::UNIX_EPOCH), snapshot::candidate()));
+ }
+ let offered = self
+ .candidate
+ .as_ref()
+ .and_then(|(_, candidate)| candidate.as_ref())
+ .filter(|candidate| candidate.status == "offered");
+ let offer = offered.and_then(|candidate| offer(&candidate.snapshot, &existing_sessions()));
+ if self.published.as_ref() == Some(&offer) {
+ return;
+ }
+ let file = snapshot::state_dir().join("offer.json");
+ match &offer {
+ Some(offer) => {
+ if let Ok(json) = serde_json::to_string_pretty(offer) {
+ let tmp = file.with_extension("tmp");
+ if std::fs::write(&tmp, json).is_ok() {
+ let _ = std::fs::rename(&tmp, &file);
+ }
+ }
+ let set = tmux::run(&["set-option", "-g", "@dingger_restore", &offer.sessions.len().to_string()]);
+ tracing::info!(sessions = offer.sessions.len(), agents = offer.agents, ok = set.is_ok(), "restore offered");
+ }
+ None => {
+ let _ = std::fs::remove_file(&file);
+ let unset = tmux::run(&["set-option", "-g", "-u", "@dingger_restore"]);
+ tracing::info!(?unset, "restore offer withdrawn");
+ }
+ }
+ self.published = Some(offer);
+ }
}
#[cfg(test)]
@@ -494,6 +670,33 @@ mod tests {
}
#[test]
+ fn offers_only_missing_sessions_with_their_agents() {
+ let mut saved = snapshot(&["0", "work"]);
+ saved.sessions[1].windows[0].panes[0].agent = Some(AgentSnap {
+ agent: "pi".into(),
+ name: "x".into(),
+ state: "working".into(),
+ session: None,
+ });
+ let offer = offer(&saved, &[Existing { name: "0".into(), bare: false }]).expect("work is missing");
+ assert_eq!(offer.sessions.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["work"]);
+ assert_eq!((offer.agents, offer.interrupted, offer.sessions[0].panes), (1, 1, 2));
+ assert_eq!(super::offer(&saved, &[
+ Existing { name: "0".into(), bare: false },
+ Existing { name: "work".into(), bare: false },
+ ]), None, "nothing missing, no offer");
+ }
+
+ #[test]
+ fn summarizes_a_restore() {
+ let summary = Summary { sessions: 9, agents: 22, resumed: 21, interrupted: 1, typed: 3, failed: 0 };
+ assert_eq!(
+ summary_message(&summary),
+ ("Restored 9 tmux sessions".into(), "resumed 21 of 22 agents · 1 was mid-turn · 3 commands typed, not run".into())
+ );
+ }
+
+ #[test]
fn shells_are_recognized() {
assert!(is_shell("zsh") && is_shell("-zsh") && is_shell("bash") && is_shell("fish"));
assert!(!is_shell("nvim") && !is_shell("node") && !is_shell("tmux"));
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index 24a2ecd..a2d6dae 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -182,6 +182,7 @@ pub fn advance(
pub struct Watcher {
tracks: HashMap<String, PaneTrack>,
snapshots: crate::snapshot::Recorder,
+ offer: crate::restore::OfferPublisher,
/// Last published (queue, blocked, done, working); avoids rewriting the
/// global options every tick.
last_published: Option<(String, usize, usize, usize)>,
@@ -195,6 +196,7 @@ impl Watcher {
Self {
tracks: HashMap::new(),
snapshots: Default::default(),
+ offer: Default::default(),
last_published: None,
interval_secs: interval_secs.max(1),
sounds,
@@ -226,6 +228,7 @@ impl Watcher {
}
// After the tick, so snapshots carry the states it just wrote.
self.snapshots.tick(unix_now());
+ self.offer.tick();
debug!(ms = started.elapsed().as_millis() as u64, "tick");
hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs));
}