mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { isNativeIos } from './platform';
|
||||
|
||||
/**
|
||||
* The iOS app's own tile store (TrekTileSchemeHandler.swift), reached through
|
||||
* the trek-tile:// scheme.
|
||||
*
|
||||
* WKWebView runs no service worker for a self-hosted server, so on iOS nothing
|
||||
* kept the map tiles a user had looked at. Binaries that report the tileCache
|
||||
* feature serve every trek-tile:// request from a store on disk, filled as the
|
||||
* tiles are viewed and kept for at most 30 days (Mapbox's limit for a device
|
||||
* cache, applied to every provider alike), and from the network otherwise. The
|
||||
* URL keeps its host and path, only the scheme changes, so the handler knows
|
||||
* where the tile really lives.
|
||||
*
|
||||
* Nothing is prefetched through here: Mapbox forbids bulk downloads, and the
|
||||
* OpenFreeMap region a trip stores goes through Cache Storage (offlineTiles.ts).
|
||||
*/
|
||||
export const TILE_CACHE_SCHEME = 'trek-tile';
|
||||
|
||||
export function tileCacheActive(): boolean {
|
||||
return isNativeIos() && hasNativeFeature('tileCache');
|
||||
}
|
||||
|
||||
/** https://host/path?query -> trek-tile://host/path?query. Anything else stays. */
|
||||
export function toTileCacheUrl(url: string): string {
|
||||
return url.startsWith('https://') ? `${TILE_CACHE_SCHEME}://${url.slice('https://'.length)}` : url;
|
||||
}
|
||||
|
||||
/** A raster tile template for Leaflet, through the store where the app has one. */
|
||||
export function nativeRasterTemplate(template: string): string {
|
||||
return tileCacheActive() ? toTileCacheUrl(template) : template;
|
||||
}
|
||||
|
||||
const MAPBOX_HOST = /^https:\/\/(?:api\.mapbox\.com|[a-z0-9-]+\.tiles\.mapbox\.com)\//i;
|
||||
/** What a map draws; telemetry and anything else keeps going straight out. */
|
||||
const CACHED_RESOURCES = new Set([
|
||||
'Style',
|
||||
'Source',
|
||||
'Tile',
|
||||
'Glyphs',
|
||||
'SpriteImage',
|
||||
'SpriteJSON',
|
||||
'Image',
|
||||
'Iconset',
|
||||
'Model',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Mapbox GL's transformRequest for the iOS app: the map's own resources from
|
||||
* Mapbox's servers go through the store, events and sessions never do.
|
||||
*/
|
||||
export function mapboxTileCacheRequest(url: string, resourceType?: string): { url: string } {
|
||||
if (!MAPBOX_HOST.test(url)) return { url };
|
||||
if (resourceType && !CACHED_RESOURCES.has(resourceType)) return { url };
|
||||
return { url: toTileCacheUrl(url) };
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export interface TrekShellPlugin {
|
||||
openOfflineCopy(): Promise<{ opened: boolean }>;
|
||||
/** What this binary offers the UI (native/capabilities.ts). Missing before shell API 2. */
|
||||
getCapabilities(): Promise<NativeCapabilities>;
|
||||
/** iOS, feature tileCache: what the map tile store takes on disk, and its limit. */
|
||||
getTileCacheUsage(): Promise<{ bytes: number; limit: number }>;
|
||||
/** iOS, feature tileCache: empties the map tile store. */
|
||||
clearTileCache(): Promise<void>;
|
||||
}
|
||||
|
||||
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell');
|
||||
|
||||
Reference in New Issue
Block a user