diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs
index dd767116..f08ca90f 100644
--- a/apps/figcraft-web/src/web.rs
+++ b/apps/figcraft-web/src/web.rs
@@ -147,12 +147,10 @@ impl Services for WebServices {
}
}
-/// The largest file read from the picker (browsers read at most 4 GiB into one buffer).
-const MAX_PICKED_BYTES: f64 = u32::MAX as f64;
-
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
-/// name; `None` when the picker is cancelled.
-async fn pick_file(accept: &str) -> Result)>, wasm_bindgen::JsValue> {
+/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
+/// can't be read.
+async fn pick_file(accept: &str) -> Result ), String>>, wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
@@ -169,15 +167,22 @@ async fn pick_file(accept: &str) -> Result )>, wasm_bindg
drop(picker);
result?;
let Some(file) = file else { return Ok(None) };
- if file.size() > MAX_PICKED_BYTES {
- return Err(format!("{} is too large to read in the browser", file.name()).into());
+ let name = file.name();
+ if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
+ return Ok(Some(Err(why)));
}
- let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await?;
- Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec())))
+ Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
+ Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
+ Err(e) => {
+ log::error!("couldn't read {name}: {e:?}");
+ Err(figcraft_ui_egui::unreadable(&name))
+ }
+ }))
}
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
-/// picked file's `change` can come just after the focus, and must win.
+/// picked file's `change` can come just after the focus, and must win. A `change` later than this
+/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index 98051314..8ef21966 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -1629,6 +1629,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}"
"{name} is on the server." = "{name} je na serveru."
"{name} is spotlighting" = "{name} sdílí pohled"
+"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji."
"{name} (from {library})" = "{name} (z {library})"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index 07669c1a..2294e82d 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -1583,6 +1583,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}"
"{name} is on the server." = "{name} liegt auf dem Server."
"{name} is spotlighting" = "{name} hat das Spotlight"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)."
"{name} was removed from the server" = "{name} wurde vom Server entfernt"
"{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht"
"{name} · deleted on the branch" = "{name} · im Branch gelöscht"
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index 5d5b1856..9a7aba63 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -1668,6 +1668,7 @@ name = "English"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}"
"{name} is on the server." = "{name} is on the server."
"{name} is spotlighting" = "{name} is spotlighting"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it."
"{name} (from {library})" = "{name} (from {library})"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index 56b6d29c..b4c7c335 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -1669,6 +1669,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}"
"{name} is on the server." = "{name} está en el servidor."
"{name} is spotlighting" = "{name} está en el foco"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla."
"{name} (from {library})" = "{name} (de {library})"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
index 602574d7..2521e46c 100644
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -1676,6 +1676,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}."
"{name} is on the server." = "{name} on palvelimella."
"{name} is spotlighting" = "{name} nostaa näkymäänsä esiin"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)."
"{name} was removed from the server" = "{name} poistettiin palvelimelta"
"{name} · deleted on main" = "{name} · poistettu päähaarassa"
"{name} · deleted on the branch" = "{name} · poistettu haarassa"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index 5edf1a62..1faff4c3 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -1665,6 +1665,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}"
"{name} is on the server." = "{name} est sur le serveur."
"{name} is spotlighting" = "{name} est sous les projecteurs"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)."
"{name} was removed from the server" = "{name} a été retiré du serveur"
"{name} · deleted on main" = "{name} · supprimé dans le principal"
"{name} · deleted on the branch" = "{name} · supprimé dans la branche"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index 65a9939c..705484d8 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -1633,6 +1633,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}"
"{name} is on the server." = "{name} ada di server."
"{name} is spotlighting" = "{name} sedang spotlight"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya."
"{name} (from {library})" = "{name} (dari {library})"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index def8ebc4..069eb09a 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -1650,6 +1650,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る"
"{name} is on the server." = "{name} はサーバーにある。"
"{name} is spotlighting" = "{name} がスポットライト中"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。"
"{name} was removed from the server" = "{name} をサーバーから削除済み"
"{name} · deleted on main" = "{name} · メインで削除"
"{name} · deleted on the branch" = "{name} · ブランチで削除"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index 3e191623..e3d8bae8 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -1574,6 +1574,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음"
"{name} is on the server." = "{name}이(가) 서버에 있음."
"{name} is spotlighting" = "{name} 님이 스포트라이트 중"
+"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)."
"{name} was removed from the server" = "{name}을(를) 서버에서 제거함"
"{name} · deleted on main" = "{name} · 메인에서 삭제됨"
"{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨"
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
index 8d8cac68..7a8b41f4 100644
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -1673,6 +1673,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}"
"{name} is on the server." = "{name} staat op de server."
"{name} is spotlighting" = "{name} staat in de spotlight"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)."
"{name} was removed from the server" = "{name} is van de server verwijderd"
"{name} · deleted on main" = "{name} · verwijderd op main"
"{name} · deleted on the branch" = "{name} · verwijderd op de branch"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index d970db14..1fdbd1a3 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -1573,6 +1573,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}"
"{name} is on the server." = "{name} está no servidor."
"{name} is spotlighting" = "{name} está em destaque"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)."
"{name} was removed from the server" = "{name} foi removido do servidor"
"{name} · deleted on main" = "{name} · excluído no principal"
"{name} · deleted on the branch" = "{name} · excluído na branch"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index 8d371cbe..17e50e77 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -1635,6 +1635,7 @@ name = "Русский"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}"
"{name} is on the server." = "{name} находится на сервере."
"{name} is spotlighting" = "{name} показывает экран всем"
+"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)."
"{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его."
"{name} (from {library})" = "{name} (из {library})"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index d57bb25a..c57f2f6a 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -1650,6 +1650,7 @@ name = "Svenska"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}"
"{name} is on the server." = "{name} finns på servern."
"{name} is spotlighting" = "{name} har spotlight"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den."
"{name} (from {library})" = "{name} (från {library})"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index a9da62ef..83b63c8c 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -1633,6 +1633,7 @@ name = "Türkçe"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır"
"{name} is on the server." = "{name} sunucuda."
"{name} is spotlighting" = "{name} öne çıkarıyor"
+"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)."
"{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun."
"{name} (from {library})" = "{name} ({library} kitaplığından)"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index a90c1c03..57f2d44b 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -1568,6 +1568,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上"
"{name} is on the server." = "{name} 已在服务器上。"
"{name} is spotlighting" = "{name} 正在聚焦"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。"
"{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。"
"{name} (from {library})" = "{name}(来自 {library})"
diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs
index ad5dcfbc..f38ac824 100644
--- a/crates/ui-egui/src/async_dialog_tests.rs
+++ b/crates/ui-egui/src/async_dialog_tests.rs
@@ -87,10 +87,40 @@ impl Services for Contents {
panic!("a platform that picks contents is never asked for a path")
}
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option {
- Some(Box::pin(std::future::ready(Some((self.0.to_string(), self.1.clone())))))
+ Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone()))))))
}
}
+/// The browser, with a picked file it won't read.
+struct Unreadable(String);
+
+impl Services for Unreadable {
+ fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option {
+ Some(Box::pin(std::future::ready(Some(Err(self.0.clone())))))
+ }
+}
+
+#[test]
+fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() {
+ let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err();
+ let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone())));
+ app.open_dialog();
+ app.poll_dialog(&egui::Context::default());
+ assert!(app.session.docs.is_empty());
+ assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str()));
+ assert!(why.contains("Huge.fig"), "{why}");
+}
+
+#[test]
+fn the_browser_reads_files_up_to_512_mib() {
+ let max = (512u64 << 20) as f64;
+ assert!(crate::browser_can_read("a", 0.0).is_ok());
+ assert!(crate::browser_can_read("a", max).is_ok());
+ assert!(crate::browser_can_read("a", max + 1.0).is_err());
+ assert!(crate::browser_can_read("a", f64::NAN).is_err());
+ assert!(crate::browser_can_read("a", f64::INFINITY).is_err());
+}
+
#[test]
fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() {
let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap();
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ce003f66..fba8be68 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -510,7 +510,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
} else {
- (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f)))
+ (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name)))
};
let Some(target) =
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 0d372d1b..f945b27d 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -187,8 +187,11 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
}
// `opens` saw an extension, so there is a file name to tell the format by.
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
- let (params, read) =
- if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(&f))) };
+ let (params, read) = if path.exists() {
+ (json!({"path": path.to_string_lossy()}), None)
+ } else {
+ (json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
+ };
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
}
}
diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs
index ea812ea0..0606c2e5 100644
--- a/crates/ui-egui/src/home_tests.rs
+++ b/crates/ui-egui/src/home_tests.rs
@@ -573,9 +573,12 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() {
assert!(!path.exists(), "nothing is written");
}
-/// A drop whose contents arrive later, as every drop in the browser does: `None` until `arrived`
+/// What a drop's read gives, once it has arrived.
+type Arrival = std::rc::Rc, String>>>>;
+
+/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived`
/// holds them.
-fn later(arrived: &std::rc::Rc>>>>) -> crate::ReadFuture {
+fn later(arrived: &Arrival) -> crate::ReadFuture {
let arrived = arrived.clone();
Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() {
Some(bytes) => std::task::Poll::Ready(bytes),
@@ -603,7 +606,7 @@ fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() {
assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read");
}
assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive");
- *arrived.borrow_mut() = Some(Some(svg));
+ *arrived.borrow_mut() = Some(Ok(svg));
for _ in 0..3 {
u.frame(vec![]);
}
@@ -617,7 +620,7 @@ fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() {
#[test]
fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() {
- let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(None)));
+ let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft")))));
let mut u = first_run();
u.app.ui.home = true;
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 026228dc..4b44c861 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -295,32 +295,55 @@ pub trait Services {
}
pub type DialogFuture = Pin>>>;
-/// A picked file's name and contents ([`Services::pick_open_contents`]).
-pub type ContentsFuture = Pin)>>>>;
-/// A dropped file's contents, read once they arrive (`None`: they couldn't be read).
-pub type ReadFuture = Pin>>>>;
+/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker
+/// was cancelled, `Err` with what to tell the user when the file couldn't be read.
+pub type ContentsFuture = Pin), String>>>>>;
+/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they
+/// couldn't be read).
+pub type ReadFuture = Pin, String>>>>;
type PickFuture = Pin>>>;
type DialogDone = Box;
-/// What a dialog picked: a path, or a file's name and contents where there are no paths.
+/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why
+/// the picked file couldn't be read.
enum Picked {
Path(PathBuf),
Contents(String, Vec),
+ Unreadable(String),
}
impl Picked {
- /// The file as `file.open` and `file.import` take it.
- fn file_params(self) -> Value {
+ /// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead).
+ fn file_params(self) -> Result {
match self {
- Picked::Path(p) => json!({"path": p.to_string_lossy()}),
+ Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})),
Picked::Contents(name, bytes) => {
use base64::Engine as _;
- json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name})
+ Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}))
}
+ Picked::Unreadable(why) => Err(why),
}
}
}
+/// What the user is told about a file whose contents couldn't be read.
+pub fn unreadable(name: &str) -> String {
+ i18n::trf("Couldn't read {name}.", &[("name", &name)])
+}
+
+/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents
+/// are held about three times on their way to `file.open` (the bytes, base64, decoded again).
+/// As large as one member of a `.figcraft` file may be.
+const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64;
+
+/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user).
+pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> {
+ if size.is_nan() || size > MAX_BROWSER_FILE {
+ return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)]));
+ }
+ Ok(())
+}
+
/// A native dialog in flight. The app keeps working while it is open, so its result goes to the
/// file and window it was opened for, not to whichever is in front when it closes.
struct PendingDialog {
@@ -809,6 +832,11 @@ impl FigApp {
fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) {
let msg = e.to_string();
log::warn!("{id}: {msg}");
+ self.show_error(msg);
+ }
+
+ /// Show an error as a toast, kept as the last error.
+ pub(crate) fn show_error(&mut self, msg: String) {
self.toast(msg.clone());
self.ui.last_error = Some(msg);
}
@@ -1154,7 +1182,9 @@ impl FigApp {
self.start_pick(
|services| -> PickFuture {
match services.pick_open_contents(title, exts) {
- Some(read) => Box::pin(async move { read.await.map(|(name, bytes)| Picked::Contents(name, bytes)) }),
+ Some(read) => {
+ Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) })
+ }
None => {
let pick = services.pick_open(title, exts);
Box::pin(async move { pick.await.map(Picked::Path) })
@@ -1162,7 +1192,10 @@ impl FigApp {
}
},
pin,
- move |app, picked| done(app, picked.file_params()),
+ move |app, picked| match picked.file_params() {
+ Ok(file) => done(app, file),
+ Err(why) => app.show_error(why),
+ },
);
}
@@ -1751,17 +1784,22 @@ pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
-/// Reads the contents of a dropped file that has no file on disk to read by path.
+/// Reads the contents of dropped file `name` that has no file on disk to read by path.
#[cfg(not(target_arch = "wasm32"))]
-pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture {
- Box::pin(std::future::ready(f.bytes().ok()))
+pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
+ Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name))))
}
-/// Reads the contents of a dropped file; the browser hands them over asynchronously.
+/// Reads the contents of dropped file `name`; the browser hands them over asynchronously.
#[cfg(target_arch = "wasm32")]
-pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture {
- let f = f.clone();
- Box::pin(async move { f.bytes_async().await.ok() })
+pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
+ let (f, name) = (f.clone(), name.to_string());
+ Box::pin(async move {
+ if let Some(file) = f.web_file() {
+ browser_can_read(&name, file.size())?;
+ }
+ f.bytes_async().await.map_err(|_| unreadable(&name))
+ })
}
/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle,
diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs
index ef798eca..9575a1ea 100644
--- a/crates/ui-egui/src/pending_open.rs
+++ b/crates/ui-egui/src/pending_open.rs
@@ -72,8 +72,7 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option {
/// An import that has no destination left: nothing is imported, and the user is told why.
fn cancel(app: &mut FigApp, message: String) {
- app.ui.last_error = Some(message.clone());
- app.toast(message);
+ app.show_error(message);
}
/// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first
@@ -94,20 +93,20 @@ fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool {
reading = true;
true
}
- Poll::Ready(Some(bytes)) => {
+ Poll::Ready(Ok(bytes)) => {
use base64::Engine as _;
pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes));
pending.read = None;
true
}
- Poll::Ready(None) => {
- unread.push(pending.label.clone());
+ Poll::Ready(Err(why)) => {
+ unread.push(why);
false
}
}
});
- for name in unread {
- cancel(app, trf("Couldn't read {name}.", &[("name", &name)]));
+ for why in unread {
+ app.show_error(why);
}
reading
}