Skip to content

Rewrite desktop on maplibre-native-ffi - #834

Merged
sargunv merged 152 commits into
mainfrom
desktop-ffi-rewrite
Aug 8, 2026
Merged

Rewrite desktop on maplibre-native-ffi#834
sargunv merged 152 commits into
mainfrom
desktop-ffi-rewrite

Conversation

@sargunv

@sargunv sargunv commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Description

Replaces the desktop JNI integration with the org.maplibre.nativeffi Kotlin Multiplatform bindings, bringing desktop to near-parity with mobile and laying the foundation for Android and iOS to share the same maplibre-native integration layer in the future.

The line count may seem unexpectedly positive, since we're getting rid of our C++ integration. But of the +11k, about 8k is new test coverage we never had on the old stack, and the code we're getting rid of was missing a lot of desktop functionality that's now implemented.

Bigger reductions will happen when the android and ios (and web?) sides drop their bespoke implementations and build on the same mlnFfiShared source set, and then when the owned facade types are replaced with exposing mln-ffi types directly.

Test plan

200 new desktop tests, including headless tests with a fake map host and real rendering tests.

Also a new demo app figure that boots the desktop app under compose-glfw for better native fidelity, and proves the custom host (no reflection) path

How it works

MapLibre renders into a texture that Skia draws as part of the Compose scene, so the map composites like any other composable—overlays, alpha, and transforms apply to it. This requires a bridge between the two graphics APIs (MapLibre supports OpenGL, Vulkan, Metal, while Compose uses OpenGL, D3D, and Metal) and reflection into Compose internals to grab the gpu context we need. An alternative glfw-fixture/ is a second demo-app on a non-AWT Compose host, which shows how this can be done without reflection as long as a custom compose host exposes its graphics context.

Caveats

  • Uses Compose internals, because Compose does not yet expose a way to access the graphics context, and that's the only way to composite the map into the compose scene on desktop. This means even minor compose upgrades may be breaking changes for us.
  • Requires Java 25, and consumers must pass --enable-native-access=ALL-UNNAMED. The binding is Java 24 bytecode and uses FFM.
  • One runtime per platform, packaged for its OS and architecture. Backend pairs are Vulkan→OpenGL on Linux, Vulkan→Direct3D 12 on Windows, Metal→Metal on macOS. Remaining runtime options will follow.
  • Device location and orientation is out of scope — the FFI does not provide it, so DesktopOrientationProvider remains a stub.

Relevant issues

Partially addresses:

Not yet addressed, but a prerequisite for:

Checklist

To your knowledge, are you making any breaking changes?

Yes. The org.maplibre.compose:maplibre-native-bindings-jni artifact and its OS/architecture/renderer capabilities are no longer published, the desktop target requires Java 25, and consumers must pass --enable-native-access=ALL-UNNAMED.

AI assistance

  • Tools: Claude Code w/ Opus 5 for api design and implementation, Codex with GPT 5.6 Sol as advisor/reviewer
  • Context: The planning doc the agents worked from is visible in the early commits. I reviewed line by line once I was happy with the overall architecture (well, I still didn't pay too close attention to the tests)

sargunv added 30 commits July 25, 2026 00:52
Step 1 of DESKTOP_FFI_REWRITE.md. Removes the desktop MapLibre Native stack
wholesale so the FFI-backed implementation is designed against a clean slate
rather than inheriting JNI assumptions.

Deletes both bindings modules, the maplibre-native and SimpleJNI submodules,
the C++/CMake build, SimpleJNI's KSP wiring, the desktopRenderer/DesktopVariant
native-variant build logic, and the JNI build jobs in CI and release
automation. Desktop keeps only host-independent code; GestureOptions and
RenderOptions lose their MapControls/MapDebugOptions coupling.

Desktop does not compile after this commit, which the plan explicitly allows:
the only error is the missing ComposableMapView actual. Because AGP's KMP lint
and Dokka analyze every target, lint-android and build-docs transitively
compile desktop too, so they join test-desktop and build-desktop-app in
dropping out of all-good until the new implementation lands. The jobs still
run so the branch shows when desktop returns.

In flake.nix, vulkan-loader moves from packages into runtimeLibraries: the
Linux FFI runtime dlopens libvulkan.so.1, which needs LD_LIBRARY_PATH rather
than just build inputs.
The Nix flake existed only to provide a C++ toolchain for building MapLibre
Native on NixOS. Consuming the published FFI removes that need, and the
graphics libraries the runtime loads come from the host system, so delete the
flake rather than trimming it down.

Pin shellcheck in mise so actionlint can check `run:` blocks; it was resolving
to an unversioned shim and failing the pre-commit hook.

Also restore the full all-good gate list. CI does not need to be green while
this branch is in progress, so there is no reason to carry a temporary
exclusion list and remember to undo it.
Step 2 of DESKTOP_FFI_REWRITE.md.

Splits the single jvmTarget property into androidJvmTarget (still 11) and
desktopJvmTarget (25), and raises the shared toolchain to 25. The FFI binding
ships Java 24 bytecode and uses FFM, so desktop cannot go lower; Android
bytecode is unchanged, verified as class file major 55. buildSrc pins 25
separately because it cannot read the root gradle.properties.

Adds the Central Portal snapshot repository scoped to org.maplibre.nativeffi,
catalog entries for the binding and both runtime backends, and the FFI binding
as a desktopMain dependency of the library. The application, not the library,
selects the native runtime.

DesktopHostPlatform replaces the deleted Configuration/DesktopVariant logic as
the single host detector: it maps the host to a natives classifier and the
Metal-on-macOS / Vulkan-elsewhere backend split, and errors rather than
guessing on an unpublished platform.

Adds --enable-native-access=ALL-UNNAMED to the demo application (covering both
`run` and packaged distributions) and to desktop test tasks, and documents it
for consumers running an unpackaged JVM.

checkDesktopFfiRuntime asserts the desktop runtime classpath carries exactly
one binding and exactly one native runtime matching this host, so a
misconfigured runtime fails at build time rather than at map creation.
Match maplibre-native-ffi, which pins zulu-25.34.17.0. Temurin does not
publish a Windows arm64 build, which the desktop machine matrix needs.
Step 3 of DESKTOP_FFI_REWRITE.md, adding the public extension point in
org.maplibre.compose.desktop that keeps map behavior independent of the Compose
host.

DesktopMapHostFactory is the replaceable piece, selected through
LocalDesktopMapHostFactory. A host owns the GPU objects on both sides of the
handoff: it allocates the target MapLibre renders into, synchronizes producer
and consumer, and draws the finished target into Compose. DesktopMapRenderer is
the other side, implemented by the map session.

Render targets are borrowed, backend-neutral handle wrappers rather than
generated FFM types, so the SPI does not leak org.maplibre.nativeffi.render.
DesktopMapExtent carries logical and physical size derived together, since
letting them drift under fractional scaling produces a stretched map.

Backend negotiation is a pure function over the FFI runtime's backends and the
factory's, deliberately free of FFI and Compose types so it can be tested
without a graphics stack. Its diagnostic names the runtime backends, the host
backends, the OS and architecture, and the likely missing dependency.

The default factory is a placeholder reporting that no host exists yet; the
Skiko host arrives with the native bridges. Tests land with the fake host in the
next commit, because desktopTest cannot compile until the ComposableMapView
actual exists.
Read out of the FFI bindings, the C headers, and the working Compose example
before writing DesktopMapSession. Most of this is observable only by reading
native source or by debugging a failure, so it belongs in the plan rather than
in commit archaeology.

The findings that change the design:

- Borrowed-texture render sessions cannot be resized at all. A size or scale
  change means close, replace the texture, and attach a new session. This is
  what DesktopRenderTarget.generation exists to signal.
- renderUpdate() reports "nothing to render" by throwing InvalidStateException,
  the same type as a detached or closed session, distinguished only by its
  diagnostic text. Treating it as an error fails every map on its first frame.
- RenderTargetExtent is logical while the texture is physical, and nothing
  validates the relationship; a mismatch renders garbage rather than throwing.
- The map's pixelRatio is fixed at creation, so moving a window between displays
  of different density requires recreating the map, not re-attaching.
- Closing a map purges its queued events, so teardown must never await one.
- Style load failures arrive only as events, never as exceptions.

Also records four confirmed gaps needing local fallbacks: no visible-region API,
no meters-per-pixel API, no maximum-FPS control, and no animation-completion
signal.
Step 4, reordered ahead of the bridge port: desktopTest cannot compile until
desktopMain does, so the fake host and its tests were unreachable while the
ComposableMapView actual was missing. Desktop compiles again for the first time
since the legacy deletion, and the first desktop tests run.

DesktopMapSession implements both MapAdapter and DesktopMapRenderer, owning the
runtime, map, and render session on one dedicated thread. Nothing native is
created eagerly: the runtime binds to its creating thread, so everything is
built lazily inside render(), which the host already calls on the owner thread.
NativeOwnerThread is re-entrant because every camera mutator requests a frame
while already on the owner thread, which would otherwise deadlock against a
single-threaded executor.

Behavior that follows directly from the native semantics recorded in the plan:

- A target generation or extent change closes the render session and attaches a
  new one, because borrowed-texture sessions cannot be resized. There is no
  map.resize to call; attaching sets the map size from the descriptor extent.
- renderUpdate's "no map render update is available" is matched by diagnostic
  and treated as a skipped frame with the pending bit intact, rather than as an
  application error that would fail every map on its first frame.
- A display scale change recreates the map, since pixelRatio is fixed at
  creation and resizing cannot change it.
- Teardown closes render session, then map, then runtime, each in its own
  finally, since a handle with live children refuses to close and a failed close
  leaves it live.
- Unknown event types are logged rather than failing, because the event type is
  a value class over Int and an FFI upgrade can introduce new ones.

Four gaps get local fallbacks with TODO(maplibre-native-ffi) at the boundary:
visible region projects the four viewport corners, meters-per-dp reimplements
mbgl's formula, maximum FPS throttles renderUpdate, and camera animation
completion uses a generation stamp against superseding transitions.

Rendered feature queries and the style object remain unimplemented and are
marked for step 6.
Collects the rough edges found while building the desktop integration so they
can be fixed upstream rather than left as permanent workarounds here. Each entry
records what MapLibre Compose does today, so the workaround can be removed when
the fix lands.

Entries are marked verified or reported. Verified means confirmed directly
against the snapshot — a compiler error, a grep of the native source, or a run.
Reported means it came from reading the bindings and headers; a few adjacent
claims from that same reading pass turned out to be wrong, so nothing should be
filed upstream unchecked.

The most consequential entries so far: borrowed-texture sessions cannot be
resized and have no re-attach, renderUpdate reports an idle map by throwing,
pixelRatio is fixed at map creation, and a failed runtime close leaves its
thread permanently unable to host another runtime.
The Skiko host bridges MapLibre's render target into Compose's GPU context,
which needs direct Vulkan and OpenGL access. LWJGL provides it, matching the
maplibre-native-ffi Compose example this port follows.

The BOM is dropped in favour of pinning each module to the catalog version.
`platform()` is deprecated for removal inside KMP source-set dependency blocks,
as is `variantOf`, so both the BOM and the natives classifiers are spelled out
directly.

LWJGL names its x64 classifier `natives-linux` where MapLibre Native FFI uses
`natives-linux-x64`, so DesktopHostPlatform now derives both rather than letting
a caller assume they are interchangeable.

checkDesktopFfiRuntime now also asserts the LWJGL natives resolve for this host;
a missing classifier jar would otherwise surface as an UnsatisfiedLinkError deep
in bridge setup rather than as a dependency problem.
Step 5 for Linux. The demo now renders MapLibre's demotiles style: MapLibre
draws into a VkImage whose memory is exported as a file descriptor, Compose
imports that descriptor into its GL context as a texture, and Skia composites
it. Ported from the maplibre-native-ffi Compose example, which is the reference
for this path.

Two bugs in the previous commit had to be fixed to get there, both of which
produced a blank map with no diagnostic:

- DesktopMapSession created its own owner thread, but the host calls render()
  on the host's renderer thread. MapLibre binds its runtime to the creating
  thread, so the runtime was bound somewhere the host never called from and
  every frame failed. The session no longer owns a thread; it dispatches through
  the host's withRendererAccess, which is what the example does.
- DesktopMapSurface set a Failed state without logging it, so the wrong-thread
  failure was completely silent. Failed and Unavailable states now always reach
  the log.

Also fixed: the surface never closed the renderer, leaking the native chain.
The renderer is now closed before the host, since its handles can only be
released from the host's still-live renderer thread.

Compose 1.10.3 spells the Skia canvas accessor `nativeCanvas`; `skiaCanvas` is
the 1.11 name the example uses. All Skiko reflection is confined to
SkikoReflection so a Compose upgrade touches one file.

Windows and macOS report unsupported for now rather than crashing mid-frame;
their bridges are next.
Compose exposes no supported way to reach its graphics context, so the default
host reads it reflectively. That makes a Compose upgrade able to break map
rendering with no compile error and no test failure — it would first appear as a
blank map at runtime, on whichever platform owns the moved member.

SkikoReflectionContractTest fails at build time instead. It starts no map,
creates no GPU resources, and loads no native library, so it runs headlessly on
any platform regardless of which backend that platform uses.

It also answers the question left open in step 3: every reflected member exists
at Compose 1.10.3 / skiko 0.9.37.4, not just every reflected class.
The desktop style implementation uses MapLibre's generic JSON style API, so
every layer, source, filter, and expression crosses this boundary. Centralizing
it is what stops layer setters and query code from growing their own subtly
different encodings.

Two details the tests pin down because getting them wrong is silent:

- Integers stay integral. MapLibre distinguishes 5 from 5.0 for some properties,
  so a zoom stop or index must not widen to a double in transit.
- Object key order is preserved. Layer JSON is assembled key by key and MapLibre
  reads `type` before the properties that depend on it.

An unsigned value past Long.MAX_VALUE is reinterpreted rather than read back
negative, since the C ABI carries uint64_t in a Long's bit pattern.
Desktop is built on MapLibre's generic JSON style API, so every filter and
styled property is encoded here. Ported from the Android encoder rather than
invented: the encodings belong to the style spec, not to a platform, so a
desktop map given the same expression as an Android map has to render
identically.

One deliberate divergence. Android and iOS wrap a map literal as
`{"literal": {...}}`; desktop emits `["literal", {...}]`. Those platforms hand
the result to their SDK's expression parser, while desktop writes raw style
JSON, where `literal` is an operator and the object form would parse as an
ordinary object with a `literal` key. Marked with a TODO to confirm against a
running map and reconcile if Android and iOS turn out to be wrong rather than
merely different.

The colour test pins that Compose quantizes alpha to 8 bits, so a nominal 0.5
encodes as 0.5019608. The encoder reports what Color actually holds, matching
Android.
Completes the default host's backend matrix: Linux bridges Vulkan to OpenGL,
Windows bridges Vulkan to Direct3D 12, and macOS runs Metal on both sides.

Only the Linux path has rendered on real hardware. These two are ported from the
maplibre-native-ffi Compose example and carry TODO(maplibre-compose) markers at
each point the port could not be verified, the sharpest being a hard-coded byte
offset into Skiko's private DirectXDevice struct. They are validated on the
machine matrix in step 9.

The two platforms allocate in opposite directions, which is worth knowing before
debugging either. On Linux MapLibre's Vulkan image is exported as a file
descriptor that OpenGL imports, and the import consumes the descriptor. On
Windows it is Compose's ID3D12Device that allocates, and Vulkan imports the
resulting NT handle — which the import duplicates rather than consumes, so our
copy is closed on every path.

Note on history: MacosMetalHost, MacosObjectiveC, SkikoMetalPresenter, and
SkikoDirect3DPresenter were swept into the preceding commit by a `git add -A`
while these were being written, so they carry that commit's unrelated message.
Left in place rather than rewriting pushed history; this commit is where they
are explained.
Step 6's bulk. Desktop styling is built on MapLibre's generic JSON style API:
sources and layers are live descriptors that accumulate their definition before
being added to a style and write through to MapLibre afterwards, so a layer can
be configured at any point in a composition and is never briefly visible in a
half-configured state.

DesktopStyle binds them to a map and reconstructs base-style sources and layers
as UnknownSource/UnknownLayer. Those reconstructions bind without re-adding:
they are views onto what MapLibre already owns, and attaching them would
duplicate a source or reorder the layer stack.

All 19 families are implemented against the style spec, verified against the
vendored mbgl converters rather than guessed. Notable calls: raster-dem Custom
encoding is emitted as "mapbox" because mbgl rejects anything else and would
drop the whole source; ComputedSource has no style-spec representation at all
and stands in as an empty GeoJSON source so layers naming it still resolve.
Cluster and source-feature queries need RenderSessionHandle, which a source
cannot reach, and are marked accordingly.

KNOWN REGRESSION, documented in DESKTOP_FFI_REWRITE.md: the desktop map no
longer renders. It rendered at 6a5088d. The runtime and map are created with no
error logged, but the frame loop stops after the first frame with both the
renderer thread and the AWT event thread parked idle — a lost wake-up, not a
deadlock or a native failure. Two real bugs found while chasing it are fixed
here and were not sufficient alone: the pump now keeps requesting frames until
MapLibre reports MAP_IDLE, since MapLibre only advances while pumped and pumping
only happens inside a frame; and requestFrame now defers its Compose state write
to a later event-loop turn, because a write made from inside the draw pass that
called it is discarded. Committed rather than dropped so the investigation
starts from a known state.

Also adds a first-frame log, since a blank map is the failure mode with the
least to go on, and pins the Windows and macOS Skiko members the bridges reflect
into after a reviewer found them unasserted.
The desktop map renders at e094615. The failure I recorded there happened only
after the machine had slept, and the same commit renders after a fresh boot, so
the cause was losing the GPU contexts underneath the Vulkan-to-OpenGL sharing
rather than the style work.

Recorded as a suspend/resume finding instead, because it is one: the frame loop
had nothing to wake it afterwards, which is the first concrete evidence that
surface loss needs real handling rather than the current assumption that the
host simply reports a new target generation.

The two bugs found while chasing it were genuine and stay fixed: the pump now
keeps requesting frames until MapLibre reports MAP_IDLE, and requestFrame defers
its Compose state write out of the draw pass that called it.

Also records the style load failure the demo logs — `http: invalid authority`,
the built-in loader refusing a non-HTTP URI — as the first concrete case for the
step 7 resource adapter.
MapLibre's network stack rejects anything that is not HTTP with `invalid
authority`, and Compose hands out `jar:file:` and `file:` URIs for packaged
resources. Any style, sprite, glyph, or tile referenced through Res.getUri
therefore failed to load: the demo logged 16 such failures at startup, and zero
after this change.

The provider intercepts only non-network schemes and passes HTTP and HTTPS
through untouched, so those keep MapLibre's own caching, retry, and
revalidation rather than being reimplemented here.

It is installed during runtime creation, before the map exists, because MapLibre
refuses to replace a resource provider once the runtime owns maps and offers no
way to clear one. Taking a request means owning the handle, so completion and
close happen on whatever worker thread MapLibre called from.

Also restores the first-frame log, which had been silently dropped by an earlier
edit. That mattered: its absence was twice read as evidence that nothing
rendered, when it only ever meant the line was gone. The demo now reports
"Rendered the first map frame with VULKAN".
The cache previously landed in the working directory, which meant it was
invisible to the user, was not shared between runs launched from different
places, and got committed often enough that the repository carried a .gitignore
entry for it. That entry is removed here along with the behavior that needed it.

It now follows each platform's own convention — XDG on Linux, Library/Caches on
macOS, LOCALAPPDATA on Windows — so it lands where a user or an uninstaller
would look. DesktopRuntimeOptions makes the path and the ambient size limit
configurable through LocalDesktopRuntimeOptions; the options are read when the
runtime is created, so changing them recreates the map.

The cache directory is created before the runtime, because MapLibre opens the
database on creation and fails when the directory is missing, which on a fresh
machine it always is.

Verified by deleting the cache and running the demo: it renders its first frame
with no errors and writes ~/.cache/maplibre-compose/maplibre-cache.db.
This decides the shape of desktop offline support, and the plan called for
measuring it rather than assuming. MapLibre binds a runtime to its creating
thread and permits one per thread, so a map owns a runtime on its own thread. An
offline manager usable without a map needs a second runtime on a second thread,
and both would open the same cache file.

They can. Two runtimes on two threads opened the same database and both pumped
without error, so the offline manager can own a runtime of its own and desktop
does not need a process-level runtime service with every map serialized onto one
owner thread — which would have been a far larger change.

The test stays in the suite so a future FFI snapshot that changes this fails
loudly rather than corrupting a user's cache.

Desktop tests now pull a native runtime for this host, since anything reaching
the FFI needs one exactly as an application does. That also unblocks the FFI
integration tests the plan asks for in step 8.
jpackage runs jlink against whichever JDK it is given, so without pinning it the
installed application takes whatever JDK Gradle happened to run on. That can be
older than the Java 24 the MapLibre Native FFI binding requires — a mismatch
that builds cleanly, installs cleanly, and fails when the user opens a map.

checkDesktopDistribution asserts what actually ends up in the image: the bundled
Java version, an FFI native runtime, natives matching this host, and LWJGL
natives. Every one of those failures is silent at build time and fatal at
launch, so CI now runs it before packaging rather than trusting an exit code.

Verified locally: the distributable bundles Java 25.0.3 alongside the
vulkan/natives-linux-x64 runtime and the LWJGL natives.

Note that `packageDeb` cannot run on this machine — jpackage needs dpkg-deb and
fakeroot, which Fedora does not ship by default — so the installer format itself
is only exercised in CI.
Getting started now carries real instructions rather than a placeholder: the
library is backend-independent and the application picks a native runtime, the
same shape as choosing an Android ABI, plus the snapshot repository, the Java 25
requirement, and the native-access argument.

The status table was set to all-unsupported for desktop when the legacy
implementation was deleted. It now reflects what works: HTTP and Compose
resource URIs, gestures, clicks, camera, projection, visible region, layers,
expressions, sources, and images. Feature queries, Material 3 controls, and
offline stay unsupported, which is accurate — queries need a RenderSessionHandle
a source cannot reach today, and offline is still outstanding.

Contributor docs no longer claim desktop does not compile.
The built-in network file source rejects an unrecognized URI with
`invalid authority`, naming neither the URI nor the reason. On Compose Desktop
that is the common case rather than an exotic one, since packaged resources are
`jar:file:` URIs, and the message gives a consumer nothing to act on.
Input was never attached. DesktopMapSession had moveBy, scaleBy, and the rest,
but no Compose pointer or key modifier ever called them, so the desktop map
could not be panned, zoomed, rotated, or pitched at all.

Rotation and pitch now follow the maplibre-native-ffi Compose example: one
jumpTo moving bearing and pitch together, at 0.5 degrees per logical pixel, with
pitch clamped to 0..60. The first attempt drove the FFI's two-point rotateBy,
which derives an angle between two pointer positions for a two-finger gesture —
from a mouse drag it rotates about the wrong centre and fights the separate
pitch change. Scroll zoom is 2^(-scroll * 0.25) rather than a flat multiplier,
since zoom is logarithmic. A new drag cancels any transition still running.

Separately, this restores the pump keep-alive that an earlier edit dropped, and
it explains the style switcher applying only every other change. MapLibre
advances only while pumped, and pumping happens only inside a frame, so a frame
that renders nothing ended the loop: a style switch loaded, failed its first
renderUpdate because parsing had not finished, and then nothing woke the pump.
The next switch's frame was what completed the previous one. The loop now keeps
requesting frames until MapLibre reports MAP_IDLE.

Desktop also drops its own OrnamentOptions and takes the maplibreNativeMain one,
now that desktop depends on that source set. Ornaments are unimplemented on
desktop: MapLibre Native's core has none, and the Material 3 controls are the
direction for this, so there is nothing to forward.
Offline: DesktopOfflineManager runs its own runtime on its own thread, which
SharedCacheDatabaseTest established is safe alongside a map's. Operations are
correlated by handle id against OFFLINE_OPERATION_COMPLETED, and every exit path
— cancellation, disposal, runtime failure — completes or cancels its
continuation rather than awaiting an event that closing the runtime would
discard.

Four bugs found by review and fixed before landing:

- The manager was reference counted and disposed with the composition, so
  navigating away from an offline screen closed the runtime and silently killed
  in-flight downloads; mbgl keeps download state in memory only, so the pack
  came back reporting paused. It is now a process singleton per options value,
  matching Android and iOS.
- The runtime leaked if anything after create threw, because teardown lived in a
  try the failure path never entered.
- setMaximumAmbientCacheSize could never succeed with the default options, since
  the guard compared against a null limit. It now warns instead of throwing,
  which is what cross-platform code calling it at startup expects.
- A TODO claimed an FFI gap that does not exist: mbgl does deliver a status
  event on a real pause. The status read-back is kept for the unchanged-state
  case, with the reason corrected, so this is not filed upstream in error.

Desktop teardown: the surface tore the surface down before closing the renderer,
which left the renderer without the host session it reaches its owner thread
through. Every remaining native call then ran on the disposing thread and was
rejected as wrong-thread, leaking the map and runtime. Closing first fixes it;
the demo now exits with no errors.

A failed layer attach no longer kills the Compose thread, and names the layer and
its source. Native reports only "layer source does not exist", which says
neither.

Demo: desktop now reports every gated feature as supported. LayerStyling works,
and InteropBlending falls out of compositing MapLibre's texture into the Compose
scene. Adds the render options and offline demos, with a desktop render-options
screen exposing MapLibre's debug overlays individually. OfflineManagerDemo moves
to a new maplibreNativeShared source set, mirroring the library, and material3's
offline controls extend to desktop.
Two separate bugs, both of which broke every demo that adds its own layers.

Compose adds a layer to the style before the effect that adds its source runs.
The applier inserts nodes and calls onEndChanges, which is where LayerManager
reaches MapLibre, and only afterwards dispatches remember-observers, where
SourceReferenceEffect lives. MapLibre's mobile SDKs tolerate a layer naming a
source that does not exist yet; the C API rejects it outright with "layer source
does not exist". A layer now attaches its own source first, and Source.attach is
idempotent so the effect's later add is a no-op. The underlying ordering is
fragile on every platform and would be better fixed in the shared layer — noted
in the plan — but desktop is the only one strict enough to notice.

Then symbol layers failed with "layer doesn't support this property". An unset
optional property compiles to a null literal, and the layer JSON carried
"symbol-sort-key": null and "icon-overlap": null. The style spec has no null:
MapLibre rejects the entire layer rather than treating the property as absent.
Null-valued properties are now omitted from the layer object, while still being
pushed to an already-attached layer, where null is how a property is reset.

Found by putting the layer JSON in the failure message, which named the offending
properties directly; the native error says only that some property is
unsupported.

The demo now renders its layer-based demos with no errors.
The map handle is created lazily on the first frame, but MaplibreMap applies the
initial camera position, zoom range, pitch range, and bounding box as soon as the
adapter is handed to it — which happens earlier. Those calls reached a null map
and were dropped, so every map opened at MapLibre's default position instead of
the one the caller asked for.

Setup calls made before the map exists are now recorded and replayed in order as
soon as it is created.

Also turns two silent no-ops into diagnostics. A layer added while its style is
unloaded was dropped without a word, which is indistinguishable from a layer that
was added and did not draw; it now says so. Adds a headless test for the offline
manager, since it owns a runtime, a thread, and a database and none of that needs
a window — a crash on opening the offline screen should be reproducible without
clicking. It passes, so the reported crash is in the UI layer above it.
Implements queryRenderedFeatures for both the point and box overloads
against the render session, converting MapLibre's queried features to the
GeoJSON ones the common API returns. Source id, source layer id, and
feature state ride along as properties, because a common Feature has
nowhere else to carry them.

Rendered feature state belongs to the render session, so none of this can
be tested without a real GPU. FakeDesktopMapHost stops at the graphics
boundary — it hands out invented handles, so MapLibre never attaches a
session and nothing below render() runs. HeadlessVulkanMapHost closes the
gap: a genuine Vulkan device and VkImage with no window, and no external
memory extensions, so it also works on a software implementation.

Two vehicles use it. HeadlessMapFixture drives a real session frame by
frame with no Compose at all; HeadlessVulkanMapHostFactory provided
through LocalDesktopMapHostFactory runs MaplibreMap itself under
runComposeUiTest, so the surface, session, sources, and layers all take
their real paths.

That found the offline demo crash on its first run. An unset filter
compiles to a null literal, which mbgl reads as "match everything", but
the null was being substituted with a scalar true — which mbgl rejects
outright as "filter value must be a non empty array", taking the whole
layer with it. Every layer with a default filter was affected; it only
happened to show up on the offline screen first.
Both were reported as broken in the demo app. Neither reproduces here: a
layer that leaves and re-enters the composition comes back, and the camera
a map is composed with reaches the map even though it is applied before
the map exists. The layer case shared a cause with the filter bug fixed in
the previous commit, which took the whole layer with it on the first add.
Two unrelated bugs, both reported from the demo app.

A style mutation never asked for a frame. MapLibre only advances while its
runtime is pumped, pumping only happens inside a frame, and once the map
goes idle nothing asks for another — so a layer added after that point was
genuinely in the style and simply never drawn, until an unrelated pan or
resize woke the loop. Every source, layer, and image write crosses
SessionStyleBinding.withMap, so the wake goes there rather than at the
dozen call sites where the next one added would forget it. Most mutations
make MapLibre notify us on its own; addSource, removeSource, and
removeImage notify nothing, which is what requestRepaint covers.
cancelTransitions and setRenderSettings had the same gap.

Clicking a cluster zoomed to the whole world because
getClusterExpansionZoom was a stub returning 0.0 and the demo animated to
it. The stub's TODO was wrong about the blocker: queryFeatureExtension
exists, just on the render session rather than the map, because it answers
from what a render pass built. StyleBinding grows a withRenderSession
accessor and the three cluster methods are implemented over it.

The subtle part is that cluster_id has to be re-typed unsigned. MapLibre
looks it up with an exact variant check, so an id encoded as a signed
integer does not fail — the lookup misses and the query returns an empty
result with an OK status. Same for the leaves limit, where a wrongly typed
value is ignored and MapLibre substitutes its own default of ten, which
still returns features and still looks correct. The test asserts limit = 2
returns exactly two leaves, which is the only thing that tells those apart.
The exact-type check is mbgl's contract, and every platform binding adapts
to it: Android casts cluster_id back by hand in two places, and iOS avoids
the id half only by never taking the feature out of C++, while still
casting limit and offset itself. maplibre-native-ffi is the analogous
layer, so that is where this belongs rather than upstream.

Recommends taking a cluster id instead of a whole feature, since mbgl
discards everything else in it and an unsigned parameter makes the mistake
unrepresentable. Our coercion is now marked for removal; only snapshots of
the FFI exist, so there is no released version to keep working against.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4da89c3d17

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 667736a839

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c244778eb0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

sargunv added 4 commits August 7, 2026 18:41
A task that only writes owner-thread state queued nothing for native to
wake the loop's park with, so a gesture end posted to a map at rest
never reached onEventsDrained and the camera move never ended.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8214299482

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 700cb4439e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sargunv

sargunv commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review

@sargunv
sargunv merged commit e2f9fcd into main Aug 8, 2026
21 checks passed
@sargunv
sargunv deleted the desktop-ffi-rewrite branch August 8, 2026 05:26
This was referenced Aug 8, 2026
@sargunv-bot
sargunv-bot restored the desktop-ffi-rewrite branch August 8, 2026 08:35
@sargunv-bot
sargunv-bot deleted the desktop-ffi-rewrite branch August 8, 2026 08:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants