AutoRecover: unsaved presentations saved to a recovery folder and reopened after a crash; save-changes prompt when quitting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 01:26:54 -04:00
1 parent ad469f9b13
commit b899290f4b
9 files changed
+323 -5

No files matched your search

+9
View File
@@ -162,6 +162,15 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
let mut app = SlideApp::new(Session::new(), services());
load_prefs(&mut app);
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
if std::env::var_os("SLIDECRAFT_NO_RECOVERY").is_none() {
app.session.recovery_dir = slidecraft_engine::recovery::default_dir();
if let Ok(v) = app.session.execute("file.recovery.open", &serde_json::json!({}))
&& let Some(n) = v.get("opened").and_then(|o| o.as_array()).map(Vec::len).filter(|n| *n > 0)
{
app.set_status(format!("Recovered {n} unsaved presentation{} from the last session", if n == 1 { "" } else { "s" }));
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+50
View File
@@ -27,6 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
properties
),
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
]
@@ -156,6 +160,10 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
st.path = path;
}
st.revision += 1;
let uid = st.uid;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
Ok(())
}
@@ -313,3 +321,45 @@ fn next_window(s: &mut Session, p: &Value) -> Result<Value> {
s.set_active(i.min(n - 1));
ok()
}
fn recovery_dir(s: &Session) -> Result<std::path::PathBuf> {
s.recovery_dir.clone().ok_or_else(|| EngineError::Other("no recovery folder is set".into()))
}
fn recovery_save(s: &mut Session, _p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
Ok(json!({"written": crate::recovery::save(s, &dir)?}))
}
fn recovery_list(s: &mut Session, _p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
Ok(Value::Array(
crate::recovery::list(&dir)
.into_iter()
.map(|(uid, mut m)| {
if let Some(o) = m.as_object_mut() {
o.insert("uid".into(), json!(uid));
}
m
})
.collect(),
))
}
fn recovery_open(s: &mut Session, _p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
Ok(json!({"opened": crate::recovery::open(s, &dir)?}))
}
fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
match p.get("uid").and_then(Value::as_u64) {
Some(uid) => crate::recovery::discard(&dir, uid),
None => {
for (uid, _) in crate::recovery::list(&dir) {
crate::recovery::discard(&dir, uid);
}
}
}
ok()
}
+5
View File
@@ -52,6 +52,11 @@ impl Session {
st.revision = revision.max(st.revision).saturating_add(1);
}
log::error!("command `{id}` panicked: {msg}");
if let Some(dir) = self.recovery_dir.clone()
&& let Err(e) = crate::recovery::save(self, &dir)
{
log::error!("recovery save after `{id}` failed: {e}");
}
Err(EngineError::Internal(id.to_string(), msg))
}
}
+11 -1
View File
@@ -12,6 +12,7 @@ pub mod connect;
pub mod guard;
pub mod keys;
pub mod links;
pub mod recovery;
pub mod sample;
pub mod tools;
@@ -213,6 +214,8 @@ pub struct Prefs {
pub smart_quotes: bool,
pub author: String,
pub undo_limit: usize,
/// Minutes between AutoRecover saves of unsaved presentations.
pub recovery_minutes: f64,
}
impl Default for Prefs {
@@ -226,6 +229,7 @@ impl Default for Prefs {
smart_quotes: true,
author: "Presenter".into(),
undo_limit: 500,
recovery_minutes: 1.0,
}
}
}
@@ -270,6 +274,8 @@ pub struct Session {
/// Nesting of commands run by commands: only the outermost one is an undo step and a journal
/// entry.
depth: u32,
/// Crash-recovery folder (the app sets it; saving or closing a presentation removes its entry).
pub recovery_dir: Option<std::path::PathBuf>,
}
impl Default for Session {
@@ -293,6 +299,7 @@ impl Session {
default_look: None,
painter: None,
depth: 0,
recovery_dir: None,
}
}
/// A session with one new presentation open.
@@ -331,7 +338,10 @@ impl Session {
}
pub fn close_document(&mut self, i: usize) {
if i < self.docs.len() {
self.docs.remove(i);
let d = self.docs.remove(i);
if let Some(dir) = &self.recovery_dir {
recovery::discard(dir, d.uid);
}
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
}
}
+165
View File
@@ -0,0 +1,165 @@
//! Crash recovery (PowerPoint's AutoRecover): unsaved changes of every open presentation are
//! written to a recovery folder (`file.recovery.save`, called on a timer by the app and after a
//! command panics); saving or closing a presentation removes its entry; after a crash
//! `file.recovery.open` reopens what was left there as unsaved presentations that remember where
//! they were saved.
//!
//! An entry is `<uid>.slidecraft` (the presentation) plus `<uid>.json` (`{"path", "title",
//! "saved"}`: the original file, the title and when it was written, Unix seconds).
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::{DocState, EngineError, Result, Session};
/// The platform's per-user recovery folder.
pub fn default_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home.map(|h| h.join("Library/Application Support/SlideCraft/Recovery"));
}
if cfg!(target_os = "windows") {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("SlideCraft").join("Recovery"));
}
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("slidecraft/recovery"))
}
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
(dir.join(format!("{uid}.slidecraft")), dir.join(format!("{uid}.json")))
}
fn now_secs() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
/// Remove a presentation's recovery entry (it was saved or closed).
pub fn discard(dir: &Path, uid: u64) {
let (d, m) = files(dir, uid);
let _ = std::fs::remove_file(d);
let _ = std::fs::remove_file(m);
}
/// Write entries for the unsaved presentations (and drop those of saved ones). Returns how many
/// were written.
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let mut n = 0;
for d in s.documents() {
if !d.is_dirty() {
discard(dir, d.uid);
continue;
}
let (doc, meta) = files(dir, d.uid);
let bytes = slidecraft_format::save(&d.doc).map_err(|e| EngineError::Other(e.to_string()))?;
// Write then rename, so a crash mid-write never leaves a torn file.
let tmp = doc.with_extension("tmp");
std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?;
let m = json!({"path": d.path, "title": d.title(), "saved": now_secs()});
std::fs::write(&meta, m.to_string()).map_err(|e| EngineError::Other(format!("{}: {e}", meta.display())))?;
n += 1;
}
Ok(n)
}
/// Entries in `dir`: (uid, metadata).
pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("slidecraft") {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = std::fs::read_to_string(p.with_extension("json"))
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.filter(Value::is_object)
.unwrap_or(json!({}));
out.push((uid, meta));
}
out.sort_by_key(|e| e.0);
out
}
/// Reopen every entry as an unsaved presentation and remove the entries (this session writes its
/// own). Unreadable entries are left in place. Returns the opened documents' indices.
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
let mut opened = Vec::new();
for (uid, meta) in list(dir) {
let (doc, _) = files(dir, uid);
let Ok(bytes) = std::fs::read(&doc) else { continue };
let Ok(mut d) = slidecraft_format::load(&bytes) else { continue };
slidecraft_format::repair(&mut d);
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
let title = meta.get("title").and_then(Value::as_str).filter(|t| !t.is_empty()).unwrap_or("Presentation");
let mut st = DocState::new(d, path, format!("{title} (Recovered)"));
// Unsaved: the copy on disk (if any) is older than what was recovered.
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
st.revision += 1;
opened.push(s.add_document(st));
discard(dir, uid);
}
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("slidecraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn recovers_unsaved_presentations() {
let dir = temp("recovery");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
// Only the edited presentation is written.
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
assert_eq!(save(&s, &dir).unwrap(), 1);
assert_eq!(list(&dir).len(), 1);
// "Crash": a new session reopens it, unsaved, with its slides.
let mut s2 = Session::new();
assert_eq!(open(&mut s2, &dir).unwrap().len(), 1);
let st = s2.doc().unwrap();
assert!(st.is_dirty());
assert_eq!(st.doc.slides.len(), 2);
assert!(st.title().ends_with("(Recovered)"));
assert!(list(&dir).is_empty(), "entries are consumed");
// Saving drops the entry.
s.recovery_dir = Some(dir.clone());
assert_eq!(save(&s, &dir).unwrap(), 1);
let path = dir.join("saved.slidecraft");
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(list(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_entries_are_skipped() {
let dir = temp("recovery-corrupt");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("7.slidecraft"), b"not a presentation").unwrap();
std::fs::write(dir.join("7.json"), b"[1, 2]").unwrap();
let mut s = Session::new();
assert!(open(&mut s, &dir).unwrap().is_empty());
s.recovery_dir = Some(dir.clone());
let v = s.execute("file.recovery.list", &json!({})).unwrap();
assert_eq!(v.as_array().map(Vec::len), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
}
+5
View File
@@ -290,6 +290,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
"app.save" => wrap(app.run("file.save", p.clone())),
"app.export" => wrap(app.run("file.export", p.clone())),
"app.quit" => {
// Automation quits without asking (`{"save": true}` saves first).
if p.get("save").and_then(Value::as_bool) == Some(true) && !app.save_all() {
return err("not every presentation could be saved");
}
app.quit_confirmed = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
+37
View File
@@ -55,6 +55,7 @@ fn title(id: &str) -> &'static str {
"chartData" => "Chart Data",
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
"quit" => "SlideCraft",
_ => "SlideCraft",
}
}
@@ -253,6 +254,42 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
"quit" => {
let names: Vec<String> = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect();
if names.is_empty() {
app.quit_confirmed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
return true;
}
let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) };
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong());
ui.label("Your changes will be lost if you don't save them.");
ui.add_space(8.0);
let mut done = false;
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() {
let _ = app.session.execute("file.recovery.discard", &json!({}));
app.quit_confirmed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
done = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
if app.save_all() {
app.quit_confirmed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
done = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
done = true;
}
});
});
done
}
"zoom" => {
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
if ui.radio(false, format!("{pct}%")).clicked() {
+37
View File
@@ -191,6 +191,10 @@ pub struct SlideApp {
restyle: bool,
fonts_ready: bool,
last_time: f64,
/// When AutoRecover data was last written (seconds, egui time).
pub last_recovery: f64,
/// Quit was confirmed (changes saved or discarded): let the window close.
pub quit_confirmed: bool,
pub(crate) notes_buf: (u64, usize, String),
/// Thumbnail pane drag (slide index, current drop index).
pub(crate) thumb_drag: Option<(usize, usize)>,
@@ -222,6 +226,8 @@ impl SlideApp {
restyle: true,
fonts_ready: false,
last_time: 0.0,
last_recovery: 0.0,
quit_confirmed: false,
notes_buf: (0, usize::MAX, String::new()),
thumb_drag: None,
}
@@ -398,6 +404,20 @@ impl SlideApp {
}
}
/// Save every presentation with unsaved changes (asking for a location where needed).
/// Returns false if one wasn't saved (cancelled or failed).
pub fn save_all(&mut self) -> bool {
let dirty: Vec<usize> = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect();
for i in dirty {
self.session.set_active(i);
self.save();
if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) {
return false;
}
}
true
}
pub fn save(&mut self) {
if self.session.active().and_then(|d| d.path.clone()).is_some() {
match self.session.execute("file.save", &json!({})) {
@@ -498,6 +518,23 @@ impl SlideApp {
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
// Closing the window with unsaved changes asks first.
if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && self.session.documents().iter().any(|d| d.is_dirty()) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.show = None;
if self.dialog.as_ref().is_none_or(|d| d.id != "quit") {
self.dialog = Some(dialogs::Dialog::new("quit"));
}
}
// AutoRecover: unsaved presentations are written to the recovery folder periodically.
if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(10.0) {
self.last_recovery = now;
if self.session.documents().iter().any(|d| d.is_dirty())
&& let Err(e) = self.session.execute("file.recovery.save", &json!({}))
{
self.set_status(format!("Couldn't save AutoRecover information: {e}"));
}
}
// On the web, dropped files can only be read asynchronously: the host reads them and
// delivers them through `Services::inbox`.
#[cfg(not(target_arch = "wasm32"))]
+4 -4
View File
@@ -15,14 +15,14 @@ Live Presentations), add-ins and VBA macros.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 72 | 58 | 13 | 1 | 90% |
| P0 | 72 | 59 | 12 | 1 | 90% |
| P1 | 65 | 35 | 23 | 7 | 72% |
| P2 | 50 | 11 | 11 | 28 | 33% |
| Area | Parity |
|---|---|
| Application shell | 71% |
| Files | 67% |
| Files | 71% |
| Slides | 95% |
| Views | 90% |
| Masters & themes | 80% |
@@ -44,7 +44,7 @@ Live Presentations), add-ins and VBA macros.
| Automation (✱) | 100% |
| Platforms (✱) | 25% |
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Autosave and crash recovery, Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format background (solid, gradient, picture, pattern), Gradient fill (linear, radial, rectangular, path), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format background (solid, gradient, picture, pattern), Gradient fill (linear, radial, rectangular, path), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
## Rows
@@ -68,7 +68,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
| Files | Open/save .potx/.ppsx/.pptm variants | P2 | M |
| Files | Legacy .ppt (binary) import | P2 | M |
| Files | Save as template | P1 | D |
| Files | Autosave and crash recovery | P0 | P |
| Files | Autosave and crash recovery | P0 | D |
| Files | Document properties | P2 | D |
| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P |
| Files | Export slides as PNG/JPEG | P0 | D |