diff --git a/api/web/src/base/overlay-class.ts b/api/web/src/base/overlay-class.ts index c8d517cbe..9c413b36e 100644 --- a/api/web/src/base/overlay-class.ts +++ b/api/web/src/base/overlay-class.ts @@ -10,12 +10,34 @@ import { bbox } from '@turf/bbox' import type { LngLatBoundsLike, LayerSpecification, VectorTileSource, RasterTileSource, GeoJSONSource } from 'maplibre-gl' import cotStyles from './utils/styles.ts' import { std, stdurl } from '../std.js'; +import KV from './kv.ts'; import { db, type DBOverlay } from '../database.ts'; import { useMapStore } from '../stores/map.js'; import ProfileConfig from './profile.ts'; import Subscription from './subscription.ts'; import { FeatureVisibility } from '../stores/modules/feature-visibility.ts'; +/** + * Remove the auth token query parameter from TileJSON tile URLs so a cached + * document never embeds a session token. String surgery instead of `new URL()` + * because the URLs contain literal `{z}/{x}/{y}` template placeholders. + */ +function stripTilesToken(tiles: string[]): string[] { + return tiles.map((t) => t + .replace(/([?&])token=[^&]*&/, '$1') + .replace(/[?&]token=[^&]*$/, '')); +} + +/** + * Append the current session token to cached TileJSON tile URLs, mirroring + * what the server does when the /tiles endpoint is called with ?token=. + */ +function applyTilesToken(tiles: string[], token: string | null): string[] { + if (!token) return tiles; + + return tiles.map((t) => `${t}${t.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`); +} + /** * @class */ @@ -242,6 +264,10 @@ export default class Overlay { } for (const l of this.styles) { + // A previous partially-failed initOverlays() attempt may have + // already registered this layer; addLayer throws on duplicate ids. + if (mapStore.map.getLayer(l.id)) continue; + if (before) { mapStore.map.addLayer(l, before); } else { @@ -337,13 +363,45 @@ export default class Overlay { const url = stdurl(this.url); if (token) url.searchParams.set('token', token); + // The TileJSON is derived server-side (potentially from upstream + // ESRI/TileJSON metadata) so it can't be constructed locally, but + // it is stable per source URL — cache it in KV so warm boots and + // offline resumes skip the network entirely. + const cacheKey = `tilejson::${this.id}::${this.url}`; + + let tileJSON: TileJSON | undefined; + try { + const cached = await KV.value(cacheKey); + if (cached) { + tileJSON = JSON.parse(cached) as TileJSON; + tileJSON.tiles = applyTilesToken(tileJSON.tiles, token); + } + } catch (err) { + console.warn(`Failed to read cached TileJSON for overlay ${this.id}:`, err); + } + // A failed /tiles lookup (network blip, expired token, deleted // basemap upstream, etc.) must NOT abort map initialization or // every other overlay disappears with it. Capture the error on // the overlay so MenuOverlays.vue surfaces an "Issue" badge, // and skip addSource so addLayers later no-ops cleanly. try { - const tileJSON = await std(url.toString()) as TileJSON + if (!tileJSON) { + tileJSON = await std(url.toString()) as TileJSON; + + try { + // Tile URLs carry the session token as a query param; + // strip it before caching (tokens rotate between + // sessions) — applyTilesToken() restores the current + // one on read. + await KV.update(cacheKey, JSON.stringify({ + ...tileJSON, + tiles: stripTilesToken(tileJSON.tiles) + })); + } catch (err) { + console.warn(`Failed to cache TileJSON for overlay ${this.id}:`, err); + } + } if (!mapStore.map.getSource(String(this.id))) { mapStore.map.addSource(String(this.id), { diff --git a/api/web/src/components/CloudTAK/Map.vue b/api/web/src/components/CloudTAK/Map.vue index c4864dace..626d023a8 100644 --- a/api/web/src/components/CloudTAK/Map.vue +++ b/api/web/src/components/CloudTAK/Map.vue @@ -504,6 +504,7 @@ import { useAppStore } from '../../stores/app.ts'; import { DrawToolMode } from '../../stores/modules/draw.ts'; import { useFloatStore } from '../../stores/float.ts'; import { liveQuery } from 'dexie'; +import { isTransientDbError } from '../../database.ts'; import Upload from '../util/Upload.vue'; import { stdurl } from '../../std.ts'; import ProfileConfig from '../../base/profile.ts'; @@ -637,18 +638,29 @@ const mapSideOffset = computed(() => { return Math.max(mapStore.toastOffset.x - 10, 0); }); +function onWindowError(evt: ErrorEvent) { + console.error(evt); + evt.preventDefault(); + + // Prefer the original error so its name survives; transient + // IndexedDB failures (WebKit invalidating the connection during an + // iOS suspend) are recovered by withDbRetry and must not modal. + const err = evt.error instanceof Error ? evt.error : new Error(evt.message); + if (isTransientDbError(err)) return; + + emit('err', err); +} + +function onWindowResize() { + height.value = window.innerHeight; + width.value = window.innerWidth; +} + onMounted(async () => { // ensure uncaught errors in the stack are captured into vue context - window.addEventListener('error', (evt) => { - console.error(evt); - evt.preventDefault(); - emit('err', new Error(evt.message)); - }); + window.addEventListener('error', onWindowError); - window.addEventListener('resize', () => { - height.value = window.innerHeight; - width.value = window.innerWidth; - }); + window.addEventListener('resize', onWindowResize); if (!mapRef.value) throw new Error('Map Element could not be found - Please refresh the page and try again'); await mapStore.init(mapRef.value); @@ -711,6 +723,8 @@ onMounted(async () => { }); onBeforeUnmount(() => { + window.removeEventListener('error', onWindowError); + window.removeEventListener('resize', onWindowResize); inviteChannel?.close(); void mapStore.destroy(); }); diff --git a/api/web/src/database.ts b/api/web/src/database.ts index 172e63eec..25e9257a1 100644 --- a/api/web/src/database.ts +++ b/api/web/src/database.ts @@ -329,6 +329,11 @@ db.version(2).stores({ let reopenPromise: Promise | null = null; +// Increments on every successful (re)open so callers can tell whether the +// connection they observed failing has already been replaced. +let connectionGeneration = 0; +let forceReopenPromise: Promise | null = null; + // An IndexedDB open issued while a page is unloading leaves WKWebView's // database process holding an orphaned request that deadlocks the next // page's first IndexedDB operation until the app is fully restarted. Close @@ -366,6 +371,7 @@ export async function ensureDatabase(): Promise { try { await db.open(); + connectionGeneration++; return; } catch (err) { if (db.isOpen()) return; @@ -383,6 +389,41 @@ export async function ensureDatabase(): Promise { return reopenPromise; } +/** + * Close and reopen the IndexedDB connection. + * + * WebKit on iOS can invalidate the underlying connection while the app is + * suspended without firing a close event, leaving Dexie reporting an open — + * but dead — connection whose every request fails with UnknownError + * ("attempt to get records from the database without an in-progress + * transaction"). ensureDatabase() is a no-op in that state, so recovery + * requires an explicit close first. + * + * Pass the generation observed before the failing operation so a connection + * another caller already replaced is not needlessly closed again. + */ +export async function forceReopenDatabase(sinceGeneration = connectionGeneration): Promise { + if (shuttingDown) return; + + if (!forceReopenPromise) { + if (sinceGeneration !== connectionGeneration) return ensureDatabase(); + + forceReopenPromise = (async () => { + try { + db.close(); + } catch (err) { + console.warn('Failed to close database before reopen', err); + } + + await ensureDatabase(); + })().finally(() => { + forceReopenPromise = null; + }); + } + + return forceReopenPromise; +} + const TRANSIENT_DB_ERROR_NAMES = new Set([ 'AbortError', 'DatabaseClosedError', @@ -399,7 +440,11 @@ const TRANSIENT_DB_ERROR_MESSAGES = [ 'transaction aborted', 'objectstore', 'connection is closing', - 'premature commit' + 'premature commit', + // WebKit UnknownError after iOS suspends the app mid-transaction; matched + // on message because wrappers (window.onerror, String(reason)) lose the + // DOMException name. + 'in-progress transaction' ]; db.on('close', () => { @@ -418,14 +463,26 @@ export async function withDbRetry(fn: () => Promise, attempts = 4): Promis let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt++) { + let generation = connectionGeneration; + try { await ensureDatabase(); + generation = connectionGeneration; return await fn(); } catch (err) { lastError = err; if (!isTransientDbError(err)) throw err; - await ensureDatabase(); + // Dexie can still report the connection open after WebKit has + // invalidated it (iOS suspend fires no close event), in which + // case ensureDatabase() alone is a no-op and every retry would + // fail identically — force a real close+reopen. + try { + await forceReopenDatabase(generation); + } catch (reopenErr) { + console.warn('Failed to reopen database during retry', reopenErr); + } + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } } diff --git a/api/web/src/stores/map.ts b/api/web/src/stores/map.ts index bfa5c5fd7..0153ddadc 100644 --- a/api/web/src/stores/map.ts +++ b/api/web/src/stores/map.ts @@ -24,6 +24,7 @@ import COT from '../base/cot.ts'; import GeolocateControl from '../lib/geolocate/main.ts'; import RoutingControl from '../lib/routing/main.ts'; import type { NavigationState, NavigationDirection } from '../lib/routing/main.ts'; +import KV from '../base/kv.ts'; import { syncPushToken } from '../base/push.ts'; import { WorkerMessageType, LocationState } from '../base/events.ts'; import type { WorkerMessage } from '../base/events.ts'; @@ -39,7 +40,7 @@ import { CloudTAKTransferHandler } from '../base/handler.ts'; import ProfileConfig from '../base/profile.ts'; import Config from '../base/config.ts'; import { isNativePlatform, addBackgroundStateListener } from '../base/capacitor.ts'; -import { ensureDatabase } from '../database.ts'; +import { db, withDbRetry } from '../database.ts'; import type { ProfileOverlay, Basemap, Feature } from '../types.ts'; import type { LngLat, LngLatLike, Point, MapMouseEvent, MapTouchEvent, MapGeoJSONFeature, GeoJSONSource } from 'maplibre-gl'; @@ -83,7 +84,6 @@ export const useMapStore = defineStore('cloudtak', { _bottomBar?: unknown; _removeOrientationListener?: () => Promise; - _boundOnVisibilityChange?: () => Promise; _removeBackgroundStateListener?: () => void; _removePushTokenListener?: () => void; @@ -422,7 +422,6 @@ export const useMapStore = defineStore('cloudtak', { await this._removeOrientationListener(); this._removeOrientationListener = undefined; } - if (this._boundOnVisibilityChange) document.removeEventListener('visibilitychange', this._boundOnVisibilityChange); if (this._removeBackgroundStateListener) { this._removeBackgroundStateListener(); this._removeBackgroundStateListener = undefined; @@ -680,18 +679,35 @@ export const useMapStore = defineStore('cloudtak', { const { value: token } = await Preferences.get({ key: 'token' }); - let sub: Subscription | null = null; - if (!opts?.reload) { - sub = (await Subscription.from(guid, token || '', { subscribed: true })) || null; + // Resolve the locally persisted mission first and render its + // features immediately — the map should never sit empty waiting + // on the network refresh below. + const local = (await Subscription.from(guid, token || '', { subscribed: true })) || null; + + if (local) { + // @ts-expect-error Source.setData is not defined + oStore.setData(await local.feature.collection(false)); } + let sub: Subscription | null = local && !opts?.reload ? local : null; + if (!sub) { - sub = await Subscription.load(guid, { - token: token || '', - reload: opts?.reload || false, - subscribed: true, - missiontoken: overlay.token || undefined - }); + try { + sub = await Subscription.load(guid, { + token: token || '', + reload: opts?.reload || false, + subscribed: true, + missiontoken: overlay.token || undefined + }); + } catch (err) { + // Offline/low-bandwidth: keep the locally persisted + // features rendered above. The next full sync or manual + // reload refreshes from the server. + if (!local) throw err; + + console.warn(`Mission:${guid} network refresh failed, using local data:`, err); + sub = local; + } } // @ts-expect-error Source.setData is not defined @@ -703,6 +719,37 @@ export const useMapStore = defineStore('cloudtak', { return sub; }, + + /** + * Recover shared state after the app returns to the foreground. iOS + * can invalidate IndexedDB connections (without firing a close event) + * and drop the WebSocket while the WebView is suspended, so probe the + * main-thread and worker database connections — withDbRetry force- + * reopens a dead one — then reconnect the WebSocket and re-render CoTs. + */ + resumeFromBackground: async function(): Promise { + if (!this._worker || !(await this.worker.initialized)) return; + + try { + await withDbRetry(() => db.kv.get('serverUrl')); + } catch (err) { + console.error('Failed to reopen IndexedDB on resume:', err); + } + + try { + await this.worker.resume(); + } catch (err) { + console.error('Failed to recover worker database on resume:', err); + } + + const isOpen = await this.worker.conn.isOpen; + if (!isOpen) { + console.log('Resumed with closed connection, reconnecting...'); + await this.worker.conn.reconnect(await this.worker.username); + } + + await this.updateCOT(); + }, init: async function(container: HTMLElement) { // Start the worker here rather than in state() so that std.ts // inside the worker resolves serverUrl from KV only after @@ -714,27 +761,6 @@ export const useMapStore = defineStore('cloudtak', { this.container = container; - this._boundOnVisibilityChange = async (): Promise => { - if (document.hidden) return; - if (!(await this.worker.initialized)) return; - - // Proactively reopen the main-thread IndexedDB connection. - // WebKit may have force-closed it while the app was backgrounded. - try { - await ensureDatabase(); - } catch (err) { - console.error('Failed to reopen IndexedDB on resume:', err); - } - - const isOpen = await this.worker.conn.isOpen; - if (!isOpen) { - console.log('Tab became visible with closed connection, reconnecting...'); - await this.worker.conn.reconnect(await this.worker.username); - } - - await this.updateCOT(); - }; - this._removeOrientationListener = await deviceStore.orientation.addListener((heading) => { // Drive the self-location puck's heading cone regardless of // whether the map itself is being rotated to match. @@ -747,14 +773,21 @@ export const useMapStore = defineStore('cloudtak', { this.map.setBearing(heading); } }); - document.addEventListener('visibilitychange', this._boundOnVisibilityChange); // Track foreground/background transitions using a native-reliable - // signal so background location reporting (submitLocationHttp) is - // gated correctly on iOS, where document.hidden is unreliable. + // signal (Capacitor appStateChange; document.hidden is unreliable + // on iOS). Background location reporting (submitLocationHttp) is + // gated on it, and returning to the foreground must recover the + // IndexedDB connections and WebSocket iOS severed during suspend. this.isBackgrounded = false; this._removeBackgroundStateListener = await addBackgroundStateListener((isBackgrounded) => { this.isBackgrounded = isBackgrounded; + + if (!isBackgrounded) { + this.resumeFromBackground().catch((err) => { + console.error('Failed to recover after returning to foreground:', err); + }); + } }); const { value: token } = await Preferences.get({ key: 'token' }); @@ -969,24 +1002,8 @@ export const useMapStore = defineStore('cloudtak', { map.addControl(routingControl); (map as mapgl.Map & { _routingControl?: RoutingControl })._routingControl = routingControl; - map.once('idle', async () => { - const displayProjection = await ProfileConfig.get('display_projection'); - - if (displayProjection && displayProjection.value === 'globe') { - map.setProjection({ type: "globe" }); - } - - void this.icons.hydrate() - .catch((error: unknown) => { - console.error('Failed to hydrate iconsets after map idle', error); - }); - - await this.initOverlays(); - - this.timer = setInterval(async () => { - if (!this.map) return; - await this.refresh(); - }, 500); + map.once('idle', () => { + void this.onMapReady(); }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -1070,8 +1087,51 @@ export const useMapStore = defineStore('cloudtak', { console.warn('Failed to submit background location via HTTP', err); } }, - initOverlays: async function() { - if (!this.map) throw new Error('Cannot initLayers before map has loaded'); + /** + * Runs once the initial style settles. isLoaded — which dismisses the + * map loading screen — is only set at the end of initOverlays(), so a + * single failure there (a timed-out request or a dead IndexedDB + * connection on a low-bandwidth native resume) must not strand the + * user on the loading screen: overlay loading is retried with capped + * backoff until it succeeds or the map is torn down. + */ + onMapReady: async function(): Promise { + try { + const displayProjection = await ProfileConfig.get('display_projection'); + + if (displayProjection && displayProjection.value === 'globe') { + this.map.setProjection({ type: "globe" }); + } + } catch (err) { + console.error('Failed to apply saved display projection', err); + } + + void this.icons.hydrate() + .catch((error: unknown) => { + console.error('Failed to hydrate iconsets after map idle', error); + }); + + this.registerMapListeners(); + + for (let attempt = 0; this._map && !this.isLoaded; attempt++) { + try { + await this.initOverlays(); + } catch (err) { + const delay = Math.min(2000 * 2 ** attempt, 30000); + console.error(`Failed to load overlays (attempt ${attempt + 1}), retrying in ${delay}ms:`, err); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + if (!this._map) return; + + this.timer = setInterval(async () => { + if (!this.map) return; + await this.refresh(); + }, 500); + }, + registerMapListeners: function() { + if (!this.map) throw new Error('Cannot registerMapListeners before map has loaded'); const map: mapgl.Map = this.map as mapgl.Map; @@ -1301,6 +1361,19 @@ export const useMapStore = defineStore('cloudtak', { } }); + }, + /** + * Load every overlay (basemaps, profile overlays, missions, the + * internal Map Features store) into the map and mark it isLoaded. + * Must be re-runnable: onMapReady() retries it after a failure, and + * source/layer registration is guarded against duplicates from a + * previous partial attempt. + */ + initOverlays: async function() { + if (!this.map) throw new Error('Cannot initOverlays before map has loaded'); + + const map: mapgl.Map = this.map as mapgl.Map; + OverlayManager.clearLoaded(); const profileOverlays = await OverlayManager.list({ localFirst: true }); @@ -1517,12 +1590,25 @@ export const useMapStore = defineStore('cloudtak', { }, updateAttribution: async function(): Promise { const attributionPromises = OverlayManager.visibleBasemaps().map(async (overlay) => { + // Attribution is static per basemap: serve it from the KV + // cache so warm boots and offline resumes never wait on + // the (un-timed) basemap endpoint. An empty string is + // cached too, marking a basemap known to have none. + const cacheKey = `attribution::${overlay.mode_id}`; + + const cached = await KV.value(cacheKey).catch(() => undefined); + if (cached !== undefined) return cached || null; + try { const basemapRes = await server.GET('/api/basemap/{:basemapid}', { params: { path: { ':basemapid': Number(overlay.mode_id) } } }); if (basemapRes.error) return null; - return (basemapRes.data as Basemap).attribution; + + const attribution = (basemapRes.data as Basemap).attribution || ''; + await KV.update(cacheKey, attribution).catch(() => undefined); + + return attribution || null; } catch (err) { console.warn('Failed to load basemap attribution:', err); return null; diff --git a/api/web/src/workers/atlas-database.ts b/api/web/src/workers/atlas-database.ts index e040e84ee..f834a73e2 100644 --- a/api/web/src/workers/atlas-database.ts +++ b/api/web/src/workers/atlas-database.ts @@ -100,14 +100,53 @@ export default class AtlasDatabase { async init(): Promise { COT.selfUid = this.atlas.profile.uid(); + + // Boot from the locally persisted features only — no network. Server + // reconciliation happens after map load via AtlasSync's full sync, + // which calls loadArchive(). try { - await this.loadArchive(); + await this.loadLocalFeatures(); } catch (err) { - console.error('Failed to load archived features:', err); + console.error('Failed to load locally persisted features:', err); } + await this.breadcrumb.load(); } + /** + * Hydrate the in-memory CoT store from the locally persisted feature + * table (which only ever holds non-mission features) so that returning + * to the foreground doesn't visually drop features until they re-arrive + * over the WebSocket — and archived features render without network + * connectivity at all. add() skips already-stale non-archived CoTs per + * the display_stale setting, so old live features from a previous + * session are not resurrected. Additions, updates and pruning of + * remotely deleted archived features are reconciled later by + * loadArchive() via AtlasSync. + */ + async loadLocalFeatures(): Promise { + const local = await withDbRetry(() => db.feature.toArray()); + + for (const feat of local) { + if (this.cots.has(feat.id)) continue; + + await this.add({ + id: feat.id, + type: 'Feature', + path: feat.path, + properties: feat.properties, + geometry: feat.geometry + } as InputFeature, { + skipSave: true, + skipBroadcast: true + }); + } + + this.atlas.postMessage({ + type: WorkerMessageType.Feature_Archived_Added, + }); + } + /** * Return a Set of coordinates within the given Map bounds * so that vertex snapping can take place when editing diff --git a/api/web/src/workers/atlas.ts b/api/web/src/workers/atlas.ts index 1b9fdaab5..b7f2e38bf 100644 --- a/api/web/src/workers/atlas.ts +++ b/api/web/src/workers/atlas.ts @@ -11,7 +11,7 @@ import AtlasDatabase from './atlas-database.ts'; import AtlasConnection from './atlas-connection.ts'; import AtlasSync from './atlas-sync.ts'; import { CloudTAKTransferHandler } from '../base/handler.ts'; -import { db } from '../database.ts'; +import { db, withDbRetry } from '../database.ts'; import Icon from '../base/icon.ts'; export default class Atlas { @@ -70,7 +70,7 @@ export default class Atlas { this.token = authToken; try { - await db.config.put({ key: 'token', value: authToken }); + await withDbRetry(() => db.config.put({ key: 'token', value: authToken })); this.username = await this.profile.init(); @@ -96,6 +96,18 @@ export default class Atlas { } } + /** + * Called by the UI thread when the app returns to the foreground. Probes + * this worker's own IndexedDB connection — iOS can invalidate it during a + * suspend without firing a close event — so withDbRetry force-reopens a + * dead connection before queued work starts failing on it. + */ + async resume(): Promise { + if (!this.initialized) return; + + await withDbRetry(() => db.kv.get('serverUrl')); + } + destroy() { this.conn.destroy(); this.profile.destroy();