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:
1 parent
20a7dd8764
commit
e8c340c59d
22 files changed
+531
-45
No files matched your search
@@ -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",
|
||||
] }
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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} です。",
|
||||
|
||||
@@ -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}입니다.",
|
||||
|
||||
@@ -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": "Выбрать другое…",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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] = &[
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user