mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-11 22:50:07 +00:00
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:
co-authored by
Robert Jin
Aram Drevekenin
parent
b558b31ed1
commit
e9173cba16
@@ -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,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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user