Skip to content

Commit be922a1

Browse files
committed
feat(map): load map tiles through the Rust backend (2D + 3D)
Map tiles now fetch through a backend command (fetch_tile → reqwest: HTTP/2 + pooled connections) instead of the OS WebView's HTTP client. The WebView caps connections per host and, on WebKitGTK/Linux, serialises requests to a single-host provider (e.g. ESRI) — making tiles crawl in while Windows/WebView2 was fast. reqwest multiplexes over HTTP/2 without that cap, so tile loading is fast and consistent across all platforms. A tokio Semaphore caps concurrency at 12 so we stay a polite client toward community providers (OSM, OpenTopoMap). A shared loadTileBytes() (with a WebView fetch() fallback) is used by both the 2D (Leaflet CachedTileLayer) and 3D (Cesium) tile pipelines; the IndexedDB cache is unchanged. Cesium World Terrain (Ion) is unaffected — it stays on Cesium's own networking. Co-Authored-By: Claude Opus 4.8
1 parent e80b39a commit be922a1

8 files changed

Lines changed: 125 additions & 11 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ serde_repr = "0.1"
3535
serialport = "4"
3636
log = "0.4"
3737
rusqlite = { version = "0.31", features = ["bundled"] }
38-
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
38+
reqwest = { version = "0.12", features = ["json", "rustls-tls", "http2"], default-features = false }
3939
chrono = { version = "0.4", features = ["serde"] }
4040
chrono-tz = "0.9" # IANA zone + date → UTC offset (DST-aware) for flight-local time display (ADR-048)
4141
tzf-rs = "0.4" # coordinates → IANA timezone name (embedded boundary data) for log imports (ADR-048)

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ pub mod rc;
2222
pub mod safehome;
2323
pub mod system;
2424
pub mod terrain;
25+
pub mod tiles;
2526
pub mod update_check;
2627
pub mod video;

src-tauri/src/commands/tiles.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Marc Hoffmann (b14ckyy)
3+
4+
//! Backend map-tile loader.
5+
//!
6+
//! Fetches map tiles through the Rust HTTP stack (reqwest → HTTP/2 + connection
7+
//! pooling) instead of the OS WebView's HTTP client, for both the 2D (Leaflet)
8+
//! and 3D (Cesium) tile pipelines. The WebView caps connections per host and, on
9+
//! WebKitGTK/Linux, serialises requests to a single-host provider (e.g. ESRI),
10+
//! which makes tiles crawl in; reqwest multiplexes over HTTP/2 without that cap,
11+
//! so tile loading is fast and consistent across all platforms. A concurrency
12+
//! gate keeps us a polite client toward community providers (see below).
13+
14+
use std::sync::OnceLock;
15+
use std::time::Duration;
16+
use tokio::sync::Semaphore;
17+
18+
/// Cap on concurrent tile fetches. Bypassing the WebView removes the browser's
19+
/// natural per-host connection limit, so a single pan could otherwise fire 100+
20+
/// requests at once — fine for CDN providers (ESRI/Carto) but a policy/ban risk
21+
/// for community servers (OSM, OpenTopoMap). 12 in flight keeps loading fast
22+
/// (parallel HTTP/2 over pooled connections) while staying a polite client.
23+
/// Easily tuned; a future refinement could vary it per provider.
24+
const MAX_CONCURRENT_TILE_FETCHES: usize = 12;
25+
26+
/// Shared HTTP client — pooled connections + HTTP/2, reused across all tile fetches.
27+
fn client() -> &'static reqwest::Client {
28+
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
29+
CLIENT.get_or_init(|| {
30+
reqwest::Client::builder()
31+
.user_agent("KiteGroundControl/1.0 (+https://github.com/b14ckyy/Kite-GC)")
32+
.timeout(Duration::from_secs(15))
33+
.build()
34+
.expect("failed to build tile HTTP client")
35+
})
36+
}
37+
38+
/// Global gate bounding how many tile fetches run at once (see the const above).
39+
fn fetch_gate() -> &'static Semaphore {
40+
static GATE: OnceLock<Semaphore> = OnceLock::new();
41+
GATE.get_or_init(|| Semaphore::new(MAX_CONCURRENT_TILE_FETCHES))
42+
}
43+
44+
/// Fetch a single map tile and return its raw bytes to the frontend.
45+
///
46+
/// Returned as a Tauri `Response` so the bytes travel over IPC as a real binary
47+
/// payload (an `ArrayBuffer` on the JS side), not a JSON number array.
48+
#[tauri::command]
49+
pub async fn fetch_tile(url: String) -> Result<tauri::ipc::Response, String> {
50+
// Hold a permit for the whole request so no more than MAX_CONCURRENT_TILE_FETCHES
51+
// are ever in flight; excess fetches queue here and proceed as permits free up.
52+
let _permit = fetch_gate()
53+
.acquire()
54+
.await
55+
.map_err(|e| format!("tile gate closed: {e}"))?;
56+
let resp = client()
57+
.get(&url)
58+
.send()
59+
.await
60+
.map_err(|e| format!("tile request failed: {e}"))?;
61+
if !resp.status().is_success() {
62+
return Err(format!("tile HTTP {}", resp.status()));
63+
}
64+
let bytes = resp
65+
.bytes()
66+
.await
67+
.map_err(|e| format!("tile body read failed: {e}"))?;
68+
Ok(tauri::ipc::Response::new(bytes.to_vec()))
69+
}

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ use commands::video::{
7171
};
7272
use video::Go2Rtc;
7373
use commands::logging::{set_log_level, get_log_path, log_session_settings};
74+
use commands::tiles::fetch_tile;
7475
use commands::radar::{radar_configure, radar_set_center, radar_set_node_pos, radar_snapshot};
7576
use commands::terrain::{
7677
terrain_cache_clear, terrain_cache_stats, terrain_elevation, terrain_elevations, terrain_fan,
@@ -269,6 +270,7 @@ pub fn run() {
269270
set_log_level,
270271
get_log_path,
271272
log_session_settings,
273+
fetch_tile,
272274
mission_get,
273275
mission_clear,
274276
mission_set,

src/lib/cache/CachedTileLayer.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import L from "leaflet";
1313
import { getCachedTile, putCachedTile } from "$lib/cache/tileCache";
14+
import { loadTileBytes } from "$lib/cache/tileLoader";
1415
import {
1516
isKnownUnavailable,
1617
isPlaceholderTile,
@@ -71,11 +72,7 @@ export const CachedTileLayer = L.TileLayer.extend({
7172
_fetchAndCache(tile: HTMLImageElement, url: string, done: L.DoneCallback, coords?: L.Coords) {
7273
const providerId: string | undefined = this.options.providerId;
7374
// Try to fetch and cache, but always display the tile even if caching fails
74-
fetch(url)
75-
.then((resp) => {
76-
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
77-
return resp.arrayBuffer();
78-
})
75+
loadTileBytes(url)
7976
.then((buf) => {
8077
// Over-zoom placeholder? Don't cache it. Once confirmed, fail the tile
8178
// and redraw — the redraw re-creates it as a parent-filled fallback.
@@ -203,8 +200,7 @@ export const CachedTileLayer = L.TileLayer.extend({
203200
const purl = this._buildUrlAt(px, py, pz);
204201
let buf: ArrayBuffer | null = null;
205202
try {
206-
const resp = await fetch(purl);
207-
if (resp.ok) buf = await resp.arrayBuffer();
203+
buf = await loadTileBytes(purl);
208204
} catch { return; /* network issue — keep the optimistic paint */ }
209205
if (!buf) return;
210206
if (!isPlaceholderTile(providerId, pz, px, py, buf, purl)) {

src/lib/cache/tileLoader.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Marc Hoffmann (b14ckyy)
3+
4+
// Shared map-tile byte loader for both the 2D (Leaflet) and 3D (Cesium) tile
5+
// pipelines. Routes fetches through the Rust backend (`fetch_tile` → reqwest:
6+
// HTTP/2, pooled connections, concurrency-capped) instead of the OS WebView's
7+
// HTTP client. The WebView caps connections per host and, on WebKitGTK/Linux,
8+
// serialises requests to a single-host provider (e.g. ESRI) — which makes tiles
9+
// crawl in. The Rust path multiplexes over HTTP/2 and behaves the same across
10+
// OSes. Falls back to the WebView's fetch() if the backend is unavailable (e.g.
11+
// a non-Tauri context or a command failure).
12+
13+
import { invoke } from "@tauri-apps/api/core";
14+
15+
/** Fetch raw tile bytes via the Rust backend, falling back to the WebView's fetch(). */
16+
export async function loadTileBytes(url: string): Promise<ArrayBuffer> {
17+
try {
18+
// Tauri delivers the command's `Response` bytes as an ArrayBuffer.
19+
return await invoke<ArrayBuffer>("fetch_tile", { url });
20+
} catch {
21+
const resp = await fetch(url);
22+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
23+
return await resp.arrayBuffer();
24+
}
25+
}

src/lib/components/Map3D.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import { getProviderById } from "$lib/config/mapProviders";
2626
import type { MapProvider } from "$lib/config/mapProviders";
2727
import { getCachedTile, putCachedTile, initTileCache } from "$lib/cache/tileCache";
28+
import { loadTileBytes } from "$lib/cache/tileLoader";
2829
import { isKnownUnavailable, isPlaceholderTile } from "$lib/cache/tileAvailability";
2930
import { isValidGpsCoordinate, MIN_FIX_SATELLITES } from "$lib/helpers/telemetry";
3031
import {
@@ -526,9 +527,8 @@
526527
* Throws on error (404, CORS, network) — Cesium will keep the parent tile visible.
527528
*/
528529
async function fetchAndCacheImage(url: string, meta?: TileMeta): Promise<HTMLImageElement> {
529-
const resp = await fetch(url);
530-
if (!resp.ok) throw new Error(`Tile ${resp.status}`);
531-
const buf = await resp.arrayBuffer();
530+
// Route through the backend loader (reqwest/HTTP2) — same speed win as the 2D map.
531+
const buf = await loadTileBytes(url);
532532
// Over-zoom placeholder? Reject (Cesium keeps the parent z-1 tile) and don't cache it; the region's
533533
// max zoom is now learned so siblings short-circuit.
534534
// NOTE: deliberately do NOT trigger a full imagery refresh here. Re-applying the provider does

0 commit comments

Comments
 (0)