use anyhow::{anyhow, Context, Result};
use reqwest::{Client, Url};
use serde::Serialize;
use serde_json::Value;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;
use crate::storage::HashtreeStore;
use hashtree_core::{nhash_encode, Cid, DirEntry, HashTree, HashTreeConfig, LinkType};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PwaShortcut {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PwaProtocolHandler {
pub protocol: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InstalledSitePwa {
pub name: String,
pub launch_url: String,
pub icon_url: Option<String>,
pub source_app_id: Option<String>,
pub source_url: String,
pub source_manifest_url: String,
pub description: Option<String>,
pub display_mode: Option<String>,
pub scope_url: Option<String>,
pub shortcuts: Vec<PwaShortcut>,
pub protocol_handlers: Vec<PwaProtocolHandler>,
}
#[derive(Debug, Clone)]
struct PwaAsset {
path: String,
data: Vec<u8>,
}
#[derive(Debug, Clone)]
struct AssetReference {
raw_value: String,
resolved_url: Url,
}
#[derive(Debug, Clone)]
struct FetchedPwa {
name: String,
source_app_id: Option<String>,
source_url: String,
source_manifest_url: String,
description: Option<String>,
display_mode: Option<String>,
scope_reference: Option<String>,
launch_reference: String,
icon_path: Option<String>,
shortcuts: Vec<PwaShortcut>,
protocol_handlers: Vec<PwaProtocolHandler>,
assets: Vec<PwaAsset>,
}
pub async fn install_site_pwa_to_store(
store: &HashtreeStore,
url: &str,
) -> Result<InstalledSitePwa> {
let fetched = fetch_pwa(url).await.context("fetch installable PWA")?;
let root_cid = store_pwa_assets(store, &fetched.assets)
.await
.context("store PWA in hashtree")?;
store.pin(&root_cid.hash).context("pin stored PWA")?;
let nhash = nhash_encode(&root_cid.hash).context("encode stored PWA root")?;
Ok(InstalledSitePwa {
name: fetched.name,
launch_url: format!("htree://{nhash}{}", fetched.launch_reference),
icon_url: fetched
.icon_path
.as_ref()
.map(|path| format!("htree://{nhash}{}", absolute_tree_path(path))),
source_app_id: fetched.source_app_id,
source_url: fetched.source_url,
source_manifest_url: fetched.source_manifest_url,
description: fetched.description,
display_mode: fetched.display_mode,
scope_url: fetched
.scope_reference
.as_ref()
.map(|scope_reference| format!("htree://{nhash}{scope_reference}")),
shortcuts: fetched
.shortcuts
.into_iter()
.map(|shortcut| PwaShortcut {
name: shortcut.name,
url: format!("htree://{nhash}{}", shortcut.url),
})
.collect(),
protocol_handlers: fetched
.protocol_handlers
.into_iter()
.map(|handler| PwaProtocolHandler {
protocol: handler.protocol,
url: format!("htree://{nhash}{}", handler.url),
})
.collect(),
})
}
pub async fn cache_bookmark_icon_to_store(
store: &HashtreeStore,
source_url: Option<&str>,
source_manifest_url: Option<&str>,
icon_url: Option<&str>,
) -> Result<Option<String>> {
let client = build_reqwest_client()?;
match cache_manifest_icon_to_store(store, &client, source_url, source_manifest_url).await {
Ok(Some(cached_icon)) => return Ok(Some(cached_icon)),
Ok(None) => {}
Err(error) => {
tracing::warn!("Failed to cache manifest-derived bookmark icon: {}", error);
}
}
let Some(icon_url) = icon_url.filter(|value| is_http_url(value)) else {
return Ok(None);
};
match cache_direct_icon_to_store(store, &client, icon_url).await {
Ok(cached_icon) => Ok(Some(cached_icon)),
Err(error) => {
tracing::warn!(
"Failed to cache direct bookmark icon {}: {}",
icon_url,
error
);
Ok(None)
}
}
}
fn build_reqwest_client() -> Result<Client> {
Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.context("build reqwest client")
}
async fn fetch_pwa(url: &str) -> Result<FetchedPwa> {
let client = build_reqwest_client()?;
let html_response = client
.get(url)
.send()
.await
.with_context(|| format!("fetch page {url}"))?;
let html_response = html_response
.error_for_status()
.with_context(|| format!("fetch page {url}"))?;
let source_url = html_response.url().to_string();
let base_url = html_response.url().clone();
let original_html = html_response
.text()
.await
.with_context(|| format!("read page body {source_url}"))?;
let html_path = url_to_path(&base_url, &base_url);
let manifest_reference = extract_manifest_reference(&original_html, &base_url)
.ok_or_else(|| anyhow!("page does not expose a web manifest"))?;
let manifest_url = manifest_reference.resolved_url.clone();
let manifest_response = client
.get(manifest_url.clone())
.send()
.await
.with_context(|| format!("fetch manifest {manifest_url}"))?;
let manifest_response = manifest_response
.error_for_status()
.with_context(|| format!("fetch manifest {manifest_url}"))?;
let source_manifest_url = manifest_response.url().to_string();
let manifest_url = manifest_response.url().clone();
let mut manifest: Value = manifest_response
.json()
.await
.with_context(|| format!("parse manifest JSON {source_manifest_url}"))?;
let mut fetched_urls = HashSet::new();
let mut assets = Vec::new();
let mut html_rewrites = Vec::new();
let mut queued_assets = BTreeSet::new();
fetched_urls.insert(source_url.clone());
fetched_urls.insert(source_manifest_url.clone());
let manifest_path = url_to_path(&manifest_url, &base_url);
html_rewrites.push((
manifest_reference.raw_value,
relative_tree_reference(&html_path, &manifest_path),
));
for link_asset in extract_link_asset_references(&original_html, &base_url) {
let asset_path = url_to_path(&link_asset.resolved_url, &base_url);
html_rewrites.push((
link_asset.raw_value,
relative_tree_reference(&html_path, &asset_path),
));
queued_assets.insert(link_asset.resolved_url.to_string());
}
for script in extract_script_references(&original_html, &base_url) {
let asset_path = url_to_path(&script.resolved_url, &base_url);
html_rewrites.push((
script.raw_value,
relative_tree_reference(&html_path, &asset_path),
));
queued_assets.insert(script.resolved_url.to_string());
}
for image in extract_image_references(&original_html, &base_url) {
let asset_path = url_to_path(&image.resolved_url, &base_url);
html_rewrites.push((
image.raw_value,
relative_tree_reference(&html_path, &asset_path),
));
queued_assets.insert(image.resolved_url.to_string());
}
for asset_url in extract_manifest_resource_urls(&manifest, &manifest_url) {
queued_assets.insert(asset_url.to_string());
}
let mut queued_asset_urls: Vec<Url> = queued_assets
.into_iter()
.filter_map(|value| Url::parse(&value).ok())
.collect();
let mut queued_asset_set: HashSet<String> = queued_asset_urls
.iter()
.map(|value| value.to_string())
.collect();
let mut queue_index = 0usize;
while queue_index < queued_asset_urls.len() {
let asset_url = queued_asset_urls[queue_index].clone();
queue_index += 1;
let discovered = fetch_asset(
&client,
&base_url,
&asset_url,
&mut fetched_urls,
&mut assets,
)
.await;
for nested_url in discovered {
if queued_asset_set.insert(nested_url.to_string()) {
queued_asset_urls.push(nested_url);
}
}
}
rewrite_manifest_urls(&mut manifest, &manifest_url, &manifest_path);
let rewritten_html = rewrite_html_urls(&original_html, &html_rewrites);
assets.push(PwaAsset {
path: html_path.clone(),
data: rewritten_html.into_bytes(),
});
assets.push(PwaAsset {
path: manifest_path.clone(),
data: serde_json::to_vec_pretty(&manifest).context("serialize rewritten manifest")?,
});
let launch_reference = manifest_start_reference(&manifest, &manifest_url)
.unwrap_or_else(|| absolute_tree_path(&html_path));
let icon_path = pick_manifest_icon_path(&manifest, &manifest_url);
let source_app_id = manifest_app_id(&manifest, &manifest_url);
let description = manifest_description(&manifest);
let display_mode = manifest_display_mode(&manifest);
let scope_reference = manifest_scope_reference(&manifest, &manifest_url);
let shortcuts = manifest_shortcuts(&manifest, &manifest_path);
let protocol_handlers = manifest_protocol_handlers(&manifest, &manifest_path);
let name = manifest_name(&manifest)
.or_else(|| extract_title(&original_html))
.unwrap_or_else(|| {
Url::parse(&source_url)
.ok()
.and_then(|value| value.host_str().map(str::to_owned))
.unwrap_or_else(|| "Installed Site".to_string())
});
Ok(FetchedPwa {
name,
source_app_id,
source_url,
source_manifest_url,
description,
display_mode,
scope_reference,
launch_reference,
icon_path,
shortcuts,
protocol_handlers,
assets,
})
}
async fn cache_manifest_icon_to_store(
store: &HashtreeStore,
client: &Client,
source_url: Option<&str>,
source_manifest_url: Option<&str>,
) -> Result<Option<String>> {
let Some((manifest, manifest_url)) =
fetch_manifest_for_icon(client, source_url, source_manifest_url).await?
else {
return Ok(None);
};
let Some(icon_url) = pick_manifest_icon_url(&manifest, &manifest_url) else {
return Ok(None);
};
cache_icon_url_to_store(store, client, &icon_url)
.await
.map(Some)
}
async fn fetch_manifest_for_icon(
client: &Client,
source_url: Option<&str>,
source_manifest_url: Option<&str>,
) -> Result<Option<(Value, Url)>> {
if let Some(manifest_url) = source_manifest_url.filter(|value| is_http_url(value)) {
return fetch_manifest_json(client, manifest_url).await.map(Some);
}
let Some(source_url) = source_url.filter(|value| is_http_url(value)) else {
return Ok(None);
};
let html_response = client
.get(source_url)
.send()
.await
.with_context(|| format!("fetch page {source_url}"))?;
let html_response = html_response
.error_for_status()
.with_context(|| format!("fetch page {source_url}"))?;
let base_url = html_response.url().clone();
let html = html_response
.text()
.await
.with_context(|| format!("read page body {}", base_url))?;
let Some(manifest_reference) = extract_manifest_reference(&html, &base_url) else {
return Ok(None);
};
fetch_manifest_json(client, manifest_reference.resolved_url.as_str())
.await
.map(Some)
}
async fn fetch_manifest_json(client: &Client, manifest_url: &str) -> Result<(Value, Url)> {
let parsed_manifest_url =
Url::parse(manifest_url).with_context(|| format!("parse manifest url {manifest_url}"))?;
let manifest_response = client
.get(parsed_manifest_url.clone())
.send()
.await
.with_context(|| format!("fetch manifest {parsed_manifest_url}"))?;
let manifest_response = manifest_response
.error_for_status()
.with_context(|| format!("fetch manifest {parsed_manifest_url}"))?;
let resolved_manifest_url = manifest_response.url().clone();
let manifest: Value = manifest_response
.json()
.await
.with_context(|| format!("parse manifest JSON {}", resolved_manifest_url))?;
Ok((manifest, resolved_manifest_url))
}
async fn cache_direct_icon_to_store(
store: &HashtreeStore,
client: &Client,
icon_url: &str,
) -> Result<String> {
let parsed_icon_url =
Url::parse(icon_url).with_context(|| format!("parse icon url {icon_url}"))?;
cache_icon_url_to_store(store, client, &parsed_icon_url).await
}
async fn cache_icon_url_to_store(
store: &HashtreeStore,
client: &Client,
icon_url: &Url,
) -> Result<String> {
if !matches!(icon_url.scheme(), "http" | "https") {
return Err(anyhow!("icon URL must use http:// or https://"));
}
let response = client
.get(icon_url.clone())
.send()
.await
.with_context(|| format!("fetch icon {icon_url}"))?;
let response = response
.error_for_status()
.with_context(|| format!("fetch icon {icon_url}"))?;
let resolved_icon_url = response.url().clone();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
let bytes = response
.bytes()
.await
.with_context(|| format!("read icon body {resolved_icon_url}"))?;
if !looks_like_image_payload(&content_type, &bytes) {
return Err(anyhow!("icon response was not an image"));
}
let icon_path = icon_asset_path(&resolved_icon_url, &content_type, &bytes);
let root_cid = store_pwa_assets(
store,
&[PwaAsset {
path: icon_path.clone(),
data: bytes.to_vec(),
}],
)
.await
.context("store bookmark icon in hashtree")?;
store
.pin(&root_cid.hash)
.context("pin cached bookmark icon")?;
let nhash = nhash_encode(&root_cid.hash).context("encode cached bookmark icon root")?;
Ok(format!("htree://{nhash}{}", absolute_tree_path(&icon_path)))
}
async fn fetch_asset(
client: &Client,
base_url: &Url,
asset_url: &Url,
fetched_urls: &mut HashSet<String>,
assets: &mut Vec<PwaAsset>,
) -> Vec<Url> {
if !matches!(asset_url.scheme(), "http" | "https") {
return Vec::new();
}
if !fetched_urls.insert(asset_url.to_string()) {
return Vec::new();
}
let response = match client.get(asset_url.clone()).send().await {
Ok(response) => response,
Err(error) => {
tracing::warn!("Failed to fetch PWA asset {}: {}", asset_url, error);
return Vec::new();
}
};
let response = match response.error_for_status() {
Ok(response) => response,
Err(error) => {
tracing::warn!("Failed to fetch PWA asset {}: {}", asset_url, error);
return Vec::new();
}
};
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
let path = url_to_path(asset_url, base_url);
if content_type.starts_with("text/css") || path.ends_with(".css") {
let css = match response.text().await {
Ok(css) => css,
Err(error) => {
tracing::warn!("Failed to read CSS asset {}: {}", asset_url, error);
return Vec::new();
}
};
let nested_urls = extract_css_urls(&css, asset_url);
let rewritten_css = rewrite_css_urls(&css, &path, asset_url, base_url);
assets.push(PwaAsset {
path,
data: rewritten_css.into_bytes(),
});
return nested_urls;
}
if content_type.starts_with("text/html") || path.ends_with(".html") || path.ends_with(".htm") {
let html = match response.text().await {
Ok(html) => html,
Err(error) => {
tracing::warn!("Failed to read HTML asset {}: {}", asset_url, error);
return Vec::new();
}
};
let (rewritten_html, nested_urls) =
rewrite_html_asset_urls(&html, &path, asset_url, base_url);
assets.push(PwaAsset {
path,
data: rewritten_html.into_bytes(),
});
return nested_urls;
}
match response.bytes().await {
Ok(bytes) => {
assets.push(PwaAsset {
path,
data: bytes.to_vec(),
});
}
Err(error) => {
tracing::warn!("Failed to read PWA asset {}: {}", asset_url, error);
}
}
Vec::new()
}
async fn store_pwa_assets(store: &HashtreeStore, assets: &[PwaAsset]) -> Result<Cid> {
let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
let mut file_entries = HashMap::new();
let mut dir_paths = HashSet::from([String::new()]);
for asset in assets {
let clean_path = normalize_asset_path(&asset.path);
if clean_path.is_empty() {
continue;
}
let (cid, size) = tree
.put(&asset.data)
.await
.with_context(|| format!("store asset {}", clean_path))?;
let (parent, name) = split_parent_and_name(&clean_path);
dir_paths.extend(parent_chain(&clean_path));
file_entries.insert(clean_path, (parent, name, cid, size));
}
let mut sorted_dirs: Vec<String> = dir_paths.into_iter().collect();
sorted_dirs.sort_by(|a, b| dir_depth(b).cmp(&dir_depth(a)).then_with(|| a.cmp(b)));
let mut dir_cids: HashMap<String, Cid> = HashMap::new();
for dir_path in sorted_dirs {
let mut entries = Vec::new();
for (parent, name, cid, size) in file_entries.values() {
if *parent == dir_path {
entries.push(DirEntry::from_cid(name.clone(), cid).with_size(*size));
}
}
for (subdir_path, cid) in &dir_cids {
if parent_path(subdir_path) == dir_path {
let name = file_name(subdir_path).unwrap_or_else(|| subdir_path.clone());
entries.push(DirEntry::from_cid(name, cid).with_link_type(LinkType::Dir));
}
}
let cid = tree
.put_directory(entries)
.await
.with_context(|| format!("create directory {}", display_dir(&dir_path)))?;
dir_cids.insert(dir_path, cid);
}
dir_cids
.remove("")
.ok_or_else(|| anyhow!("failed to build PWA root directory"))
}
fn manifest_name(manifest: &Value) -> Option<String> {
manifest
.get("name")
.and_then(Value::as_str)
.or_else(|| manifest.get("short_name").and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn manifest_description(manifest: &Value) -> Option<String> {
manifest
.get("description")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn manifest_start_reference(manifest: &Value, manifest_url: &Url) -> Option<String> {
let start_url = manifest.get("start_url")?.as_str()?;
let resolved = manifest_url.join(start_url).ok()?;
Some(absolute_tree_reference_for_url(&resolved, manifest_url))
}
fn manifest_scope_reference(manifest: &Value, manifest_url: &Url) -> Option<String> {
let raw_scope = manifest
.get("scope")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let resolved_scope = resolve_resource_url(raw_scope, manifest_url)?;
if resolved_scope.origin() != manifest_url.origin() {
return None;
}
let path = resolved_scope.path();
if path.is_empty() {
Some("/".to_string())
} else {
Some(path.to_string())
}
}
fn manifest_shortcuts(manifest: &Value, manifest_path: &str) -> Vec<PwaShortcut> {
manifest
.get("shortcuts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|shortcut| {
let name = shortcut
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let url = shortcut
.get("url")
.and_then(Value::as_str)
.and_then(|value| absolute_tree_reference_from_path(manifest_path, value))?;
Some(PwaShortcut {
name: name.to_string(),
url,
})
})
.collect()
}
fn manifest_protocol_handlers(manifest: &Value, manifest_path: &str) -> Vec<PwaProtocolHandler> {
manifest
.get("protocol_handlers")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|handler| {
let protocol = handler
.get("protocol")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let url = handler
.get("url")
.and_then(Value::as_str)
.and_then(|value| absolute_tree_reference_from_path(manifest_path, value))?;
Some(PwaProtocolHandler {
protocol: protocol.to_string(),
url,
})
})
.collect()
}
fn manifest_app_id(manifest: &Value, manifest_url: &Url) -> Option<String> {
let raw_id = manifest.get("id")?.as_str()?.trim();
if raw_id.is_empty() {
return None;
}
Some(
manifest_url
.join(raw_id)
.map(|resolved| resolved.to_string())
.unwrap_or_else(|_| raw_id.to_string()),
)
}
fn manifest_display_mode(manifest: &Value) -> Option<String> {
let display_mode = manifest
.get("display")?
.as_str()?
.trim()
.to_ascii_lowercase();
match display_mode.as_str() {
"browser" | "minimal-ui" | "standalone" | "fullscreen" => Some(display_mode),
_ => None,
}
}
fn extract_manifest_resource_urls(manifest: &Value, manifest_url: &Url) -> Vec<Url> {
let mut urls = Vec::new();
collect_manifest_url_field(manifest.get("start_url"), manifest_url, &mut urls);
collect_manifest_icon_like_urls(manifest.get("icons"), manifest_url, &mut urls);
collect_manifest_icon_like_urls(manifest.get("screenshots"), manifest_url, &mut urls);
if let Some(shortcuts) = manifest.get("shortcuts").and_then(Value::as_array) {
for shortcut in shortcuts {
collect_manifest_url_field(shortcut.get("url"), manifest_url, &mut urls);
collect_manifest_icon_like_urls(shortcut.get("icons"), manifest_url, &mut urls);
}
}
if let Some(protocol_handlers) = manifest.get("protocol_handlers").and_then(Value::as_array) {
for handler in protocol_handlers {
collect_manifest_url_field(handler.get("url"), manifest_url, &mut urls);
}
}
if let Some(file_handlers) = manifest.get("file_handlers").and_then(Value::as_array) {
for handler in file_handlers {
collect_manifest_url_field(handler.get("action"), manifest_url, &mut urls);
collect_manifest_icon_like_urls(handler.get("icons"), manifest_url, &mut urls);
}
}
if let Some(share_target) = manifest.get("share_target") {
collect_manifest_url_field(share_target.get("action"), manifest_url, &mut urls);
collect_manifest_url_field(share_target.get("url_template"), manifest_url, &mut urls);
}
if let Some(note_taking) = manifest.get("note_taking") {
collect_manifest_url_field(note_taking.get("new_note_url"), manifest_url, &mut urls);
}
if let Some(tab_strip) = manifest.get("tab_strip") {
if let Some(home_tab) = tab_strip.get("home_tab") {
collect_manifest_url_field(home_tab.get("url"), manifest_url, &mut urls);
collect_manifest_icon_like_urls(home_tab.get("icons"), manifest_url, &mut urls);
}
if let Some(new_tab_button) = tab_strip.get("new_tab_button") {
collect_manifest_url_field(new_tab_button.get("url"), manifest_url, &mut urls);
collect_manifest_icon_like_urls(new_tab_button.get("icons"), manifest_url, &mut urls);
}
}
urls
}
fn pick_manifest_icon_url(manifest: &Value, manifest_url: &Url) -> Option<Url> {
let icons = manifest.get("icons")?.as_array()?;
let mut best_icon: Option<(&str, u8, u32)> = None;
for value in icons {
let Some(src) = value.get("src").and_then(Value::as_str) else {
continue;
};
let priority = manifest_icon_priority(value);
let size = value
.get("sizes")
.and_then(Value::as_str)
.and_then(parse_largest_icon_size)
.unwrap_or(0);
if best_icon
.map(|(_, best_priority, best_size)| {
priority > best_priority || (priority == best_priority && size > best_size)
})
.unwrap_or(true)
{
best_icon = Some((src, priority, size));
}
}
manifest_url.join(best_icon?.0).ok()
}
fn pick_manifest_icon_path(manifest: &Value, manifest_url: &Url) -> Option<String> {
let resolved = pick_manifest_icon_url(manifest, manifest_url)?;
Some(url_to_path(&resolved, manifest_url))
}
fn manifest_icon_priority(icon: &Value) -> u8 {
let Some(purpose) = icon.get("purpose").and_then(Value::as_str) else {
return 3;
};
let purposes: Vec<&str> = purpose.split_whitespace().collect();
if purposes
.iter()
.any(|value| value.eq_ignore_ascii_case("any"))
{
return 3;
}
if purposes
.iter()
.any(|value| value.eq_ignore_ascii_case("maskable"))
{
return 2;
}
if purposes
.iter()
.any(|value| value.eq_ignore_ascii_case("monochrome"))
{
return 1;
}
0
}
fn parse_largest_icon_size(sizes: &str) -> Option<u32> {
sizes
.split_whitespace()
.filter_map(|value| value.split_once('x'))
.filter_map(|(width, height)| {
let width = width.parse::<u32>().ok()?;
let height = height.parse::<u32>().ok()?;
Some(width.max(height))
})
.max()
}
fn rewrite_manifest_urls(manifest: &mut Value, manifest_url: &Url, manifest_path: &str) {
rewrite_manifest_url_field(manifest, "start_url", manifest_url, manifest_path);
rewrite_manifest_icon_like_array(manifest, "icons", manifest_url, manifest_path);
rewrite_manifest_icon_like_array(manifest, "screenshots", manifest_url, manifest_path);
if let Some(shortcuts) = manifest.get_mut("shortcuts").and_then(Value::as_array_mut) {
for shortcut in shortcuts {
rewrite_manifest_url_field(shortcut, "url", manifest_url, manifest_path);
rewrite_manifest_icon_like_array(shortcut, "icons", manifest_url, manifest_path);
}
}
if let Some(protocol_handlers) = manifest
.get_mut("protocol_handlers")
.and_then(Value::as_array_mut)
{
for handler in protocol_handlers {
rewrite_manifest_url_field(handler, "url", manifest_url, manifest_path);
}
}
if let Some(file_handlers) = manifest
.get_mut("file_handlers")
.and_then(Value::as_array_mut)
{
for handler in file_handlers {
rewrite_manifest_url_field(handler, "action", manifest_url, manifest_path);
rewrite_manifest_icon_like_array(handler, "icons", manifest_url, manifest_path);
}
}
if let Some(share_target) = manifest.get_mut("share_target") {
rewrite_manifest_url_field(share_target, "action", manifest_url, manifest_path);
rewrite_manifest_url_field(share_target, "url_template", manifest_url, manifest_path);
}
if let Some(note_taking) = manifest.get_mut("note_taking") {
rewrite_manifest_url_field(note_taking, "new_note_url", manifest_url, manifest_path);
}
if let Some(tab_strip) = manifest.get_mut("tab_strip") {
if let Some(home_tab) = tab_strip.get_mut("home_tab") {
rewrite_manifest_url_field(home_tab, "url", manifest_url, manifest_path);
rewrite_manifest_icon_like_array(home_tab, "icons", manifest_url, manifest_path);
}
if let Some(new_tab_button) = tab_strip.get_mut("new_tab_button") {
rewrite_manifest_url_field(new_tab_button, "url", manifest_url, manifest_path);
rewrite_manifest_icon_like_array(new_tab_button, "icons", manifest_url, manifest_path);
}
}
}
fn collect_manifest_url_field(value: Option<&Value>, manifest_url: &Url, urls: &mut Vec<Url>) {
let Some(raw_value) = value.and_then(Value::as_str) else {
return;
};
let Some(resolved) = resolve_resource_url(raw_value, manifest_url) else {
return;
};
urls.push(resolved);
}
fn collect_manifest_icon_like_urls(value: Option<&Value>, manifest_url: &Url, urls: &mut Vec<Url>) {
let Some(items) = value.and_then(Value::as_array) else {
return;
};
for item in items {
collect_manifest_url_field(item.get("src"), manifest_url, urls);
}
}
fn rewrite_manifest_url_field(
object: &mut Value,
key: &str,
manifest_url: &Url,
manifest_path: &str,
) {
let Some(value) = object.get_mut(key) else {
return;
};
let Some(raw_value) = value.as_str() else {
return;
};
let Some(resolved) = resolve_resource_url(raw_value, manifest_url) else {
return;
};
*value = Value::String(relative_tree_reference_for_url(
manifest_path,
&resolved,
manifest_url,
));
}
fn rewrite_manifest_icon_like_array(
object: &mut Value,
key: &str,
manifest_url: &Url,
manifest_path: &str,
) {
let Some(items) = object.get_mut(key).and_then(Value::as_array_mut) else {
return;
};
for item in items {
rewrite_manifest_url_field(item, "src", manifest_url, manifest_path);
}
}
fn relative_tree_reference_for_url(from_path: &str, target_url: &Url, base_url: &Url) -> String {
let target_path = url_to_path(target_url, base_url);
let mut relative = relative_tree_reference(from_path, &target_path);
if let Some(query) = target_url.query() {
relative.push('?');
relative.push_str(query);
}
if let Some(fragment) = target_url.fragment() {
relative.push('#');
relative.push_str(fragment);
}
relative
}
fn absolute_tree_reference_for_url(target_url: &Url, base_url: &Url) -> String {
let target_path = url_to_path(target_url, base_url);
let mut absolute = absolute_tree_path(&target_path);
if let Some(query) = target_url.query() {
absolute.push('?');
absolute.push_str(query);
}
if let Some(fragment) = target_url.fragment() {
absolute.push('#');
absolute.push_str(fragment);
}
absolute
}
fn absolute_tree_reference_from_path(from_path: &str, reference: &str) -> Option<String> {
let trimmed_reference = reference.trim();
if trimmed_reference.is_empty() {
return None;
}
let base_url = Url::parse(&format!(
"https://tree.invalid/{}",
normalize_asset_path(from_path)
))
.ok()?;
let resolved = base_url.join(trimmed_reference).ok()?;
Some(absolute_tree_reference_for_url(&resolved, &resolved))
}
fn rewrite_html_asset_urls(
html: &str,
html_path: &str,
html_url: &Url,
base_url: &Url,
) -> (String, Vec<Url>) {
let mut html_rewrites = Vec::new();
let mut queued_assets = BTreeSet::new();
for link_asset in extract_link_asset_references(html, html_url) {
let asset_path = url_to_path(&link_asset.resolved_url, base_url);
html_rewrites.push((
link_asset.raw_value,
relative_tree_reference(html_path, &asset_path),
));
queued_assets.insert(link_asset.resolved_url);
}
for script in extract_script_references(html, html_url) {
let asset_path = url_to_path(&script.resolved_url, base_url);
html_rewrites.push((
script.raw_value,
relative_tree_reference(html_path, &asset_path),
));
queued_assets.insert(script.resolved_url);
}
for image in extract_image_references(html, html_url) {
let asset_path = url_to_path(&image.resolved_url, base_url);
html_rewrites.push((
image.raw_value,
relative_tree_reference(html_path, &asset_path),
));
queued_assets.insert(image.resolved_url);
}
(
rewrite_html_urls(html, &html_rewrites),
queued_assets.into_iter().collect(),
)
}
fn rewrite_html_urls(html: &str, rewrites: &[(String, String)]) -> String {
let mut output = html.to_string();
let mut sorted_rewrites = rewrites.to_vec();
sorted_rewrites.sort_by(|(left, _), (right, _)| right.len().cmp(&left.len()));
for (from, to) in sorted_rewrites {
output = output.replace(&from, &to);
}
output
}
fn rewrite_css_urls(css: &str, css_path: &str, css_url: &Url, base_url: &Url) -> String {
let mut output = String::with_capacity(css.len());
let mut cursor = 0usize;
while let Some(found) = css[cursor..].find("url(") {
let start = cursor + found;
output.push_str(&css[cursor..start]);
let mut value_start = start + 4;
while let Some(ch) = css[value_start..].chars().next() {
if ch.is_whitespace() {
value_start += ch.len_utf8();
continue;
}
break;
}
let mut quoted = None;
if let Some(ch) = css[value_start..].chars().next() {
if ch == '"' || ch == '\'' {
quoted = Some(ch);
value_start += ch.len_utf8();
}
}
let mut value_end = value_start;
while value_end < css.len() {
let ch = css[value_end..].chars().next().unwrap_or(')');
if let Some(quote) = quoted {
if ch == quote {
break;
}
} else if ch == ')' {
break;
}
value_end += ch.len_utf8();
}
let raw_value = css[value_start..value_end].trim();
let mut after_value = value_end;
if quoted.is_some() && after_value < css.len() {
after_value += css[after_value..]
.chars()
.next()
.map(|ch| ch.len_utf8())
.unwrap_or(0);
}
while after_value < css.len() {
let ch = css[after_value..].chars().next().unwrap_or(')');
after_value += ch.len_utf8();
if ch == ')' {
break;
}
}
if let Some(resolved) = resolve_resource_url(raw_value, css_url) {
let target_path = url_to_path(&resolved, base_url);
output.push_str(&format!(
"url(\"{}\")",
relative_tree_reference(css_path, &target_path)
));
} else {
output.push_str(&css[start..after_value]);
}
cursor = after_value;
}
output.push_str(&css[cursor..]);
output
}
fn is_http_url(value: &str) -> bool {
Url::parse(value)
.ok()
.map(|url| matches!(url.scheme(), "http" | "https"))
.unwrap_or(false)
}
fn looks_like_image_payload(content_type: &str, bytes: &[u8]) -> bool {
let normalized_content_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
if normalized_content_type.starts_with("image/") {
return true;
}
bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])
|| bytes.starts_with(&[0xFF, 0xD8, 0xFF])
|| bytes.starts_with(b"GIF87a")
|| bytes.starts_with(b"GIF89a")
|| bytes.starts_with(&[0x00, 0x00, 0x01, 0x00])
|| bytes.starts_with(&[0x00, 0x00, 0x02, 0x00])
|| (bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP")
|| bytes_trimmed_starts_with_svg(bytes)
}
fn bytes_trimmed_starts_with_svg(bytes: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(bytes) else {
return false;
};
let trimmed = text.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{FEFF}');
trimmed.starts_with("<svg") || (trimmed.starts_with("<?xml") && trimmed.contains("<svg"))
}
fn icon_asset_path(icon_url: &Url, content_type: &str, bytes: &[u8]) -> String {
let mut path = url_to_path(icon_url, icon_url);
let has_extension = Path::new(&path)
.extension()
.and_then(|value| value.to_str())
.map(|value| !value.is_empty())
.unwrap_or(false);
if !has_extension {
let extension = infer_icon_extension(content_type, bytes).unwrap_or("bin");
if path == "index.html" {
path = format!("icon.{extension}");
} else {
path = format!("{path}.{extension}");
}
}
path
}
fn infer_icon_extension(content_type: &str, bytes: &[u8]) -> Option<&'static str> {
let normalized_content_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
if normalized_content_type == "image/png" || bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
return Some("png");
}
if normalized_content_type == "image/jpeg" || bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some("jpg");
}
if normalized_content_type == "image/gif"
|| bytes.starts_with(b"GIF87a")
|| bytes.starts_with(b"GIF89a")
{
return Some("gif");
}
if normalized_content_type == "image/webp"
|| (bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP")
{
return Some("webp");
}
if normalized_content_type == "image/svg+xml" || bytes_trimmed_starts_with_svg(bytes) {
return Some("svg");
}
if normalized_content_type == "image/x-icon"
|| normalized_content_type == "image/vnd.microsoft.icon"
|| bytes.starts_with(&[0x00, 0x00, 0x01, 0x00])
|| bytes.starts_with(&[0x00, 0x00, 0x02, 0x00])
{
return Some("ico");
}
None
}
fn extract_manifest_reference(html: &str, base_url: &Url) -> Option<AssetReference> {
extract_tag_attributes(html, "link")
.into_iter()
.find(|attrs| rel_contains(attrs, "manifest") && attrs.contains_key("href"))
.and_then(|attrs| attrs.get("href").cloned())
.and_then(|href| asset_reference(href, base_url))
}
fn extract_link_asset_references(html: &str, base_url: &Url) -> Vec<AssetReference> {
const ASSET_LINK_RELS: &[&str] = &["stylesheet", "modulepreload", "preload", "prefetch"];
extract_tag_attributes(html, "link")
.into_iter()
.filter(|attrs| ASSET_LINK_RELS.iter().any(|rel| rel_contains(attrs, rel)))
.filter_map(|attrs| attrs.get("href").cloned())
.filter_map(|href| asset_reference(href, base_url))
.collect()
}
fn extract_script_references(html: &str, base_url: &Url) -> Vec<AssetReference> {
extract_tag_attributes(html, "script")
.into_iter()
.filter_map(|attrs| attrs.get("src").cloned())
.filter_map(|src| asset_reference(src, base_url))
.collect()
}
fn extract_image_references(html: &str, base_url: &Url) -> Vec<AssetReference> {
extract_tag_attributes(html, "img")
.into_iter()
.filter_map(|attrs| attrs.get("src").cloned())
.filter_map(|src| asset_reference(src, base_url))
.collect()
}
fn extract_css_urls(css: &str, css_url: &Url) -> Vec<Url> {
let mut urls = Vec::new();
let mut cursor = 0usize;
while let Some(found) = css[cursor..].find("url(") {
let start = cursor + found + 4;
let mut value_start = start;
while let Some(ch) = css[value_start..].chars().next() {
if ch.is_whitespace() {
value_start += ch.len_utf8();
continue;
}
break;
}
let mut quoted = None;
if let Some(ch) = css[value_start..].chars().next() {
if ch == '"' || ch == '\'' {
quoted = Some(ch);
value_start += ch.len_utf8();
}
}
let mut value_end = value_start;
while value_end < css.len() {
let ch = css[value_end..].chars().next().unwrap_or(')');
if let Some(quote) = quoted {
if ch == quote {
break;
}
} else if ch == ')' {
break;
}
value_end += ch.len_utf8();
}
let raw_value = css[value_start..value_end].trim();
if let Some(resolved) = resolve_resource_url(raw_value, css_url) {
urls.push(resolved);
}
let mut after_value = value_end;
if quoted.is_some() && after_value < css.len() {
after_value += css[after_value..]
.chars()
.next()
.map(|ch| ch.len_utf8())
.unwrap_or(0);
}
while after_value < css.len() {
let ch = css[after_value..].chars().next().unwrap_or(')');
after_value += ch.len_utf8();
if ch == ')' {
break;
}
}
cursor = after_value;
}
urls
}
fn extract_tag_attributes(html: &str, tag_name: &str) -> Vec<HashMap<String, String>> {
let needle = format!("<{}", tag_name.to_ascii_lowercase());
let lowercase_html = html.to_ascii_lowercase();
let mut results = Vec::new();
let mut cursor = 0usize;
while let Some(found) = lowercase_html[cursor..].find(&needle) {
let start = cursor + found;
let end = match find_tag_end(html, start + 1) {
Some(end) => end,
None => break,
};
let tag_body = &html[start + 1..end];
if tag_body
.split_whitespace()
.next()
.map(|name| name.eq_ignore_ascii_case(tag_name))
.unwrap_or(false)
{
results.push(parse_attributes(tag_body));
}
cursor = end + 1;
}
results
}
fn find_tag_end(html: &str, mut cursor: usize) -> Option<usize> {
let mut quote = None;
while cursor < html.len() {
let ch = html[cursor..].chars().next()?;
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
} else if ch == '"' || ch == '\'' {
quote = Some(ch);
} else if ch == '>' {
return Some(cursor);
}
cursor += ch.len_utf8();
}
None
}
fn parse_attributes(tag_body: &str) -> HashMap<String, String> {
let mut attributes = HashMap::new();
let mut cursor = tag_body
.chars()
.position(char::is_whitespace)
.unwrap_or(tag_body.len());
while cursor < tag_body.len() {
while cursor < tag_body.len() {
let ch = tag_body[cursor..].chars().next().unwrap_or(' ');
if !ch.is_whitespace() && ch != '/' {
break;
}
cursor += ch.len_utf8();
}
if cursor >= tag_body.len() {
break;
}
let name_start = cursor;
while cursor < tag_body.len() {
let ch = tag_body[cursor..].chars().next().unwrap_or(' ');
if ch.is_whitespace() || ch == '=' || ch == '/' {
break;
}
cursor += ch.len_utf8();
}
if name_start == cursor {
break;
}
let name = tag_body[name_start..cursor].to_ascii_lowercase();
while cursor < tag_body.len()
&& tag_body[cursor..]
.chars()
.next()
.unwrap_or(' ')
.is_whitespace()
{
cursor += tag_body[cursor..]
.chars()
.next()
.map(|ch| ch.len_utf8())
.unwrap_or(1);
}
let mut value = String::new();
if cursor < tag_body.len() && tag_body[cursor..].starts_with('=') {
cursor += 1;
while cursor < tag_body.len()
&& tag_body[cursor..]
.chars()
.next()
.unwrap_or(' ')
.is_whitespace()
{
cursor += tag_body[cursor..]
.chars()
.next()
.map(|ch| ch.len_utf8())
.unwrap_or(1);
}
if cursor < tag_body.len() {
let next = tag_body[cursor..].chars().next().unwrap_or('"');
if next == '"' || next == '\'' {
let quote = next;
cursor += quote.len_utf8();
let value_start = cursor;
while cursor < tag_body.len() {
let ch = tag_body[cursor..].chars().next().unwrap_or(quote);
if ch == quote {
break;
}
cursor += ch.len_utf8();
}
value = tag_body[value_start..cursor].to_string();
if cursor < tag_body.len() {
cursor += quote.len_utf8();
}
} else {
let value_start = cursor;
while cursor < tag_body.len() {
let ch = tag_body[cursor..].chars().next().unwrap_or(' ');
if ch.is_whitespace() || ch == '/' {
break;
}
cursor += ch.len_utf8();
}
value = tag_body[value_start..cursor].to_string();
}
}
}
attributes.insert(name, value);
}
attributes
}
fn rel_contains(attrs: &HashMap<String, String>, token: &str) -> bool {
attrs
.get("rel")
.map(|value| {
value
.split_whitespace()
.any(|part| part.eq_ignore_ascii_case(token))
})
.unwrap_or(false)
}
fn asset_reference(raw_value: String, base_url: &Url) -> Option<AssetReference> {
let resolved_url = resolve_resource_url(&raw_value, base_url)?;
Some(AssetReference {
raw_value,
resolved_url,
})
}
fn resolve_resource_url(value: &str, base_url: &Url) -> Option<Url> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.starts_with("data:")
|| trimmed.starts_with("javascript:")
|| trimmed.starts_with("mailto:")
|| trimmed.starts_with("tel:")
{
return None;
}
let resolved = base_url.join(trimmed).ok()?;
if matches!(resolved.scheme(), "http" | "https") {
Some(resolved)
} else {
None
}
}
fn extract_title(html: &str) -> Option<String> {
let lowercase = html.to_ascii_lowercase();
let start = lowercase.find("<title>")?;
let end = lowercase[start + 7..].find("</title>")?;
let value = html[start + 7..start + 7 + end].trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn url_to_path(url: &Url, base_url: &Url) -> String {
let mut path = url.path().trim_start_matches('/').to_string();
if path.is_empty() || path.ends_with('/') {
path.push_str("index.html");
}
if url.origin() == base_url.origin() {
return path;
}
let host = url.host_str().unwrap_or("external");
format!("_external/{host}/{path}")
}
fn relative_tree_reference(from_path: &str, target_path: &str) -> String {
let from_clean = normalize_asset_path(from_path);
let target_clean = normalize_asset_path(target_path);
if target_clean.is_empty() {
return "index.html".to_string();
}
let from_parent = parent_path(&from_clean);
let from_segments: Vec<&str> = from_parent
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
let target_segments: Vec<&str> = target_clean
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
let mut common = 0usize;
while common < from_segments.len()
&& common < target_segments.len()
&& from_segments[common] == target_segments[common]
{
common += 1;
}
let mut relative_segments: Vec<String> =
vec!["..".to_string(); from_segments.len().saturating_sub(common)];
relative_segments.extend(
target_segments[common..]
.iter()
.map(|segment| (*segment).to_string()),
);
if relative_segments.is_empty() {
file_name(&target_clean).unwrap_or_else(|| "index.html".to_string())
} else {
relative_segments.join("/")
}
}
fn root_relative_path(path: &str) -> String {
let clean = normalize_asset_path(path);
if clean.is_empty() {
"/index.html".to_string()
} else {
format!("/{}", clean)
}
}
fn absolute_tree_path(path: &str) -> String {
root_relative_path(path)
}
fn normalize_asset_path(path: &str) -> String {
path.trim_matches('/').to_string()
}
fn split_parent_and_name(path: &str) -> (String, String) {
match path.rsplit_once('/') {
Some((parent, name)) => (parent.to_string(), name.to_string()),
None => (String::new(), path.to_string()),
}
}
fn parent_chain(path: &str) -> Vec<String> {
let mut parents = Vec::new();
let mut current = parent_path(path);
parents.push(String::new());
while !current.is_empty() {
parents.push(current.clone());
current = parent_path(¤t);
}
parents
}
fn parent_path(path: &str) -> String {
path.rsplit_once('/')
.map(|(parent, _)| parent.to_string())
.unwrap_or_default()
}
fn file_name(path: &str) -> Option<String> {
path.rsplit('/').next().map(str::to_owned)
}
fn dir_depth(path: &str) -> usize {
if path.is_empty() {
0
} else {
path.split('/').count()
}
}
fn display_dir(path: &str) -> &str {
if path.is_empty() {
"/"
} else {
path
}
}
#[cfg(test)]
mod tests {
use super::*;
use hashtree_core::nhash_decode;
use serde_json::json;
use tempfile::tempdir;
const LIVE_JUMBLE_SMOKE_URL: &str = "https://jumble.social/";
const LIVE_JUMBLE_SMOKE_MANIFEST_URL: &str = "https://jumble.social/manifest.webmanifest";
const LIVE_PHOTOPEA_SMOKE_URL: &str = "https://www.photopea.com/";
const LIVE_PHOTOPEA_SMOKE_MANIFEST_URL: &str = "https://www.photopea.com/manifest.json";
const LIVE_EXCALIDRAW_SMOKE_URL: &str = "https://excalidraw.com/";
const LIVE_EXCALIDRAW_SMOKE_MANIFEST_URL: &str = "https://excalidraw.com/manifest.webmanifest";
const LIVE_FASTMAIL_SMOKE_URL: &str = "https://app.fastmail.com/";
const LIVE_FASTMAIL_SMOKE_MANIFEST_PREFIX: &str = "https://app.fastmail.com/static/jmapui/";
const LIVE_FASTMAIL_SMOKE_MANIFEST_SUFFIX: &str = "/app.webmanifest";
const LIVE_MASTODON_SMOKE_URL: &str = "https://mastodon.social/";
const LIVE_MASTODON_SMOKE_MANIFEST_URL: &str = "https://mastodon.social/manifest";
fn split_htree_nhash_url(url: &str) -> (String, String) {
let trimmed = url.strip_prefix("htree://").expect("htree:// url");
let (host, path) = trimmed.split_once('/').unwrap_or((trimmed, ""));
let normalized_path = if path.is_empty() {
"/".to_string()
} else {
format!("/{path}")
};
(host.to_string(), normalized_path)
}
async fn read_exported_tree_text(
store: &HashtreeStore,
root_nhash: &str,
path: &str,
) -> String {
let root = nhash_decode(root_nhash).unwrap();
let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
let root_cid = Cid::public(root.hash);
let resolved_path = path
.trim_start_matches('/')
.split(['?', '#'])
.next()
.unwrap_or("");
let file_cid = tree
.resolve_path(&root_cid, resolved_path)
.await
.unwrap()
.expect("resolve exported path");
String::from_utf8(
tree.read_file(&file_cid.hash)
.await
.unwrap()
.expect("read exported file"),
)
.unwrap()
}
#[test]
fn extract_manifest_url_finds_manifest_link() {
let html = r#"
<html>
<head>
<link rel="manifest" href="/manifest.webmanifest">
</head>
</html>
"#;
let base_url = Url::parse("https://jumble.social/").unwrap();
assert_eq!(
extract_manifest_reference(html, &base_url)
.unwrap()
.resolved_url
.as_str(),
"https://jumble.social/manifest.webmanifest"
);
}
#[test]
fn parse_attributes_supports_quoted_values() {
let attrs = parse_attributes(
r#"link rel="manifest preload" href='/manifest.webmanifest' crossorigin"#,
);
assert_eq!(
attrs.get("rel").map(String::as_str),
Some("manifest preload")
);
assert_eq!(
attrs.get("href").map(String::as_str),
Some("/manifest.webmanifest")
);
assert_eq!(attrs.get("crossorigin").map(String::as_str), Some(""));
}
#[test]
fn url_to_path_maps_root_and_trailing_slash_to_index() {
let base_url = Url::parse("https://jumble.social/").unwrap();
assert_eq!(
url_to_path(&Url::parse("https://jumble.social/").unwrap(), &base_url),
"index.html"
);
assert_eq!(
url_to_path(
&Url::parse("https://jumble.social/app/").unwrap(),
&base_url
),
"app/index.html"
);
assert_eq!(
url_to_path(&Url::parse("https://jumble.social/app").unwrap(), &base_url),
"app"
);
assert_eq!(
url_to_path(
&Url::parse("https://cdn.example.com/fonts/app.woff2").unwrap(),
&base_url
),
"_external/cdn.example.com/fonts/app.woff2"
);
}
#[test]
fn rewrite_css_urls_rewrites_relative_and_absolute_urls_to_root_paths() {
let css = r#"
body { background-image: url("../img/bg.png"); }
@font-face { src: url("https://cdn.example.com/fonts/app.woff2"); }
"#;
let css_url = Url::parse("https://jumble.social/assets/main.css").unwrap();
let rewritten = rewrite_css_urls(
css,
"assets/main.css",
&css_url,
&Url::parse("https://jumble.social/").unwrap(),
);
assert!(rewritten.contains("url(\"../img/bg.png\")"));
assert!(rewritten.contains("url(\"../_external/cdn.example.com/fonts/app.woff2\")"));
}
#[test]
fn rewrite_html_asset_urls_rewrites_nested_page_dependencies() {
let html = r#"
<link rel="stylesheet" href="/assets/main.css">
<link rel="modulepreload" href="/assets/chunk.js">
<link rel="preload" as="font" href="https://cdn.example.com/fonts/app.woff2">
<script type="module" src="bundle.js"></script>
<img src="https://cdn.example.com/logo.png">
"#;
let html_url = Url::parse("https://jumble.social/app/index.html").unwrap();
let base_url = Url::parse("https://jumble.social/").unwrap();
let (rewritten, mut nested_urls) =
rewrite_html_asset_urls(html, "app/index.html", &html_url, &base_url);
nested_urls.sort_by(|left, right| left.as_str().cmp(right.as_str()));
assert!(rewritten.contains(r#"href="../assets/main.css""#));
assert!(rewritten.contains(r#"href="../assets/chunk.js""#));
assert!(rewritten.contains(r#"href="../_external/cdn.example.com/fonts/app.woff2""#));
assert!(rewritten.contains(r#"src="bundle.js""#));
assert!(rewritten.contains(r#"src="../_external/cdn.example.com/logo.png""#));
assert_eq!(
nested_urls
.into_iter()
.map(|url| url.to_string())
.collect::<Vec<_>>(),
vec![
"https://cdn.example.com/fonts/app.woff2",
"https://cdn.example.com/logo.png",
"https://jumble.social/app/bundle.js",
"https://jumble.social/assets/chunk.js",
"https://jumble.social/assets/main.css",
]
);
}
#[test]
fn manifest_start_reference_preserves_query_and_fragment() {
let manifest = json!({
"start_url": "../index.html?source=pwa#home"
});
let manifest_url = Url::parse("https://jumble.social/app/manifest.webmanifest").unwrap();
assert_eq!(
manifest_start_reference(&manifest, &manifest_url),
Some("/index.html?source=pwa#home".to_string())
);
}
#[test]
fn manifest_scope_reference_preserves_root_scope() {
let manifest = json!({
"scope": "/"
});
let manifest_url =
Url::parse("https://app.fastmail.com/static/jmapui/hash/app.webmanifest").unwrap();
assert_eq!(
manifest_scope_reference(&manifest, &manifest_url),
Some("/".to_string())
);
}
#[test]
fn manifest_handlers_resolve_to_absolute_tree_paths() {
let manifest = json!({
"shortcuts": [
{
"name": "Compose",
"url": "../../../mail/Inbox/compose"
},
{
"name": "Contacts",
"url": "../../../contacts/index.html"
}
],
"protocol_handlers": [
{
"protocol": "mailto",
"url": "../../../mail/compose?mailto=%s"
}
]
});
assert_eq!(
manifest_shortcuts(&manifest, "static/jmapui/hash/app.webmanifest"),
vec![
PwaShortcut {
name: "Compose".to_string(),
url: "/mail/Inbox/compose".to_string(),
},
PwaShortcut {
name: "Contacts".to_string(),
url: "/contacts/index.html".to_string(),
},
]
);
assert_eq!(
manifest_protocol_handlers(&manifest, "static/jmapui/hash/app.webmanifest"),
vec![PwaProtocolHandler {
protocol: "mailto".to_string(),
url: "/mail/compose?mailto=%s".to_string(),
}]
);
}
#[test]
fn installed_site_pwa_serialization_includes_manifest_metadata() {
let installed = InstalledSitePwa {
name: "Example App".to_string(),
launch_url: "htree://nhash-example/app/index.html".to_string(),
icon_url: Some("htree://nhash-example/icons/pwa-192.png".to_string()),
source_app_id: Some("https://example.com/app".to_string()),
source_url: "https://example.com/app".to_string(),
source_manifest_url: "https://example.com/manifest.webmanifest".to_string(),
description: Some("Portable notes".to_string()),
display_mode: Some("minimal-ui".to_string()),
scope_url: Some("htree://nhash-example/".to_string()),
shortcuts: vec![PwaShortcut {
name: "Compose".to_string(),
url: "htree://nhash-example/mail/Inbox/compose".to_string(),
}],
protocol_handlers: vec![PwaProtocolHandler {
protocol: "mailto".to_string(),
url: "htree://nhash-example/mail/compose?mailto=%s".to_string(),
}],
};
let value = serde_json::to_value(installed).unwrap();
assert_eq!(
value.get("description").and_then(Value::as_str),
Some("Portable notes")
);
assert_eq!(
value.get("displayMode").and_then(Value::as_str),
Some("minimal-ui")
);
assert_eq!(
value.get("scopeUrl").and_then(Value::as_str),
Some("htree://nhash-example/")
);
assert_eq!(
value["shortcuts"][0].get("name").and_then(Value::as_str),
Some("Compose")
);
assert_eq!(
value["shortcuts"][0].get("url").and_then(Value::as_str),
Some("htree://nhash-example/mail/Inbox/compose")
);
assert_eq!(
value["protocolHandlers"][0]
.get("protocol")
.and_then(Value::as_str),
Some("mailto")
);
assert_eq!(
value["protocolHandlers"][0]
.get("url")
.and_then(Value::as_str),
Some("htree://nhash-example/mail/compose?mailto=%s")
);
}
#[test]
fn manifest_metadata_extractors_read_description_and_supported_display() {
let manifest = json!({
"description": " Portable notes ",
"display": "minimal-ui"
});
assert_eq!(
manifest_description(&manifest),
Some("Portable notes".to_string())
);
assert_eq!(
manifest_display_mode(&manifest),
Some("minimal-ui".to_string())
);
}
#[test]
fn manifest_display_mode_ignores_unsupported_values() {
let manifest = json!({
"display": "window-controls-overlay"
});
assert_eq!(manifest_display_mode(&manifest), None);
}
#[test]
fn pick_manifest_icon_url_prefers_any_icons_over_monochrome_ties() {
let manifest = json!({
"icons": [
{"src": "/pwa-512x512.png", "sizes": "512x512", "purpose": "any"},
{"src": "/pwa-192x192.png", "sizes": "192x192", "purpose": "any"},
{"src": "/pwa-512x512-maskable.png", "sizes": "512x512", "purpose": "maskable"},
{"src": "/pwa-monochrome.svg", "sizes": "512x512", "purpose": "monochrome"}
]
});
let manifest_url = Url::parse("https://jumble.social/manifest.webmanifest").unwrap();
assert_eq!(
pick_manifest_icon_url(&manifest, &manifest_url).map(|value| value.to_string()),
Some("https://jumble.social/pwa-512x512.png".to_string())
);
}
#[test]
fn extract_manifest_resource_urls_collects_nested_manifest_fields() {
let manifest = json!({
"start_url": "../index.html?source=pwa#home",
"icons": [
{"src": "icons/app.png"},
{"src": "https://cdn.example.com/icons/maskable.png"}
],
"screenshots": [
{"src": "shots/hero.png"}
],
"shortcuts": [
{
"url": "../launch/compose.html?mode=quick#composer",
"icons": [
{"src": "icons/shortcut.png"}
]
}
],
"protocol_handlers": [
{"url": "open?uri=placeholder"}
],
"file_handlers": [
{
"action": "/open-file",
"icons": [
{"src": "icons/file.png"}
]
}
],
"share_target": {
"action": "/share/submit?from=manifest"
},
"note_taking": {
"new_note_url": "/notes/new.html"
},
"tab_strip": {
"home_tab": {
"url": "/home.html",
"icons": [
{"src": "icons/home.png"}
]
},
"new_tab_button": {
"url": "tabs/new.html"
}
}
});
let manifest_url = Url::parse("https://jumble.social/app/manifest.webmanifest").unwrap();
let mut urls: Vec<String> = extract_manifest_resource_urls(&manifest, &manifest_url)
.into_iter()
.map(|url| url.to_string())
.collect();
urls.sort();
assert_eq!(
urls,
vec![
"https://cdn.example.com/icons/maskable.png",
"https://jumble.social/app/icons/app.png",
"https://jumble.social/app/icons/file.png",
"https://jumble.social/app/icons/home.png",
"https://jumble.social/app/icons/shortcut.png",
"https://jumble.social/app/open?uri=placeholder",
"https://jumble.social/app/shots/hero.png",
"https://jumble.social/app/tabs/new.html",
"https://jumble.social/home.html",
"https://jumble.social/index.html?source=pwa#home",
"https://jumble.social/launch/compose.html?mode=quick#composer",
"https://jumble.social/notes/new.html",
"https://jumble.social/open-file",
"https://jumble.social/share/submit?from=manifest",
]
);
}
#[test]
fn rewrite_manifest_urls_rewrites_nested_manifest_fields() {
let mut manifest = json!({
"start_url": "../index.html?source=pwa#home",
"icons": [
{"src": "icons/app.png"},
{"src": "https://cdn.example.com/icons/maskable.png"}
],
"screenshots": [
{"src": "shots/hero.png"}
],
"shortcuts": [
{
"url": "../launch/compose.html?mode=quick#composer",
"icons": [
{"src": "icons/shortcut.png"}
]
}
],
"protocol_handlers": [
{"url": "open?uri=placeholder"}
],
"file_handlers": [
{
"action": "/open-file",
"icons": [
{"src": "icons/file.png"}
]
}
],
"share_target": {
"action": "/share/submit?from=manifest"
},
"note_taking": {
"new_note_url": "/notes/new.html"
},
"tab_strip": {
"home_tab": {
"url": "/home.html",
"icons": [
{"src": "icons/home.png"}
]
},
"new_tab_button": {
"url": "tabs/new.html"
}
}
});
let manifest_url = Url::parse("https://jumble.social/app/manifest.webmanifest").unwrap();
rewrite_manifest_urls(&mut manifest, &manifest_url, "app/manifest.webmanifest");
assert_eq!(
manifest.get("start_url").and_then(Value::as_str),
Some("../index.html?source=pwa#home")
);
assert_eq!(
manifest["icons"][0].get("src").and_then(Value::as_str),
Some("icons/app.png")
);
assert_eq!(
manifest["icons"][1].get("src").and_then(Value::as_str),
Some("../_external/cdn.example.com/icons/maskable.png")
);
assert_eq!(
manifest["screenshots"][0]
.get("src")
.and_then(Value::as_str),
Some("shots/hero.png")
);
assert_eq!(
manifest["shortcuts"][0].get("url").and_then(Value::as_str),
Some("../launch/compose.html?mode=quick#composer")
);
assert_eq!(
manifest["shortcuts"][0]["icons"][0]
.get("src")
.and_then(Value::as_str),
Some("icons/shortcut.png")
);
assert_eq!(
manifest["protocol_handlers"][0]
.get("url")
.and_then(Value::as_str),
Some("open?uri=placeholder")
);
assert_eq!(
manifest["file_handlers"][0]
.get("action")
.and_then(Value::as_str),
Some("../open-file")
);
assert_eq!(
manifest["file_handlers"][0]["icons"][0]
.get("src")
.and_then(Value::as_str),
Some("icons/file.png")
);
assert_eq!(
manifest["share_target"]
.get("action")
.and_then(Value::as_str),
Some("../share/submit?from=manifest")
);
assert_eq!(
manifest["note_taking"]
.get("new_note_url")
.and_then(Value::as_str),
Some("../notes/new.html")
);
assert_eq!(
manifest["tab_strip"]["home_tab"]
.get("url")
.and_then(Value::as_str),
Some("../home.html")
);
assert_eq!(
manifest["tab_strip"]["home_tab"]["icons"][0]
.get("src")
.and_then(Value::as_str),
Some("icons/home.png")
);
assert_eq!(
manifest["tab_strip"]["new_tab_button"]
.get("url")
.and_then(Value::as_str),
Some("tabs/new.html")
);
}
#[tokio::test]
#[ignore = "live network smoke test against jumble.social"]
async fn installs_live_jumble_social_pwa_with_primary_app_icon() {
let temp_dir = tempdir().unwrap();
let store = HashtreeStore::new(temp_dir.path()).unwrap();
let installed = install_site_pwa_to_store(&store, LIVE_JUMBLE_SMOKE_URL)
.await
.unwrap();
assert_eq!(installed.name, "Jumble");
assert_eq!(installed.source_app_id, None);
assert_eq!(installed.source_url, LIVE_JUMBLE_SMOKE_URL);
assert_eq!(
installed.source_manifest_url,
LIVE_JUMBLE_SMOKE_MANIFEST_URL
);
assert_eq!(
installed.description.as_deref(),
Some("A user-friendly Nostr client for exploring relay feeds")
);
assert_eq!(installed.display_mode.as_deref(), Some("standalone"));
assert!(installed.launch_url.starts_with("htree://nhash1"));
assert!(installed.launch_url.ends_with("/index.html"));
let icon_url = installed.icon_url.clone().expect("installed icon url");
let (launch_nhash, launch_path) = split_htree_nhash_url(&installed.launch_url);
let (icon_nhash, icon_path) = split_htree_nhash_url(&icon_url);
assert_eq!(icon_nhash, launch_nhash);
assert_eq!(icon_path, "/pwa-512x512.png");
let launch_html = read_exported_tree_text(&store, &launch_nhash, &launch_path).await;
assert!(launch_html.contains("manifest.webmanifest"));
assert!(!launch_html.contains("src=\"/assets/"));
assert!(!launch_html.contains("href=\"/assets/"));
}
#[tokio::test]
#[ignore = "live network smoke test against photopea.com"]
async fn installs_live_photopea_pwa_with_rewritten_file_handlers() {
let temp_dir = tempdir().unwrap();
let store = HashtreeStore::new(temp_dir.path()).unwrap();
let installed = install_site_pwa_to_store(&store, LIVE_PHOTOPEA_SMOKE_URL)
.await
.unwrap();
assert_eq!(installed.name, "Photopea");
assert_eq!(installed.source_app_id, None);
assert_eq!(installed.source_url, LIVE_PHOTOPEA_SMOKE_URL);
assert_eq!(
installed.source_manifest_url,
LIVE_PHOTOPEA_SMOKE_MANIFEST_URL
);
assert_eq!(installed.description, None);
assert_eq!(installed.display_mode.as_deref(), Some("standalone"));
assert!(installed.launch_url.starts_with("htree://nhash1"));
assert!(installed
.launch_url
.ends_with("/index.html?utm_source=homescreen"));
let icon_url = installed.icon_url.clone().expect("installed icon url");
let (launch_nhash, launch_path) = split_htree_nhash_url(&installed.launch_url);
let (icon_nhash, icon_path) = split_htree_nhash_url(&icon_url);
assert_eq!(icon_nhash, launch_nhash);
assert_eq!(icon_path, "/promo/icon512.png");
let launch_html = read_exported_tree_text(&store, &launch_nhash, &launch_path).await;
assert!(launch_html.contains("manifest.json"));
assert!(!launch_html.contains("src=\"/img/"));
assert!(!launch_html.contains("href=\"/img/"));
let manifest_text = read_exported_tree_text(&store, &launch_nhash, "/manifest.json").await;
let manifest: Value = serde_json::from_str(&manifest_text).unwrap();
assert_eq!(
manifest.get("start_url").and_then(Value::as_str),
Some("index.html?utm_source=homescreen")
);
assert_eq!(
manifest["share_target"]
.get("action")
.and_then(Value::as_str),
Some("index.html")
);
let file_handlers = manifest["file_handlers"]
.as_array()
.expect("file_handlers array");
assert!(file_handlers.len() >= 20);
for handler in file_handlers {
assert_eq!(
handler.get("action").and_then(Value::as_str),
Some("index.html")
);
}
}
#[tokio::test]
#[ignore = "live network smoke test against excalidraw.com"]
async fn installs_live_excalidraw_pwa_with_rewritten_screenshots() {
let temp_dir = tempdir().unwrap();
let store = HashtreeStore::new(temp_dir.path()).unwrap();
let installed = install_site_pwa_to_store(&store, LIVE_EXCALIDRAW_SMOKE_URL)
.await
.unwrap();
assert_eq!(installed.name, "Excalidraw");
assert_eq!(
installed.source_app_id.as_deref(),
Some("https://excalidraw.com/excalidraw")
);
assert_eq!(installed.source_url, LIVE_EXCALIDRAW_SMOKE_URL);
assert_eq!(
installed.source_manifest_url,
LIVE_EXCALIDRAW_SMOKE_MANIFEST_URL
);
assert_eq!(
installed.description.as_deref(),
Some(
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
)
);
assert_eq!(installed.display_mode.as_deref(), Some("standalone"));
assert!(installed.launch_url.starts_with("htree://nhash1"));
assert!(installed.launch_url.ends_with("/index.html"));
let icon_url = installed.icon_url.clone().expect("installed icon url");
let (launch_nhash, launch_path) = split_htree_nhash_url(&installed.launch_url);
let (icon_nhash, icon_path) = split_htree_nhash_url(&icon_url);
assert_eq!(icon_nhash, launch_nhash);
assert!(icon_path.ends_with(".png"));
let launch_html = read_exported_tree_text(&store, &launch_nhash, &launch_path).await;
assert!(launch_html.contains("manifest.webmanifest"));
let manifest_text =
read_exported_tree_text(&store, &launch_nhash, "/manifest.webmanifest").await;
let manifest: Value = serde_json::from_str(&manifest_text).unwrap();
assert_eq!(
manifest.get("start_url").and_then(Value::as_str),
Some("index.html")
);
assert_eq!(
manifest["share_target"]
.get("action")
.and_then(Value::as_str),
Some("web-share-target")
);
assert_eq!(
manifest["file_handlers"][0]
.get("action")
.and_then(Value::as_str),
Some("index.html")
);
let screenshots = manifest["screenshots"]
.as_array()
.expect("screenshots array");
assert!(screenshots.len() >= 6);
for screenshot in screenshots {
let src = screenshot
.get("src")
.and_then(Value::as_str)
.expect("screenshot src");
assert!(src.starts_with("screenshots/"));
}
}
#[tokio::test]
#[ignore = "live network smoke test against app.fastmail.com"]
async fn installs_live_fastmail_pwa_with_protocol_handlers_and_shortcuts() {
let fetched = fetch_pwa(LIVE_FASTMAIL_SMOKE_URL).await.unwrap();
let temp_dir = tempdir().unwrap();
let store = HashtreeStore::new(temp_dir.path()).unwrap();
let installed = install_site_pwa_to_store(&store, LIVE_FASTMAIL_SMOKE_URL)
.await
.unwrap();
assert_eq!(installed.name, "Fastmail");
assert_eq!(installed.source_app_id, None);
assert_eq!(installed.source_url, LIVE_FASTMAIL_SMOKE_URL);
assert!(installed
.source_manifest_url
.starts_with(LIVE_FASTMAIL_SMOKE_MANIFEST_PREFIX));
assert!(installed
.source_manifest_url
.ends_with(LIVE_FASTMAIL_SMOKE_MANIFEST_SUFFIX));
assert_eq!(
installed.description.as_deref(),
Some("Email + calendar made better")
);
assert_eq!(installed.display_mode.as_deref(), Some("standalone"));
assert!(installed.launch_url.starts_with("htree://nhash1"));
assert!(installed.launch_url.ends_with("/mail/Inbox/index.html"));
let icon_url = installed.icon_url.clone().expect("installed icon url");
let (launch_nhash, _launch_path) = split_htree_nhash_url(&installed.launch_url);
let (icon_nhash, icon_path) = split_htree_nhash_url(&icon_url);
assert_eq!(icon_nhash, launch_nhash);
assert!(icon_path.starts_with("/static/appicons/"));
assert!(icon_path.ends_with(".png"));
let expected_root = format!("htree://{launch_nhash}");
assert_eq!(
installed.scope_url.as_deref(),
Some(format!("{expected_root}/").as_str())
);
assert_eq!(installed.protocol_handlers.len(), 1);
assert_eq!(installed.protocol_handlers[0].protocol, "mailto");
assert_eq!(
installed.protocol_handlers[0].url,
format!("{expected_root}/mail/compose?mailto=%s")
);
assert_eq!(installed.shortcuts.len(), 4);
let installed_shortcut_pairs: Vec<(String, String)> = installed
.shortcuts
.iter()
.map(|shortcut| (shortcut.name.clone(), shortcut.url.clone()))
.collect();
assert!(installed_shortcut_pairs.contains(&(
"Compose".to_string(),
format!("{expected_root}/mail/Inbox/compose")
)));
assert!(installed_shortcut_pairs
.contains(&("Mail".to_string(), format!("{expected_root}/mail/Inbox"))));
assert!(installed_shortcut_pairs.contains(&(
"Contacts".to_string(),
format!("{expected_root}/contacts/index.html")
)));
assert!(installed_shortcut_pairs.contains(&(
"Calendar".to_string(),
format!("{expected_root}/calendar/index.html")
)));
let launch_path = fetched.launch_reference.trim_start_matches('/');
let launch_html = String::from_utf8(
fetched
.assets
.iter()
.find(|asset| asset.path == launch_path)
.expect("launch html asset")
.data
.clone(),
)
.unwrap();
assert!(launch_html.contains("app.webmanifest"));
let base_url = Url::parse(&installed.source_url).unwrap();
let manifest_url = Url::parse(&installed.source_manifest_url).unwrap();
let manifest_path = url_to_path(&manifest_url, &base_url);
let manifest: Value = serde_json::from_slice(
&fetched
.assets
.iter()
.find(|asset| asset.path == manifest_path)
.expect("manifest asset")
.data,
)
.unwrap();
assert_eq!(
manifest.get("start_url").and_then(Value::as_str),
Some("../../../mail/Inbox/index.html")
);
let protocol_handlers = manifest["protocol_handlers"]
.as_array()
.expect("protocol_handlers array");
assert_eq!(protocol_handlers.len(), 1);
assert_eq!(
protocol_handlers[0].get("protocol").and_then(Value::as_str),
Some("mailto")
);
assert_eq!(
protocol_handlers[0].get("url").and_then(Value::as_str),
Some("../../../mail/compose?mailto=%s")
);
let shortcuts = manifest["shortcuts"].as_array().expect("shortcuts array");
assert_eq!(shortcuts.len(), 4);
let shortcut_pairs: Vec<(String, String)> = shortcuts
.iter()
.map(|shortcut| {
(
shortcut
.get("name")
.and_then(Value::as_str)
.expect("shortcut name")
.to_string(),
shortcut
.get("url")
.and_then(Value::as_str)
.expect("shortcut url")
.to_string(),
)
})
.collect();
assert!(shortcut_pairs.contains(&(
"Compose".to_string(),
"../../../mail/Inbox/compose".to_string()
)));
assert!(shortcut_pairs.contains(&("Mail".to_string(), "../../../mail/Inbox".to_string())));
assert!(shortcut_pairs.contains(&(
"Contacts".to_string(),
"../../../contacts/index.html".to_string()
)));
assert!(shortcut_pairs.contains(&(
"Calendar".to_string(),
"../../../calendar/index.html".to_string()
)));
}
#[tokio::test]
#[ignore = "live network smoke test against mastodon.social"]
async fn installs_live_mastodon_pwa_with_source_app_id_and_shortcuts() {
let fetched = fetch_pwa(LIVE_MASTODON_SMOKE_URL).await.unwrap();
let temp_dir = tempdir().unwrap();
let store = HashtreeStore::new(temp_dir.path()).unwrap();
let installed = install_site_pwa_to_store(&store, LIVE_MASTODON_SMOKE_URL)
.await
.unwrap();
assert_eq!(installed.name, "Mastodon");
assert_eq!(
installed.source_app_id.as_deref(),
Some("https://mastodon.social/home")
);
assert_eq!(installed.source_url, LIVE_MASTODON_SMOKE_URL);
assert_eq!(
installed.source_manifest_url,
LIVE_MASTODON_SMOKE_MANIFEST_URL
);
assert_eq!(installed.description, None);
assert_eq!(installed.display_mode.as_deref(), Some("standalone"));
assert!(installed.launch_url.starts_with("htree://nhash1"));
assert!(installed.launch_url.ends_with("/index.html"));
let icon_url = installed.icon_url.clone().expect("installed icon url");
let (launch_nhash, launch_path) = split_htree_nhash_url(&installed.launch_url);
let (icon_nhash, icon_path) = split_htree_nhash_url(&icon_url);
assert_eq!(icon_nhash, launch_nhash);
assert_eq!(
installed.scope_url.as_deref(),
Some(format!("htree://{launch_nhash}/").as_str())
);
assert!(icon_path.starts_with("/packs/assets/android-chrome-512x512-"));
assert!(icon_path.ends_with(".png"));
let launch_html = read_exported_tree_text(&store, &launch_nhash, &launch_path).await;
assert!(launch_html.contains("manifest"));
for attrs in extract_tag_attributes(&launch_html, "link") {
if ["stylesheet", "modulepreload", "preload", "prefetch"]
.iter()
.any(|rel| rel_contains(&attrs, rel))
{
let href = attrs.get("href").expect("asset link href");
assert!(!href.starts_with('/'), "asset link stayed absolute: {href}");
}
}
assert!(!launch_html.contains("src=\"/packs/"));
assert_eq!(installed.shortcuts.len(), 3);
let expected_root = format!("htree://{launch_nhash}");
let installed_shortcut_pairs: Vec<(String, String)> = installed
.shortcuts
.iter()
.map(|shortcut| (shortcut.name.clone(), shortcut.url.clone()))
.collect();
assert!(installed_shortcut_pairs.contains(&(
"Compose new post".to_string(),
format!("{expected_root}/publish")
)));
assert!(installed_shortcut_pairs.contains(&(
"Notifications".to_string(),
format!("{expected_root}/notifications")
)));
assert!(installed_shortcut_pairs
.contains(&("Explore".to_string(), format!("{expected_root}/explore"))));
let base_url = Url::parse(&installed.source_url).unwrap();
let manifest_url = Url::parse(&installed.source_manifest_url).unwrap();
let manifest_path = url_to_path(&manifest_url, &base_url);
let manifest: Value = serde_json::from_slice(
&fetched
.assets
.iter()
.find(|asset| asset.path == manifest_path)
.expect("manifest asset")
.data,
)
.unwrap();
assert_eq!(
manifest.get("start_url").and_then(Value::as_str),
Some("index.html")
);
let shortcuts = manifest["shortcuts"].as_array().expect("shortcuts array");
assert_eq!(shortcuts.len(), 3);
let shortcut_pairs: Vec<(String, String)> = shortcuts
.iter()
.map(|shortcut| {
(
shortcut
.get("name")
.and_then(Value::as_str)
.expect("shortcut name")
.to_string(),
shortcut
.get("url")
.and_then(Value::as_str)
.expect("shortcut url")
.to_string(),
)
})
.collect();
assert!(shortcut_pairs.contains(&("Compose new post".to_string(), "publish".to_string())));
assert!(
shortcut_pairs.contains(&("Notifications".to_string(), "notifications".to_string()))
);
assert!(shortcut_pairs.contains(&("Explore".to_string(), "explore".to_string())));
}
}