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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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 (release)
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 pinned to a full commit SHA (v3 tag head) per the
# repo's policy for non-official actions; a tag could be re-pointed.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3

- 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)"
store_pass_file="$RUNNER_TEMP/ks.pass"
key_pass_file="$RUNNER_TEMP/key.pass"
if [ -n "${KEYSTORE_BASE64:-}" ]; then
# Fail fast if the keystore secret is set but its companions are not,
# instead of a cryptic apksigner error later.
if [ -z "${KEYSTORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ]; then
echo "::error::ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_ALIAS must be set when ANDROID_KEYSTORE_BASE64 is provided"
exit 1
fi
echo "Signing with the release keystore from secrets."
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
keystore="$RUNNER_TEMP/release.jks"
printf '%s' "$KEYSTORE_PASSWORD" > "$store_pass_file"
printf '%s' "${KEY_PASSWORD:-$KEYSTORE_PASSWORD}" > "$key_pass_file"
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"
printf '%s' android > "$store_pass_file"
printf '%s' android > "$key_pass_file"
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"
# Pass passwords via files (pass:file:) so they never appear in the
# process argument list / CI logs.
"$build_tools/apksigner" sign --ks "$keystore" \
--ks-pass "file:$store_pass_file" --key-pass "file:$key_pass_file" \
--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')
rm -f "$store_pass_file" "$key_pass_file"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Security – decoded keystore file left on disk after signing

The password temp-files (ks.pass, key.pass) are deleted here, but $RUNNER_TEMP/release.jks written earlier is not. GitHub-hosted runners are ephemeral, so this is low-risk in practice, but defense-in-depth suggests wiping the keystore bytes as well. Consider:

Suggested change
rm -f "$store_pass_file" "$key_pass_file"
rm -f "$store_pass_file" "$key_pass_file" "$RUNNER_TEMP/release.jks"

Confidence: low (ephemeral runners make exploitation unlikely, but trivial to fix).

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": []
}
}
Loading
Loading