use std::cell::RefCell;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::error::{AppError, Result};
pub trait AppControl {
fn quit(&self) -> Result<()>;
fn relaunch(&self) -> Result<()>;
fn archive(&self, archive: &Path, root: &Path, members: &[&str]) -> Result<()>;
fn restore(&self, archive: &Path, root: &Path, cleanup_members: &[&str]) -> Result<()>;
}
pub struct DesktopApp;
const QUIT_GRACE: Duration = Duration::from_secs(2);
const QUIT_POLL: Duration = Duration::from_millis(100);
const QUIT_POLLS: usize = 20;
#[cfg(unix)]
fn set_private_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.map_err(|error| AppError::io_at(path, error))
}
#[cfg(not(unix))]
fn set_private_mode(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
fn is_running() -> bool {
Command::new("/usr/bin/pgrep")
.args(["-x", "Claude"])
.output()
.is_ok_and(|output| output.status.success())
}
fn wait_until_stopped() -> bool {
for _ in 0..QUIT_POLLS {
if !is_running() {
return true;
}
std::thread::sleep(QUIT_POLL);
}
!is_running()
}
impl AppControl for DesktopApp {
fn quit(&self) -> Result<()> {
let _ = Command::new("/usr/bin/osascript")
.args(["-e", "tell application \"Claude\" to quit"])
.output();
std::thread::sleep(QUIT_GRACE);
if wait_until_stopped() {
return Ok(());
}
let _ = Command::new("/usr/bin/pkill")
.args(["-x", "Claude"])
.output();
if wait_until_stopped() {
return Ok(());
}
let _ = Command::new("/usr/bin/pkill")
.args(["-KILL", "-x", "Claude"])
.output();
if wait_until_stopped() {
Ok(())
} else {
Err(AppError::Other(
"Claude Desktop did not stop; no account data was changed".into(),
))
}
}
fn relaunch(&self) -> Result<()> {
let output = Command::new("/usr/bin/open")
.args(["-a", "Claude"])
.output()
.map_err(|e| AppError::Other(format!("could not relaunch Claude Desktop: {e}")))?;
if output.status.success() {
Ok(())
} else {
Err(AppError::Other(format!(
"could not relaunch Claude Desktop (open exited {})",
output.status.code().unwrap_or(-1)
)))
}
}
fn archive(&self, archive: &Path, root: &Path, members: &[&str]) -> Result<()> {
if let Some(parent) = archive.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
set_private_mode(parent, 0o700)?;
}
let output = Command::new("/usr/bin/tar")
.arg("-czf")
.arg(archive)
.arg("-C")
.arg(root)
.arg("--")
.args(members)
.output()
.map_err(|e| AppError::Other(format!("could not run `tar`: {e}")))?;
if output.status.success() {
set_private_mode(archive, 0o600)?;
return Ok(());
}
let detail = String::from_utf8_lossy(&output.stderr);
Err(AppError::Other(format!(
"could not write the rollback archive {} (tar exited {}): {}",
archive.display(),
output.status.code().unwrap_or(-1),
detail.trim()
)))
}
fn restore(&self, archive: &Path, root: &Path, cleanup_members: &[&str]) -> Result<()> {
for member in cleanup_members {
let path = root.join(member);
match std::fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_dir() => {
std::fs::remove_dir_all(&path).map_err(|e| AppError::io_at(&path, e))?;
}
Ok(_) => {
std::fs::remove_file(&path).map_err(|e| AppError::io_at(&path, e))?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(AppError::io_at(&path, error)),
}
}
let output = Command::new("/usr/bin/tar")
.arg("-xzf")
.arg(archive)
.arg("-C")
.arg(root)
.output()
.map_err(|e| AppError::Other(format!("could not run `tar` for rollback: {e}")))?;
if output.status.success() {
Ok(())
} else {
Err(AppError::Other(format!(
"could not restore {} (tar exited {})",
archive.display(),
output.status.code().unwrap_or(-1)
)))
}
}
}
#[derive(Debug, Default)]
pub struct Recorder {
steps: RefCell<Vec<String>>,
}
impl Recorder {
pub fn steps(&self) -> Vec<String> {
self.steps.borrow().clone()
}
pub fn record(&self, step: impl Into<String>) {
self.steps.borrow_mut().push(step.into());
}
}
impl AppControl for Recorder {
fn quit(&self) -> Result<()> {
self.record("quit");
Ok(())
}
fn relaunch(&self) -> Result<()> {
self.record("relaunch");
Ok(())
}
fn archive(&self, archive: &Path, _root: &Path, members: &[&str]) -> Result<()> {
self.record(format!(
"archive {} [{}]",
archive.display(),
members.join(", ")
));
Ok(())
}
fn restore(&self, archive: &Path, _root: &Path, members: &[&str]) -> Result<()> {
self.record(format!(
"restore {} [{}]",
archive.display(),
members.join(", ")
));
Ok(())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn rollback_archives_and_their_directory_are_private() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("Claude");
let backup_dir = temp.path().join("backups");
let archive = backup_dir.join("rollback.tar.gz");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("config.json"), b"secret state").unwrap();
DesktopApp
.archive(&archive, &root, &["config.json"])
.unwrap();
let dir_mode = std::fs::metadata(&backup_dir).unwrap().permissions().mode() & 0o777;
let archive_mode = std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(archive_mode, 0o600);
}
}