diff --git a/editor/src/document/document_message_handler.rs b/editor/src/document/document_message_handler.rs index 9ec2b5575d..ec0c8172fb 100644 --- a/editor/src/document/document_message_handler.rs +++ b/editor/src/document/document_message_handler.rs @@ -202,7 +202,13 @@ impl MessageHandler for DocumentsMessageHa Ok(document) => { self.load_document(document, responses); } - Err(e) => responses.push_back(FrontendMessage::DisplayError { description: e.to_string() }.into()), + Err(e) => responses.push_back( + FrontendMessage::DisplayError { + title: "Failed to open document".to_string(), + description: e.to_string(), + } + .into(), + ), } } GetOpenDocumentsList => { diff --git a/editor/src/frontend/frontend_message_handler.rs b/editor/src/frontend/frontend_message_handler.rs index faadf950ca..dda1b1df0a 100644 --- a/editor/src/frontend/frontend_message_handler.rs +++ b/editor/src/frontend/frontend_message_handler.rs @@ -12,7 +12,8 @@ pub enum FrontendMessage { SetActiveTool { tool_name: String, tool_options: Option }, SetActiveDocument { document_index: usize }, UpdateOpenDocumentsList { open_documents: Vec }, - DisplayError { description: String }, + DisplayError { title: String, description: String }, + DisplayPanic { title: String, description: String }, DisplayConfirmationToCloseDocument { document_index: usize }, DisplayConfirmationToCloseAllDocuments, UpdateCanvas { document: String }, diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 10675dd062..57475cca0c 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -41,8 +41,10 @@ impl Editor { pub fn handle_message>(&mut self, message: T) -> Vec { self.dispatcher.handle_message(message); + let mut responses = Vec::new(); std::mem::swap(&mut responses, &mut self.dispatcher.responses); + responses } } diff --git a/frontend/src/components/panels/Document.vue b/frontend/src/components/panels/Document.vue index c071a04aff..842635a9ab 100644 --- a/frontend/src/components/panels/Document.vue +++ b/frontend/src/components/panels/Document.vue @@ -227,6 +227,7 @@ import { defineComponent } from "vue"; import { ResponseType, registerResponseHandler, Response, UpdateCanvas, UpdateScrollbars, SetActiveTool, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler"; import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets"; import { comingSoon } from "@/utilities/errors"; +import { panicProxy } from "@/utilities/panic"; import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue"; @@ -245,7 +246,7 @@ import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue"; import ToolOptions from "@/components/widgets/options/ToolOptions.vue"; import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); const documentModeEntries: SectionsOfMenuListEntries = [ [ diff --git a/frontend/src/components/panels/LayerTree.vue b/frontend/src/components/panels/LayerTree.vue index 5d2d71c547..470b42b29c 100644 --- a/frontend/src/components/panels/LayerTree.vue +++ b/frontend/src/components/panels/LayerTree.vue @@ -182,6 +182,7 @@ import { defineComponent } from "vue"; import { ResponseType, registerResponseHandler, Response, BlendMode, ExpandFolder, CollapseFolder, UpdateLayer, LayerPanelEntry, LayerType } from "@/utilities/response-handler"; +import { panicProxy } from "@/utilities/panic"; import { SeparatorType } from "@/components/widgets/widgets"; import LayoutRow from "@/components/layout/LayoutRow.vue"; @@ -195,7 +196,7 @@ import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue"; import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); const blendModeEntries: SectionsOfMenuListEntries = [ [{ label: "Normal", value: BlendMode.Normal }], diff --git a/frontend/src/components/widgets/floating-menus/DialogModal.vue b/frontend/src/components/widgets/floating-menus/DialogModal.vue index 2eb465ebec..889690290b 100644 --- a/frontend/src/components/widgets/floating-menus/DialogModal.vue +++ b/frontend/src/components/widgets/floating-menus/DialogModal.vue @@ -57,12 +57,14 @@ .main-column { .heading { - white-space: pre; + white-space: pre-wrap; + max-width: 400px; margin-bottom: 4px; } .details { - white-space: pre; + white-space: pre-wrap; + max-width: 400px; } .buttons-row { diff --git a/frontend/src/components/widgets/inputs/MenuBarInput.vue b/frontend/src/components/widgets/inputs/MenuBarInput.vue index 79924fbe1d..57f7fdf03c 100644 --- a/frontend/src/components/widgets/inputs/MenuBarInput.vue +++ b/frontend/src/components/widgets/inputs/MenuBarInput.vue @@ -54,13 +54,14 @@ import { defineComponent } from "vue"; import { comingSoon } from "@/utilities/errors"; +import { panicProxy } from "@/utilities/panic"; import IconLabel from "@/components/widgets/labels/IconLabel.vue"; import { ApplicationPlatform } from "@/components/window/MainWindow.vue"; import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); const menuEntries: MenuListEntries = [ { diff --git a/frontend/src/components/widgets/inputs/SwatchPairInput.vue b/frontend/src/components/widgets/inputs/SwatchPairInput.vue index 7c9cacec13..6687273aee 100644 --- a/frontend/src/components/widgets/inputs/SwatchPairInput.vue +++ b/frontend/src/components/widgets/inputs/SwatchPairInput.vue @@ -69,12 +69,13 @@ import { defineComponent } from "vue"; import { rgbToDecimalRgb, RGB } from "@/utilities/color"; +import { panicProxy } from "@/utilities/panic"; import { ResponseType, registerResponseHandler, Response, UpdateWorkingColors } from "@/utilities/response-handler"; import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue"; import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); export default defineComponent({ components: { diff --git a/frontend/src/components/widgets/options/ToolOptions.vue b/frontend/src/components/widgets/options/ToolOptions.vue index 42c1da72ae..47eb41d0bd 100644 --- a/frontend/src/components/widgets/options/ToolOptions.vue +++ b/frontend/src/components/widgets/options/ToolOptions.vue @@ -32,6 +32,7 @@ import { defineComponent, PropType } from "vue"; import { comingSoon } from "@/utilities/errors"; +import { panicProxy } from "@/utilities/panic"; import { WidgetRow, SeparatorType, IconButtonWidget } from "@/components/widgets/widgets"; import Separator from "@/components/widgets/separators/Separator.vue"; @@ -39,7 +40,7 @@ import IconButton from "@/components/widgets/buttons/IconButton.vue"; import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue"; import NumberInput from "@/components/widgets/inputs/NumberInput.vue"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); export default defineComponent({ props: { diff --git a/frontend/src/components/window/title-bar/WindowButtonsWeb.vue b/frontend/src/components/window/title-bar/WindowButtonsWeb.vue index 8dfd211ad2..824c400578 100644 --- a/frontend/src/components/window/title-bar/WindowButtonsWeb.vue +++ b/frontend/src/components/window/title-bar/WindowButtonsWeb.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/utilities/documents.ts b/frontend/src/utilities/documents.ts index 0094c4bc55..14677771fd 100644 --- a/frontend/src/utilities/documents.ts +++ b/frontend/src/utilities/documents.ts @@ -11,9 +11,10 @@ import { ExportDocument, SaveDocument, } from "@/utilities/response-handler"; -import { download, upload } from "./files"; +import { download, upload } from "@/utilities/files"; +import { panicProxy } from "@/utilities/panic"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); const state = reactive({ title: "", diff --git a/frontend/src/utilities/errors.ts b/frontend/src/utilities/errors.ts index d8f60cff19..db3478b938 100644 --- a/frontend/src/utilities/errors.ts +++ b/frontend/src/utilities/errors.ts @@ -1,6 +1,7 @@ import { createDialog, dismissDialog } from "@/utilities/dialog"; import { TextButtonWidget } from "@/components/widgets/widgets"; -import { ResponseType, registerResponseHandler, Response, DisplayError } from "@/utilities/response-handler"; +import { getPanicDetails } from "@/utilities/panic"; +import { ResponseType, registerResponseHandler, Response, DisplayError, DisplayPanic } from "@/utilities/response-handler"; export function comingSoon(issueNumber?: number) { const bugMessage = `— but you can help add it!\nSee issue #${issueNumber} on GitHub.`; @@ -32,5 +33,74 @@ registerResponseHandler(ResponseType.DisplayError, (responseData: Response) => { }; const buttons = [okButton]; - createDialog("Warning", "Editor error", data.description, buttons); + createDialog("Warning", data.title, data.description, buttons); }); + +registerResponseHandler(ResponseType.DisplayPanic, (responseData: Response) => { + const data = responseData as DisplayPanic; + + const reloadButton: TextButtonWidget = { + kind: "TextButton", + callback: async () => window.location.reload(), + props: { label: "Reload", emphasized: true, minWidth: 96 }, + }; + const copyErrorLogButton: TextButtonWidget = { + kind: "TextButton", + callback: async () => navigator.clipboard.writeText(getPanicDetails()), + props: { label: "Copy Error Log", emphasized: false, minWidth: 96 }, + }; + const reportOnGithubButton: TextButtonWidget = { + kind: "TextButton", + callback: async () => window.open(githubUrl(), "_blank"), + props: { label: "Report Bug", emphasized: false, minWidth: 96 }, + }; + const buttons = [reloadButton, copyErrorLogButton, reportOnGithubButton]; + + createDialog("Warning", data.title, data.description, buttons); +}); + +function githubUrl() { + const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new"); + + const body = ` +**Describe the Crash** +Explain clearly what you were doing when the crash occurred. + +**Steps To Reproduce** +Describe precisely how the crash occurred, step by step, starting with a new editor window. +1. Open the Graphite Editor at https://editor.graphite.design +2. +3. +4. +5. + +**Browser and OS* +List of your browser and its version, as well as your operating system. + +**Additional Details** +Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue. + +**Stack Trace** +Copied from the crash dialog in the Graphite Editor: + +\`\`\` +${getPanicDetails()} +\`\`\` +`.trim(); + + const fields = { + title: "[Crash Report] ", + body, + labels: ["Crash"].join(","), + projects: [].join(","), + milestone: "", + assignee: "", + template: "", + }; + + Object.entries(fields).forEach(([field, value]) => { + if (value) url.searchParams.set(field, value); + }); + + return url.toString(); +} diff --git a/frontend/src/utilities/input.ts b/frontend/src/utilities/input.ts index 5429c4c368..46bac879d3 100644 --- a/frontend/src/utilities/input.ts +++ b/frontend/src/utilities/input.ts @@ -1,7 +1,8 @@ import { toggleFullscreen } from "@/utilities/fullscreen"; import { dialogIsVisible, dismissDialog, submitDialog } from "@/utilities/dialog"; +import { panicProxy } from "@/utilities/panic"; -const wasm = import("@/../wasm/pkg"); +const wasm = import("@/../wasm/pkg").then(panicProxy); let viewportMouseInteractionOngoing = false; @@ -45,10 +46,12 @@ export async function onKeyDown(e: KeyboardEvent) { if (dialogIsVisible()) { if (e.key === "Escape") dismissDialog(); - if (e.key === "Enter") submitDialog(); + if (e.key === "Enter") { + submitDialog(); - // Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog - e.preventDefault(); + // Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog + e.preventDefault(); + } } } diff --git a/frontend/src/utilities/panic.ts b/frontend/src/utilities/panic.ts new file mode 100644 index 0000000000..d9051bcd42 --- /dev/null +++ b/frontend/src/utilities/panic.ts @@ -0,0 +1,50 @@ +// Import this function and chain it on all `wasm` imports like: const wasm = import("@/../wasm/pkg").then(panicProxy); +// This works by proxying every function call wrapping a try-catch block to filter out redundant and confusing `RuntimeError: unreachable` exceptions sent to the console +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function panicProxy(module: any) { + const proxyHandler = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(target: any, propKey: any, receiver: any) { + const targetValue = Reflect.get(target, propKey, receiver); + + // Keep the original value being accessed if it isn't a function or it is a class + // TODO: Figure out how to also wrap (class) constructor functions instead of skipping them for now + const isFunction = typeof targetValue === "function"; + const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString()); + if (!isFunction || isClass) return targetValue; + + // Replace the original function with a wrapper function that runs the original in a try-catch block + // eslint-disable-next-line @typescript-eslint/no-explicit-any, func-names + return function (...args: any) { + let result; + try { + // @ts-expect-error + result = targetValue.apply(this, args); + } catch (err: any) { + // Suppress `unreachable` WebAssembly.RuntimeError exceptions + if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err; + } + return result; + }; + }, + }; + + return new Proxy(module, proxyHandler); +} + +// Intercept console.error() for panic messages sent by code in the WASM toolchain +let panicDetails = ""; +// eslint-disable-next-line no-console +const error = console.error.bind(console); +// eslint-disable-next-line no-console +console.error = (...args) => { + const details = "".concat(...args).trim(); + if (details.startsWith("panicked at")) panicDetails = details; + + error(...args); +}; + +// Get the body of the panic's exception that was printed in the console +export function getPanicDetails(): string { + return panicDetails; +} diff --git a/frontend/src/utilities/response-handler.ts b/frontend/src/utilities/response-handler.ts index de9dc01bec..c4145c678e 100644 --- a/frontend/src/utilities/response-handler.ts +++ b/frontend/src/utilities/response-handler.ts @@ -28,6 +28,7 @@ export enum ResponseType { SetCanvasZoom = "SetCanvasZoom", SetCanvasRotation = "SetCanvasRotation", DisplayError = "DisplayError", + DisplayPanic = "DisplayPanic", DisplayConfirmationToCloseDocument = "DisplayConfirmationToCloseDocument", DisplayConfirmationToCloseAllDocuments = "DisplayConfirmationToCloseAllDocuments", } @@ -43,8 +44,10 @@ export function handleResponse(responseType: string, responseData: any) { if (callback && data) { callback(data); } else if (data) { + // eslint-disable-next-line no-console console.error(`Received a Response of type "${responseType}" but no handler was registered for it from the client.`); } else { + // eslint-disable-next-line no-console console.error(`Received a Response of type "${responseType}" but but was not able to parse the data.`); } } @@ -83,6 +86,8 @@ function parseResponse(responseType: string, data: any): Response { return newUpdateWorkingColors(data.UpdateWorkingColors); case "DisplayError": return newDisplayError(data.DisplayError); + case "DisplayPanic": + return newDisplayPanic(data.DisplayPanic); case "DisplayConfirmationToCloseDocument": return newDisplayConfirmationToCloseDocument(data.DisplayConfirmationToCloseDocument); case "DisplayConfirmationToCloseAllDocuments": @@ -144,10 +149,23 @@ function newSetActiveDocument(input: any): SetActiveDocument { } export interface DisplayError { + title: string; description: string; } function newDisplayError(input: any): DisplayError { return { + title: input.title, + description: input.description, + }; +} + +export interface DisplayPanic { + title: string; + description: string; +} +function newDisplayPanic(input: any): DisplayPanic { + return { + title: input.title, description: input.description, }; } diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index e893b28c1f..256fe9d898 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -5,6 +5,7 @@ pub mod wrappers; use editor::{message_prelude::*, Editor}; use std::cell::RefCell; +use std::sync::atomic::AtomicBool; use utils::WasmLog; use wasm_bindgen::prelude::*; @@ -13,6 +14,7 @@ thread_local! { pub static EDITOR_STATE: RefCell = RefCell::new(Editor::new()); } static LOGGER: WasmLog = WasmLog; +static EDITOR_HAS_CRASHED: AtomicBool = AtomicBool::new(false); #[wasm_bindgen(start)] pub fn init() { @@ -22,21 +24,41 @@ pub fn init() { } // Sends FrontendMessages to JavaScript -pub fn dispatch>(message: T) { - let messages = EDITOR_STATE.with(|state| state.borrow_mut().handle_message(message.into())); - - for message in messages.into_iter() { - let message_type = message.to_discriminant().local_name(); - let message_data = JsValue::from_serde(&message).expect("Failed to serialize response"); - - let _ = handleResponse(message_type, message_data).map_err(|error| { - log::error!( - "While handling FrontendMessage \"{:?}\", JavaScript threw an error: {:?}", - message.to_discriminant().local_name(), - error - ) - }); +fn dispatch>(message: T) { + // Process no further messages after a crash to avoid spamming the console + if EDITOR_HAS_CRASHED.load(std::sync::atomic::Ordering::SeqCst) { + return; } + + match EDITOR_STATE.with(|state| state.try_borrow_mut().ok().map(|mut state| state.handle_message(message.into()))) { + Some(messages) => { + for message in messages.into_iter() { + handle_response(message); + } + } + None => { + EDITOR_HAS_CRASHED.store(true, std::sync::atomic::Ordering::SeqCst); + + let title = "The editor crashed — sorry about that".to_string(); + let description = "An internal error occurred. Reload the editor to continue. Please report this by filing an issue on GitHub.".to_string(); + + handle_response(FrontendMessage::DisplayPanic { title, description }); + } + } +} + +// Sends a FrontendMessage to JavaScript +fn handle_response(message: FrontendMessage) { + let message_type = message.to_discriminant().local_name(); + let message_data = JsValue::from_serde(&message).expect("Failed to serialize response"); + + let _ = handleResponse(message_type, message_data).map_err(|error| { + log::error!( + "While handling FrontendMessage \"{:?}\", JavaScript threw an error: {:?}", + message.to_discriminant().local_name(), + error + ) + }); } // The JavaScript function to call into