Skip to content

Commit 271643b

Browse files
authored
feat: render PMTiles URLs as external native layers (#81)
* feat: render PMTiles URLs as external native layers Adds the pmtiles dependency and registers the pmtiles:// protocol so PMTiles vector and raster sources can be added directly to MapLibre. Vector sources fan out into fill, line, and circle layers per source layer, while raster sources render through a single raster layer. * refactor: improve XYZ tile URL handling and add async URL resolution Updated the XYZ tile URL rendering to bypass IPC for better performance in Tauri, allowing direct use of HTTPS image tiles. Introduced asynchronous functions for fetching and resolving URLs, with improved error handling and timeout settings for remote tile requests. This enhances responsiveness and reliability when dealing with external tile sources. * Address Copilot review feedback - Skip rendering when a PMTiles vector source has no known source layer instead of adding an invalid vector fill layer with no `source-layer`, which MapLibre would reject at runtime. - URL-encode the source-layer part of PMTiles vector layer IDs to match the MBTiles/vector-tile convention and keep IDs stable and comparable. - Register and add archives through a single shared PMTiles Protocol instance stored on globalThis, and let protocol-registration errors surface instead of being silently swallowed.
1 parent 873e29a commit 271643b

5 files changed

Lines changed: 301 additions & 10 deletions

File tree

apps/geolibre-desktop/src-tauri/src/lib.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const MARTIN_VERSION: &str = "martin-v1.10.1";
2424
const MARTIN_RELEASE_BASE_URL: &str = "https://github.com/maplibre/martin/releases/download";
2525
const MARTIN_START_ATTEMPTS: usize = 3;
2626
const MARTIN_HEALTH_ATTEMPTS: usize = 30;
27+
const REMOTE_TILE_TIMEOUT_SECS: u64 = 8;
28+
const REMOTE_TILE_CONNECT_TIMEOUT_SECS: u64 = 4;
29+
const URL_RESOLVE_TIMEOUT_SECS: u64 = 15;
2730

2831
struct MartinServerState {
2932
process: Mutex<Option<MartinProcess>>,
@@ -82,12 +85,25 @@ fn close_oauth_popups(app: tauri::AppHandle) {
8285
}
8386

8487
#[tauri::command]
85-
fn fetch_url_bytes(url: String) -> Result<Vec<u8>, String> {
88+
async fn fetch_url_bytes(url: String) -> Result<Vec<u8>, String> {
89+
tauri::async_runtime::spawn_blocking(move || fetch_url_bytes_blocking(url))
90+
.await
91+
.map_err(|error| format!("Tile fetch task failed: {error}"))?
92+
}
93+
94+
fn fetch_url_bytes_blocking(url: String) -> Result<Vec<u8>, String> {
8695
if !url.starts_with("https://") && !url.starts_with("http://") {
8796
return Err("Only HTTP and HTTPS URLs can be fetched".to_string());
8897
}
8998

90-
let response = reqwest::blocking::Client::new()
99+
let client = reqwest::blocking::Client::builder()
100+
.timeout(Duration::from_secs(REMOTE_TILE_TIMEOUT_SECS))
101+
.connect_timeout(Duration::from_secs(REMOTE_TILE_CONNECT_TIMEOUT_SECS))
102+
.user_agent("GeoLibre Desktop")
103+
.build()
104+
.map_err(|error| format!("Could not create HTTP client: {error}"))?;
105+
106+
let response = client
91107
.get(&url)
92108
.send()
93109
.map_err(|error| format!("Request failed: {error}"))?;
@@ -103,13 +119,21 @@ fn fetch_url_bytes(url: String) -> Result<Vec<u8>, String> {
103119
}
104120

105121
#[tauri::command]
106-
fn resolve_url_redirect(url: String) -> Result<String, String> {
122+
async fn resolve_url_redirect(url: String) -> Result<String, String> {
123+
tauri::async_runtime::spawn_blocking(move || resolve_url_redirect_blocking(url))
124+
.await
125+
.map_err(|error| format!("URL resolve task failed: {error}"))?
126+
}
127+
128+
fn resolve_url_redirect_blocking(url: String) -> Result<String, String> {
107129
if !url.starts_with("https://") && !url.starts_with("http://") {
108130
return Err("Only HTTP and HTTPS URLs can be resolved".to_string());
109131
}
110132

111133
let client = reqwest::blocking::Client::builder()
112-
.timeout(Duration::from_secs(15))
134+
.timeout(Duration::from_secs(URL_RESOLVE_TIMEOUT_SECS))
135+
.connect_timeout(Duration::from_secs(REMOTE_TILE_CONNECT_TIMEOUT_SECS))
136+
.user_agent("GeoLibre Desktop")
113137
.build()
114138
.map_err(|error| format!("Could not create HTTP client: {error}"))?;
115139

apps/geolibre-desktop/src/lib/xyz-url.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,10 @@ function getSavedXyzUrl(layer: GeoLibreLayer): string | null {
171171
}
172172

173173
function renderableXyzTileUrl(url: string): string {
174-
if (!isTauri() || !isHttpUrl(url)) return url;
175-
176-
registerXyzTileProtocol();
177-
return `${XYZ_TILE_PROTOCOL}://tile/{z}/{x}/{y}?url=${encodeURIComponent(
178-
url,
179-
)}`;
174+
// Tauri allows HTTPS image tiles via CSP, so keep the browser/WebView tile
175+
// path. Routing every XYZ tile through IPC makes slow tile servers affect
176+
// desktop responsiveness.
177+
return url;
180178
}
181179

182180
function parseXyzTileRequest(request: RequestParameters): string {
@@ -208,10 +206,24 @@ async function resolveShortXyzUrl(
208206
if (!isHttpUrl(url)) return url;
209207

210208
if (isTauri()) {
209+
try {
210+
return await resolveShortXyzUrlWithFetch(url, signal);
211+
} catch (error) {
212+
if (isAbortError(error)) throw error;
213+
console.warn("Falling back to desktop URL resolver", error);
214+
}
215+
211216
const { invoke } = await import("@tauri-apps/api/core");
212217
return invoke<string>("resolve_url_redirect", { url });
213218
}
214219

220+
return resolveShortXyzUrlWithFetch(url, signal);
221+
}
222+
223+
async function resolveShortXyzUrlWithFetch(
224+
url: string,
225+
signal?: AbortSignal,
226+
): Promise<string> {
215227
const response = await fetch(url, {
216228
headers: { Accept: "application/json, text/plain;q=0.9, */*;q=0.8" },
217229
redirect: "follow",
@@ -229,6 +241,10 @@ async function resolveShortXyzUrl(
229241
return response.url || url;
230242
}
231243

244+
function isAbortError(error: unknown): boolean {
245+
return error instanceof DOMException && error.name === "AbortError";
246+
}
247+
232248
function urlFromResolverResponse(response: Response): string | null {
233249
const resolvedUrl = normalizeTileUrlTemplate(response.url);
234250
return resolvedUrl && hasXyzTilePlaceholders(resolvedUrl)

package-lock.json

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

packages/map/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@turf/helpers": "^7.2.0",
1515
"maplibre-gl": "^5.24.0",
1616
"maplibre-gl-layer-control": "^0.14.1",
17+
"pmtiles": "^4.4.1",
1718
"react": "^18.3.1",
1819
"react-dom": "^18.3.1"
1920
},

0 commit comments

Comments
 (0)