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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
name: Android

# Android builds run on published releases (matching release.yml), not on every
# PR — the Rust cross-compile + Gradle build is slow and rarely needs per-PR
# coverage. Use the manual "Run workflow" button (workflow_dispatch) to build a
# signed APK on demand from any branch.
on:
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true

env:
# Keep these in sync with docs/android.md and the local setup.
ANDROID_PLATFORM: "platforms;android-34"
ANDROID_BUILD_TOOLS: "build-tools;34.0.0"
# NDK r27 LTS — Tauri v2's supported line. Bump deliberately.
ANDROID_NDK_VERSION: "27.3.13750724"

jobs:
build:
name: Build Android APK (debug)
Comment thread
giswqs marked this conversation as resolved.
Outdated
runs-on: ubuntu-22.04
Comment thread
giswqs marked this conversation as resolved.
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
Comment thread
giswqs marked this conversation as resolved.

Comment thread
giswqs marked this conversation as resolved.
- name: Set up Node.js
uses: actions/setup-node@v6
with:
Comment thread
giswqs marked this conversation as resolved.
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json

- name: Set up JDK
Comment thread
giswqs marked this conversation as resolved.
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"

- name: Set up Android SDK
# Third-party action — pin to a commit SHA to match the repo's policy
# for non-official actions before relying on this in protected branches.
uses: android-actions/setup-android@v3
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated

- name: Install Android NDK, platform, and build-tools
run: |
sdkmanager --install \
"platform-tools" \
"$ANDROID_PLATFORM" \
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
"$ANDROID_BUILD_TOOLS" \
"ndk;$ANDROID_NDK_VERSION"
echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/$ANDROID_NDK_VERSION" >> "$GITHUB_ENV"

- name: Install Rust stable with Android targets
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
targets: >-
aarch64-linux-android,
armv7-linux-androideabi,
i686-linux-android,
x86_64-linux-android

- name: Cache Rust dependencies
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: apps/geolibre-desktop/src-tauri -> target

- name: Install frontend dependencies
run: npm ci

- name: Generate Android project
# gen/android is not committed; regenerate it on the clean runner. The
# Tauri CLI is resolved through the geolibre-desktop workspace.
working-directory: apps/geolibre-desktop
run: npx tauri android init

- name: Apply GeoLibre launcher icons
# `tauri android init` writes default Tauri icons; overwrite the generated
# mipmaps with the GeoLibre launcher icons checked in under src-tauri/icons.
working-directory: apps/geolibre-desktop
run: cp -r src-tauri/icons/android/. src-tauri/gen/android/app/src/main/res/

- name: Build release APKs (per ABI)
# Release (not --debug): the size-optimized + stripped Cargo profile keeps
# each .so small. --split-per-abi emits one ~40 MB APK per architecture
# instead of a single ~150 MB universal APK bundling all four ABIs.
# Release APKs are unsigned by default; the next step signs them.
working-directory: apps/geolibre-desktop
run: npx tauri android build --apk --split-per-abi
env:
VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }}

- name: Sign APKs
# With release-keystore secrets set, the APKs are signed for distribution.
# Without them, they're signed with a throwaway debug keystore so the CI
# artifacts are still installable for testing (do NOT publish those).
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
build_tools="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)"
if [ -n "${KEYSTORE_BASE64:-}" ]; then
echo "Signing with the release keystore from secrets."
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
keystore="$RUNNER_TEMP/release.jks"
store_pass="$KEYSTORE_PASSWORD"
key_pass="${KEY_PASSWORD:-$KEYSTORE_PASSWORD}"
alias="$KEY_ALIAS"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret set — signing with a throwaway debug keystore. Installable for testing only, NOT for distribution."
keystore="$RUNNER_TEMP/debug.jks"
"$JAVA_HOME/bin/keytool" -genkeypair -v -keystore "$keystore" \
-storepass android -keypass android -alias androiddebugkey \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
store_pass=android; key_pass=android; alias=androiddebugkey
fi
out="$RUNNER_TEMP/apks"; mkdir -p "$out"
found=0
while IFS= read -r unsigned; do
found=1
# e.g. app-arm64-v8a-release-unsigned.apk -> geolibre-arm64-v8a.apk
abi="$(basename "$unsigned" | sed -E 's/^app-(.*)-release-unsigned\.apk$/\1/')"
aligned="$RUNNER_TEMP/aligned-$abi.apk"
signed="$out/geolibre-android-$abi.apk"
"$build_tools/zipalign" -p -f 4 "$unsigned" "$aligned"
"$build_tools/apksigner" sign --ks "$keystore" \
--ks-pass "pass:$store_pass" --key-pass "pass:$key_pass" \
--ks-key-alias "$alias" --out "$signed" "$aligned"
"$build_tools/apksigner" verify "$signed"
echo "signed $signed ($(du -h "$signed" | cut -f1))"
done < <(find apps/geolibre-desktop/src-tauri/gen/android \
-name '*release-unsigned.apk')
if [ "$found" -eq 0 ]; then
echo "::error::No release-unsigned APKs found"; exit 1
fi

- name: Upload signed APKs
uses: actions/upload-artifact@v4
with:
name: geolibre-android-release-apks
path: ${{ runner.temp }}/apks/*.apk
if-no-files-found: error
retention-days: 14
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@
[![image](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)


A lightweight, cloud-native GIS platform for visualizing, exploring, and analyzing geospatial data across desktop and web environments, with a responsive layout for mobile screens.
A lightweight, cloud-native GIS platform for visualizing, exploring, and analyzing geospatial data across desktop, web, and Android, with a responsive layout for mobile screens.

GeoLibre is built with **Tauri v2**, **React**, **TypeScript**, **MapLibre GL JS**, **DuckDB-WASM Spatial**, and **deck.gl**. The same workspace runs as a native desktop app, in any modern web browser, and adapts responsively to mobile and small screens.
GeoLibre is built with **Tauri v2**, **React**, **TypeScript**, **MapLibre GL JS**, **DuckDB-WASM Spatial**, and **deck.gl**. The same workspace runs as a native desktop app, a native Android app, in any modern web browser, and adapts responsively to mobile and small screens.

[![GeoLibre demo showing 3D Tiles rendered on a MapLibre map](https://files.opengeos.org/GeoLibre-demo.webp)](https://viewer.geolibre.app/?url=https://share.geolibre.app/giswqs/3d-tiles.geolibre.json)

**Video tutorial:** [GeoLibre 1.0: A Free, Open-Source Cloud-Native GIS That Runs Anywhere (Browser, Desktop & Jupyter)](https://youtu.be/87Cm0QagtxI)

## Features (v1.3)

- Runs across desktop (Tauri), web (browser), and mobile or small screens, with a responsive layout that adapts menus, dialogs, and panels, plus per-panel visibility through Layout settings
- Runs across desktop (Tauri), web (browser), native Android (Tauri v2 mobile), and mobile or small screens, with a responsive, touch-friendly layout that adapts menus, dialogs, and panels (on phones the Layers/Style panels overlay the map as slide-over sheets), plus per-panel visibility through Layout settings
- MapLibre map workspace with OpenFreeMap basemaps, blank background support, and toggleable navigation, fullscreen, geolocation, globe, terrain, scale, attribution, and logo controls
- Load local vector layers supported by DuckDB-WASM Spatial, including common formats such as GeoJSON, GeoParquet, GeoPackage, Shapefile, FlatGeobuf, KML/KMZ, GML, delimited text, GPX, and OpenStreetMap PBF extracts (parsed in-browser with osmix)
- Reproject vector layers to EPSG:4326 on load and split dragged GPX files into named waypoint, track, and route layers
Expand Down Expand Up @@ -63,7 +63,8 @@ GeoLibre is built with **Tauri v2**, **React**, **TypeScript**, **MapLibre GL JS
- External plugin zip loading from the app data plugins directory and local development plugin directories
- Bundled drop-in plugins under `public/plugins/<id>/` that bake into both the web and desktop builds and load automatically with no manifest URL
- Browser deployment with Docker, embed-friendly URL parameters, and a `maponly` chrome-free mode
- Installable, offline-capable Progressive Web App (PWA) build
- Native Android app built from the same codebase with Tauri v2 mobile, producing signed, per-architecture APKs (~40 MB) through a GitHub Actions workflow; tools that depend on a local desktop process (Whitebox, Raster, Conversion, AI Segmentation, PostgreSQL/Martin) are hidden on mobile so nothing is shown that cannot run. See [Android](docs/android.md)
- Installable, offline-capable Progressive Web App (PWA) build, plus a **Download Offline Area** tool that pre-caches the current map view's basemap tiles, and service-worker caching of the CDN-loaded Pyodide and PGlite/PostGIS engines so browser SQL and Python keep working offline after first use
- Internationalization framework with react-i18next and per-build translation catalogs, plus a `?locale`/`?lang` query parameter to set the embed language
- Accessibility pass with axe-checked screens, keyboard navigation, and screen-reader labels
- App-wide, section, and plugin React error boundaries that contain failures and keep the rest of the workspace usable
Expand Down Expand Up @@ -378,6 +379,28 @@ Where to find the output:
- **Web build** — static files in `apps/geolibre-desktop/dist/`. Serve this directory with any static web server (or the Docker image above).
- **Desktop installers** — `apps/geolibre-desktop/src-tauri/target/release/bundle/`, with per-platform subfolders: `deb/`, `rpm/`, and `appimage/` on Linux; `msi/` and `nsis/` on Windows; `dmg/` and `macos/` on macOS. The unbundled executable is in `apps/geolibre-desktop/src-tauri/target/release/`. On Linux, `npm run tauri:build` builds `deb` and `rpm` by default; passing `--bundles` replaces that default selection rather than adding to it, so list every format you want, for example `npm run tauri:build -- --bundles deb,rpm,appimage` for all three.

## Android

GeoLibre builds as a native Android app from the same codebase via Tauri v2
mobile. You need the Android SDK + NDK, a JDK (17 or 21), and the Rust Android
targets; see [docs/android.md](docs/android.md) for the full toolchain setup,
signing, and sideloading guide. Once set up:

```bash
cd apps/geolibre-desktop
npx tauri android init # generate the Gradle project (once)
npm run tauri android dev # run on a connected device/emulator
npx tauri android build --apk --split-per-abi # release APKs, ~40 MB per ABI
```

Install the `arm64-v8a` APK on real phones. The CI workflow
(`.github/workflows/android.yml`) builds and signs per-ABI release APKs on each
published GitHub release (and on demand via the "Run workflow" button) and
uploads them as artifacts; set the `ANDROID_KEYSTORE_*` repository secrets
to sign with a real release key (otherwise a debug key is used for testable
builds). Heavy tools that need the Python sidecar or local helper processes are
hidden on mobile.

## Quality checks

Run the fast TypeScript unit tests:
Expand Down Expand Up @@ -521,6 +544,7 @@ Full documentation, including the User Guide and Tutorials, is published at
- **Tutorials** - [hands-on, end-to-end workflows](https://geolibre.app/tutorials/): your first map, cloud-native data, vector analysis, terrain analysis, spatial SQL, and sharing and embedding.
- **Reference**
- [Architecture](docs/architecture.md)
- [Android](docs/android.md)
- [Project format](docs/project-format.md)
- [Plugin API](docs/plugin-api.md)
- [Python package (Jupyter)](docs/python.md)
Expand Down
8 changes: 7 additions & 1 deletion apps/geolibre-desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
<link rel="icon" type="image/png" href="%BASE_URL%favicon.png" sizes="32x32" />
<link rel="icon" href="%BASE_URL%favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover lets the page extend under the mobile status/nav bars
and display cutouts so env(safe-area-inset-*) report their sizes; the app
shell then pads itself by those insets (see #root in src/index.css). -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta
name="description"
content="A lightweight, cloud-native GIS platform for visualizing, exploring, and analyzing geospatial data."
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 16 additions & 4 deletions apps/geolibre-desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ use std::thread;
use std::time::Duration;
use tauri::Manager;

// OAuth popups are a desktop-only, multi-window concept; Android/iOS have no
// equivalent, and `WebviewWindowBuilder::{on_new_window, window_features}` do
// not exist on the mobile runtime.
#[cfg(desktop)]
static POPUP_COUNTER: AtomicU64 = AtomicU64::new(0);

const MARTIN_VERSION: &str = "martin-v1.10.1";
Expand Down Expand Up @@ -1728,17 +1732,25 @@ fn create_main_window(app: &mut tauri::App) -> tauri::Result<()> {
.first()
.cloned()
.expect("GeoLibre Desktop requires a main window config");
let app_handle = app.handle().clone();

tauri::WebviewWindowBuilder::from_config(app, &window_config)?
.on_new_window(move |url, features| {
let builder = tauri::WebviewWindowBuilder::from_config(app, &window_config)?;

// Only desktop opens OAuth flows in child windows; on mobile they navigate
// in-page (or via the system browser), so skip the new-window handler.
#[cfg(desktop)]
let builder = {
let app_handle = app.handle().clone();
builder.on_new_window(move |url, features| {
create_oauth_popup_window(app_handle.clone(), url, features)
})
.build()?;
};

builder.build()?;

Ok(())
}

#[cfg(desktop)]
fn create_oauth_popup_window(
app_handle: tauri::AppHandle,
url: tauri::Url,
Expand Down
7 changes: 7 additions & 0 deletions apps/geolibre-desktop/src-tauri/tauri.android.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GeoLibre",
"bundle": {
"resources": []
}
}
32 changes: 16 additions & 16 deletions apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import {
type CSSProperties,
type DragEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
lazy,
Suspense,
useCallback,
Expand Down Expand Up @@ -878,7 +878,7 @@ export function DesktopShell({
);

const startLayerPanelResize = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
(event: ReactPointerEvent<HTMLDivElement>) => {
Comment thread
giswqs marked this conversation as resolved.
event.preventDefault();
Comment thread
giswqs marked this conversation as resolved.
event.stopPropagation();

Expand All @@ -894,7 +894,7 @@ export function DesktopShell({
document.body.style.userSelect = "none";
window.dispatchEvent(new Event(PANEL_RESIZE_START_EVENT));

const onMouseMove = (moveEvent: MouseEvent) => {
const onPointerMove = (moveEvent: PointerEvent) => {
nextWidth = clamp(
startWidth + moveEvent.clientX - startX,
MIN_SIDE_PANEL_WIDTH,
Expand All @@ -919,9 +919,9 @@ export function DesktopShell({
});
};

const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
if (resizeFrame !== null) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
window.cancelAnimationFrame(resizeFrame);
resizeFrame = null;
Expand All @@ -937,14 +937,14 @@ export function DesktopShell({
document.body.style.userSelect = previousUserSelect;
};

window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
},
[deferPanelResize, layerPanelWidth],
);

const startStylePanelResize = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();

Expand All @@ -960,7 +960,7 @@ export function DesktopShell({
document.body.style.userSelect = "none";
window.dispatchEvent(new Event(PANEL_RESIZE_START_EVENT));

const onMouseMove = (moveEvent: MouseEvent) => {
const onPointerMove = (moveEvent: PointerEvent) => {
nextWidth = clamp(
startWidth + startX - moveEvent.clientX,
MIN_SIDE_PANEL_WIDTH,
Expand All @@ -985,9 +985,9 @@ export function DesktopShell({
});
};

const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = null;
Expand All @@ -1003,8 +1003,8 @@ export function DesktopShell({
document.body.style.userSelect = previousUserSelect;
};

window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
Comment thread
giswqs marked this conversation as resolved.
},
[deferPanelResize, stylePanelWidth],
);
Expand Down Expand Up @@ -1033,7 +1033,7 @@ export function DesktopShell({
/>
</SectionErrorBoundary>
) : null}
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="relative flex min-h-0 flex-1 flex-col md:flex-row">
{layoutOptions.layerPanelVisible ? (
<SectionErrorBoundary label="Layer panel">
<LayerPanel
Expand Down
Loading
Loading