From 57b7e85d7f0dd5a43458e70b849c61e07579f5b7 Mon Sep 17 00:00:00 2001
From: Jack Lavigne <jacklavigne00@gmail.com>
Date: Fri, 4 Apr 2025 21:38:06 +0200
Subject: [PATCH 1/3] fix: download in chunks

---
 src/wasm/src/funcs.rs | 63 ++++++++++++++++++++++++++++++++++++-------
 1 file changed, 54 insertions(+), 9 deletions(-)

diff --git a/src/wasm/src/funcs.rs b/src/wasm/src/funcs.rs
index 0ed467f..97413e1 100644
--- a/src/wasm/src/funcs.rs
+++ b/src/wasm/src/funcs.rs
@@ -1,6 +1,6 @@
 use std::{
     fs::{self, OpenOptions},
-    io::Cursor,
+    io::{Cursor, Write},
 };
 
 use anyhow::{anyhow, Context, Result};
@@ -21,8 +21,12 @@ use crate::{
     DownloadProgressEvent, DownloadProgressPhase, InterfaceEvent,
 };
 
+/// The URL to get the latest available cycle number
 const LATEST_CYCLE_ENDPOINT: &str = "https://navdata.api.navigraph.com/info";
 
+/// The max size in bytes of each request during the download function (set to 4MB curently)
+const DOWNLOAD_CHUNK_SIZE_BYTES: usize = 4 * 1024 * 1024;
+
 /// The trait definition for a function that can be called through the navigation data interface
 trait Function: DeserializeOwned {
     type ReturnType: Serialize;
@@ -70,13 +74,54 @@ impl Function for DownloadNavigationData {
             unzipped: None,
         })?;
 
-        // Download the data
-        let data = NetworkRequestBuilder::new(&self.url)
-            .context("can't create new NetworkRequestBuilder")?
-            .get()
-            .context(".get() returned None")?
-            .wait_for_data()
-            .await?;
+        // We need to download the data in chunks of DOWNLOAD_CHUNK_SIZE_BYTES to avoid a timeout, so we need to keep track of a "working" accumulation of all responses
+        let mut bytes = vec![];
+
+        let mut current_byte_index = 0;
+        loop {
+            // Dispatch the request
+            let range_end = current_byte_index + DOWNLOAD_CHUNK_SIZE_BYTES - 1;
+            let request = NetworkRequestBuilder::new(&self.url)
+                .context("can't create new NetworkRequestBuilder")?
+                .with_header(&format!("Range: bytes={current_byte_index}-{range_end}"))
+                .context(".with_header() returned None")?
+                .get()
+                .context(".get() returned None")?;
+
+            request.wait_for_data().await?;
+
+            // Get the size of actual data. The response will be as long as the requested range is, but content-length contains the amount we actually want to read
+            let content_length = request
+                .header_section("content-length")
+                .context("no content-length header")?
+                .trim()
+                .parse::<usize>()?;
+
+            // Check if we somehow have no more data (file size would be a perfect multiple of DOWNLOAD_CHUNK_SIZE_BYTES)
+            if content_length == 0 {
+                break;
+            }
+
+            let data = request.data().ok_or(anyhow!("no data"))?;
+
+            // Make sure we don't panic if server sent less data than claimed (should never happen, but avoid a panic)
+            if data.len() < content_length {
+                return Err(anyhow!(
+                    "Received less data ({}) than content-length ({})",
+                    data.len(),
+                    content_length
+                ));
+            }
+
+            bytes.write_all(&data[..content_length])?;
+
+            // Check if we have hit the last chunk
+            if content_length < DOWNLOAD_CHUNK_SIZE_BYTES {
+                break;
+            }
+
+            current_byte_index += content_length;
+        }
 
         // Only close connection if DATABASE_STATE has already been initialized - otherwise we end up unnecessarily copying the bundled data and instantly replacing it (due to initialization logic in database state)
         if Lazy::get(&DATABASE_STATE).is_some() {
@@ -103,7 +148,7 @@ impl Function for DownloadNavigationData {
         })?;
 
         // Load the zip archive
-        let mut zip = ZipArchive::new(Cursor::new(data))?;
+        let mut zip = ZipArchive::new(Cursor::new(bytes))?;
 
         // Ensure parent folder exists (ignore the result as it will return an error if it already exists)
         let _ = fs::create_dir_all(WORK_NAVIGATION_DATA_FOLDER);

From eac4adc67c2f8b97c0b796ba3130036d422a0443 Mon Sep 17 00:00:00 2001
From: Jack Lavigne <jacklavigne00@gmail.com>
Date: Mon, 7 Apr 2025 15:36:01 +0200
Subject: [PATCH 2/3] feat: report download percent

---
 example/gauge/Components/Pages/Auth/Auth.tsx  | 18 +------
 .../interface/NavigationDataInterfaceTypes.ts | 12 +----
 src/wasm/src/funcs.rs                         | 51 +++++++++++--------
 src/wasm/src/lib.rs                           | 16 ++----
 4 files changed, 36 insertions(+), 61 deletions(-)

diff --git a/example/gauge/Components/Pages/Auth/Auth.tsx b/example/gauge/Components/Pages/Auth/Auth.tsx
index 132d830..077763d 100644
--- a/example/gauge/Components/Pages/Auth/Auth.tsx
+++ b/example/gauge/Components/Pages/Auth/Auth.tsx
@@ -1,6 +1,5 @@
 import { ComponentProps, DisplayComponent, FSComponent, VNode } from "@microsoft/msfs-sdk";
 import {
-  DownloadProgressPhase,
   NavigationDataStatus,
   NavigraphEventType,
   NavigraphNavigationDataInterface,
@@ -29,22 +28,7 @@ export class AuthPage extends DisplayComponent<AuthPageProps> {
     super(props);
 
     this.props.navigationDataInterface.onEvent(NavigraphEventType.DownloadProgress, data => {
-      switch (data.phase) {
-        case DownloadProgressPhase.Downloading:
-          this.displayMessage("Downloading navigation data...");
-          break;
-        case DownloadProgressPhase.Cleaning:
-          if (!data.deleted) return;
-          this.displayMessage(`Cleaning destination directory. ${data.deleted} files deleted so far`);
-          break;
-        case DownloadProgressPhase.Extracting: {
-          // Ensure non-null
-          if (!data.unzipped || !data.total_to_unzip) return;
-          const percent = Math.round((data.unzipped / data.total_to_unzip) * 100);
-          this.displayMessage(`Unzipping files... ${percent}% complete`);
-          break;
-        }
-      }
+      this.displayMessage(`Downloaded ${data.downloaded_bytes}/${data.total_bytes} bytes`);
     });
   }
 
diff --git a/src/ts/interface/NavigationDataInterfaceTypes.ts b/src/ts/interface/NavigationDataInterfaceTypes.ts
index 3e20254..180f06d 100644
--- a/src/ts/interface/NavigationDataInterfaceTypes.ts
+++ b/src/ts/interface/NavigationDataInterfaceTypes.ts
@@ -11,17 +11,9 @@ export enum NavigraphEventType {
   DownloadProgress = "DownloadProgress",
 }
 
-export enum DownloadProgressPhase {
-  Downloading = "Downloading",
-  Cleaning = "Cleaning",
-  Extracting = "Extracting",
-}
-
 export interface DownloadProgressData {
-  phase: DownloadProgressPhase;
-  deleted: number | null;
-  total_to_unzip: number | null;
-  unzipped: number | null;
+  total_bytes: number;
+  downloaded_bytes: number;
 }
 
 export enum NavigraphFunction {
diff --git a/src/wasm/src/funcs.rs b/src/wasm/src/funcs.rs
index 97413e1..02a6282 100644
--- a/src/wasm/src/funcs.rs
+++ b/src/wasm/src/funcs.rs
@@ -18,7 +18,7 @@ use crate::{
         WORK_NAVIGATION_DATA_FOLDER,
     },
     futures::AsyncNetworkRequest,
-    DownloadProgressEvent, DownloadProgressPhase, InterfaceEvent,
+    DownloadProgressEvent, InterfaceEvent,
 };
 
 /// The URL to get the latest available cycle number
@@ -66,12 +66,30 @@ impl Function for DownloadNavigationData {
     type ReturnType = ();
 
     async fn run(&mut self) -> Result<Self::ReturnType> {
-        // Send an initial progress event TODO: remove these in a breaking version, these are only here for backwards compatibility
+        // Figure out total size of download (this request is acting like a HEAD since we don't have those in this environment. Nothing actually gets downloaded since we are constraining the range)
+        let request = NetworkRequestBuilder::new(&self.url)
+            .context("can't create new NetworkRequestBuilder")?
+            .with_header(&format!("Range: bytes=0-0"))
+            .context(".with_header() returned None")?
+            .get()
+            .context(".get() returned None")?;
+
+        request.wait_for_data().await?;
+
+        // Try parsing the content-range header
+        let total_bytes = request
+            .header_section("content-range")
+            .context("no content-range header")?
+            .trim()
+            .split("/")
+            .last()
+            .ok_or(anyhow!("invalid content-range"))?
+            .parse::<usize>()?;
+
+        // Report the amount
         InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
-            phase: DownloadProgressPhase::Downloading,
-            deleted: None,
-            total_to_unzip: None,
-            unzipped: None,
+            total_bytes,
+            downloaded_bytes: 0,
         })?;
 
         // We need to download the data in chunks of DOWNLOAD_CHUNK_SIZE_BYTES to avoid a timeout, so we need to keep track of a "working" accumulation of all responses
@@ -121,6 +139,12 @@ impl Function for DownloadNavigationData {
             }
 
             current_byte_index += content_length;
+
+            // Send the current download amount
+            InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
+                total_bytes,
+                downloaded_bytes: current_byte_index,
+            })?;
         }
 
         // Only close connection if DATABASE_STATE has already been initialized - otherwise we end up unnecessarily copying the bundled data and instantly replacing it (due to initialization logic in database state)
@@ -132,21 +156,6 @@ impl Function for DownloadNavigationData {
                 .close_connection()?;
         }
 
-        // Send the deleting and extraction events
-        InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
-            phase: DownloadProgressPhase::Cleaning,
-            deleted: Some(2),
-            total_to_unzip: None,
-            unzipped: None,
-        })?;
-
-        InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
-            phase: DownloadProgressPhase::Extracting,
-            deleted: None,
-            total_to_unzip: Some(2),
-            unzipped: None,
-        })?;
-
         // Load the zip archive
         let mut zip = ZipArchive::new(Cursor::new(bytes))?;
 
diff --git a/src/wasm/src/lib.rs b/src/wasm/src/lib.rs
index 86c0a6a..916f248 100644
--- a/src/wasm/src/lib.rs
+++ b/src/wasm/src/lib.rs
@@ -21,28 +21,18 @@ mod sentry_gauge;
 /// Amount of MS between dispatches of the heartbeat commbus event
 const HEARTBEAT_INTERVAL_MS: u128 = 1000;
 
-/// The current phase of downloading
-#[derive(Serialize)]
-pub enum DownloadProgressPhase {
-    Downloading,
-    Cleaning,
-    Extracting,
-}
-
 /// The data associated with the `DownloadProgress` event
 #[derive(Serialize)]
 pub struct DownloadProgressEvent {
-    pub phase: DownloadProgressPhase,
-    pub deleted: Option<usize>,
-    pub total_to_unzip: Option<usize>,
-    pub unzipped: Option<usize>,
+    pub total_bytes: usize,
+    pub downloaded_bytes: usize,
 }
 
 /// The types of events that can be emitted from the interface
 #[derive(Serialize)]
 enum NavigraphEventType {
     Heartbeat,
-    DownloadProgress, // TODO: remove in a future version. here for backwards compatibility
+    DownloadProgress,
 }
 
 /// The structure of an event message

From 0f995e4593f273bba3d79be8bdf703215023560d Mon Sep 17 00:00:00 2001
From: Jack Lavigne <jacklavigne00@gmail.com>
Date: Mon, 7 Apr 2025 16:08:38 +0200
Subject: [PATCH 3/3] feat: include chunk number in download report

---
 example/gauge/Components/Pages/Auth/Auth.tsx  |  4 +-
 .../interface/NavigationDataInterfaceTypes.ts |  2 +
 src/wasm/Cargo.toml                           |  2 +-
 src/wasm/src/funcs.rs                         | 71 ++++++-------------
 src/wasm/src/lib.rs                           |  6 ++
 5 files changed, 33 insertions(+), 52 deletions(-)

diff --git a/example/gauge/Components/Pages/Auth/Auth.tsx b/example/gauge/Components/Pages/Auth/Auth.tsx
index 077763d..eabd267 100644
--- a/example/gauge/Components/Pages/Auth/Auth.tsx
+++ b/example/gauge/Components/Pages/Auth/Auth.tsx
@@ -28,7 +28,9 @@ export class AuthPage extends DisplayComponent<AuthPageProps> {
     super(props);
 
     this.props.navigationDataInterface.onEvent(NavigraphEventType.DownloadProgress, data => {
-      this.displayMessage(`Downloaded ${data.downloaded_bytes}/${data.total_bytes} bytes`);
+      this.displayMessage(
+        `Downloaded ${data.downloaded_bytes}/${data.total_bytes} bytes (chunk ${data.current_chunk}/${data.total_chunks})`,
+      );
     });
   }
 
diff --git a/src/ts/interface/NavigationDataInterfaceTypes.ts b/src/ts/interface/NavigationDataInterfaceTypes.ts
index 180f06d..14ea3d4 100644
--- a/src/ts/interface/NavigationDataInterfaceTypes.ts
+++ b/src/ts/interface/NavigationDataInterfaceTypes.ts
@@ -14,6 +14,8 @@ export enum NavigraphEventType {
 export interface DownloadProgressData {
   total_bytes: number;
   downloaded_bytes: number;
+  current_chunk: number;
+  total_chunks: number;
 }
 
 export enum NavigraphFunction {
diff --git a/src/wasm/Cargo.toml b/src/wasm/Cargo.toml
index fc3b7ff..3117f75 100644
--- a/src/wasm/Cargo.toml
+++ b/src/wasm/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "msfs-navigation-data-interface"
-version = "1.2.0-rc1"
+version = "1.2.0-rc2"
 edition = "2021"
 
 [lib]
diff --git a/src/wasm/src/funcs.rs b/src/wasm/src/funcs.rs
index 02a6282..3128cee 100644
--- a/src/wasm/src/funcs.rs
+++ b/src/wasm/src/funcs.rs
@@ -86,65 +86,36 @@ impl Function for DownloadNavigationData {
             .ok_or(anyhow!("invalid content-range"))?
             .parse::<usize>()?;
 
-        // Report the amount
-        InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
-            total_bytes,
-            downloaded_bytes: 0,
-        })?;
+        // Total amount of chunks to download
+        let total_chunks = total_bytes.div_ceil(DOWNLOAD_CHUNK_SIZE_BYTES);
 
         // We need to download the data in chunks of DOWNLOAD_CHUNK_SIZE_BYTES to avoid a timeout, so we need to keep track of a "working" accumulation of all responses
         let mut bytes = vec![];
 
-        let mut current_byte_index = 0;
-        loop {
+        for i in 0..total_chunks {
+            // Calculate the range for the current chunk
+            let range_start = i * DOWNLOAD_CHUNK_SIZE_BYTES;
+            let range_end = ((i + 1) * DOWNLOAD_CHUNK_SIZE_BYTES - 1).min(total_bytes - 1);
+
+            // Report the current download progress
+            InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
+                total_bytes,
+                downloaded_bytes: range_start,
+                current_chunk: i,
+                total_chunks,
+            })?;
+
             // Dispatch the request
-            let range_end = current_byte_index + DOWNLOAD_CHUNK_SIZE_BYTES - 1;
-            let request = NetworkRequestBuilder::new(&self.url)
+            let data = NetworkRequestBuilder::new(&self.url)
                 .context("can't create new NetworkRequestBuilder")?
-                .with_header(&format!("Range: bytes={current_byte_index}-{range_end}"))
+                .with_header(&format!("Range: bytes={range_start}-{range_end}"))
                 .context(".with_header() returned None")?
                 .get()
-                .context(".get() returned None")?;
-
-            request.wait_for_data().await?;
-
-            // Get the size of actual data. The response will be as long as the requested range is, but content-length contains the amount we actually want to read
-            let content_length = request
-                .header_section("content-length")
-                .context("no content-length header")?
-                .trim()
-                .parse::<usize>()?;
-
-            // Check if we somehow have no more data (file size would be a perfect multiple of DOWNLOAD_CHUNK_SIZE_BYTES)
-            if content_length == 0 {
-                break;
-            }
-
-            let data = request.data().ok_or(anyhow!("no data"))?;
+                .context(".get() returned None")?
+                .wait_for_data()
+                .await?;
 
-            // Make sure we don't panic if server sent less data than claimed (should never happen, but avoid a panic)
-            if data.len() < content_length {
-                return Err(anyhow!(
-                    "Received less data ({}) than content-length ({})",
-                    data.len(),
-                    content_length
-                ));
-            }
-
-            bytes.write_all(&data[..content_length])?;
-
-            // Check if we have hit the last chunk
-            if content_length < DOWNLOAD_CHUNK_SIZE_BYTES {
-                break;
-            }
-
-            current_byte_index += content_length;
-
-            // Send the current download amount
-            InterfaceEvent::send_download_progress_event(DownloadProgressEvent {
-                total_bytes,
-                downloaded_bytes: current_byte_index,
-            })?;
+            bytes.write_all(&data)?;
         }
 
         // Only close connection if DATABASE_STATE has already been initialized - otherwise we end up unnecessarily copying the bundled data and instantly replacing it (due to initialization logic in database state)
diff --git a/src/wasm/src/lib.rs b/src/wasm/src/lib.rs
index 916f248..0994442 100644
--- a/src/wasm/src/lib.rs
+++ b/src/wasm/src/lib.rs
@@ -24,8 +24,14 @@ const HEARTBEAT_INTERVAL_MS: u128 = 1000;
 /// The data associated with the `DownloadProgress` event
 #[derive(Serialize)]
 pub struct DownloadProgressEvent {
+    /// The total amount of bytes to download
     pub total_bytes: usize,
+    /// The amount of bytes downloaded
     pub downloaded_bytes: usize,
+    /// The chunk number (starting at 0) of the current download
+    pub current_chunk: usize,
+    /// The total number of chunks needed to download
+    pub total_chunks: usize,
 }
 
 /// The types of events that can be emitted from the interface