From e8c340c59d4e518edec02e088182e95882e5a204 Mon Sep 17 00:00:00 2001 From: Jan Szumiec Date: Thu, 15 Oct 2026 11:34:57 -0400 Subject: [PATCH] Web: installable as an app, with Install in the File menu (#1233) The web build ships a manifest so the browser can install it as an app, and offers Install in the File menu while the browser allows it. --- apps/solidcraft-web/Cargo.toml | 2 +- apps/solidcraft-web/index.html | 13 ++ apps/solidcraft-web/manifest.webmanifest | 16 ++ apps/solidcraft-web/src/install.rs | 67 +++++++ apps/solidcraft-web/src/main.rs | 32 +++- apps/solidcraft/src/main.rs | 2 + assets/README.md | 2 +- assets/locales/de.json | 12 ++ assets/locales/en.json | 12 ++ assets/locales/fr.json | 12 ++ assets/locales/ja.json | 12 ++ assets/locales/ko.json | 12 ++ assets/locales/ru.json | 12 ++ assets/locales/uk.json | 12 ++ assets/locales/zh.json | 12 ++ crates/ui-egui/src/colour_tokens_tests.rs | 1 - crates/ui-egui/src/install.rs | 210 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/toolbar.rs | 47 ++++- crates/ui-egui/src/update.rs | 32 +--- docs/book/src/guide/getting-started.md | 17 +- xtask/src/web_offline_test.rs | 32 +++- 22 files changed, 531 insertions(+), 45 deletions(-) create mode 100644 apps/solidcraft-web/manifest.webmanifest create mode 100644 apps/solidcraft-web/src/install.rs create mode 100644 crates/ui-egui/src/install.rs diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index db51d3dc..db27ddaf 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", - "BeforeUnloadEvent", "Event", "EventTarget", "Response", + "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", ] } diff --git a/apps/solidcraft-web/index.html b/apps/solidcraft-web/index.html index 1a671690..aa66df9a 100644 --- a/apps/solidcraft-web/index.html +++ b/apps/solidcraft-web/index.html @@ -23,6 +23,15 @@ + + + + + + + + + @@ -32,6 +41,10 @@ if ("serviceWorker" in navigator) { addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {})); } + // Install as an app: the browser's offer can come before the app runs, so it is kept here for + // the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers. + addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; }); + addEventListener("appinstalled", () => { window.solidcraftInstall = null; }); diff --git a/apps/solidcraft-web/manifest.webmanifest b/apps/solidcraft-web/manifest.webmanifest new file mode 100644 index 00000000..54b56d59 --- /dev/null +++ b/apps/solidcraft-web/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "SolidCraft", + "short_name": "SolidCraft", + "description": "Open-source parametric CAD built in Rust.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#e8ebf0", + "theme_color": "#343840", + "categories": ["productivity", "utilities"], + "icons": [ + { "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" }, + { "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" }, + { "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/solidcraft-web/src/install.rs b/apps/solidcraft-web/src/install.rs new file mode 100644 index 00000000..e8ae0bd9 --- /dev/null +++ b/apps/solidcraft-web/src/install.rs @@ -0,0 +1,67 @@ +//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a +//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event +//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for +//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it +//! gets its steps, unless the app is already running installed. + +use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps}; +use wasm_bindgen::{JsCast as _, JsValue}; + +/// Where index.html keeps the browser's offer. +const OFFER: &str = "solidcraftInstall"; + +/// The browser's offer, kept by index.html (None: none, or it was used). +fn offer() -> Option { + let window = web_sys::window()?; + js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok() +} + +/// Show the browser's install prompt (from the File menu click, so the page may). +fn prompt() { + let Some(offer) = offer() else { return }; + if let Some(window) = web_sys::window() { + let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL); + } + let shown = js_sys::Reflect::get(&offer, &"prompt".into()) + .and_then(|f| f.dyn_into::().map_err(JsValue::from)) + .and_then(|f| f.call0(&offer)) + .and_then(|p| p.dyn_into::().map_err(JsValue::from)); + match shown { + // Accepted or dismissed, the browser handles it; a refusal is only logged. + Ok(p) => wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await { + log::warn!("install prompt: {e:?}"); + } + }), + Err(e) => log::warn!("install prompt: {e:?}"), + } +} + +/// The page runs as the installed app (its own window, or from the Home Screen on iOS). +fn running_installed(window: &web_sys::Window) -> bool { + let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches()); + let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false); + standalone || home_screen +} + +/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser +/// makes or withdraws its offer (even with nothing else happening). +pub fn service(ctx: &egui::Context) -> InstallApp { + // Safari's steps, decided once: the browser and the way the page runs don't change. + let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| { + let nav = w.navigator(); + let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default(); + safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points()) + }); + if let Some(window) = web_sys::window() { + for event in ["beforeinstallprompt", "appinstalled"] { + let ctx = ctx.clone(); + let redraw = wasm_bindgen::closure::Closure::::new(move || ctx.request_repaint()); + let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref()); + // The listener lives as long as the page. + redraw.forget(); + } + } + let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) }; + InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) } +} diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index ac6fd240..f3a788de 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -2,12 +2,14 @@ //! //! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner //! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this -//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the -//! sample part. +//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until +//! `?webgpu`), `?sample` opens the sample part. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #[cfg(target_arch = "wasm32")] mod files; +#[cfg(target_arch = "wasm32")] +mod install; #[cfg(target_arch = "wasm32")] mod web { @@ -398,6 +400,29 @@ mod web { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } + /// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which + /// has no query, so the choice has to outlive the page it was made on. + const WEBGL_KEY: &str = "solidcraft.webgl"; + + /// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`). + fn webgl() -> bool { + let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten()); + let q = query(); + if q.contains("webgl") { + if let Some(s) = &storage { + let _ = s.set_item(WEBGL_KEY, "1"); + } + return true; + } + if q.contains("webgpu") { + if let Some(s) = &storage { + let _ = s.remove_item(WEBGL_KEY); + } + return false; + } + storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some() + } + /// Fetch font bytes asynchronously through the browser. async fn fetch_font_bytes(url: &str) -> Result, String> { let window = web_sys::window().ok_or("no window")?; @@ -422,7 +447,7 @@ mod web { return; }; let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() }; - if query().contains("webgl") + if webgl() && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; @@ -447,6 +472,7 @@ mod web { let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT); Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS })))) })), + install_app: Some(crate::install::service(&cc.egui_ctx)), ..Default::default() }; let mut app = SolveApp::new(Session::default(), services); diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 21fa6e24..18b0a285 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -157,6 +157,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services { graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()), http_get: Some(std::sync::Arc::new(http_get)), http_download: Some(std::sync::Arc::new(http_download)), + // Already an installed app: only the browser build offers to install. + install_app: None, show_in_folder: Some(Box::new(|path| { let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new(".")); let mut command = std::process::Command::new(if cfg!(target_os = "windows") { diff --git a/assets/README.md b/assets/README.md index 8d81e20e..2bfafd71 100644 --- a/assets/README.md +++ b/assets/README.md @@ -75,7 +75,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`. | Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` | | AppImage | Uses the Linux 256 px image and `.DirIcon` alias | | FreeBSD | Shares Linux hicolor images and freedesktop metadata | -| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists | +| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app | | Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path | ## Code and provenance diff --git a/assets/locales/de.json b/assets/locales/de.json index 2de4f56b..45a7fa3d 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -804,6 +804,18 @@ "Profiles/Curves": "Profile/Kurven", "update.button": "Update", "update.button_tip": "SolidCraft {version} ist verfügbar", + "install.menu": "App installieren…", + "install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline", + "install.title": "SolidCraft installieren", + "install.steps_intro": "Safari installiert Web-Apps über seine eigenen Menüs:", + "install.home_screen_1": "Tippen Sie auf „Teilen“ (im Menü …, falls es nicht in der Symbolleiste ist).", + "install.home_screen_2": "Wählen Sie „Zum Home-Bildschirm“.", + "install.home_screen_3": "Tippen Sie auf „Hinzufügen“.", + "install.home_screen_after": "SolidCraft öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.", + "install.dock_1": "Wählen Sie Ablage › Zum Dock hinzufügen.", + "install.dock_2": "Klicken Sie auf „Hinzufügen“.", + "install.dock_after": "SolidCraft öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.", + "install.done": "Fertig", "update.title": "Update verfügbar", "update.available": "SolidCraft {version} ist verfügbar", "update.you_have": "Installiert ist {version}.", diff --git a/assets/locales/en.json b/assets/locales/en.json index f2171842..bebf39f4 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1062,6 +1062,18 @@ "cmd.material_properties_directional": "Directional Elastic Modulus", "update.button": "Update", "update.button_tip": "SolidCraft {version} is available", + "install.menu": "Install app…", + "install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline", + "install.title": "Install SolidCraft", + "install.steps_intro": "Safari installs web apps from its own menus:", + "install.home_screen_1": "Tap Share (in the … menu if it isn't on the toolbar).", + "install.home_screen_2": "Choose Add to Home Screen.", + "install.home_screen_3": "Tap Add.", + "install.home_screen_after": "SolidCraft then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.", + "install.dock_1": "Choose File › Add to Dock.", + "install.dock_2": "Click Add.", + "install.dock_after": "SolidCraft then opens in its own window from the Dock, and works offline.", + "install.done": "Done", "update.title": "Update available", "update.available": "SolidCraft {version} is available", "update.you_have": "You have {version}.", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 2c6fcad3..fbc04e66 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1062,6 +1062,18 @@ "cmd.material_properties_directional": "Module d'élasticité directionnel", "update.button": "Mettre à jour", "update.button_tip": "SolidCraft {version} est disponible", + "install.menu": "Installer l'app…", + "install.button_tip": "Installer SolidCraft comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne", + "install.title": "Installer SolidCraft", + "install.steps_intro": "Safari installe les apps web depuis ses propres menus :", + "install.home_screen_1": "Touchez Partager (dans le menu … s'il n'est pas dans la barre d'outils).", + "install.home_screen_2": "Choisissez Sur l'écran d'accueil.", + "install.home_screen_3": "Touchez Ajouter.", + "install.home_screen_after": "SolidCraft s'ouvre ensuite depuis son icône sur l'écran d'accueil, sans les barres de Safari, et fonctionne hors ligne.", + "install.dock_1": "Choisissez Fichier › Ajouter au Dock.", + "install.dock_2": "Cliquez sur Ajouter.", + "install.dock_after": "SolidCraft s'ouvre ensuite dans sa propre fenêtre depuis le Dock, et fonctionne hors ligne.", + "install.done": "Terminé", "update.title": "Mise à jour disponible", "update.available": "SolidCraft {version} est disponible", "update.you_have": "Vous avez la version {version}.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index de09e9ab..a7ec8fb4 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1062,6 +1062,18 @@ "cmd.material_properties_directional": "方向別弾性率", "update.button": "アップデート", "update.button_tip": "SolidCraft {version} が利用できます", + "install.menu": "アプリをインストール…", + "install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます", + "install.title": "SolidCraft をインストール", + "install.steps_intro": "Safari では、Web アプリを Safari 自身のメニューからインストールします:", + "install.home_screen_1": "「共有」をタップします(ツールバーにない場合は … メニューの中にあります)。", + "install.home_screen_2": "「ホーム画面に追加」を選びます。", + "install.home_screen_3": "「追加」をタップします。", + "install.home_screen_after": "SolidCraft はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。", + "install.dock_1": "「ファイル」›「Dockに追加」を選びます。", + "install.dock_2": "「追加」をクリックします。", + "install.dock_after": "SolidCraft は Dock から専用のウィンドウで開き、オフラインでも使えます。", + "install.done": "完了", "update.title": "アップデートがあります", "update.available": "SolidCraft {version} が利用できます", "update.you_have": "現在のバージョンは {version} です。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 8c75e363..9b29f58c 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -804,6 +804,18 @@ "Profiles/Curves": "프로파일/곡선", "update.button": "업데이트", "update.button_tip": "SolidCraft {version} 사용 가능", + "install.menu": "앱 설치…", + "install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다", + "install.title": "SolidCraft 설치", + "install.steps_intro": "Safari는 자체 메뉴에서 웹 앱을 설치합니다:", + "install.home_screen_1": "공유를 탭합니다(도구 막대에 없으면 … 메뉴 안에 있습니다).", + "install.home_screen_2": "홈 화면에 추가를 선택합니다.", + "install.home_screen_3": "추가를 탭합니다.", + "install.home_screen_after": "이후 SolidCraft는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.", + "install.dock_1": "파일 › Dock에 추가를 선택합니다.", + "install.dock_2": "추가를 클릭합니다.", + "install.dock_after": "이후 SolidCraft는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.", + "install.done": "완료", "update.title": "업데이트 사용 가능", "update.available": "SolidCraft {version} 사용 가능", "update.you_have": "현재 버전은 {version}입니다.", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 46a89c8a..1e6ad545 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -971,6 +971,18 @@ "ui.removeFromToolbar": "Убрать с панели", "ui.repeat": "Повторить", "ui.report_a_bug": "Сообщить об ошибке", + "install.menu": "Установить приложение…", + "install.button_tip": "Установить SolidCraft как приложение: отдельное окно и работа без сети", + "install.title": "Установка SolidCraft", + "install.steps_intro": "Safari устанавливает веб-приложения через собственные меню:", + "install.home_screen_1": "Нажмите «Поделиться» (в меню …, если кнопки нет на панели).", + "install.home_screen_2": "Выберите «На экран „Домой“».", + "install.home_screen_3": "Нажмите «Добавить».", + "install.home_screen_after": "После этого SolidCraft открывается значком на экране «Домой», без панелей Safari, и работает без сети.", + "install.dock_1": "Выберите «Файл» › «Добавить в Dock».", + "install.dock_2": "Нажмите «Добавить».", + "install.dock_after": "После этого SolidCraft открывается из Dock в отдельном окне и работает без сети.", + "install.done": "Готово", "ui.restore": "Восстановить", "ui.heading_setup": "НАСТРОЙКА", "ui.selectOther": "Выбрать другое…", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index da37bd53..df4694b4 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -988,6 +988,18 @@ "cmd.material_properties_directional": "Спрямований модуль пружності", "update.button": "Оновити", "update.button_tip": "Доступна версія SolidCraft {version}", + "install.menu": "Встановити застосунок…", + "install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі", + "install.title": "Встановлення SolidCraft", + "install.steps_intro": "Safari встановлює вебзастосунки через власні меню:", + "install.home_screen_1": "Торкніться «Поширити» (у меню …, якщо цієї кнопки немає на панелі).", + "install.home_screen_2": "Виберіть «На початковий екран».", + "install.home_screen_3": "Торкніться «Додати».", + "install.home_screen_after": "Після цього SolidCraft відкривається значком на початковому екрані, без панелей Safari, і працює без мережі.", + "install.dock_1": "Виберіть «Файл» › «Додати в Dock».", + "install.dock_2": "Натисніть «Додати».", + "install.dock_after": "Після цього SolidCraft відкривається з Dock в окремому вікні й працює без мережі.", + "install.done": "Готово", "update.title": "Доступне оновлення", "update.available": "Доступна версія SolidCraft {version}", "update.you_have": "У вас версія {version}.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 39acf92a..5df4f233 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1062,6 +1062,18 @@ "cmd.material_properties_directional": "方向弹性模量", "update.button": "更新", "update.button_tip": "SolidCraft {version} 已发布", + "install.menu": "安装应用…", + "install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用", + "install.title": "安装 SolidCraft", + "install.steps_intro": "Safari 通过自身的菜单安装网页应用:", + "install.home_screen_1": "轻点“共享”(若不在工具栏上,则在 … 菜单中)。", + "install.home_screen_2": "选择“添加到主屏幕”。", + "install.home_screen_3": "轻点“添加”。", + "install.home_screen_after": "之后 SolidCraft 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。", + "install.dock_1": "选择“文件”›“添加到程序坞”。", + "install.dock_2": "点按“添加”。", + "install.dock_after": "之后 SolidCraft 从程序坞在独立窗口中打开,离线也能使用。", + "install.done": "完成", "update.title": "有可用更新", "update.available": "SolidCraft {version} 已发布", "update.you_have": "当前版本为 {version}。", diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs index 57915bee..e4fbc6df 100644 --- a/crates/ui-egui/src/colour_tokens_tests.rs +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -177,7 +177,6 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[ ("sketch_text.rs", 1, "white text on the selection chip"), ("titlebar.rs", 4, "the window close button's red hover (the system's convention), white hovers and ink"), ("toolbar.rs", 3, "white hover overlays and the version text"), - ("update.rs", 1, "text laid out only to measure its width, never drawn"), ]; const COLOUR_LITERALS: &[&str] = &[ diff --git a/crates/ui-egui/src/install.rs b/crates/ui-egui/src/install.rs new file mode 100644 index 00000000..5314e3d1 --- /dev/null +++ b/crates/ui-egui/src/install.rs @@ -0,0 +1,210 @@ +//! Install as an app (the browser build). A browser that can install the web app (a PWA: its own +//! window and launcher icon, working offline) offers it to the page; while it does, the File menu +//! has Install app…, which shows the browser's own install prompt. An installed app, or the app +//! running installed, gets no offer, so the item is gone. The host keeps the offer +//! (`Services::install_app`); desktop builds have none. +//! +//! Safari installs web apps too (Add to Home Screen on iPhone and iPad, Add to Dock on a Mac) but +//! offers no prompt a page can show. There the item opens a window with the steps. + +use egui::RichText; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// How the browser installs the app. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Offer { + /// Its own install prompt (`InstallApp::prompt`). + Prompt, + /// No prompt a page can show: SolidCraft shows these steps (Safari). + Steps(Steps), +} + +/// Safari's steps for installing a web app. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Steps { + /// iPhone and iPad: Share ▸ Add to Home Screen. + HomeScreen, + /// A Mac (Safari 17 or later): File ▸ Add to Dock. + Dock, +} + +impl Steps { + /// The steps' message ids, in order, and the one that says what follows. + fn messages(self) -> (&'static [&'static str], &'static str) { + match self { + Steps::HomeScreen => (&["install.home_screen_1", "install.home_screen_2", "install.home_screen_3"], "install.home_screen_after"), + Steps::Dock => (&["install.dock_1", "install.dock_2"], "install.dock_after"), + } + } +} + +/// The browser's offer to install the app, kept by the host. +pub struct InstallApp { + /// What the browser offers now; None: nothing (installed, or it can't install). + pub offer: Box Option>, + /// Show the browser's install prompt (`Offer::Prompt`). Browsers only show it right after a + /// click. + pub prompt: Box, +} + +/// Safari's steps for this browser, from its `navigator.userAgent`, `navigator.vendor` and +/// `navigator.maxTouchPoints`; None: not Safari, or a Safari that can't install web apps. +/// Other iOS browsers are WebKit too (vendor Apple) but have no `Version/` and name themselves. +pub fn safari_steps(user_agent: &str, vendor: &str, touch_points: i32) -> Option { + let other = ["CriOS/", "FxiOS/", "EdgiOS/", "OPiOS/", "OPT/"]; + let safari = vendor.starts_with("Apple") && user_agent.contains("Safari/") && !other.iter().any(|o| user_agent.contains(o)); + if !safari { + return None; + } + let version = user_agent.split("Version/").nth(1)?; + // iPadOS Safari presents itself as a Mac; only its touch screen tells them apart. + let mobile = ["iPhone", "iPad", "iPod"].iter().any(|d| user_agent.contains(d)); + if mobile || (user_agent.contains("Macintosh") && touch_points > 1) { + return Some(Steps::HomeScreen); + } + let major: u32 = version.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()?; + (user_agent.contains("Macintosh") && major >= 17).then_some(Steps::Dock) +} + +/// What the browser offers now (None: nothing, so File has no Install app… item). +pub fn offered(app: &SolveApp) -> Option { + app.services.install_app.as_ref().and_then(|i| (i.offer)()) +} + +/// File ▸ Install app…: the browser's own prompt (browsers only show it right after a click), or +/// Safari's steps. +pub fn start(app: &mut SolveApp, offer: Offer) { + match offer { + Offer::Prompt => { + if let Some(install) = &app.services.install_app { + (install.prompt)(); + } + } + Offer::Steps(steps) => app.install_steps = Some(steps), + } +} + +/// The window with Safari's steps, while open. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(steps) = app.install_steps else { return }; + let t = Tokens::get(); + let (lines, after) = steps.messages(); + let mut open = true; + let mut done = false; + crate::frame::window(ctx, crate::i18n::tr("install.title"), crate::frame::Width::Normal) + .id(egui::Id::new("sc_install_steps")) + .open(&mut open) + .resizable(false) + .show(ctx, |ui| { + ui.label(crate::i18n::tr("install.steps_intro")); + ui.add_space(4.0); + for (i, id) in lines.iter().enumerate() { + ui.label(format!("{}. {}", i + 1, crate::i18n::tr(id))); + } + ui.add_space(4.0); + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(after)).color(t.text_dim)).wrap()); + ui.add_space(6.0); + if ui.button(crate::i18n::tr("install.done")).clicked() { + done = true; + } + }); + if !open || done { + app.install_steps = None; + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use serde_json::json; + + use super::*; + use crate::scenario::{Harness, handle_at}; + + const ITEM: &str = "install.menu"; + + fn host(h: &mut Harness, offer: Rc>>, prompts: Rc>) { + h.app.services.install_app = + Some(InstallApp { offer: Box::new(move || offer.get()), prompt: Box::new(move || prompts.set(prompts.get() + 1)) }); + } + + /// Open the File menu and look for Install app…. + fn file_menu_item(h: &mut Harness) -> bool { + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true)); + h.frames(2); + h.widget(crate::i18n::tr(ITEM)).is_some() + } + + #[test] + fn install_is_in_the_file_menu_only_while_offered_and_prompts_on_click() { + let offer = Rc::new(Cell::new(None)); + let prompts = Rc::new(Cell::new(0)); + let mut h = Harness::new(); + host(&mut h, offer.clone(), prompts.clone()); + assert!(!file_menu_item(&mut h), "no offer, no menu item"); + + offer.set(Some(Offer::Prompt)); + assert!(file_menu_item(&mut h), "offered: File has Install app…"); + assert_eq!(h.step(&json!({ "widget": crate::i18n::tr(ITEM) })), Ok(())); + assert_eq!(prompts.get(), 1, "a click shows the browser's prompt"); + assert_eq!(h.app.install_steps, None, "no steps window for a browser with a prompt"); + + offer.set(None); + assert!(!file_menu_item(&mut h), "installed: the item is gone"); + } + + #[test] + fn install_is_not_in_the_application_bar() { + let mut h = Harness::new(); + host(&mut h, Rc::new(Cell::new(Some(Offer::Prompt))), Rc::new(Cell::new(0))); + h.frames(2); + assert!(handle_at("titlebar:install").is_none()); + assert!(h.widget(crate::i18n::tr(ITEM)).is_none(), "only in the File menu"); + } + + #[test] + fn safari_shows_its_steps() { + let prompts = Rc::new(Cell::new(0)); + let mut h = Harness::new(); + host(&mut h, Rc::new(Cell::new(Some(Offer::Steps(Steps::Dock)))), prompts.clone()); + assert!(file_menu_item(&mut h)); + assert_eq!(h.step(&json!({ "widget": crate::i18n::tr(ITEM) })), Ok(())); + assert_eq!(h.app.install_steps, Some(Steps::Dock), "a click opens the steps"); + assert_eq!(prompts.get(), 0, "Safari has no prompt"); + h.frames(2); + assert_eq!(h.step(&json!({ "widget": crate::i18n::tr("install.done") })), Ok(())); + assert_eq!(h.app.install_steps, None, "Done closes the steps"); + } + + #[test] + fn no_install_item_without_the_host_service() { + let mut h = Harness::new(); + assert!(!file_menu_item(&mut h)); + } + + #[test] + fn safari_steps_by_device() { + const APPLE: &str = "Apple Computer, Inc."; + let iphone = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"; + let mac = + |v: &str| format!("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{v} Safari/605.1.15"); + assert_eq!(safari_steps(iphone, APPLE, 5), Some(Steps::HomeScreen)); + assert_eq!(safari_steps(&mac("18.5"), APPLE, 5), Some(Steps::HomeScreen), "an iPad presents itself as a Mac"); + assert_eq!(safari_steps(&mac("18.5"), APPLE, 0), Some(Steps::Dock)); + assert_eq!(safari_steps(&mac("17.0"), APPLE, 0), Some(Steps::Dock)); + assert_eq!(safari_steps(&mac("16.6"), APPLE, 0), None, "Add to Dock came with Safari 17"); + let chrome_ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.156 Mobile/15E148 Safari/604.1"; + assert_eq!(safari_steps(chrome_ios, APPLE, 5), None, "Chrome on iOS isn't Safari"); + let chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"; + assert_eq!(safari_steps(chrome, "Google Inc.", 0), None, "Chrome prompts instead"); + let firefox = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0"; + assert_eq!(safari_steps(firefox, "", 0), None); + assert_eq!(safari_steps("", APPLE, 0), None, "hostile input is just not Safari"); + assert_eq!(safari_steps("Safari/ Version/99999999999999999999 Macintosh", APPLE, 0), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea96f0a6..d79edd06 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,6 +55,7 @@ mod i18n_menu_tests; mod i18n_ui_tests; pub mod icons; pub mod inference; +pub mod install; pub mod issue_report; pub mod keymap; #[cfg(test)] @@ -244,6 +245,8 @@ pub struct Services { pub http_get: Option, /// HTTPS download to a file, with progress, for one-click updates (`update::install`). pub http_download: Option, + /// The browser's offer to install the web app (`install`); None: no Install button (desktop). + pub install_app: Option, } pub struct SolveApp { @@ -286,6 +289,8 @@ pub struct SolveApp { pub crashes: crash_ui::CrashUi, /// The update check and its window (`update`). pub updater: update::Updater, + /// Safari's steps for installing the web app, while their window is open (`install`). + pub install_steps: Option, /// Toolbar buttons the user pinned, removed or reordered. pub toolbar_custom: toolbar_custom::ToolbarCustom, pub sketch_choices: sketch_families::Choices, @@ -394,6 +399,7 @@ impl SolveApp { issue_report: issue_report::ReportState::default(), crashes: crash_ui::CrashUi::default(), updater: update::Updater::default(), + install_steps: None, toolbar_custom: toolbar_custom::ToolbarCustom::default(), sketch_choices: sketch_families::Choices::default(), preferences: prefs::Prefs::default(), @@ -1261,6 +1267,7 @@ impl SolveApp { file_load::show(self, ui.ctx()); documents::prompt(self, ui.ctx()); update::show(self, ui.ctx()); + install::show(self, ui.ctx()); if self.home.open { home::show(self, ui); if self.home.open { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c5110e4c..289ff5b9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -166,6 +166,34 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32) -> ( (Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled) } +/// An application-bar button for something on offer (Update, Install) at the right of `span` (the +/// bar's free space): an accent-framed `icon`, labeled with `text` when `labeled` and it fits. +/// None when even the icon alone doesn't fit: it never covers the bar's other buttons. `handle` +/// names it for scenarios. +pub(crate) fn offer_button(ui: &mut egui::Ui, span: Rect, labeled: bool, text: &str, icon: &str, handle: &str) -> Option<(Rect, egui::Response)> { + let t = Tokens::get(); + let full = 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), t.app_bar_text).size().x) + 10.0; + let labeled = labeled && full <= span.width(); + let width = if labeled { full } else { 28.0 }; + if width > span.width() { + return None; + } + let rect = Rect::from_min_size(pos2(span.right() - width, span.top()), vec2(width, 26.0)); + let response = ui.interact(rect, ui.id().with(("sc_offer_button", handle)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); + crate::scenario::publish_handle(handle, rect.center()); + crate::scenario::publish_rect(handle, rect); + let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; + ui.painter().rect_filled(rect, 4.0, fill); + ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + let glyph = Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), vec2(18.0, 18.0)); + icons::paint(ui.painter(), glyph, icon, t.accent, t.accent, t.app_bar); + if labeled { + ui.painter().text(pos2(rect.left() + 29.0, rect.center().y), Align2::LEFT_CENTER, text, FontId::proportional(13.0), t.app_bar_text); + } + Some((rect, response)) +} + /// Where the Report a bug text starts, from the button's left edge. const REPORT_TEXT_X: f32 = 29.0; @@ -206,6 +234,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, idle, &label)); crate::scenario::publish_handle("appbar:file", br.center()); crate::scenario::publish_handle("appbar:folder", br.center()); + crate::scenario::publish_rect("appbar:file", br); let open = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_file_menu")).unwrap_or(false)); if idle && (resp.hovered() || open) { ui.painter().rect_filled(br, 4.0, t.app_bar_hover); @@ -222,6 +251,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center()); + crate::scenario::publish_rect(&format!("appbar:{icon}"), br); if enabled && resp.hovered() { ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); } @@ -277,8 +307,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { d.insert_temp(egui::Id::new("sc_help_anchor"), at); }); } - // A newer release: its Update button, left of Report a bug (`update`). - let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label); + // A newer release: its Update button, left of Report a bug (`update`), in the space up to + // the bar's own buttons on the left (File, Undo and Redo, which end before `x`). + let span = Rect::from_min_max(pos2(x, report.top()), pos2(report.left() - 6.0, report.bottom())); + let update = crate::update::button(app, ui, span, report_label); let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0; // Narrow title bars can fit only part of the tab strip. Its hit regions must not // extend into the persistent report and window controls. @@ -289,6 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let response = ui.interact(report, ui.id().with("sc_report_button"), Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), report_text)); crate::scenario::publish_handle("titlebar:report", report.center()); + crate::scenario::publish_rect("titlebar:report", report); ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }); ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0)); @@ -433,6 +466,16 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { close = true; } ui.separator(); + // The browser can install SolidCraft as an app (`install`): only while it offers to. + if let Some(offer) = crate::install::offered(app) + && ui + .add(egui::Button::new(crate::i18n::tr("install.menu")).min_size(vec2(180.0, 22.0))) + .on_hover_text(crate::i18n::tr("install.button_tip")) + .clicked() + { + crate::install::start(app, offer); + close = true; + } if item(ui, &format!("{}…", app.tr("ui.preferences")), "") { app.prefs_window.open = true; close = true; diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 3f0c171a..b49c6003 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError}; -use egui::{Color32, FontId, RichText, text::LayoutJob}; +use egui::{FontId, RichText, text::LayoutJob}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -714,34 +714,10 @@ pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec)> { // ---- user interface --------------------------------------------------------------------------- -/// The application bar's Update button, right of `right`; returns its rect (None: not shown). -pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, right: f32, top: f32, labeled: bool) -> Option { +/// The application bar's Update button, at the right of `span`; returns its rect (None: not shown). +pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, span: egui::Rect, labeled: bool) -> Option { offered(app)?; - let t = Tokens::get(); - let text = crate::i18n::tr("update.button"); - let width = if labeled { - 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), Color32::WHITE).size().x) + 10.0 - } else { - 28.0 - }; - let rect = egui::Rect::from_min_size(egui::pos2(right - width, top), egui::vec2(width, 26.0)); - let response = ui.interact(rect, ui.id().with("sc_update_button"), egui::Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); - crate::scenario::publish_handle("titlebar:update", rect.center()); - let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; - ui.painter().rect_filled(rect, 4.0, fill); - ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), egui::vec2(18.0, 18.0)); - crate::icons::paint(ui.painter(), icon, "update", t.accent, t.accent, t.app_bar); - if labeled { - ui.painter().text( - egui::pos2(rect.left() + 29.0, rect.center().y), - egui::Align2::LEFT_CENTER, - text, - FontId::proportional(13.0), - t.app_bar_text, - ); - } + let (rect, response) = crate::toolbar::offer_button(ui, span, labeled, crate::i18n::tr("update.button"), "update", "titlebar:update")?; let version = app.updater.release.as_ref().map(|r| r.version().to_string()).unwrap_or_default(); if response.on_hover_text(crate::i18n::format("update.button_tip", &[("version", &version)])).clicked() { app.updater.open = !app.updater.open; diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index f550209c..fb842020 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -73,16 +73,25 @@ doesn't update automatically. ## Web The same app runs in the browser: [open SolidCraft on the web](../app/) (`?sample` opens the sample -part, `?webgl` forces WebGL2 where WebGPU is missing). Designs are saved in the browser's private -file storage, which needs HTTPS or localhost. Planned worker and tiered-storage integration is +part, `?webgl` forces WebGL2 where WebGPU is missing or misbehaves; the browser remembers it until +`?webgpu`, and an app installed from Chrome or Edge starts with it too). Designs are saved in the +browser's private file storage, which needs HTTPS or localhost. +Planned worker and tiered-storage integration is described in [web-runtime.md](https://github.com/bherbruck/solidcraft/blob/main/docs/web-runtime.md) (design; not yet wired in the shipped wasm build). After the first visit the web app works offline: the browser keeps a copy of it. Online, each launch gets the latest published build (the browser downloads it once when it changes); offline, when the network doesn't answer within a few seconds, or when a new build can't be downloaded -whole, the kept copy starts (and the download carries on for the next launch). To build it -yourself: +whole, the kept copy starts (and the download carries on for the next launch). + +Browsers that can install web apps add **Install app…** to the File menu: it installs SolidCraft +as an app with its own window and launcher icon, working offline like the page. In Chrome, Edge +and other Chromium browsers it shows the browser's install prompt, and once SolidCraft is +installed the item is gone. Safari has no such prompt, so the item shows its steps instead: +Share › Add to Home Screen on iPhone and iPad, File › Add to Dock on a Mac (Safari 17 or later). + +To build it yourself: ```sh rustup target add wasm32-unknown-unknown diff --git a/xtask/src/web_offline_test.rs b/xtask/src/web_offline_test.rs index 8b77b5fa..817d9753 100644 --- a/xtask/src/web_offline_test.rs +++ b/xtask/src/web_offline_test.rs @@ -48,6 +48,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -247,9 +248,15 @@ pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String // v5's worker installs, its files coming slowly; meanwhile v6 is deployed and launched. let update = "navigator.serviceWorker.getRegistration().then((r) => { r.update(); return true; })"; let name5 = kept.get(4).map(|(v, _)| cache_name(PREFIX, v)).unwrap_or_default(); + let asked = server.slow.load(Ordering::SeqCst); server.set(Mode::SlowFiles(v5)); cdp.eval(update)?; wait_for(&mut cdp, &state_js, "v5's worker starts downloading", |s| s["caches"].get(&name5).is_some())?; + // Chrome sends a server at most six requests at once: a build with more files has the rest + // wait for the first ones. v6 is deployed once all of v5's are asked for (any asked later + // would get v6's file, which rightly ends v5's download, but that isn't this step). + let files5 = kept.get(4).map_or(0, |(_, n)| *n); + server.wait_asked_slowly(asked + files5)?; server.set(Mode::Serve(v6)); let t = launch(&mut cdp, "v6")?; wait_for(&mut cdp, &state_js, "v5's download is finished, not dropped", |s| s["complete"].as_array().is_some_and(|c| c.contains(&json!(name5))))?; @@ -439,6 +446,8 @@ enum Mode { struct Server { port: u16, mode: Arc>, + /// How many files were asked for in Mode::SlowFiles. + slow: Arc, } impl Server { @@ -447,14 +456,26 @@ impl Server { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("server: {e}"))?; let port = listener.local_addr().map_err(|e| format!("server: {e}"))?.port(); let mode = Arc::new(Mutex::new(Mode::Refuse)); - let shared = mode.clone(); + let slow = Arc::new(AtomicUsize::new(0)); + let (shared, asked) = (mode.clone(), slow.clone()); std::thread::spawn(move || { for stream in listener.incoming().flatten() { - let (shared, elsewhere) = (shared.clone(), elsewhere.clone()); - std::thread::spawn(move || answer(stream, &shared, &elsewhere)); + let (shared, asked, elsewhere) = (shared.clone(), asked.clone(), elsewhere.clone()); + std::thread::spawn(move || answer(stream, &shared, &asked, &elsewhere)); } }); - Ok(Server { port, mode }) + Ok(Server { port, mode, slow }) + } + + /// Wait (at most 30 s) until `n` files in all were asked for in Mode::SlowFiles. + fn wait_asked_slowly(&self, n: usize) -> Result<(), String> { + for _ in 0..300 { + if self.slow.load(Ordering::SeqCst) >= n { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(format!("only {} of {n} slow files were asked for", self.slow.load(Ordering::SeqCst))) } fn set(&self, mode: Mode) { @@ -474,7 +495,7 @@ fn hold(shared: &Mutex, still: impl Fn(&Mode) -> bool) { } } -fn answer(mut stream: TcpStream, shared: &Mutex, elsewhere: &Path) { +fn answer(mut stream: TcpStream, shared: &Mutex, slow: &AtomicUsize, elsewhere: &Path) { let Ok(read) = stream.try_clone() else { return }; let mut reader = BufReader::new(read); let mut request = String::new(); @@ -506,6 +527,7 @@ fn answer(mut stream: TcpStream, shared: &Mutex, elsewhere: &Path) { Mode::SlowCheck(dir) => (dir, false, true), Mode::SlowFiles(dir) => { if !small { + slow.fetch_add(1, Ordering::SeqCst); std::thread::sleep(SLOW_FILE); } (dir, false, false)