Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/dashboard/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { registerMcpIpc } from "./mcp-ipc";
import { mcpManager } from "./mcp/manager";
import { registerRegionPacksIpc } from "./region-packs";
import {
prewarmWorldArchive,
registerAssetProtocol,
registerLocalSchemes,
registerRegionProtocol
Expand Down Expand Up @@ -184,6 +185,7 @@ app.whenReady().then(() => {
// global glyphs/sprites + world basemap bundled with the app.
registerRegionProtocol(join(appStateDir, "regions"), worldPmtilesPath());
registerAssetProtocol(basemapAssetsDir());
void prewarmWorldArchive(worldPmtilesPath());
// Reads `vaultRoot` per-request so vault switches/renames need no re-registration.
registerVaultProtocol(() => vaultRoot);
registerWikiIpc(() => vaultRoot);
Expand Down
84 changes: 77 additions & 7 deletions apps/dashboard/src/main/region-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ const R2_BASE = (
// Completeness marker written last after a successful download, so an interrupted
// download (whose .part files are cleaned up) never leaves a dir that looks installed.
const PACK_META_FILENAME = ".pack.json";
const MANIFEST_CACHE_FILENAME = "manifest-cache.json";
const MANIFEST_TTL_MS = 60_000;
const PROGRESS_THROTTLE_MS = 200;

let manifestCache: { at: number; data: RegionManifest } | null = null;
/** Set by {@link registerRegionPacksIpc} — disk cache lives in userData. */
let manifestAppStateDir: string | null = null;
let manifestRefresh: Promise<RegionManifest> | null = null;

/** Active downloads keyed by region slug, so cancelDownload can abort them. */
const activeDownloads = new Map<string, AbortController>();
Expand Down Expand Up @@ -68,24 +72,86 @@ function broadcastChanged(): void {
sendToRenderer("regions:changed");
}

/**
* Fetch the region catalog from R2. Cached briefly so opening the Offline tab and
* re-rendering doesn't re-hit the network on every keystroke. `force` bypasses it.
*/
export async function fetchManifest(force = false): Promise<RegionManifest> {
if (!force && manifestCache && Date.now() - manifestCache.at < MANIFEST_TTL_MS) {
return manifestCache.data;
function broadcastManifestUpdated(data: RegionManifest): void {
sendToRenderer("regions:manifest-updated", data);
}

function manifestDiskPath(): string {
if (!manifestAppStateDir) throw new Error("Region manifest disk cache is not initialized.");
return join(manifestAppStateDir, MANIFEST_CACHE_FILENAME);
}

function readManifestFromDisk(): RegionManifest | null {
if (!manifestAppStateDir) return null;
try {
const data = JSON.parse(readFileSync(manifestDiskPath(), "utf-8")) as RegionManifest;
if (!data || typeof data !== "object" || typeof data.regions !== "object") return null;
return data;
} catch {
return null;
}
}

function writeManifestToDisk(data: RegionManifest): void {
if (!manifestAppStateDir) return;
writeFileSync(manifestDiskPath(), JSON.stringify(data));
}

function seedManifestCacheFromDisk(): void {
const disk = readManifestFromDisk();
if (disk) manifestCache = { at: 0, data: disk };
}

async function fetchManifestFromNetwork(): Promise<RegionManifest> {
const res = await fetch(`${R2_BASE}/manifest.json`, { cache: "no-store" });
if (!res.ok) throw new Error(`Failed to fetch region manifest: HTTP ${res.status}`);
const data = (await res.json()) as RegionManifest;
if (!data || typeof data !== "object" || typeof data.regions !== "object") {
throw new Error("Region manifest is malformed.");
}
manifestCache = { at: Date.now(), data };
writeManifestToDisk(data);
broadcastManifestUpdated(data);
return data;
}

function refreshManifestInBackground(): void {
if (manifestRefresh) return;
manifestRefresh = fetchManifestFromNetwork().finally(() => {
manifestRefresh = null;
});
void manifestRefresh.catch(() => {});
}

/**
* Fetch the region catalog from R2. Cached briefly in memory and persistently on
* disk so cold starts (after the in-memory TTL expires) can still resolve region
* bboxes for the coverage pill without waiting on the network. `force` bypasses
* the stale-while-revalidate path and always hits the network.
*/
export async function fetchManifest(force = false): Promise<RegionManifest> {
if (!force && manifestCache && Date.now() - manifestCache.at < MANIFEST_TTL_MS) {
return manifestCache.data;
}

if (!force) {
const stale = manifestCache?.data ?? readManifestFromDisk();
if (stale) {
if (!manifestCache) manifestCache = { at: 0, data: stale };
refreshManifestInBackground();
return stale;
}
}

if (manifestRefresh && !force) return manifestRefresh;
const pending = fetchManifestFromNetwork();
if (!force)
manifestRefresh = pending.finally(() => {
manifestRefresh = null;
});
return pending;
}

/** Region packs present on disk, read from their `.pack.json` sidecars. */
export function listLocal(regionsDir: string): InstalledRegionPack[] {
if (!existsSync(regionsDir)) return [];
Expand Down Expand Up @@ -287,6 +353,10 @@ export function deleteRegion(appStateDir: string, region: string): void {
* for the lifetime of the window, alongside the other service IPCs.
*/
export function registerRegionPacksIpc(appStateDir: string): void {
manifestAppStateDir = appStateDir;
seedManifestCacheFromDisk();
refreshManifestInBackground();

ipcMain.handle("regions:get-manifest", (_e, force?: boolean) => fetchManifest(!!force));
ipcMain.handle("regions:list-local", () => listLocal(regionsDirFor(appStateDir)));
ipcMain.handle(
Expand Down
28 changes: 27 additions & 1 deletion apps/dashboard/src/main/region-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ type StyleLayer = {
};

function styleResponse(regionsDir: string, theme: "light" | "dark", monochrome: boolean): Response {
const cacheKey = styleCacheKey(regionsDir, theme, monochrome);
if (styleCache?.key === cacheKey) {
return new Response(styleCache.body, {
headers: { "content-type": "application/json", ...CORS }
});
}

// Theme × "Map color" → Protomaps flavor. The monochrome flavors ("black" /
// "white") define no POI colors or icons, so graft the tinted flavor's POI
// palette onto them (and reuse the tinted sprite sheet, a superset of icons).
Expand Down Expand Up @@ -172,7 +179,9 @@ function styleResponse(regionsDir: string, theme: "light" | "dark", monochrome:
sources,
layers: [...worldLayers, ...regionLayers]
};
return new Response(JSON.stringify(style), {
const body = JSON.stringify(style);
styleCache = { key: cacheKey, body };
return new Response(body, {
headers: { "content-type": "application/json", ...CORS }
});
}
Expand Down Expand Up @@ -215,6 +224,22 @@ function archive(path: string): PMTiles {
return a.pmtiles;
}

/** Open the bundled world archive and read its header so the first map tile isn't cold. */
export async function prewarmWorldArchive(worldPmtilesPath: string): Promise<void> {
await archive(worldPmtilesPath).getHeader();
}

let styleCache: { key: string; body: string } | null = null;

function styleCacheKey(regionsDir: string, theme: "light" | "dark", monochrome: boolean): string {
const slugs = listInstalledRegions(regionsDir)
.filter((r) => r.pmtiles)
.map((r) => r.region)
.sort()
.join(",");
return `${theme}:${monochrome ? "1" : "0"}:${slugs}`;
}

/**
* Close every cached pmtiles file descriptor and drop the archive cache. Call
* after a pack is deleted or re-downloaded — otherwise the cached archive keeps
Expand All @@ -224,6 +249,7 @@ function archive(path: string): PMTiles {
export function closeRegionArchives(): void {
for (const { source } of archives.values()) source.close();
archives.clear();
styleCache = null;
}

async function pmtilesTile(path: string, z: number, x: number, y: number): Promise<Response> {
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ declare global {
delete: (region: string) => Promise<void>;
onProgress: (cb: (data: RegionDownloadProgress) => void) => () => void;
onChanged: (cb: () => void) => () => void;
onManifestUpdated: (cb: (data: RegionManifest) => void) => () => void;
};
};
}
Expand Down
7 changes: 7 additions & 0 deletions apps/dashboard/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,13 @@ const api = {
return () => {
ipcRenderer.off("regions:changed", listener);
};
},
onManifestUpdated: (cb: (data: RegionManifest) => void): (() => void) => {
const listener = (_e: unknown, data: RegionManifest): void => cb(data);
ipcRenderer.on("regions:manifest-updated", listener);
return () => {
ipcRenderer.off("regions:manifest-updated", listener);
};
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,18 @@ export function RegionCoverageIndicator(): React.JSX.Element | null {
useEffect(() => {
const map = mapRef?.getMap();
if (!map) return;
sync(); // seed for the initial viewport

const seedView = (): void => {
const c = map.getCenter();
setView({ lng: c.lng, lat: c.lat, zoom: map.getZoom() });
};

if (map.loaded()) seedView();
else map.once("load", seedView);

map.on("moveend", sync);
return () => {
map.off("load", seedView);
map.off("moveend", sync);
sync.cancel();
};
Expand Down
4 changes: 4 additions & 0 deletions apps/dashboard/src/renderer/src/hooks/use-region-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export function useRegionPacks(enabled: boolean): UseRegionPacks {
// coverage indicator — stays in sync without waiting for a manual refresh.
useEffect(() => window.api.regions.onChanged(() => void refreshLocal()), [refreshLocal]);

// Background manifest refresh (after a stale disk cache was returned) updates
// the catalog without forcing a full reload.
useEffect(() => window.api.regions.onManifestUpdated((data) => setManifest(data)), []);

// Stream download progress. "done" clears the row and re-reads local packs;
// a cancellation just clears it; real errors stay so the row can show + offer retry.
useEffect(() => {
Expand Down