diff options
| author | Robert Jin <20613660+jzyrobert@users.noreply.github.com> | 2026-06-01 14:35:39 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-01 15:35:39 +0200 |
| commit | e9173cba163506491becbeacad162315d6e8f726 (patch) | |
| tree | 6f54f125c0f289ac1605afb98eb5b353264026db | |
| parent | docs(changelog): update release (diff) | |
| download | sl0ppij-e9173cba163506491becbeacad162315d6e8f726.tar.gz sl0ppij-e9173cba163506491becbeacad162315d6e8f726.tar.xz sl0ppij-e9173cba163506491becbeacad162315d6e8f726.zip | |
feat: PWA support for the web client (#5184)
* feat: PWA support for the web client
Adds Progressive Web App support so the Zellij web client can be
installed as a standalone app via Chromium/Firefox/Safari/iOS.
- New static asset `zellij-client/assets/manifest.webmanifest` with
required W3C members (name, start_url, scope, display, icons). All
path-bearing fields are relative to the manifest URL so a single
static file works under any reverse-proxy base_url prefix without
runtime templating.
- New icon set under `zellij-client/assets/`: icon.svg (vector),
icon-192.png and icon-512.png (purpose "any"), and
icon-maskable-512.png (purpose "maskable") rendered at 70% of
canvas height on a #080317 backdrop so Android adaptive masks
honour the W3C 80%-diameter safe zone.
- `scripts/render-pwa-icons.sh` pins the rendering toolchain
(librsvg + ImageMagick) and the maskable safe-zone geometry so
re-renders are reproducible.
- `.webmanifest` -> `application/manifest+json` mapping added to
`get_mime_type` in `zellij-client/src/web_client/utils.rs`.
- `index.html` gains <link rel="manifest">, apple-touch-icon, and
apple-mobile-web-app-* meta tags. Placed immediately after the
existing favicon link and before any <script> tag so the manifest
is in the browser's preload scanner early.
- 5 new tests cover Content-Type, required JSON members, the
relative-URL contract, index.html references, icon reachability,
and base_url invariance.
Closes #5094.
* update changelog with pull url
* feat(web): trim PWA icon set to PNGs only
Drops the duplicate `zellij-client/assets/icon.svg` (verbatim copy of
`assets/logo.svg`) and the maskable PNG variant from the manifest.
Net result is the minimal icon set most production PWAs ship:
icon-192.png and icon-512.png.
- `icon.svg` was redundant with the canonical `assets/logo.svg` at
the repo root and offered no install-criterion benefit on top of
the 192/512 PNGs.
- `icon-maskable-512.png` only benefits Android Chrome installs
(desktop browsers and iOS ignore the maskable purpose). The
Android win was small for the hexagonal Zellij glyph; a hand-tuned
maskable can be added in a follow-up if Android installs need
polish.
`scripts/render-pwa-icons.sh` now reads from `assets/logo.svg`
directly instead of the duplicate copy.
* feat(web): use existing assets/logo.png as the PWA icon
Removes the pre-rasterized 192/512 PNG variants and the render
script in favour of serving the canonical `assets/logo.png` via a
small dedicated handler. The repo's existing logo becomes the single
source of truth; the PWA picks up logo changes automatically with no
re-render step.
- `LOGO_PNG` static is loaded via `include_bytes!` from the repo
root, mirroring how `WEB_CLIENT_PAGE` reaches `assets/index.html`.
- New `serve_logo_png` handler is registered at `/assets/logo.png`
before the `get_static_asset` wildcard. axum's matchit router
gives literal paths precedence, so the explicit route always wins.
- Manifest declares a single icon entry at the actual file dimensions
(2269x2620). Browsers letterbox onto square launcher surfaces, the
same way they do for the existing favicon.
- `apple-touch-icon` retargets to `assets/logo.png`.
- `test_pwa_icon_assets_reachable` collapses to a single case.
- `scripts/render-pwa-icons.sh` and the rendering toolchain
(librsvg + ImageMagick) are no longer needed.
* feat(web): drop redundant apple-mobile-web-app meta tags
The three `apple-mobile-web-app-*` meta tags are backward-compat
scaffolding for iOS < 16.4:
- `apple-mobile-web-app-capable: yes` — iOS 16.4+ reads
`display: standalone` from the manifest.
- `apple-mobile-web-app-title: Zellij` — iOS 16.4+ reads `short_name`
from the manifest.
- `apple-mobile-web-app-status-bar-style: default` — explicitly sets
iOS's default status-bar behaviour, i.e. a no-op.
iOS 16.4 shipped in March 2023; the cohort still on iOS 15 is
essentially devices stuck on the iPhone 6s / 7 / SE 1st gen line
(2015-16 hardware), which is not the zellij audience.
`<link rel="apple-touch-icon">` is kept because iOS still ignores
manifest `icons` entries and reads only that tag for the home-screen
icon on every iOS version.
* feat(web): restore square pre-rasterized PNG icon for installability
The earlier swap to serving `assets/logo.png` directly fails
Chromium's PWA installability gate: the logo is 2269x2620 (not
square), and Chromium specifically requires "an icon at least 144
pixels square" -- a non-square icon is rejected even though the
declared `sizes` covers the dimension threshold. DevTools'
Application -> Manifest panel was misleading: it parsed and
displayed the icon, but installability is a separate stricter check.
This walks the swap back to a single committed 192x192 square PNG
(`icon-192.png`) rendered from `assets/logo.svg` with the logo
composited onto a transparent square canvas. The PNG is served by
the existing `get_static_asset` route via `include_dir!`. The
`LOGO_PNG` static, `serve_logo_png` handler, and dedicated
`/assets/logo.png` route are dropped.
apple-touch-icon retargets to icon-192.png (closer to iOS's
preferred 180px home-screen size).
Net effect on the diff: ~13KB committed PNG + a single icon entry
in the manifest, in exchange for actually meeting the
installability criterion.
* style: rustfmt
---------
Co-authored-by: Robert Jin <robert.jin@gresearch.co.uk>
Co-authored-by: Aram Drevekenin <aram@poor.dev>
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | zellij-client/assets/icon-192.png | bin | 0 -> 13275 bytes | |||
| -rw-r--r-- | zellij-client/assets/index.html | 3 | ||||
| -rw-r--r-- | zellij-client/assets/manifest.webmanifest | 18 | ||||
| -rw-r--r-- | zellij-client/src/web_client/unit/web_client_tests.rs | 255 | ||||
| -rw-r--r-- | zellij-client/src/web_client/utils.rs | 1 |
6 files changed, 278 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 343e34fe..f0dd1fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] * feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133) +* feat: PWA support for the web client (manifest + icons + iOS meta tags) so the page can be installed as a standalone app (https://github.com/zellij-org/zellij/pull/5184) ## [0.44.3] - 2026-05-13 * fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139) diff --git a/zellij-client/assets/icon-192.png b/zellij-client/assets/icon-192.png Binary files differnew file mode 100644 index 00000000..0f60e181 --- /dev/null +++ b/zellij-client/assets/icon-192.png diff --git a/zellij-client/assets/index.html b/zellij-client/assets/index.html index 92f59a0a..c3fce356 100644 --- a/zellij-client/assets/index.html +++ b/zellij-client/assets/index.html @@ -3,6 +3,9 @@ <head> <base href="BASE_URL" /> <link rel="icon" href="assets/favicon.ico" /> + <link rel="manifest" href="assets/manifest.webmanifest" /> + <link rel="apple-touch-icon" href="assets/icon-192.png" /> + <meta name="theme-color" content="#000000" /> <!-- preload the font to avoid rendering issue on first load not 100% perfect but much better than without check this for some additional context: https://github.com/xtermjs/xterm.js/issues/5164 diff --git a/zellij-client/assets/manifest.webmanifest b/zellij-client/assets/manifest.webmanifest new file mode 100644 index 00000000..2385d428 --- /dev/null +++ b/zellij-client/assets/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Zellij Web Client", + "short_name": "Zellij", + "start_url": "../", + "scope": "../", + "display": "standalone", + "display_override": ["standalone", "minimal-ui"], + "theme_color": "#000000", + "background_color": "#000000", + "icons": [ + { + "src": "./icon-192.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "any" + } + ] +} diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs index 2fa82cd2..726e73a6 100644 --- a/zellij-client/src/web_client/unit/web_client_tests.rs +++ b/zellij-client/src/web_client/unit/web_client_tests.rs @@ -2822,6 +2822,261 @@ mod web_client_tests { server_handle.abort(); tokio::time::sleep(Duration::from_millis(100)).await; } + + // ========== PWA manifest and icon tests ========== + + async fn spawn_test_server_with_config(config: Config) -> (u16, tokio::task::JoinHandle<()>) { + let session_manager = Arc::new(MockSessionManager::new()); + let client_os_api_factory = Arc::new(MockClientOsApiFactory::new()); + let options = Options::default(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + + let temp_config_path = std::env::temp_dir().join("test_config.kdl"); + let server_handle = tokio::spawn(serve_web_client( + config, + options, + Some(temp_config_path), + listener, + None, + Some(session_manager), + Some(client_os_api_factory), + addr.ip(), + port, + )); + + wait_for_server(port, Duration::from_secs(5)) + .await + .expect("Server failed to start"); + + (port, server_handle) + } + + async fn fetch_url(url: String) -> isahc::Response<isahc::Body> { + timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking(move || isahc::get(&url)), + ) + .await + .expect("Request timed out") + .expect("Spawn blocking failed") + .expect("Request failed") + } + + #[tokio::test] + #[serial] + async fn test_manifest_endpoint_returns_correct_content_type_and_required_fields() { + let _ = delete_db(); + + let (port, server_handle) = spawn_test_server_with_config(Config::default()).await; + + let url = format!("http://127.0.0.1:{}/assets/manifest.webmanifest", port); + let mut response = fetch_url(url).await; + + assert!( + response.status().is_success(), + "manifest endpoint should return 2xx, got {}", + response.status() + ); + + let content_type = response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + assert_eq!( + content_type, "application/manifest+json", + "manifest must be served as application/manifest+json" + ); + + let body = response.text().expect("Failed to read manifest body"); + let manifest: serde_json::Value = + serde_json::from_str(&body).expect("Manifest body must be valid JSON"); + + assert!( + manifest.get("name").and_then(|v| v.as_str()).is_some(), + "manifest must declare `name`" + ); + assert!( + manifest.get("start_url").is_some(), + "manifest must declare `start_url` (W3C required member)" + ); + let icons = manifest + .get("icons") + .and_then(|v| v.as_array()) + .expect("manifest must declare a non-empty `icons` array"); + assert!( + !icons.is_empty(), + "icons array must contain at least one icon" + ); + assert!( + manifest.get("display").is_some() || manifest.get("display_override").is_some(), + "manifest must declare `display` or `display_override`" + ); + + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_manifest_uses_relative_urls() { + // Pins the relative-URL contract that lets the static manifest work under any + // reverse-proxy base_url. start_url, scope, and icons[*].src must all be relative + // to the manifest URL — never absolute paths and never absolute URLs. + let _ = delete_db(); + + let (port, server_handle) = spawn_test_server_with_config(Config::default()).await; + + let url = format!("http://127.0.0.1:{}/assets/manifest.webmanifest", port); + let mut response = fetch_url(url).await; + let body = response.text().expect("Failed to read manifest body"); + let manifest: serde_json::Value = serde_json::from_str(&body).expect("Invalid JSON"); + + let is_relative = |v: &serde_json::Value| -> bool { + v.as_str() + .map(|s| { + !s.starts_with('/') && !s.starts_with("http://") && !s.starts_with("https://") + }) + .unwrap_or(false) + }; + + if let Some(start_url) = manifest.get("start_url") { + assert!( + is_relative(start_url), + "start_url must be relative, got {:?}", + start_url + ); + } + if let Some(scope) = manifest.get("scope") { + assert!( + is_relative(scope), + "scope must be relative, got {:?}", + scope + ); + } + let icons = manifest + .get("icons") + .and_then(|v| v.as_array()) + .expect("icons array required"); + for icon in icons { + let src = icon.get("src").expect("each icon must have src"); + assert!(is_relative(src), "icon src must be relative, got {:?}", src); + } + + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_pwa_icon_assets_reachable() { + let _ = delete_db(); + + let (port, server_handle) = spawn_test_server_with_config(Config::default()).await; + + let cases: &[(&str, &str)] = &[("icon-192.png", "image/png")]; + + for (filename, expected_mime) in cases { + let url = format!("http://127.0.0.1:{}/assets/{}", port, filename); + let response = fetch_url(url).await; + + assert!( + response.status().is_success(), + "{} should return 2xx, got {}", + filename, + response.status() + ); + + let content_type = response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + assert_eq!( + content_type, *expected_mime, + "{} must be served as {}", + filename, expected_mime + ); + } + + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_index_html_references_manifest_and_pwa_meta_tags() { + let _ = delete_db(); + + let (port, server_handle) = spawn_test_server_with_config(Config::default()).await; + + let url = format!("http://127.0.0.1:{}/", port); + let mut response = fetch_url(url).await; + let body = response.text().expect("Failed to read index.html"); + + assert!( + body.contains("rel=\"manifest\""), + "index.html must reference the manifest via <link rel=\"manifest\">" + ); + assert!( + body.contains("manifest.webmanifest"), + "manifest link must point at manifest.webmanifest" + ); + assert!( + body.contains("apple-touch-icon"), + "index.html must declare an apple-touch-icon for iOS" + ); + assert!( + body.contains("theme-color"), + "index.html must declare a theme-color meta tag" + ); + + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + #[tokio::test] + #[serial] + async fn test_manifest_body_invariant_under_base_url() { + // The server-side manifest response is identical regardless of base_url config — + // base_url only affects the browser-side URL composition via <base href> in + // serve_html. The manifest itself is a static asset. + let _ = delete_db(); + + let default_body = { + let (port, server_handle) = spawn_test_server_with_config(Config::default()).await; + let url = format!("http://127.0.0.1:{}/assets/manifest.webmanifest", port); + let mut response = fetch_url(url).await; + let body = response + .text() + .expect("Failed to read default-base manifest"); + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + body + }; + + let prefixed_body = { + let mut config = Config::default(); + config.web_client.base_url = Some("/zellij/".to_string()); + let (port, server_handle) = spawn_test_server_with_config(config).await; + let url = format!("http://127.0.0.1:{}/assets/manifest.webmanifest", port); + let mut response = fetch_url(url).await; + let body = response + .text() + .expect("Failed to read prefixed-base manifest"); + server_handle.abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + body + }; + + assert_eq!( + default_body, prefixed_body, + "manifest body must be identical regardless of base_url config" + ); + } } #[derive(Debug, Clone)] diff --git a/zellij-client/src/web_client/utils.rs b/zellij-client/src/web_client/utils.rs index dfc40a84..edc52f50 100644 --- a/zellij-client/src/web_client/utils.rs +++ b/zellij-client/src/web_client/utils.rs @@ -14,6 +14,7 @@ pub fn get_mime_type(ext: Option<&str>) -> &str { "png" => "image/png", "ico" => "image/x-icon", "svg" => "image/svg+xml", + "webmanifest" => "application/manifest+json", _ => "text/plain", }, } |
