permissions, manganis::ffi, full Info.plist/AndroidManifest.xml customization (#4842)

* initial version

* make custom permission work without const-vec change

* wiring up with cli

* yey symbol found

* wip

* holy moly

* proper ui

* cleanup

* okay

* wip cleanup

* reduce boilerplate code into mobile-core

* Delete copilot-instructions.md

* revert hello-world example for low diff

* fix android build in mobile-core

* rename permission to static_permission

* small success with popup location on android

* android popup working

* Refactor permission checks for readability

Improves code formatting in has_location_permission and check_permission functions by splitting long lines and updating function signatures for better readability. No functional changes were made.

* wip consolidate all java files and build from cli instead of build.rs

* few cli cleanups

* wip java_plugin macro

* cleanup cleanup cleanup

* Update java_plugin macro to use full relative file paths

The java_plugin macro now accepts full relative paths for Java files instead of assuming a fixed directory structure. Documentation and usage examples have been updated to reflect this change, improving flexibility and clarity for specifying file locations.

* Improve iOS geolocation permission and retrieval logic

Added checks for authorization status before requesting location permissions and retrieving location data. Now only requests permission if not determined, and attempts to retrieve cached location before starting location updates, improving efficiency and user experience.

* Add ios_plugin macro for iOS framework metadata

Introduces the ios_plugin! macro in mobile-core-macro for declarative iOS framework metadata embedding. Updates mobile-core to re-export the macro and refactors mobile-geolocation to use ios_plugin! instead of manual linker attributes.

* not needed

* simplify the plugin

* rename mobile-core to platform-bridge and mobile-core-macro to platform-bridge-macro

* Refactor metadata serialization buffer initialization

Replaces inline creation of ConstVec with a named EMPTY constant for buffer initialization in metadata serialization. This improves code clarity and maintainability.

* mobile-geolocation out of dioxus

* take the example also out of dioxus

* remove example from cargo toml

* remove demo css

* revert hello-world example

* revert mac.plist

* revert gitignore

* Remove basic usage example for permissions crate

Deleted the basic_usage.rs example file from the permissions crate. This change may be part of a cleanup or refactoring to remove outdated or unnecessary example code.

* Delete plan.md

* remove dups

* cleanup java build

* cleanup permission symbol extraction

* update todo

* small slop of tested permissions in enums for minimum diff

* Refactor symbol extraction into linker_symbols module

Moved generic symbol extraction logic from android_java.rs and permissions.rs into a new linker_symbols.rs module. Updated both files to use the shared utilities for finding symbol offsets, reducing code duplication and improving maintainability.

* wip consolidate ios and macos as darwin

* Add docs and re-export for MainThreadCell and MainThreadMarker

Expanded documentation for MainThreadCell, clarifying usage and safety. Changed visibility of MainThreadCell to public, improved method docs, and re-exported MainThreadMarker in darwin/mod.rs for easier access.

* Update Darwin framework symbol and section names

Changed the framework symbol prefix from '__DARWIN_FRAMEWORK__' to '__DARWIN_FW__' and updated the Mach-O section name from '__darwin_framework' to '__darwin_fw' for compliance with section name length limits and consistency.

* Fix Info.plist path handling for iOS and macOS

Adds comments and corrects the path to Info.plist for iOS and macOS app bundles. For iOS, Info.plist is at the root of the .app bundle, while for macOS it is inside Contents/. Also improves warning message to include the missing path.

* Handle missing permission symbols and web bundle case

Improves permission extraction by returning an empty manifest when no permission symbols are found or when building a web bundle, as permissions are runtime-only in that case. Adds debug logging for missing symbols and ensures robust handling of these scenarios.

* objc2 over framework

* windows, linux, web all permissions are runtime so cleanup

* only include permissions for build time

* cleanup unnecessary test

* more cleanup

* extract shared into dx-macro-helpers

* rc.3 to 0.7.0 for const-serialize

* Add CustomPermissionBuilder and enhance PermissionBuilder for better permission management

- Introduced `CustomPermissionBuilder` for creating custom permissions with platform-specific identifiers.
- Enhanced `PermissionBuilder` to support custom permissions and location permissions with a clear builder pattern.
- Updated documentation and examples in the macro README to reflect new usage patterns for custom and location permissions.
- Improved parsing logic in the macro to handle both builder and direct construction patterns for permissions.

* clippy fixes

* implement const cbor encoding + decoding for basic types

* Add #[used] attribute to linker sections for optimization protection

- Added #[used] attribute to linker sections in both permission and Android plugin macros to prevent the linker from optimizing away symbols, ensuring they are preserved even if unused.
- Created static references to linker sections to enhance defense-in-depth against optimization issues.

* implement map encoding and decoding

* fix map test

* wip unified linker symbol

* cleanup permissions

* pipe java sources with manganis as well

* all tests passing

* dynamically sized arrays

* smaller str encoding

* swap array and list and add more documentation

* move serialization and deserialization for each struct together

* support both legacy and new assets

* bump const serialize version

* fix asset conversion code

* pull out manganis symbol struct from tuble

* test reading old asset versions

* more list/array cleanup and bump rust version

* fix clippy

* Revert "pipe java sources with manganis as well"

This reverts commit ddea9a459d9ac26f3f037a74d9ae0de3edfced67.

* Revert "cleanup permissions"

This reverts commit 7b18500155237403e179f0783cb8903a4f158b80.

* Revert "wip unified linker symbol"

This reverts commit 813e33feddb30d26868f6ac6e6da318402ac138f.

* Revert "Add #[used] attribute to linker sections for optimization protection"

This reverts commit a94a7a3a5e8eee80060de9d66a53aabfcf554f96.

* update cargo lock

* wip permission through assets

* abit cleanup

* fix reexports

* fix assets

* Fix the permissions piping

* some small cleanups

* checkout const-serialize as per #4932 and fix build

* invoking swift as a package

* geolocation package & example dumping for easy testing

* keep geolocation simple for testing

* cleanups

* pipe everything through __ASSETS__ and cleanup

* remove linker_symbols helper file

* example

* re example

* copy build methos into mobile-plugin-build crate

* Add conditional import for dioxus_07 feature

Introduces a conditional import to use the dioxus_07 crate when the corresponding feature is enabled, improving compatibility with different dioxus versions.

* Improve error handling and JNI reference usage

Adds a null check for Objective-C instance initialization in the iOS geolocation plugin to prevent errors on failed allocation. Refactors Android callback to use local JNI references instead of creating unnecessary global references, reducing potential memory leaks.

* revert changes on playwright

* Update dioxus dependency to 0.7.2 in Cargo.lock

Removed dioxus 0.7.1 from dependencies, retaining only dioxus 0.7.2 for the dioxus-cli-optimization-test package.

* small cleanup

* Add legacy const-serialize 0.7 support to dx-macro-helpers

Introduces support for the const-serialize 0.7 format in dx-macro-helpers by adding a new dependency and export, as well as a helper function for serialization with layout padding. Updates manganis to use the new helper for serializing assets in the legacy format, simplifying code and improving maintainability.

* Add helper for legacy and current linker sections

Introduces generate_link_sections_with_legacy in dx-macro-helpers to emit both legacy and current linker sections for compatibility. Refactors manganis-macro to use this new helper, reducing duplication and improving maintainability.

* Fix lints

* Fix clippy and doctest

* Refactor asset bundling and allow large enum variant

Refactored Asset::bundled to use Vec<u8> instead of ConstVec for reading volatile slices, simplifying the code and improving compatibility. Added #[allow(clippy::large_enum_variant)] to SymbolData enum to suppress Clippy warnings about large enum variants.

* Update license URLs in README to use angle brackets

Changed license URLs in the README from parentheses to angle brackets for improved formatting and clarity.

* Fix asset deserialization with correct buffer type

Replaces the use of `bytes.read()` with `ConstReadBuffer::new(bytes.as_ref())` for asset deserialization, ensuring compatibility with the expected deserialization API.

* Refactor geolocation and asset handling for clarity

Removed unnecessary clones in geolocation example event handlers to simplify state management. Updated SymbolDataOrAsset to store SymbolData in a Box for improved memory handling and adjusted related logic. Fixed documentation example in geolocation lib to include PermissionState in the import.

* Update geolocation example and permissions usage docs

Removes unused variables in the geolocation example's App function. Updates the permissions crate documentation to reflect new API usage with PermissionBuilder and LocationPrecision.

* Update permissions-macro to use permissions crate

Switched imports and dev-dependency from permissions_core to permissions in permissions-macro. Updated documentation and examples to reflect the new crate usage.

* rollback const-serialize changes

* move items around (doesn't compile)

* convert gelocation plugin to example

* remove dx-macro-helpers

* we're just gonna full-send linker everything

* rollback desktop changes

* remove gradle

* fix most compile?

* fix dioxus vers

* small cleanups

* add claude design docs

* add ffi macro

* remove

* loads but not linking yet

* fix output leak

* a little bit closer but not quite

* improve android code gen

* sheeeeee works!!!

* small tweaks to demo

* replace ios/android plugin with ffi macro entirely

* simplify ffi module

* simplify again

* condense / rip out some stuff from manganis

* remove types form public api

* wip, permissions

* oops I broke it

* I unbroke it

* move arch doc

* remove extra manifest types

* drop bindgen file

* remove permission

* small cleanup

* remove unsafecall mtm thing

* yoink perms

* wire up manifest

* Add deeplinks

* add schema generation

* update schema

* wip....

* fix recurse limit schema generation

* wip, muck around with schema

* add inline dioxus.toml support

* merge bundle config schemas

* claude my man

* Remove cdecl, annotate ffi

* rename design docs

* wip: live activities

* wip: apple plugin bundling

* wip

* get live activities linking properly

* small cleanups

* remove sidecars

* remove widget

* yoink legacy

* cleanup

* more fixups

* wire up more things to dioxus.toml

* fix workspace join

* final cleanups

* fmt

* final bit of clippy

* ci tweak

* update schema

* fmt, doc

* update schema docs

---------

Co-authored-by: Evan Almloff <evanalmloff@gmail.com>
Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-13 15:39:38 -08:00
1 parent 64cc42e16e
commit eeb60f7173
73 files changed
+13767 -336

No files matched your search

@@ -0,0 +1,21 @@
[package]
name = "geolocation-native-plugin"
version = "0.1.0"
authors = ["Sabin Regmi <get2sabin@gmail.com>"]
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dioxus = { workspace = true, features = [] }
manganis = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
[features]
default = ["mobile"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
@@ -0,0 +1,27 @@
#:schema ../../../packages/cli/schema.json
[bundle]
identifier = "com.dioxuslabs.geolocation"
publisher = "Dioxus Labs"
[ios]
deployment_target = "16.2"
background_modes = ["location"]
[ios.plist]
NSSupportsLiveActivities = true
[[ios.widget_extensions]]
source = "src/ios/widget"
display_name = "Location Widget"
bundle_id_suffix = "location-widget"
deployment_target = "16.2"
module_name = "GeolocationPlugin"
[android]
min_sdk = 24
target_sdk = 34
features = ["android.hardware.location.gps"]
[permissions]
location = { precision = "fine", description = "Access your precise location to provide location-based services" }
@@ -0,0 +1,32 @@
# Geolocation demo
A minimal Dioxus application that implements a native plugin.
The plugin demonstrated here makes it possible to access the user's geolocation. It does a few things:
- Inspect and request location permissions using the native Android/iOS dialogs.
- Configure one-shot position requests (high-accuracy toggle + maximum cached age).
- Inspect the last reported coordinates, accuracy, altitude, heading, and speed.
The example shares the same metadata pipeline as any plugin crate: the native Gradle/Swift
artifacts are embedded via linker symbols and bundled automatically by `dx`.
## Running the example
```bash
# Inside the repository root
dx serve --project examples/01-app-demos/geolocation --platform mobile
```
For Android/iOS you’ll need the respective toolchains installed (Android SDK/NDK, Xcode) so the
geolocation crate’s `build.rs` can build the native modules. The UI also works on desktop/web,
but location calls will return an error because the plugin only supports mobile targets—those
errors are shown inline in the demo.
## Things to try
1. Tap **Check permissions** to see the current OS state (granted/denied/prompt).
2. Tap **Request permissions** to trigger the native dialog from within the app.
3. Toggle *High accuracy* and set a *Max cached age* before requesting the current position.
4. Observe the coordinate grid update whenever a new reading arrives, or the error banner if the
operation fails (e.g., permissions denied or running on an unsupported platform).
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1007 197"><style>
@keyframes a0_t { 0% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } 35% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { transform: translate(225px,339.9px) scale(1,1) translate(-89.5px,-24px); } 50% { transform: translate(225px,339.9px) scale(1,1) translate(-89.5px,-24px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } 100% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } }
@keyframes a1_t { 0% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } 20% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,348.9px) scale(1,1) translate(-41.5px,-11px); } 35% { transform: translate(225px,348.9px) scale(1,1) translate(-41.5px,-11px); } 45% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } 100% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } }
@keyframes a3_t { 0% { transform: translate(225px,198.9px) rotate(-90deg); } 10% { transform: translate(225px,198.9px) rotate(-90deg); } 20% { transform: translate(225px,198.9px) rotate(-90deg); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,198.9px) rotate(0deg); } 35% { transform: translate(225px,198.9px) rotate(0deg); } 45% { transform: translate(225px,198.9px) rotate(0deg); } 50% { transform: translate(225px,198.9px) rotate(0deg); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(225px,198.9px) rotate(-90deg); } 65% { transform: translate(225px,198.9px) rotate(-90deg); } 75% { transform: translate(225px,198.9px) rotate(-90deg); } 100% { transform: translate(225px,198.9px) rotate(-90deg); } }
@keyframes a2_t { 0% { transform: translate(-200px,-167px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(-150px,-80px); } 20% { transform: translate(-150px,-80px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(-200px,-123px); } 35% { transform: translate(-200px,-123px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { transform: translate(-150px,-100px); } 50% { transform: translate(-150px,-100px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(-150px,-80px); } 65% { transform: translate(-150px,-80px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { transform: translate(-217px,-200px); } 100% { transform: translate(-217px,-200px); } }
@keyframes a2_o { 0% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { opacity: 1; } 65% { opacity: 1; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { opacity: 0; } 100% { opacity: 0; } }
@keyframes a2_sw { 0% { stroke-width: 30px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { stroke-width: 8px; } 65% { stroke-width: 8px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { stroke-width: 30px; } 100% { stroke-width: 30px; } }
@keyframes a2_w { 0% { width: 400px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { width: 300px; } 20% { width: 300px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { width: 400px; } 35% { width: 400px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { width: 300px; } 50% { width: 300px; } 60% { width: 300px; } 65% { width: 300px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { width: 434px; } 100% { width: 434px; } }
@keyframes a2_h { 0% { height: 334px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { height: 160px; } 20% { height: 160px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { height: 246px; } 35% { height: 246px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { height: 200px; } 50% { height: 200px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { height: 160px; } 65% { height: 160px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { height: 400px; } 100% { height: 400px; } }
@keyframes a4_t { 0% { transform: translate(143.5px,48.9px); } 10% { transform: translate(180.5px,98.9px); } 100% { transform: translate(180.5px,98.9px); } }
@keyframes a4_w { 0% { width: 168.5px; } 10% { width: 94.5px; } 100% { width: 94.5px; } }
@keyframes a4_h { 0% { height: 302px; } 10% { height: 202px; } 100% { height: 202px; } }
@keyframes a5_t { 0% { transform: translate(217px,324.9px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(190px,324.9px); } 20% { transform: translate(190px,324.9px); } 30% { transform: translate(190px,324.9px); } 100% { transform: translate(190px,324.9px); } }
@keyframes a5_o { 0% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { opacity: 1; } 20% { opacity: 1; } 30% { opacity: 0; } 100% { opacity: 0; } }
@keyframes a5_w { 0% { width: 16px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { width: 70px; } 20% { width: 70px; } 30% { width: 70px; } 100% { width: 70px; } }
@keyframes a6_t { 0% { transform: translate(217px,324.9px); } 50% { transform: translate(217px,324.9px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(190px,324.9px); } 65% { transform: translate(190px,324.9px); } 75% { transform: translate(190px,324.9px); } 100% { transform: translate(190px,324.9px); } }
@keyframes a6_o { 0% { opacity: 0; } 50% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { opacity: 1; } 65% { opacity: 1; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { opacity: 0; } 100% { opacity: 0; } }
@keyframes a6_w { 0% { width: 16px; } 50% { width: 16px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { width: 70px; } 65% { width: 70px; } 75% { width: 70px; } 100% { width: 70px; } }
@keyframes a7_t { 0% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 20% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 25% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 35% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 45% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); } 50% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 55% { transform: translate(225px,198.9px) scale(.7,.7) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 60% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 65% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 75% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); } 100% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); } }
</style><defs><symbol id="Symbol-2" preserveAspectRatio="none" width="111.5" height="186" viewBox="0 0 111.5 186" overflow="visible"><g transform="translate(8.5,4.5)"><rect x="4" width="85" height="8" fill="#d9d9d9"/><rect x="4" y="26" width="85" height="8" fill="#d9d9d9"/><rect x="25" y="56" width="43" height="8" fill="#d9d9d9"/><rect width="85" height="8" fill="#d9d9d9" transform="translate(46.5,175) rotate(180) scale(-1,1) translate(-42.5,-4)"/><rect width="85" height="8" fill="#d9d9d9" transform="translate(46.5,149) rotate(180) scale(-1,1) translate(-42.5,-4)"/><rect width="43" height="8" fill="#d9d9d9" transform="translate(46.5,119) rotate(180) scale(-1,1) translate(-21.5,-4)"/><path d="M0 4c0 53 94.5 115 94.5 169" stroke="#3cc4dc" stroke-width="17" stroke-linecap="square" fill="none"/><path d="M94.5 4c0 53-94.5 115-94.5 169" stroke="#fb422d" stroke-width="17" stroke-linecap="square" fill="none"/></g></symbol><symbol id="Symbol-5" preserveAspectRatio="none" width="84.5" height="186" viewBox="0 0 84.5 186" overflow="visible"><g transform="translate(42.3,93) scale(5.352,6.9) translate(-16,60)"><g transform="translate(-34.9,-125.8)"><g transform="translate(24,5)"><path d="M33 46.5c0 3.1-0.8 5.5-2.2 7.4c-1.4 1.9-3.3 3.6-5.4 5.4c-2.2 1.7-4.5 3.6-6.3 6.2c-1.8 2.5-3.1 5.5-3.1 9.5h4.7c0-3.1 .9-5.4 2.2-7.3c1.4-1.9 3.3-3.5 5.5-5.3c2.1-1.8 4.4-3.7 6.2-6.2c1.8-2.6 3.1-5.6 3.1-9.7Z" fill="#e96020"/><path d="M20.4 70.7c-0.6 0-1 .5-1 1.1c0 .7 .4 1.2 1 1.2h12.9c.7 0 1.2-0.5 1.2-1.2c0-0.6-0.5-1.1-1.2-1.1Z" fill="#2d323b"/><path d="M21.8 66.6c-0.7 0-1 .6-1 1.2c0 .6 .3 1 .9 1h10.3c.6 0 1.1-0.5 1.1-1.1c0-0.6-0.5-1.1-1.1-1.1Z" fill="#2d323b"/><path d="M21.8 53c-0.7 0-1.2 .5-1.1 1.1c0 .6 .4 1.1 1.1 1.1h10.2c.6 0 .9-0.6 .9-1.2c0-0.6-0.3-1-0.9-1Z" fill="#2d323b"/><path d="M20.4 48.8c-0.6 0-1.2 .5-1.2 1.1c0 .7 .6 1.2 1.2 1.2h12.9c.7 0 1-0.5 1-1.1c0-0.7-0.3-1.2-1-1.2Z" fill="#2d323b"/><path d="M16 46.5c0 4.1 1.3 7.1 3.1 9.7c1.8 2.5 4.1 4.4 6.3 6.2c2.1 1.8 4 3.6 5.4 5.5c1.4 1.9 2.2 4 2.2 7.1h4.7c0-4-1.3-7.1-3.1-9.6c-1.8-2.5-4.1-4.4-6.2-6.2c-2.2-1.8-4.1-3.4-5.5-5.3c-1.3-1.9-2.2-4.3-2.2-7.4Z" fill="#00a8d6"/></g></g></g></symbol></defs><rect width="1007" height="197" stroke="#0f1116" fill="#0f1116" stroke-width="0"/><path fill="#fff" d="M83.3 106.1v-65.7h17c4.2 0 7.9 .8 11 2.4c3.1 1.6 5.5 3.9 7.2 6.9c1.7 2.9 2.6 6.4 2.6 10.4v26.2c0 4-0.9 7.5-2.6 10.5c-1.7 3-4.1 5.3-7.2 6.9c-3.1 1.6-6.8 2.4-11 2.4Zm8.1-7.2h8.9c4 0 7.1-1.1 9.3-3.3c2.3-2.3 3.4-5.3 3.4-9.3v-26.2c0-3.9-1.1-7-3.4-9.2c-2.2-2.2-5.3-3.3-9.3-3.3h-8.9Zm45.3 7.2v-7.4h17.5v-34.7h-15.7v-7.4h23.8v42.1h16.7v7.4Zm20.7-58.4c-2 0-3.6-0.5-4.7-1.5c-1.2-1.1-1.7-2.4-1.7-4.2c0-1.8 .5-3.2 1.7-4.3c1.1-1 2.7-1.5 4.7-1.5c1.9 0 3.5 .5 4.6 1.5c1.2 1.1 1.7 2.5 1.7 4.3c0 1.8-0.5 3.1-1.7 4.2c-1.1 1-2.7 1.5-4.6 1.5Zm52.6 59.1c-6 0-10.7-1.7-14.2-5.1c-3.4-3.4-5.1-8-5.1-13.8v-13.1c0-5.9 1.7-10.5 5.1-13.9c3.5-3.3 8.2-5 14.2-5c6 0 10.7 1.7 14.2 5c3.4 3.4 5.2 8 5.2 13.8v13.2c0 5.8-1.8 10.4-5.2 13.8c-3.5 3.4-8.2 5.1-14.2 5.1Zm0-7.2c3.5 0 6.3-1 8.3-3c2-1.9 3-4.8 3-8.7v-13.1c0-3.9-1-6.8-3-8.8c-2-1.9-4.8-2.9-8.3-2.9c-3.5 0-6.2 1-8.2 2.9c-2 2-3 4.9-3 8.8v13.1c0 3.9 1 6.8 3 8.7c2 2 4.7 3 8.2 3Zm31.5 6.5l17.6-25.5l-16.5-24h9.5l9.8 15.3c.5 .7 .9 1.4 1.2 2.2c.4 .7 .8 1.4 1 1.8c.2-0.4 .5-1.1 .8-1.8c.4-0.8 .8-1.5 1.3-2.2l9.9-15.3h9.4l-16.5 24.1l17.5 25.4h-9.5l-10.7-16.2c-0.4-0.7-0.8-1.4-1.2-2.3c-0.4-0.9-0.7-1.6-1-2.1c-0.2 .5-0.6 1.2-1 2.1c-0.5 .9-1 1.6-1.4 2.3l-10.7 16.2Zm76.4 .9c-5.7 0-10.2-1.7-13.7-5.1c-3.4-3.4-5.1-8-5.1-13.8v-31.5h8.1v31.5c0 3.7 1 6.6 2.9 8.7c1.9 2.1 4.5 3.1 7.8 3.1c3.4 0 6-1 8-3.1c1.9-2.1 2.9-5 2.9-8.7v-31.5h8.1v31.5c0 5.8-1.7 10.4-5.2 13.8c-3.5 3.4-8.1 5.1-13.8 5.1Zm52.6-0.2c-5.1 0-9.3-1.2-12.5-3.5c-3.3-2.3-4.9-5.5-4.9-9.6h8.3c0 1.8 .9 3.3 2.6 4.3c1.8 1.1 4 1.6 6.6 1.6h3.8c3.2 0 5.6-0.6 7.1-1.9c1.6-1.3 2.4-3 2.4-5.2c0-2.1-0.8-3.8-2.2-5c-1.5-1.2-3.6-2-6.4-2.5l-6.6-1c-5-0.9-8.6-2.4-10.9-4.7c-2.4-2.3-3.5-5.5-3.5-9.5c0-4.5 1.4-7.9 4.3-10.3c2.9-2.4 7.1-3.6 12.6-3.6h3.4c5 0 9.1 1.1 12.1 3.4c3 2.2 4.6 5.2 4.6 8.9h-8.3c0-1.6-0.8-2.8-2.3-3.7c-1.5-1-3.6-1.4-6.3-1.4h-3.4c-2.8 0-5 .6-6.5 1.7c-1.5 1.2-2.3 2.9-2.3 5c0 3.6 2.5 5.8 7.6 6.7l6.7 1.1c5.3 .8 9.2 2.4 11.7 4.6c2.4 2.3 3.6 5.6 3.6 9.9c0 4.6-1.5 8.2-4.4 10.8c-2.9 2.6-7.3 3.9-13.2 3.9Z"/><path fill="#fff" d="M91 164c-2 0-3.6-0.6-4.7-1.7c-1.1-1.1-1.6-2.7-1.6-4.6v-15.9h2.7v15.9c0 1.2 .3 2.2 .9 2.8c.6 .7 1.5 1.1 2.7 1.1c1.2 0 2.1-0.4 2.7-1.1c.6-0.7 .9-1.6 .9-2.8v-15.9h2.7v15.9c0 2-0.5 3.5-1.6 4.6c-1.1 1.1-2.6 1.7-4.7 1.7Zm17.5-0.1c-1.7 0-3.1-0.3-4.2-1.1c-1.1-0.8-1.6-1.9-1.6-3.2h2.8c0 .6 .3 1.1 .8 1.4c.6 .4 1.4 .5 2.2 .5h1.3c1.1 0 1.9-0.2 2.4-0.6c.5-0.4 .8-1 .8-1.7c0-0.7-0.3-1.3-0.8-1.7c-0.5-0.4-1.2-0.7-2.1-0.8l-2.2-0.4c-1.7-0.3-2.9-0.8-3.6-1.5c-0.8-0.8-1.2-1.9-1.2-3.2c0-1.5 .5-2.6 1.4-3.4c1-0.8 2.4-1.2 4.2-1.2h1.2c1.6 0 3 .3 4 1.1c1 .7 1.5 1.7 1.5 3h-2.7c0-0.6-0.3-1-0.8-1.3c-0.5-0.3-1.2-0.4-2.1-0.4h-1.1c-1 0-1.7 .2-2.2 .5c-0.5 .4-0.8 1-0.8 1.7c0 1.2 .9 1.9 2.6 2.2l2.2 .4c1.8 .3 3.1 .8 3.9 1.5c.8 .8 1.2 1.9 1.2 3.3c0 1.6-0.5 2.8-1.5 3.6c-0.9 .9-2.4 1.3-Line truncated

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,222 @@
body {
background-color: #05060a;
color: #f4f4f5;
font-family: 'Inter', 'Segoe UI', sans-serif;
margin: 0;
min-height: 100vh;
display: flex;
justify-content: center;
padding: calc(16px + env(safe-area-inset-top, 0px)) 0 40px;
}
.app {
width: min(960px, 100%);
padding: 0 20px;
box-sizing: border-box;
}
.hero {
display: flex;
gap: 24px;
align-items: center;
margin-bottom: 32px;
flex-wrap: wrap;
}
.hero img {
width: 200px;
max-width: 35%;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.hero__copy h1 {
margin: 0 0 8px;
font-size: clamp(28px, 6vw, 36px);
}
.hero__copy p {
margin: 0;
line-height: 1.5;
color: #c8cad7;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 16px;
}
.card {
background: linear-gradient(165deg, rgba(17, 20, 32, 0.95), rgba(6, 7, 16, 0.98));
border: 1px solid #222534;
border-radius: 16px;
padding: 24px;
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.4);
}
.card h2 {
margin-top: 0;
font-size: 1.5rem;
}
.muted {
color: #a5a7b6;
font-size: 0.95rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
button {
border: none;
border-radius: 999px;
padding: 10px 18px;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.2s ease;
}
button.primary,
button {
background: linear-gradient(135deg, #8f63ff, #4d8dff);
color: white;
box-shadow: 0 10px 25px rgba(77, 141, 255, 0.25);
}
button.secondary {
background: transparent;
color: #b3b7cf;
border: 1px solid #2f3244;
}
button.full-width {
width: 100%;
margin-top: 16px;
}
button.toggle {
width: fit-content;
background: #1a1d29;
border: 1px solid #2c2f40;
color: #d8d9e5;
}
button.toggle--active {
background: #23304d;
border-color: #4b6cff;
color: #ffffff;
}
.settings {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field input {
background: #0b0d13;
border: 1px solid #26293a;
border-radius: 10px;
padding: 10px 12px;
color: white;
}
.status-grid {
margin-top: 20px;
display: grid;
gap: 14px;
}
.permission-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.85rem;
text-transform: uppercase;
}
.badge--granted {
background: rgba(70, 221, 154, 0.15);
color: #7efac6;
border: 1px solid rgba(70, 221, 154, 0.4);
}
.badge--denied {
background: rgba(255, 98, 98, 0.16);
color: #ff8ea0;
border: 1px solid rgba(255, 98, 98, 0.4);
}
.badge--prompt {
background: rgba(255, 205, 112, 0.16);
color: #ffd27e;
border: 1px solid rgba(255, 205, 112, 0.35);
}
.position {
margin-top: 20px;
}
.position__grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.coordinate-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px;
background: #080a11;
border-radius: 12px;
border: 1px solid #1c1f2b;
}
.error-banner {
margin-top: 24px;
padding: 14px 18px;
background: #3c1017;
border: 1px solid #a44856;
border-radius: 12px;
color: #ffe6ea;
}
@media (max-width: 640px) {
.hero {
flex-direction: column;
text-align: center;
}
.hero img {
max-width: 60%;
}
.button-row {
flex-direction: column;
}
button {
width: 100%;
text-align: center;
}
}
@@ -0,0 +1,44 @@
import org.gradle.api.tasks.bundling.AbstractArchiveTask
plugins {
id("com.android.library") version "8.4.2"
kotlin("android") version "1.9.24"
}
android {
namespace = "com.dioxus.geolocation"
compileSdk = 34
defaultConfig {
minSdk = 24
targetSdk = 34
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
getByName("debug") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
}
tasks.withType<AbstractArchiveTask>().configureEach {
archiveBaseName.set("geolocation-plugin")
}
@@ -0,0 +1 @@
# Intentionally empty; no consumer Proguard rules required for the geolocation plugin.
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Background location is optional; uncomment if needed by host app -->
<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> -->
<application />
</manifest>
@@ -0,0 +1,87 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package com.dioxus.geolocation
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.SystemClock
import androidx.core.location.LocationManagerCompat
import android.util.Log
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
class Geolocation(private val context: Context) {
fun isLocationServicesEnabled(): Boolean {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return LocationManagerCompat.isLocationEnabled(lm)
}
@SuppressWarnings("MissingPermission")
fun sendLocation(
enableHighAccuracy: Boolean,
successCallback: (location: Location) -> Unit,
errorCallback: (error: String) -> Unit,
) {
val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
if (resultCode == ConnectionResult.SUCCESS) {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (this.isLocationServicesEnabled()) {
var networkEnabled = false
try {
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
Log.e("Geolocation", "isProviderEnabled failed")
}
val lowPrio =
if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio
Log.d("Geolocation", "Using priority $prio")
LocationServices
.getFusedLocationProviderClient(context)
.getCurrentLocation(prio, null)
.addOnFailureListener { e -> e.message?.let { errorCallback(it) } }
.addOnSuccessListener { location ->
if (location == null) {
errorCallback("Location unavailable.")
} else {
successCallback(location)
}
}
} else {
errorCallback("Location disabled.")
}
} else {
errorCallback("Google Play Services unavailable.")
}
}
@SuppressLint("MissingPermission")
fun getLastLocation(maximumAge: Long): Location? {
var lastLoc: Location? = null
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
for (provider in lm.allProviders) {
val tmpLoc = lm.getLastKnownLocation(provider)
if (tmpLoc != null) {
val locationAge = SystemClock.elapsedRealtimeNanos() - tmpLoc.elapsedRealtimeNanos
val maxAgeNano = maximumAge * 1_000_000L
if (locationAge <= maxAgeNano && (lastLoc == null || lastLoc.elapsedRealtimeNanos > tmpLoc.elapsedRealtimeNanos)) {
lastLoc = tmpLoc
}
}
}
return lastLoc
}
}
@@ -0,0 +1,179 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package com.dioxus.geolocation
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import android.webkit.WebView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.Timer
import kotlin.concurrent.schedule
class GeolocationPlugin(private val activity: Activity) {
private val geolocation = Geolocation(activity)
fun checkPermissions(): Map<String, String> {
val response = mutableMapOf<String, String>()
val coarseStatus = ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_COARSE_LOCATION)
val fineStatus = ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
response["location"] = permissionToStatus(fineStatus)
response["coarseLocation"] = permissionToStatus(coarseStatus)
return response
}
fun requestPermissions(callback: (Map<String, String>) -> Unit) {
val permissionsToRequest = mutableListOf<String>()
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(Manifest.permission.ACCESS_COARSE_LOCATION)
}
if (permissionsToRequest.isEmpty()) {
callback(checkPermissions())
} else {
ActivityCompat.requestPermissions(activity, permissionsToRequest.toTypedArray(), 1001)
Handler(Looper.getMainLooper()).postDelayed({ callback(checkPermissions()) }, 1000)
}
}
fun getCurrentPosition(
enableHighAccuracy: Boolean,
timeout: Long,
maximumAge: Long,
successCallback: (Location) -> Unit,
errorCallback: (String) -> Unit,
) {
val lastLocation = geolocation.getLastLocation(maximumAge)
if (lastLocation != null) {
successCallback(lastLocation)
return
}
val timer = Timer()
timer.schedule(timeout) {
activity.runOnUiThread { errorCallback("Timeout waiting for location.") }
}
geolocation.sendLocation(
enableHighAccuracy,
{ location ->
timer.cancel()
successCallback(location)
},
{ error ->
timer.cancel()
errorCallback(error)
},
)
}
private fun permissionToStatus(value: Int): String =
when (value) {
PackageManager.PERMISSION_GRANTED -> "granted"
PackageManager.PERMISSION_DENIED -> "denied"
else -> "prompt"
}
// ---- Platform bridge helpers expected by Rust JNI layer ----
// Called by Rust after constructing the plugin. No-op placeholder to match signature.
fun load(webView: WebView?) { /* no-op */ }
// Serialize current permission status as JSON string
fun checkPermissionsJson(): String {
val status = checkPermissions()
val json = JSONObject()
json.put("location", status["location"]) // granted|denied|prompt
json.put("coarseLocation", status["coarseLocation"]) // granted|denied|prompt
return json.toString()
}
// Request permissions and return resulting status JSON (waits briefly for result)
fun requestPermissionsJson(permissionsJson: String?): String {
val latch = CountDownLatch(1)
var result: String = checkPermissionsJson()
requestPermissions { status ->
val json = JSONObject()
json.put("location", status["location"])
json.put("coarseLocation", status["coarseLocation"])
result = json.toString()
latch.countDown()
}
// Wait up to 5 seconds for the permission result, then return whatever we have
latch.await(5, TimeUnit.SECONDS)
return result
}
// Convert a Location to the Position JSON expected by Rust side
private fun locationToPositionJson(location: Location): String {
val coords = JSONObject()
coords.put("latitude", location.latitude)
coords.put("longitude", location.longitude)
coords.put("accuracy", location.accuracy.toDouble())
if (location.hasAltitude()) coords.put("altitude", location.altitude)
if (android.os.Build.VERSION.SDK_INT >= 26) {
val vAcc = try { location.verticalAccuracyMeters } catch (_: Exception) { null }
if (vAcc != null) coords.put("altitudeAccuracy", vAcc.toDouble())
}
if (location.hasSpeed()) coords.put("speed", location.speed.toDouble())
if (location.hasBearing()) coords.put("heading", location.bearing.toDouble())
val obj = JSONObject()
obj.put("timestamp", System.currentTimeMillis())
obj.put("coords", coords)
return obj.toString()
}
// Synchronous wrapper returning JSON for getCurrentPosition
// Accepts a JSON string with options: {"enableHighAccuracy": bool, "timeout": number, "maximumAge": number}
fun getCurrentPositionJson(optionsJson: String?): String {
val options = try {
if (optionsJson.isNullOrEmpty()) JSONObject() else JSONObject(optionsJson)
} catch (e: Exception) {
JSONObject()
}
val enableHighAccuracy = options.optBoolean("enableHighAccuracy", false)
val timeout = options.optLong("timeout", 10000L)
val maximumAge = options.optLong("maximumAge", 0L)
var output: String? = null
val latch = CountDownLatch(1)
getCurrentPosition(
enableHighAccuracy,
timeout,
maximumAge,
{ location ->
output = locationToPositionJson(location)
latch.countDown()
},
{ error ->
output = JSONObject(mapOf("error" to error)).toString()
latch.countDown()
},
)
// Wait up to the timeout + 2s buffer
latch.await(timeout + 2000, TimeUnit.MILLISECONDS)
return output ?: JSONObject(mapOf("error" to "Timeout waiting for location.")).toString()
}
}
@@ -0,0 +1,10 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
@@ -0,0 +1,33 @@
// swift-tools-version:5.9
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import PackageDescription
let package = Package(
name: "GeolocationPlugin",
platforms: [
.iOS(.v17), // iOS 17+ for latest ActivityKit APIs
.macOS(.v14),
],
products: [
.library(
name: "GeolocationPlugin",
type: .static,
targets: ["GeolocationPlugin"]
)
],
dependencies: [],
targets: [
.target(
name: "GeolocationPlugin",
path: "Sources",
linkerSettings: [
.linkedFramework("CoreLocation"),
.linkedFramework("Foundation"),
.linkedFramework("ActivityKit", .when(platforms: [.iOS])),
]
)
]
)
@@ -0,0 +1,322 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import CoreLocation
import Foundation
import Dispatch
import ActivityKit
/**
* Simplified GeolocationPlugin for Dioxus that works without Tauri dependencies.
* This can be shared with Tauri plugins with minimal changes.
*/
@objc(GeolocationPlugin)
public class GeolocationPlugin: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private var positionCallbacks: [String: (String) -> Void] = [:]
override init() {
super.init()
locationManager.delegate = self
}
/**
* Get current position as JSON string (called from ObjC/Rust)
*/
@objc public func getCurrentPositionJson(_ optionsJson: String) -> String {
// Parse options from JSON
guard let optionsData = optionsJson.data(using: .utf8),
let optionsDict = try? JSONSerialization.jsonObject(with: optionsData) as? [String: Any] else {
let error = ["error": "Invalid options JSON"]
return (try? JSONSerialization.data(withJSONObject: error))?.base64EncodedString() ?? ""
}
let enableHighAccuracy = optionsDict["enableHighAccuracy"] as? Bool ?? false
let timeoutMs = optionsDict["timeout"] as? Double ?? 10000
let maximumAgeMs = optionsDict["maximumAge"] as? Double ?? 0
// If we have a recent cached location, return it immediately
if let lastLocation = self.locationManager.location {
let ageMs = Date().timeIntervalSince(lastLocation.timestamp) * 1000
if maximumAgeMs <= 0 || ageMs <= maximumAgeMs {
return self.convertLocationToJson(lastLocation)
}
}
let callbackId = UUID().uuidString
let semaphore = DispatchSemaphore(value: 0)
var responseJson: String?
self.positionCallbacks[callbackId] = { result in
responseJson = result
semaphore.signal()
}
if enableHighAccuracy {
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
} else {
self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
}
if CLLocationManager.authorizationStatus() == .notDetermined {
self.locationManager.requestWhenInUseAuthorization()
} else {
self.locationManager.requestLocation()
}
let timeoutSeconds = max(timeoutMs / 1000.0, 0.1)
let deadline = Date().addingTimeInterval(timeoutSeconds)
while responseJson == nil && Date() < deadline {
let _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
if semaphore.wait(timeout: .now()) == .success {
break
}
}
if let json = responseJson {
return json
} else {
// Timed out waiting for location
self.positionCallbacks.removeValue(forKey: callbackId)
let error = ["error": "Timeout waiting for location"]
return (try? JSONSerialization.data(withJSONObject: error)).flatMap {
String(data: $0, encoding: .utf8)
} ?? "{\"error\":\"Timeout waiting for location\"}"
}
}
/**
* Check permissions and return JSON string (called from ObjC/Rust)
*/
@objc public func checkPermissionsJson() -> String {
var status: String = ""
if CLLocationManager.locationServicesEnabled() {
switch CLLocationManager.authorizationStatus() {
case .notDetermined:
status = "prompt"
case .restricted, .denied:
status = "denied"
case .authorizedAlways, .authorizedWhenInUse:
status = "granted"
@unknown default:
status = "prompt"
}
} else {
let error = ["error": "Location services are not enabled"]
return (try? JSONSerialization.data(withJSONObject: error))?.base64EncodedString() ?? ""
}
let result: [String: String] = ["location": status, "coarseLocation": status]
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
let jsonString = String(data: jsonData, encoding: .utf8) {
return jsonString
}
return ""
}
/**
* Request permissions and return JSON string (called from ObjC/Rust)
*/
@objc public func requestPermissionsJson(_ permissionsJson: String) -> String {
if CLLocationManager.locationServicesEnabled() {
if CLLocationManager.authorizationStatus() == .notDetermined {
DispatchQueue.main.async {
self.locationManager.requestWhenInUseAuthorization()
}
// Return current status - actual result comes via delegate
return self.checkPermissionsJson()
} else {
return self.checkPermissionsJson()
}
} else {
let error = ["error": "Location services are not enabled"]
if let jsonData = try? JSONSerialization.data(withJSONObject: error),
let jsonString = String(data: jsonData, encoding: .utf8) {
return jsonString
}
return ""
}
}
//
// CLLocationManagerDelegate methods
//
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let errorMessage = error.localizedDescription
// Notify all position callbacks
for (_, callback) in self.positionCallbacks {
let errorJson = "{\"error\":\"\(errorMessage)\"}"
callback(errorJson)
}
self.positionCallbacks.removeAll()
}
public func locationManager(
_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.last else {
return
}
let resultJson = self.convertLocationToJson(location)
// Notify all position callbacks
for (_, callback) in self.positionCallbacks {
callback(resultJson)
}
self.positionCallbacks.removeAll()
}
public func locationManager(
_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus
) {
if !self.positionCallbacks.isEmpty {
self.locationManager.requestLocation()
}
}
//
// Internal/Helper methods
//
private func convertLocationToJson(_ location: CLLocation) -> String {
var ret: [String: Any] = [:]
var coords: [String: Any] = [:]
coords["latitude"] = location.coordinate.latitude
coords["longitude"] = location.coordinate.longitude
coords["accuracy"] = location.horizontalAccuracy
coords["altitude"] = location.altitude
coords["altitudeAccuracy"] = location.verticalAccuracy
coords["speed"] = location.speed
coords["heading"] = location.course
ret["timestamp"] = Int((location.timestamp.timeIntervalSince1970 * 1000))
ret["coords"] = coords
if let jsonData = try? JSONSerialization.data(withJSONObject: ret),
let jsonString = String(data: jsonData, encoding: .utf8) {
return jsonString
}
return "{\"error\":\"Failed to serialize location\"}"
}
//
// Live Activity methods
//
/// Start a Live Activity showing current location
/// Returns JSON with activity ID or error
@objc public func startLiveActivityJson() -> String {
if #available(iOS 16.2, *) {
// Check if Live Activities are enabled
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
return "{\"error\":\"Live Activities are not enabled\"}"
}
// Get current location
guard let location = self.locationManager.location else {
return "{\"error\":\"No location available. Request location first.\"}"
}
let attributes = LocationPermissionAttributes(appName: "Geolocation Demo")
let contentState = LocationPermissionAttributes.ContentState(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
accuracy: location.horizontalAccuracy,
speed: location.speed >= 0 ? location.speed : nil,
heading: location.course >= 0 ? location.course : nil,
lastUpdated: Date()
)
do {
let activity = try Activity.request(
attributes: attributes,
content: .init(state: contentState, staleDate: nil),
pushType: nil
)
let result: [String: Any] = [
"activityId": activity.id,
"latitude": location.coordinate.latitude,
"longitude": location.coordinate.longitude,
"accuracy": location.horizontalAccuracy
]
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
let jsonString = String(data: jsonData, encoding: .utf8) {
return jsonString
}
return "{\"error\":\"Failed to serialize result\"}"
} catch {
return "{\"error\":\"Failed to start Live Activity: \(error.localizedDescription)\"}"
}
} else {
return "{\"error\":\"Live Activities require iOS 16.2+\"}"
}
}
/// Update the Live Activity with current location
@objc public func updateLiveActivityJson(_ statusJson: String) -> String {
if #available(iOS 16.2, *) {
// Get current location
guard let location = self.locationManager.location else {
return "{\"error\":\"No location available\"}"
}
let contentState = LocationPermissionAttributes.ContentState(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
accuracy: location.horizontalAccuracy,
speed: location.speed >= 0 ? location.speed : nil,
heading: location.course >= 0 ? location.course : nil,
lastUpdated: Date()
)
// Update all running activities of this type
Task {
for activity in Activity<LocationPermissionAttributes>.activities {
await activity.update(
ActivityContent(state: contentState, staleDate: nil)
)
}
}
let result: [String: Any] = [
"latitude": location.coordinate.latitude,
"longitude": location.coordinate.longitude,
"accuracy": location.horizontalAccuracy
]
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
let jsonString = String(data: jsonData, encoding: .utf8) {
return jsonString
}
return "{\"error\":\"Failed to serialize result\"}"
} else {
return "{\"error\":\"Live Activities require iOS 16.2+\"}"
}
}
/// End all Live Activities
@objc public func endLiveActivityJson() -> String {
if #available(iOS 16.2, *) {
Task {
for activity in Activity<LocationPermissionAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
return "{\"success\":true}"
} else {
return "{\"error\":\"Live Activities require iOS 16.2+\"}"
}
}
}
@@ -0,0 +1,50 @@
// Shared ActivityAttributes for Live Activities
// This MUST be the single source of truth for both the main app and widget extension
import Foundation
import ActivityKit
/// Live Activity attributes for displaying current location.
///
/// This struct is shared between the main app (which starts/updates activities)
/// and the widget extension (which renders them on the lock screen).
public struct LocationPermissionAttributes: ActivityAttributes {
/// Dynamic content that can be updated while the activity is running
public struct ContentState: Codable, Hashable {
/// Current latitude
public var latitude: Double
/// Current longitude
public var longitude: Double
/// Horizontal accuracy in meters
public var accuracy: Double
/// Current speed in m/s (nil if not available)
public var speed: Double?
/// Current heading in degrees (nil if not available)
public var heading: Double?
/// Last update timestamp
public var lastUpdated: Date
public init(
latitude: Double,
longitude: Double,
accuracy: Double,
speed: Double? = nil,
heading: Double? = nil,
lastUpdated: Date
) {
self.latitude = latitude
self.longitude = longitude
self.accuracy = accuracy
self.speed = speed
self.heading = heading
self.lastUpdated = lastUpdated
}
}
/// Static data set when the activity is started (cannot change)
public var appName: String
public init(appName: String) {
self.appName = appName
}
}
@@ -0,0 +1,12 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import XCTest
@testable import ExamplePlugin
final class ExamplePluginTests: XCTestCase {
func testExample() throws {
let plugin = ExamplePlugin()
}
}
@@ -0,0 +1,37 @@
// swift-tools-version:5.9
// Widget Extension for displaying location permission status on lock screen
//
// IMPORTANT: The target name MUST match the main app's Swift module name.
// The plugin uses "GeolocationPlugin" as its package/target name, so the
// widget must also use "GeolocationPlugin" for ActivityKit type matching.
import PackageDescription
let package = Package(
name: "GeolocationPlugin",
platforms: [
.iOS(.v17), // iOS 17+ for latest ActivityKit APIs
],
products: [
// Executable name must be "widget" for the build system to find it
// But the TARGET name determines the Swift module name
.executable(
name: "widget",
targets: ["GeolocationPlugin"]
)
],
dependencies: [],
targets: [
// Target name = Swift module name = "GeolocationPlugin"
// This MUST match the main app's Swift plugin module name!
.executableTarget(
name: "GeolocationPlugin",
path: "Sources",
linkerSettings: [
.linkedFramework("WidgetKit"),
.linkedFramework("SwiftUI"),
.linkedFramework("ActivityKit"),
]
)
]
)
@@ -0,0 +1,50 @@
// Shared ActivityAttributes for Live Activities
// This MUST be the single source of truth for both the main app and widget extension
import Foundation
import ActivityKit
/// Live Activity attributes for displaying current location.
///
/// This struct is shared between the main app (which starts/updates activities)
/// and the widget extension (which renders them on the lock screen).
public struct LocationPermissionAttributes: ActivityAttributes {
/// Dynamic content that can be updated while the activity is running
public struct ContentState: Codable, Hashable {
/// Current latitude
public var latitude: Double
/// Current longitude
public var longitude: Double
/// Horizontal accuracy in meters
public var accuracy: Double
/// Current speed in m/s (nil if not available)
public var speed: Double?
/// Current heading in degrees (nil if not available)
public var heading: Double?
/// Last update timestamp
public var lastUpdated: Date
public init(
latitude: Double,
longitude: Double,
accuracy: Double,
speed: Double? = nil,
heading: Double? = nil,
lastUpdated: Date
) {
self.latitude = latitude
self.longitude = longitude
self.accuracy = accuracy
self.speed = speed
self.heading = heading
self.lastUpdated = lastUpdated
}
}
/// Static data set when the activity is started (cannot change)
public var appName: String
public init(appName: String) {
self.appName = appName
}
}
@@ -0,0 +1,316 @@
// Widget Extension for Live Activity only
// Note: Having multiple widgets in the same bundle can cause Live Activities to show black
// See: https://developer.apple.com/forums/thread/807726
import ActivityKit
import SwiftUI
import WidgetKit
@main
struct LocationWidgetBundle: WidgetBundle {
var body: some Widget {
// Only include the Live Activity - other widgets can cause rendering issues
LocationPermissionLiveActivity()
}
}
// Helper to get accuracy color
func accuracyColor(_ accuracy: Double) -> Color {
if accuracy < 10 {
return .green
} else if accuracy < 50 {
return .yellow
} else if accuracy < 100 {
return .orange
} else {
return .red
}
}
// Helper to format coordinates with degree symbol
func formatCoord(_ value: Double, isLat: Bool) -> String {
let direction = isLat ? (value >= 0 ? "N" : "S") : (value >= 0 ? "E" : "W")
return String(format: "%.5f° %@", abs(value), direction)
}
// Live Activity widget
struct LocationPermissionLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LocationPermissionAttributes.self) { context in
// Lock screen view - flashy gradient design
ZStack {
// Gradient background
LinearGradient(
colors: [
Color(red: 0.1, green: 0.1, blue: 0.2),
Color(red: 0.05, green: 0.15, blue: 0.25)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: 12) {
// Header with pulsing indicator
HStack(spacing: 12) {
// Animated location icon with glow
ZStack {
Circle()
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
.frame(width: 44, height: 44)
Circle()
.fill(accuracyColor(context.state.accuracy).opacity(0.6))
.frame(width: 32, height: 32)
Image(systemName: "location.fill")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(context.attributes.appName)
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.white)
HStack(spacing: 4) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.caption2)
Text("LIVE")
.font(.caption2)
.fontWeight(.bold)
}
.foregroundColor(accuracyColor(context.state.accuracy))
}
Spacer()
// Accuracy with animated ring
VStack(spacing: 2) {
Text("\(Int(context.state.accuracy))")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(accuracyColor(context.state.accuracy))
.contentTransition(.numericText())
Text("meters")
.font(.caption2)
.foregroundColor(.gray)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(accuracyColor(context.state.accuracy).opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(accuracyColor(context.state.accuracy).opacity(0.5), lineWidth: 1)
)
)
}
// Coordinates in stylish cards
HStack(spacing: 8) {
// Latitude card
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.up.arrow.down")
.font(.caption2)
Text("LATITUDE")
.font(.caption2)
.fontWeight(.semibold)
}
.foregroundColor(.cyan.opacity(0.8))
Text(formatCoord(context.state.latitude, isLat: true))
.font(.system(.callout, design: .monospaced))
.fontWeight(.medium)
.foregroundColor(.white)
.contentTransition(.numericText())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.cyan.opacity(0.1))
)
// Longitude card
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.left.arrow.right")
.font(.caption2)
Text("LONGITUDE")
.font(.caption2)
.fontWeight(.semibold)
}
.foregroundColor(.purple.opacity(0.8))
Text(formatCoord(context.state.longitude, isLat: false))
.font(.system(.callout, design: .monospaced))
.fontWeight(.medium)
.foregroundColor(.white)
.contentTransition(.numericText())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.purple.opacity(0.1))
)
}
// Speed and heading row (if available)
if let speed = context.state.speed, speed >= 0 {
HStack(spacing: 16) {
// Speed
HStack(spacing: 6) {
Image(systemName: "speedometer")
.foregroundColor(.orange)
Text(String(format: "%.1f m/s", speed))
.font(.system(.caption, design: .monospaced))
.fontWeight(.medium)
.foregroundColor(.white)
.contentTransition(.numericText())
}
// Heading if available
if let heading = context.state.heading, heading >= 0 {
HStack(spacing: 6) {
Image(systemName: "safari")
.foregroundColor(.mint)
.rotationEffect(.degrees(heading))
Text(String(format: "%.0f°", heading))
.font(.system(.caption, design: .monospaced))
.fontWeight(.medium)
.foregroundColor(.white)
.contentTransition(.numericText())
}
}
Spacer()
// Last updated
Text(context.state.lastUpdated, style: .relative)
.font(.caption2)
.foregroundColor(.gray)
}
}
}
.padding()
}
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded regions
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
ZStack {
Circle()
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
.frame(width: 36, height: 36)
Image(systemName: "location.fill")
.foregroundColor(accuracyColor(context.state.accuracy))
.font(.system(size: 16, weight: .bold))
}
Text("\(Int(context.state.accuracy))m")
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(accuracyColor(context.state.accuracy))
.contentTransition(.numericText())
}
}
DynamicIslandExpandedRegion(.center) {
VStack(spacing: 6) {
// Latitude
HStack(spacing: 4) {
Text("LAT")
.font(.caption2)
.foregroundColor(.cyan.opacity(0.7))
Text(String(format: "%.5f°", context.state.latitude))
.font(.system(.caption, design: .monospaced))
.fontWeight(.semibold)
.foregroundColor(.cyan)
.contentTransition(.numericText())
}
// Longitude
HStack(spacing: 4) {
Text("LON")
.font(.caption2)
.foregroundColor(.purple.opacity(0.7))
Text(String(format: "%.5f°", context.state.longitude))
.font(.system(.caption, design: .monospaced))
.fontWeight(.semibold)
.foregroundColor(.purple)
.contentTransition(.numericText())
}
}
}
DynamicIslandExpandedRegion(.trailing) {
if let speed = context.state.speed, speed >= 0 {
VStack(alignment: .trailing, spacing: 4) {
Image(systemName: "speedometer")
.foregroundColor(.orange)
.font(.caption)
Text(String(format: "%.1f", speed))
.font(.system(.caption, design: .rounded))
.fontWeight(.bold)
.foregroundColor(.white)
.contentTransition(.numericText())
Text("m/s")
.font(.caption2)
.foregroundColor(.gray)
}
} else {
// Show heading compass if no speed
if let heading = context.state.heading, heading >= 0 {
VStack(spacing: 2) {
Image(systemName: "safari")
.font(.title3)
.foregroundColor(.mint)
.rotationEffect(.degrees(heading))
Text(String(format: "%.0f°", heading))
.font(.caption2)
.foregroundColor(.white)
}
}
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text(context.attributes.appName)
.font(.caption2)
.foregroundColor(.gray)
Spacer()
Text(context.state.lastUpdated, style: .relative)
.font(.caption2)
.foregroundColor(.gray)
}
}
} compactLeading: {
ZStack {
Circle()
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
.frame(width: 24, height: 24)
Image(systemName: "location.fill")
.foregroundColor(accuracyColor(context.state.accuracy))
.font(.caption)
}
} compactTrailing: {
Text(String(format: "%.4f°", context.state.latitude))
.font(.system(.caption2, design: .monospaced))
.fontWeight(.medium)
.foregroundColor(.cyan)
.contentTransition(.numericText())
} minimal: {
ZStack {
Circle()
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
.frame(width: 22, height: 22)
Image(systemName: "location.fill")
.foregroundColor(accuracyColor(context.state.accuracy))
.font(.system(size: 10, weight: .bold))
}
}
}
}
}
@@ -0,0 +1,282 @@
//! A simple Dioxus app demonstrating how to build a native plugin using manganis.
//!
//! This example shows how to use the `#[manganis::ffi]` macro to automatically generate
//! FFI bindings between Rust and native platforms (Swift/Kotlin).
//!
//! It also demonstrates how to use the widget!() macro to bundle a Widget Extension
//! for Live Activities on iOS.
use dioxus::prelude::*;
// Import the local plugin module
mod plugin;
#[cfg(target_os = "ios")]
use plugin::LiveActivityResult;
use plugin::{Geolocation, PermissionState, PermissionStatus, Position, PositionOptions};
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
let mut geolocation = use_signal(Geolocation::new);
let mut permission_status = use_signal(|| None::<PermissionStatus>);
let mut last_position = use_signal(|| None::<Position>);
let mut error = use_signal(|| None::<String>);
let mut use_high_accuracy = use_signal(|| true);
let mut max_age_input = use_signal(|| String::from("0"));
let on_check_permissions = {
move |_| match geolocation.write().check_permissions() {
Ok(status) => {
permission_status.set(Some(status));
error.set(None);
}
Err(err) => error.set(Some(err.to_string())),
}
};
let on_request_permissions = move |_| {
let mut geo = geolocation.write();
match geo.request_permissions(None) {
Ok(_) => match geo.check_permissions() {
Ok(status) => {
permission_status.set(Some(status));
error.set(None);
}
Err(err) => error.set(Some(err.to_string())),
},
Err(err) => error.set(Some(err.to_string())),
}
};
let on_toggle_accuracy = move |_| use_high_accuracy.toggle();
let on_max_age_input = move |evt: FormEvent| max_age_input.set(evt.value());
let on_fetch_position = move |_| {
let maximum_age = max_age_input.read().trim().parse::<u32>().unwrap_or(0);
let options = PositionOptions {
enable_high_accuracy: use_high_accuracy(),
timeout: 10_000,
maximum_age,
};
match geolocation.write().get_current_position(Some(options)) {
Ok(position) => {
last_position.set(Some(position));
error.set(None);
}
Err(err) => error.set(Some(err.to_string())),
}
};
let accuracy_label = if use_high_accuracy() {
"High accuracy: on"
} else {
"High accuracy: off"
};
rsx! {
Stylesheet { href: asset!("/assets/main.css") }
main { class: "app",
header { class: "hero",
div { class: "hero__copy",
h1 { "Geolocation plugin demo" }
p { "One-shot location fetching through the Dioxus geolocation plugin.
Measure permissions, request access, and inspect the last fix received from the device." }
}
}
div { class: "cards",
section { class: "card",
h2 { "Permissions" }
p { class: "muted",
"First, inspect what the OS currently allows this app to do. \
On Android & iOS these calls talk to the native permission dialog APIs." }
div { class: "button-row",
button { onclick: on_check_permissions, "Check permissions" }
button { class: "secondary", onclick: on_request_permissions, "Request permissions" }
}
match permission_status() {
Some(status) => rsx! {
div { class: "status-grid",
PermissionBadge { label: "Location".to_string(), state: status.location }
PermissionBadge { label: "Coarse location".to_string(), state: status.coarse_location }
}
},
None => rsx!(p { class: "muted", "Tap “Check permissions” to see the current status." }),
}
}
section { class: "card",
h2 { "Current position" }
p { class: "muted",
"The plugin resolves the device location once per request (no background watch). \
Configure the query and then fetch the coordinates." }
div { class: "settings",
button {
class: if use_high_accuracy() { "toggle toggle--active" } else { "toggle" },
onclick: on_toggle_accuracy,
"{accuracy_label}"
}
label { class: "field",
span { "Max cached age (ms)" }
input {
r#type: "number",
inputmode: "numeric",
min: "0",
placeholder: "0",
value: "{max_age_input()}",
oninput: on_max_age_input,
}
}
}
button { class: "primary full-width", onclick: on_fetch_position, "Get current position" }
match last_position() {
Some(position) => {
let snapshot = position.clone();
let coords = snapshot.coords.clone();
rsx! {
div { class: "position",
h3 { "Latest reading" }
p { class: "muted", "Timestamp: {snapshot.timestamp} ms since Unix epoch" }
div { class: "position__grid",
CoordinateRow { label: "Latitude".to_string(), value: format!("{:.6}", coords.latitude) }
CoordinateRow { label: "Longitude".to_string(), value: format!("{:.6}", coords.longitude) }
CoordinateRow { label: "Accuracy (m)".to_string(), value: format!("{:.1}", coords.accuracy) }
CoordinateRow { label: "Altitude (m)".to_string(), value: format_optional(coords.altitude) }
CoordinateRow { label: "Altitude accuracy (m)".to_string(), value: format_optional(coords.altitude_accuracy) }
CoordinateRow { label: "Speed (m/s)".to_string(), value: format_optional(coords.speed) }
CoordinateRow { label: "Heading (°)".to_string(), value: format_optional(coords.heading) }
}
}
}
}
None => rsx!(p { class: "muted", "No location fetched yet." }),
}
}
// Live Activity card (iOS only)
LiveActivityCard { geolocation, error }
}
if let Some(message) = error() {
div { class: "error-banner", "Last error: {message}" }
}
}
}
}
#[component]
fn PermissionBadge(label: String, state: PermissionState) -> Element {
let (text, class) = match state {
PermissionState::Granted => ("Granted", "badge badge--granted"),
PermissionState::Denied => ("Denied", "badge badge--denied"),
PermissionState::Prompt | PermissionState::PromptWithRationale => {
("Needs prompt", "badge badge--prompt")
}
};
rsx! {
div { class: "permission-row",
span { class: "muted", "{label}" }
span { class: class, "{text}" }
}
}
}
#[component]
fn CoordinateRow(label: String, value: String) -> Element {
rsx! {
div { class: "coordinate-row",
span { class: "muted", "{label}" }
strong { "{value}" }
}
}
}
fn format_optional(value: Option<f64>) -> String {
value
.map(|inner| format!("{inner:.2}"))
.unwrap_or_else(|| "—".to_string())
}
#[cfg(target_os = "ios")]
#[component]
fn LiveActivityCard(
mut geolocation: Signal<Geolocation>,
mut error: Signal<Option<String>>,
) -> Element {
let mut live_activity = use_signal(|| None::<LiveActivityResult>);
let on_start_live_activity = move |_| match geolocation.write().start_live_activity() {
Ok(result) => {
live_activity.set(Some(result));
error.set(None);
}
Err(err) => error.set(Some(err.to_string())),
};
let on_update_live_activity = move |_| match geolocation.write().update_live_activity() {
Ok(_) => error.set(None),
Err(err) => error.set(Some(err.to_string())),
};
let on_end_live_activity = move |_| match geolocation.write().end_live_activity() {
Ok(_) => {
live_activity.set(None);
error.set(None);
}
Err(err) => error.set(Some(err.to_string())),
};
rsx! {
section { class: "card",
h2 { "Live Activity" }
p { class: "muted",
"Start a Live Activity to show your current location on the lock screen. \
Fetch your position first, then start the activity." }
div { class: "button-row",
button { onclick: on_start_live_activity, "Start Activity" }
button { class: "secondary", onclick: on_update_live_activity, "Update" }
button { class: "secondary", onclick: on_end_live_activity, "End" }
}
match live_activity() {
Some(activity) => rsx! {
div { class: "status-grid",
div { class: "permission-row",
span { class: "muted", "Activity ID" }
span { "{activity.activity_id}" }
}
div { class: "permission-row",
span { class: "muted", "Latitude" }
span { "{activity.latitude:.6}" }
}
div { class: "permission-row",
span { class: "muted", "Longitude" }
span { "{activity.longitude:.6}" }
}
div { class: "permission-row",
span { class: "muted", "Accuracy" }
span { class: "badge badge--granted", "{activity.accuracy:.1}m" }
}
}
},
None => rsx! {
p { class: "muted", "No Live Activity running." }
},
}
}
}
}
#[cfg(not(target_os = "ios"))]
#[component]
fn LiveActivityCard(geolocation: Signal<Geolocation>, error: Signal<Option<String>>) -> Element {
VNode::empty()
}
@@ -0,0 +1,48 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::{ser::Serializer, Serialize};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// JSON serialization/deserialization error
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Platform bridge error
#[error("Platform bridge error: {0}")]
PlatformBridge(String),
/// Location unavailable
#[error("Location unavailable: {0}")]
LocationUnavailable(String),
/// Live Activity error (iOS 16.1+)
#[cfg(target_os = "ios")]
#[error("Live Activity error: {0}")]
LiveActivity(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
impl From<&str> for Error {
fn from(s: &str) -> Self {
Error::PlatformBridge(s.to_string())
}
}
impl From<String> for Error {
fn from(s: String) -> Self {
Error::PlatformBridge(s)
}
}
@@ -0,0 +1,313 @@
#![allow(non_snake_case)]
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Dioxus Geolocation Plugin
//!
//! This plugin provides APIs for getting and tracking the device's current position
//! on Android and iOS mobile platforms.
//!
//! This example demonstrates the use of the `#[manganis::ffi]` macro for automatic
//! FFI binding generation between Rust and native platforms.
pub use models::*;
mod error;
mod models;
pub use error::{Error, Result};
// Note: Permissions are now declared in Dioxus.toml using the unified manifest system.
// See Dioxus.toml in the project root for the permission configuration:
//
// [permissions]
// location = { precision = "fine", description = "Access your precise location..." }
//
// The CLI automatically maps these to platform-specific identifiers:
// - Android: ACCESS_FINE_LOCATION in AndroidManifest.xml
// - iOS: NSLocationWhenInUseUsageDescription in Info.plist
/// Access to the geolocation APIs.
///
/// This struct provides a unified interface for accessing geolocation functionality
/// on both Android and iOS platforms. It uses the `#[manganis::ffi]` macro for
/// automatic FFI binding generation.
///
/// # Example
///
/// ```rust,no_run
/// use plugin::{Geolocation, PermissionState, PositionOptions};
///
/// let mut geolocation = Geolocation::new();
///
/// // Check permissions
/// let status = geolocation.check_permissions()?;
/// if status.location == PermissionState::Prompt {
/// let new_status = geolocation.request_permissions(None)?;
/// }
///
/// // Get current position
/// let options = PositionOptions {
/// enable_high_accuracy: true,
/// timeout: 10000,
/// maximum_age: 0,
/// };
/// let position = geolocation.get_current_position(Some(options))?;
/// println!("Latitude: {}, Longitude: {}", position.coords.latitude, position.coords.longitude);
///
/// # Ok::<(), plugin::Error>(())
/// ```
pub struct Geolocation {
plugin: Option<GeolocationPlugin>,
}
impl Geolocation {
/// Create a new Geolocation instance
pub fn new() -> Self {
Self { plugin: None }
}
/// Get or initialize the plugin instance
fn get_plugin(&mut self) -> Result<&GeolocationPlugin> {
if self.plugin.is_none() {
self.plugin = Some(GeolocationPlugin::new()?);
}
Ok(self.plugin.as_ref().unwrap())
}
/// Get the device's current position.
///
/// # Arguments
///
/// * `options` - Optional position options. If `None`, default options are used.
///
/// # Returns
///
/// Returns the current position or an error if the location cannot be obtained.
pub fn get_current_position(&mut self, options: Option<PositionOptions>) -> Result<Position> {
let options = options.unwrap_or_default();
let options_json = serde_json::to_string(&options).map_err(Error::Json)?;
let plugin = self.get_plugin()?;
let result_json = getCurrentPositionJson(plugin, options_json)?;
// Check for error in response
let json_value: serde_json::Value =
serde_json::from_str(&result_json).map_err(Error::Json)?;
if let Some(error_msg) = json_value.get("error") {
return Err(Error::LocationUnavailable(
error_msg.as_str().unwrap_or("Unknown error").to_string(),
));
}
let position: Position = serde_json::from_str(&result_json).map_err(Error::Json)?;
Ok(position)
}
/// Check the current permission status.
///
/// # Returns
///
/// Returns the permission status for location and coarse location permissions.
pub fn check_permissions(&mut self) -> Result<PermissionStatus> {
let plugin = self.get_plugin()?;
let result_json = checkPermissionsJson(plugin)?;
let status: PermissionStatus = serde_json::from_str(&result_json).map_err(Error::Json)?;
Ok(status)
}
/// Request location permissions from the user.
///
/// # Arguments
///
/// * `permissions` - Optional list of specific permission types to request.
/// If `None`, requests all location permissions.
///
/// # Returns
///
/// Returns the permission status after the user responds to the permission request.
pub fn request_permissions(
&mut self,
permissions: Option<Vec<PermissionType>>,
) -> Result<PermissionStatus> {
let perms_json = serde_json::to_string(&permissions).map_err(Error::Json)?;
let plugin = self.get_plugin()?;
let result_json = requestPermissionsJson(plugin, perms_json)?;
let status: PermissionStatus = serde_json::from_str(&result_json).map_err(Error::Json)?;
Ok(status)
}
// =========================================================================
// Live Activity methods (iOS 16.1+)
// =========================================================================
/// Start a Live Activity showing the current permission status.
///
/// Live Activities appear on the lock screen and Dynamic Island (on supported devices).
/// Requires iOS 16.1+ and a Widget Extension for the UI (see docs).
///
/// # Returns
///
/// Returns the activity ID and current permission status, or an error.
#[cfg(target_os = "ios")]
pub fn start_live_activity(&mut self) -> Result<LiveActivityResult> {
let plugin = self.get_plugin()?;
let result_json = startLiveActivityJson(plugin)?;
// Check for error in response
let json_value: serde_json::Value =
serde_json::from_str(&result_json).map_err(Error::Json)?;
if let Some(error_msg) = json_value.get("error") {
return Err(Error::LiveActivity(
error_msg.as_str().unwrap_or("Unknown error").to_string(),
));
}
let result: LiveActivityResult = serde_json::from_str(&result_json).map_err(Error::Json)?;
Ok(result)
}
/// Update the Live Activity with the current permission status.
///
/// Call this after permission changes to reflect the new state.
#[cfg(target_os = "ios")]
pub fn update_live_activity(&mut self) -> Result<LiveActivityUpdate> {
let plugin = self.get_plugin()?;
let result_json = updateLiveActivityJson(plugin, "{}".to_string())?;
let json_value: serde_json::Value =
serde_json::from_str(&result_json).map_err(Error::Json)?;
if let Some(error_msg) = json_value.get("error") {
return Err(Error::LiveActivity(
error_msg.as_str().unwrap_or("Unknown error").to_string(),
));
}
let result: LiveActivityUpdate = serde_json::from_str(&result_json).map_err(Error::Json)?;
Ok(result)
}
/// End all Live Activities for this app.
#[cfg(target_os = "ios")]
pub fn end_live_activity(&mut self) -> Result<()> {
let plugin = self.get_plugin()?;
let result_json = endLiveActivityJson(plugin)?;
let json_value: serde_json::Value =
serde_json::from_str(&result_json).map_err(Error::Json)?;
if let Some(error_msg) = json_value.get("error") {
return Err(Error::LiveActivity(
error_msg.as_str().unwrap_or("Unknown error").to_string(),
));
}
Ok(())
}
}
impl Default for Geolocation {
fn default() -> Self {
Self::new()
}
}
/// iOS/macOS native bindings - the macro generates all FFI code automatically.
/// The path "src/ios/plugin" points to the SwiftPM package containing GeolocationPlugin.swift
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[manganis::ffi("src/ios/plugin")]
extern "Swift" {
/// The native GeolocationPlugin class
pub type GeolocationPlugin;
/// Get current position as JSON string
/// Swift signature: func getCurrentPositionJson(_ optionsJson: String) -> String
pub fn getCurrentPositionJson(this: &GeolocationPlugin, optionsJson: String) -> String;
/// Check permissions and return status as JSON
/// Swift signature: func checkPermissionsJson() -> String
pub fn checkPermissionsJson(this: &GeolocationPlugin) -> String;
/// Request permissions with optional types list as JSON, return status as JSON
/// Swift signature: func requestPermissionsJson(_ permissionsJson: String) -> String
pub fn requestPermissionsJson(this: &GeolocationPlugin, permissionsJson: String) -> String;
/// Start a Live Activity showing permission status (iOS 16.1+)
/// Swift signature: func startLiveActivityJson() -> String
pub fn startLiveActivityJson(this: &GeolocationPlugin) -> String;
/// Update the Live Activity with current permission status
/// Swift signature: func updateLiveActivityJson(_ statusJson: String) -> String
pub fn updateLiveActivityJson(this: &GeolocationPlugin, statusJson: String) -> String;
/// End all Live Activities
/// Swift signature: func endLiveActivityJson() -> String
pub fn endLiveActivityJson(this: &GeolocationPlugin) -> String;
}
/// Android native bindings - the macro generates all JNI code automatically.
/// The path "src/android" points to the Gradle project containing GeolocationPlugin.kt
#[cfg(target_os = "android")]
#[manganis::ffi("src/android")]
extern "Kotlin" {
/// The native GeolocationPlugin class
pub type GeolocationPlugin;
/// Get current position as JSON string
/// Kotlin signature: fun getCurrentPositionJson(optionsJson: String): String
pub fn getCurrentPositionJson(this: &GeolocationPlugin, optionsJson: String) -> String;
/// Check permissions and return status as JSON
/// Kotlin signature: fun checkPermissionsJson(): String
pub fn checkPermissionsJson(this: &GeolocationPlugin) -> String;
/// Request permissions with optional types list as JSON, return status as JSON
/// Kotlin signature: fun requestPermissionsJson(permissionsJson: String): String
pub fn requestPermissionsJson(this: &GeolocationPlugin, permissionsJson: String) -> String;
}
// =============================================================================
// Stub for non-native platforms (web, Linux desktop, etc.)
// =============================================================================
#[cfg(not(any(
all(any(target_os = "ios", target_os = "macos")),
all(target_os = "android")
)))]
use fallback::*;
#[cfg(not(any(
all(any(target_os = "ios", target_os = "macos")),
all(target_os = "android")
)))]
mod fallback {
#![allow(non_snake_case)]
use super::{Error, Result};
pub struct GeolocationPlugin;
impl GeolocationPlugin {
pub fn new() -> Result<Self> {
Err(Error::PlatformBridge(
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
))
}
}
pub fn getCurrentPositionJson(_: &GeolocationPlugin, _: String) -> Result<String> {
Err(Error::PlatformBridge(
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
))
}
pub fn checkPermissionsJson(_: &GeolocationPlugin) -> Result<String> {
Err(Error::PlatformBridge(
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
))
}
pub fn requestPermissionsJson(_: &GeolocationPlugin, _: String) -> Result<String> {
Err(Error::PlatformBridge(
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
))
}
}
@@ -0,0 +1,134 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
/// Permission state for geolocation permissions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum PermissionState {
/// Permission not yet determined (user hasn't been asked)
#[default]
Prompt,
/// Permission prompt shown with rationale (Android 12+)
PromptWithRationale,
/// Permission granted
Granted,
/// Permission denied
Denied,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionStatus {
/// Permission state for the location alias.
///
/// On Android it requests/checks both ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION permissions.
///
/// On iOS it requests/checks location permissions.
pub location: PermissionState,
/// Permissions state for the coarseLocation alias.
///
/// On Android it requests/checks ACCESS_COARSE_LOCATION.
///
/// On Android 12+, users can choose between Approximate location (ACCESS_COARSE_LOCATION) and Precise location (ACCESS_FINE_LOCATION).
///
/// On iOS it will have the same value as the `location` alias.
pub coarse_location: PermissionState,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PositionOptions {
/// High accuracy mode (such as GPS, if available)
/// Will be ignored on Android 12+ if users didn't grant the ACCESS_FINE_LOCATION permission.
pub enable_high_accuracy: bool,
/// The maximum wait time in milliseconds for location updates.
/// Default: 10000
/// On Android the timeout gets ignored for getCurrentPosition.
/// Ignored on iOS.
// TODO: Handle Infinity and default to it.
// TODO: Should be u64+ but specta doesn't like that?
pub timeout: u32,
/// The maximum age in milliseconds of a possible cached position that is acceptable to return.
/// Default: 0
/// Ignored on iOS.
// TODO: Handle Infinity.
// TODO: Should be u64+ but specta doesn't like that?
pub maximum_age: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PermissionType {
Location,
CoarseLocation,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Coordinates {
/// Latitude in decimal degrees.
pub latitude: f64,
/// Longitude in decimal degrees.
pub longitude: f64,
/// Accuracy level of the latitude and longitude coordinates in meters.
pub accuracy: f64,
/// Accuracy level of the altitude coordinate in meters, if available.
/// Available on all iOS versions and on Android 8 and above.
pub altitude_accuracy: Option<f64>,
/// The altitude the user is at, if available.
pub altitude: Option<f64>,
// The speed the user is traveling, if available.
pub speed: Option<f64>,
/// The heading the user is facing, if available.
pub heading: Option<f64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
/// Creation time for these coordinates.
// TODO: Check if we're actually losing precision.
pub timestamp: u64,
/// The GPS coordinates along with the accuracy of the data.
pub coords: Coordinates,
}
// =============================================================================
// Live Activity types (iOS 16.1+)
// =============================================================================
/// Result from starting a Live Activity
#[cfg(target_os = "ios")]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveActivityResult {
/// Unique identifier for the activity
pub activity_id: String,
/// Current latitude displayed in the activity
pub latitude: f64,
/// Current longitude displayed in the activity
pub longitude: f64,
/// Horizontal accuracy in meters
pub accuracy: f64,
}
/// Result from updating a Live Activity
#[cfg(target_os = "ios")]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveActivityUpdate {
/// Current latitude after update
pub latitude: f64,
/// Current longitude after update
pub longitude: f64,
/// Horizontal accuracy in meters
pub accuracy: f64,
}