use super::Edits;
use crate::{CliResult, config, output::CliError, project::Project};
const LAYOUT: &str = "templates/layout.html";
const HEAD: &str = r##" <link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#cc7722">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<script src="/pwa.js" defer></script>
"##;
const MANIFEST: &str = r##"{
"name": "__NAME__",
"short_name": "__NAME__",
"description": "__NAME__",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#cc7722",
"icons": [
{ "src": "/icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any maskable" }
]
}
"##;
const SERVICE_WORKER: &str = r#"// Service worker of the app, generated by `ocre g pwa` and registered by
// pwa.js. It runs in the browser, not on Cloudflare: requests it answers
// from its cache cost no Worker request.
const CACHE = "offline-v1";
const OFFLINE_URLS = ["/"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(OFFLINE_URLS)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))),
);
self.clients.claim();
});
// Pages come from the network; when offline, the cached home page answers.
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(fetch(event.request).catch(() => caches.match("/")));
});
// Web push: a message `{ "title": "...", "options": { "body": "...", "data": { "path": "/..." } } }`.
self.addEventListener("push", (event) => {
const { title, options } = event.data ? event.data.json() : { title: "Update", options: {} };
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const path = (event.notification.data && event.notification.data.path) || "/";
event.waitUntil(self.clients.openWindow(path));
});
"#;
const REGISTER: &str = r#"// Registers the service worker (generated by `ocre g pwa`).
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
}
"#;
const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="#cc7722"/><text x="256" y="330" font-family="system-ui, sans-serif" font-size="240" font-weight="700" text-anchor="middle" fill="#ffffff">__INITIAL__</text></svg>
"##;
pub fn pwa(project: &Project) -> CliResult {
if project.api_only {
return Err(CliError::new("a PWA needs HTML pages; this app is API-only")
.hint("serve a manifest.webmanifest from the assets directory of the front end that uses this API"));
}
let mut edits = Edits::new(project);
let layout = edits.read(LAYOUT)?.ok_or_else(|| {
CliError::new(format!("{LAYOUT} not found")).hint("the manifest link goes in the layout's <head>: restore it")
})?;
if layout.contains("manifest.webmanifest") {
return Err(CliError::new(format!("{LAYOUT} already links a web app manifest"))
.hint("nothing to generate: edit manifest.webmanifest in the assets directory"));
}
let head = layout.find("</head>").ok_or_else(|| {
CliError::new(format!("{LAYOUT} has no </head>")).hint("add the <head> element back to the layout")
})?;
let config = project.config()?;
let name = config.name.as_deref().unwrap_or("app");
let initial = name.chars().next().unwrap_or('A').to_ascii_uppercase().to_string();
let assets = config::assets_directory(&project.root);
let assets = assets.as_deref().unwrap_or("public");
edits.create(&format!("{assets}/manifest.webmanifest"), MANIFEST.replace("__NAME__", name))?;
edits.create(&format!("{assets}/service-worker.js"), SERVICE_WORKER.to_owned())?;
edits.create(&format!("{assets}/pwa.js"), REGISTER.to_owned())?;
edits.create(&format!("{assets}/icon.svg"), ICON.replace("__INITIAL__", &initial))?;
edits.update(LAYOUT, format!("{}{HEAD}{}", &layout[..head], &layout[head..]));
let mut report = edits.apply("generate pwa")?;
report.next = vec![
format!("edit {assets}/manifest.webmanifest (name, colors) and replace {assets}/icon.svg"),
"ocre dev, then open the app: the browser offers to install it".to_owned(),
];
Ok(report)
}