111 Commits
Author SHA1 Message Date
82cea2a60e fix(plugins): harden the zip entry listing for file handlers (#3320)
* fix(plugins): validate the zip end record and decode CP437 entry names

The scan from the end took the first end-record signature it found, so an
archive comment containing the signature broke the listing. Accept a record
only when its comment fits the remaining bytes and the central directory ends
where the record (or the ZIP64 record) begins, otherwise keep scanning.

Names without general purpose flag bit 11 are now decoded as CP437, the zip
default, instead of UTF-8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): note on reading archives safely and folder file paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): accept a zip end record by where the directory starts

Requiring the directory to end exactly at the end record rejected valid
archives with bytes in front of them (self-extractors). A candidate is now
accepted when a central directory entry signature sits at the declared offset,
or, for a non-ZIP64 record, at the offset implied by the directory size. ZIP64
checks the signature at its own directory offset. An empty archive is accepted
only when its record sits at the declared offset. At most 16 candidates are
tried so a comment full of signatures stays cheap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): move the archive note to the dropped-files section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): rank zip end records, keep unflagged UTF-8 names, skip spanned archives

Prefer an end record whose directory also ends where the record begins over
one that merely points at a directory start; try records with a fitting
comment first and fall back to a truncated comment. Read unflagged names as
UTF-8 when valid, CP437 otherwise. Accept empty classic (also shifted) and
empty ZIP64 archives. Do not list spanned archives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): try the end record that closes the file first, handle signature records

Order end-record candidates without extra reads: the one whose comment ends at
the end of the file first, then other fitting ones. The cap now bounds only
range reads, and a directory inside the tail is checked in memory, so a comment
full of fake records no longer hides the real one. A digital signature record
between the directory and the end record is accounted for when the offsets are
shifted. The UTF-8 decoders are module constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): keep the real zip end record reachable, read signature records before the tail

Rank candidates without reads (directory visibly ends at the record, exact
comment fit, fitting, overrunning), evaluate every group within the read cap and
stop only on a record the layout fully backs. Keep every signature record that
ends at an end record, and read one bounded window when it starts before the
tail. Reject ZIP64 records and locators that name a nonzero disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): reduce the zip entry listing to the announced scope

Keep the end record that closes the file first, then scan back through at most
16 candidates, accepting one only when a central directory entry really starts
at its declared offset (or it is an empty archive). Drop the ranking, the
prepended-data shift and the signature-record handling, and document in the
code that such archives (and spanned ones) are deliberately not listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): accept an empty zip only when its directory sits at its end record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): tighten zip end-record checks and docs

Reject ZIP64 archives whose locator or ZIP64 record names more than one
disk, and reject a classic record whose directory-disk field is set, so
spanned archives are not listed. Reject a directory shorter than one
central entry before probing it, so a lone entry signature cannot stop
the end-record search. Restore an explicit bounds check on the directory
before it is read.

Drop the digital-signature clause from listZipEntryPaths' doc comment:
the signature record sits after the declared directory, so signed zips
list normally. Add regression tests for the ZIP64 disk checks and the
short directory.

Applied by the claude-autofix workflow.

* refactor(plugins): collect zip end-record candidates without unshift

Split the end-record candidates into exact and other arrays and
concatenate them once, instead of prepending each exact match. The
order is unchanged: exact records still come first, lowest offset
first.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

Applied by the claude-autofix workflow.

* fix(plugins): harden zip listing reads and tighten its tests

Check the ZIP64 end record read for a short read and return no listing
instead of throwing a RangeError. Read a central directory signature
from the tail already in memory when it falls inside it, so a comment
full of candidates costs fewer IPC round trips on desktop.

Add regression tests for a zero-length directory that does not sit at
the end record, and for a short ZIP64 read.

Applied by the claude-autofix workflow.

* Address Claude review feedback

- Pass ignoreBOM to both UTF-8 name decoders so a leading U+FEFF in an
  entry name is kept rather than silently stripped.
- Rename the fake empty-archive test: the record carries a signature, so
  "all-zero" did not describe it.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: giswqs <5016453+giswqs@users.noreply.github.com>
Co-authored-by: giswqs <giswqs@gmail.com>
2026-11-03 12:12:25 -05:00
53345f73b7 feat(plugins): offer data loaded by URL to plugin file handlers (#3321)
* feat(plugins): offer data loaded by URL to plugin file handlers

A file loaded with ?data= or the embed addData command never reached the
file handlers added in #3305: a TASKDATA.zip failed as a GeoJSON zip even
though an installed plugin could read it.

After the download, and before the built-in classification, the file is
offered to the registered handlers through the same routing as a drop
(extension filter, claim with the zip entries, open, same error
handling). A handler can also declare dataTypes; a URL with
dataType=<name> then goes straight to its open, for URLs without an
extension. lidar stays built in. The embed addData command now accepts
dataType too.

URL loads wait, bounded, for plugin activation before offering a file,
keep every download ceiling, and reach handlers only where drops are
allowed (not in the viewer, and with data:add).

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(plugins): offer an extensionless zip download to .zip file handlers

A URL load already recognizes a zip by extension, content type,
Content-Disposition or the zip signature. When the derived file name has
no .zip extension, offer it as name.zip so the extension pre-filter and
the zip entries work, for example for a signed link that returns
application/zip. No other format is guessed.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): drop the status line claim for URL-loaded files

The data URL loader's success message is not shown in the UI, only its
errors are, so the docs should not promise an "Opened by" status.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): wait for the ?plugin= deep link before offering URL data to handlers

The startup wait only covered the project plugin restore. A plugin that a
?plugin= link activates registers its file handler from a second effect,
so a ?data= load combined with ?plugin= could offer the file too early.

The settle gate now has two parts, restore and deep link, and
whenPluginsSettled waits for both within the same 15 s bound. Without a
?plugin= link the deep link part settles at once.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): bound the URL load wait, clean up after handlers, and tighten the details

- One absolute 15 s deadline covers the settle parts and any activation
  still pending; once it passes, or startup has settled, later loads do
  not wait.
- The restore part settles only for the restore that follows a ?url=
  project, and the deep link part only after that project has loaded.
- A plugin hint combined with a style fails before downloading. A failed
  open, a style that arrives too late, or an abort removes the layers the
  handler added. Handled layers are framed by the shell like GeoJSON.
- Remove the unused success message; sanitize the download file name
  (control characters, 255 characters, escaped quotes, empty names).
- addData accepts dataType null; DataTypeHint keeps the lidar literal.
- Docs: the handler's plugin must be active (use plugin= or a ?url=
  project), open must validate hinted bytes, every download outside the
  viewer waits up to the limit, and the first addData after ready may
  need a longer requestTimeoutMs.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): say which plugins ?plugin= can activate for URL loads

?plugin= accepts built-in plugin names and registry plugin names. A plugin
installed from a file is not found by it, so a link that relies on such a
plugin needs a ?url= project that lists it as active.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): address review findings on URL loads and file handler hints

- Latch the ?url= settled flag so the loader returning to idle cannot
  un-settle plugin activation; settle the ?plugin= part only after
  registry plugins awaiting trust or installing have activated.
- Count only the layers added while a handler's open runs as the
  handler's, so a layer the user adds during the download is never
  removed or framed on its behalf.
- Send no style request for a hint a handler declares, and abort the
  download as soon as the style request fails (and vice versa).
- Check the abort signal after the readiness wait, before offering.
- A malformed dataType is dropped with a console warning in both the
  ?dataType= parameter and the embed addData option.
- Registry: describe an invalid data type by its type so the warning
  cannot throw; re-registering an id now moves the handler to the end of
  the order, so hint ownership stays with the earlier handler as the
  warning says (the test that pinned "keeps the slot" changed).
- DataTypeHint is plain string; docs say the style rule applies only to
  a hint a handler declares.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): address a further review round on URL file handlers

- Wrap handlers for layer tracking with a proxy instead of a spread, so
  a class-instance handler keeps its prototype methods (claim) and every
  method runs with the original handler as this.
- Revert the "replacement moves to the end" order change: re-registering
  an id keeps its slot again, as in #3305. Hint ownership is tracked
  apart from the order: first declaration wins, a replacement keeps the
  hints its predecessor owned, never takes one another live handler owns,
  and a hint it drops goes to the next handler declaring it.
- The plugin readiness wait is raced against the load abort, for the
  offer, the hinted path and the style check.
- A trusted registry plugin whose manifest reports a load issue counts as
  failed even when another plugin holds its id, so it cannot hold the
  startup gate until the deadline.
- A dataType hint is trimmed and compared case-insensitively in both the
  ?dataType= parameter and embed addData; docs say so.
- The startup ?data= load and the embed addData command share one queue,
  so two URL loads never run a handler at once.
- Docs: bytes from a shared ?data= link or an allowed host reach active
  plugins' claim/open and must be treated as untrusted input.

Refs #3283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): state how URL loads reach file handlers

Three review findings on the URL file handler docs are answered in
docs/plugin-api.md. The layer attribution limit is now stated: a layer
the user adds while a handler's open runs is counted as the handler's,
since it cannot be told apart from the handler's own layers. Downloads
that the built-in loaders take by extension (.tif, .tiff, .cog,
.pmtiles, .parquet, .geoparquet, .las, .laz, EPT ept.json) are loaded
before any download, so an extensions filter never sees them; a dataType
hint still routes them to a handler.

Code changes are left to maintainers: the layer attribution design, the
plugin registry order on reactivation, the i18n of handler errors, the
load-issue mismatch for trusted manifests, and the queue stall on a
handler that never settles are marked unclear in the thread replies.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

Applied by the claude-autofix workflow.

* fix(embed): treat an empty addData dataType as no hint

An empty dataType on addData logged a "malformed" warning, while an
empty ?dataType= parameter is ignored silently. Treat the empty string
as no hint in addData too, so both entry points behave the same, and
cover it with a regression test.

Applied by the claude-autofix workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: giswqs <5016453+giswqs@users.noreply.github.com>
2026-11-03 11:46:15 -05:00
Qiusheng Wu 64b4e058c9 feat(map): set the map CRS from a link and from plugins (#3309)
* feat(map): set the map CRS from a link and from plugins

A link could not carry the map CRS, so a shared map relying on one
opened on Web Mercator, where a WMS offered only in a national system
stays blank.

Add a `crs` host URL parameter taking an EPSG code or a preset id. It is
seeded before the map exists, so the Web Mercator basemap never loads,
overrides a `?url=` project's saved CRS, frames the CRS when the link has
no view, and is ignored with a warning when it does not resolve. The web
address now follows the CRS of the map, so a copied link reopens it.

Add `setMapCrs(id | null)` to the plugin API, next to `getMapCrs` and
`onMapCrsChange`, with the checks and effects of the Settings choice.

Fixes #3308

* Address review feedback

- Clear the controller's initial frame flag when a linked CRS fails, so a
  later switch does not reframe over a view the user set.
- Recheck the engine in setMapCrs after the lookup, since a renderer swap
  can land while it is pending. Covered by a test.
- Say in the embedding guide that a project keeps its saved view when a
  link names a CRS and no view.
- Rewrap the long line in the plugin API guide.

* Address review feedback

- Fix the type of the fake engine in the renderer swap test, which failed
  the test type check.
- Clear the initial frame request when a CRS has nothing to frame, so it
  cannot carry over to a later map.
- State plainly in the comment and docs that a link's crs that does not
  resolve stays in the address.
2026-11-02 08:01:00 -05:00
Qiusheng Wu 56a79c8f37 feat(plugins): let plugins register file handlers for drag and drop (#3305)
* feat(plugins): let plugins register file handlers for drag and drop

A plugin could add a format's reader and an Add Data entry, but a dropped
file never reached it: every extension belongs to a built-in group, .zip
is always read as a shapefile, and the Tauri native drop never becomes a
DOM event a plugin could listen for.

Add registerFileHandler/unregisterFileHandler to the app API. Both drop
paths now offer files to handlers after project files and before the
built-in groups. A handler pre-filters by extension and claims by content:
a .zip carries its entry list, read from the central directory, so a
shapefile zip still loads as one. Dropped folders are expanded for
handlers, and anything unclaimed loads exactly as before.

The range read needs fs:allow-open, allow-read and allow-seek on desktop;
the fs scope still limits them to paths that could already be read.

Refs #3283

* Address review feedback

- Count claimed keys so returning one of two same-named files claims only
  that one instead of both.
- Lower the central directory cap to 16 MB, which still covers far more
  entries than a drop handler needs.
- Reject non-string and empty extensions at registration, so one bad entry
  cannot break every later drop.
- Move the drop status reporting into the tested plugin-file-drop module to
  stay within the untested-module allowance for useFileDrop.ts.

* Address review feedback

- Keep a list of dropped folders per name, so two same-named folders are
  both walked rather than the second twice.
- Keep the success message when another handler fails, and show every
  failure instead of only the last.
- Group the file handler imports after the menu contribution ones.

* Address CodeRabbit review feedback

- Align captured folders with the files list by index, so a plain file can
  never be mistaken for a same-named folder.
- Set the success message after the error, since the error setter dismisses
  the current status.

* Address Claude review feedback

- Match files returned from claim by identity first and by key only for
  copies, so a handler gets the file it chose when two share a name.
- Log a failed zip entry read instead of swallowing it silently.

* Address review feedback

- Match claim results inside the claim error handler, so a malformed result
  counts as not mine instead of failing the whole drop.
- Skip an unreadable folder entry instead of abandoning the rest of the walk.
- Index unmatched files by key so matching returned copies stays linear.
2026-11-01 13:05:58 -05:00
4a6600ad25 feat(osm): improve downloader reliability and export (#3304)
* fix(osm): gate downloads by viewport area

* fix(osm): add resilient download fallbacks

* feat(osm): preview downloads before adding

* fix(osm): allow export without preview

* fix(osm): preserve preview while navigating

* feat(osm): add geoparquet export

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(osm): address review findings

* fix(osm): ignore inherited keys in custom tag filter

The map API fallback checked custom tag keys with `key in tags`, which
also matches Object.prototype members. A filter on a key such as
`constructor` with no value matched every tagged element. Use an
own-property check instead and add a regression test.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

Applied by the claude-autofix workflow.

* fix(osm): match cached preview to current extent

Panning keeps the OSM preview on the map, but Save GeoJSON and Save
GeoParquet reused it without checking the bbox inputs. The export could
then contain the old area while the panel showed the new one. Record the
preview's bbox and reuse it only when it still matches.

Also read the failed Overpass response body before the map-API fallback
so its connection is released on every path. The error detail is kept.

Adds a regression assertion for exporting after a pan.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

Applied by the claude-autofix workflow.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: giswqs <5016453+giswqs@users.noreply.github.com>
2026-11-01 10:23:19 -05:00
Qiusheng Wu 0359719211 fix(s3): re-probe a bucket's region after a network failure (#3278)
Follow-up to #3277. Only remember region probes S3 answered without
naming a region; a network error or timeout says nothing about the
bucket, so the next read probes again instead of signing for the
default region for five minutes. Test added.

Docs and JSDoc: an s3:// URL whose key has a "." or ".." segment
rejects, since no URL can address it.
2026-10-28 21:31:57 +00:00
130f6c8d10 feat(plugins): let external plugins read private S3 objects (#3277)
* feat(plugins): let external plugins read private S3 objects

Add an optional resolveReadableUrl(url, signal?) member to the plugin API,
backed by the host's own resolveReadableUrl:

- an s3:// (or S3 HTTPS) URL in a bucket a configured S3 connection covers
  resolves to a presigned HTTPS URL, signed with that connection's
  credentials (on the desktop also AWS profiles, SSO, the environment and
  instance roles);
- any other s3:// URL resolves to its public HTTPS object URL;
- every other URL is returned unchanged.

Plugins that read many files from S3 (an index and the files it lists,
images) could not do so from private buckets: nothing on the plugin API
presigned, and the host's credentials must not reach plugins. Only signed
URLs do, and the host remembers each one's s3:// source so it is never
saved.

Document it in docs/plugin-api.md and cover it in the app API contract
tests.

* Address review feedback

- resolveReadableUrl rejects a non-AbortSignal signal, never starts
  signing on an already-aborted signal, and rejects as soon as the signal
  aborts, even mid credential lookup (which ignores the signal); tests
  cover both abort paths and the signal check.
- Docs: note whitespace trimming and the signal argument, state the trust
  model (any object a covered bucket holds), note that anonymous
  connections return non-expiring URLs, and resolve the example's
  relative file with new URL(), which does resolve against s3:// bases.

* Address review feedback

- resolveReadableUrl treats a null signal as no signal instead of
  rejecting it.
- The S3 signer no longer passes one caller's signal into the shared
  region probe: a cancelled caller made the probe resolve null, so it
  cached a default-region URL and failed the probe for everyone sharing
  it. Callers now race their own signal against the probe and check it
  again after the credential lookup, so a cancelled caller gets no URL
  and caches none; a test covers both.
- The in-flight abort test asserts the rejection before releasing the
  pending lookup, so a wait-for-lookup implementation cannot pass.
- The docs example decodes the resolved URL, since new URL()
  percent-encodes names (a space as %20) and keys are taken literally.

* fix(s3): bound the shared region probe and harden its test

The shared bucket-region probe takes no signal, so a stalled browser
fetch could hold its pending entry and hang every later presign for
that bucket. Give it a 30 s timeout, matching the Tauri path.

The cancellation test waited a fixed 10 ms for the mocked fetch to
start, so under load it could hang. It now awaits a signal from the
mock.

The plugin-api docs example used decodeURI, which throws on a lone
"%" and leaves "?" and "#" encoded. It now appends a plain relative
name to the index's folder, and the prose says when keys need the
plugin's own encoding.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

Applied by the claude-autofix workflow.

* docs(plugin-api): match S3 reading docs to the snippet

Reword the S3 reading snippet comment and the paragraph after it so they
describe the code shown: the file name is appended as written, and a
`?` or `#` in an s3:// URL ends the key early. Drop the decodeURI
reference, which the snippet never calls.

Rename the local abort helper in app-api.ts to startUnlessAborted so it
no longer shares its name with rejectOnAbort in abortable.ts. Its
behavior is unchanged: it does not start the lookup once the signal has
aborted.

Applied by the claude-autofix workflow.

* Address Claude review feedback

- Fold the two "settle or reject on abort" helpers (app-api's
  startUnlessAborted and s3-signer's untilAborted) into one exported
  untilAborted in abortable.ts, beside its rejectOnAbort; the plugin
  wrapper still skips the lookup when the signal has already aborted.
- Fix the doubled parenthetical in the signed-URL expiry bullet.

* Address review feedback

- The S3 signer no longer caches a URL signed for a guessed region (the
  probe timed out or S3 did not name the region), so a wrong-region
  signature is not reused for hours; the next read probes again. Test
  added.
- untilAborted observes the dropped promise when the signal has already
  aborted, and both abort paths fall back to an AbortError for a
  signal-like object with no reason (shared abortReason helper).
- The region-probe test's fetch mock honors its signal, so a caller's
  abort leaking into the shared probe fails it.
- resolveReadableUrl's JSDoc notes the whitespace trimming.
- Docs: keys with ? or # go through an encoded S3 HTTPS URL; s3:// keys
  are literal, so a space or % needs no encoding.

* Address Claude review feedback

- Remember a failed region probe per bucket for five minutes, so reads
  in a bucket without list permission no longer each pay for a probe now
  that URLs signed for a guessed region are not cached; the test covers
  both the skipped probe and the re-probe once the window passes.
- Check the abort with signal.aborted and abortReason() rather than
  throwIfAborted(), which a signal-like object from a plugin may lack.

* Address Claude review feedback

- Check the signal before the credential lookup too, so a cancelled
  caller does not start an SSO refresh.
- Docs: fetchArrayBuffer falls back to the webview, where CORS applies,
  for 401/403 and unreachable hosts.

* Address Claude review feedback

- Record a failed region probe inside the shared probe, so it holds even
  when every waiting caller cancelled, and forget it when the bucket's
  credentials are invalidated, since the failure may have come from the
  refused credentials.
- Docs: an s3:// URL with an invalid bucket name is returned unchanged.

* Address Claude review feedback

- s3ObjectHttpsUrl refuses keys with a "." or ".." segment. URL parsers
  resolve those away (encoded or not), so s3://private/../other/x on a
  path-style connection was signed as a read of bucket "other" with the
  credentials of the connection covering "private". Tests cover the URL
  builder and the signer.
- invalidateCredentials with only a connection id forgets just that
  connection's endpoint's failed probes.
- Docs: a signed URL carries the access key ID (and session token), but
  never the secret.

---------

Co-authored-by: Qiusheng Wu <qswu@amazon.com>
Co-authored-by: giswqs <5016453+giswqs@users.noreply.github.com>
2026-10-28 17:11:23 -04:00
Qiusheng Wu 81a02c86c2 feat(plugins): let external plugins add and render point clouds
Add two optional members to the plugin API:

- addPointCloudLayer(source, options) adds a LAS/LAZ/COPC file or an EPT
  dataset as a GeoLibre LiDAR layer through the shared LiDAR control, the
  same path as Add Data -> LiDAR, the ?data= deep link and file drops. The
  layer is listed in the Layers panel, styled in the LiDAR panel, and a URL
  layer is saved with the project. Accepts an HTTP(S)/s3:// URL, a File, an
  ArrayBuffer or a Uint8Array, and resolves with the layer id.
- getMaplibreGlLidar() hands plugins the host's own maplibre-gl-lidar
  module, like getMaplibreGlRaster(), so they can decode LAS/LAZ/COPC with
  the WebAssembly the host already serves and render on the host's single
  deck.gl instance instead of bundling a second copy.

Document both in docs/plugin-api.md and cover them in the app API contract
tests.
2026-10-28 11:14:08 -04:00
Thang Quach 73e088b94c feat/support XYZ Layer with {-y} placeholder (#3265)
* support XYZ Layer with {-y} placeholder

GeoLibre XYZ layers now accept {-y} in a tile URL and in a TileJSON entry, and flip that row the way QGIS and https://viewer.gishub.vn do.

* fix bot comments

* fix oxfmt

* fix minor bot comments

* fix storymap export to deal with {-y} raster tile
2026-10-27 19:08:38 -04:00
Qiusheng Wu 5f5048d2bc feat: let projects open Measure and Minimap collapsed, with Measure units (#3245)
* feat: let projects open Measure and Minimap collapsed, with Measure units

A project's interaction.controls entry for measure or minimap can now be
an object, {"visible": true, "collapsed": true, "distanceUnit": "feet",
"areaUnit": "acres"}, instead of a plain boolean. collapsed opens only
the control's on-map button, so a shared or HTML-exported map no longer
starts with the Measure panel over it and its tool turning the viewer's
first clicks into points. A plain true keeps today's expanded behaviour.

- Python: show_control(name, visible, *, collapsed, distance_unit,
  area_unit), validated and round-tripped through save/load/to_html.
- openControlPanel accepts Measure options; Minimap gains collapsed.
- Measure builds its panel on first expand, so the terrain section and
  resize styling now attach on that expand when it opens collapsed.
- New units on an open Measure control rebuild it, since its unit
  dropdown only reads them when the panel is built.

Fixes #3241

* Address review feedback

- Widget replay forwards control options only for measure and minimap,
  matching the desktop restore hook, so a stray option cannot keep
  another panel closed.
- openMeasurePanel returns early when nothing changes on an open
  control, and a rebuild for new units keeps a folded ruler folded.
- Minimap applies an explicit collapsed request immediately, so a
  basemap refresh in the same turn rebuilds it in the requested state.
- show_control rejects a non-boolean collapsed instead of coercing it.

* Address Claude review feedback

- Add a test that the Python Measure unit sets match the TypeScript
  lists in measure.ts, like the existing control-list drift test.

* Address Claude review feedback

- The widget replays every control on each _ui change; re-sending a
  collapsed option would fold a Measure or Minimap panel the viewer had
  since opened. Send a setting with options only when it changed, and
  forget what was sent when the iframe reports ready again.
2026-10-26 23:05:19 +00:00
Qiusheng Wu b8b6c18531 feat(plugins): tell plugins which CRS the map is drawn in (#3214)
Add getMapCrs and onMapCrsChange to the plugin app API. getMapCrs
returns { id, code, wmsSrs? } for a MapLibre map drawn in another CRS
(preferences.map.mapCrs), or null on the default Web Mercator map,
the globe and the other renderers. onMapCrsChange reports changes.

The map controller fires a geolibre:map-crs-change window event when
it enters or leaves a CRS, and when it is torn down in one, so a
renderer swap is reported too. The host reads the primary engine's
new getMapCrs and drops events that do not change the value.

Document the API, and note in the addWmsLayer docs that a WMS listed
only in the map's own CRS lines up on both builds.

Fixes #3213
2026-10-25 20:40:18 -04:00
Qiusheng Wu c70f56ec78 Keep map views and plugin state in the web address (#3196)
* Keep map views and plugin parameters synchronized with web URLs

* Address Claude review feedback

Protect existing data, catalog, screenshot, and layout launch parameters from plugin URL writes and cover them with a regression test.

* Address Claude review feedback

Flush pending camera URL updates on pagehide and shell teardown, and keep the refresh directive above the shell imports.

* Address plugin URL ownership review feedback

Prevent conflicting plugin declarations from overwriting or clearing another plugin's URL parameters while preserving existing read dispatch. Preserve untouched inbound links when activation fails so plugins can retry.

* Address CodeRabbit review feedback

- Retain retired plugin URL names until host-backed cleanup so replacing a plugin cannot leave stale parameters behind.
- Restrict replacement writes to current declarations and release retired claims after cleanup, with regression coverage for publishing, deactivation, and unregistration.

* Address CodeRabbit review feedback

- Contain host URL-writer failures so plugin deactivation, unregistration, and activation rollback can complete.
- Retain retired URL claims after failed writes and mark publication only after success, preserving retries and untouched inbound links.
- Cover lifecycle completion, stale callbacks, cleanup retries, and failed publication with regression tests.

* Address CodeRabbit review feedback

- Retain publication markers when URL cleanup fails so a later activation failure can retry removing stale published values.
- Clear markers after successful cleanup or final unregistration, with regression coverage for declined, thrown, and rejected activations and fresh inbound links.
2026-10-25 01:05:28 -04:00
Qiusheng Wu a3002fe2c0 feat(plugins): let plugins open built-in control panels (#3190)
* feat(plugins): expose built-in control panels to plugins

Share panel toggles with the scripting API so plugins can open the existing host controls. Allow Minimap zoom and size options while keeping menu visibility synchronized.

* Address Claude review feedback

Preserve Minimap visibility and collapsed state when updating its options. Keep repeated direct opens idempotent and retain collapsed state on basemap refreshes.

* Address CodeRabbit review feedback

Invalidate pending bookmark, search, measure, and print opens when closed or torn down. Forward scripting closes during initialization and cover both constructor loading and deferred show callbacks in regression tests.
2026-10-24 00:06:43 -04:00
ffe2665a3b feat(plugins): let plugins set a layer's visibility (#3182)
* feat(plugins): let plugins set a layer's visibility

Adds setLayerVisibility(layerId, visible) to the external plugin API, delegating to the
store action the Layers panel's eye button, the embed API and the AI Assistant already use,
so the panel, the map and a saved project stay in step. Throws for an unknown layer id, like
getLayerFeatures and importLayerStyle.

Plugins could read visibility through listLayers but not set it, so a plugin hiding one of
its own layers (e.g. a raster it has just vectorised) had to hide the MapLibre layer behind
the store's back, leaving the eye icon wrong until the next sync re-showed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(plugins): reject a non-boolean visibility in setLayerVisibility

An untyped JavaScript plugin could pass e.g. the string "false", which is truthy and would
leave a non-boolean in layer state. Throw a TypeError instead, as the embed API validates
its own setLayerVisibility. Requested in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: agraham <agraham@aslenv.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-22 17:44:31 -04:00
Qiusheng Wu f8cf4a4a3a feat(plugins): pass the cancel signal to assistant tool specs (#3161)
* feat(plugins): pass the cancel signal to assistant tool specs

registerAssistantToolSpec dropped the SDK's per-call context, so a plugin
tool waiting on the network kept the assistant busy after Stop and the
next message failed with "already in progress". The spec callback now
gets an optional second argument, { signal }, wired to the tool
context's cancelSignal; existing one-argument callbacks are unaffected.

Closes #3155

* test: type the spec helper callback with the optional context

* Address Claude review feedback

- Fall back to a never-aborting signal when the tool context has no
  cancelSignal: the fast path (runToolDirectly) invokes tools with a
  context built from toolUse alone, so plugins would have received
  signal: undefined and thrown on signal.aborted.
- Test that a spec callback gets a live AbortSignal without one.
2026-10-19 23:43:24 -04:00
Qiusheng Wu 12527f86b2 feat(assistant): let the assistant turn on an installed plugin and use its tools in the same turn (#3160)
* feat(assistant): let the assistant turn on an installed plugin

Plugin tools exist only while their plugin is on, so the assistant could
not reach an installed plugin that was off, and a plugin turned on during
a turn only showed its tools at the next prompt.

Plugins now declare `assistant: { description }` (in plugin.json or on
the plugin object). While such a plugin is installed and off, the
assistant gets an `activate_plugin` host tool listing it. Calling it
turns the plugin on the way the Plugins menu does, adds the tools it
registers to the live agent so they are callable in the same turn, and
returns them with the plugin's guidance.

Fixes #3156

* Address review feedback

- Keep polling after a plugin's first tool appears, until its tool list
  has been unchanged for 250 ms, so tools registered in several steps
  all reach the live agent in the same turn (CodeRabbit). Always waiting
  the full timeout, as suggested, would stall every activation 3 s.
- Collapse whitespace in plugin ids, names and descriptions before they
  go into the activate_plugin description, so a description cannot
  forge list entries or lines (Claude review).
- Document the wait, including the 3 s stall for a plugin that
  registers no tools.

* Address CodeRabbit review feedback

- State that 3 s is the total upper bound on waiting for a plugin's
  tools, with the 250 ms quiet period inside it, and rewrap the line.
2026-10-19 21:51:56 -04:00
Rob Gaston 39dde26e45 feat(map): request headers for authenticated tile services (#3142)
* feat(map): request headers for authenticated tile services

MapLibre takes one request transform per map, so a plugin that needs a
header on its tile requests has to call setTransformRequest itself and
replaces anyone else's (GeoLens did, for private rasters). GeoLibre now
builds its MapLibre maps with a single transform backed by a header
registry in @geolibre/map/tile-request-headers, keyed by owner and matched
by URL prefix, and GeoLens registers its API keys there.

Plugins get a headers option on addVectorTileLayer and
setLayerRequestHeaders for any tile layer, so a token refresh needs no
reload. Headers stay in memory: they never reach the store or a saved
project, and opening another project or removeLayer drops them. Only
MapLibre maps send them.

Closes #3127

* fix(map): keep tile request headers to their own server and layer

- A URL prefix must now name one host and the start of a path on it before
  it can carry headers. A template with a placeholder in its host matched on
  "https://", and a prefix without its trailing slash matched look-alike
  hosts, so a layer's credentials could reach basemaps and other servers.
  The registry throws for such a prefix, and setLayerRequestHeaders and
  addVectorTileLayer say why.
- A layer's headers are dropped when the layer leaves the store, however it
  is removed (the Layers panel, undo, a plugin), not only through the plugin
  API's removeLayer.
- Header names given twice in different cases are rejected, since fetch
  would join their values into one.

* fix(map): drop tile headers when a layer's URL moves, and match fetch's rules

- A layer's headers are recorded with the tile URL prefixes they were set
  for, and dropped when the layer's URL moves to other prefixes, so a new
  server doesn't inherit them and the old prefix doesn't keep them. A token
  refresh that only changes the query string after the placeholders keeps
  the same prefixes and the headers.
- Header validation now also rejects what fetch won't send: names the
  browser sets itself (Cookie, Host, Sec-*, Proxy-* and the rest of the
  Fetch standard's forbidden names), which fetch silently drops, and values
  with characters above U+00FF, which make fetch throw inside MapLibre.
- GeoLens checks the renderer before registering a private raster's API
  key, so the key isn't left registered when the call refuses.
2026-10-14 21:54:07 -04:00
Rob Gaston 7106dd6911 fix(plugins): keep plugin metadata from rerouting a vector tile layer (#3143)
* fix(plugins): keep plugin metadata from rerouting a vector tile layer

The map decides how to draw a layer from metadata keys such as sourceKind
and customLayerType. A plugin passing sourceKind "maplibre-gl-vector" and a
customLayerType in options.metadata sent its layer down the control-owned
path, where nothing drew it. The layer now gets its own sourceKind,
"plugin-vector-tiles", after the plugin's metadata, and the other
render-routing keys are dropped from it.

* test(plugins): cover every render-routing metadata key

The addVectorTileLayer test now passes all seven keys it drops from plugin metadata (sourceIds, externalDeckLayer, controlOwnsPaint and nativeFillExtrusion as well) and checks each is removed.

* fix(plugins): drop metadata.sourceId from vector tile layer metadata

MapController and the layer sync read the singular sourceId as well as sourceIds, for instance to tell basemap sources apart, so a plugin setting it to a basemap source's id could affect basemap handling. It is now dropped with the other render-routing keys, and the test and docs list it.
2026-10-14 21:53:03 -04:00
Rob Gaston de62b66163 feat(plugins): GeoEditor session events and startEditSession for plugins (#3138)
* feat(plugins): GeoEditor session events and startEditSession for plugins

Plugins couldn't tell when a GeoEditor session on their layer ended, or
start one. A plugin that syncs edits to a server had to infer a session's
end from the layer's visibility (hidden during a session, shown after),
which can't tell Save from Cancel and fires when the user toggles the
layer by hand (#3125).

- getEditSessionLayerId reports the layer being edited.
- onEditSessionChange reports sessions starting and ending, with whether
  the edits were saved. The GeoEditor records how its last session ended
  (getLastGeometryEditEnd) before notifying, covering save, cancel, and a
  layer removed mid-session.
- startEditSession opens a session as a layer's Edit geometry action does.
  That logic moves from useLayerEditActions into
  lib/geometry-edit-session.ts, shared by the Layers panel and the plugin
  API; it takes the GeoEditor functions as arguments, so app-api.ts still
  doesn't import plugin runtime code.

Closes #3125

* fix(plugins): serialize edit-session starts and report same-layer restarts

Overlapping startEditSession calls could end the first session while it was
still loading its features, so the first load might finish against the
second session. Starts now run one after another, on the Layers panel path
too.

onEditSessionChange also compares the last end record as well as the layer
id, so a session that ends and restarts on the same layer between two
notifications is still reported as an end and a start.

* fix(plugins): skip GeoEditor activation for a missing layer

startEditSession on a layer id that isn't in the project now resolves false
straight away instead of loading and activating the GeoEditor first. The
docs now say the session left open after overlapping starts is the last
call's only when that call succeeds.

* fix(plugins): report a GeoEditor activation that throws as activation-failed

PluginManager.activate rethrows when a plugin's activate() throws, so
startEditSession rejected instead of resolving false as documented, and the
Layers panel's Edit geometry action left an unhandled rejection. The shared
start now reports it as activation-failed, which the panel shows as its
existing error.

* fix(plugins): only report saved for an end recorded with the session

onEditSessionChange now reads saved from an end record only if it was written since the last notification, so a session that ends on a path that records nothing can't inherit an earlier session's saved value on the same layer.

* fix(plugins): don't report a session as saved when its layer is gone

syncEditTargetToStore returned silently when the target layer had been removed, and endLayerGeometryEdit still recorded the session as saved. It now returns whether it wrote the edits, and that is what the end record reports. A comment next to lastGeometryEditEnd states that every end path records before notifying, which onEditSessionChange relies on.

* fix(plugins): isolate session listeners and wait for the sketches restore

A geometry edit listener that threw (a plugin's onEditSessionChange
callback) stopped the rest from being notified, so the Layers panel could
miss a session ending. Each listener now runs on its own, and a failure is
logged.

A session ends and releases its layer before its sketches are restored, so a
start arriving in that gap (from a plugin, while the user pressed Save) ran
alongside the restore. A new session now waits for any restore in progress.

* refactor(plugins): make GeoLibreEditSessionChange a discriminated union

saved is now present exactly on an "ended" change, so plugins narrow on state instead of checking an optional field. The docs example narrows the same way, and the docs say a throwing callback is logged.

* fix(plugins): deliver each edit-session change to a callback separately

On a switch, onEditSessionChange delivers the old session's end and the new one's start in one notification. A callback that threw on the end never got the start, and its bookkeeping had already moved on, so that start was lost. Each change is now delivered on its own, and a throw is logged.

* fix(plugins): stop queued edit-session starts waiting forever

Starts run one at a time, so a start that never finished (a stalled editor
package load, say) kept every later one waiting, including the Layers
panel's Edit geometry toggle. A start that has waited a minute for earlier
ones now gives up with start-failed. A running start is still never
abandoned, and later starts keep waiting for it, so two starts never run at
once.

* fix(plugins): report a session start that throws as start-failed

startLayerGeometryEdit can reject while it ends the previous session (its write-back or sketches restore), so startEditSession rejected instead of resolving false and the Layers panel skipped its error message. The shared start now logs the error and reports start-failed.
2026-10-14 19:29:55 -04:00
Rob Gaston 5cf70dccd8 feat(plugins): add addVectorTileLayer to the plugin API (#3140)
* feat(plugins): add addVectorTileLayer to the plugin API

Plugins can add raster tile layers but not vector tile layers, so the
GeoLens plugin builds the store record itself and external plugins can't.
addVectorTileLayer takes a tile URL template or a TileJSON URL and the
source layers to draw, and adds the same record Add Data -> OGC Vector Tiles
builds: it appears in the Layers panel, takes the Style panel's controls,
and saves with the project.

Part of #3127. Request headers for authenticated tile services follow in a
separate change.

* fix(plugins): validate addVectorTileLayer's options more strictly

- A missing options object now throws the sourceLayers error rather than a
  raw TypeError.
- A URL with some but not all of {z}, {x} and {y} throws, instead of being
  read as TileJSON and failing when the map loads it.
- opacity must be a number from 0 to 1, and a NaN zoom is rejected.

* fix(plugins): check addVectorTileLayer's extent and TileJSON zoom options

- bounds must lie within WGS84 limits with west <= east and south <= north,
  so MapLibre's addSource can't reject it after the layer is in the store.
- minzoom and maxzoom throw for a TileJSON URL, where MapLibre takes the
  zoom range from the TileJSON; bounds is still kept for Zoom to layer.
- Duplicate source layer names are listed once.

* refactor(plugins): tidy addVectorTileLayer's import and source layer check

Sort the import with the other plugin-* modules, drop the array cast in validateSourceLayers, and note in the docs which tile URL placeholders are recognized.

* fix(plugins): copy addVectorTileLayer's bounds and source layers

The layer record kept the plugin's own bounds array, so a plugin that changed it later changed the stored layer outside the store. Both arrays are now copied. A test also covers a beforeLayerId that isn't a layer: the layer is added on top and the id is kept as a map anchor, as for the other add*Layer methods.

* fix(plugins): reject a blank or non-string addVectorTileLayer name

The name is the Layers panel label and is saved with the project, so a plain-JavaScript plugin passing a blank or non-string value now gets an error like the other options instead of an unlabelled layer.

* test(plugins): cover addVectorTileLayer's URL trimming

A TileJSON URL with surrounding whitespace is now tested to be stored trimmed, and a comment explains why source and metadata get separate copies of the arrays.
2026-10-14 19:28:25 -04:00
Rob Gaston a1c00ad86a feat(plugins): OAuth authorization-code sign-in for plugins (#3139)
* feat(plugins): OAuth authorization-code sign-in for plugins

Add app.getOAuthRedirectUri and app.authorizeInPopup so a plugin can sign
users in with OAuth 2.0 (authorization code with PKCE) without its own
callback page or deep-link handling. The host runs only the round trip: a
popup through oauth-callback.html on the web, the system browser and the
custom-scheme callback on the desktop. The plugin builds the request, keeps
its PKCE verifier and exchanges the code itself.

The popup wait used by ArcGIS sign-in moves to lib/oauth-popup.ts so both
flows share the origin, source and state checks.

Closes #3126

* fix(plugins): refuse a second OAuth sign-in before opening its popup

The popup has a fixed name, so a second authorizeInPopup call during a
sign-in got the first one's popup back from window.open, navigated it away
and closed it, cancelling the first sign-in. The request, the pending flag
and timeoutMs are now checked before window.open, so a second call rejects
with already-pending and leaves the first alone, and an invalid request
never opens a popup.

timeoutMs must be a positive number of milliseconds within setTimeout's
range, or the call rejects with invalid-request instead of timing out at
once. On the desktop, a failure to open the system browser rejects with the
new launch-failed reason rather than a raw error.

* fix(plugins): close the OAuth popup when a plugin sign-in fails

After a timeout, a denial or a bad callback the popup stayed on the
provider's page, and the next sign-in picked it up again by its fixed name.
It is now closed when the sign-in fails.

PluginOAuthFailure is now an alias of the public GeoLibreOAuthFailure rather
than a second copy of the union, and the desktop callback failures map
through a lookup instead of a nested ternary.
2026-10-14 18:42:14 -04:00
Rob Gaston e908594a10 feat(plugins): honor expected-status markers in nativeFetch diagnostics (#3124)
* feat(plugins): honour expected-status markers in nativeFetch diagnostics

Plugins' app.nativeFetch recorded every response of 400 or above as an
error in Diagnostics, even when the plugin expected it, such as the
400 authorization_pending answers an OAuth device sign-in polls through
(RFC 8628). The webview's fetch capture already lets a request mark
expected outcomes with the client-only x-geolibre-expected-status and
x-geolibre-optional-resource headers.

nativeFetch now honours the same markers: listed statuses, and any
failure of an optional request, are recorded as info, and the markers
are stripped before the request goes to the native client. Both markers
are documented in the plugin API guide.

* docs(plugins): document diagnosticRecord and installDiagnosticsCapture
2026-10-13 22:48:12 -04:00
Rob Gaston cc82ef4705 feat(plugins): add updateGeoJsonLayer and removeLayer to the plugin API (#3128)
* feat(plugins): add updateGeoJsonLayer and removeLayer to the plugin API

A plugin that shows data held elsewhere had no way to replace a layer's
features or remove a layer: it could only add layers. To show a remote
service's current data it had to remove the layer through
unregisterExternalNativeLayer, which removes any layer although it's meant
for plugin-registered native layers, and add a new one, losing the layer's
place in the order, group, style and visibility.

updateGeoJsonLayer replaces a GeoJSON layer's features through the store's
updateLayer, as saving a GeoEditor session or refreshing a layer does. It
returns false, changing nothing, for an unknown or non-GeoJSON layer, or one
open in the GeoEditor, so unsaved edits aren't overwritten. removeLayer
removes a layer through the store's removeLayer, as the Layers panel does.

* fix(plugins): clear a native layer's paint bridge when removeLayer removes it

removeLayer removed the store record but left a plugin-painted layer's paint bridge registered, since bridges live outside the store. unregisterExternalNativeLayer already cleared it; removeLayer now does too.
2026-10-13 22:46:50 -04:00
Qiusheng Wu b81b19b630 feat(cesium): recording control facade so plugin panels run on the globe (#3090)
* feat(cesium): recording control facade so plugin panels run on the globe

Port the ArcGIS control host's model to the Cesium globe (#3088). The
MapLibre-shaped facade a plugin control receives on the globe now records
its Style Spec calls into a shadow style instead of throwing: a layer the
plugin mirrors into the store is drawn by CesiumLayerSync, and any other
GeoJSON fill, line, circle or text layer is drawn as ground-clamped globe
entities. Layer-scoped events and queryRenderedFeatures answer from the
mirroring store layer or the overlay graphics, and dragPan/scrollZoom
switch the globe's own camera inputs.

- Move the pieces both hosts share into neutral modules
  (control-layer-events, shadow-overlay, geometry-hit) and share the
  store-mirror lookup (controlLayerMirrors).
- Overlay lines and polygon edges use rhumb arcs, matching MapLibre's
  straight Mercator segments; a geodesic ground polyline spanning the
  globe (a graticule parallel) did not draw at all. Points and labels
  past the horizon are hidden instead of drawing through the Earth.
- Carry text-anchor/text-offset in the overlay's text symbol, honoured
  by both hosts.
- Read a raster record's {level}/{row}/{col} tile template from
  source.url on the globe, as ArcGIS does (Esri Wayback).
- Graticule edge labels anchor to the screen edges on a globe projection.
- getControlMap(app) resolves the globe facade (getCesiumControlMap).

Opt in, each verified in the running app: Gridlines, the grid-factory
DGGS grids (H3, S2, A5, OLC, Geohash, Tilecode), DGGAL, DGGRID, FEMA
NFHL, USGS National Map, US EPA EnviroAtlas, NASA Earthdata and Esri
Wayback.

* feat(cesium): STAC, catalogs and COGs on the globe; guard GPU controls

Continue #3088.

- Refuse custom layers on the globe facade: a recording facade let
  maplibre-gl-raster's control mount on the globe, and the first COG add
  hung the tab. COG adds on the globe now import as store `cog` records
  (native-raster-import.ts, shared with ArcGIS).
- Re-dispatch the mousedown/mousemove/mouseup the browser drops after
  Cesium cancels pointerdown, so drag-drawing controls (the STAC bbox)
  receive the drag.
- Answer getLayer for a store layer's derived `layer-<id>-*` style
  layers and map them to that store layer for picks, so STAC footprint
  selection works on the globe.
- Draw a `wms` record whose tile is an ArcGIS REST export from that
  template rather than sending GetMap to the REST endpoint (Earthdata
  GIS ImageServers).
- Disable the LiDAR and Gaussian splat Add Data entries on the globe,
  where their controls cannot mount and used to do nothing.

Opt in, each verified in the running app: the STAC browsers, Ocean Data
Platform, Earthdata GIS, GeoLens and Hugging Face. Document the globe's
plugin control host.

* Address review feedback

- Reject keyed GeoLens rasters on the Cesium globe as on Mapbox and
  ArcGIS: the API key rides MapLibre's request transform, so a private
  raster added there would 401 on every tile (CodeRabbit). The error
  message no longer names Mapbox alone.
- Replace the per-entity CallbackProperty that hid overlay points and
  labels past the horizon with one pass over them on camera change and
  move end, writing only changed flags, so a dense grid's labels are not
  re-evaluated every frame (Claude review).

* Address Claude review feedback

- Probe both sides of a point for the globe overlay's hit tolerance and
  take the short way round across the antimeridian, so a probe that lands
  in space next to the limb no longer zeroes the tolerance and leaves
  overlay points and lines there unclickable.

* Address Claude review feedback

- Redraw the globe's plugin overlay on a store change only when the
  fields that decide mirroring (layer ids, nativeLayerIds, sourceId,
  sourceIds) change, so an opacity drag or restyle no longer rebuilds
  every overlay entity.

* Address Claude review feedback

- Match a store layer's derived style-layer id only when the whole
  remainder after `layer-<id>-` is a known suffix, so store layer `a` no
  longer claims `layer-a-b-fill` (layer `a-b`'s fill) for picks, mirror
  checks, or getLayer.
- Report a Mercator projection from the globe facade in Cesium's 2D and
  Columbus scene modes, as the docs state, instead of always "globe".
- Fix the test type-check ratchet: an excess-property object literal in
  the mirrorSignature test.
2026-10-10 08:56:09 -04:00
Qiusheng Wu 94af13fc0d feat(plugins): report the GeoEditor from getActiveMapTool (#3029)
* feat(plugins): report the GeoEditor from getActiveMapTool

getActiveMapTool now returns "geo-editor" while the GeoEditor plugin is
active, so plugins that stand down for Identify and feature selection
also stand down while the user draws or edits geometry (including a
layer in "Edit geometry"). onActiveMapToolChange fires when the editor
turns on and off by also subscribing to the plugin manager, where that
state lives. Feature selection and Identify keep precedence.

Adds a lightweight @geolibre/plugins/plugin-ids export so app-api.ts can
read the editor's id without loading the plugins barrel under node.

Fixes #3020

* test: type the map tool events with GeoLibreActiveMapTool

The existing map tools test typed its events array with the old literal
union, which no longer accepts the widened callback type and failed the
test type-check ratchet.

* Address Claude review feedback

- Wrap the getActiveMapTool JSDoc line in types.ts to match the
  surrounding comment width.
2026-10-07 15:20:22 -04:00
Qiusheng Wu 38ed979d3b docs: review the docs against the code and fix drift (#3008)
* docs: review the docs against the code and fix drift

Check every docs page against the current code and correct what no
longer matches: menu paths and labels, plugin placement (most plugins
now open in a docked panel), Cesium ion tokens being optional, desktop
OAuth sign-in for sharing, browser project save/open, project schema
fields, server and collaboration endpoints, Python and MCP APIs, and
stale paths in the source-code deep dives.

Fix the Spatial SQL tutorial's ST_Area_Spheroid query, which needs
ST_FlipCoordinates for lat/lon axis order. Document the Settings
sections that were listed but never described (Language, Appearance,
Interface, Geocoding, AI Providers), and add the USGS NLDI and ArcGIS
feature editing pages to the nav.

* docs: keep the NLDI and ArcGIS editing pages out of the nav

* Address CodeRabbit review feedback

- collaboration.md: give both snapshot-cap defaults, 10 MB for the
  relay and 1 MB in the Docker Compose setup.
- features.md, cesium-renderer.md: without an Ion token the globe still
  gets terrain from keyless Terrarium elevation (or a configured COG),
  not a bare ellipsoid (cesium-engine.ts enableWorldTerrain).

* Address CodeRabbit review feedback

- project-format.md: type blankBackgroundColor and selectedLayerId as
  string | null, matching ProjectFile in packages/core/src/types.ts.
2026-10-06 19:03:19 -04:00
Daniel Hofbauer c802b78ac5 feat(plugins): expose active map tool state (#2966) 2026-10-06 00:06:11 +02:00
Daniel Hofbauerandpre-commit-ci[bot] 9395772216 feat(plugins): make WFS feature limit configurable (#2963)
* feat(plugins): make WFS feature limit configurable

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* style(plugins): separate WFS option doc blocks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-05 21:11:31 +02:00
Qiusheng Wu a8ca595a73 feat(s3): keep the S3 Browser open across project loads, open it at startup (#2941)
* feat(s3): keep the S3 Browser open across project loads, open it at startup

Opening a project from the S3 Browser closed the panel: restoring the
loaded project's plugin state deactivates every plugin the project does
not list. Plugins can now declare `sessionScoped`, which a project load
or a map swap leaves running and which is never saved in the project's
active plugins. The S3 Browser declares it.

Settings > Startup gains "Open the S3 Browser at startup".

* Address Claude review feedback

- Warn when the startup activation of the S3 Browser returns false, not
  only when it throws, matching the deep-link activation path.
- Sort the useStartupPanels import after usePluginStateRestore.
2026-10-04 23:15:04 -04:00
Daniel HofbauerandQiusheng Wu fff0083a04 docs: document deployment policy cutover (#2900)
* docs: document deployment policy cutover

* docs: address deployment policy review feedback

* docs: clarify legacy profile lock field

* docs: drop dated release note and part numbers from availability

* docs: restore dropped headings and list spacing

* docs: separate Docker and desktop availability remedies

---------

Co-authored-by: Qiusheng Wu <giswqs@gmail.com>
2026-10-04 10:25:41 -04:00
55ce70447f feat(identify): skip WMS layers marked queryable="0", show WMS exceptions as errors (#2890)
* feat(identify): skip WMS layers marked queryable="0", show WMS exceptions as errors (#2887)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

* fix(identify): treat a WMS exception sent with an error status as an error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

* docs(plugin-api): document the WMS queryable option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

* fix(identify): send WMS GetFeatureInfo in the layer's CRS (#2889)

* feat(identify): skip non-queryable WMS and report exceptions

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(identify): fall back to EPSG:3857 when the projection cannot convert the click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJ1vRuz1nnmmsXQfZ2c3ZH

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Hofbauer <hofbauer.d@icloud.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-04 08:20:43 -04:00
Qiusheng Wu 94fb172104 feat(i18n): complete the catalogs and let plugins ship translations (#2893)
* feat(i18n): complete the catalogs and let plugins ship translations

Fill every key missing from the 20 non-English catalogs (the USGS 3DEP
panel, the newer Manage Plugins policy and registry-trust strings, the
keychain wording and the 3D model export), and translate the
identical-to-English values in de/nl/id that were genuinely
untranslated prose. All insertions are additive; no catalog is
reordered.

tests/i18n-catalogs.test.ts now fails when a bundled locale lacks a key
that en.json has (plural keys compared by base), replacing the
report-only coverage log, so new UI strings ship with translations.

Plugins can register their own per-locale strings through
app.registerTranslations (plugin.<id>.* keys only, never overriding the
host catalog). Built-in plugins get createPluginTranslator and
pluginDisplayTitle from @geolibre/plugins. The elevation profile, 3D
Tiles key toggle, GeoAgent Earth Engine sign-in, annotation element
list and point cloud annotation stop hard-coding English DOM text, and
15 docked plugin panels title their header from the translated plugin
name.

Part of #2858

* Address review feedback

- Export pluginDisplayTitle from @geolibre/plugins, as docs/plugin-api.md
  promises (Claude, CodeRabbit).
- Elevation Profile: title its panel with pluginDisplayTitle and reuse
  interpolatePluginText for the English fallback instead of a second
  regex (Claude, CodeRabbit).
- 3D Tiles: re-label the API-key toggle and the layer visibility
  checkboxes on a language change instead of only at build time
  (CodeRabbit).
- registerTranslations test: assert on Object.prototype and the i18next
  store, so it can catch a real prototype-pollution regression (Claude).
2026-10-04 07:39:47 -04:00
Qiusheng Wu 7099ceb16a feat(plugins): honor the plugin registry's blocklist (#2873)
* feat(plugins): honor the plugin registry's blocklist

The hosted plugin registry now publishes blocklist.json beside
plugin-registry.json (opengeos/geolibre-plugins#77): plugins, or single
bundles of them, that its maintainers have pulled. Removing a plugin
from the registry only stops new installs; the blocklist also stops
installed copies, which load from their saved manifest URL.

- plugin-blocklist: fetch blocklist.json next to the configured
  registry once per session (4 s timeout), cache it for offline starts,
  and fail open: with no copy, nothing is blocked. Malformed entries are
  dropped individually.
- evaluatePlugin: deny a plugin blocked outright ("blocklisted", with
  the reason) for every external source; bundled drop-ins are exempt.
  All install and load paths already go through this gate.
- external-plugins: refuse a URL bundle whose hash is blocked, both on
  load and on Update, before its code runs.
- External plugin loading waits for the blocklist first.

* fix(plugins): address review feedback on the blocklist

- plugin-blocklist: read the response through readBodyWithCap (now
  exported from plugin-registry with a label), so the 1 MB cap is
  enforced while streaming instead of after buffering the whole body.
- Wait for the blocklist before an Update and before both zip install
  paths, not only before startup loading, so nothing is checked against
  a list that is still loading. The desktop zip install now also reads
  the manifest id when the blocklist has entries.
- external-plugins: hash each startup bundle once and pass the hash to
  verifyPluginBundleIntegrity, which accepts a precomputed hash.
- ensurePluginBlocklistLoaded never throws: a registry URL that can't
  be resolved means nothing is blocked.

* fix(plugins): scope the cached blocklist to its registry

- plugin-blocklist: store the blocklist URL with the cached copy and use
  the cache only for the same URL, so switching registries never applies
  one registry's blocklist to another's plugins while offline.

* fix(plugins): treat a malformed blocklist as a failed fetch

- plugin-blocklist: a 200 response without a "blocked" array no longer
  replaces the active list or the cache with an empty one; it falls back
  to the cached copy like any other failure. A 404 still means the
  registry has no blocklist.

* fix(plugins): address review nits on the blocklist parser

- plugin-blocklist: cap the number of valid entries rather than raw ones,
  so malformed items can't push valid entries past the limit.
- plugin-blocklist: key single-bundle blocks with JSON.stringify([id,
  hash]) so the pair is unambiguous whatever an id contains.

* fix(plugins): keep a cached blocklist through 404s; block bundles in zips

- plugin-blocklist: a 404 keeps a cached list for that registry and
  never overwrites the cache, since a registry that published one is
  likelier mid-deploy than to have withdrawn it. A registry that never
  published a blocklist still blocks nothing.
- external-plugins: assertBundleNotBlocklisted() hashes a bundle and
  refuses a blocklisted release. Zip and plugin-directory bundles are
  now checked at startup, by the web zip install, and by the desktop zip
  install before it persists the archive; URL bundles already were.
- docs/plugin-api.md: hash blocks apply to every external source.

* fix(plugins): one translated denial for every blocklist block

- plugin-policy: blocklistedDecision() builds the "blocklisted" denial.
  The single-bundle checks (URL load, Update, zip and directory bundles)
  now raise PluginPolicyError with it instead of plain English errors,
  so the UI shows the same translated managePlugins.policyBlocklisted
  message as for a whole-plugin block.
- plugin-blocklist: remember which blocklist URL the active list came
  from; a failed fetch with no cache keeps that registry's own list but
  drops another registry's.

* perf(plugins): skip bundle hashing when the blocklist is empty

- external-plugins: assertBundleNotBlocklisted returns early when no
  plugin or bundle is blocklisted, so zip and directory bundles aren't
  hashed on every scan in the usual case.
2026-10-04 07:36:13 -04:00
Qiusheng Wu d625dcbe4a fix(desktop): pin the CSP's jsDelivr script-src to versioned paths (#2875)
* fix(desktop): pin the CSP's jsDelivr script-src to versioned paths

The desktop CSP allowed scripts from all of cdn.jsdelivr.net/npm/ and
/pyodide/. Replace those with one version-pinned path per package the
app actually executes from the CDN: Pyodide, PGlite and its PostGIS
extension, onnxruntime-web, the duckdb-wasm worker that the components
DuckDB converter loads, and the duckdb-wasm, sql.js, geojson-vt and
vt-pbf URLs maplibre-gl-vector hard-codes (plus the modules jsDelivr's
/+esm bundles for them import).

tests/tauri-csp.test.ts re-derives every path from its owner (lockfile,
PYODIDE_VERSION, ORT_VERSION, maplibre-gl-vector's dist) so a bump that
moves a URL fails CI instead of the packaged app.

'unsafe-eval' stays: maplibre-gl-vector calls new Function() at module
scope in the startup graph, and the packaged app boots to a blank window
without it. docs/maintenance.md records that, the other eval consumers,
and why connect-src keeps https: and http:.

Part of #2858.

* Address review feedback

- docs/maintenance.md: scope the security claim to what path pinning
  does (blocks other packages and versions), and say that it does not
  verify content at an allowed path or survive redirects (CodeRabbit).
- docs/maintenance.md: fix the garbled DuckDB converter table row
  (Claude review).
2026-10-04 00:35:00 -04:00
Qiusheng Wu 232627700d feat(plugins): verify registry installs and updates against the published bundle hash (#2857)
* feat(plugins): verify registry installs and updates against the published bundle hash

The hosted plugin registry now publishes a bundleSha256 for each plugin:
the hash computePluginBundleHash produces for the bundle that was
reviewed (opengeos/geolibre-plugins#71). Use it so a registry install no
longer trusts whatever its URL serves first.

- plugin-registry: parse bundleSha256 (lowercase hex SHA-256, otherwise
  ignored) and add pinRegistryEntryBundle.
- Installing from Manage Plugins or a ?plugin= deep link pins the
  announced hash before the URL is installed, so the existing load-time
  check holds back a download that differs.
- The Update action takes the entry's hash, hashes the download before
  importing it, and refuses a mismatch without evaluating the code.
  The computed hash is reused for the new pin.
- Entries without bundleSha256 keep trust-on-first-use.

* fix(plugins): warn when a registry bundleSha256 is malformed

- plugin-registry: log a console warning when an entry carries a
  bundleSha256 that isn't 64 lowercase hex characters, so a registry
  typo that silently falls back to trust-on-first-use gets noticed.

* Address CodeRabbit review feedback

- Reload coalescing in reloadExternalUrlPlugin keyed the in-flight
  promise on the manifest URL alone, so an update started mid-reload
  with a different registry hash (or version) received the first
  call's result without its own hash ever being checked. The in-flight
  entry now records its expectedVersion/expectedHash; only a matching
  call shares it, and any other waits for it to settle and then runs a
  reload that checks its own expectations. Covered by a new concurrent
  update test that fails without the fix.
2026-10-03 22:36:01 -04:00
Qiusheng Wu 6327719397 fix(plugins): close credential-store reads before external plugins load (#2861)
* fix(plugins): close credential-store reads before external plugins load

External plugins are imported with the app's own privileges and can call
any Tauri command, so secure_store_get_many handed every saved token
(Settings tokens, Share refresh token, PostGIS DSNs, other plugins'
app.credentials values) to any installed plugin.

The app already reads every account in one call during startup
hydration, before it renders and therefore before any external plugin
is imported. Make that the only read: secure_store_get_many now answers
once per page load of the calling webview (the read is used up even
when it fails), the page-load hook reopens it when a webview starts a
new document, and a new secure_store_seal command closes reads without
reading. Hydration now always makes its read, and importExternalPlugin
seals before evaluating any plugin code, refusing to load the plugin if
sealing fails. Writes and deletes stay open, since the app saves
credentials whenever Settings change and a write discloses nothing.

This is a narrowing, not isolation: plugins still share the JS realm,
so values already hydrated into memory remain reachable. The docs say
so.

Part of #2858

* Address Claude review feedback

- readSecureCredentials now marks reads sealed only after the read
  succeeds. A failed call may never have reached the native command (an
  IPC transport error), so it no longer short-circuits the
  sealSecureCredentialReads backstop before external plugins load; the
  Rust-side seal is idempotent, so the extra call is harmless when the
  read did claim the gate. Adds a test for the failed-read path.
2026-10-03 21:53:57 -04:00
Qiusheng Wu 5773b71463 feat(layers): show a service layer's source in its Metadata dialog, and let plugins add metadata (#2856)
* feat(layers): show a service layer's source in its Metadata dialog

The Metadata dialog showed only layer.metadata, the name, the type and
sourcePath, so a WMS layer listed little more than "service": "wms".
It now also shows the service address and request fields GeoLibre keeps
on the source of WMS, WMTS, XYZ and the other tile and service layers
(layers, styles, format, version, CRS), with inlined data left out.
The whole payload is scrubbed with the same credential rules a saved
project uses, since the dialog offers Copy.

WMS sources now record the request CRS, from both Add Data and the
plugin API.

Plugins can pass an optional metadata object to addTileLayer,
addWmtsLayer, addWmsLayer and addWfsLayer, merged into the layer's
metadata, so a catalogue plugin can leave a layer's provenance with it.

Fixes #2855

* Address review feedback

- Scrub the metadata, source summary, source URLs and sourcePath one by
  one, so a GeoJSON-looking `type` in the metadata cannot make the whole
  payload skip redaction.
- Keep only HTTP(S) URLs under the source's data/geojson keys and always
  omit kerchunkRefs, so inline text never lands in the dialog.
- Reject plugin metadata whose toJSON() serializes to a non-object.
2026-10-03 19:19:49 -04:00
Qiusheng Wu 59dd8cdc3e feat(plugins): let users move a plugin's menu out of the toolbar (#2853)
Each installed plugin that registered a top-level toolbar menu now has a
"Show menu in toolbar" switch in its Plugins → Installed entry. Turning
it off takes the plugin's menus out of the banner and renders them as
submenus inside that entry instead, so the user, not the plugin,
decides what occupies the menu bar. The choice persists per plugin in
the desktop settings (foldedPluginMenus). Built-in plugin menus are
unaffected.

Part of #2850.
2026-10-03 18:09:31 -04:00
Qiusheng Wu d24528d84a feat(plugins): let plugins add items to the built-in toolbar menus (#2852)
* feat(plugins): let plugins add items to the built-in toolbar menus

Add app.registerMenuContribution(), the GeoLibre counterpart of QGIS's
addPluginToVectorMenu: a plugin adds items to Add Data, Processing or
Controls instead of claiming a top-level menu of its own. The host nests
each plugin's items under a submenu named after the plugin, after a
separator at the end of the target menu, sorted alphabetically, so a
plugin cannot inject loose items or reorder the built-in entries.

The PluginManager injects the owner id and name the same way it tags
toolbar menus. An unknown target menu warns and is ignored rather than
failing activation, so plugins written for a newer host still load.
Contributions follow the plugins:install gate and are hidden in the
viewer.

Part of #2850.

* fix(plugins): release menu contributions with their plugin and skip malformed items

The PluginManager now drops a plugin's menu contributions wherever it
already drops its assistant tools (deactivate, unregister, failed
activation), so a plugin that forgets to dispose of them leaves nothing
behind. Replacing another plugin's contribution id warns, and the shared
menu renderer skips null items and submenus without an items array
instead of throwing.

* fix(plugins): only accept menu contributions from live plugins

A restore callback can run for a plugin that stays inactive
(applyProjectState); a contribution registered there would linger,
since owner cleanup runs on deactivation. The scoped registrar now
ignores contributions unless the plugin is active or activating.

* fix(plugins): skip menu contributions with nothing renderable

A contribution whose items are all malformed or separators would group
into a plugin submenu that opens to nothing once renderItems drops them.

* fix(plugins): scope unregisterMenuContribution to the calling plugin

A plugin's scoped unregisterMenuContribution now removes only
contributions that plugin registered, so one plugin cannot take
another's menu items away. The host can still remove any id.

* fix(plugins): build a plugin scope when only unregisterMenuContribution exists
2026-10-03 17:49:25 -04:00
Qiusheng Wu ec4722c2a7 feat(plugins): group external plugins in a Plugins → Installed submenu (#2851)
Plugins loaded from the registry, a zip, a manifest URL, or a bundled
drop-in were appended to the flat Plugins menu, which had grown long.
They now live in one "Installed" submenu at the bottom of the menu,
sorted alphabetically by display name, with a Manage Plugins… shortcut
at the end. The submenu only appears when an external plugin is loaded.

The Web Services sort moves into a shared sortPluginsByDisplayName
helper used by both submenus.

Part of #2850.
2026-10-03 20:29:59 +00:00
Qiusheng Wu 661fd5c791 feat(plugins): give desktop plugins a native fetch with a cookie jar (#2848)
* feat(plugins): give desktop plugins a native fetch with a cookie jar

The Data to Science plugin signs in with a session cookie. In the desktop
app the page runs at tauri://localhost, so the cookie D2S sets is
third-party, and WKWebView on macOS drops it: the login returns 200 and
the next request returns 401, shown as "Session expired". Plugins had no
other route: the host only offered a GET-only fetchArrayBuffer, and the
Tauri HTTP plugin is scoped to a fixed host list.

Add app.nativeFetch on the desktop. It has the fetch signature and runs
through a new plugin_http_request command: any method and body, no CORS,
a cookie jar kept for the app session, Set-Cookie kept out of
JavaScript, the existing SSRF guard (link-local and cloud-metadata
addresses refused), 64 MiB bodies, a 120 s timeout, and AbortSignal
cancellation. Requests appear in Diagnostics with credentials redacted.

The ArcGIS transport's async client setup (SSRF resolver, enterprise
CAs, mTLS identity) moves into a shared guarded_async_client_builder so
both transports use it.

Refs #2837

* Address review feedback

- Check literal-IP redirect hops against the SSRF block list. The
  guarded DNS resolver re-checks hostnames, but a literal IP never
  reaches a resolver, so a redirect to 169.254.169.254 was followed
  (Claude review).
- Accept any method token fetch accepts (PROPFIND and other extension
  methods) except CONNECT and TRACE, which fetch forbids too
  (CodeRabbit).
2026-10-03 15:48:47 -04:00
Daniel Hofbauerandpre-commit-ci[bot] ada13fce47 feat: expose WFS layer API to plugins (#2826)
* feat: expose WFS layer API to plugins

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix: guard WFS requests across project changes

* fix: hydrate WFS layers when panels are hidden

* fix plugin WFS review findings

* refactor: align plugin WFS with the shared Add Data request path

- Drop the plugin-specific destination/IP/DNS/redirect policy and the
  browser public-IP-literal restriction; validate only absolute HTTP(S)
  URLs, matching Add Data.
- Remove the fetch_plugin_wfs_response command and its Vite proxy route;
  plugin WFS rides fetch_url_response / the WFS dev proxy.
- Keep credential redaction in native diagnostics, now applied to every
  native command.
- Restore fetch_url_bytes in the Tauri invoke handler.
- Hoist refresh state into LayerRefreshProvider so WFS hydration runs
  outside the conditionally mounted layer panel.

* refactor: keep plugin WFS on the existing Add Data path

- Revert vite-proxy-guard.ts to main: the PR-added per-address IPv6
  transition checks and the leftover userinfo-stripping indirection had no
  place in the browser/dev-proxy path once the plugin-only policy was dropped.
- Desktop: keep the resolved-address checks in one place, the generic
  build_guarded_http_client_with_redirects; drop the single-caller generic
  resolver the plugin command needed.
- Drop the LayerRefreshProvider hoist; the shell owns useLayerRefresh and
  passes it to LayerPanel as before, removing the shell-wide re-indent.
- Drop the unused isCredentialUrlParam export; diagnostics redaction is
  unchanged.

* fix: review findings on the plugin WFS path

- Save a credential-free WFS reference with a `wfsCredentialsRedacted`
  marker: the embedded collection is the only copy a sanitized URL can
  render, and a later save of the reopened project could no longer tell
  that and dropped it.
- Read diagnostics credential names from @geolibre/core's URL registry
  (`isCredentialUrlParam`) instead of a second, narrower list, and drop
  the redundant text pattern.
- Drop a URL fragment before building the GetFeature request: the
  fragment swallowed the appended operation parameters.
- Only `source.url` makes edited WFS geometry restorable; a stray
  `metadata.originalUrl` (a tile-layer field) could let a no-embed save
  discard features nothing can re-fetch.
- Document that a plugin bbox must not cross the antimeridian.

* docs: clarify credentialed WFS project saves

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-03 13:48:54 -04:00
Qiusheng Wu 36c144e51f feat(plugins): let registry plugins keep project state through credential stripping (#2836)
* feat(plugins): let registry plugins keep project state through credential stripping

A registry entry can now declare publishableSettings (true or a list of
top-level keys). The host feeds those to the credential redaction so the
plugin's state survives Strip credentials and shared exports. Built-in
allowlists cannot be widened, and kept state is still swept for credentials.

* Address review feedback

- Gate the registry prefetch on the plugins:install capability like the marketplace
- Note that the Python mirror does not know registry-declared settings

* Address review feedback

- Ignore registry publishableSettings for built-in plugin ids
- Update the earlier plugin-state paragraph in the plugin API docs
2026-10-03 10:37:36 -04:00
Daniel Hofbauer b378efc5c9 feat(plugins): expose host proj4 to external plugins (#2824)
* feat(plugins): expose host proj4 to external plugins

* docs(plugins): recommend private proj4 CRS definitions
2026-10-03 07:45:56 -04:00
815985a912 plugins: move Hugging Face, Mapillary and God's Eye View tokens to app.credentials (#2793)
* plugins: store Hugging Face, Mapillary and God's Eye View tokens via app.credentials (#2729)

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* plugin-credentials: keep legacy-key cleanup from failing a landed write

* Update apps/geolibre-desktop/src/lib/plugin-credentials.ts

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* plugin-credentials: keep the session override when legacy cleanup fails

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-01 17:19:39 -04:00
Daniel Hofbauerandpre-commit-ci[bot] 5786f9741e feat(plugins): add app.credentials API backed by the OS keychain on desktop (#2778)
* feat(plugins): add app.credentials API backed by the OS keychain on desktop (#2729)

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(plugins): make desktop credential set/delete retry-safe

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-01 08:13:20 -04:00
Andrea BorrusoandClaude Opus 5.5 54305ad477 feat(plugins): add importLayerStyle to the plugin API (#2702)
* feat(plugins): add importLayerStyle to the plugin API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): import styles only into vector layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plugins): point the style module comment at both siblings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:35:51 -04:00
Andrea BorrusoandClaude Opus 5.5 6c0e453f3c feat(plugins): accept an optional crs in addWmsLayer (#2701)
* feat(plugins): accept an optional crs in addWmsLayer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): normalize the WMS version inside normalizeWmsCrs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:34:38 -04:00
Qiusheng Wu 27aad9ddd8 feat(arcgis): run MapLibre-control plugins on the ArcGIS renderer (#2683)
* feat(arcgis): run MapLibre-control plugins on the ArcGIS renderer

Most Plugins-menu entries were greyed out on ArcGIS because their
controls talk to a MapLibre style the SDK does not have. Give the
control facade a style that controls can use there:

- shadow-style.ts records addSource/addLayer/setPaintProperty/...
  with MapLibre's contract (errors as events, getStyle read-back).
- A layer the plugin mirrors into the store is drawn by the engine
  from that record; any other GeoJSON layer (grids, selection
  outlines, draw previews) is drawn in one graphics overlay, with
  filters and paint evaluated per feature by the style spec
  (style-layer-evaluator.ts in @geolibre/core).
- Layer-scoped events and point queryRenderedFeatures answer from
  whichever of the two draws the layer; dragPan.disable() stops the
  view panning so box drawing works; mousedown/mouseup are fired.
- getArcgisControlMap / getControlMap(app) hand the facade to docked
  panels and plugins.

Enabled on ArcGIS: the Web Services WMS catalogs (FEMA, NASA
Earthdata, EnviroAtlas, National Map), Esri Wayback, the eight DGGS
grids and the Graticule, STAC (plus Planet Open Data and Portolan),
OpenAerialMap, Vantor, Fields of the World, Satellite Embeddings, IGN
LiDAR HD, GeoLens, Earthdata GIS, Hugging Face, Source Cooperative
and Natural Earth.

Also fixes two pre-existing ArcGIS bugs found on the way: the engine
stacked store layers upside down (the store is bottom to top, as the
Layers panel and MapLibre's sync read it), and a WMS layer never drew
under the dev server because its proxied template hid the LAYERS
parameter from the WMSLayer parser.

* fix(arcgis): address review on the plugin control facade

- Shadow style: an unknown beforeId in addLayer/moveLayer reports an
  error instead of appending; a kept source view merges into the
  recorded spec and is inert after removeSource.
- Layer events: layer-scoped on returns a Subscription, a
  listener-less once resolves with the next hit, off with the
  original listener removes a once, mousedown/mouseup dispatch, and
  negative numeric feature ids stay numbers.
- Control host: no overlay redraw after destroy().
- IGN LiDAR HD: the unsupported-renderer message names ArcGIS in
  every locale, with a note tying the denylist to `engines`.

* fix(arcgis): allowlist point-cloud renderers; mirror check without a source

IGN LiDAR HD refuses any renderer not known to draw lidar-url layers
again (now MapLibre, Mapbox and ArcGIS), so a renderer added later is
not assumed to. The control host consults the store mirrors for a
layer whose source it cannot resolve, since a mirror can match by id.

* docs(arcgis): describe the control map's overlay; reattach moved JSDoc

getControlMap and getArcgisControlMap still said a grid or highlight
would not show on ArcGIS, which the overlay in this PR now draws. Two
doc blocks had been separated from their methods by the inserted
code, and a held shadow source's serialize() now reads the live
entry.
2026-09-26 08:54:25 -04:00
Qiusheng Wu 0f39d5be00 feat(plugins): add a Fields of the World plugin (#2612)
* feat(plugins): add a Fields of the World plugin

Adds Plugins > Web Services > Fields of the World, which visualizes,
searches, and downloads the FTW global agricultural field boundaries
from the same Source Cooperative files the FTW inference app reads:

- Field boundaries: the 2025 or 2024 global PMTiles archive as a
  regular pmtiles store layer, colored by confidence_mean with the FTW
  app's red-to-green ramp (expression mode, editable in the Style
  panel).
- Field density: the global 500 m density COG, kept at the current
  view (new optional addCogLayer zoomTo) and hidden above zoom 12.
- A confidence threshold slider (70% by default, mapped onto the
  model's 0-0.578 range like the FTW app) that writes each FTW field
  layer's filterExpression, so it persists with the project.
- Search of the 1-degree download grid by map view or drawn box, with
  footprints in the Layers panel and click-to-select.
- Per tile: Add to map, source GeoParquet, or GeoJSON, optionally
  clipped to the search area. Tiles are read one row group at a time
  (the largest hold ~3M fields in ~123k-row groups), so a small clip
  of a huge tile no longer exhausts the tab's memory.

* Address review feedback

- Mark the field-density COG with a plugin-owned metadata key instead of
  overwriting its sourceKind: the raster sync, Style panel and Mapbox COG
  path key on "maplibre-gl-raster" (CodeRabbit).
- Check the abort signal before addCogLayer, and finish configuring the
  layer (marker, maxZoom, attribution) even when the task is aborted
  while it loads, so no orphaned density layer is left (Claude,
  CodeRabbit).
- Report a cancelled save dialog as cancelled instead of "Saved":
  saveBlob now returns whether the host saver wrote the file
  (CodeRabbit).
- Build the GeoJSON Blob from per-feature strings so a large export does
  not exceed V8's maximum string length (CodeRabbit).
- Search a map view crossing the antimeridian as two boxes instead of the
  whole world; the grid search and clipping split such boxes (Claude).

* Address review feedback

- Disable the Field density button while a task runs, like the row
  actions, so a second click during addCogLayer cannot pass the
  duplicate check before the first layer lands (Claude, CodeRabbit).
2026-09-23 20:13:58 -04:00