feat(native): an iOS map tile store, so viewed maps keep working offline

WKWebView runs no service worker for a self-hosted server, so on iOS nothing
kept a map tile once it scrolled away. Binaries with the tileCache feature
answer a trek-tile:// scheme from a store on disk: a tile viewed once is
served from there for up to 30 days (Mapbox's limit for a device cache,
applied to every provider), then fetched anew; the store keeps under 250 MB,
dropping the least recently used tiles first, sends a unique app User-Agent,
and never prefetches. Mapbox's own resources (style, tiles, glyphs, sprites)
and Leaflet's raster tiles go through it, telemetry never does.

Offline, a Mapbox map in such an app now stays Mapbox where it was viewed,
and the pill offers the stored trip region instead. The offline settings
show what the store takes against its limit, with a way to empty it.
This commit is contained in:
Maurice
2026-11-06 13:17:25 +01:00
parent d859e23879
commit dec6babe1d
69 changed files with 652 additions and 15 deletions
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { hasNativeFeature } from './capabilities';
import { isNativeIos } from './platform';
import { mapboxTileCacheRequest, nativeRasterTemplate, tileCacheActive, toTileCacheUrl } from './tileCache';
vi.mock('./platform', () => ({ isNativeIos: vi.fn() }));
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() }));
beforeEach(() => {
vi.mocked(isNativeIos).mockReturnValue(true);
vi.mocked(hasNativeFeature).mockReturnValue(true);
});
describe('tileCache', () => {
it('is on only in an iOS binary that has the store', () => {
expect(tileCacheActive()).toBe(true);
vi.mocked(hasNativeFeature).mockReturnValue(false);
expect(tileCacheActive()).toBe(false);
vi.mocked(hasNativeFeature).mockReturnValue(true);
vi.mocked(isNativeIos).mockReturnValue(false);
expect(tileCacheActive()).toBe(false);
});
it('swaps only the scheme, so the store knows where the tile lives', () => {
expect(toTileCacheUrl('https://a.tile.example.com/1/2/3.png?k=v')).toBe(
'trek-tile://a.tile.example.com/1/2/3.png?k=v'
);
expect(toTileCacheUrl('http://insecure.example.com/x')).toBe('http://insecure.example.com/x');
});
it('routes Leaflet raster templates through the store only where there is one', () => {
expect(nativeRasterTemplate('https://{s}.tiles.example.com/{z}/{x}/{y}.png')).toBe(
'trek-tile://{s}.tiles.example.com/{z}/{x}/{y}.png'
);
vi.mocked(hasNativeFeature).mockReturnValue(false);
expect(nativeRasterTemplate('https://{s}.tiles.example.com/{z}/{x}/{y}.png')).toBe(
'https://{s}.tiles.example.com/{z}/{x}/{y}.png'
);
});
it('sends what a Mapbox map draws through the store, never its telemetry', () => {
expect(
mapboxTileCacheRequest('https://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=pk', 'Style').url
).toBe('trek-tile://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=pk');
expect(mapboxTileCacheRequest('https://a.tiles.mapbox.com/v4/x/1/2/3.vector.pbf', 'Tile').url).toMatch(
/^trek-tile:/
);
expect(mapboxTileCacheRequest('https://events.mapbox.com/events/v2', 'Unknown').url).toBe(
'https://events.mapbox.com/events/v2'
);
expect(mapboxTileCacheRequest('https://api.mapbox.com/map-sessions/v1', 'Unknown').url).toBe(
'https://api.mapbox.com/map-sessions/v1'
);
expect(mapboxTileCacheRequest('https://tiles.openfreemap.org/planet/1/2/3.pbf', 'Tile').url).toBe(
'https://tiles.openfreemap.org/planet/1/2/3.pbf'
);
});
});