Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ lzma-rust2 = { version = "0.16", default-features = false, features = ["std", "e
scraper = "0.25"
linesweeper = "0.3"
smallvec = "1.13.2"
zip = { version = "8", default-features = false }

[workspace.lints.rust]
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] }
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ impl App {
});
}
DesktopFrontendMessage::WriteFile { path, content } => {
// Create the parent directory on demand so flows that drop multiple files into a fresh folder
// (e.g. recovered-documents bundle) don't have to materialize it ahead of time
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = fs::create_dir_all(parent)
{
tracing::error!("Failed to create parent directory {}: {}", parent.display(), e);
}
if let Err(e) = fs::write(&path, content) {
tracing::error!("Failed to write file {}: {}", path.display(), e);
}
Expand Down
9 changes: 9 additions & 0 deletions desktop/wrapper/src/handle_desktop_wrapper_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
SaveFileDialogContext::File { content } => {
dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content });
}
SaveFileDialogContext::RecoveredDocuments { files } => {
// Strip any extension the user might have typed (e.g. picking `MyRecovery.zip` yields `MyRecovery/`).
// `WriteFile`'s handler creates parent directories on demand, so the folder is materialized lazily.
let folder = path.with_extension("");
for (filename, content) in files {
let file_path = folder.join(&filename);
dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content });
}
}
},
DesktopWrapperMessage::OpenFile { path, content } => {
let message = PortfolioMessage::OpenFile { path, content };
Expand Down
12 changes: 12 additions & 0 deletions desktop/wrapper/src/intercept_frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
context: SaveFileDialogContext::File { content },
});
}
FrontendMessage::TriggerSaveRecoveredDocumentsFolder { folder_name, files } => {
// Treat the save dialog's chosen path as a folder name. The dialog result handler strips any
// extension the user typed and writes each entry inside that folder
let files: Vec<(String, Vec<u8>)> = files.into_iter().map(|(name, bytes)| (name, bytes.into_vec())).collect();
dispatcher.respond(DesktopFrontendMessage::SaveFileDialog {
title: "Save Recovered Documents Folder As".to_string(),
default_filename: folder_name,
default_folder: None,
filters: Vec::new(),
context: SaveFileDialogContext::RecoveredDocuments { files },
});
}
FrontendMessage::TriggerVisitLink { url } => {
dispatcher.respond(DesktopFrontendMessage::OpenUrl(url));
}
Expand Down
15 changes: 13 additions & 2 deletions desktop/wrapper/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,19 @@ pub enum OpenFileDialogContext {
}

pub enum SaveFileDialogContext {
Document { document_id: DocumentId, content: Vec<u8> },
File { content: Vec<u8> },
Document {
document_id: DocumentId,
content: Vec<u8>,
},
File {
content: Vec<u8>,
},
/// Bundle of recovered (failed-to-deserialize) autosaved documents to be written into a single folder.
/// The chosen `path` returned by the save dialog has its extension stripped to become the folder name.
/// The parent directories are created on first write.
RecoveredDocuments {
files: Vec<(String, Vec<u8>)>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Validate or sanitize recovered document filenames before transporting them as raw Strings. As written, a crafted document name can escape the selected recovery folder when the desktop wrapper later does folder.join(filename).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/wrapper/src/messages.rs, line 125:

<comment>Validate or sanitize recovered document filenames before transporting them as raw `String`s. As written, a crafted document name can escape the selected recovery folder when the desktop wrapper later does `folder.join(filename)`.</comment>

<file context>
@@ -111,8 +111,19 @@ pub enum OpenFileDialogContext {
+	/// The chosen `path` returned by the save dialog has its extension stripped to become the folder name.
+	/// The parent directories are created on first write.
+	RecoveredDocuments {
+		files: Vec<(String, Vec<u8>)>,
+	},
 }
</file context>

},
}

pub enum MenuItem {
Expand Down
1 change: 1 addition & 0 deletions editor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ base64 = { workspace = true }
spin = { workspace = true }
image = { workspace = true }
color = { workspace = true }
zip = { workspace = true }

# Optional local dependencies
wgpu-executor = { workspace = true, optional = true }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;

/// A dialog shown after startup when one or more autosaved documents fail to deserialize.
/// Offers the user a chance to download the raw content (so the data isn't lost), discard the failed
/// documents, or dismiss the dialog (keeping the autosave for a later session, when the deserialization
/// bug may be fixed in a future release).
pub struct FailedToLoadDocumentsDialog {
pub failed_document_names: Vec<String>,
}

impl DialogLayoutHolder for FailedToLoadDocumentsDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Failed to Open Documents";

fn layout_buttons(&self) -> Layout {
let widgets = vec![
TextButton::new("Download")
.emphasized(true)
.tooltip_description("Save the raw document data to disk so it can be recovered later.")
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![PortfolioMessage::DownloadFailedToLoadDocuments.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Discard")
.tooltip_description("Permanently delete the autosaved data for these documents.")
.on_update(|_| {
DialogMessage::CloseAndThen {
followups: vec![PortfolioMessage::DiscardFailedToLoadDocuments.into()],
}
.into()
})
.widget_instance(),
TextButton::new("Dismiss")
.tooltip_description("Close this dialog. The autosaved data is kept and this dialog will reappear on next launch.")
.on_update(|_| FrontendMessage::DialogClose.into())
.widget_instance(),
];

Layout(vec![LayoutGroup::row(widgets)])
}
}

impl LayoutHolder for FailedToLoadDocumentsDialog {
fn layout(&self) -> Layout {
let count = self.failed_document_names.len();
let header = format!("{count} document{} couldn't be reopened.", if count == 1 { "" } else { "s" });
let list = "• ".to_string() + &self.failed_document_names.join("\n• ");
let plural_s = if count == 1 { "" } else { "s" };
let plural_it_them = if count == 1 { "it" } else { "them" };

Layout(vec![
LayoutGroup::row(vec![TextLabel::new(header).bold(true).multiline(true).widget_instance()]),
LayoutGroup::row(vec![
TextLabel::new(format!(
"Sorry about that!\n\
This shouldn't happen, and we'd like to help.\n\
\n\
Click \"Download\" to save a copy of the affected file{plural_s},\n\
then please share {plural_it_them} with us so we can investigate:"
))
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Ask on Discord")
.icon("Volunteer")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://discord.graphite.art".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Report on GitHub")
.icon("Bug")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextLabel::new(
"In the meantime, you can keep working in the\n\
previous version of Graphite:",
)
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Sept. 2025 Release")
.icon("GraphiteLogo")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://57130155.graphite.pages.dev/".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![TextLabel::new(format!("Affected document{plural_s}:\n{list}")).multiline(true).widget_instance()]),
])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;

/// A dialog shown when a manually opened document fails to deserialize. Mirrors the recovery affordances
/// of [`super::FailedToLoadDocumentsDialog`] (offering Discord/GitHub/previous-version links) but for
/// the single-file, user-initiated open case, where the file is already on the user's disk and there's
/// nothing to download or discard.
pub struct FailedToOpenDocumentDialog {
/// Display name of the file the user tried to open; used in the dialog header. Falls back to a
/// generic phrase when empty.
pub document_name: String,
}

impl DialogLayoutHolder for FailedToOpenDocumentDialog {
const ICON: &'static str = "Warning";
const TITLE: &'static str = "Failed to Open Document";

fn layout_buttons(&self) -> Layout {
let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()];
Layout(vec![LayoutGroup::row(widgets)])
}
}

impl LayoutHolder for FailedToOpenDocumentDialog {
fn layout(&self) -> Layout {
let header = if self.document_name.trim().is_empty() {
"The document couldn't be opened.".to_string()
} else {
format!("\"{}\" couldn't be opened.", self.document_name)
};

Layout(vec![
LayoutGroup::row(vec![TextLabel::new(header).bold(true).multiline(true).widget_instance()]),
LayoutGroup::row(vec![
TextLabel::new(
"Sorry about that!\n\
This shouldn't happen, and we'd like to help.\n\
\n\
Please share the file with us so we can investigate:",
)
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Ask on Discord")
.icon("Volunteer")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://discord.graphite.art".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Report on GitHub")
.icon("Bug")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(),
}
.into()
})
.widget_instance(),
]),
LayoutGroup::row(vec![
TextLabel::new(
"In the meantime, you can keep working in the\n\
previous version of Graphite:",
)
.multiline(true)
.widget_instance(),
]),
LayoutGroup::row(vec![
TextButton::new("Sept. 2025 Release")
.icon("GraphiteLogo")
.flush(true)
.on_update(|_| {
FrontendMessage::TriggerVisitLink {
url: "https://57130155.graphite.pages.dev/".into(),
}
.into()
})
.widget_instance(),
]),
])
}
}
4 changes: 4 additions & 0 deletions editor/src/messages/dialog/simple_dialogs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ mod close_document_dialog;
mod confirm_restart_dialog;
mod demo_artwork_dialog;
mod error_dialog;
mod failed_to_load_documents_dialog;
mod failed_to_open_document_dialog;
mod licenses_dialog;
mod licenses_third_party_dialog;

Expand All @@ -14,5 +16,7 @@ pub use confirm_restart_dialog::ConfirmRestartDialog;
pub use demo_artwork_dialog::ARTWORK;
pub use demo_artwork_dialog::DemoArtworkDialog;
pub use error_dialog::ErrorDialog;
pub use failed_to_load_documents_dialog::FailedToLoadDocumentsDialog;
pub use failed_to_open_document_dialog::FailedToOpenDocumentDialog;
pub use licenses_dialog::LicensesDialog;
pub use licenses_third_party_dialog::LicensesThirdPartyDialog;
10 changes: 10 additions & 0 deletions editor/src/messages/frontend/frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ pub enum FrontendMessage {
folder: Option<PathBuf>,
content: serde_bytes::ByteBuf,
},
/// Save a bundle of recovered (failed-to-deserialize) autosaved documents to disk on desktop.
/// The desktop wrapper intercepts this, prompts the user for a location via the native save
/// dialog, and writes each entry as a separate file into a folder named after `folderName`.
/// The web build bundles them as a single `.zip` and delivers it through `TriggerSaveFile`,
/// so this message is only ever dispatched on desktop and never reaches the web frontend.
TriggerSaveRecoveredDocumentsFolder {
#[serde(rename = "folderName")]
folder_name: String,
files: Vec<(String, serde_bytes::ByteBuf)>,
},
TriggerExportImage {
svg: String,
name: String,
Expand Down
10 changes: 3 additions & 7 deletions editor/src/messages/portfolio/document/utility_types/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ pub enum EditorError {
#[error("The operation caused a document error:\n{0:?}")]
Document(String),

#[error(
"This document was created in an older version of the editor.\n\
\n\
Full backwards compatibility is not guaranteed in the current alpha release.\n\
\n\
If this document is critical, ask for support in Graphite's Discord community."
)]
// User-facing presentation now lives in `FailedToOpenDocumentDialog` /
// `FailedToLoadDocumentsDialog`; this Display string is for logs only.
#[error("Failed to deserialize document: {0}")]
DocumentDeserialization(String),

#[error("{0}")]
Expand Down
3 changes: 3 additions & 0 deletions editor/src/messages/portfolio/portfolio_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ pub enum PortfolioMessage {
document_id: DocumentId,
document_serialized_content: String,
},
ShowFailedToLoadDocumentsDialog,
DiscardFailedToLoadDocuments,
DownloadFailedToLoadDocuments,
MoveAllPanelTabs {
source_group: PanelGroupId,
target_group: PanelGroupId,
Expand Down
Loading
Loading