diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 8524696a..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -**/coi-serviceworker.js linguist-vendored diff --git a/Cargo.lock b/Cargo.lock index d1bcd43a..99ea426f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3032,7 +3032,6 @@ dependencies = [ "color-eyre", "egui", "egui_extras", - "flume", "futures-lite 2.1.0", "git-version", "image 0.24.7", @@ -3048,11 +3047,13 @@ dependencies = [ "luminol-ui", "luminol-web", "once_cell", + "oneshot", "parking_lot", "poll-promise", "rfd", "steamworks", "strum", + "tempfile", "tokio", "tracing", "tracing-log 0.1.4", @@ -3134,13 +3135,13 @@ dependencies = [ "egui-modal", "egui-notify", "egui_dock", - "git-version", "itertools", "luminol-audio", "luminol-config", "luminol-data", "luminol-filesystem", "luminol-graphics", + "once_cell", "poll-promise", "rand", "serde", @@ -3318,7 +3319,6 @@ dependencies = [ "color-eyre", "egui", "futures-util", - "git-version", "itertools", "luminol-audio", "luminol-components", @@ -3333,7 +3333,10 @@ dependencies = [ "poll-promise", "qp-trie", "reqwest", + "serde", + "strip-ansi-escapes", "strum", + "target-triple", "zip", ] @@ -4759,9 +4762,9 @@ checksum = "216080ab382b992234dda86873c18d4c48358f5cfcb70fd693d7f6f2131b628b" [[package]] name = "reqwest" -version = "0.11.22" +version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" dependencies = [ "base64", "bytes", @@ -5402,6 +5405,15 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.10.0" @@ -5573,6 +5585,12 @@ version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c39fd04924ca3a864207c66fc2cd7d22d7c016007f9ce846cbb9326331930a" +[[package]] +name = "target-triple" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea67965c3f2666e59325eec14b6f0e296d27044051e65fc2a7d77ed3a3eff82d" + [[package]] name = "tempfile" version = "3.8.1" @@ -6235,6 +6253,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d257817081c7dffcdbab24b9e62d2def62e2ff7d00b1c20062551e6cccc145ff" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "vtparse" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 84991939..9730131a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ doc_markdown = "allow" missing_panics_doc = "allow" too_many_lines = "allow" # you must provide a safety doc. -missing_safety_doc = "forbid" +missing_safety_doc = "warn" [workspace.package] version = "0.4.0" @@ -125,11 +125,10 @@ qp-trie = "0.8.2" itertools = "0.11.0" rfd = "0.12.0" +tempfile = "3.8.1" rand = "0.8.5" -git-version = "0.3.9" - luminol-audio = { version = "0.4.0", path = "crates/audio/" } luminol-components = { version = "0.4.0", path = "crates/components/" } luminol-config = { version = "0.4.0", path = "crates/config/" } @@ -180,7 +179,7 @@ zstd = "0.13.0" async-std.workspace = true futures-lite.workspace = true -git-version.workspace = true +git-version = "0.3.9" # Native [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -192,6 +191,7 @@ tokio = { version = "1.33", features = [ "rt-multi-thread", "parking_lot", ] } # *sigh* +tempfile.workspace = true luminol-term.workspace = true # Set poll promise features here based on the target @@ -213,7 +213,7 @@ wasm-bindgen = "0.2.87" wasm-bindgen-futures = "0.4" js-sys = "0.3" -flume.workspace = true +oneshot.workspace = true luminol-web = { version = "0.4.0", path = "crates/web/" } diff --git a/assets/coi-serviceworker.js b/assets/coi-serviceworker.js deleted file mode 100644 index af2caf1b..00000000 --- a/assets/coi-serviceworker.js +++ /dev/null @@ -1,118 +0,0 @@ -/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */ -let coepCredentialless = false; -if (typeof window === 'undefined') { - self.addEventListener("install", () => self.skipWaiting()); - self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); - - self.addEventListener("message", (ev) => { - if (!ev.data) { - return; - } else if (ev.data.type === "deregister") { - self.registration - .unregister() - .then(() => { - return self.clients.matchAll(); - }) - .then(clients => { - clients.forEach((client) => client.navigate(client.url)); - }); - } else if (ev.data.type === "coepCredentialless") { - coepCredentialless = ev.data.value; - } - }); - - self.addEventListener("fetch", function (event) { - const r = event.request; - if (r.cache === "only-if-cached" && r.mode !== "same-origin") { - return; - } - - const request = (coepCredentialless && r.mode === "no-cors") - ? new Request(r, { - credentials: "omit", - }) - : r; - event.respondWith( - fetch(request) - .then((response) => { - if (response.status === 0) { - return response; - } - - const newHeaders = new Headers(response.headers); - newHeaders.set("Cross-Origin-Embedder-Policy", - coepCredentialless ? "credentialless" : "require-corp" - ); - if (!coepCredentialless) { - newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin"); - } - newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: newHeaders, - }); - }) - .catch((e) => console.error(e)) - ); - }); - -} else { - (() => { - // You can customize the behavior of this script through a global `coi` variable. - const coi = { - shouldRegister: () => true, - shouldDeregister: () => false, - coepCredentialless: () => (window.chrome !== undefined || window.netscape !== undefined), - doReload: () => window.location.reload(), - quiet: false, - ...window.coi - }; - - const n = navigator; - - if (n.serviceWorker && n.serviceWorker.controller) { - n.serviceWorker.controller.postMessage({ - type: "coepCredentialless", - value: coi.coepCredentialless(), - }); - - if (coi.shouldDeregister()) { - n.serviceWorker.controller.postMessage({ type: "deregister" }); - } - } - - // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are - // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here. - if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return; - - if (!window.isSecureContext) { - !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required."); - return; - } - - // In some environments (e.g. Chrome incognito mode) this won't be available - if (n.serviceWorker) { - n.serviceWorker.register(window.document.currentScript.src).then( - (registration) => { - !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); - - registration.addEventListener("updatefound", () => { - !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); - coi.doReload(); - }); - - // If the registration is active, but it's not controlling the page - if (registration.active && !n.serviceWorker.controller) { - !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); - coi.doReload(); - } - }, - (err) => { - !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); - } - ); - } - })(); -} diff --git a/assets/main.js b/assets/main.js index 6663047b..c1c42e08 100644 --- a/assets/main.js +++ b/assets/main.js @@ -15,7 +15,16 @@ // You should have received a copy of the GNU General Public License // along with Luminol. If not, see . -import wasm_bindgen, { luminol_main_start } from './luminol.js'; +window.restartLuminol = async function() { + // We need to reload luminol.js every time by invalidating the cache, + // otherwise it'll just reload the same WebAssembly module every time + // instead of reinstantiating it + const invalidator = crypto.randomUUID(); -await wasm_bindgen(); -luminol_main_start(); + const { default: wasm_bindgen, luminol_main_start } = await import(`./luminol.js?luminol-invalidator=${invalidator}`); + + await wasm_bindgen(); + luminol_main_start(); +}; + +await window.restartLuminol(); diff --git a/assets/sw.js b/assets/sw.js new file mode 100644 index 00000000..c9626394 --- /dev/null +++ b/assets/sw.js @@ -0,0 +1,201 @@ +//! This is a slightly modified version of coi-serviceworker, commit 7b1d2a092d0d2dd2b7270b6f12f13605de26f214 +//! https://github.com/gzuidhof/coi-serviceworker +/*! + * MIT License + * + * Copyright (c) 2021 Guido Zuidhof + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. +*/ + +const CACHE_NAME = "luminol"; + +let coepCredentialless = false; +if (typeof window === 'undefined') { + self.addEventListener("install", () => self.skipWaiting()); + self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); + + self.addEventListener("message", (ev) => { + if (!ev.data) { + return; + } else if (ev.data.type === "deregister") { + self.registration + .unregister() + .then(() => { + return self.clients.matchAll(); + }) + .then(clients => { + clients.forEach((client) => client.navigate(client.url)); + }); + } else if (ev.data.type === "coepCredentialless") { + coepCredentialless = ev.data.value; + } + }); + + self.addEventListener("fetch", function (event) { + const r = event.request; + if (r.cache === "only-if-cached" && r.mode !== "same-origin") { + return; + } + + const request = (coepCredentialless && r.mode === "no-cors") + ? new Request(r, { + credentials: "omit", + }) + : new URL(r.url).searchParams.has("luminol-invalidator") + ? (() => { + // Remove 'luminol-invalidator' from the request's query string if it exists in the query string + const url = new URL(r.url); + url.searchParams.delete("luminol-invalidator"); + return new Request(url, r); + })() + : r; + event.respondWith( + self.caches + .match(request) + .then((cached) => cached || fetch(request)) // Respond with cached response if one exists for this request + .then((response) => { + if (response.status === 0) { + return new Response(); + } + + const newHeaders = new Headers(response.headers); + newHeaders.set("Cross-Origin-Embedder-Policy", + coepCredentialless ? "credentialless" : "require-corp" + ); + if (!coepCredentialless) { + newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin"); + } + newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); + + const newResponse = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + + // Auto-cache non-error, non-opaque responses for all same-origin requests + if (response.type === "error" || new URL(request.url).origin !== self.origin) { + return newResponse; + } else { + return self.caches + .open(CACHE_NAME) + .then((cache) => cache.put(request, newResponse.clone())) + .then(() => newResponse); + } + }) + .catch((e) => console.error(e)) + ); + }); + +} else { + (() => { + const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf"); + window.sessionStorage.removeItem("coiReloadedBySelf"); + const coepDegrading = (reloadedBySelf == "coepdegrade"); + + // You can customize the behavior of this script through a global `coi` variable. + const coi = { + shouldRegister: () => !reloadedBySelf, + shouldDeregister: () => false, + coepCredentialless: () => true, + coepDegrade: () => true, + doReload: () => window.location.reload(), + quiet: false, + ...window.coi + }; + + const n = navigator; + const controlling = n.serviceWorker && n.serviceWorker.controller; + + // Record the failure if the page is served by serviceWorker. + if (controlling && !window.crossOriginIsolated) { + window.sessionStorage.setItem("coiCoepHasFailed", "true"); + } + const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed"); + + if (controlling) { + // Reload only on the first failure. + const reloadToDegrade = coi.coepDegrade() && !( + coepDegrading || window.crossOriginIsolated + ); + n.serviceWorker.controller.postMessage({ + type: "coepCredentialless", + value: (reloadToDegrade || coepHasFailed && coi.coepDegrade()) + ? false + : coi.coepCredentialless(), + }); + if (reloadToDegrade) { + !coi.quiet && console.log("Reloading page to degrade COEP."); + window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade"); + coi.doReload("coepdegrade"); + } + + if (coi.shouldDeregister()) { + n.serviceWorker.controller.postMessage({ type: "deregister" }); + } + } + + // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are + // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here. + if (window.crossOriginIsolated !== false || !coi.shouldRegister()) { + // Reload once to set the COEP for this service worker as well + if (!window.sessionStorage.getItem("coiReloadedAfterSuccess")) { + !coi.quiet && console.log("Reloading page to set COEP for this service worker."); + window.sessionStorage.setItem("coiReloadedAfterSuccess", "true"); + coi.doReload("coepaftersuccess"); + } + return; + } + window.sessionStorage.removeItem("coiReloadedAfterSuccess"); + + if (!window.isSecureContext) { + !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required."); + return; + } + + // In some environments (e.g. Firefox private mode) this won't be available + if (!n.serviceWorker) { + !coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode."); + return; + } + + n.serviceWorker.register(window.document.currentScript.src).then( + (registration) => { + !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); + + registration.addEventListener("updatefound", () => { + !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "updatefound"); + coi.doReload(); + }); + + // If the registration is active, but it's not controlling the page + if (registration.active && !n.serviceWorker.controller) { + !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling"); + coi.doReload(); + } + }, + (err) => { + !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); + } + ); + })(); +} diff --git a/crates/components/src/lib.rs b/crates/components/src/lib.rs index 42903467..64fb53e6 100644 --- a/crates/components/src/lib.rs +++ b/crates/components/src/lib.rs @@ -21,6 +21,7 @@ // it with Steamworks API by Valve Corporation, containing parts covered by // terms of the Steamworks API by Valve Corporation, the licensors of this // Program grant you additional permission to convey the resulting work. +#![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] #![feature(is_sorted)] /// Syntax highlighter diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cda0e472..8b5c8fa1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -24,6 +24,7 @@ egui-notify = "0.12.0" egui-modal = "0.3.2" poll-promise.workspace = true +once_cell.workspace = true tracing.workspace = true color-eyre.workspace = true @@ -38,8 +39,6 @@ alox-48.workspace = true rand.workspace = true -git-version.workspace = true - luminol-audio.workspace = true luminol-config.workspace = true luminol-data.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ae6ff149..fa0553f6 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -45,6 +45,12 @@ pub mod project_manager; pub use project_manager::spawn_future; pub use project_manager::ProjectManager; +static GIT_REVISION: once_cell::sync::OnceCell<&'static str> = once_cell::sync::OnceCell::new(); + +pub fn set_git_revision(revision: &'static str) { + let _ = GIT_REVISION.set(revision); +} + pub struct UpdateState<'res> { pub ctx: &'res egui::Context, @@ -72,6 +78,8 @@ pub struct UpdateState<'res> { pub modified: ModifiedState, pub project_manager: &'res mut ProjectManager, + + pub git_revision: &'static str, } /// This stores whether or not there are unsaved changes in any file in the current project and is @@ -148,6 +156,7 @@ impl<'res> UpdateState<'res> { toolbar: self.toolbar, modified: self.modified.clone(), project_manager: self.project_manager, + git_revision: self.git_revision, } } @@ -170,6 +179,7 @@ impl<'res> UpdateState<'res> { toolbar: self.toolbar, modified: self.modified.clone(), project_manager: self.project_manager, + git_revision: self.git_revision, } } diff --git a/crates/core/src/toasts.rs b/crates/core/src/toasts.rs index 54387f0c..d5dc3903 100644 --- a/crates/core/src/toasts.rs +++ b/crates/core/src/toasts.rs @@ -67,7 +67,11 @@ impl Toasts { #[doc(hidden)] pub fn _e_add_version_section(error: color_eyre::Report) -> color_eyre::Report { - error.section(format!("Luminol version: {}", git_version::git_version!())) + if let Some(git_revision) = crate::GIT_REVISION.get() { + error.section(format!("Luminol version: {git_revision}")) + } else { + error + } } #[doc(hidden)] diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index 584a18cd..324d0960 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -41,6 +41,16 @@ pub struct EditWindows { type CleanFn = Box) -> bool>; impl Windows { + pub fn new() -> Self { + Default::default() + } + + pub fn new_with_windows(windows: Vec) -> Self { + Self { + windows: windows.into_iter().map(|w| Box::new(w) as Box<_>).collect(), + } + } + /// A function to add a window. pub fn add_window(&mut self, window: impl Window + 'static) { self.add_boxed_window(Box::new(window)) @@ -177,3 +187,9 @@ impl Window for Box { } } */ + +impl From>> for Windows { + fn from(windows: Vec>) -> Self { + Self { windows } + } +} diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index 7e5fade9..93024519 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -750,6 +750,8 @@ pub(crate) fn install_canvas_events(state: &MainState) -> Result<(), JsValue> { let mut options = web_sys::MutationObserverInit::new(); options.attributes(true); observer.observe_with_options(&state.canvas, &options)?; + // We don't need to unregister this mutation observer on panic because it auto-deregisters + // when the target (the canvas) is removed from the DOM and garbage-collected callback.forget(); } diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 61e701a6..e64f4057 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -374,7 +374,17 @@ impl MainState { // Add the event listener to the target target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?; - closure.forget(); + // Add a hook to unregister this event listener after panicking + EVENTS_TO_UNSUBSCRIBE.with_borrow_mut(|events| { + events.push(web_runner::EventToUnsubscribe::TargetEvent( + web_runner::TargetEvent { + target: target.clone(), + event_name: event_name.to_string(), + closure, + }, + )); + }); + Ok(()) } } @@ -433,3 +443,7 @@ enum WebRunnerOutput { } static PANIC_LOCK: once_cell::sync::OnceCell<()> = once_cell::sync::OnceCell::new(); + +thread_local! { + static EVENTS_TO_UNSUBSCRIBE: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); +} diff --git a/crates/eframe/src/web/panic_handler.rs b/crates/eframe/src/web/panic_handler.rs index 9656ba28..cadeed2a 100644 --- a/crates/eframe/src/web/panic_handler.rs +++ b/crates/eframe/src/web/panic_handler.rs @@ -13,13 +13,18 @@ pub struct PanicHandler(Arc>); impl PanicHandler { /// Install a panic hook. - pub fn install() -> Self { + pub fn install(panic_tx: Arc>>>) -> Self { let handler = Self(Arc::new(Mutex::new(Default::default()))); let handler_clone = handler.clone(); let previous_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let _ = super::PANIC_LOCK.set(()); + if let Some(mut panic_tx) = panic_tx.try_lock() { + if let Some(panic_tx) = panic_tx.take() { + let _ = panic_tx.send(()); + } + } let summary = PanicSummary::new(panic_info); diff --git a/crates/eframe/src/web/storage.rs b/crates/eframe/src/web/storage.rs index 09af42a2..e92bb673 100644 --- a/crates/eframe/src/web/storage.rs +++ b/crates/eframe/src/web/storage.rs @@ -34,7 +34,7 @@ pub(crate) async fn load_memory(_: &egui::Context, _: &super::WorkerChannels) {} #[cfg(feature = "persistence")] pub(crate) fn save_memory(ctx: &egui::Context, channels: &super::WorkerChannels) { - match ctx.memory(|mem| ron::to_string(mem)) { + match ctx.memory(ron::to_string) { Ok(ron) => { let (oneshot_tx, oneshot_rx) = oneshot::channel(); channels.send(super::WebRunnerOutput::StorageSet( diff --git a/crates/eframe/src/web/web_runner.rs b/crates/eframe/src/web/web_runner.rs index df8c963f..187ab6a2 100644 --- a/crates/eframe/src/web/web_runner.rs +++ b/crates/eframe/src/web/web_runner.rs @@ -29,13 +29,13 @@ pub struct WebRunner { impl WebRunner { /// Will install a panic handler that will catch and log any panics #[allow(clippy::new_without_default)] - pub fn new() -> Self { + pub fn new(panic_tx: std::sync::Arc>>>) -> Self { #[cfg(not(web_sys_unstable_apis))] log::warn!( "eframe compiled without RUSTFLAGS='--cfg=web_sys_unstable_apis'. Copying text won't work." ); - let panic_handler = PanicHandler::install(); + let panic_handler = PanicHandler::install(panic_tx); Self { panic_handler, @@ -46,7 +46,25 @@ impl WebRunner { /// Set up the event listeners on the main thread in order to do things like respond to /// mouse events and resize the canvas to fill the screen. - pub fn setup_main_thread_hooks(state: super::MainState) -> Result<(), JsValue> { + pub fn setup_main_thread_hooks( + state: super::MainState, + ) -> Result>>>, JsValue> { + let (panic_tx, panic_rx) = oneshot::channel(); + + wasm_bindgen_futures::spawn_local(async move { + let _ = panic_rx.await; + super::EVENTS_TO_UNSUBSCRIBE.with_borrow_mut(|events| { + for event in events.drain(..) { + if let Err(e) = event.unsubscribe() { + log::warn!( + "Failed to unsubscribe from event: {}", + super::string_from_js_value(&e), + ); + } + } + }); + }); + { events::install_canvas_events(&state)?; events::install_document_events(&state)?; @@ -93,7 +111,7 @@ impl WebRunner { } }); - Ok(()) + Ok(std::sync::Arc::new(parking_lot::Mutex::new(Some(panic_tx)))) } /// Create the application, install callbacks, and start running the app. @@ -225,19 +243,19 @@ impl WebRunner { // ---------------------------------------------------------------------------- -struct TargetEvent { - target: web_sys::EventTarget, - event_name: String, - closure: Closure, +pub(super) struct TargetEvent { + pub(super) target: web_sys::EventTarget, + pub(super) event_name: String, + pub(super) closure: Closure, } #[allow(unused)] -struct IntervalHandle { - handle: i32, - closure: Closure, +pub(super) struct IntervalHandle { + pub(super) handle: i32, + pub(super) closure: Closure, } -enum EventToUnsubscribe { +pub(super) enum EventToUnsubscribe { TargetEvent(TargetEvent), #[allow(unused)] diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index da3e4763..30b5f5a9 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -19,7 +19,7 @@ #![allow(unsafe_code)] // Luminol's wgpu resources are not Send or Sync on web. // We are doing this here to reduce merge conflicts, since it's likely wgpu will fix this. -#![allow(clippy::arc_with_non_send_sync)] +#![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] pub use wgpu; diff --git a/crates/filesystem/Cargo.toml b/crates/filesystem/Cargo.toml index 4188442e..ded3e385 100644 --- a/crates/filesystem/Cargo.toml +++ b/crates/filesystem/Cargo.toml @@ -54,7 +54,7 @@ slab.workspace = true winreg = "0.51.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tempfile = "3.8.1" +tempfile.workspace = true async-fs = "2.1.0" [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/filesystem/src/project.rs b/crates/filesystem/src/project.rs index e5872243..0bf03edd 100644 --- a/crates/filesystem/src/project.rs +++ b/crates/filesystem/src/project.rs @@ -458,21 +458,17 @@ impl FileSystem { global_config: &mut luminol_config::global::Config, ) -> Result { let entries = host.read_dir("")?; - if entries - .iter() - .find(|e| { - if let Some(extension) = e.path.extension() { - e.metadata.is_file - && (extension == "rxproj" - || extension == "rvproj" - || extension == "rvproj2" - || extension == "lumproj") - } else { - false - } - }) - .is_none() - { + if !entries.iter().any(|e| { + if let Some(extension) = e.path.extension() { + e.metadata.is_file + && (extension == "rxproj" + || extension == "rvproj" + || extension == "rvproj2" + || extension == "lumproj") + } else { + false + } + }) { return Err(Error::InvalidProjectFolder.into()); }; diff --git a/crates/filesystem/src/web/mod.rs b/crates/filesystem/src/web/mod.rs index 4ff3a4c8..c62c549f 100644 --- a/crates/filesystem/src/web/mod.rs +++ b/crates/filesystem/src/web/mod.rs @@ -146,7 +146,7 @@ impl FileSystem { /// Returns whether or not the user's browser supports the JavaScript File System API. pub fn filesystem_supported() -> bool { - send_and_recv(|tx| FileSystemCommand::Supported(tx)) + send_and_recv(FileSystemCommand::Supported) } /// Attempts to prompt the user to choose a directory from their local machine using the @@ -159,7 +159,7 @@ impl FileSystem { if !Self::filesystem_supported() { return Err(Error::Wasm32FilesystemNotSupported).wrap_err(c); } - send_and_await(|tx| FileSystemCommand::DirPicker(tx)) + send_and_await(FileSystemCommand::DirPicker) .await .map(|(key, name)| Self { key, @@ -314,7 +314,7 @@ impl File { /// Creates a new empty temporary file with read-write permissions. pub fn new() -> std::io::Result { let c = "While creating a temporary file on a host filesystem"; - send_and_recv(|tx| FileSystemCommand::FileCreateTemp(tx)) + send_and_recv(FileSystemCommand::FileCreateTemp) .map(|(key, temp_file_name)| Self { key, path: None, diff --git a/crates/filesystem/src/web/util.rs b/crates/filesystem/src/web/util.rs index bd2af6c8..a1cd91fa 100644 --- a/crates/filesystem/src/web/util.rs +++ b/crates/filesystem/src/web/util.rs @@ -115,10 +115,10 @@ pub(super) async fn idb( // Create store for our directory handles if it doesn't exist db_req.set_on_upgrade_needed(Some(|e: &IdbVersionChangeEvent| { - if e.db() + if !e + .db() .object_store_names() - .find(|n| n == "filesystem.dir_handles") - .is_none() + .any(|n| n == "filesystem.dir_handles") { e.db().create_object_store("filesystem.dir_handles")?; } diff --git a/crates/graphics/src/lib.rs b/crates/graphics/src/lib.rs index 1627759b..1d6d02cb 100644 --- a/crates/graphics/src/lib.rs +++ b/crates/graphics/src/lib.rs @@ -14,6 +14,7 @@ // // You should have received a copy of the GNU General Public License // along with Luminol. If not, see . +#![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] pub mod binding_helpers; pub use binding_helpers::{BindGroupBuilder, BindGroupLayoutBuilder}; diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 86975edb..b4da44a0 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -34,12 +34,16 @@ camino.workspace = true strum.workspace = true -git-version.workspace = true +serde.workspace = true + +target-triple = "0.1.2" + +strip-ansi-escapes = "0.2.0" poll-promise.workspace = true async-std.workspace = true futures-util = "0.3.30" -reqwest = "0.11.22" +reqwest = { version = "0.11.23", features = ["json"] } zip = { version = "0.6.6", default-features = false, features = ["deflate"] } diff --git a/crates/ui/src/windows/about.rs b/crates/ui/src/windows/about.rs index d4ce6948..d4e8c888 100644 --- a/crates/ui/src/windows/about.rs +++ b/crates/ui/src/windows/about.rs @@ -54,7 +54,7 @@ impl luminol_core::Window for Window { &mut self, ctx: &egui::Context, open: &mut bool, - _update_state: &mut luminol_core::UpdateState<'_>, + update_state: &mut luminol_core::UpdateState<'_>, ) { // Show the window. Name it "About Luminol" egui::Window::new("About Luminol") @@ -69,7 +69,7 @@ impl luminol_core::Window for Window { ui.separator(); ui.label(format!("Luminol version {}", env!("CARGO_PKG_VERSION"))); - ui.label(format!("git-rev {}", git_version::git_version!())); + ui.label(format!("git-rev {}", update_state.git_revision)); ui.separator(); ui.label("Luminol is a FOSS version of the RPG Maker XP editor."); diff --git a/crates/ui/src/windows/items.rs b/crates/ui/src/windows/items.rs index 4ca63ab4..ff88c433 100644 --- a/crates/ui/src/windows/items.rs +++ b/crates/ui/src/windows/items.rs @@ -56,7 +56,7 @@ impl luminol_core::Window for Window { } fn id(&self) -> egui::Id { - egui::Id::new("Item Editor") + egui::Id::new("item_editor") } fn requires_filesystem(&self) -> bool { diff --git a/crates/ui/src/windows/mod.rs b/crates/ui/src/windows/mod.rs index 5f103472..b619ddc7 100644 --- a/crates/ui/src/windows/mod.rs +++ b/crates/ui/src/windows/mod.rs @@ -45,6 +45,8 @@ pub mod map_picker; pub mod misc; /// New project window pub mod new_project; +/// The crash reporter. +pub mod reporter; /// The script editor pub mod script_edit; /// The sound test. diff --git a/crates/ui/src/windows/reporter.rs b/crates/ui/src/windows/reporter.rs new file mode 100644 index 00000000..bd6194d6 --- /dev/null +++ b/crates/ui/src/windows/reporter.rs @@ -0,0 +1,222 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +/// Crash reporter window. +pub struct Window { + normalized_report: String, + json: ReportJson, + send_promise: Option>>, + first_render: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct ReportJson { + reporter_version: u32, + luminol_revision: String, + target: String, + wgpu_backend: String, + debug: bool, + report: String, +} + +impl Window { + pub fn new(report: impl Into, git_revision: impl Into) -> Self { + let report: String = report.into(); + + Self { + normalized_report: strip_ansi_escapes::strip_str(&report), + json: ReportJson { + reporter_version: 1, + luminol_revision: git_revision.into(), + target: target_triple::target!().to_string(), + wgpu_backend: "empty".into(), + debug: cfg!(debug_assertions), + report, + }, + send_promise: None, + first_render: true, + } + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + "Crash Reporter".into() + } + + fn id(&self) -> egui::Id { + egui::Id::new("reporter") + } + + fn requires_filesystem(&self) -> bool { + false + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + if self.first_render { + self.json.wgpu_backend = update_state + .graphics + .render_state + .adapter + .get_info() + .backend + .to_str() + .to_string(); + } + + let mut should_close = false; + + egui::Window::new(self.name()) + .id(egui::Id::new("reporter")) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + ui.label("Luminol has crashed!"); + ui.label( + "Would you like to send the following crash report to the Luminol developers?", + ); + + ui.add_space(ui.spacing().indent); + + ui.label(format!("Luminol version: {}", self.json.luminol_revision)); + ui.label(format!("Target platform: {}", self.json.target)); + ui.label(format!("Graphics backend: {}", self.json.wgpu_backend)); + ui.label(format!( + "Build profile: {}", + if self.json.debug { "debug" } else { "release" } + )); + + ui.group(|ui| { + ui.with_cross_justify(|ui| { + // Forget the scroll position from the last time the reporter opened + let scroll_area_id_source = "scroll_area"; + if self.first_render { + self.first_render = false; + let scroll_area_id = + ui.make_persistent_id(egui::Id::new(scroll_area_id_source)); + egui::scroll_area::State::default().store(ui.ctx(), scroll_area_id); + } + + egui::ScrollArea::both() + .id_source(scroll_area_id_source) + .max_height( + ui.available_height() + - ui.spacing().interact_size.y.max( + ui.text_style_height(&egui::TextStyle::Button) + + 2. * ui.spacing().button_padding.y, + ) + - ui.spacing().item_spacing.y, + ) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.normalized_report.as_str()) + .layouter(&mut |ui, text, wrap_width| { + // Make the text monospace and non-wrapping + egui::WidgetText::from(text) + .color( + ui.visuals() + .override_text_color + .unwrap_or_else(|| { + ui.visuals() + .widgets + .noninteractive + .fg_stroke + .color + }), + ) + .into_galley( + ui, + Some(false), + wrap_width, + egui::TextStyle::Monospace, + ) + }), + ); + }); + }); + }); + + ui.with_cross_justify_center(|ui| { + if self.send_promise.is_none() { + ui.columns(2, |columns| { + if columns[0].button("Don't send").clicked() { + should_close = true; + } + + if columns[1].button("Send").clicked() { + let json = self.json.clone(); + self.send_promise = Some(luminol_core::spawn_future(async move { + let client = reqwest::Client::new(); + let response = client + .post("http://localhost:3246") + .json(&json) + .fetch_mode_no_cors() + .send() + .await + .map_err(|e| color_eyre::eyre::eyre!(e))?; + if response.status().is_success() { + Ok(()) + } else { + Err(color_eyre::eyre::eyre!(format!( + "Request returned {}", + response.status() + ))) + } + })); + } + }); + } else { + ui.spinner(); + } + }); + }); + + if let Some(p) = self.send_promise.take() { + match p.try_take() { + Ok(Ok(())) => { + luminol_core::info!(update_state.toasts, "Crash report sent!"); + should_close = true; + } + Ok(Err(e)) => { + luminol_core::error!( + update_state.toasts, + e.wrap_err("Error sending crash report") + ); + } + Err(p) => self.send_promise = Some(p), + } + } + + if should_close { + *open = false; + } + } +} diff --git a/hooks/trunk_enable_build_std.sh.cmd b/hooks/trunk_enable_build_std.sh.cmd index 6c28a0a9..05d263db 100644 --- a/hooks/trunk_enable_build_std.sh.cmd +++ b/hooks/trunk_enable_build_std.sh.cmd @@ -1,3 +1,4 @@ @echo off +setlocal start /b %TRUNK_SOURCE_DIR%\hooks\trunk_enable_build_std_background.sh diff --git a/hooks/trunk_enable_build_std_background.sh.cmd b/hooks/trunk_enable_build_std_background.sh.cmd index d0f587cd..d5d27c80 100644 --- a/hooks/trunk_enable_build_std_background.sh.cmd +++ b/hooks/trunk_enable_build_std_background.sh.cmd @@ -1,4 +1,5 @@ @echo off +setlocal :: Wait until Trunk errors out or builds successfully, then restore the old Cargo config :loop diff --git a/hooks/trunk_enable_build_std_pre.sh b/hooks/trunk_enable_build_std_pre.sh index 983b99bf..47df86dd 100755 --- a/hooks/trunk_enable_build_std_pre.sh +++ b/hooks/trunk_enable_build_std_pre.sh @@ -1,8 +1,14 @@ #!/bin/sh set -e -# Enable std support for multithreading +git_version=$(git describe --always --dirty=-modified) + +# Enable std support for multithreading and set the LUMINOL_VERSION environment variable [ ! -f $TRUNK_SOURCE_DIR/.cargo/config.toml.bak ] || mv $TRUNK_SOURCE_DIR/.cargo/config.toml.bak $TRUNK_SOURCE_DIR/.cargo/config.toml cp $TRUNK_SOURCE_DIR/.cargo/config.toml $TRUNK_SOURCE_DIR/.cargo/config.toml.bak + +echo '[env]' >> $TRUNK_SOURCE_DIR/.cargo/config.toml +echo "LUMINOL_VERSION = { value = \"$git_version\", force = true }" >> $TRUNK_SOURCE_DIR/.cargo/config.toml + echo '[unstable]' >> $TRUNK_SOURCE_DIR/.cargo/config.toml echo 'build-std = ["std", "panic_abort"]' >> $TRUNK_SOURCE_DIR/.cargo/config.toml diff --git a/hooks/trunk_enable_build_std_pre.sh.cmd b/hooks/trunk_enable_build_std_pre.sh.cmd index 21354b1d..f609e79c 100644 --- a/hooks/trunk_enable_build_std_pre.sh.cmd +++ b/hooks/trunk_enable_build_std_pre.sh.cmd @@ -1,7 +1,14 @@ @echo off +setlocal -:: Enable std support for multithreading +for /f "tokens=*" %%i in ('git describe --always --dirty=-modified') do set git_version=%%i + +:: Enable std support for multithreading and set the LUMINOL_VERSION environment variable if exist %TRUNK_SOURCE_DIR%\.cargo\config.toml.bak move %TRUNK_SOURCE_DIR%\.cargo\config.toml.bak %TRUNK_SOURCE_DIR%\.cargo\config.toml copy %TRUNK_SOURCE_DIR%\.cargo\config.toml %TRUNK_SOURCE_DIR%\.cargo\config.toml.bak + +echo [env] >> %TRUNK_SOURCE_DIR%\.cargo\config.toml +echo LUMINOL_VERSION = { value = "%git_version%", force = true } >> %TRUNK_SOURCE_DIR%\.cargo\config.toml + echo [unstable] >> %TRUNK_SOURCE_DIR%\.cargo\config.toml echo build-std = ["std", "panic_abort"] >> %TRUNK_SOURCE_DIR%\.cargo\config.toml diff --git a/index.html b/index.html index 9ad4d580..66f29cc5 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@ - + @@ -122,13 +122,13 @@ - +
- + diff --git a/src/app/mod.rs b/src/app/mod.rs index a937f44b..a0f2256a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -83,6 +83,7 @@ impl App { #[must_use] pub fn new( cc: &luminol_eframe::CreationContext<'_>, + report: Option, modified: luminol_core::ModifiedState, #[cfg(not(target_arch = "wasm32"))] log_term_rx: luminol_term::TermReceiver, #[cfg(not(target_arch = "wasm32"))] log_byte_rx: luminol_term::ByteReceiver, @@ -90,6 +91,8 @@ impl App { #[cfg(target_arch = "wasm32")] audio: luminol_audio::AudioWrapper, #[cfg(feature = "steamworks")] steamworks: Steamworks, ) -> Self { + luminol_core::set_git_revision(crate::git_revision()); + let render_state = cc .wgpu_render_state .clone() @@ -238,7 +241,11 @@ impl App { bytes_loader, toasts, - windows: luminol_core::Windows::default(), + windows: report.map_or_else(luminol_core::Windows::new, |report| { + luminol_core::Windows::new_with_windows(vec![ + luminol_ui::windows::reporter::Window::new(report, crate::git_revision()), + ]) + }), tabs: luminol_core::Tabs::new_with_tabs( "luminol_main_tabs", vec![luminol_ui::tabs::started::Tab::default()], @@ -266,6 +273,8 @@ impl luminol_eframe::App for App { #[cfg(not(target_arch = "wasm32"))] ctx.input(|i| { if let Some(f) = i.raw.dropped_files.first() { + super::RESTART_AFTER_PANIC.store(true, std::sync::atomic::Ordering::Relaxed); + let path = f.path.clone().expect("dropped file has no path"); let path = camino::Utf8PathBuf::from_path_buf(path).expect("path was not utf8"); @@ -305,6 +314,7 @@ impl luminol_eframe::App for App { toolbar: &mut self.toolbar, modified: self.modified.clone(), project_manager: &mut self.project_manager, + git_revision: crate::git_revision(), }; // If a file/folder picker is open, prevent the user from interacting with the application @@ -378,6 +388,8 @@ impl luminol_eframe::App for App { self.lumi.ui(ctx); + super::RESTART_AFTER_PANIC.store(true, std::sync::atomic::Ordering::Relaxed); + self.bytes_loader.load_unloaded_files(ctx, &self.filesystem); #[cfg(feature = "steamworks")] diff --git a/src/main.rs b/src/main.rs index 85926f19..6bd93eb5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,9 +21,13 @@ // it with Steamworks API by Valve Corporation, containing parts covered by // terms of the Steamworks API by Valve Corporation, the licensors of this // Program grant you additional permission to convey the resulting work. +#![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![cfg_attr(target_arch = "wasm32", no_main)] // there is no main function in web builds +#[cfg(not(target_arch = "wasm32"))] +use std::io::{Read, Write}; + #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; @@ -31,6 +35,9 @@ use wasm_bindgen::prelude::*; /// Embedded icon 256x256 in size. const ICON: &[u8] = include_bytes!("../assets/icon-256.png"); +static RESTART_AFTER_PANIC: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + mod app; mod lumi; @@ -40,15 +47,24 @@ compile_error!("Steamworks is not supported on webassembly"); #[cfg(feature = "steamworks")] mod steam; +pub fn git_revision() -> &'static str { + #[cfg(not(target_arch = "wasm32"))] + { + git_version::git_version!() + } + #[cfg(target_arch = "wasm32")] + option_env!("LUMINOL_VERSION").unwrap_or(git_version::git_version!()) +} + #[cfg(not(target_arch = "wasm32"))] /// A writer that copies whatever is written to it to two other writers. struct CopyWriter(A, B); #[cfg(not(target_arch = "wasm32"))] -impl std::io::Write for CopyWriter +impl Write for CopyWriter where - A: std::io::Write, - B: std::io::Write, + A: Write, + B: Write, { fn write(&mut self, buf: &[u8]) -> std::io::Result { Ok(self.0.write(buf)?.min(self.1.write(buf)?)) @@ -77,7 +93,7 @@ static CONTEXT: once_cell::sync::OnceCell = once_cell::sync::Once struct LogWriter(luminol_term::termwiz::escape::parser::Parser); #[cfg(not(target_arch = "wasm32"))] -impl std::io::Write for LogWriter { +impl Write for LogWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { LOG_BYTE_SENDER .get() @@ -122,6 +138,18 @@ impl std::io::Write for LogWriter { #[cfg(not(target_arch = "wasm32"))] fn main() { + // Load the panic report from the previous run if it exists + let mut report = None; + if let Some(path) = std::env::var_os("LUMINOL_PANIC_REPORT_FILE") { + if let Ok(mut file) = std::fs::File::open(&path) { + let mut buffer = String::new(); + if file.read_to_string(&mut buffer).is_ok() { + report = Some(buffer); + } + } + let _ = std::fs::remove_file(path); + } + #[cfg(feature = "steamworks")] let steamworks = match steam::Steamworks::new() { Ok(s) => s, @@ -172,14 +200,12 @@ fn main() { std::process::abort(); }); - // Enable full backtraces unless the user manually set the RUST_BACKTRACE environment variable - if std::env::var("RUST_BACKTRACE").is_err() { - std::env::set_var("RUST_BACKTRACE", "full"); - } + // Enable full backtraces + std::env::set_var("RUST_BACKTRACE", "full"); // Set up hooks for formatting errors and panics - color_eyre::config::HookBuilder::default() - .panic_section(format!("Luminol version: {}", git_version::git_version!())) + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!("Luminol version: {}", git_revision())) .add_frame_filter(Box::new(|frames| { let filters = &[ "_", @@ -209,8 +235,49 @@ fn main() { }) }) })) + .into_hooks(); + eyre_hook .install() .expect("failed to install color-eyre hooks"); + std::panic::set_hook(Box::new(move |info| { + let report = panic_hook.panic_report(info).to_string(); + eprintln!("{report}"); + + if !RESTART_AFTER_PANIC.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + + let mut args = std::env::args_os(); + let arg0 = args.next(); + let exe_path = std::env::current_exe().map_or_else( + |_| arg0.expect("could not get path to current executable"), + |exe_path| exe_path.into_os_string(), + ); + + let mut file = tempfile::NamedTempFile::new().expect("failed to create temporary file"); + file.write_all(report.as_bytes()) + .expect("failed to write to temporary file"); + file.flush().expect("failed to flush temporary file"); + let (_, path) = file.keep().expect("failed to persist temporary file"); + std::env::set_var("LUMINOL_PANIC_REPORT_FILE", &path); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + let error = std::process::Command::new(exe_path).args(args).exec(); + eprintln!("Failed to restart Luminol: {error:?}"); + let _ = std::fs::remove_file(&path); + } + + #[cfg(not(unix))] + { + if let Err(error) = std::process::Command::new(exe_path).args(args).spawn() { + eprintln!("Failed to restart Luminol: {error:?}"); + let _ = std::fs::remove_file(&path); + } + } + })); // Log to stderr as well as Luminol's log. let (log_term_tx, log_term_rx) = luminol_term::unbounded(); @@ -262,6 +329,7 @@ fn main() { CONTEXT.set(cc.egui_ctx.clone()).unwrap(); Box::new(app::App::new( cc, + report, Default::default(), log_term_rx, log_byte_rx, @@ -274,16 +342,27 @@ fn main() { .expect("failed to start luminol"); } +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen( + inline_js = "let report = null; export function get_panic_report() { return report; }; export function set_panic_report(r) { report = r; window.restartLuminol(); };" +)] +extern "C" { + fn get_panic_report() -> Option; + fn set_panic_report(r: String); +} + #[cfg(target_arch = "wasm32")] const CANVAS_ID: &str = "luminol-canvas"; #[cfg(target_arch = "wasm32")] struct WorkerData { + report: Option, audio: luminol_audio::AudioWrapper, modified: luminol_core::ModifiedState, prefers_color_scheme_dark: Option, fs_worker_channels: luminol_filesystem::web::WorkerChannels, runner_worker_channels: luminol_eframe::web::WorkerChannels, + runner_panic_tx: std::sync::Arc>>>, } #[cfg(target_arch = "wasm32")] @@ -292,40 +371,82 @@ static WORKER_DATA: parking_lot::Mutex> = parking_lot::Mutex: #[cfg(target_arch = "wasm32")] #[wasm_bindgen] pub fn luminol_main_start() { - let (panic_tx, panic_rx) = flume::unbounded(); + // Load the panic report from the previous run if it exists + let report = get_panic_report(); - wasm_bindgen_futures::spawn_local(async move { - if panic_rx.recv_async().await.is_ok() { - let _ = web_sys::window().map(|window| window.alert_with_message("Luminol has crashed! Please check your browser's developer console for more details.")); - } - }); + let worker_cell = std::rc::Rc::new(once_cell::unsync::OnceCell::::new()); + let before_unload_cell = std::rc::Rc::new(std::cell::RefCell::new( + None::>, + )); + let (panic_tx, panic_rx) = oneshot::channel(); + let panic_tx = std::sync::Arc::new(parking_lot::Mutex::new(Some(panic_tx))); + + { + let worker_cell = worker_cell.clone(); + let before_unload_cell = before_unload_cell.clone(); + + wasm_bindgen_futures::spawn_local(async move { + if let Ok(report) = panic_rx.await { + if let Some(worker) = worker_cell.get() { + worker.terminate(); + } + + if let (Some(window), Some(closure)) = + (web_sys::window(), before_unload_cell.take()) + { + let _ = window.remove_event_listener_with_callback( + "beforeunload", + closure.as_ref().unchecked_ref(), + ); + } + + if RESTART_AFTER_PANIC.load(std::sync::atomic::Ordering::Relaxed) { + set_panic_report(report); + } else { + let _ = web_sys::window().map(|window| window.alert_with_message("Luminol has crashed! Please check your browser's developer console for more details.")); + } + } + }); + } // Set up hooks for formatting errors and panics let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() - .panic_section(format!("Luminol version: {}", git_version::git_version!())) + .panic_section(format!("Luminol version: {}", git_revision())) .into_hooks(); eyre_hook .install() .expect("failed to install color-eyre hooks"); std::panic::set_hook(Box::new(move |info| { - web_sys::console::log_1(&js_sys::JsString::from( - panic_hook.panic_report(info).to_string(), - )); - let _ = panic_tx.send(()); + let report = panic_hook.panic_report(info).to_string(); + web_sys::console::log_1(&report.as_str().into()); + + // Send the panic report to the main thread to be persisted + // We need to send the panic report to the main thread because JavaScript global variables + // are thread-local and this panic handler runs on the thread that panicked + if let Some(mut panic_tx) = panic_tx.try_lock() { + if let Some(panic_tx) = panic_tx.take() { + let _ = panic_tx.send(report); + } + } })); - let window = web_sys::window().expect("could not get `window` object (make sure you're running this in the main thread of a web browser)"); + let window = web_sys::window().expect("could not get `window` object"); let prefers_color_scheme_dark = window .match_media("(prefers-color-scheme: dark)") .unwrap() .map(|x| x.matches()); - let canvas = window + let document = window .document() - .expect("could not get `window.document` object (make sure you're running this in a web browser)") + .expect("could not get `window.document` object"); + let canvas = document + .create_element("canvas") + .expect("could not create canvas element") + .unchecked_into::(); + document .get_element_by_id(CANVAS_ID) .expect(format!("could not find HTML element with ID '{CANVAS_ID}'").as_str()) - .unchecked_into::(); + .replace_children_with_node_1(&canvas); let offscreen_canvas = canvas .transfer_control_to_offscreen() .expect("could not transfer canvas control to offscreen"); @@ -341,7 +462,10 @@ pub fn luminol_main_start() { tracing_log::LogTracer::init().expect("failed to initialize tracing-log"); if !luminol_web::bindings::cross_origin_isolated() { - tracing::error!("Luminol requires Cross-Origin Isolation to be enabled in order to run."); + tracing::error!( + "Cross-Origin Isolation is not enabled. Reloading page to attempt to enable it." + ); + window.location().reload().expect("failed to reload page"); return; } @@ -349,21 +473,24 @@ pub fn luminol_main_start() { let (runner_worker_channels, runner_main_channels) = luminol_eframe::web::channels(); luminol_filesystem::host::setup_main_thread_hooks(fs_main_channels); - luminol_eframe::WebRunner::setup_main_thread_hooks(luminol_eframe::web::MainState { - inner: Default::default(), - canvas: canvas.clone(), - channels: runner_main_channels, - }) - .expect("unable to setup web runner main thread hooks"); + let runner_panic_tx = + luminol_eframe::WebRunner::setup_main_thread_hooks(luminol_eframe::web::MainState { + inner: Default::default(), + canvas: canvas.clone(), + channels: runner_main_channels, + }) + .expect("unable to setup web runner main thread hooks"); let modified = luminol_core::ModifiedState::default(); *WORKER_DATA.lock() = Some(WorkerData { + report, audio: luminol_audio::Audio::default().into(), modified: modified.clone(), prefers_color_scheme_dark, fs_worker_channels, runner_worker_channels, + runner_panic_tx, }); // Show confirmation dialogue if the user tries to close the browser tab while there are @@ -380,7 +507,7 @@ pub fn luminol_main_start() { window .add_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()) .expect("failed to add beforeunload listener"); - closure.forget(); + *before_unload_cell.borrow_mut() = Some(closure); } let mut worker_options = web_sys::WorkerOptions::new(); @@ -388,6 +515,7 @@ pub fn luminol_main_start() { worker_options.type_(web_sys::WorkerType::Module); let worker = web_sys::Worker::new_with_options("./worker.js", &worker_options) .expect("failed to spawn web worker"); + worker_cell.set(worker.clone()).unwrap(); let message = js_sys::Array::new(); message.push(&wasm_bindgen::memory()); @@ -403,22 +531,24 @@ pub fn luminol_main_start() { #[wasm_bindgen] pub async fn luminol_worker_start(canvas: web_sys::OffscreenCanvas) { let WorkerData { + report, audio, modified, prefers_color_scheme_dark, fs_worker_channels, runner_worker_channels, + runner_panic_tx, } = WORKER_DATA.lock().take().unwrap(); luminol_filesystem::host::FileSystem::setup_worker_channels(fs_worker_channels); let web_options = luminol_eframe::WebOptions::default(); - luminol_eframe::WebRunner::new() + luminol_eframe::WebRunner::new(runner_panic_tx) .start( canvas, web_options, - Box::new(|cc| Box::new(app::App::new(cc, modified, audio))), + Box::new(|cc| Box::new(app::App::new(cc, report, modified, audio))), luminol_eframe::web::WorkerOptions { prefers_color_scheme_dark, channels: runner_worker_channels,