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.
This commit is contained in:
Jan Szumiec authored and GitHub committed 2026-10-15 11:34:57 -04:00
1 parent 20a7dd8764
commit e8c340c59d
22 files changed
+531 -45

No files matched your search

+1 -1
View File
@@ -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",
] }
+13
View File
@@ -23,6 +23,15 @@
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
<meta name="theme-color" content="#343840">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
@@ -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; });
</script>
</body>
</html>
+16
View File
@@ -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" }
]
}
+67
View File
@@ -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<js_sys::Object> {
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::<js_sys::Function>().map_err(JsValue::from))
.and_then(|f| f.call0(&offer))
.and_then(|p| p.dyn_into::<js_sys::Promise>().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::<dyn FnMut()>::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) }
}
+29 -3
View File
@@ -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<Vec<u8>, 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);
+2
View File
@@ -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") {
+1 -1
View File
@@ -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
+12
View File
@@ -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}.",
+12
View File
@@ -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}.",
+12
View File
@@ -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}.",
+12
View File
@@ -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} です。",
+12
View File
@@ -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}입니다.",
+12
View File
@@ -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": "Выбрать другое…",
+12
View File
@@ -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}.",
+12
View File
@@ -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}。",
@@ -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] = &[
+210
View File
@@ -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<dyn Fn() -> Option<Offer>>,
/// Show the browser's install prompt (`Offer::Prompt`). Browsers only show it right after a
/// click.
pub prompt: Box<dyn Fn()>,
}
/// 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<Steps> {
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<Offer> {
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<Cell<Option<Offer>>>, prompts: Rc<Cell<u32>>) {
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);
}
}
+7
View File
@@ -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<update::HttpGet>,
/// HTTPS download to a file, with progress, for one-click updates (`update::install`).
pub http_download: Option<update::install::HttpDownload>,
/// The browser's offer to install the web app (`install`); None: no Install button (desktop).
pub install_app: Option<install::InstallApp>,
}
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<install::Steps>,
/// 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 {
+45 -2
View File
@@ -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::<bool>(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;
+4 -28
View File
@@ -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<String>)> {
// ---- 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<egui::Rect> {
/// 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<egui::Rect> {
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;
+13 -4
View File
@@ -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
+27 -5
View File
@@ -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<Mutex<Mode>>,
/// How many files were asked for in Mode::SlowFiles.
slow: Arc<AtomicUsize>,
}
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<Mode>, still: impl Fn(&Mode) -> bool) {
}
}
fn answer(mut stream: TcpStream, shared: &Mutex<Mode>, elsewhere: &Path) {
fn answer(mut stream: TcpStream, shared: &Mutex<Mode>, 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<Mode>, 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)