Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ base64 = "0.22.1"
cairo-rs = { version = "0.21.1", features = ["v1_18", "png", "svg", "pdf"] }
chrono = "0.4.41"
clap = { version = "4.5", features = ["derive"] }
crc32fast = "1.5"
dialoguer = "0.12.0"
flate2 = "1.1"
fs_extra = "1.3"
Expand Down Expand Up @@ -87,6 +88,7 @@ serde_json = "1.0"
slotmap = { version = "1.0", features = ["serde"] }
smol = "2.0"
svg = "0.18.0"
tempfile = "3.26"
thiserror = "2.0.12"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/rnote-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ base64 = { workspace = true }
cairo-rs = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, optional = true }
crc32fast = { workspace = true }
flate2 = { workspace = true }
futures = { workspace = true }
geo = { workspace = true }
Expand Down Expand Up @@ -51,6 +52,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
slotmap = { workspace = true }
svg = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
unicode-segmentation = { workspace = true }
Expand Down
61 changes: 60 additions & 1 deletion crates/rnote-engine/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// Imports
use crate::fileformats::xoppformat;
use anyhow::Context;
use geo::line_string;
use hayro::hayro_syntax;
use p2d::bounding_volume::Aabb;
use rnote_compose::Color;
use std::ops::Range;
use std::{
io::{Read, Seek, Write},
ops::Range,
};

pub const fn crate_version() -> &'static str {
env!("CARGO_PKG_VERSION")
Expand Down Expand Up @@ -118,3 +122,58 @@ pub mod glib_bytes_base64 {
rnote_compose::serialize::sliceu8_base64::deserialize(d).map(glib::Bytes::from_owned)
}
}

/// Attempts to atomically save data to a file.
/// This function is not asynchronous, don't forget to wrap
/// it inside a `gio::spawn_blocking` or equivalent if need be.
pub fn atomic_save_to_file<Q>(filepath: Q, bytes: &[u8]) -> anyhow::Result<()>
Comment thread
flxzt marked this conversation as resolved.
Outdated
where
Q: AsRef<std::path::Path>,
{
// We first create the named temporary file, specifically in the parent
// directory of the target filepath, as `.persist()` will not work
// if the temporary file is in a different filesystem than the target.
let mut temp_file = tempfile::NamedTempFile::new_in(
filepath
.as_ref()
.parent()
.ok_or_else(|| anyhow::anyhow!("The filepath does not have a parent directory"))?,
)
.with_context(|| "Failed to create a temporary file")?;

// We then write all of the bytes to the temporary file before syncing
// the contents and metadata of the temporary file.
temp_file
.write_all(bytes)
.with_context(|| "Failed to write to the temporary file")?;
temp_file
.as_file()
.sync_all()
.with_context(|| "Failed to sync the contents and metadata of the temporary file")?;

// We then rewind the file cursor to the start, as we are going to read
// all of its contents to verify the integrity of the data (using a checksum)
temp_file.rewind()?; // resets the file cursor to the beginning

let mut data: Vec<u8> = Vec::with_capacity(bytes.len());
temp_file
.read_to_end(&mut data)
.with_context(|| "Failed to read from the temporary file")?;

let external_checksum = crc32fast::hash(&data);
let internal_checksum = crc32fast::hash(bytes);

if internal_checksum != external_checksum {
anyhow::bail!(
"The checksum of the temporary file does not match the expected one. No file will be created or modified."
);
}

// Finally, we persist the temporary file to the target filepath, if a file
// pre-exists at this location, it will be atomically replaced by our new file.
let _ = temp_file
.persist(filepath)
.with_context(|| "Failed to persist the temporary file to the target filepath")?;

Ok(())
}
46 changes: 14 additions & 32 deletions crates/rnote-ui/src/canvas/imexport.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Imports
use super::RnCanvas;
use crate::RnAppWindow;
use anyhow::Context;
use futures::AsyncWriteExt;
use futures::channel::oneshot;
use gtk4::{gio, prelude::*};
use rnote_compose::ext::Vector2Ext;
Expand Down Expand Up @@ -223,7 +221,7 @@ impl RnCanvas {
self.set_save_in_progress(true);
debug!("Saving file is now in progress");

let file_path = file.path().ok_or_else(|| {
let filepath = file.path().ok_or_else(|| {
self.set_save_in_progress(false);
anyhow::anyhow!("Could not get a path for file: `{file:?}`.")
})?;
Expand All @@ -234,40 +232,22 @@ impl RnCanvas {
let rnote_bytes_receiver = self
.engine_ref()
.save_as_rnote_bytes(basename.to_string_lossy().to_string());
let mut skip_set_output_file = false;
if let Some(output_file_path) = self.output_file().and_then(|f| f.path())
&& crate::utils::paths_abs_eq(output_file_path, &file_path).unwrap_or(false)
{
skip_set_output_file = true;
}

self.dismiss_output_file_modified_toast();

let file_write_operation = async move {
let file_write_operation = async {
let bytes = rnote_bytes_receiver.await??;
self.set_output_file_expect_write(true);
let mut write_file = async_fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&file_path)
.await
.context(format!(
"Failed to create/open/truncate file for path '{}'",
file_path.display()
))?;
if !skip_set_output_file {
// this installs the file watcher.
self.set_output_file(Some(file.to_owned()));
let filepath_clone = filepath.clone();
let thread_result = gio::spawn_blocking(move || {
rnote_engine::utils::atomic_save_to_file(filepath_clone, &bytes)
})
.await;

match thread_result {
Ok(atomic_result) => atomic_result,
Err(_panic) => Err(anyhow::anyhow!("Atomic saving thread panicked")),
}
write_file.write_all(&bytes).await.context(format!(
"Failed to write bytes to file with path '{}'",
file_path.display()
))?;
write_file.sync_all().await.context(format!(
"Failed to sync file after writing with path '{}'",
file_path.display()
))?;
Ok(())
};

if let Err(e) = file_write_operation.await {
Expand All @@ -278,6 +258,8 @@ impl RnCanvas {
return Err(e);
}

// Required, as atomic file saving moves a new file into the old one
self.set_output_file(Some(gio::File::for_path(&filepath)));
debug!("Saving file has finished successfully");
self.set_unsaved_changes(false);
self.set_save_in_progress(false);
Expand Down
Loading