dash-frontend: http_client: Progress callback, display download status

This commit is contained in:
Aleksander 2026-04-12 17:49:08 +02:00 committed by galister
parent 2035d9ba76
commit 1bee41aea9
11 changed files with 229 additions and 32 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="#33FF99" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="#FF4455" d="M12 17q.425 0 .713-.288T13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17m-1-4h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8"/></svg>

After

Width:  |  Height:  |  Size: 574 B

View File

@ -1,10 +1,17 @@
<layout>
<include src="../t_separator.xml"/>
<elements>
<div align_items="center" justify_content="center">
<div flex_direction="column" gap="8">
<label translation="DOWNLOADING_FILE" size="14"/>
<label id="label_target_path" />
<label id="label_progress"/>
<div align_items="center" justify_content="center" width="100%">
<div flex_direction="column" gap="8" width="100%">
<label translation="DOWNLOADING_FILE" size="24" weight="bold"/>
<Separator/>
<label id="label_target_path" color="~color_text_translucent" />
<div gap="8" align_items="center">
<div id="loading_parent"/>
<label id="label_status"/>
</div>
</div>
</div>
</elements>

View File

@ -23,7 +23,7 @@ pub async fn request_image(executor: AsyncExecutor, app_id: AppID) -> anyhow::Re
app_id
);
match http_client::get(&executor, &url).await {
match http_client::get_simple(&executor, &url).await {
Ok(response) => {
log::info!("Success");
cache_dir::set_data(&cache_file_path, &response.data).await?;
@ -68,7 +68,7 @@ async fn get_app_details_json_internal(
// Fetch from Steam API
log::info!("Fetching app detail ID {}", app_id);
let url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id);
let response = http_client::get(&executor, &url).await?;
let response = http_client::get_simple(&executor, &url).await?;
let res_utf8 = String::from_utf8(response.data)?;
let root = serde_json::from_str::<serde_json::Value>(&res_utf8)?;
let body = root.get(&app_id).context("invalid body")?;

View File

@ -11,6 +11,7 @@ use http_body_util::{BodyStream, Empty};
use hyper::Request;
use smol::{net::TcpStream, prelude::*};
use std::convert::TryInto;
use std::fmt::Debug;
use std::pin::Pin;
use std::task::{Context, Poll};
use wlx_common::async_executor::AsyncExecutor;
@ -28,10 +29,23 @@ impl HttpClientResponse {
}
}
pub async fn get(executor: &AsyncExecutor, url: &str) -> anyhow::Result<HttpClientResponse> {
log::info!("fetching URL \"{}\"", url);
pub struct ProgressFuncData {
pub bytes_downloaded: u64,
pub file_size: u64,
}
let url: hyper::Uri = url.try_into()?;
pub type ProgressFunc = Box<dyn Fn(ProgressFuncData)>;
pub struct GetParams<'a> {
pub executor: &'a AsyncExecutor,
pub url: &'a str,
pub on_progress: Option<ProgressFunc>,
}
pub async fn get(params: GetParams<'_>) -> anyhow::Result<HttpClientResponse> {
log::info!("fetching URL \"{}\"", params.url);
let url: hyper::Uri = params.url.try_into()?;
let req = Request::builder()
.header(
hyper::header::HOST,
@ -40,23 +54,56 @@ pub async fn get(executor: &AsyncExecutor, url: &str) -> anyhow::Result<HttpClie
.uri(url)
.body(Empty::new())?;
let resp = fetch(executor, req).await?;
let resp = fetch(params.executor, req).await?;
if !resp.status().is_success() {
// non-200 HTTP response
anyhow::bail!("non-200 HTTP response: {}", resp.status().as_str());
}
let body = BodyStream::new(resp.into_body())
let mut bytes_downloaded: u64 = 0;
let mut file_size: u64 = 1;
let (parts, body) = resp.into_parts();
// that's a pretty interesting way to get file size :]
if let Some(val) = parts.headers.get("Content-Length") {
if let Ok(str) = val.to_str() {
if let Ok(s) = str.parse() {
file_size = s;
}
}
}
let mut on_progress = params.on_progress;
let data = BodyStream::new(body)
.try_fold(Vec::new(), |mut body, chunk| {
if let Some(chunk) = chunk.data_ref() {
bytes_downloaded += chunk.len() as u64;
body.extend_from_slice(chunk);
if let Some(on_progress) = &mut on_progress {
on_progress(ProgressFuncData {
bytes_downloaded,
file_size,
})
}
}
Ok(body)
})
.await?;
Ok(HttpClientResponse { data: body })
Ok(HttpClientResponse { data })
}
pub async fn get_simple(executor: &AsyncExecutor, url: &str) -> anyhow::Result<HttpClientResponse> {
get(GetParams {
executor,
url,
on_progress: None,
})
.await
}
async fn fetch(

View File

@ -8,7 +8,7 @@ pub async fn fetch_to_glyph_data(
executor: &AsyncExecutor,
url: &str,
) -> anyhow::Result<CustomGlyphData> {
let res = http_client::get(executor, url).await?;
let res = http_client::get_simple(executor, url).await?;
let glyph_data = CustomGlyphData::from_bytes_raster(globals, url, &res.data)?;
Ok(glyph_data)
}

View File

@ -1,4 +1,7 @@
#![allow(dead_code)] // TODO: Remove later
#![allow(dead_code)]
use std::path::PathBuf;
// TODO: Remove later
use serde::Deserialize;
use wlx_common::async_executor::AsyncExecutor;
@ -36,12 +39,18 @@ impl SkymapCatalogEntryFiles {
format!("{}/files/{}", WAYVR_SKYMAPS_ROOT, self.preview)
}
pub fn get_filename_from_res(&self, res: SkymapResolution) -> Option<&String> {
pub fn get_filename_from_res(&self, res: SkymapResolution) -> Option<String> {
match res {
SkymapResolution::Res2k => Some(&self.size_2k),
SkymapResolution::Res4k => self.size_4k.as_ref(),
SkymapResolution::Res8k => self.size_8k.as_ref(),
}
.map(|raw_filename| {
// sanitize filename, do not allow "../" just in case
PathBuf::from(raw_filename)
.file_name()
.map(|s| String::from(s.to_string_lossy()))
})?
}
// example result: "https://wayvr.org/skymaps/files/my_skymap_8k.png"
@ -89,7 +98,7 @@ impl SkymapCatalog {
pub async fn request_catalog(executor: &AsyncExecutor) -> anyhow::Result<SkymapCatalog> {
log::info!("Fetching skymap list");
let res = http_client::get(executor, &format!("{}/catalog.json", networking::WAYVR_SKYMAPS_ROOT)).await?;
let res = http_client::get_simple(executor, &format!("{}/catalog.json", networking::WAYVR_SKYMAPS_ROOT)).await?;
let catalog = res.as_json::<SkymapCatalog>()?;
catalog.validate()?;

View File

@ -1,4 +1,4 @@
use glam::Mat4;
use glam::{Mat4, Vec2};
use wgui::{
animation::{Animation, AnimationEasing},
assets::AssetPath,
@ -7,10 +7,15 @@ use wgui::{
layout::{Layout, LayoutTask, WidgetID},
parser::{Fetchable, ParseDocumentParams},
renderer_vk::{
text::{FontWeight, TextStyle},
text::{FontWeight, TextStyle, custom_glyph::CustomGlyphData},
util::centered_matrix,
},
widget::label::{WidgetLabel, WidgetLabelParams},
taffy::{self, prelude::length},
widget::{
self,
label::{WidgetLabel, WidgetLabelParams},
sprite::{WidgetSprite, WidgetSpriteParams},
},
};
pub fn create_label(layout: &mut Layout, parent: WidgetID, content: Translation) -> anyhow::Result<()> {
@ -49,6 +54,31 @@ pub fn create_label_error(layout: &mut Layout, parent: WidgetID, content: String
Ok(())
}
pub fn create_icon(layout: &mut Layout, id_parent: WidgetID, size: Vec2, path: AssetPath) -> anyhow::Result<WidgetID> {
let widget_sprite = WidgetSprite::create(WidgetSpriteParams {
color: None,
glyph_data: Some(CustomGlyphData::from_assets(&layout.state.globals, path)?),
});
let size = taffy::Size {
width: length(size.x),
height: length(size.y),
};
let (widget, _) = layout.add_child(
id_parent,
widget_sprite,
taffy::Style {
min_size: size.clone(),
max_size: size.clone(),
size: size.clone(),
..Default::default()
},
)?;
Ok(widget.id)
}
pub struct CreateLoadingParams<'a> {
pub layout: &'a mut Layout,
pub parent_id: WidgetID,

View File

@ -1,8 +1,13 @@
use crate::{
frontend::{FrontendTask, FrontendTasks},
util::popup_manager::{MountPopupOnceParams, PopupHolder},
util::{
networking::http_client::{self, ProgressFuncData},
popup_manager::{MountPopupOnceParams, PopupHolder},
wgui_simple,
},
views::{ViewTrait, ViewUpdateParams},
};
use glam::Vec2;
use std::path::PathBuf;
use wgui::{
assets::AssetPath,
@ -26,7 +31,12 @@ pub struct Params<'a> {
}
#[derive(Clone)]
enum Task {}
enum Task {
StartDownload(/*url*/ String),
SetStatusText(String),
ShowIconSuccess,
ShowIconError,
}
pub struct View {
id_parent: WidgetID,
@ -36,12 +46,47 @@ pub struct View {
#[allow(dead_code)]
parser_state: ParserState,
id_label_status: WidgetID,
id_loading_parent: WidgetID,
}
impl ViewTrait for View {
fn update(&mut self, _par: &mut ViewUpdateParams) -> anyhow::Result<()> {
fn update(&mut self, par: &mut ViewUpdateParams) -> anyhow::Result<()> {
for task in self.tasks.drain() {
match task {}
match task {
Task::StartDownload(url) => {
self
.executor
.spawn(View::download(self.tasks.clone(), self.executor.clone(), url))
.detach();
}
Task::SetStatusText(text) => {
let widgets = &mut par.layout.state.widgets;
widgets
.fetch(self.id_label_status)?
.cast::<WidgetLabel>()?
.set_text(&mut par.layout.common(), Translation::from_raw_text_string(text));
}
Task::ShowIconSuccess => {
par.layout.remove_children(self.id_loading_parent);
wgui_simple::create_icon(
par.layout,
self.id_loading_parent,
Vec2::splat(32.0),
AssetPath::BuiltIn("dashboard/check.svg"),
)?;
}
Task::ShowIconError => {
par.layout.remove_children(self.id_loading_parent);
wgui_simple::create_icon(
par.layout,
self.id_loading_parent,
Vec2::splat(32.0),
AssetPath::BuiltIn("dashboard/error.svg"),
)?;
}
}
}
Ok(())
}
@ -57,7 +102,15 @@ impl View {
extra: Default::default(),
};
let mut parser_state = wgui::parser::parse_from_assets(&doc_params, par.layout, par.parent_id)?;
let parser_state = wgui::parser::parse_from_assets(&doc_params, par.layout, par.parent_id)?;
let id_label_status = parser_state.get_widget_id("label_status")?;
let id_loading_parent = parser_state.get_widget_id("loading_parent")?;
wgui_simple::create_loading(wgui_simple::CreateLoadingParams {
parent_id: id_loading_parent,
layout: par.layout,
with_text: false,
})?;
let str_target_path = par.globals.i18n().translate("TARGET_PATH");
@ -71,14 +124,50 @@ impl View {
);
}
tasks.push(Task::StartDownload(par.url.clone()));
Ok(Self {
id_parent: par.parent_id,
tasks,
globals: par.globals.clone(),
executor: par.executor.clone(),
parser_state,
id_label_status,
id_loading_parent,
})
}
async fn download(tasks: Tasks<Task>, executor: AsyncExecutor, url: String) {
tasks.push(Task::SetStatusText(String::from("Connecting to the server...")));
let res = http_client::get(http_client::GetParams {
executor: &executor,
url: &url,
on_progress: Some(Box::new({
let tasks = tasks.clone();
move |data: ProgressFuncData| {
tasks.push(Task::SetStatusText(format!(
"{}/{} KiB ({}%)",
data.bytes_downloaded / 1024,
data.file_size / 1024,
(data.bytes_downloaded as f32 / data.file_size as f32 * 100.0).round()
)))
}
})),
})
.await;
match res {
Ok(_response) => {
tasks.push(Task::SetStatusText(String::from("Download finished")));
tasks.push(Task::ShowIconSuccess);
}
Err(e) => {
tasks.push(Task::ShowIconError);
tasks.push(Task::SetStatusText(format!("Download failed: {:?}", e)))
}
}
}
}
pub fn mount_popup(

View File

@ -19,7 +19,7 @@ use wgui::{
task::Tasks,
widget::{image::WidgetImage, label::WidgetLabel},
};
use wlx_common::async_executor::AsyncExecutor;
use wlx_common::{async_executor::AsyncExecutor, config_io};
pub struct Params<'a> {
pub globals: &'a WguiGlobals,
@ -72,10 +72,12 @@ impl ViewTrait for View {
for task in self.tasks.drain() {
match task {
Task::ResolutionClicked(skymap_resolution) => {
self.run_download(&mut par.layout, skymap_resolution)?;
self.run_download(skymap_resolution)?;
}
}
}
self.popup_download.update(par)?;
Ok(())
}
}
@ -130,7 +132,7 @@ impl View {
.fetch_widget_as::<WidgetLabel>(&par.layout.state, "label_creation_date")?
.set_text_simple(
&mut par.globals.get(),
Translation::from_raw_text_string(format!("{}: {}", str_creation_date, par.entry.created_at,)),
Translation::from_raw_text_string(format!("{}: {}", str_creation_date, par.entry.created_at)),
);
// Set modification date label
@ -138,7 +140,7 @@ impl View {
.fetch_widget_as::<WidgetLabel>(&par.layout.state, "label_modification_date")?
.set_text_simple(
&mut par.globals.get(),
Translation::from_raw_text_string(format!("{}: {}", str_modification_date, par.entry.created_at,)),
Translation::from_raw_text_string(format!("{}: {}", str_modification_date, par.entry.created_at)),
);
let files = &par.entry.files;
@ -173,18 +175,24 @@ impl View {
})
}
fn run_download(&mut self, layout: &mut Layout, resolution: SkymapResolution) -> anyhow::Result<()> {
let target_path = PathBuf::from(format!(""));
fn run_download(&mut self, resolution: SkymapResolution) -> anyhow::Result<()> {
let Some(url) = self.entry.files.get_url_from_res(resolution) else {
return Ok(());
};
let Some(filename) = self.entry.files.get_filename_from_res(resolution) else {
return Ok(());
};
let mut path = config_io::get_skymaps_root();
path = path.join(filename);
views::download_file::mount_popup(
self.frontend_tasks.clone(),
self.executor.clone(),
self.globals.clone(),
self.popup_download.clone(),
target_path,
path,
url,
);
Ok(())

View File

@ -86,6 +86,11 @@ impl WidgetMap {
self.0.get(handle)
}
// same as get(), but with error message
pub fn fetch(&self, handle: WidgetID) -> anyhow::Result<Widget> {
self.get(handle).cloned().context("Failed to fetch widget")
}
pub fn insert(&mut self, obj: Widget) -> WidgetID {
self
.0