diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index c8bfd46bf..a7c6f8181 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -5,6 +5,7 @@ import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' import type { JourneyTrack } from '@trek/shared' import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' +import { offlineStyle } from '../../native/offlineTiles' export interface JourneyMapGLHandle { highlightMarker: (id: string | null) => void @@ -290,7 +291,7 @@ function JourneyMapGL( const mapOptions: Record = { container: containerRef.current, - style: glStyle, + style: isMapLibre ? offlineStyle(glStyle) : glStyle, center: hasPoints ? bounds.getCenter() : [0, 30], zoom: hasPoints ? 2 : 1, pitch: enableMapbox3d && fullScreen ? 45 : 0, diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index b122575cf..88233eed3 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -43,6 +43,7 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport' import { selectedPlaceTarget } from './selectedPlaceTarget' +import { offlineStyle } from '../../native/offlineTiles' function categoryIconSvg(iconName: string | null | undefined, size: number): string { const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] @@ -1219,7 +1220,7 @@ export function MapViewGL({ const mapOptions: Record = { container: containerRef.current, - style: glStyle, + style: isMapLibre ? offlineStyle(glStyle) : glStyle, center: [initial.center[1], initial.center[0]], zoom: initial.zoom, pitch: enableMapbox3d ? 45 : 0, diff --git a/client/src/components/Map/VectorBasemap.tsx b/client/src/components/Map/VectorBasemap.tsx index 235612aa3..876911640 100644 --- a/client/src/components/Map/VectorBasemap.tsx +++ b/client/src/components/Map/VectorBasemap.tsx @@ -9,6 +9,7 @@ import { } from '../../constants/mapDefaults' import { hasWebGL } from '../../utils/webgl' import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload' +import { offlineStyle } from '../../native/offlineTiles' /** The Leaflet layer maplibre-gl-leaflet hands back. */ export type GlLeafletLayer = InstanceType @@ -142,7 +143,7 @@ export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void */ export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void { if (!layer || !isGlLayer(layer)) return - layer.getMaplibreMap()?.setStyle(style) + layer.getMaplibreMap()?.setStyle(offlineStyle(style)) } /** @@ -188,7 +189,7 @@ export function VectorBasemap({ style }: { style: string }) { // change must retile in place rather than tear the layer down, the same // reason the raster maps keep their template out of the build effect (#2097). const layer = maplibreGL({ - style: styleRef.current, + style: offlineStyle(styleRef.current), interactive: false, attributionControl: false, }) @@ -264,7 +265,7 @@ export async function attachVectorBasemap( return } - const layer = maplibreGL({ style, interactive: false, attributionControl: false }) + const layer = maplibreGL({ style: offlineStyle(style), interactive: false, attributionControl: false }) // The raster layer this replaces carried the credit, and OpenFreeMap asks for // one of its own; attachGlLayer adds it once the layer is actually on the map. if (!attachGlLayer(map, layer, style)) { diff --git a/client/src/components/Map/engines/maplibre.ts b/client/src/components/Map/engines/maplibre.ts index d67716a70..012898d23 100644 --- a/client/src/components/Map/engines/maplibre.ts +++ b/client/src/components/Map/engines/maplibre.ts @@ -2,6 +2,7 @@ import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url' import { registerRtlTextPlugin } from '../rtlText' +import { installOfflineProtocol } from '../../../native/offlineTiles' /** * The only module in the client that pulls maplibre-gl in at runtime. Same rule as @@ -15,5 +16,7 @@ import { registerRtlTextPlugin } from '../rtlText' // Arabic/Hebrew/Persian labels come out unjoined and reversed without this. // MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts. registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true)) +// The iOS app has no service worker to serve pre-downloaded tiles; see native/offlineTiles.ts. +installOfflineProtocol(maplibregl) export default maplibregl diff --git a/client/src/native/offlineTiles.test.ts b/client/src/native/offlineTiles.test.ts new file mode 100644 index 000000000..26a0d2b9e --- /dev/null +++ b/client/src/native/offlineTiles.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + fromOfflineUrl, installOfflineProtocol, offlineProtocol, offlineStyle, rewriteDocument, toOfflineUrl, +} from './offlineTiles' +import { isNativeIos } from './platform' + +vi.mock('./platform', () => ({ isNativeIos: vi.fn() })) + +const cached = new Map() +const cache = { match: vi.fn(async (url: string) => cached.get(url)?.clone()) } + +beforeEach(() => { + cached.clear() + vi.mocked(isNativeIos).mockReturnValue(true) + vi.stubGlobal('caches', { open: vi.fn(async () => cache) }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function params(url: string, type: 'json' | 'arrayBuffer' | 'image' | 'string') { + return { url, type } +} + +describe('URL rewriting', () => { + it('moves https URLs onto the protocol and back, and leaves everything else', () => { + expect(toOfflineUrl('https://tiles.openfreemap.org/styles/positron')).toBe('trek-offline://tiles.openfreemap.org/styles/positron') + expect(fromOfflineUrl('trek-offline://tiles.openfreemap.org/styles/positron')).toBe('https://tiles.openfreemap.org/styles/positron') + expect(toOfflineUrl('mapbox://styles/mapbox/streets-v12')).toBe('mapbox://styles/mapbox/streets-v12') + expect(fromOfflineUrl('https://x.test/a')).toBe('https://x.test/a') + }) + + it('rewrites the style only inside the iOS app with Cache Storage', () => { + const style = 'https://tiles.openfreemap.org/styles/liberty?key=abc' + expect(offlineStyle(style)).toBe('trek-offline://tiles.openfreemap.org/styles/liberty?key=abc') + expect(offlineStyle({ version: 8 })).toEqual({ version: 8 }) + + vi.mocked(isNativeIos).mockReturnValue(false) + expect(offlineStyle(style)).toBe(style) + + vi.mocked(isNativeIos).mockReturnValue(true) + vi.stubGlobal('caches', undefined) + expect(offlineStyle(style)).toBe(style) + }) + + it('points sources, tiles, glyphs and sprites of a style at the protocol', () => { + expect(rewriteDocument({ + version: 8, + glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', + sprite: [{ id: 'default', url: 'https://tiles.openfreemap.org/sprites/ofm' }, 'odd'], + sources: { + openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' }, + inline: { type: 'raster', tiles: ['https://a.test/{z}/{x}/{y}.png', 7] }, + broken: null, + }, + layers: [], + })).toEqual({ + version: 8, + glyphs: 'trek-offline://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', + sprite: [{ id: 'default', url: 'trek-offline://tiles.openfreemap.org/sprites/ofm' }, 'odd'], + sources: { + openmaptiles: { type: 'vector', url: 'trek-offline://tiles.openfreemap.org/planet' }, + inline: { type: 'raster', tiles: ['trek-offline://a.test/{z}/{x}/{y}.png', 7] }, + broken: null, + }, + layers: [], + }) + }) + + it('handles a TileJSON, a single sprite URL and things that are not documents', () => { + expect(rewriteDocument({ tiles: ['https://t.test/20250101/{z}/{x}/{y}.pbf'] })).toEqual({ tiles: ['trek-offline://t.test/20250101/{z}/{x}/{y}.pbf'] }) + expect(rewriteDocument({ sprite: 'https://s.test/sprite' })).toEqual({ sprite: 'trek-offline://s.test/sprite' }) + expect(rewriteDocument('x')).toBe('x') + expect(rewriteDocument([1])).toEqual([1]) + }) +}) + +describe('offlineProtocol', () => { + it('answers a document from the network and rewrites it', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ tiles: ['https://t.test/{z}/{x}/{y}.pbf'] }))) + vi.stubGlobal('fetch', fetchMock) + const result = await offlineProtocol(params('trek-offline://t.test/planet', 'json'), new AbortController()) + expect(fetchMock).toHaveBeenCalledWith('https://t.test/planet', expect.objectContaining({ mode: 'cors' })) + expect(result.data).toEqual({ tiles: ['trek-offline://t.test/{z}/{x}/{y}.pbf'] }) + }) + + it('falls back to the cached document when the network is gone', async () => { + cached.set('https://t.test/style', new Response(JSON.stringify({ version: 8 }))) + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline') })) + const result = await offlineProtocol(params('trek-offline://t.test/style', 'json'), new AbortController()) + expect(result.data).toEqual({ version: 8 }) + }) + + it('serves tiles from the cache without asking the network', async () => { + cached.set('https://t.test/1/2/3.pbf', new Response(new Uint8Array([1, 2, 3]))) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const result = await offlineProtocol(params('trek-offline://t.test/1/2/3.pbf', 'arrayBuffer'), new AbortController()) + expect(new Uint8Array(result.data as ArrayBuffer)).toEqual(new Uint8Array([1, 2, 3])) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches an uncached tile, and returns text for string requests', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('hello'))) + const tile = await offlineProtocol(params('trek-offline://t.test/9/9/9.png', 'image'), new AbortController()) + expect(new TextDecoder().decode(tile.data as ArrayBuffer)).toBe('hello') + const text = await offlineProtocol(params('trek-offline://t.test/a.txt', 'string'), new AbortController()) + expect(text.data).toBe('hello') + }) + + it('fails loudly on an uncached tile offline or an HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline') })) + await expect(offlineProtocol(params('trek-offline://t.test/0/0/0.pbf', 'arrayBuffer'), new AbortController())).rejects.toThrow('offline') + + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 }))) + await expect(offlineProtocol(params('trek-offline://t.test/0/0/0.pbf', 'arrayBuffer'), new AbortController())).rejects.toThrow('HTTP 404') + await expect(offlineProtocol(params('trek-offline://t.test/style', 'json'), new AbortController())).rejects.toThrow('Not available offline') + }) + + it('passes an abort straight through for documents', async () => { + const controller = new AbortController() + controller.abort() + vi.stubGlobal('fetch', vi.fn(async () => { throw new DOMException('aborted', 'AbortError') })) + await expect(offlineProtocol(params('trek-offline://t.test/style', 'json'), controller)).rejects.toThrow('aborted') + }) +}) + +describe('installOfflineProtocol', () => { + it('registers only inside the iOS app', () => { + const addProtocol = vi.fn() + installOfflineProtocol({ addProtocol }) + expect(addProtocol).toHaveBeenCalledWith('trek-offline', offlineProtocol) + + addProtocol.mockClear() + vi.mocked(isNativeIos).mockReturnValue(false) + installOfflineProtocol({ addProtocol }) + expect(addProtocol).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/native/offlineTiles.ts b/client/src/native/offlineTiles.ts new file mode 100644 index 000000000..3a6248878 --- /dev/null +++ b/client/src/native/offlineTiles.ts @@ -0,0 +1,104 @@ +import type { AddProtocolAction, GetResourceResponse, RequestParameters } from 'maplibre-gl' +import { VECTOR_CACHE } from '../sync/glPrefetcher' +import { isNativeIos } from './platform' + +/** + * Offline vector maps for the iOS app. + * + * In a browser the service worker answers MapLibre's requests from the + * 'gl-map-offline' cache that glPrefetcher fills for trips marked for offline. + * The iOS app has no service worker, so nothing reads that cache there. This + * protocol takes the service worker's place: the style URL is rewritten to + * trek-offline://, every https URL inside the style and its TileJSON gets the + * same scheme, and the handler serves them from the cache or the network. + * + * Mapbox GL has no protocol hook, so a Mapbox basemap stays online-only in + * the app. + */ + +export const OFFLINE_SCHEME = 'trek-offline' +const PREFIX = `${OFFLINE_SCHEME}://` + +export function offlineTilesActive(): boolean { + return isNativeIos() && typeof caches !== 'undefined' +} + +export function toOfflineUrl(url: string): string { + return url.startsWith('https://') ? PREFIX + url.slice('https://'.length) : url +} + +export function fromOfflineUrl(url: string): string { + return url.startsWith(PREFIX) ? 'https://' + url.slice(PREFIX.length) : url +} + +/** The style URL to hand MapLibre: rewritten inside the iOS app, untouched anywhere else. */ +export function offlineStyle(style: T): T { + return offlineTilesActive() && typeof style === 'string' ? (toOfflineUrl(style) as T) : style +} + +type Doc = Record + +const isDoc = (value: unknown): value is Doc => typeof value === 'object' && value !== null && !Array.isArray(value) +const rewriteList = (value: unknown): unknown => (Array.isArray(value) ? value.map((v) => (typeof v === 'string' ? toOfflineUrl(v) : v)) : value) + +/** + * Points the URLs inside a style document or a TileJSON at the protocol, so + * that MapLibre asks this handler for the sprite, the glyphs, the TileJSON and + * the tiles as well, not only for the style. + */ +export function rewriteDocument(doc: unknown): unknown { + if (!isDoc(doc)) return doc + const out: Doc = { ...doc } + if ('tiles' in out) out.tiles = rewriteList(out.tiles) + if (typeof out.glyphs === 'string') out.glyphs = toOfflineUrl(out.glyphs) + if (typeof out.sprite === 'string') out.sprite = toOfflineUrl(out.sprite) + else if (Array.isArray(out.sprite)) { + out.sprite = out.sprite.map((entry) => (isDoc(entry) && typeof entry.url === 'string' ? { ...entry, url: toOfflineUrl(entry.url) } : entry)) + } + if (isDoc(out.sources)) { + out.sources = Object.fromEntries(Object.entries(out.sources).map(([id, source]) => { + if (!isDoc(source)) return [id, source] + const rewritten: Doc = { ...source } + if (typeof rewritten.url === 'string') rewritten.url = toOfflineUrl(rewritten.url) + if ('tiles' in rewritten) rewritten.tiles = rewriteList(rewritten.tiles) + return [id, rewritten] + })) + } + return out +} + +/** + * Documents are asked of the network first so a changed style shows up, and + * fall back to the cache offline. Tiles, glyphs and sprites never change under + * their URL, so the cache goes first and saves the round trip. + */ +async function load(url: string, networkFirst: boolean, signal: AbortSignal): Promise { + const cache = await caches.open(VECTOR_CACHE) + if (!networkFirst) { + const cached = await cache.match(url) + if (cached) return cached + } + try { + const response = await fetch(url, { mode: 'cors', signal }) + if (response.ok) return response + if (!networkFirst) return response + } catch (err) { + if (signal.aborted || !networkFirst) throw err + } + const cached = await cache.match(url) + if (cached) return cached + throw new Error(`Not available offline: ${url}`) +} + +export const offlineProtocol: AddProtocolAction = async (params: RequestParameters, abortController: AbortController): Promise> => { + const url = fromOfflineUrl(params.url) + const response = await load(url, params.type === 'json', abortController.signal) + if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`) + if (params.type === 'json') return { data: rewriteDocument(await response.json()) } + if (params.type === 'string') return { data: await response.text() } + return { data: await response.arrayBuffer() } +} + +export function installOfflineProtocol(maplibre: { addProtocol: (name: string, action: AddProtocolAction) => void }): void { + if (offlineTilesActive()) maplibre.addProtocol(OFFLINE_SCHEME, offlineProtocol) +} diff --git a/client/src/sync/glPrefetcher.ts b/client/src/sync/glPrefetcher.ts index c3e574b99..063bfd9a6 100644 --- a/client/src/sync/glPrefetcher.ts +++ b/client/src/sync/glPrefetcher.ts @@ -35,7 +35,7 @@ const CONCURRENCY = 6 const MAX_VECTOR_TILES = 2000 /** Workbox runtime cache for the vector basemap (see vite.config.js). */ -const VECTOR_CACHE = 'gl-map-offline' +export const VECTOR_CACHE = 'gl-map-offline' /** * Latin glyph ranges. A label needs the range its codepoints fall in, and Latin