From 21b33c5eaf972c53bd174d9c49fab8cd38b3e647 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 13 Sep 2026 21:25:03 +0200 Subject: [PATCH] fix(location): unblock openstreetmap tiles and add native location on mobile --- package.json | 3 +- pnpm-lock.yaml | 10 + src-tauri/Cargo.lock | 55 ++-- src-tauri/Cargo.toml | 1 + src-tauri/Info.plist | 2 + src-tauri/capabilities/mobile.json | 3 + src-tauri/src/lib.rs | 6 + src-tauri/src/network/map_tiles.rs | 239 ++++++++++++++++++ src-tauri/src/network/mod.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/app/components/message/LocationMap.tsx | 6 +- .../room/location-modal/LocationDialog.tsx | 49 ++-- src/app/utils/geolocation.ts | 61 +++++ src/app/utils/tileUrl.test.ts | 23 ++ src/app/utils/tileUrl.ts | 12 + 15 files changed, 421 insertions(+), 52 deletions(-) create mode 100644 src-tauri/src/network/map_tiles.rs create mode 100644 src/app/utils/geolocation.ts create mode 100644 src/app/utils/tileUrl.test.ts create mode 100644 src/app/utils/tileUrl.ts diff --git a/package.json b/package.json index c2b9cd8df1..0cecdcc76c 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@tauri-apps/plugin-deep-link": "2.4.9", "@tauri-apps/plugin-dialog": "2.7.2", "@tauri-apps/plugin-fs": "2.5.1", + "@tauri-apps/plugin-geolocation": "2.3.3", "@tauri-apps/plugin-http": "2.5.9", "@tauri-apps/plugin-opener": "2.5.4", "@tauri-apps/plugin-os": "2.3.2", @@ -141,7 +142,6 @@ "@sableclient/sable-call-embedded": "1.1.8", "@sentry/vite-plugin": "^5.4.0", "@tauri-apps/cli": "2.11.4", - "tauri-plugin-app-icon-api": "github:SableClient/tauri-plugin-app-icon#bab80ae17f6d018702e472c539d0f02eba480c21", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", @@ -164,6 +164,7 @@ "oxfmt": "^0.63.0", "oxlint": "^1.78.0", "oxlint-tsgolint": "^7.0.2001", + "tauri-plugin-app-icon-api": "github:SableClient/tauri-plugin-app-icon#bab80ae17f6d018702e472c539d0f02eba480c21", "testcontainers": "^12.1.0", "typescript": "^7.0.2", "vite": "^8.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7947b78a4..cbef0a2a1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@tauri-apps/plugin-fs': specifier: 2.5.1 version: 2.5.1 + '@tauri-apps/plugin-geolocation': + specifier: 2.3.3 + version: 2.3.3 '@tauri-apps/plugin-http': specifier: 2.5.9 version: 2.5.9 @@ -2563,6 +2566,9 @@ packages: '@tauri-apps/plugin-fs@2.5.1': resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} + '@tauri-apps/plugin-geolocation@2.3.3': + resolution: {integrity: sha512-8TDI9YS9I2eXj1IwUfeiFY4OLkhVan08vpgHSSTM1tl7p/fNA7APlS6QAgLX9j73zqVp5Pl9HrFHG2gJtIunqg==} + '@tauri-apps/plugin-http@2.5.9': resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} @@ -7252,6 +7258,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-geolocation@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-http@2.5.9': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3f13d90162..05543d7adb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -201,7 +201,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -212,7 +212,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1238,7 +1238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1986,7 +1986,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2314,7 +2314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3426,7 +3426,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4781,7 +4781,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4905,7 +4905,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5390,7 +5390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6017,7 +6017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -6088,7 +6088,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -6127,9 +6127,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6740,7 +6740,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6799,7 +6799,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6875,6 +6875,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-edge-to-edge", "tauri-plugin-fs", + "tauri-plugin-geolocation", "tauri-plugin-global-shortcut", "tauri-plugin-http", "tauri-plugin-livekit-mobile", @@ -7587,7 +7588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8248,6 +8249,20 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-geolocation" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc86d035296ed4be0c3ca0fc595019b4ba696d929785f623f11c461e04e00a8" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.2" @@ -8680,7 +8695,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9311,7 +9326,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9397,7 +9412,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10129,7 +10144,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a69b0004c..83cab82d81 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -146,6 +146,7 @@ tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } tauri-plugin-app-icon = { git = "https://github.com/SableClient/tauri-plugin-app-icon", rev = "bab80ae17f6d018702e472c539d0f02eba480c21" } tauri-plugin-livekit-mobile = { git = "https://github.com/SableClient/tauri-plugin-livekit-mobile.git", rev = "c6f0d02ab3da1174535bc1dc8e598a2e6873f977" } +tauri-plugin-geolocation = "2.3.3" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index d516df900c..f778031cac 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -10,6 +10,8 @@ Sable needs camera access for video calls. NSMicrophoneUsageDescription Sable needs microphone access for voice and video calls. + NSLocationWhenInUseUsageDescription + Sable needs your location to share it in a conversation. TauriAppIconAliases diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index ced96b9c24..58c0323288 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -8,6 +8,9 @@ "app-icon:default", "deep-link:default", "edge-to-edge:default", + "geolocation:allow-check-permissions", + "geolocation:allow-request-permissions", + "geolocation:allow-get-current-position", "livekit-mobile:default", "notifications:default", "sharekit:default" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 47e0669944..7724e11e06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -371,6 +371,7 @@ pub fn run() { let builder = builder .plugin(tauri_plugin_app_icon::init()) .plugin(tauri_plugin_edge_to_edge::init()) + .plugin(tauri_plugin_geolocation::init()) .plugin(tauri_plugin_sharekit::init()) .plugin(tauri_plugin_livekit_mobile::init()); @@ -388,6 +389,10 @@ pub fn run() { network::media_protocol::MEDIA_URI_SCHEME, network::media_protocol::respond, ) + .register_asynchronous_uri_scheme_protocol( + network::map_tiles::TILE_URI_SCHEME, + network::map_tiles::respond, + ) .setup(|app| { #[cfg(target_os = "android")] mobile::set_app_handle(app.handle().clone()); @@ -402,6 +407,7 @@ pub fn run() { } network::native_upload::cleanup_uploads(app.handle()); + network::map_tiles::cleanup_cache(app.handle()); #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] { diff --git a/src-tauri/src/network/map_tiles.rs b/src-tauri/src/network/map_tiles.rs new file mode 100644 index 0000000000..93930f8c5f --- /dev/null +++ b/src-tauri/src/network/map_tiles.rs @@ -0,0 +1,239 @@ +//! osm.org demands a `Referer` from browser-like clients, and a webview never sends one from the +//! `tauri://` origin, so its tiles come back as the "Access blocked" placeholder. Fetching from +//! Rust identifies the app instead, and the disk cache covers the policy's caching rule. + +use std::{ + fs, + path::{Path, PathBuf}, + sync::OnceLock, + time::Duration, +}; + +use tauri::{ + http::{header, Request, Response, StatusCode}, + AppHandle, Manager, Runtime, UriSchemeContext, UriSchemeResponder, +}; +use tauri_plugin_http::reqwest::Client; +use tokio::sync::Semaphore; + +pub const TILE_URI_SCHEME: &str = "sable-tiles"; + +const TILE_ORIGIN: &str = "https://tile.openstreetmap.org"; +const CACHE_SUBDIR: &str = "sable-tiles"; +const CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_CONCURRENT_REQUESTS: usize = 6; +const MAX_ZOOM: u32 = 19; + +static CLIENT: OnceLock = OnceLock::new(); +static LANE: Semaphore = Semaphore::const_new(MAX_CONCURRENT_REQUESTS); + +pub fn respond( + ctx: UriSchemeContext<'_, R>, + request: Request>, + responder: UriSchemeResponder, +) { + let app = ctx.app_handle().clone(); + let path = request.uri().path().to_owned(); + tauri::async_runtime::spawn(async move { + responder.respond( + handle_request(&app, &path) + .await + .unwrap_or_else(error_response), + ); + }); +} + +pub fn cleanup_cache(app: &AppHandle) { + let Ok(dir) = cache_dir(app) else { return }; + tauri::async_runtime::spawn_blocking(move || { + for tile in walk_tiles(&dir) { + if !is_fresh(&tile) { + let _ = fs::remove_file(tile); + } + } + }); +} + +struct Tile { + z: u32, + x: u32, + y: u32, +} + +impl Tile { + /// Anything but `/{z}/{x}/{y}.png` is rejected, so the scheme is not an open proxy. + fn parse(path: &str) -> Option { + let mut segments = path.trim_start_matches('/').split('/'); + let z: u32 = segments.next()?.parse().ok()?; + let x: u32 = segments.next()?.parse().ok()?; + let y: u32 = segments.next()?.strip_suffix(".png")?.parse().ok()?; + if segments.next().is_some() || z > MAX_ZOOM { + return None; + } + let span = 1u32 << z; + (x < span && y < span).then_some(Self { z, x, y }) + } + + fn url(&self) -> String { + format!("{TILE_ORIGIN}/{}/{}/{}.png", self.z, self.x, self.y) + } + + fn cache_path(&self, dir: &Path) -> PathBuf { + dir.join(self.z.to_string()) + .join(self.x.to_string()) + .join(format!("{}.png", self.y)) + } +} + +async fn handle_request( + app: &AppHandle, + path: &str, +) -> Result>, StatusCode> { + let tile = Tile::parse(path).ok_or(StatusCode::NOT_FOUND)?; + let cache_path = cache_dir(app).ok().map(|dir| tile.cache_path(&dir)); + + if let Some(bytes) = cache_path.as_deref().and_then(read_fresh) { + return Ok(tile_response(bytes)); + } + + let _permit = LANE + .acquire() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response = client(app) + .get(tile.url()) + .send() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + if !response.status().is_success() { + return Err(StatusCode::BAD_GATEWAY); + } + // A policy block comes back as 200 with a placeholder image; caching that would hide it. + if let Some(reason) = response.headers().get("x-blocked") { + log::warn!("openstreetmap tile request blocked: {reason:?}"); + return Err(StatusCode::BAD_GATEWAY); + } + + let bytes = response + .bytes() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)? + .to_vec(); + + if let Some(path) = cache_path { + write_cache(&path, &bytes); + } + Ok(tile_response(bytes)) +} + +fn client(app: &AppHandle) -> &'static Client { + CLIENT.get_or_init(|| { + let user_agent = format!( + "Sable/{} (+https://app.sable.moe)", + app.package_info().version + ); + Client::builder() + .user_agent(user_agent) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|err| { + log::error!("tile client build failed, tiles will be blocked: {err}"); + Client::default() + }) + }) +} + +fn tile_response(bytes: Vec) -> Response> { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/png") + .header(header::CACHE_CONTROL, "public, max-age=604800") + .body(bytes) + .unwrap_or_else(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR)) +} + +fn error_response(status: StatusCode) -> Response> { + Response::builder() + .status(status) + .body(Vec::new()) + .expect("empty body response is always valid") +} + +fn cache_dir(app: &AppHandle) -> Result { + app.path() + .app_cache_dir() + .map(|dir| dir.join(CACHE_SUBDIR)) + .map_err(|err| err.to_string()) +} + +fn is_fresh(path: &Path) -> bool { + let Ok(modified) = fs::metadata(path).and_then(|meta| meta.modified()) else { + return false; + }; + modified.elapsed().is_ok_and(|age| age < CACHE_TTL) +} + +fn read_fresh(path: &Path) -> Option> { + is_fresh(path).then(|| fs::read(path).ok()).flatten() +} + +fn write_cache(path: &Path, bytes: &[u8]) { + let Some(parent) = path.parent() else { return }; + if fs::create_dir_all(parent).is_err() { + return; + } + // Renamed into place so a concurrent read never sees a half-written tile. + let temp = path.with_extension("png.part"); + if fs::write(&temp, bytes).is_ok() && fs::rename(&temp, path).is_err() { + let _ = fs::remove_file(&temp); + } +} + +fn walk_tiles(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .flat_map(|entry| { + let path = entry.path(); + if path.is_dir() { + walk_tiles(&path) + } else { + vec![path] + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::Tile; + + #[test] + fn parses_tile_paths() { + let tile = Tile::parse("/16/33202/22539.png").expect("valid tile path"); + assert_eq!((tile.z, tile.x, tile.y), (16, 33202, 22539)); + } + + #[test] + fn rejects_non_tile_paths() { + for path in [ + "/", + "/16/33202", + "/16/33202/22539.jpg", + "/16/33202/22539.png/extra", + "/20/1/1.png", + // Outside the tile grid for zoom 2. + "/2/4/1.png", + "/../../etc/passwd", + ] { + assert!(Tile::parse(path).is_none(), "{path} should not parse"); + } + } +} diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index ae26b7f556..f101069a82 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -1,3 +1,4 @@ pub mod loopback_http; +pub mod map_tiles; pub mod media_protocol; pub mod native_upload; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5e24275035..94bca1a232 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,7 +12,7 @@ "app": { "windows": [], "security": { - "csp": "default-src 'self' blob: data: filesystem: tauri:; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' filesystem: tauri:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' blob: data: filesystem: http: https: tauri:; font-src 'self' blob: data: filesystem: http: https: tauri:; img-src 'self' data: blob: filesystem: http: https: sable-media:; media-src 'self' blob: data: http: https: sable-media:; frame-src 'self' blob: http: https: tauri:; connect-src 'self' ipc: ws: wss: http: https: http://ipc.localhost sable-media: http://sable-media.localhost; object-src 'none'; base-uri 'self'; form-action 'self'" + "csp": "default-src 'self' blob: data: filesystem: tauri:; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' filesystem: tauri:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' blob: data: filesystem: http: https: tauri:; font-src 'self' blob: data: filesystem: http: https: tauri:; img-src 'self' data: blob: filesystem: http: https: sable-media: sable-tiles:; media-src 'self' blob: data: http: https: sable-media:; frame-src 'self' blob: http: https: tauri:; connect-src 'self' ipc: ws: wss: http: https: http://ipc.localhost sable-media: http://sable-media.localhost; object-src 'none'; base-uri 'self'; form-action 'self'" } }, "bundle": { diff --git a/src/app/components/message/LocationMap.tsx b/src/app/components/message/LocationMap.tsx index 5a04ec93cf..ebba503b4e 100644 --- a/src/app/components/message/LocationMap.tsx +++ b/src/app/components/message/LocationMap.tsx @@ -3,6 +3,7 @@ import type { LatLngExpression } from 'leaflet'; import markerIconPng from 'leaflet/dist/images/marker-icon.png'; import 'leaflet/dist/leaflet.css'; import { MapContainer, Marker, TileLayer } from 'react-leaflet'; +import { getTileUrl, TILE_ATTRIBUTION } from '$utils/tileUrl'; const markerIcon = new Icon({ iconUrl: markerIconPng, @@ -26,10 +27,7 @@ export function LocationMap({ coordinates, className }: LocationMapProps) { className={className} attributionControl > - + > = { + permissions: LocationErrors.permissions, + unavailable: LocationErrors.module, + unsupported: LocationErrors.unsupported, +}; + export type LocationPoint = { status: LocationErrors; lat?: number; @@ -183,28 +196,15 @@ export function LocationDialog({ onCancel, onSubmit, room }: LocationDialogProps .catch(() => storeLocation({ status: LocationErrors.missingClipboard })); } - function getLocation() { - const options = { - enableHighAccuracy: true, - timeout: 5000, - maximumAge: 0, - }; - function success(pos: GeolocationPosition) { - const crd = pos.coords; - - if (!crd.latitude || !crd.longitude) { - setLocationError(LocationErrors.unknown); - return; - } - storeLocation({ lat: crd.latitude, lon: crd.longitude, status: LocationErrors.none }); - } - - function error(err: GeolocationPositionError) { - if (err.code === 1) setLocationError(LocationErrors.permissions); - else if (err.code === 2) setLocationError(LocationErrors.module); - else setLocationError(LocationErrors.unknown); + async function getLocation() { + try { + const { lat, lon } = await getCurrentCoordinates(); + storeLocation({ lat, lon, status: LocationErrors.none }); + } catch (err) { + setLocationError( + (err instanceof GeolocationError && FAILURE_ERRORS[err.reason]) || LocationErrors.unknown + ); } - navigator.geolocation.getCurrentPosition(success, error, options); } const handleLat: ChangeEventHandler = (evt) => { const val = evt.target.value; @@ -278,7 +278,7 @@ export function LocationDialog({ onCancel, onSubmit, room }: LocationDialogProps : 'Critical' } className={classNames(css.LocationInputItem, css.LocationInputCurLocation)} - onClick={getLocation} + onClick={() => void getLocation()} before={} > Share Current Location @@ -344,10 +344,7 @@ export function LocationDialog({ onCancel, onSubmit, room }: LocationDialogProps className={css.LocationMapContainer} ref={setMap} > - + => { + let status = await checkPermissions(); + if (status.location === 'prompt' || status.location === 'prompt-with-rationale') { + status = await requestPermissions(['location']); + } + if (status.location !== 'granted') throw new GeolocationError('permissions'); + + const { coords } = await getCurrentPosition(POSITION_OPTIONS); + return { lat: coords.latitude, lon: coords.longitude }; +}; + +const getWebCoordinates = (): Promise => + new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + ({ coords }) => resolve({ lat: coords.latitude, lon: coords.longitude }), + (err) => { + if (err.code === err.PERMISSION_DENIED) reject(new GeolocationError('permissions')); + else if (err.code === err.POSITION_UNAVAILABLE) reject(new GeolocationError('unavailable')); + else reject(new GeolocationError('unknown')); + }, + POSITION_OPTIONS + ); + }); + +export const getCurrentCoordinates = async (): Promise => { + if (!isTauri()) return getWebCoordinates(); + if (!isMobileTauri()) throw new GeolocationError('unsupported'); + try { + return await getNativeCoordinates(); + } catch (err) { + throw err instanceof GeolocationError ? err : new GeolocationError('unknown'); + } +}; diff --git a/src/app/utils/tileUrl.test.ts b/src/app/utils/tileUrl.test.ts new file mode 100644 index 0000000000..32d1145a46 --- /dev/null +++ b/src/app/utils/tileUrl.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +const hoistedIsTauri = vi.hoisted(() => vi.fn<() => boolean>(() => false)); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: hoistedIsTauri, + convertFileSrc: (path: string, protocol: string) => + `${protocol}://localhost/${encodeURIComponent(path)}`, +})); + +const { getTileUrl } = await import('./tileUrl'); + +describe('getTileUrl', () => { + it('uses openstreetmap.org directly on web', () => { + hoistedIsTauri.mockReturnValue(false); + expect(getTileUrl()).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png'); + }); + + it('keeps the leaflet placeholders unencoded behind the native scheme', () => { + hoistedIsTauri.mockReturnValue(true); + expect(getTileUrl()).toBe('sable-tiles://localhost/{z}/{x}/{y}.png'); + }); +}); diff --git a/src/app/utils/tileUrl.ts b/src/app/utils/tileUrl.ts new file mode 100644 index 0000000000..55fd7cba2c --- /dev/null +++ b/src/app/utils/tileUrl.ts @@ -0,0 +1,12 @@ +import { convertFileSrc, isTauri } from '@tauri-apps/api/core'; + +const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +const TILE_URI_SCHEME = 'sable-tiles'; + +export const TILE_ATTRIBUTION = + '© OpenStreetMap'; + +// Appended to the scheme origin rather than passed through convertFileSrc, which would +// percent-encode the placeholders out of Leaflet's reach. +export const getTileUrl = (): string => + isTauri() ? `${convertFileSrc('', TILE_URI_SCHEME)}{z}/{x}/{y}.png` : OSM_TILE_URL;