From 90b74f29d22067a212a40c6c47aac1e71b37d136 Mon Sep 17 00:00:00 2001 From: yozhgoor Date: Tue, 28 Apr 2026 03:58:13 +0200 Subject: [PATCH 1/4] Create the `pwa` module --- Cargo.toml | 3 + src/lib.rs | 7 ++ src/pwa.rs | 308 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 src/pwa.rs diff --git a/Cargo.toml b/Cargo.toml index 3c042de..a0fcfcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ include = ["src/**/*.rs", "README.md", "CHANGELOG.md", "LICENSE.Apache-2.0", "LI run-example = ["xtask-wasm-run-example", "console_error_panic_hook", "wasm-bindgen", "env_logger"] sass = ["sass-rs"] wasm-opt = ["binary-install", "xtask-wasm-run-example?/wasm-opt"] +pwa = ["serde", "serde_json"] [dependencies] xtask-wasm-run-example = { version = "0.6", path = "xtask-wasm-run-example", optional = true } @@ -33,6 +34,8 @@ fs_extra = "1.2.0" lazy_static = "1.4.0" log = "0.4.14" sass-rs = { version = "0.2.2", optional = true } +serde = { version = "1.0.228", optional = true } +serde_json = { version = "1.0.149", optional = true } walkdir = "2.3.2" wasm-bindgen-cli-support = "0.2.100" xtask-watch = "0.3.3" diff --git a/src/lib.rs b/src/lib.rs index 4ec8fad..6553b7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,8 @@ pub use xtask_watch::{ mod dev_server; #[cfg(not(target_arch = "wasm32"))] mod dist; +#[cfg(all(not(target_arch = "wasm32"), feature = "pwa"))] +mod pwa; #[cfg(all(not(target_arch = "wasm32"), feature = "sass"))] mod sass; #[cfg(all(not(target_arch = "wasm32"), feature = "wasm-opt"))] @@ -24,6 +26,7 @@ mod wasm_opt; pub use dev_server::*; #[cfg(not(target_arch = "wasm32"))] pub use dist::*; + #[cfg(all(not(target_arch = "wasm32"), feature = "sass"))] #[cfg_attr(docsrs, doc(cfg(feature = "sass")))] pub use sass::*; @@ -32,6 +35,10 @@ pub use sass::*; #[cfg_attr(docsrs, doc(cfg(feature = "wasm-opt")))] pub use wasm_opt::*; +#[cfg(all(not(target_arch = "wasm32"), feature = "pwa"))] +#[cfg_attr(docsrs, doc(cfg(feature = "pwa")))] +pub use pwa::*; + #[cfg(all(not(target_arch = "wasm32"), feature = "sass"))] #[cfg_attr(docsrs, doc(cfg(feature = "sass")))] pub use sass_rs; diff --git a/src/pwa.rs b/src/pwa.rs new file mode 100644 index 0000000..8da4b8a --- /dev/null +++ b/src/pwa.rs @@ -0,0 +1,308 @@ +use serde::Serialize; +use std::{ + fmt::Write, + fs, + path::{Path, PathBuf}, +}; +use xtask_watch::anyhow::{Context, Result}; + +/// Progressive Web App generation options for [`Dist`](crate::Dist). +/// +/// When enabled via [`Dist::pwa`](crate::Dist::pwa), missing PWA files from the `assets_directory` +/// are generated as fallback defaults: +/// +/// - `manifest.json` (if absent). +/// - `sw.js` (if absent). +/// +/// Existing user-provided assets always win. +#[non_exhaustive] +#[derive(Debug, Serialize)] +pub struct Pwa { + /// Full application name. + pub name: String, + /// Short application name. + pub short_name: String, + /// Application description. + pub description: String, + /// Start URL for installed launch. + pub start_url: String, + /// Navigation scope. + pub scope: String, + /// Display mode. + pub display: PwaDisplayMode, + /// Theme color. + pub theme_color: String, + /// Background color. + pub background_color: String, + /// Cache version token. + #[serde(skip)] + pub cache_version: String, + /// Icons + pub icons: Vec, +} + +impl Pwa { + /// Create a new Pwa instance. + pub fn new() -> Self { + Self::default() + } + + /// Provide the full application name. + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Provide the short application name. + pub fn short_name(mut self, short_name: impl Into) -> Self { + self.short_name = short_name.into(); + self + } + + /// Provide the description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Provide the start URL. + pub fn start_url(mut self, url: impl Into) -> Self { + self.start_url = url.into(); + self + } + + /// Provide the scope URL. + pub fn scope(mut self, url: impl Into) -> Self { + self.scope = url.into(); + self + } + + /// Provide the display mode. + pub fn display_mode(mut self, mode: PwaDisplayMode) -> Self { + self.display = mode; + self + } + + /// Provide the theme color. + pub fn theme_color(mut self, color: impl Into) -> Self { + self.theme_color = color.into(); + self + } + + /// Provide the background color. + pub fn background_color(mut self, color: impl Into) -> Self { + self.background_color = color.into(); + self + } + + /// Provide the cache version. + pub fn cache_version(mut self, version: impl Into) -> Self { + self.cache_version = version.into(); + self + } + + /// Provide icons metadata. + pub fn icons(mut self, icons: Vec) -> Self { + self.icons = icons; + self + } + + pub(crate) fn apply(self, dist_dir: &Path) -> Result<()> { + let manifest_path = dist_dir.join("manifest.json"); + if !manifest_path.exists() { + let manifest = + serde_json::to_string_pretty(&self).context("failed to serialize PWA manifest")?; + fs::write(&manifest_path, manifest) + .with_context(|| format!("failed to write `{}`", manifest_path.display()))?; + } + + let sw_path = dist_dir.join("sw.js"); + if !sw_path.exists() { + let static_resources = static_resources_to_cache(dist_dir)?; + fs::write( + &sw_path, + service_worker_file( + self.cache_version.as_ref(), + self.name.as_ref(), + static_resources.as_ref(), + )?, + ) + .with_context(|| format!("failed to write `{}`", sw_path.display()))?; + } + + Ok(()) + } +} + +impl Default for Pwa { + fn default() -> Self { + Self { + name: "app".to_string(), + short_name: "app".to_string(), + description: "A web application".to_string(), + start_url: "./".to_string(), + scope: "./".to_string(), + display: PwaDisplayMode::Standalone, + theme_color: "#000000".to_string(), + background_color: "#000000".to_string(), + cache_version: "v1".to_string(), + icons: Vec::new(), + } + } +} + +/// Display mode of the generated application. +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PwaDisplayMode { + /// Hide browser UI and uses the entirety of the available display area. + Fullscreen, + /// Opens the app to look and feel like a standalone native app. + Standalone, + /// Opens the app to look and feel like a standalone app but with a minimal set of UI + /// elements for navigation. + MinimalUI, + /// Opens the app in a conventional browser tab or new window. + Browser, +} + +/// Icon metadata for a generated web app manifest. +#[derive(Debug, Serialize)] +pub struct PwaIcon { + /// Source of the icon (relative to the `dist` directory). + pub src: String, + /// MIME type of the icon. + #[serde(rename = "type")] + pub mime_type: String, + /// Pixel size descriptor (e.g. 192x192). + pub sizes: String, +} + +pub(crate) fn service_worker_file( + cache_version: &str, + app_name: &str, + static_resources: &[String], +) -> Result { + let mut out = String::new(); + + writeln!(out, "const VERSION= {cache_version:?};").expect("can write String"); + writeln!(out, "const APP_NAME = {app_name:?};").expect("can write String"); + writeln!(out, "const CACHE_NAME = `${{APP_NAME}}-${{VERSION}}`;").expect("can write String"); + + writeln!(out, "const APP_STATIC_RESOURCES = [").expect("can write String"); + for resource in static_resources { + writeln!(out, " {resource:?},").expect("can write String"); + } + writeln!(out, "];").expect("can write String"); + writeln!(out).expect("can write String"); + + write!( + out, + r#"self.addEventListener("install", (event) => {{ + event.waitUntil( + (async () => {{ + const cache = await caches.open(CACHE_NAME); + await cache.addAll(APP_STATIC_RESOURCES); + await self.skipWaiting(); + }})(), + ); +}}); + +self.addEventListener("activate", (event) => {{ + event.waitUntil( + (async () => {{ + const names = await caches.keys(); + await Promise.all( + names.map((name) => {{ + if (name.startsWith(APP_NAME) && name !== CACHE_NAME) {{ + return caches.delete(name); + }} + return Promise.resolve(false); + }}), + ); + await clients.claim(); + }})(), + ); +}}); + +self.addEventListener("fetch", (event) => {{ + if (event.request.method !== "GET") return; + + event.respondWith( + (async () => {{ + const cache = await caches.open(CACHE_NAME); + if (event.request.mode === "navigate") {{ + try {{ + return await fetch(event.request); + }} catch {{ + const cachedIndex = await cache.match("./index.html"); + if (cachedIndex) return cachedIndex; + throw new Error("Offline and no cached index.html"); + }} + }} + const cachedResponse = await cache.match(event.request); + if (cachedResponse) return cachedResponse; + + try {{ + const networkResponse = await fetch(event.request); + const url = new URL(event.request.url); + if (url.origin === self.location.origin && networkResponse.ok) {{ + cache.put(event.request, networkResponse.clone()); + }} + return networkResponse; + }} catch {{ + const fallback = await cache.match(event.request, {{ ignoreSearch: true }}); + if (fallback) return fallback; + throw new Error(`Offline and no cache match: ${{event.request.url}}`); + }} + }})(), + ); +}});"# + ) + .expect("can write String"); + + Ok(out) +} + +fn static_resources_to_cache(dist_dir: &Path) -> Result> { + fn should_exclude(path: &Path) -> bool { + if path.file_name().and_then(|x| x.to_str()) == Some("sw.js") { + return true; + } + matches!( + path.extension().and_then(|x| x.to_str()), + Some("map") | Some("txt") + ) + } + + let mut resources = Vec::new(); + resources.push("./".to_string()); + let walker = walkdir::WalkDir::new(dist_dir); + for entry in walker { + let entry = entry + .with_context(|| format!("failed to walk into directory `{}`", dist_dir.display()))?; + let source = entry.path(); + + if !source.is_file() || should_exclude(source) { + continue; + } + + let relative: PathBuf = source + .strip_prefix(dist_dir) + .with_context(|| { + format!( + "cannot strip dist prefix `{}` from `{}`", + dist_dir.display(), + source.display(), + ) + })? + .to_path_buf(); + + let web_path = format!("./{}", relative.to_string_lossy().replace('\\', "/")); + resources.push(web_path); + } + + resources.sort(); + resources.dedup(); + Ok(resources) +} From a93eaf2049b289d4a8899ad14fcb334975aac8ab Mon Sep 17 00:00:00 2001 From: yozhgoor Date: Tue, 28 Apr 2026 03:58:36 +0200 Subject: [PATCH 2/4] Add `pwa` to `Dist` --- src/dist.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/dist.rs b/src/dist.rs index af7ff10..90b9130 100644 --- a/src/dist.rs +++ b/src/dist.rs @@ -191,6 +191,14 @@ pub struct Dist { #[cfg(feature = "wasm-opt")] #[clap(skip)] pub wasm_opt: Option, + + /// Optional Progressive Web App metadata and fallback file generation. + /// + /// Set via [`Dist::pwa`]. When enabled, files needed (e.g. `manifest.json` and `sw.js`) are + /// generated if they don't exists in the `assets` directory. + #[cfg(feature = "pwa")] + #[clap(skip)] + pub pwa: Option, } impl Dist { @@ -276,6 +284,18 @@ impl Dist { self } + /// Enable Progressive Web App support by generating needed files: + /// - `manifest.json` + /// - `sw.js` + /// + /// Existing user assets always take precedence. + #[cfg(feature = "pwa")] + #[cfg_attr(docsrs, doc(cfg(feature = "pwa")))] + pub fn pwa(mut self, pwa: crate::Pwa) -> Self { + self.pwa = Some(pwa); + self + } + /// Set the example to build. pub fn example(mut self, example: impl Into) -> Self { self.example = Some(example.into()); @@ -472,6 +492,11 @@ impl Dist { } } + #[cfg(feature = "pwa")] + if let Some(pwa) = self.pwa { + pwa.apply(&dist_dir)?; + } + log::info!("Successfully built in {}", dist_dir.display()); Ok(dist_dir) @@ -502,6 +527,8 @@ impl Default for Dist { transformers: vec![], #[cfg(feature = "wasm-opt")] wasm_opt: None, + #[cfg(feature = "pwa")] + pwa: None, } } } From 6459d43bead56e3a0304fad0a2dac11c0f931c3e Mon Sep 17 00:00:00 2001 From: yozhgoor Date: Tue, 28 Apr 2026 03:59:30 +0200 Subject: [PATCH 3/4] Add PWA-related content type to `DevServer` --- src/dev_server.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dev_server.rs b/src/dev_server.rs index b25b13d..8cac032 100644 --- a/src/dev_server.rs +++ b/src/dev_server.rs @@ -560,6 +560,9 @@ pub fn default_request_handler(request: Request) -> Result<()> { Some("css") => "text/css;charset=utf-8", Some("js") => "application/javascript", Some("wasm") => "application/wasm", + Some("json") => "application/json;charset=utf-8", + Some("png") => "image/png", + Some("svg") => "image/svg+xml", _ => "application/octet-stream", }; From 8bc3396c465b4b1884e21c65f742da08e4f85684 Mon Sep 17 00:00:00 2001 From: yozhgoor Date: Tue, 28 Apr 2026 04:00:05 +0200 Subject: [PATCH 4/4] Add PWA support to the `demo` example --- examples/demo/xtask/Cargo.toml | 2 +- examples/demo/xtask/src/main.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/demo/xtask/Cargo.toml b/examples/demo/xtask/Cargo.toml index 12db6ad..78e3f8a 100644 --- a/examples/demo/xtask/Cargo.toml +++ b/examples/demo/xtask/Cargo.toml @@ -8,4 +8,4 @@ edition = "2021" [dependencies] env_logger = "0.11" log = "0.4" -xtask-wasm = { path = "../../../", features = ["wasm-opt", "sass"]} +xtask-wasm = { path = "../../../", features = ["wasm-opt", "sass", "pwa"]} diff --git a/examples/demo/xtask/src/main.rs b/examples/demo/xtask/src/main.rs index 22287b6..8d5b0f8 100644 --- a/examples/demo/xtask/src/main.rs +++ b/examples/demo/xtask/src/main.rs @@ -31,6 +31,10 @@ fn main() -> Result<()> { .app_name("web_app") .transformer(xtask_wasm::SassTransformer::default()) .optimize_wasm(xtask_wasm::WasmOpt::level(1).shrink(2)) + .pwa( + xtask_wasm::Pwa::new() + .name("web_app") + ) .build("webapp")?; } Command::Watch(arg) => {