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 <[email protected]>
Co-authored-by: Aram Drevekenin <[email protected]>
This commit is contained in:
Robert Jin
2026-06-01 15:35:39 +02:00
committed by GitHub
co-authored by Robert Jin Aram Drevekenin
parent b558b31ed1
commit e9173cba16
6 changed files with 278 additions and 0 deletions
+1
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+3
View File
@@ -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
+18
View File
@@ -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"
}
]
}
@@ -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)]
+1
View File
@@ -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",
},
}