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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user