Skip to content

Commit 6299590

Browse files
authored
feat(chrome-extension): detect geospatial services (#1967)
* feat(chrome-extension): detect geospatial services * fix(chrome-extension): hand services to add data * fix(chrome-extension): scope detected services to pages * fix(chrome-extension): harden service lifecycle * fix(chrome-extension): detect services in embedded maps * fix(chrome-extension): show services on raw responses * fix(chrome-extension): make detected services addable Service detection never stored anything in a real browser. The page scope seeded a tab's document set from the main_frame request's documentId, but Chrome sets no documentId on a navigation request: the document does not exist yet. The set stayed empty, every later request was rejected, and the earlier page-scoping, embedded-map and raw-response fixes were all inert. Scope by retiring the previous page's documents instead, which needs no id on the navigation itself and so covers raw responses and iframes too. A detected endpoint was also not enough to add a layer. Each result now carries the layer the page asked for, passed as serviceLayer/serviceStyle and prefilled into the matching form field: - WMS LAYERS and WFS typeName, with WFS operation parameters stripped from the endpoint rather than left on it - a WMTS GetTile request rewritten into the tile template that produced it - the ArcGIS layer index, which a bare FeatureServer URL loses: GeoLibre then falls back to the service's first layer and draws the wrong one - the style document a vector tileset needs, since its source layers live in the style and not in the tile URL Results are listed per layer, as one endpoint can serve many. Two false positives are gone as well: a style's glyph ranges are no longer offered as a tile service, and a documentation link whose wording mentions a format is no longer read as a dataset. Verified end to end against live WMS, WMTS, WFS, OGC API Features, ArcGIS, XYZ and vector tile sites: each adds a layer that draws, with no typing. * Address Claude and CodeRabbit review feedback - Draw the page boundary when a navigation starts rather than when it completes. A tile or service request made by the incoming page can finish before that page's own HTML does, and retiring documents at completion swept up the new page's document along with the outgoing one's, rejecting everything it went on to request. - Require an OGC format parameter before treating a bare /collections as an OGC API service. The path is an ordinary REST and storefront route as well, so on its own it put unrelated services in the popup. /collections/<id>/items is specific enough to stand alone and is unchanged. - Tell a selection holding one service and one file apart from a selection holding two services, which shared a message that only described the latter. - Filter the deep-link source kind through masHidesDataSource, as every other path that opens the Add Data dialog from outside the component already does. Inert today, since no service kind is MAS-hidden. - Point the README's CORS note at the service being fetched rather than at a static file, which described the wrong failure mode for a detected service. * Address Claude review feedback - Skip the storage read for a candidate already at the head of a tab's list. Panning a slippy map resolves nearly every tile to the same candidate, and the dedup check only fired after the read it was meant to avoid. - Fill in a vector tileset's style when the style document completes after the tiles it describes. Either request can finish first, and an entry recorded without a style leaves Add Data with no source layers to resolve. - Narrow the page test in the document scanner. Any trailing slash counted as a page, which silently dropped hint-based detection for REST endpoints that conventionally end in one; a directory-style URL now counts as a page only when the link text reads its slug back, which is what the documentation links that motivated the rule look like. - Cover both watcher behaviors with a test that drives background.mjs through a stub of the extension APIs, which nothing exercised before. * Address Claude review feedback - Ignore the WMS and WFS form caches when a deep link supplies the endpoint. A link whose service was detected without a layer (a GetCapabilities hit, say) paired its fresh endpoint with the layer or feature type left over from whichever service the dialog was last used for, which submits a request the new service cannot answer. The WMS style is cleared with the layer for the same reason. - Keep a document marked for retirement when a straggling request from the outgoing page completes mid-navigation. It was moved back among the incoming page's documents and so outlived the navigation it should not have survived.
1 parent d79c87d commit 6299590

24 files changed

Lines changed: 1186 additions & 71 deletions

apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ interface AddDataDialogProps {
4444
* clicked PostGIS table.
4545
*/
4646
initialPostgres?: OpenAddDataPostgres;
47+
/** Service URL supplied by a browser-extension deep link. */
48+
initialUrl?: string;
49+
/**
50+
* The layer that deep link asked for — a WMS `LAYERS` value, a WFS feature
51+
* type, a vector tile source layer. Prefilled so the form the extension opens
52+
* is complete rather than an endpoint the user must still name a layer on.
53+
*/
54+
initialLayer?: string;
55+
/** Style document accompanying a deep-linked vector tileset. */
56+
initialStyleUrl?: string;
4757
}
4858

4959
/**
@@ -55,20 +65,29 @@ function renderSource(
5565
kind: AddDataKind,
5666
initialDeckVizKind: string | undefined,
5767
initialPostgres: OpenAddDataPostgres | undefined,
68+
initialUrl: string | undefined,
69+
initialLayer: string | undefined,
70+
initialStyleUrl: string | undefined,
5871
) {
5972
switch (kind) {
6073
case "xyz":
61-
return <XyzSource />;
74+
return <XyzSource initialUrl={initialUrl} />;
6275
case "wms":
63-
return <WmsSource />;
76+
return <WmsSource initialUrl={initialUrl} initialLayers={initialLayer} />;
6477
case "wfs":
65-
return <WfsSource />;
78+
return <WfsSource initialUrl={initialUrl} initialTypeName={initialLayer} />;
6679
case "wmts":
67-
return <WmtsSource />;
80+
return <WmtsSource initialUrl={initialUrl} />;
6881
case "ogc-features":
69-
return <OgcFeaturesSource />;
82+
return <OgcFeaturesSource initialUrl={initialUrl} />;
7083
case "ogc-vector-tiles":
71-
return <OgcVectorTilesSource />;
84+
return (
85+
<OgcVectorTilesSource
86+
initialUrl={initialUrl}
87+
initialStyleUrl={initialStyleUrl}
88+
initialSourceLayers={initialLayer}
89+
/>
90+
);
7291
case "gpx":
7392
return <GpxSource />;
7493
case "georss":
@@ -84,7 +103,7 @@ function renderSource(
84103
case "mbtiles":
85104
return <MbtilesSource />;
86105
case "arcgis":
87-
return <ArcGISSource />;
106+
return <ArcGISSource initialUrl={initialUrl} />;
88107
case "postgres":
89108
return <PostgresSource initialPostgres={initialPostgres} />;
90109
case "video":
@@ -107,6 +126,9 @@ export function AddDataDialog({
107126
onOpenChange,
108127
initialDeckVizKind,
109128
initialPostgres,
129+
initialUrl,
130+
initialLayer,
131+
initialStyleUrl,
110132
}: AddDataDialogProps) {
111133
const { t } = useTranslation();
112134
const open = kind !== null;
@@ -158,7 +180,14 @@ export function AddDataDialog({
158180

159181
{kind ? (
160182
<AddDataShellProvider value={contextValue}>
161-
{renderSource(kind, initialDeckVizKind, initialPostgres)}
183+
{renderSource(
184+
kind,
185+
initialDeckVizKind,
186+
initialPostgres,
187+
initialUrl,
188+
initialLayer,
189+
initialStyleUrl,
190+
)}
162191
</AddDataShellProvider>
163192
) : null}
164193
</DialogContent>

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import { IS_MAS_BUILD } from "../../lib/build-flags";
114114
import { masHidesDataSource } from "../../lib/mas-build";
115115
import { IS_STORE_BUILD } from "../../lib/updates";
116116
import { AddDataDialog, type AddDataKind } from "./AddDataDialog";
117+
import { serviceUrlParameter } from "../../lib/data-url";
117118
import {
118119
OPEN_ADD_DATA_EVENT,
119120
type OpenAddDataDetail,
@@ -1030,7 +1031,16 @@ export function TopToolbar({
10301031
{} as Record<ToolbarMapControl, boolean>,
10311032
),
10321033
);
1033-
const [addDataKind, setAddDataKind] = useState<AddDataKind | null>(null);
1034+
const [initialService, setInitialService] = useState(() =>
1035+
viewer || typeof window === "undefined" ? null : serviceUrlParameter(window.location.search),
1036+
);
1037+
const [addDataKind, setAddDataKind] = useState<AddDataKind | null>(() => {
1038+
const kind = initialService?.kind as AddDataKind | undefined;
1039+
// Every other path that opens this dialog from outside the component
1040+
// filters MAS-hidden sources first; a deep link must not be the way around
1041+
// that, even though no service kind is hidden today.
1042+
return kind && !masHidesDataSource(kind) ? kind : null;
1043+
});
10341044
const [addDataTargetGroupId, setAddDataTargetGroupId] = useState<string | null>(null);
10351045
const addDataInitialLayerIdsRef = useRef<Set<string>>(new Set());
10361046
// Every path that opens the dialog outside the OPEN_ADD_DATA_EVENT listener
@@ -2086,6 +2096,13 @@ export function TopToolbar({
20862096
mapControllerRef={mapControllerRef}
20872097
initialDeckVizKind={addDataDeckVizKind}
20882098
initialPostgres={addDataPostgres}
2099+
initialUrl={addDataKind === initialService?.kind ? initialService.url : undefined}
2100+
initialLayer={
2101+
addDataKind === initialService?.kind ? (initialService.layer ?? undefined) : undefined
2102+
}
2103+
initialStyleUrl={
2104+
addDataKind === initialService?.kind ? (initialService.styleUrl ?? undefined) : undefined
2105+
}
20892106
onOpenChange={(open: boolean) => {
20902107
if (!open) {
20912108
if (addDataTargetGroupId) {
@@ -2098,6 +2115,7 @@ export function TopToolbar({
20982115
}
20992116
}
21002117
setAddDataKind(null);
2118+
setInitialService(null);
21012119
setAddDataTargetGroupId(null);
21022120
setAddDataDeckVizKind(undefined);
21032121
setAddDataPostgres(undefined);

apps/geolibre-desktop/src/components/layout/add-data/sources/ArcGISSource.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ const URL_PLACEHOLDER_KEYS = {
3939
"image-service": "addData.arcgis.imageServiceUrlPlaceholder",
4040
} as const satisfies Record<ArcGISLayerType, string>;
4141

42-
export function ArcGISSource() {
42+
export function ArcGISSource({ initialUrl = "" }: { initialUrl?: string }) {
4343
const { t } = useTranslation();
4444
const source = useAddDataSource(t("addData.arcgis.defaultName"));
4545
const [arcgisLayerType, setArcgisLayerType] = useState<ArcGISLayerType>("feature");
4646
const [arcgisSourceType, setArcgisSourceType] = useState<ArcGISSourceType>("url");
47-
const [arcgisUrl, setArcgisUrl] = useState("");
47+
const [arcgisUrl, setArcgisUrl] = useState(initialUrl);
4848
const [arcgisItemId, setArcgisItemId] = useState("");
4949
const [arcgisPortalUrl, setArcgisPortalUrl] = useState("");
5050
const [arcgisAccessToken, setArcgisAccessToken] = useState("");

apps/geolibre-desktop/src/components/layout/add-data/sources/OgcFeaturesSource.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,22 @@ interface OgcFeaturesSample {
4343
* feature count is reached, because a single `/items` request returns only one
4444
* server-sized page.
4545
*/
46-
export function OgcFeaturesSource() {
46+
export function OgcFeaturesSource({ initialUrl = "" }: { initialUrl?: string }) {
4747
const { t } = useTranslation();
4848
const source = useAddDataSource(t("addData.ogcFeatures.defaultName"));
49-
const [endpoint, setEndpoint] = useState(ogcFeaturesFormCache?.endpoint ?? "");
50-
const [collectionId, setCollectionId] = useState(ogcFeaturesFormCache?.collectionId ?? "");
49+
const [endpoint, setEndpoint] = useState(initialUrl || ogcFeaturesFormCache?.endpoint || "");
50+
// See WmsSource: submitting prefers this field over the collection id in the
51+
// URL, so a value cached from another service would quietly request that
52+
// collection from the deep-linked one.
53+
const serviceCache = initialUrl ? null : ogcFeaturesFormCache;
54+
const [collectionId, setCollectionId] = useState(serviceCache?.collectionId ?? "");
5155
const [maxFeatures, setMaxFeatures] = useState(
5256
ogcFeaturesFormCache?.maxFeatures ?? String(DEFAULT_OGC_FEATURES_MAX_FEATURES),
5357
);
5458
const [bbox, setBbox] = useState(ogcFeaturesFormCache?.bbox ?? "");
5559
const [datetime, setDatetime] = useState(ogcFeaturesFormCache?.datetime ?? "");
5660
const [collectionOptions, setCollectionOptions] = useState<OgcFeaturesCollectionOption[]>(
57-
ogcFeaturesFormCache?.options ?? [],
61+
serviceCache?.options ?? [],
5862
);
5963
const [isRetrieving, setIsRetrieving] = useState(false);
6064
const [retrieveError, setRetrieveError] = useState<string | null>(null);

apps/geolibre-desktop/src/components/layout/add-data/sources/OgcVectorTilesSource.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,20 @@ interface OgcSample {
1818
* API TileJSON often omits them). Rendering uses GeoLibre's default per-source
1919
* layer styling; the style's own paint is not applied.
2020
*/
21-
export function OgcVectorTilesSource() {
21+
export function OgcVectorTilesSource({
22+
initialUrl = "",
23+
initialStyleUrl = "",
24+
initialSourceLayers = "",
25+
}: {
26+
initialUrl?: string;
27+
initialStyleUrl?: string;
28+
initialSourceLayers?: string;
29+
}) {
2230
const { t } = useTranslation();
2331
const source = useAddDataSource(t("addData.ogcVectorTiles.defaultName"));
24-
const [tilesUrl, setTilesUrl] = useState("");
25-
const [styleUrl, setStyleUrl] = useState("");
26-
const [sourceLayersText, setSourceLayersText] = useState("");
32+
const [tilesUrl, setTilesUrl] = useState(initialUrl);
33+
const [styleUrl, setStyleUrl] = useState(initialStyleUrl);
34+
const [sourceLayersText, setSourceLayersText] = useState(initialSourceLayers);
2735

2836
// Cancel the in-flight metadata/style/collections fetches if the dialog closes
2937
// mid-request, so a slow response cannot add the layer after the user leaves.

apps/geolibre-desktop/src/components/layout/add-data/sources/WfsSource.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,28 @@ interface WfsFormCache {
3131
}
3232
let wfsFormCache: WfsFormCache | null = null;
3333

34-
export function WfsSource() {
34+
export function WfsSource({
35+
initialUrl = "",
36+
initialTypeName = "",
37+
}: {
38+
initialUrl?: string;
39+
initialTypeName?: string;
40+
}) {
3541
const { t } = useTranslation();
3642
const source = useAddDataSource(t("addData.wfs.defaultName"));
37-
const [wfsEndpoint, setWfsEndpoint] = useState(wfsFormCache?.endpoint ?? "");
38-
const [wfsTypeName, setWfsTypeName] = useState(wfsFormCache?.typeName ?? "");
43+
const [wfsEndpoint, setWfsEndpoint] = useState(initialUrl || wfsFormCache?.endpoint || "");
44+
// See WmsSource: a deep link's endpoint must not inherit the feature type or
45+
// the retrieved type list cached from an unrelated service.
46+
const serviceCache = initialUrl ? null : wfsFormCache;
47+
const [wfsTypeName, setWfsTypeName] = useState(initialTypeName || (serviceCache?.typeName ?? ""));
3948
const [wfsVersion, setWfsVersion] = useState(wfsFormCache?.version ?? "2.0.0");
4049
const [wfsOutputFormat, setWfsOutputFormat] = useState(
4150
wfsFormCache?.outputFormat ?? "application/json",
4251
);
4352
const [wfsSrsName, setWfsSrsName] = useState(wfsFormCache?.srsName ?? "EPSG:4326");
4453
const [wfsMaxFeatures, setWfsMaxFeatures] = useState(wfsFormCache?.maxFeatures ?? "1000");
4554
const [typeOptions, setTypeOptions] = useState<WfsFeatureTypeOption[]>(
46-
wfsFormCache?.options ?? [],
55+
serviceCache?.options ?? [],
4756
);
4857
const [isRetrieving, setIsRetrieving] = useState(false);
4958
const [retrieveError, setRetrieveError] = useState<string | null>(null);

apps/geolibre-desktop/src/components/layout/add-data/sources/WmsSource.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,28 +38,40 @@ interface WmsFormCache {
3838
}
3939
let wmsFormCache: WmsFormCache | null = null;
4040

41-
export function WmsSource() {
41+
export function WmsSource({
42+
initialUrl = "",
43+
initialLayers = "",
44+
}: {
45+
initialUrl?: string;
46+
initialLayers?: string;
47+
}) {
4248
const { t } = useTranslation();
4349
const source = useAddDataSource(t("addData.wms.defaultName"));
44-
const [wmsEndpoint, setWmsEndpoint] = useState(wmsFormCache?.endpoint ?? "");
45-
const [wmsLayers, setWmsLayers] = useState(wmsFormCache?.layers ?? "");
46-
const [wmsStyles, setWmsStyles] = useState(wmsFormCache?.styles ?? "");
50+
const [wmsEndpoint, setWmsEndpoint] = useState(initialUrl || wmsFormCache?.endpoint || "");
51+
// A deep link brings its own service, so everything the cache holds *about a
52+
// service* belongs to a different one: its layers, styles, retrieved layer
53+
// list and negotiated version. Pairing a fresh endpoint with any of those
54+
// would describe a service this form is no longer pointed at. Generic
55+
// preferences (image format, transparency, tile size) still carry over.
56+
const serviceCache = initialUrl ? null : wmsFormCache;
57+
const [wmsLayers, setWmsLayers] = useState(initialLayers || (serviceCache?.layers ?? ""));
58+
const [wmsStyles, setWmsStyles] = useState(serviceCache?.styles ?? "");
4759
const [wmsFormat, setWmsFormat] = useState(wmsFormCache?.format ?? "image/png");
4860
const [wmsTransparent, setWmsTransparent] = useState(wmsFormCache?.transparent ?? true);
4961
const [wmsTileSize, setWmsTileSize] = useState(wmsFormCache?.tileSize ?? "256");
50-
const [wmsVersion, setWmsVersion] = useState(wmsFormCache?.version ?? "1.1.1");
62+
const [wmsVersion, setWmsVersion] = useState(serviceCache?.version ?? "1.1.1");
5163
// True while the version has an explicit source — the selector, a pasted
5264
// URL's VERSION parameter, or a saved service entry. Capabilities
5365
// auto-detection only fills the version in when no explicit source exists.
5466
// Mirrored in a ref so the async retrieve handler reads the value current at
5567
// response time, not the one captured when the button was clicked.
56-
const [versionTouched, setVersionTouched] = useState(wmsFormCache?.versionTouched ?? false);
68+
const [versionTouched, setVersionTouched] = useState(serviceCache?.versionTouched ?? false);
5769
const versionTouchedRef = useRef(versionTouched);
5870
const markVersionTouched = (touched: boolean) => {
5971
versionTouchedRef.current = touched;
6072
setVersionTouched(touched);
6173
};
62-
const [layerOptions, setLayerOptions] = useState<WmsLayerOption[]>(wmsFormCache?.options ?? []);
74+
const [layerOptions, setLayerOptions] = useState<WmsLayerOption[]>(serviceCache?.options ?? []);
6375
const [isRetrieving, setIsRetrieving] = useState(false);
6476
const [retrieveError, setRetrieveError] = useState<string | null>(null);
6577
const layerListId = useId();

apps/geolibre-desktop/src/components/layout/add-data/sources/WmtsSource.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import { ServiceLibrarySection } from "../ServiceLibrarySection";
77
import { serviceFieldString, type ServiceFields } from "../service-library";
88
import { AddDataSourceForm, SampleDataSelect, useAddDataSource } from "../shared";
99

10-
export function WmtsSource() {
10+
export function WmtsSource({ initialUrl = "" }: { initialUrl?: string }) {
1111
const { t } = useTranslation();
1212
const source = useAddDataSource(t("addData.wmts.defaultName"));
13-
const [wmtsUrl, setWmtsUrl] = useState("");
13+
const [wmtsUrl, setWmtsUrl] = useState(initialUrl);
1414
const [wmtsTileSize, setWmtsTileSize] = useState("256");
1515

1616
const getFields = (): ServiceFields => ({

apps/geolibre-desktop/src/components/layout/add-data/sources/XyzSource.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import { ServiceLibrarySection } from "../ServiceLibrarySection";
1212
import { serviceFieldBoolean, serviceFieldString, type ServiceFields } from "../service-library";
1313
import { AddDataSourceForm, SampleDataSelect, useAddDataSource } from "../shared";
1414

15-
export function XyzSource() {
15+
export function XyzSource({ initialUrl = "" }: { initialUrl?: string }) {
1616
const { t } = useTranslation();
1717
const source = useAddDataSource(t("addData.xyz.defaultName"));
18-
const [xyzUrl, setXyzUrl] = useState("");
18+
const [xyzUrl, setXyzUrl] = useState(initialUrl);
1919
const [xyzTileSize, setXyzTileSize] = useState("256");
2020
const [xyzShortUrl, setXyzShortUrl] = useState(false);
2121

apps/geolibre-desktop/src/hooks/useStartupProject.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useAppStore, type MapProjection } from "@geolibre/core";
22
import { useEffect, useState } from "react";
33
import { useTranslation } from "react-i18next";
4-
import { dataUrlParameters } from "../lib/data-url";
4+
import { dataUrlParameters, serviceUrlParameter } from "../lib/data-url";
55
import { isTauri } from "../lib/is-tauri";
66
import { projectUrlFromLocation } from "../lib/project-url";
77
import { planStartup, startupDefaultProjection, type StartupPlan } from "../lib/startup-project";
@@ -36,6 +36,7 @@ const RESTORE_GATE_TIMEOUT_MS = 10_000;
3636
function hasExplicitLaunchPayload(): boolean {
3737
if (projectUrlFromLocation() !== null) return true;
3838
if (dataUrlParameters(window.location.search) !== null) return true;
39+
if (serviceUrlParameter(window.location.search) !== null) return true;
3940
return false;
4041
}
4142

0 commit comments

Comments
 (0)