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

@@ -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;