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:
73 files changed
+13766
-335
No files matched your search
@@ -119,6 +119,25 @@ jobs:
|
||||
cache-all-crates: "true"
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
schema:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: Check Schema
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.88.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-all-crates: "true"
|
||||
- name: Generate schema
|
||||
run: cargo run -p dioxus-cli -- config schema --out packages/cli/schema.json
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code packages/cli/schema.json; then
|
||||
echo "::error::Schema is out of date. Run 'dx config schema --out packages/cli/schema.json' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docs:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: Docs
|
||||
|
||||
+3
-1
@@ -30,7 +30,9 @@ node_modules/
|
||||
/test-results/
|
||||
/packages/playwright-report/
|
||||
/packages/playwright/.cache/
|
||||
|
||||
# Allow geolocation plugin sources to be tracked
|
||||
/packages/geolocation/android/build/
|
||||
/packages/geolocation/android/.gradle/
|
||||
# Zed
|
||||
.zed/
|
||||
|
||||
|
||||
Generated
+41
-1
@@ -5550,6 +5550,7 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rustls 0.23.36",
|
||||
"schemars 0.8.22",
|
||||
"self-replace",
|
||||
"self_update",
|
||||
"sentry-backtrace",
|
||||
@@ -7900,6 +7901,17 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "geolocation-native-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dioxus",
|
||||
"manganis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -11016,8 +11028,12 @@ version = "0.7.3"
|
||||
dependencies = [
|
||||
"const-serialize 0.7.2",
|
||||
"const-serialize 0.8.0-alpha.0",
|
||||
"jni 0.21.1",
|
||||
"manganis-core",
|
||||
"manganis-macro",
|
||||
"ndk-context",
|
||||
"objc2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -15034,6 +15050,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"schemars_derive 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
@@ -15054,11 +15082,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"schemars_derive 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
|
||||
+18
-42
@@ -8,7 +8,6 @@ members = [
|
||||
"packages/cli-opt",
|
||||
"packages/cli-config",
|
||||
"packages/cli-telemetry",
|
||||
"packages/core-types",
|
||||
"packages/core-macro",
|
||||
"packages/config-macro",
|
||||
"packages/router-macro",
|
||||
@@ -24,42 +23,22 @@ members = [
|
||||
"packages/liveview",
|
||||
"packages/autofmt",
|
||||
"packages/check",
|
||||
"packages/config-macro",
|
||||
"packages/core-macro",
|
||||
"packages/core-types",
|
||||
"packages/core",
|
||||
"packages/desktop",
|
||||
"packages/devtools-types",
|
||||
"packages/devtools",
|
||||
"packages/dioxus",
|
||||
"packages/document",
|
||||
"packages/extension",
|
||||
"packages/fullstack",
|
||||
"packages/fullstack-core",
|
||||
"packages/fullstack-macro",
|
||||
"packages/fullstack-server",
|
||||
"packages/generational-box",
|
||||
"packages/history",
|
||||
"packages/hooks",
|
||||
"packages/html-internal-macro",
|
||||
"packages/html",
|
||||
"packages/interpreter",
|
||||
"packages/lazy-js-bundle",
|
||||
"packages/liveview",
|
||||
"packages/router-macro",
|
||||
"packages/router",
|
||||
"packages/rsx-hotreload",
|
||||
"packages/rsx-rosetta",
|
||||
"packages/rsx",
|
||||
"packages/signals",
|
||||
"packages/stores",
|
||||
"packages/stores-macro",
|
||||
"packages/ssr",
|
||||
"packages/lazy-js-bundle",
|
||||
"packages/cli-config",
|
||||
"packages/devtools",
|
||||
"packages/devtools-types",
|
||||
"packages/rsx-hotreload",
|
||||
"packages/const-serialize",
|
||||
"packages/const-serialize-macro",
|
||||
"packages/dx-wire-format",
|
||||
@@ -77,9 +56,22 @@ members = [
|
||||
# Playwright tests
|
||||
"packages/playwright-tests/liveview",
|
||||
"packages/playwright-tests/web",
|
||||
"packages/playwright-tests/web-routing",
|
||||
"packages/playwright-tests/web-hash-routing",
|
||||
"packages/playwright-tests/barebones-template",
|
||||
"packages/playwright-tests/fullstack",
|
||||
"packages/playwright-tests/fullstack-errors",
|
||||
"packages/playwright-tests/fullstack-mounted",
|
||||
"packages/playwright-tests/fullstack-spread",
|
||||
"packages/playwright-tests/fullstack-routing",
|
||||
"packages/playwright-tests/fullstack-hydration-order",
|
||||
"packages/playwright-tests/suspense-carousel",
|
||||
"packages/playwright-tests/nested-suspense",
|
||||
"packages/playwright-tests/cli-optimization",
|
||||
"packages/playwright-tests/wasm-split-harness",
|
||||
"packages/playwright-tests/default-features-disabled",
|
||||
"packages/playwright-tests/fullstack-error-codes",
|
||||
"packages/playwright-tests/windows-headless",
|
||||
|
||||
# manganis
|
||||
"packages/manganis/manganis",
|
||||
@@ -106,6 +98,7 @@ members = [
|
||||
"examples/01-app-demos/bluetooth-scanner",
|
||||
"examples/01-app-demos/file-explorer",
|
||||
"examples/01-app-demos/hotdog",
|
||||
"examples/01-app-demos/geolocation-native-plugin",
|
||||
|
||||
# Fullstack examples
|
||||
"examples/07-fullstack/hello-world",
|
||||
@@ -121,28 +114,9 @@ members = [
|
||||
"examples/10-integrations/native-headless",
|
||||
"examples/10-integrations/native-headless-in-bevy",
|
||||
"examples/10-integrations/bevy",
|
||||
|
||||
# Playwright tests
|
||||
"packages/playwright-tests/liveview",
|
||||
"packages/playwright-tests/web",
|
||||
"packages/playwright-tests/web-routing",
|
||||
"packages/playwright-tests/web-hash-routing",
|
||||
"packages/playwright-tests/barebones-template",
|
||||
"packages/playwright-tests/fullstack",
|
||||
"packages/playwright-tests/fullstack-errors",
|
||||
"packages/playwright-tests/fullstack-mounted",
|
||||
"packages/playwright-tests/fullstack-spread",
|
||||
"packages/playwright-tests/fullstack-routing",
|
||||
"packages/playwright-tests/fullstack-hydration-order",
|
||||
"packages/playwright-tests/suspense-carousel",
|
||||
"packages/playwright-tests/nested-suspense",
|
||||
"packages/playwright-tests/cli-optimization",
|
||||
"packages/playwright-tests/wasm-split-harness",
|
||||
"packages/playwright-tests/default-features-disabled",
|
||||
"packages/playwright-tests/fullstack-error-codes",
|
||||
"packages/playwright-tests/windows-headless",
|
||||
]
|
||||
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.3"
|
||||
|
||||
@@ -213,7 +187,8 @@ manganis-core = { path = "packages/manganis/manganis-core", version = "0.7.3" }
|
||||
manganis-macro = { path = "packages/manganis/manganis-macro", version = "0.7.3" }
|
||||
manganis-core-07 = { path = "packages/manganis/manganis-07", version = "0.7.2" }
|
||||
|
||||
# const-serialize. these are on "alpha" versions, but really, we are prepping for 0.8 but not committing to it yet
|
||||
# const-serialize.
|
||||
# these are on "alpha" versions, but really, we are prepping for 0.8 but not committing to it yet
|
||||
# once the workspace moves onto 0.8, we can clean this up, moving the const-serialize stuff back to normal versions
|
||||
const-serialize = { path = "packages/const-serialize", version = "0.8.0-alpha.0" }
|
||||
const-serialize-macro = { path = "packages/const-serialize-macro", version = "0.8.0-alpha.0" }
|
||||
@@ -292,6 +267,7 @@ rustls = { version = "0.23.28", default-features = false, features = [
|
||||
] }
|
||||
serde_json = "1.0.140"
|
||||
serde = "1.0.219"
|
||||
schemars = "1.0"
|
||||
syn = "2.0"
|
||||
quote = "1.0"
|
||||
axum-core = "0.5"
|
||||
|
||||
@@ -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.
|
||||
+10
@@ -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>
|
||||
+87
@@ -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
|
||||
}
|
||||
}
|
||||
+179
@@ -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])),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
+322
@@ -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+\"}"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+12
@@ -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"),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+316
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
# Native Plugin System Design Document
|
||||
|
||||
## Summary
|
||||
|
||||
Build a system that lets Rust projects include Swift/Kotlin/C source files via macros, with metadata emitted through the linker. A bundler extracts this metadata and compiles/links the native code into the final app.
|
||||
|
||||
We're adding this feature to manganis by leveraging the recently added cbor support for general assets.
|
||||
|
||||
## Core Concept
|
||||
|
||||
```rust
|
||||
#[manganis::ffi("/src/android")]
|
||||
extern "Kotlin" {
|
||||
fn do_thing() -> JObject;
|
||||
}
|
||||
|
||||
#[manganis::ffi("/src/ios")]
|
||||
extern "Swift" {
|
||||
fn do_thing() -> NSObject;
|
||||
}
|
||||
```
|
||||
|
||||
The macro:
|
||||
1. Emits a new asset type (source folder / source file)
|
||||
2. Generates the relevant ffi functions
|
||||
3. The bundler later extracts metadata and compiles the sources
|
||||
|
||||
## Why This Approach
|
||||
|
||||
| Problem with build.rs | Our solution |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| Tied to Cargo | Linker metadata works with any build system |
|
||||
| Slows rust-analyzer | Zero impact on IDE |
|
||||
| Finicky caching | Bundler handles caching |
|
||||
| Must run before rustc | Bundler runs after rustc |
|
||||
|
||||
The tradeoff: Rust can't use *outputs* of native compilation (generated headers, etc.). For our use case (runtime FFI via JNI/ObjC), this is fine.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: The Macro
|
||||
|
||||
### Basic API
|
||||
|
||||
```rust
|
||||
// Annotate an extern block with hardcoded supported languages (swift, kotlin, java, etc)
|
||||
// Additional props may be passed in as necessary.
|
||||
#[manganis::ffi("/src/ios", some_prop = "123")]
|
||||
extern "Swift" {
|
||||
// objects that may be declared, using `type` syntax
|
||||
pub type SomeSwiftObject;
|
||||
|
||||
// functions associated with said object
|
||||
// values passed must be pointer-like or values that can be coerced between languages
|
||||
//
|
||||
// calling this goes through runtime lookup instead of actually being linked
|
||||
// for kotlin, this would be a jni lookup and call
|
||||
pub fn do_thing_with_swift_object_swift(this: &SomeSwiftObject) -> Option<u32>;
|
||||
}
|
||||
|
||||
// User can extend the extern types with their own rusty methods
|
||||
impl SomeSwiftObject {
|
||||
pub fn new() -> Self {
|
||||
// objc code, constructor maybe?
|
||||
}
|
||||
|
||||
// custom extensions
|
||||
pub fn custom_wrapper(&mut self) {
|
||||
_ = self.do_thing_with_swift_object_swift().unwrap();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Macro
|
||||
|
||||
- [ ] Add new the manganis types to manganis-core for emitting source folders
|
||||
- [ ] Come up with more work to make the macro ready
|
||||
|
||||
### Bundler
|
||||
|
||||
- [ ] Extract ffi blocks from the manganis extractor
|
||||
- [ ] Parse PluginMeta entries
|
||||
- [ ] Deduplicate paths
|
||||
- [ ] Swift compilation
|
||||
- [ ] Invoke swiftc
|
||||
- [ ] Handle iOS vs macOS targets
|
||||
- [ ] Framework/module configuration
|
||||
- [ ] Kotlin compilation
|
||||
- [ ] Invoke kotlinc or gradle
|
||||
- [ ] Handle Android-specific setup
|
||||
- [ ] DEX generation for Android
|
||||
- [ ] Link outputs into final binary/bundle
|
||||
- [ ] Caching (hash sources, skip if unchanged)
|
||||
|
||||
### Integration with dx (your bundler)
|
||||
|
||||
- [ ] Hook into post-rustc phase
|
||||
- [ ] Call plugin extraction
|
||||
- [ ] Call compilation pipeline
|
||||
- [ ] Include outputs in app bundle
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
### Swift Compilation
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
swiftc -target arm64-apple-ios15.0 \
|
||||
-sdk /path/to/iPhoneOS.sdk \
|
||||
-emit-library \
|
||||
-o libplugins.a \
|
||||
LocationManager.swift
|
||||
|
||||
# macOS
|
||||
swiftc -emit-library \
|
||||
-o libplugins.dylib \
|
||||
LocationManager.swift
|
||||
```
|
||||
|
||||
### Kotlin Compilation
|
||||
|
||||
For Android, you need to produce DEX:
|
||||
|
||||
```bash
|
||||
# Compile to class files
|
||||
kotlinc Geolocator.kt -include-runtime -d classes/
|
||||
|
||||
# Convert to DEX
|
||||
d8 classes/*.class --output dex/
|
||||
```
|
||||
|
||||
Or use Gradle if dependencies are needed.
|
||||
|
||||
### C Compilation
|
||||
|
||||
```bash
|
||||
# Android
|
||||
$NDK/toolchains/llvm/prebuilt/*/bin/clang \
|
||||
--target=aarch64-linux-android21 \
|
||||
-c fast_math.c -o fast_math.o
|
||||
|
||||
# iOS
|
||||
clang -target arm64-apple-ios15.0 \
|
||||
-isysroot /path/to/iPhoneOS.sdk \
|
||||
-c fast_math.c -o fast_math.o
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Macro tests**: Verify correct link_section output using `trybuild` or manual inspection
|
||||
2. **Extraction tests**: Create test binaries, verify metadata extraction works
|
||||
3. **Compilation tests**: Verify Swift/Kotlin/C compilation produces valid artifacts
|
||||
4. **Integration tests**: End-to-end build of sample app with plugins
|
||||
@@ -0,0 +1,284 @@
|
||||
# Dioxus Manifest & Permissions System
|
||||
|
||||
Declare permissions, deep links, background modes, and platform-specific manifest settings through `Dioxus.toml` configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
Dioxus provides a unified configuration system for cross-platform app manifest customization. Instead of using macros or platform-specific files, all manifest configuration is centralized in your `Dioxus.toml` file.
|
||||
|
||||
The system follows a **unified-first, platform-override** pattern:
|
||||
1. Define cross-platform settings in unified sections (`[permissions]`, `[deep_links]`, `[background]`)
|
||||
2. Override or extend with platform-specific sections (`[ios]`, `[android]`, `[macos]`)
|
||||
|
||||
## Permissions
|
||||
|
||||
Add a `[permissions]` section to your `Dioxus.toml`:
|
||||
|
||||
```toml
|
||||
[bundle]
|
||||
identifier = "com.example.myapp"
|
||||
|
||||
[permissions]
|
||||
location = { precision = "fine", description = "Track your location for navigation" }
|
||||
camera = { description = "Take photos for your profile" }
|
||||
microphone = { description = "Record voice messages" }
|
||||
```
|
||||
|
||||
The CLI automatically maps these unified permissions to platform-specific identifiers:
|
||||
|
||||
| Unified Permission | Android | iOS/macOS |
|
||||
|-------------------|---------|-----------|
|
||||
| `location.fine` | `ACCESS_FINE_LOCATION` | `NSLocationWhenInUseUsageDescription` |
|
||||
| `location.coarse` | `ACCESS_COARSE_LOCATION` | `NSLocationWhenInUseUsageDescription` |
|
||||
| `background_location` | `ACCESS_BACKGROUND_LOCATION` | `NSLocationAlwaysAndWhenInUseUsageDescription` |
|
||||
| `camera` | `CAMERA` | `NSCameraUsageDescription` |
|
||||
| `microphone` | `RECORD_AUDIO` | `NSMicrophoneUsageDescription` |
|
||||
| `notifications` | `POST_NOTIFICATIONS` | (runtime only) |
|
||||
| `photos.read` | `READ_MEDIA_IMAGES` | `NSPhotoLibraryUsageDescription` |
|
||||
| `photos.write` | `WRITE_EXTERNAL_STORAGE` | `NSPhotoLibraryAddUsageDescription` |
|
||||
| `bluetooth` | `BLUETOOTH_CONNECT` | `NSBluetoothAlwaysUsageDescription` |
|
||||
| `contacts` | `READ_CONTACTS` | `NSContactsUsageDescription` |
|
||||
| `calendar` | `READ_CALENDAR` | `NSCalendarsUsageDescription` |
|
||||
| `biometrics` | `USE_BIOMETRIC` | `NSFaceIDUsageDescription` |
|
||||
| `nfc` | `NFC` | `NFCReaderUsageDescription` |
|
||||
| `motion` | `ACTIVITY_RECOGNITION` | `NSMotionUsageDescription` |
|
||||
| `health` | `BODY_SENSORS` | `NSHealthShareUsageDescription` |
|
||||
| `speech` | `RECORD_AUDIO` | `NSSpeechRecognitionUsageDescription` |
|
||||
|
||||
## Deep Linking
|
||||
|
||||
Configure URL schemes and universal/app links with the unified `[deep_links]` section:
|
||||
|
||||
```toml
|
||||
[deep_links]
|
||||
# Custom URL schemes (e.g., myapp://path)
|
||||
schemes = ["myapp", "com.example.myapp"]
|
||||
|
||||
# Universal links / App Links hosts
|
||||
hosts = ["example.com", "*.example.com"]
|
||||
|
||||
# Path patterns (optional, matches all paths if empty)
|
||||
paths = ["/app/*", "/share/*"]
|
||||
```
|
||||
|
||||
Platform-specific overrides extend (not replace) the unified config:
|
||||
|
||||
```toml
|
||||
# iOS-only additional schemes
|
||||
[ios]
|
||||
url_schemes = ["myapp-ios"]
|
||||
|
||||
# Android-specific intent filters for advanced cases
|
||||
[[android.intent_filters]]
|
||||
actions = ["android.intent.action.VIEW"]
|
||||
categories = ["android.intent.category.DEFAULT", "android.intent.category.BROWSABLE"]
|
||||
auto_verify = true # For App Links (requires assetlinks.json)
|
||||
|
||||
[[android.intent_filters.data]]
|
||||
scheme = "https"
|
||||
host = "example.com"
|
||||
path_prefix = "/app"
|
||||
```
|
||||
|
||||
## Background Modes
|
||||
|
||||
Configure background execution capabilities with the unified `[background]` section:
|
||||
|
||||
```toml
|
||||
[background]
|
||||
location = true # Background location updates
|
||||
audio = true # Background audio playback
|
||||
fetch = true # Background data fetch
|
||||
remote-notifications = true # Remote push notification processing
|
||||
voip = true # VoIP calls
|
||||
bluetooth = true # Bluetooth LE accessories
|
||||
processing = true # Background processing tasks
|
||||
```
|
||||
|
||||
Platform-specific overrides:
|
||||
|
||||
```toml
|
||||
# iOS: Additional background modes
|
||||
[ios]
|
||||
background_modes = ["newsstand-content", "external-accessory"]
|
||||
|
||||
# Android: Foreground service types
|
||||
[android]
|
||||
foreground_service_types = ["location", "mediaPlayback", "phoneCall"]
|
||||
```
|
||||
|
||||
## Platform-Specific Configuration
|
||||
|
||||
### iOS Configuration
|
||||
|
||||
```toml
|
||||
[ios]
|
||||
deployment_target = "15.0"
|
||||
url_schemes = ["myapp-ios"] # Platform-specific URL schemes
|
||||
background_modes = ["location", "fetch"] # Additional background modes
|
||||
|
||||
# Document types the app can open
|
||||
[[ios.document_types]]
|
||||
name = "My Document"
|
||||
extensions = ["mydoc", "mydocx"]
|
||||
role = "Editor"
|
||||
|
||||
# Add Info.plist entries as key-value pairs
|
||||
[ios.plist]
|
||||
ITSAppUsesNonExemptEncryption = false
|
||||
|
||||
# Add entitlements
|
||||
[ios.entitlements]
|
||||
"com.apple.security.application-groups" = ["group.com.example.app"]
|
||||
"aps-environment" = "development"
|
||||
|
||||
# Raw XML for advanced cases
|
||||
[ios.raw]
|
||||
info_plist = """
|
||||
<key>CustomKey</key>
|
||||
<string>custom-value</string>
|
||||
"""
|
||||
```
|
||||
|
||||
### Android Configuration
|
||||
|
||||
```toml
|
||||
[android]
|
||||
min_sdk = 24
|
||||
target_sdk = 34
|
||||
features = ["android.hardware.location.gps"]
|
||||
url_schemes = ["myapp-android"] # Platform-specific URL schemes
|
||||
foreground_service_types = ["location", "mediaPlayback"]
|
||||
|
||||
# Intent filters for deep linking
|
||||
[[android.intent_filters]]
|
||||
actions = ["android.intent.action.VIEW"]
|
||||
categories = ["android.intent.category.DEFAULT", "android.intent.category.BROWSABLE"]
|
||||
auto_verify = true
|
||||
|
||||
[[android.intent_filters.data]]
|
||||
scheme = "https"
|
||||
host = "example.com"
|
||||
|
||||
# Package visibility queries (Android 11+)
|
||||
[android.queries]
|
||||
packages = ["com.other.app"]
|
||||
|
||||
# Additional permissions not covered by unified permissions
|
||||
[android.permissions]
|
||||
"android.permission.FOREGROUND_SERVICE" = { description = "Run background service" }
|
||||
|
||||
# Raw manifest XML for advanced cases
|
||||
[android.raw]
|
||||
manifest = """
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
"""
|
||||
```
|
||||
|
||||
### macOS Configuration
|
||||
|
||||
```toml
|
||||
[macos]
|
||||
minimum_system_version = "11.0"
|
||||
frameworks = ["CoreLocation.framework"]
|
||||
url_schemes = ["myapp-macos"] # Platform-specific URL schemes
|
||||
category = "public.app-category.productivity"
|
||||
|
||||
# Document types (same format as iOS)
|
||||
[[macos.document_types]]
|
||||
name = "My Format"
|
||||
extensions = ["myfmt"]
|
||||
|
||||
# Add Info.plist entries
|
||||
[macos.plist]
|
||||
LSUIElement = true
|
||||
|
||||
# Add entitlements
|
||||
[macos.entitlements]
|
||||
"com.apple.security.app-sandbox" = true
|
||||
"com.apple.security.network.client" = true
|
||||
|
||||
# Raw XML for advanced cases
|
||||
[macos.raw]
|
||||
info_plist = """
|
||||
<key>CustomKey</key>
|
||||
<string>custom-value</string>
|
||||
"""
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example for a geolocation app:
|
||||
|
||||
```toml
|
||||
[application]
|
||||
name = "GeoTracker"
|
||||
|
||||
[bundle]
|
||||
identifier = "com.example.geotracker"
|
||||
|
||||
# Unified permissions - automatically mapped to each platform
|
||||
[permissions]
|
||||
location = { precision = "fine", description = "Track your precise location for navigation" }
|
||||
notifications = { description = "Send alerts when you arrive at destinations" }
|
||||
|
||||
# iOS-specific settings
|
||||
[ios]
|
||||
deployment_target = "15.0"
|
||||
|
||||
[ios.plist]
|
||||
UIBackgroundModes = ["location"]
|
||||
|
||||
[ios.entitlements]
|
||||
"com.apple.developer.healthkit" = false
|
||||
|
||||
# Android-specific settings
|
||||
[android]
|
||||
min_sdk = 24
|
||||
target_sdk = 34
|
||||
features = ["android.hardware.location.gps"]
|
||||
|
||||
# macOS-specific settings
|
||||
[macos]
|
||||
minimum_system_version = "11.0"
|
||||
|
||||
[macos.entitlements]
|
||||
"com.apple.security.app-sandbox" = true
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
1. **Parse**: The CLI parses `Dioxus.toml` and extracts all permission and platform-specific configuration
|
||||
2. **Map**: The `PermissionMapper` converts unified permissions to platform-specific identifiers
|
||||
3. **Generate**: Handlebars templates inject the permissions and configuration into platform manifests:
|
||||
- `AndroidManifest.xml` for Android
|
||||
- `Info.plist` for iOS and macOS
|
||||
4. **Bundle**: The final app bundle includes the configured permissions
|
||||
|
||||
## Migration from Macro-Based System
|
||||
|
||||
If you were previously using the `permission!()` macro, migrate to `Dioxus.toml`:
|
||||
|
||||
**Before (deprecated):**
|
||||
```rust
|
||||
use manganis::permission;
|
||||
|
||||
const LOCATION: Permission = permission!(
|
||||
PermissionBuilder::location(LocationPrecision::Fine)
|
||||
.with_description("Track your runs")
|
||||
.build()
|
||||
);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```toml
|
||||
# Dioxus.toml
|
||||
[permissions]
|
||||
location = { precision = "fine", description = "Track your runs" }
|
||||
```
|
||||
|
||||
The `permission!()` macro has been removed. All permissions should now be declared in `Dioxus.toml`.
|
||||
|
||||
## FFI Integration
|
||||
|
||||
For native plugins that require specific permissions, declare them in your library's documentation and let the app developer add them to their `Dioxus.toml`. The `#[manganis::ffi]` macro for FFI bindings is still available for Swift/Kotlin integration.
|
||||
@@ -109,6 +109,7 @@ tracing-subscriber = { version = "0.3.19", features = [
|
||||
"fmt",
|
||||
] }
|
||||
console-subscriber = { version = "0.4.1", optional = true }
|
||||
schemars = "0.8"
|
||||
tracing = { workspace = true }
|
||||
ansi-to-tui = { workspace = true }
|
||||
ansi-to-html = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package dev.dioxus.main;
|
||||
|
||||
// need to re-export buildconfig down from the parent
|
||||
import {{application_id}}.BuildConfig;
|
||||
typealias BuildConfig = BuildConfig;
|
||||
package dev.dioxus.main
|
||||
|
||||
typealias BuildConfig = {{application_id}}.BuildConfig
|
||||
|
||||
class MainActivity : WryActivity()
|
||||
@@ -1,17 +1,20 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
{{#each gradle_plugins}}
|
||||
id("{{ this }}")
|
||||
{{/each}}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace="{{ application_id }}"
|
||||
compileSdk = 33
|
||||
compileSdk = {{ compile_sdk }}
|
||||
defaultConfig {
|
||||
applicationId = "{{ application_id }}"
|
||||
minSdk = 24
|
||||
targetSdk = 33
|
||||
minSdk = {{ min_sdk }}
|
||||
targetSdk = {{ target_sdk }}
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionName = "{{ version }}"
|
||||
}
|
||||
{{#if android_bundle}}
|
||||
signingConfigs {
|
||||
@@ -48,15 +51,27 @@ android {
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs("src/main/kotlin", "src/main/java")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.6.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.8.0")
|
||||
{{#each gradle_dependencies}}
|
||||
implementation("{{ this }}")
|
||||
{{/each}}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Default permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application android:hasCode="true" android:supportsRtl="true" android:icon="@mipmap/ic_launcher"
|
||||
|
||||
<!-- Permissions from Dioxus.toml [permissions] section -->
|
||||
{{#each permissions}}
|
||||
<uses-permission android:name="{{ this }}" />
|
||||
{{/each}}
|
||||
|
||||
<!-- Hardware features from Dioxus.toml [android] section -->
|
||||
{{#each features}}
|
||||
<uses-feature android:name="{{ this }}" android:required="true" />
|
||||
{{/each}}
|
||||
|
||||
<!-- Raw manifest XML from Dioxus.toml [android.raw.manifest] -->
|
||||
{{{ raw_manifest }}}
|
||||
|
||||
<application android:hasCode="true" android:icon="@mipmap/ic_launcher"
|
||||
android:extractNativeLibs="true"
|
||||
android:allowNativeHeapPointerTagging="false"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:supportsRtl="{{#if supports_rtl}}{{ supports_rtl }}{{else}}true{{/if}}"
|
||||
android:theme="{{#if app_theme}}{{ app_theme }}{{else}}@style/AppTheme{{/if}}"
|
||||
{{#if uses_cleartext_traffic}}android:usesCleartextTraffic="{{ uses_cleartext_traffic }}"{{/if}}
|
||||
{{#if large_heap}}android:largeHeap="{{ large_heap }}"{{/if}}
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="true"
|
||||
android:label="@string/app_name" android:name="dev.dioxus.main.MainActivity">
|
||||
@@ -15,6 +33,33 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
{{#each url_schemes}}
|
||||
<!-- URL scheme deep link: {{ this }}:// -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="{{ this }}" />
|
||||
</intent-filter>
|
||||
{{/each}}
|
||||
|
||||
{{#each app_link_hosts}}
|
||||
<!-- App link: https://{{ this }} -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="{{ this }}" />
|
||||
</intent-filter>
|
||||
{{/each}}
|
||||
</activity>
|
||||
|
||||
{{#if foreground_service_type}}
|
||||
<service
|
||||
android:name=".DioxusForegroundService"
|
||||
android:foregroundServiceType="{{ foreground_service_type }}"
|
||||
android:exported="false" />
|
||||
{{/if}}
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -16,9 +15,9 @@
|
||||
<string>{{ bundle_name }}</string>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<string>{{ version }}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>{{ version }}</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en_US</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
@@ -42,8 +41,6 @@
|
||||
<string>iPhoneOS</string>
|
||||
<string>iPadOS</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@@ -57,5 +54,42 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
|
||||
<!-- Permission usage descriptions from Dioxus.toml [permissions] section -->
|
||||
{{#each permissions}}
|
||||
<key>{{ this.key }}</key>
|
||||
<string>{{ this.description }}</string>
|
||||
{{/each}}
|
||||
|
||||
{{#if url_schemes}}
|
||||
<!-- URL schemes from Dioxus.toml [deep_links] and [ios] sections -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
{{#each url_schemes}}
|
||||
<string>{{ this }}</string>
|
||||
{{/each}}
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
{{/if}}
|
||||
|
||||
{{#if background_modes}}
|
||||
<!-- Background modes from Dioxus.toml [background] and [ios] sections -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
{{#each background_modes}}
|
||||
<string>{{ this }}</string>
|
||||
{{/each}}
|
||||
</array>
|
||||
{{/if}}
|
||||
|
||||
<!-- Additional plist entries from Dioxus.toml [ios.plist] section -->
|
||||
{{{ plist_entries }}}
|
||||
|
||||
<!-- Raw plist XML from Dioxus.toml [ios.raw.info_plist] -->
|
||||
{{{ raw_plist }}}
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -24,15 +24,39 @@
|
||||
<string>APPL</string>
|
||||
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>7.4.0</string>
|
||||
<string>{{ version }}</string>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<string>{{ version }}</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.15</string>
|
||||
<string>{{ minimum_system_version }}</string>
|
||||
|
||||
<!-- Permission usage descriptions from Dioxus.toml [permissions] section -->
|
||||
{{#each permissions}}
|
||||
<key>{{ this.key }}</key>
|
||||
<string>{{ this.description }}</string>
|
||||
{{/each}}
|
||||
|
||||
{{#if url_schemes}}
|
||||
<!-- URL schemes from Dioxus.toml [deep_links] and [macos] sections -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
{{#each url_schemes}}
|
||||
<string>{{ this }}</string>
|
||||
{{/each}}
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
{{/if}}
|
||||
|
||||
<!-- Additional plist entries from Dioxus.toml [macos.plist] section -->
|
||||
{{{ plist_entries }}}
|
||||
|
||||
<!-- Raw plist XML from Dioxus.toml [macos.raw.info_plist] -->
|
||||
{{{ raw_plist }}}
|
||||
</dict>
|
||||
</plist>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -35,9 +35,10 @@ use std::{
|
||||
|
||||
use crate::Result;
|
||||
use anyhow::{bail, Context};
|
||||
use const_serialize::{serialize_const, ConstVec, SerializeConst};
|
||||
use const_serialize::{deserialize_const, serialize_const, ConstVec};
|
||||
use dioxus_cli_opt::AssetManifest;
|
||||
use manganis::{AssetOptions, AssetVariant, BundledAsset, ImageFormat, ImageSize};
|
||||
use manganis_core::{AndroidArtifactMetadata, SwiftPackageMetadata, SymbolData};
|
||||
use object::{File, Object, ObjectSection, ObjectSymbol, ReadCache, ReadRef, Section, Symbol};
|
||||
use pdb::FallibleIterator;
|
||||
use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
|
||||
@@ -60,6 +61,7 @@ enum ManganisVersion {
|
||||
/// The legacy version of the manganis format published with 0.7.0 and 0.7.1
|
||||
Legacy,
|
||||
/// The new version of the manganis format 0.7.2 onward
|
||||
/// This now includes both assets (old BundledAsset format) and permissions (SymbolData format)
|
||||
New,
|
||||
}
|
||||
|
||||
@@ -69,11 +71,18 @@ impl ManganisVersion {
|
||||
ManganisVersion::Legacy => {
|
||||
<manganis_core_07::BundledAsset as const_serialize_07::SerializeConst>::MEMORY_LAYOUT.size()
|
||||
}
|
||||
ManganisVersion::New => BundledAsset::MEMORY_LAYOUT.size(),
|
||||
// For new format, we use a larger buffer size to accommodate variable-length CBOR
|
||||
// The actual size will be determined by CBOR deserialization
|
||||
ManganisVersion::New => 4096,
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize(&self, data: &[u8]) -> Option<BundledAsset> {
|
||||
/// Deserialize data, trying multiple formats for backward compatibility
|
||||
///
|
||||
/// Tries in order:
|
||||
/// 1. SymbolData (new unified format) - can contain Asset or Permission
|
||||
/// 2. BundledAsset (old asset format) - for backward compatibility
|
||||
fn deserialize(&self, data: &[u8]) -> Option<SymbolDataOrAsset> {
|
||||
match self {
|
||||
ManganisVersion::Legacy => {
|
||||
let buffer = const_serialize_07::ConstReadBuffer::new(data);
|
||||
@@ -81,18 +90,75 @@ impl ManganisVersion {
|
||||
let (_, legacy_asset) =
|
||||
const_serialize_07::deserialize_const!(manganis_core_07::BundledAsset, buffer)?;
|
||||
|
||||
Some(legacy_asset_to_modern_asset(&legacy_asset))
|
||||
Some(SymbolDataOrAsset::Asset(legacy_asset_to_modern_asset(
|
||||
&legacy_asset,
|
||||
)))
|
||||
}
|
||||
ManganisVersion::New => {
|
||||
let (_, asset) =
|
||||
const_serialize::deserialize_const!(manganis_core::BundledAsset, data)?;
|
||||
// First try SymbolData (new format with enum variant)
|
||||
// const-serialize deserialization returns (remaining_bytes, value)
|
||||
// We accept if remaining is empty or contains only padding (zeros)
|
||||
if let Some((remaining, symbol_data)) = deserialize_const!(SymbolData, data) {
|
||||
// Check if remaining bytes are all zeros (padding) or empty
|
||||
// This handles the case where the linker section is larger than the actual data
|
||||
// Be very lenient with padding - as long as we successfully deserialized, accept it
|
||||
// The padding is just zeros added to fill the buffer size
|
||||
let is_valid = remaining.is_empty()
|
||||
|| remaining.iter().all(|&b| b == 0)
|
||||
|| remaining.len() <= data.len(); // Allow any amount of padding as long as it's not larger than data
|
||||
|
||||
Some(asset)
|
||||
if is_valid {
|
||||
return Some(SymbolDataOrAsset::SymbolData(Box::new(symbol_data)));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"SymbolData deserialized but invalid padding: {} remaining bytes out of {} total (first few bytes: {:?})",
|
||||
remaining.len(),
|
||||
data.len(),
|
||||
&data[..data.len().min(32)]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Failed to deserialize as SymbolData. Data length: {}, first few bytes: {:?}",
|
||||
data.len(),
|
||||
&data[..data.len().min(32)]
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: try BundledAsset (direct format - assets are now serialized this way)
|
||||
// This handles assets that were serialized directly as BundledAsset (not wrapped in SymbolData)
|
||||
if let Some((remaining, asset)) = deserialize_const!(BundledAsset, data) {
|
||||
// Check if remaining bytes are all zeros (padding) or empty
|
||||
// Accept any amount of padding as long as it's all zeros (which is what we pad with)
|
||||
let is_valid = remaining.is_empty() || remaining.iter().all(|&b| b == 0);
|
||||
|
||||
if is_valid {
|
||||
tracing::debug!(
|
||||
"Successfully deserialized BundledAsset, remaining padding: {} bytes",
|
||||
remaining.len()
|
||||
);
|
||||
return Some(SymbolDataOrAsset::Asset(asset));
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"BundledAsset deserialized but remaining bytes are not all zeros: {} remaining bytes, first few: {:?}",
|
||||
remaining.len(),
|
||||
&remaining[..remaining.len().min(16)]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Failed to deserialize as BundledAsset. Data length: {}, first 32 bytes: {:?}",
|
||||
data.len(),
|
||||
&data[..data.len().min(32)]
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize(&self, asset: &BundledAsset) -> Vec<u8> {
|
||||
fn serialize_asset(&self, asset: &BundledAsset) -> Vec<u8> {
|
||||
match self {
|
||||
ManganisVersion::Legacy => {
|
||||
let legacy_asset = modern_asset_to_legacy_asset(asset);
|
||||
@@ -103,11 +169,71 @@ impl ManganisVersion {
|
||||
buffer.as_ref().to_vec()
|
||||
}
|
||||
ManganisVersion::New => {
|
||||
// New format: serialize as BundledAsset directly (backward compatible)
|
||||
// Pad to 4096 bytes to match the linker output size
|
||||
let buffer = serialize_const(asset, ConstVec::new());
|
||||
buffer.as_ref().to_vec()
|
||||
let mut data = buffer.as_ref().to_vec();
|
||||
if data.len() < 4096 {
|
||||
data.resize(4096, 0);
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_symbol_data(&self, data: &SymbolData) -> Option<Vec<u8>> {
|
||||
match self {
|
||||
ManganisVersion::Legacy => None,
|
||||
ManganisVersion::New => {
|
||||
let buffer = serialize_const(data, ConstVec::new());
|
||||
let mut bytes = buffer.as_ref().to_vec();
|
||||
if bytes.len() < 4096 {
|
||||
bytes.resize(4096, 0);
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of deserializing a symbol - can be either SymbolData or legacy Asset
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum SymbolDataOrAsset {
|
||||
/// New unified format (can contain Asset or Permission)
|
||||
SymbolData(Box<SymbolData>),
|
||||
|
||||
/// Old asset format (backward compatibility)
|
||||
Asset(BundledAsset),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AssetWriteEntry {
|
||||
symbol: ManganisSymbolOffset,
|
||||
asset_index: usize,
|
||||
representation: AssetRepresentation,
|
||||
}
|
||||
|
||||
impl AssetWriteEntry {
|
||||
fn new(
|
||||
symbol: ManganisSymbolOffset,
|
||||
asset_index: usize,
|
||||
representation: AssetRepresentation,
|
||||
) -> Self {
|
||||
Self {
|
||||
symbol,
|
||||
asset_index,
|
||||
representation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AssetRepresentation {
|
||||
/// Serialized as a raw BundledAsset (legacy or new format)
|
||||
RawBundled,
|
||||
/// Serialized as SymbolData::Asset (new CBOR format)
|
||||
SymbolData,
|
||||
}
|
||||
|
||||
fn legacy_asset_to_modern_asset(
|
||||
@@ -570,9 +696,24 @@ fn find_wasm_symbol_offsets<'a, R: ReadRef<'a>>(
|
||||
Ok(offsets)
|
||||
}
|
||||
|
||||
/// Result of extracting symbols from a binary file
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SymbolExtractionResult {
|
||||
/// Assets found in the binary
|
||||
pub assets: Vec<BundledAsset>,
|
||||
|
||||
/// Android plugin artifacts discovered in the binary
|
||||
pub android_artifacts: Vec<AndroidArtifactMetadata>,
|
||||
|
||||
/// Swift packages discovered in the binary
|
||||
pub swift_packages: Vec<SwiftPackageMetadata>,
|
||||
}
|
||||
|
||||
/// Find all assets in the given file, hash them, and write them back to the file.
|
||||
/// Then return an `AssetManifest` containing all the assets found in the file.
|
||||
pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<AssetManifest> {
|
||||
/// Also extracts Android/Swift plugin metadata for FFI bindings.
|
||||
pub(crate) async fn extract_symbols_from_file(
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<SymbolExtractionResult> {
|
||||
let path = path.as_ref();
|
||||
let mut file = open_file_for_writing_with_timeout(
|
||||
path,
|
||||
@@ -588,23 +729,82 @@ pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<A
|
||||
let offsets = find_symbol_offsets(path, &file_contents, &object_file)?;
|
||||
|
||||
let mut assets = Vec::new();
|
||||
let mut android_artifacts = Vec::new();
|
||||
let mut swift_packages = Vec::new();
|
||||
let mut write_entries = Vec::new();
|
||||
|
||||
// Read each asset from the data section using the offsets
|
||||
// Read each symbol from the data section using the offsets
|
||||
for symbol in offsets.iter().copied() {
|
||||
let version = symbol.version;
|
||||
let offset = symbol.offset;
|
||||
file.seek(std::io::SeekFrom::Start(offset))?;
|
||||
let mut data_in_range = vec![0; version.size()];
|
||||
file.read_exact(&mut data_in_range)?;
|
||||
|
||||
if let Some(bundled_asset) = version.deserialize(&data_in_range) {
|
||||
tracing::debug!(
|
||||
"Found asset at offset {offset}: {:?}",
|
||||
bundled_asset.absolute_source_path()
|
||||
);
|
||||
assets.push(bundled_asset);
|
||||
// Read data from file_contents (already loaded into memory)
|
||||
// Use a large buffer for variable length data, but don't exceed file size
|
||||
let buffer_size = version
|
||||
.size()
|
||||
.min(file_contents.len().saturating_sub(offset as usize));
|
||||
if buffer_size == 0 {
|
||||
tracing::warn!("Symbol at offset {offset} is beyond file size");
|
||||
continue;
|
||||
}
|
||||
|
||||
let data_in_range = if (offset as usize) + buffer_size <= file_contents.len() {
|
||||
&file_contents[offset as usize..(offset as usize) + buffer_size]
|
||||
} else {
|
||||
tracing::warn!("Found an asset at offset {offset} that could not be deserialized. This may be caused by a mismatch between your dioxus and dioxus-cli versions.");
|
||||
&file_contents[offset as usize..]
|
||||
};
|
||||
|
||||
// Try to deserialize - const-serialize will handle variable-length data correctly
|
||||
// The deserialization should work even with padding (zeros) at the end
|
||||
if let Some(result) = version.deserialize(data_in_range) {
|
||||
match result {
|
||||
SymbolDataOrAsset::SymbolData(symbol_data) => match *symbol_data {
|
||||
SymbolData::Asset(asset) => {
|
||||
tracing::debug!(
|
||||
"Found asset (via SymbolData) at offset {offset}: {:?}",
|
||||
asset.absolute_source_path()
|
||||
);
|
||||
let asset_index = assets.len();
|
||||
assets.push(asset);
|
||||
write_entries.push(AssetWriteEntry::new(
|
||||
symbol,
|
||||
asset_index,
|
||||
AssetRepresentation::SymbolData,
|
||||
));
|
||||
}
|
||||
|
||||
SymbolData::AndroidArtifact(meta) => {
|
||||
tracing::debug!(
|
||||
"Found Android artifact declaration for plugin {}",
|
||||
meta.plugin_name.as_str()
|
||||
);
|
||||
android_artifacts.push(meta);
|
||||
}
|
||||
SymbolData::SwiftPackage(meta) => {
|
||||
tracing::debug!(
|
||||
"Found Swift package declaration for plugin {}",
|
||||
meta.plugin_name.as_str()
|
||||
);
|
||||
swift_packages.push(meta);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
SymbolDataOrAsset::Asset(asset) => {
|
||||
tracing::debug!(
|
||||
"Found asset (old format) at offset {offset}: {:?}",
|
||||
asset.absolute_source_path()
|
||||
);
|
||||
let asset_index = assets.len();
|
||||
assets.push(asset);
|
||||
write_entries.push(AssetWriteEntry::new(
|
||||
symbol,
|
||||
asset_index,
|
||||
AssetRepresentation::RawBundled,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Found a symbol at offset {offset} that could not be deserialized. This may be caused by a mismatch between your dioxus and dioxus-cli versions, or the symbol may be in an unsupported format.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,14 +813,47 @@ pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<A
|
||||
.par_iter_mut()
|
||||
.for_each(dioxus_cli_opt::add_hash_to_asset);
|
||||
|
||||
// Write back the assets to the binary file
|
||||
for (symbol, asset) in offsets.into_iter().zip(&assets) {
|
||||
let version = symbol.version;
|
||||
let offset = symbol.offset;
|
||||
let new_data = version.serialize(asset);
|
||||
// Write back only assets to the binary file (permissions are not modified)
|
||||
for entry in write_entries {
|
||||
let version = entry.symbol.version;
|
||||
let offset = entry.symbol.offset;
|
||||
let asset = assets
|
||||
.get(entry.asset_index)
|
||||
.copied()
|
||||
.expect("asset index collected from symbol scan");
|
||||
|
||||
file.seek(std::io::SeekFrom::Start(offset))?;
|
||||
file.write_all(new_data.as_ref())?;
|
||||
match entry.representation {
|
||||
AssetRepresentation::RawBundled => {
|
||||
tracing::debug!("Writing asset to offset {offset}: {:?}", asset);
|
||||
let new_data = version.serialize_asset(&asset);
|
||||
if new_data.len() > version.size() {
|
||||
tracing::warn!(
|
||||
"Asset at offset {offset} serialized to {} bytes, but buffer is only {} bytes. Truncating output.",
|
||||
new_data.len(),
|
||||
version.size()
|
||||
);
|
||||
}
|
||||
write_serialized_bytes(&mut file, offset, &new_data, version.size())?;
|
||||
}
|
||||
AssetRepresentation::SymbolData => {
|
||||
tracing::debug!("Writing asset (SymbolData) to offset {offset}: {:?}", asset);
|
||||
let Some(new_data) = version.serialize_symbol_data(&SymbolData::Asset(asset))
|
||||
else {
|
||||
tracing::warn!(
|
||||
"Symbol at offset {offset} was stored as SymbolData but the binary format only supports raw assets"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if new_data.len() > version.size() {
|
||||
tracing::warn!(
|
||||
"SymbolData asset at offset {offset} serialized to {} bytes, but buffer is only {} bytes. Truncating output.",
|
||||
new_data.len(),
|
||||
version.size()
|
||||
);
|
||||
}
|
||||
write_serialized_bytes(&mut file, offset, &new_data, version.size())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the file is flushed to disk
|
||||
@@ -645,12 +878,23 @@ pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<A
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, create the asset manifest
|
||||
Ok(SymbolExtractionResult {
|
||||
assets,
|
||||
android_artifacts,
|
||||
swift_packages,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find all assets in the given file, hash them, and write them back to the file.
|
||||
/// Then return an `AssetManifest` containing all the assets found in the file.
|
||||
///
|
||||
/// This is a convenience function that extracts symbols and returns only assets.
|
||||
pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<AssetManifest> {
|
||||
let result = extract_symbols_from_file(path).await?;
|
||||
let mut manifest = AssetManifest::default();
|
||||
for asset in assets {
|
||||
for asset in result.assets {
|
||||
manifest.insert_asset(asset);
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
@@ -680,3 +924,25 @@ async fn open_file_for_writing_with_timeout(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_serialized_bytes(
|
||||
file: &mut std::fs::File,
|
||||
offset: u64,
|
||||
data: &[u8],
|
||||
buffer_size: usize,
|
||||
) -> Result<()> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
if data.len() <= buffer_size {
|
||||
file.write_all(data)?;
|
||||
if data.len() < buffer_size {
|
||||
let padding = vec![0; buffer_size - data.len()];
|
||||
file.write_all(&padding)?;
|
||||
}
|
||||
} else {
|
||||
file.write_all(&data[..buffer_size])?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -109,6 +109,14 @@ impl BuildContext {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn status_compiling_native_plugins(&self, detail: impl Into<String>) {
|
||||
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
|
||||
stage: BuildStage::CompilingNativePlugins {
|
||||
detail: detail.into(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn status_codesigning(&self) {
|
||||
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
|
||||
stage: BuildStage::CodeSigning,
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
//! iOS/macOS Swift package manifest helpers and compilation.
|
||||
|
||||
use crate::Result;
|
||||
use anyhow::Context;
|
||||
use manganis_core::SwiftPackageMetadata;
|
||||
use std::path::{Path, PathBuf};
|
||||
use target_lexicon::{OperatingSystem, Triple};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Create a proper framework bundle from a dylib for iOS/macOS.
|
||||
///
|
||||
/// iOS uses a flat structure while macOS uses a versioned structure.
|
||||
/// Both require an Info.plist for proper App Store submission.
|
||||
pub async fn create_framework_bundle(
|
||||
dylib_path: &Path,
|
||||
framework_name: &str,
|
||||
output_dir: &Path,
|
||||
target_triple: &Triple,
|
||||
bundle_identifier: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let is_ios = matches!(target_triple.operating_system, OperatingSystem::IOS(_));
|
||||
let min_os_version = if is_ios { "13.0" } else { "11.0" };
|
||||
|
||||
let framework_dir = output_dir.join(format!("{}.framework", framework_name));
|
||||
|
||||
// Remove existing framework if present
|
||||
if framework_dir.exists() {
|
||||
std::fs::remove_dir_all(&framework_dir)?;
|
||||
}
|
||||
|
||||
if is_ios {
|
||||
// iOS uses flat structure: Framework.framework/FrameworkName + Info.plist
|
||||
std::fs::create_dir_all(&framework_dir)?;
|
||||
|
||||
// Copy dylib as the framework executable (no extension)
|
||||
let exec_path = framework_dir.join(framework_name);
|
||||
std::fs::copy(dylib_path, &exec_path)?;
|
||||
|
||||
// Set the install name using install_name_tool
|
||||
let output = Command::new("xcrun")
|
||||
.arg("install_name_tool")
|
||||
.arg("-id")
|
||||
.arg(format!(
|
||||
"@rpath/{}.framework/{}",
|
||||
framework_name, framework_name
|
||||
))
|
||||
.arg(&exec_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("install_name_tool failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Create Info.plist
|
||||
let info_plist = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{framework_name}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>{bundle_identifier}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{framework_name}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>{min_os_version}</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>iPhoneOS</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>"#
|
||||
);
|
||||
|
||||
std::fs::write(framework_dir.join("Info.plist"), info_plist)?;
|
||||
} else {
|
||||
// macOS uses versioned structure with symlinks
|
||||
let versions_a = framework_dir.join("Versions").join("A");
|
||||
let resources_dir = versions_a.join("Resources");
|
||||
std::fs::create_dir_all(&resources_dir)?;
|
||||
|
||||
// Copy dylib as the framework executable
|
||||
let exec_path = versions_a.join(framework_name);
|
||||
std::fs::copy(dylib_path, &exec_path)?;
|
||||
|
||||
// Set install name
|
||||
let output = Command::new("xcrun")
|
||||
.arg("install_name_tool")
|
||||
.arg("-id")
|
||||
.arg(format!(
|
||||
"@rpath/{}.framework/Versions/A/{}",
|
||||
framework_name, framework_name
|
||||
))
|
||||
.arg(&exec_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("install_name_tool failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Create Info.plist in Resources
|
||||
let info_plist = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{framework_name}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>{bundle_identifier}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{framework_name}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>{min_os_version}</string>
|
||||
</dict>
|
||||
</plist>"#
|
||||
);
|
||||
|
||||
std::fs::write(resources_dir.join("Info.plist"), info_plist)?;
|
||||
|
||||
// Create symbolic links (required for macOS framework structure)
|
||||
let versions_dir = framework_dir.join("Versions");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink("A", versions_dir.join("Current"))?;
|
||||
std::os::unix::fs::symlink(
|
||||
format!("Versions/Current/{}", framework_name),
|
||||
framework_dir.join(framework_name),
|
||||
)?;
|
||||
std::os::unix::fs::symlink(
|
||||
"Versions/Current/Resources",
|
||||
framework_dir.join("Resources"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Created {} framework bundle: {}",
|
||||
if is_ios { "iOS" } else { "macOS" },
|
||||
framework_dir.display()
|
||||
);
|
||||
|
||||
Ok(framework_dir)
|
||||
}
|
||||
|
||||
/// Compile Swift sources and return the path to the dynamic framework bundle.
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Generates an umbrella Package.swift that includes all Swift plugins
|
||||
/// 2. Runs `swift build` to compile into a dynamic library
|
||||
/// 3. Wraps the dylib in a proper .framework bundle for iOS/macOS
|
||||
/// 4. Returns the path to the resulting `.framework` bundle
|
||||
pub async fn compile_swift_sources(
|
||||
swift_sources: &[SwiftPackageMetadata],
|
||||
target_triple: &Triple,
|
||||
build_dir: &Path,
|
||||
release: bool,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
if swift_sources.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Compiling {} Swift plugin(s) for {}",
|
||||
swift_sources.len(),
|
||||
target_triple
|
||||
);
|
||||
|
||||
// Create the plugins build directory
|
||||
let plugins_dir = build_dir.join("swift-plugins");
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
|
||||
// Copy and prepare all Swift source packages
|
||||
let mut plugin_paths = Vec::new();
|
||||
for source in swift_sources {
|
||||
let source_path = PathBuf::from(source.package_path.as_str());
|
||||
let plugin_name = source.plugin_name.as_str();
|
||||
let product_name = source.product.as_str();
|
||||
|
||||
if !source_path.exists() {
|
||||
tracing::warn!(
|
||||
"Swift package path does not exist: {} (for plugin {})",
|
||||
source_path.display(),
|
||||
plugin_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest_path = plugins_dir.join(plugin_name);
|
||||
if dest_path.exists() {
|
||||
std::fs::remove_dir_all(&dest_path)?;
|
||||
}
|
||||
copy_dir_recursive(&source_path, &dest_path)?;
|
||||
|
||||
// Modify the Package.swift to produce a dynamic library
|
||||
if let Err(e) = modify_package_for_dynamic_library(&dest_path, product_name) {
|
||||
tracing::warn!("Failed to modify Package.swift for dynamic library: {}", e);
|
||||
}
|
||||
|
||||
plugin_paths.push((plugin_name.to_string(), product_name.to_string(), dest_path));
|
||||
tracing::debug!(
|
||||
"Copied Swift plugin '{}' from {} to {}",
|
||||
plugin_name,
|
||||
source_path.display(),
|
||||
plugins_dir.join(plugin_name).display()
|
||||
);
|
||||
}
|
||||
|
||||
if plugin_paths.is_empty() {
|
||||
tracing::warn!("No valid Swift packages found to compile");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Determine Swift target triple and SDK
|
||||
let (swift_triple, sdk_name) = swift_target_and_sdk(target_triple)?;
|
||||
let sdk_path = lookup_sdk_path(&sdk_name).await?;
|
||||
|
||||
// Build configuration
|
||||
let configuration = if release { "release" } else { "debug" };
|
||||
|
||||
// Build each plugin package individually
|
||||
for (plugin_name, product_name, package_path) in &plugin_paths {
|
||||
tracing::debug!(
|
||||
"Building Swift plugin '{}' (product: {})",
|
||||
plugin_name,
|
||||
product_name
|
||||
);
|
||||
|
||||
let build_path = package_path.join(".build");
|
||||
|
||||
let mut cmd = Command::new("xcrun");
|
||||
cmd.args(["swift", "build"])
|
||||
.arg("--package-path")
|
||||
.arg(package_path)
|
||||
.arg("--configuration")
|
||||
.arg(configuration)
|
||||
.arg("--triple")
|
||||
.arg(&swift_triple)
|
||||
.arg("--sdk")
|
||||
.arg(&sdk_path)
|
||||
.arg("--product")
|
||||
.arg(product_name)
|
||||
.arg("--build-path")
|
||||
.arg(&build_path);
|
||||
|
||||
tracing::debug!("Running: xcrun swift build for {}", product_name);
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
anyhow::bail!(
|
||||
"Swift build failed for plugin '{}':\n{}\n{}",
|
||||
plugin_name,
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
||||
if !output.stderr.is_empty() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::debug!("Swift build warnings for {}:\n{}", plugin_name, stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the output dynamic library for each plugin
|
||||
// Swift puts the output in .build/<triple>/<configuration>/lib<ProductName>.dylib
|
||||
// or .build/<configuration>/lib<ProductName>.dylib depending on the version
|
||||
let mut all_dylibs = Vec::new();
|
||||
|
||||
for (_, product_name, package_path) in &plugin_paths {
|
||||
let build_path = package_path.join(".build");
|
||||
let lib_name = format!("lib{}.dylib", product_name);
|
||||
|
||||
let lib_search_paths = [
|
||||
build_path.join(&swift_triple).join(configuration),
|
||||
build_path.join(configuration),
|
||||
build_path.clone(),
|
||||
];
|
||||
|
||||
let mut found = false;
|
||||
for search_path in &lib_search_paths {
|
||||
let lib_path = search_path.join(&lib_name);
|
||||
if lib_path.exists() {
|
||||
tracing::debug!("Found Swift dynamic library: {}", lib_path.display());
|
||||
all_dylibs.push((product_name.clone(), lib_path));
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
tracing::warn!(
|
||||
"Could not find compiled Swift dynamic library for product '{}' (expected {})",
|
||||
product_name,
|
||||
lib_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if all_dylibs.is_empty() {
|
||||
tracing::warn!("No Swift dynamic libraries were compiled successfully");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// For dynamic libraries, we need to wrap each in a framework bundle
|
||||
// If there's only one library, create a single framework
|
||||
// If there are multiple, we'll create frameworks for each (they're independent)
|
||||
// The first one is the "primary" framework that gets returned
|
||||
|
||||
let (_primary_name, primary_dylib) = all_dylibs.remove(0);
|
||||
|
||||
// Create the framework bundle from the dylib
|
||||
// Use "DioxusSwiftPlugins" as the umbrella framework name
|
||||
let framework_name = "DioxusSwiftPlugins";
|
||||
let bundle_identifier = "com.dioxus.swift.plugins";
|
||||
|
||||
let framework_path = create_framework_bundle(
|
||||
&primary_dylib,
|
||||
framework_name,
|
||||
build_dir,
|
||||
target_triple,
|
||||
bundle_identifier,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// If there are additional dylibs, create separate framework bundles for them
|
||||
for (name, dylib_path) in all_dylibs {
|
||||
let extra_framework = create_framework_bundle(
|
||||
&dylib_path,
|
||||
&name,
|
||||
build_dir,
|
||||
target_triple,
|
||||
&format!("com.dioxus.swift.{}", name.to_lowercase()),
|
||||
)
|
||||
.await?;
|
||||
tracing::debug!(
|
||||
"Created additional framework: {}",
|
||||
extra_framework.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(framework_path))
|
||||
}
|
||||
|
||||
/// Modify a Package.swift to produce a dynamic library instead of static.
|
||||
/// This is needed for runtime class lookup via NSClassFromString.
|
||||
fn modify_package_for_dynamic_library(package_path: &Path, product_name: &str) -> Result<()> {
|
||||
let package_swift_path = package_path.join("Package.swift");
|
||||
if !package_swift_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Package.swift not found at {}",
|
||||
package_swift_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&package_swift_path)?;
|
||||
|
||||
// Replace .static with .dynamic for the library type
|
||||
let modified = content
|
||||
.replace("type: .static", "type: .dynamic")
|
||||
.replace("type:.static", "type: .dynamic");
|
||||
|
||||
// If no library type was specified, we need to add it
|
||||
// Look for .library(name: "ProductName", targets: [...]) and change to
|
||||
// .library(name: "ProductName", type: .dynamic, targets: [...])
|
||||
let pattern = format!(
|
||||
r#".library\s*\(\s*name\s*:\s*"{}"\s*,\s*targets"#,
|
||||
regex::escape(product_name)
|
||||
);
|
||||
let replacement = format!(
|
||||
r#".library(name: "{}", type: .dynamic, targets"#,
|
||||
product_name
|
||||
);
|
||||
|
||||
let modified = if let Ok(re) = regex::Regex::new(&pattern) {
|
||||
re.replace_all(&modified, replacement.as_str()).to_string()
|
||||
} else {
|
||||
modified
|
||||
};
|
||||
|
||||
std::fs::write(&package_swift_path, modified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a Rust target triple to Swift target triple and SDK name
|
||||
fn swift_target_and_sdk(triple: &Triple) -> Result<(String, String)> {
|
||||
use target_lexicon::{Architecture, Environment, OperatingSystem};
|
||||
|
||||
// Check if this is a simulator target using the environment field
|
||||
let is_simulator = triple.environment == Environment::Sim;
|
||||
|
||||
let swift_triple = match (&triple.architecture, &triple.operating_system) {
|
||||
(Architecture::Aarch64(_), OperatingSystem::IOS(_)) => {
|
||||
if is_simulator {
|
||||
"arm64-apple-ios-simulator"
|
||||
} else {
|
||||
"arm64-apple-ios"
|
||||
}
|
||||
}
|
||||
(Architecture::Aarch64(_), OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_)) => {
|
||||
"arm64-apple-macosx"
|
||||
}
|
||||
(Architecture::X86_64, OperatingSystem::IOS(_)) => "x86_64-apple-ios-simulator",
|
||||
(Architecture::X86_64, OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_)) => {
|
||||
"x86_64-apple-macosx"
|
||||
}
|
||||
_ => anyhow::bail!("Unsupported target for Swift compilation: {}", triple),
|
||||
};
|
||||
|
||||
let sdk_name = match &triple.operating_system {
|
||||
OperatingSystem::IOS(_) => {
|
||||
// Check if this is a simulator target using the environment field
|
||||
if is_simulator {
|
||||
"iphonesimulator"
|
||||
} else {
|
||||
"iphoneos"
|
||||
}
|
||||
}
|
||||
OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_) => "macosx",
|
||||
_ => anyhow::bail!(
|
||||
"Unsupported operating system for Swift compilation: {:?}",
|
||||
triple.operating_system
|
||||
),
|
||||
};
|
||||
|
||||
Ok((swift_triple.to_string(), sdk_name.to_string()))
|
||||
}
|
||||
|
||||
/// Look up the SDK path using xcrun
|
||||
async fn lookup_sdk_path(sdk_name: &str) -> Result<String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["--sdk", sdk_name, "--show-sdk-path"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run xcrun to find SDK path")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to find SDK '{}': {}", sdk_name, stderr);
|
||||
}
|
||||
|
||||
let sdk_path = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in SDK path")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if sdk_path.is_empty() {
|
||||
anyhow::bail!("SDK path for '{}' is empty", sdk_name);
|
||||
}
|
||||
|
||||
Ok(sdk_path)
|
||||
}
|
||||
|
||||
/// Recursively copy a directory
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
|
||||
if ty.is_dir() {
|
||||
// Skip .build directories
|
||||
if entry.file_name() == ".build" {
|
||||
continue;
|
||||
}
|
||||
copy_dir_recursive(&src_path, &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(&src_path, &dst_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract Swift metadata from object files in link arguments
|
||||
pub fn extract_swift_metadata_from_link_args(
|
||||
link_args: &[String],
|
||||
workspace_dir: &Path,
|
||||
) -> Vec<SwiftPackageMetadata> {
|
||||
let mut swift_packages = Vec::new();
|
||||
|
||||
// Look through rlibs and object files for Swift metadata
|
||||
for arg in link_args {
|
||||
let path = PathBuf::from(arg);
|
||||
|
||||
// Only process files in our workspace
|
||||
if !path.starts_with(workspace_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for .rlib files
|
||||
if arg.ends_with(".rlib") {
|
||||
if let Ok(swift_meta) = extract_swift_from_rlib(&path) {
|
||||
swift_packages.extend(swift_meta);
|
||||
}
|
||||
}
|
||||
// Check for .o files
|
||||
else if arg.ends_with(".o") || arg.ends_with(".obj") {
|
||||
if let Ok(swift_meta) = extract_swift_from_object(&path) {
|
||||
swift_packages.extend(swift_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by plugin name
|
||||
swift_packages.sort_by(|a, b| a.plugin_name.as_str().cmp(b.plugin_name.as_str()));
|
||||
swift_packages.dedup_by(|a, b| a.plugin_name.as_str() == b.plugin_name.as_str());
|
||||
|
||||
swift_packages
|
||||
}
|
||||
|
||||
/// Extract Swift metadata from an rlib file
|
||||
fn extract_swift_from_rlib(rlib_path: &Path) -> Result<Vec<SwiftPackageMetadata>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let rlib_contents = std::fs::read(rlib_path)?;
|
||||
let mut reader = ar::Archive::new(std::io::Cursor::new(rlib_contents));
|
||||
|
||||
while let Some(Ok(entry)) = reader.next_entry() {
|
||||
let name = std::str::from_utf8(entry.header().identifier()).unwrap_or_default();
|
||||
|
||||
// Only process .o files
|
||||
if !name.ends_with(".rcgu.o") && !name.ends_with(".obj") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read the object file contents
|
||||
let mut obj_contents = Vec::new();
|
||||
std::io::Read::read_to_end(&mut std::io::BufReader::new(entry), &mut obj_contents)?;
|
||||
|
||||
if let Ok(swift_meta) = extract_swift_from_bytes(&obj_contents) {
|
||||
results.extend(swift_meta);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Extract Swift metadata from an object file
|
||||
fn extract_swift_from_object(obj_path: &Path) -> Result<Vec<SwiftPackageMetadata>> {
|
||||
let obj_contents = std::fs::read(obj_path)?;
|
||||
extract_swift_from_bytes(&obj_contents)
|
||||
}
|
||||
|
||||
/// Extract Swift metadata from raw object file bytes
|
||||
fn extract_swift_from_bytes(bytes: &[u8]) -> Result<Vec<SwiftPackageMetadata>> {
|
||||
use manganis_core::SymbolData;
|
||||
use object::{Object, ObjectSection, ObjectSymbol};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
let file = match object::File::parse(bytes) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Ok(results),
|
||||
};
|
||||
|
||||
// Look for __ASSETS__ symbols
|
||||
for symbol in file.symbols() {
|
||||
let name = match symbol.name() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if !name.starts_with("__ASSETS__") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to get the symbol's data
|
||||
if let Some(section_idx) = symbol.section().index() {
|
||||
if let Ok(section) = file.section_by_index(section_idx) {
|
||||
if let Ok(data) = section.data() {
|
||||
// Try to find the symbol data in the section
|
||||
let addr = symbol.address();
|
||||
let section_addr = section.address();
|
||||
let offset = (addr - section_addr) as usize;
|
||||
|
||||
if offset < data.len() {
|
||||
let symbol_data = &data[offset..];
|
||||
// Try to deserialize as SymbolData
|
||||
if let Some((_, SymbolData::SwiftPackage(meta))) =
|
||||
const_serialize::deserialize_const!(SymbolData, symbol_data)
|
||||
{
|
||||
results.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Recursively collect all Swift source files from a directory
|
||||
fn collect_swift_files(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut swift_files = Vec::new();
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(swift_files);
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
// Recursively collect from subdirectories
|
||||
swift_files.extend(collect_swift_files(&path)?);
|
||||
} else if path.extension().is_some_and(|ext| ext == "swift") {
|
||||
swift_files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(swift_files)
|
||||
}
|
||||
|
||||
/// Information about an Apple Widget Extension to compile
|
||||
pub struct AppleWidgetSource {
|
||||
/// Path to the Swift package source directory
|
||||
pub source_path: PathBuf,
|
||||
/// Display name for the widget (shown in system UI)
|
||||
pub display_name: String,
|
||||
/// Bundle ID suffix (appended to app bundle ID)
|
||||
pub bundle_id_suffix: String,
|
||||
/// Minimum deployment target (e.g., "16.0")
|
||||
pub deployment_target: String,
|
||||
/// Swift module name for the widget.
|
||||
/// This MUST match the module name used by the main app's Swift plugin
|
||||
/// for ActivityKit type matching to work (e.g., both must define
|
||||
/// `ModuleName.LocationPermissionAttributes` as the same type).
|
||||
pub module_name: String,
|
||||
}
|
||||
|
||||
/// Compile an Apple Widget Extension from a Swift package source.
|
||||
///
|
||||
/// Widget Extensions are compiled as executables (not libraries) and bundled
|
||||
/// as .appex bundles which are installed in the app's PlugIns folder.
|
||||
///
|
||||
/// **Important**: Widget extensions are XPC services that require special initialization.
|
||||
/// We use `-e _NSExtensionMain` as the entry point instead of the default `_main` that
|
||||
/// Swift generates with `@main`. The `_NSExtensionMain` entry point (provided by Foundation):
|
||||
/// 1. Sets up the XPC listener
|
||||
/// 2. Initializes ExtensionFoundation's `_EXRunningExtension` singleton
|
||||
/// 3. Registers with PlugInKit
|
||||
/// 4. Then calls your Widget code
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `widget` - Widget extension source configuration
|
||||
/// * `target_triple` - The target platform (e.g., aarch64-apple-ios)
|
||||
/// * `build_dir` - Directory for intermediate build files
|
||||
/// * `app_bundle_id` - The main app's bundle identifier (widget ID is derived from this)
|
||||
/// * `release` - Whether to build in release mode
|
||||
///
|
||||
/// # Returns
|
||||
/// Path to the compiled .appex bundle, ready to be installed to PlugIns/
|
||||
pub async fn compile_apple_widget(
|
||||
widget: &AppleWidgetSource,
|
||||
target_triple: &Triple,
|
||||
build_dir: &Path,
|
||||
app_bundle_id: &str,
|
||||
release: bool,
|
||||
) -> Result<PathBuf> {
|
||||
use target_lexicon::OperatingSystem;
|
||||
|
||||
// Validate we're on an Apple platform
|
||||
let is_ios = matches!(target_triple.operating_system, OperatingSystem::IOS(_));
|
||||
let is_macos = matches!(
|
||||
target_triple.operating_system,
|
||||
OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_)
|
||||
);
|
||||
|
||||
if !is_ios && !is_macos {
|
||||
anyhow::bail!(
|
||||
"Apple Widget Extensions are only supported on iOS and macOS, not {:?}",
|
||||
target_triple.operating_system
|
||||
);
|
||||
}
|
||||
|
||||
// Validate source path exists
|
||||
if !widget.source_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Widget Extension source path does not exist: {}",
|
||||
widget.source_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Compiling Apple Widget Extension '{}' for {}",
|
||||
widget.display_name,
|
||||
target_triple
|
||||
);
|
||||
|
||||
// Create the widget build directory
|
||||
let widget_build_dir = build_dir.join("widget-extensions");
|
||||
std::fs::create_dir_all(&widget_build_dir)?;
|
||||
|
||||
// Copy the Swift package to build directory
|
||||
// Use the bundle_id_suffix as a unique name since the folder name might just be "widget"
|
||||
let widget_name = widget.bundle_id_suffix.replace("-", "_");
|
||||
let source_dir = widget_build_dir.join(format!("{}_src", widget_name));
|
||||
if source_dir.exists() {
|
||||
std::fs::remove_dir_all(&source_dir)?;
|
||||
}
|
||||
copy_dir_recursive(&widget.source_path, &source_dir)?;
|
||||
|
||||
// Get Swift target triple and SDK
|
||||
let (swift_triple, sdk_name) = swift_target_and_sdk(target_triple)?;
|
||||
|
||||
// Collect all Swift source files from the Sources directory
|
||||
let swift_sources_dir = source_dir.join("Sources");
|
||||
let swift_files = collect_swift_files(&swift_sources_dir)?;
|
||||
|
||||
if swift_files.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No Swift source files found in widget extension Sources directory: {}",
|
||||
swift_sources_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Found {} Swift files for widget: {:?}",
|
||||
swift_files.len(),
|
||||
swift_files
|
||||
);
|
||||
|
||||
// Build output path
|
||||
let exec_path = widget_build_dir.join(&widget_name);
|
||||
|
||||
// Compile the widget extension using swiftc directly
|
||||
// Widget extensions are XPC services that require _NSExtensionMain as the entry point
|
||||
let mut cmd = Command::new("xcrun");
|
||||
cmd.arg("--sdk").arg(&sdk_name).arg("swiftc");
|
||||
|
||||
// Add all Swift source files
|
||||
for swift_file in &swift_files {
|
||||
cmd.arg(swift_file);
|
||||
}
|
||||
|
||||
// Output executable
|
||||
cmd.arg("-o").arg(&exec_path);
|
||||
|
||||
// Target triple with proper iOS version
|
||||
// Format: arm64-apple-ios17.0 or arm64-apple-ios17.0-simulator
|
||||
let is_simulator = swift_triple.contains("simulator");
|
||||
let base_triple = swift_triple.replace("-simulator", "");
|
||||
let swift_target = if is_simulator {
|
||||
format!("{}{}-simulator", base_triple, widget.deployment_target)
|
||||
} else {
|
||||
format!("{}{}", base_triple, widget.deployment_target)
|
||||
};
|
||||
cmd.arg("-target").arg(&swift_target);
|
||||
|
||||
// Module name - use a consistent name that matches the main app's plugin module
|
||||
// This is critical for ActivityKit type matching between app and widget
|
||||
cmd.arg("-module-name").arg(&widget.module_name);
|
||||
|
||||
// Optimization flags
|
||||
if release {
|
||||
cmd.arg("-O").arg("-whole-module-optimization");
|
||||
}
|
||||
|
||||
// Extension-specific flags
|
||||
cmd.arg("-application-extension");
|
||||
|
||||
// Critical: Use _NSExtensionMain as the entry point for widget extensions
|
||||
// Without this, the widget crashes because ExtensionFoundation's singleton isn't initialized
|
||||
cmd.arg("-Xlinker")
|
||||
.arg("-e")
|
||||
.arg("-Xlinker")
|
||||
.arg("_NSExtensionMain");
|
||||
|
||||
// Link Objective-C runtime (required for Swift/ObjC interop)
|
||||
cmd.arg("-lobjc");
|
||||
|
||||
// Link required frameworks
|
||||
cmd.arg("-framework").arg("Foundation");
|
||||
cmd.arg("-framework").arg("SwiftUI");
|
||||
cmd.arg("-framework").arg("WidgetKit");
|
||||
cmd.arg("-framework").arg("ActivityKit");
|
||||
|
||||
tracing::debug!("Running swiftc for widget: {:?}", cmd);
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
anyhow::bail!(
|
||||
"Swift compilation failed for widget extension '{}':\n{}\n{}",
|
||||
widget_name,
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
||||
tracing::debug!("Compiled widget executable: {}", exec_path.display());
|
||||
|
||||
// Create the .appex bundle
|
||||
let appex_name = format!("{}.appex", widget_name);
|
||||
let appex_dir = widget_build_dir.join(&appex_name);
|
||||
|
||||
// Remove existing appex if present
|
||||
if appex_dir.exists() {
|
||||
std::fs::remove_dir_all(&appex_dir)?;
|
||||
}
|
||||
std::fs::create_dir_all(&appex_dir)?;
|
||||
|
||||
// Copy the executable into the appex bundle
|
||||
let bundle_exec = appex_dir.join(&widget_name);
|
||||
std::fs::copy(&exec_path, &bundle_exec)?;
|
||||
|
||||
// Create Info.plist for the widget extension
|
||||
let widget_bundle_id = format!("{}.{}", app_bundle_id, widget.bundle_id_suffix);
|
||||
let min_os_version = &widget.deployment_target;
|
||||
|
||||
let platform_info = if is_ios {
|
||||
format!(
|
||||
r#" <key>MinimumOSVersion</key>
|
||||
<string>{min_os_version}</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>iPhoneOS</string>
|
||||
</array>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#" <key>LSMinimumSystemVersion</key>
|
||||
<string>{min_os_version}</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>"#
|
||||
)
|
||||
};
|
||||
|
||||
let info_plist = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>{display_name}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{widget_name}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>{widget_bundle_id}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{widget_name}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
{platform_info}
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>"#,
|
||||
display_name = widget.display_name,
|
||||
widget_name = widget_name,
|
||||
widget_bundle_id = widget_bundle_id,
|
||||
platform_info = platform_info,
|
||||
);
|
||||
|
||||
std::fs::write(appex_dir.join("Info.plist"), info_plist)?;
|
||||
|
||||
tracing::debug!("Created Widget Extension bundle: {}", appex_dir.display());
|
||||
|
||||
Ok(appex_dir)
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
//! Maps unified Dioxus.toml config to platform-specific manifest data.
|
||||
//!
|
||||
//! This module converts cross-platform declarations (permissions, deep links,
|
||||
//! background modes) into platform-specific identifiers:
|
||||
//! - Android: `<uses-permission>` entries, intent filters, foreground service types
|
||||
//! - iOS/macOS: Info.plist keys, URL schemes, UIBackgroundModes
|
||||
|
||||
use crate::config::{
|
||||
AndroidConfig, BackgroundConfig, DeepLinkConfig, IosConfig, LocationPrecision, MacosConfig,
|
||||
PermissionsConfig, StorageAccess,
|
||||
};
|
||||
|
||||
/// Android permission entry for AndroidManifest.xml
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndroidPermissionEntry {
|
||||
/// Full Android permission string (e.g., "android.permission.CAMERA")
|
||||
pub permission: String,
|
||||
/// User-facing description (used for documentation)
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// iOS/macOS plist entry for Info.plist
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlistEntry {
|
||||
/// Plist key (e.g., "NSCameraUsageDescription")
|
||||
pub key: String,
|
||||
/// User-facing description shown in permission dialogs
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Maps unified permissions, deep links, and background modes to platform-specific identifiers
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManifestMapper {
|
||||
pub android_permissions: Vec<AndroidPermissionEntry>,
|
||||
pub android_features: Vec<String>,
|
||||
pub ios_plist_entries: Vec<PlistEntry>,
|
||||
pub macos_plist_entries: Vec<PlistEntry>,
|
||||
|
||||
/// URL schemes for iOS CFBundleURLTypes (merged from deep_links.schemes + ios.url_schemes)
|
||||
pub ios_url_schemes: Vec<String>,
|
||||
/// URL schemes for macOS CFBundleURLTypes (merged from deep_links.schemes + macos.url_schemes)
|
||||
pub macos_url_schemes: Vec<String>,
|
||||
/// URL schemes for Android intent-filter (merged from deep_links.schemes + android.url_schemes)
|
||||
pub android_url_schemes: Vec<String>,
|
||||
/// Associated domains for iOS (from deep_links.hosts → "applinks:host")
|
||||
pub ios_associated_domains: Vec<String>,
|
||||
/// Android intent filters from config (android.intent_filters)
|
||||
pub android_intent_filters: Vec<crate::config::AndroidIntentFilter>,
|
||||
/// App link hosts for Android auto-verify (from deep_links.hosts)
|
||||
pub android_app_link_hosts: Vec<String>,
|
||||
|
||||
/// iOS UIBackgroundModes (merged from BackgroundConfig + ios.background_modes)
|
||||
pub ios_background_modes: Vec<String>,
|
||||
/// Android foreground service types (from BackgroundConfig + android.foreground_service_types)
|
||||
pub android_foreground_service_types: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManifestMapper {
|
||||
/// Create a new permission mapper from the unified config
|
||||
pub fn from_config(
|
||||
permissions: &PermissionsConfig,
|
||||
deep_links: &DeepLinkConfig,
|
||||
background: &BackgroundConfig,
|
||||
android: &AndroidConfig,
|
||||
ios: &IosConfig,
|
||||
macos: &MacosConfig,
|
||||
) -> Self {
|
||||
let mut mapper = Self::default();
|
||||
|
||||
// Map unified permissions
|
||||
mapper.map_location(permissions);
|
||||
mapper.map_camera(permissions);
|
||||
mapper.map_microphone(permissions);
|
||||
mapper.map_notifications(permissions);
|
||||
mapper.map_photos(permissions);
|
||||
mapper.map_bluetooth(permissions);
|
||||
mapper.map_background_location(permissions);
|
||||
mapper.map_contacts(permissions);
|
||||
mapper.map_calendar(permissions);
|
||||
mapper.map_biometrics(permissions);
|
||||
mapper.map_nfc(permissions);
|
||||
mapper.map_motion(permissions);
|
||||
mapper.map_health(permissions);
|
||||
mapper.map_speech(permissions);
|
||||
mapper.map_media_library(permissions);
|
||||
mapper.map_siri(permissions);
|
||||
mapper.map_homekit(permissions);
|
||||
mapper.map_local_network(permissions);
|
||||
mapper.map_nearby_wifi(permissions);
|
||||
|
||||
// Add raw Android permissions
|
||||
for (perm, config) in &android.permissions {
|
||||
mapper.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: perm.clone(),
|
||||
description: config.description.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Android features
|
||||
mapper.android_features.extend(android.features.clone());
|
||||
|
||||
// Map deep links
|
||||
mapper.map_deep_links(deep_links, android, ios, macos);
|
||||
|
||||
// Map background modes
|
||||
mapper.map_background_modes(background, android, ios);
|
||||
|
||||
// Log mapped permissions for debugging
|
||||
for perm in &mapper.android_permissions {
|
||||
tracing::debug!(
|
||||
"Android permission: {} - {}",
|
||||
perm.permission,
|
||||
perm.description
|
||||
);
|
||||
}
|
||||
for entry in &mapper.ios_plist_entries {
|
||||
tracing::debug!("iOS plist: {} = {}", entry.key, entry.value);
|
||||
}
|
||||
|
||||
mapper
|
||||
}
|
||||
|
||||
fn map_location(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(loc) = &permissions.location {
|
||||
let android_perm = match loc.precision {
|
||||
LocationPrecision::Fine => "android.permission.ACCESS_FINE_LOCATION",
|
||||
LocationPrecision::Coarse => "android.permission.ACCESS_COARSE_LOCATION",
|
||||
};
|
||||
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: android_perm.to_string(),
|
||||
description: loc.description.clone(),
|
||||
});
|
||||
|
||||
// For fine location, also add coarse as it's often needed
|
||||
if loc.precision == LocationPrecision::Fine {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.ACCESS_COARSE_LOCATION".to_string(),
|
||||
description: loc.description.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSLocationWhenInUseUsageDescription".to_string(),
|
||||
value: loc.description.clone(),
|
||||
});
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSLocationUsageDescription".to_string(),
|
||||
value: loc.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_camera(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(cam) = &permissions.camera {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.CAMERA".to_string(),
|
||||
description: cam.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSCameraUsageDescription".to_string(),
|
||||
value: cam.description.clone(),
|
||||
});
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSCameraUsageDescription".to_string(),
|
||||
value: cam.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_microphone(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(mic) = &permissions.microphone {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.RECORD_AUDIO".to_string(),
|
||||
description: mic.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSMicrophoneUsageDescription".to_string(),
|
||||
value: mic.description.clone(),
|
||||
});
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSMicrophoneUsageDescription".to_string(),
|
||||
value: mic.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_notifications(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(notif) = &permissions.notifications {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.POST_NOTIFICATIONS".to_string(),
|
||||
description: notif.description.clone(),
|
||||
});
|
||||
// iOS notifications are handled at runtime, no plist entry needed
|
||||
}
|
||||
}
|
||||
|
||||
fn map_photos(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(photos) = &permissions.photos {
|
||||
match photos.access {
|
||||
StorageAccess::Read => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_MEDIA_IMAGES".to_string(),
|
||||
description: photos.description.clone(),
|
||||
});
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSPhotoLibraryUsageDescription".to_string(),
|
||||
value: photos.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::Write => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.WRITE_EXTERNAL_STORAGE".to_string(),
|
||||
description: photos.description.clone(),
|
||||
});
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSPhotoLibraryAddUsageDescription".to_string(),
|
||||
value: photos.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::ReadWrite => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_MEDIA_IMAGES".to_string(),
|
||||
description: photos.description.clone(),
|
||||
});
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.WRITE_EXTERNAL_STORAGE".to_string(),
|
||||
description: photos.description.clone(),
|
||||
});
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSPhotoLibraryUsageDescription".to_string(),
|
||||
value: photos.description.clone(),
|
||||
});
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSPhotoLibraryAddUsageDescription".to_string(),
|
||||
value: photos.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSPhotoLibraryUsageDescription".to_string(),
|
||||
value: photos.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bluetooth(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(bt) = &permissions.bluetooth {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.BLUETOOTH_CONNECT".to_string(),
|
||||
description: bt.description.clone(),
|
||||
});
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.BLUETOOTH_SCAN".to_string(),
|
||||
description: bt.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSBluetoothAlwaysUsageDescription".to_string(),
|
||||
value: bt.description.clone(),
|
||||
});
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSBluetoothAlwaysUsageDescription".to_string(),
|
||||
value: bt.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_background_location(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(bg_loc) = &permissions.background_location {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.ACCESS_BACKGROUND_LOCATION".to_string(),
|
||||
description: bg_loc.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSLocationAlwaysAndWhenInUseUsageDescription".to_string(),
|
||||
value: bg_loc.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_contacts(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(contacts) = &permissions.contacts {
|
||||
match contacts.access {
|
||||
StorageAccess::Read => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_CONTACTS".to_string(),
|
||||
description: contacts.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::Write => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.WRITE_CONTACTS".to_string(),
|
||||
description: contacts.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::ReadWrite => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_CONTACTS".to_string(),
|
||||
description: contacts.description.clone(),
|
||||
});
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.WRITE_CONTACTS".to_string(),
|
||||
description: contacts.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSContactsUsageDescription".to_string(),
|
||||
value: contacts.description.clone(),
|
||||
});
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSContactsUsageDescription".to_string(),
|
||||
value: contacts.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_calendar(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(cal) = &permissions.calendar {
|
||||
match cal.access {
|
||||
StorageAccess::Read => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_CALENDAR".to_string(),
|
||||
description: cal.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::Write => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.WRITE_CALENDAR".to_string(),
|
||||
description: cal.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::ReadWrite => {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_CALENDAR".to_string(),
|
||||
description: cal.description.clone(),
|
||||
});
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.WRITE_CALENDAR".to_string(),
|
||||
description: cal.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSCalendarsUsageDescription".to_string(),
|
||||
value: cal.description.clone(),
|
||||
});
|
||||
|
||||
self.macos_plist_entries.push(PlistEntry {
|
||||
key: "NSCalendarsUsageDescription".to_string(),
|
||||
value: cal.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_biometrics(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(bio) = &permissions.biometrics {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.USE_BIOMETRIC".to_string(),
|
||||
description: bio.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSFaceIDUsageDescription".to_string(),
|
||||
value: bio.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_nfc(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(nfc) = &permissions.nfc {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.NFC".to_string(),
|
||||
description: nfc.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NFCReaderUsageDescription".to_string(),
|
||||
value: nfc.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_motion(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(motion) = &permissions.motion {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.ACTIVITY_RECOGNITION".to_string(),
|
||||
description: motion.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSMotionUsageDescription".to_string(),
|
||||
value: motion.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_health(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(health) = &permissions.health {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.BODY_SENSORS".to_string(),
|
||||
description: health.description.clone(),
|
||||
});
|
||||
|
||||
match health.access {
|
||||
StorageAccess::Read => {
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSHealthShareUsageDescription".to_string(),
|
||||
value: health.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::Write => {
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSHealthUpdateUsageDescription".to_string(),
|
||||
value: health.description.clone(),
|
||||
});
|
||||
}
|
||||
StorageAccess::ReadWrite => {
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSHealthShareUsageDescription".to_string(),
|
||||
value: health.description.clone(),
|
||||
});
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSHealthUpdateUsageDescription".to_string(),
|
||||
value: health.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_speech(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(speech) = &permissions.speech {
|
||||
// Speech recognition uses microphone on Android
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.RECORD_AUDIO".to_string(),
|
||||
description: speech.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSSpeechRecognitionUsageDescription".to_string(),
|
||||
value: speech.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_media_library(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(media) = &permissions.media_library {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.READ_MEDIA_AUDIO".to_string(),
|
||||
description: media.description.clone(),
|
||||
});
|
||||
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSAppleMusicUsageDescription".to_string(),
|
||||
value: media.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_siri(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(siri) = &permissions.siri {
|
||||
// Siri is iOS only
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSSiriUsageDescription".to_string(),
|
||||
value: siri.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_homekit(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(homekit) = &permissions.homekit {
|
||||
// HomeKit is iOS only
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSHomeKitUsageDescription".to_string(),
|
||||
value: homekit.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_network(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(network) = &permissions.local_network {
|
||||
// Local network is iOS only
|
||||
self.ios_plist_entries.push(PlistEntry {
|
||||
key: "NSLocalNetworkUsageDescription".to_string(),
|
||||
value: network.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn map_nearby_wifi(&mut self, permissions: &PermissionsConfig) {
|
||||
if let Some(wifi) = &permissions.nearby_wifi {
|
||||
// Nearby WiFi is Android only
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.NEARBY_WIFI_DEVICES".to_string(),
|
||||
description: wifi.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Map deep link config to platform-specific URL schemes, associated domains, and intent filters
|
||||
fn map_deep_links(
|
||||
&mut self,
|
||||
deep_links: &DeepLinkConfig,
|
||||
android: &AndroidConfig,
|
||||
ios: &IosConfig,
|
||||
macos: &MacosConfig,
|
||||
) {
|
||||
// Merge unified schemes with platform-specific overrides
|
||||
let mut ios_schemes: Vec<String> = deep_links.schemes.clone();
|
||||
ios_schemes.extend(ios.url_schemes.clone());
|
||||
ios_schemes.dedup();
|
||||
self.ios_url_schemes = ios_schemes;
|
||||
|
||||
let mut macos_schemes: Vec<String> = deep_links.schemes.clone();
|
||||
macos_schemes.extend(macos.url_schemes.clone());
|
||||
macos_schemes.dedup();
|
||||
self.macos_url_schemes = macos_schemes;
|
||||
|
||||
let mut android_schemes: Vec<String> = deep_links.schemes.clone();
|
||||
android_schemes.extend(android.url_schemes.clone());
|
||||
android_schemes.dedup();
|
||||
self.android_url_schemes = android_schemes;
|
||||
|
||||
// Map universal link hosts to iOS associated domains
|
||||
for host in &deep_links.hosts {
|
||||
self.ios_associated_domains.push(format!("applinks:{host}"));
|
||||
}
|
||||
|
||||
// Store app link hosts for Android auto-verify intent filters
|
||||
self.android_app_link_hosts = deep_links.hosts.clone();
|
||||
|
||||
// Add explicit Android intent filters from config
|
||||
self.android_intent_filters = android.intent_filters.clone();
|
||||
}
|
||||
|
||||
/// Map background mode config to platform-specific background capabilities
|
||||
fn map_background_modes(
|
||||
&mut self,
|
||||
background: &BackgroundConfig,
|
||||
android: &AndroidConfig,
|
||||
ios: &IosConfig,
|
||||
) {
|
||||
// Build iOS UIBackgroundModes from unified config
|
||||
let mut ios_modes: Vec<String> = Vec::new();
|
||||
if background.location {
|
||||
ios_modes.push("location".to_string());
|
||||
}
|
||||
if background.audio {
|
||||
ios_modes.push("audio".to_string());
|
||||
}
|
||||
if background.fetch {
|
||||
ios_modes.push("fetch".to_string());
|
||||
}
|
||||
if background.remote_notifications {
|
||||
ios_modes.push("remote-notification".to_string());
|
||||
}
|
||||
if background.voip {
|
||||
ios_modes.push("voip".to_string());
|
||||
}
|
||||
if background.bluetooth {
|
||||
ios_modes.push("bluetooth-central".to_string());
|
||||
ios_modes.push("bluetooth-peripheral".to_string());
|
||||
}
|
||||
if background.external_accessory {
|
||||
ios_modes.push("external-accessory".to_string());
|
||||
}
|
||||
if background.processing {
|
||||
ios_modes.push("processing".to_string());
|
||||
}
|
||||
// Merge platform-specific overrides
|
||||
for mode in &ios.background_modes {
|
||||
if !ios_modes.contains(mode) {
|
||||
ios_modes.push(mode.clone());
|
||||
}
|
||||
}
|
||||
self.ios_background_modes = ios_modes;
|
||||
|
||||
// Build Android foreground service types and permissions
|
||||
let mut android_types: Vec<String> = Vec::new();
|
||||
if background.location {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.ACCESS_BACKGROUND_LOCATION".to_string(),
|
||||
description: "Background location updates".to_string(),
|
||||
});
|
||||
}
|
||||
if background.audio {
|
||||
android_types.push("mediaPlayback".to_string());
|
||||
}
|
||||
if background.voip {
|
||||
android_types.push("phoneCall".to_string());
|
||||
}
|
||||
if background.bluetooth {
|
||||
android_types.push("connectedDevice".to_string());
|
||||
}
|
||||
// Merge platform-specific overrides
|
||||
for stype in &android.foreground_service_types {
|
||||
if !android_types.contains(stype) {
|
||||
android_types.push(stype.clone());
|
||||
}
|
||||
}
|
||||
// If we have any foreground service types, add the FOREGROUND_SERVICE permission
|
||||
if !android_types.is_empty() {
|
||||
self.android_permissions.push(AndroidPermissionEntry {
|
||||
permission: "android.permission.FOREGROUND_SERVICE".to_string(),
|
||||
description: "Run foreground services".to_string(),
|
||||
});
|
||||
}
|
||||
self.android_foreground_service_types = android_types;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LocationPermission, SimplePermission};
|
||||
|
||||
#[test]
|
||||
fn test_location_permission_mapping() {
|
||||
let permissions = PermissionsConfig {
|
||||
location: Some(LocationPermission {
|
||||
precision: LocationPrecision::Fine,
|
||||
description: "Track your runs".to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mapper = ManifestMapper::from_config(
|
||||
&permissions,
|
||||
&DeepLinkConfig::default(),
|
||||
&BackgroundConfig::default(),
|
||||
&AndroidConfig::default(),
|
||||
&IosConfig::default(),
|
||||
&MacosConfig::default(),
|
||||
);
|
||||
|
||||
// Should have both fine and coarse for Android
|
||||
assert!(mapper
|
||||
.android_permissions
|
||||
.iter()
|
||||
.any(|p| p.permission == "android.permission.ACCESS_FINE_LOCATION"));
|
||||
assert!(mapper
|
||||
.android_permissions
|
||||
.iter()
|
||||
.any(|p| p.permission == "android.permission.ACCESS_COARSE_LOCATION"));
|
||||
|
||||
// Should have iOS location plist entry
|
||||
assert!(mapper
|
||||
.ios_plist_entries
|
||||
.iter()
|
||||
.any(|e| e.key == "NSLocationWhenInUseUsageDescription"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camera_permission_mapping() {
|
||||
let permissions = PermissionsConfig {
|
||||
camera: Some(SimplePermission {
|
||||
description: "Take photos".to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mapper = ManifestMapper::from_config(
|
||||
&permissions,
|
||||
&DeepLinkConfig::default(),
|
||||
&BackgroundConfig::default(),
|
||||
&AndroidConfig::default(),
|
||||
&IosConfig::default(),
|
||||
&MacosConfig::default(),
|
||||
);
|
||||
|
||||
assert!(mapper
|
||||
.android_permissions
|
||||
.iter()
|
||||
.any(|p| p.permission == "android.permission.CAMERA"));
|
||||
assert!(mapper
|
||||
.ios_plist_entries
|
||||
.iter()
|
||||
.any(|e| e.key == "NSCameraUsageDescription"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_android_camera_permission_data() {
|
||||
let permissions = PermissionsConfig {
|
||||
camera: Some(SimplePermission {
|
||||
description: "Take photos".to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mapper = ManifestMapper::from_config(
|
||||
&permissions,
|
||||
&DeepLinkConfig::default(),
|
||||
&BackgroundConfig::default(),
|
||||
&AndroidConfig::default(),
|
||||
&IosConfig::default(),
|
||||
&MacosConfig::default(),
|
||||
);
|
||||
|
||||
assert!(mapper
|
||||
.android_permissions
|
||||
.iter()
|
||||
.any(|p| p.permission == "android.permission.CAMERA"));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ mod assets;
|
||||
mod builder;
|
||||
mod cache;
|
||||
mod context;
|
||||
mod ios_swift;
|
||||
mod manifest;
|
||||
mod manifest_mapper;
|
||||
mod patch;
|
||||
mod pre_render;
|
||||
mod request;
|
||||
|
||||
+1245
-119
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@ use dioxus_component_manifest::{
|
||||
component_manifest_schema, CargoDependency, Component, ComponentDependency,
|
||||
};
|
||||
use git2::Repository;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{process::Command, task::JoinSet};
|
||||
use tracing::debug;
|
||||
@@ -292,7 +293,7 @@ impl ComponentCommand {
|
||||
let crate_package = workspace.find_main_package(None)?;
|
||||
|
||||
Ok(workspace
|
||||
.load_dioxus_config(crate_package)?
|
||||
.load_dioxus_config(crate_package, None)?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -331,7 +332,7 @@ impl ComponentCommand {
|
||||
|
||||
/// Arguments for the default or custom remote registry
|
||||
/// If both values are None, the default registry will be used
|
||||
#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct RemoteComponentRegistry {
|
||||
/// The url of the component registry
|
||||
#[arg(long)]
|
||||
@@ -449,7 +450,7 @@ impl RemoteComponentRegistry {
|
||||
|
||||
/// Arguments for a component registry
|
||||
/// Either a path to a local directory or a remote git repo (with optional rev)
|
||||
#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ComponentRegistry {
|
||||
/// The remote repo args
|
||||
#[clap(flatten)]
|
||||
|
||||
@@ -24,6 +24,14 @@ pub(crate) enum Config {
|
||||
/// Set CLI settings.
|
||||
#[command(subcommand)]
|
||||
Set(Setting),
|
||||
|
||||
/// Generate JSON schema for Dioxus.toml configuration.
|
||||
/// Useful for IDE autocomplete and validation.
|
||||
Schema {
|
||||
/// Output file path. If not provided, prints to stdout.
|
||||
#[clap(long, short)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Subcommand)]
|
||||
@@ -117,6 +125,17 @@ impl Config {
|
||||
})?;
|
||||
tracing::info!(dx_src = ?TraceSrc::Dev, "🚩 CLI setting `{setting}` has been set.");
|
||||
}
|
||||
Config::Schema { out } => {
|
||||
let schema = crate::config::generate_manifest_schema();
|
||||
let json = serde_json::to_string_pretty(&schema)?;
|
||||
match out {
|
||||
Some(path) => {
|
||||
std::fs::write(&path, format!("{json}\n"))?;
|
||||
tracing::info!(dx_src = ?TraceSrc::Dev, "Schema written to {}", path.display());
|
||||
}
|
||||
None => println!("{json}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StructuredOutput::Success)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct ApplicationConfig {
|
||||
/// The path where global assets will be added when components are added with `dx components add`
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct BundleConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) identifier: Option<String>,
|
||||
@@ -32,7 +33,7 @@ pub(crate) struct BundleConfig {
|
||||
pub(crate) android: Option<AndroidSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct DebianSettings {
|
||||
// OS-specific settings:
|
||||
/// the list of debian dependencies.
|
||||
@@ -88,7 +89,7 @@ pub(crate) struct DebianSettings {
|
||||
pub post_remove_script: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct WixSettings {
|
||||
#[serde(default)]
|
||||
pub(crate) language: Vec<(String, Option<PathBuf>)>,
|
||||
@@ -137,10 +138,11 @@ pub(crate) struct WixSettings {
|
||||
/// It is recommended that you set this value in your tauri config file to avoid accidental changes in your upgrade code
|
||||
/// whenever you want to change your product name.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "Option<String>")]
|
||||
pub upgrade_code: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct MacOsSettings {
|
||||
#[serde(default)]
|
||||
pub(crate) bundle_version: Option<String>,
|
||||
@@ -177,7 +179,7 @@ fn default_hardened_runtime() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct WindowsSettings {
|
||||
#[serde(default)]
|
||||
pub(crate) digest_algorithm: Option<String>,
|
||||
@@ -215,7 +217,7 @@ pub(crate) struct WindowsSettings {
|
||||
pub sign_command: Option<CustomSignCommandSettings>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct NsisSettings {
|
||||
#[serde(default)]
|
||||
pub(crate) template: Option<PathBuf>,
|
||||
@@ -246,7 +248,7 @@ pub(crate) struct NsisSettings {
|
||||
pub minimum_webview2_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) enum NSISInstallerMode {
|
||||
#[default]
|
||||
CurrentUser,
|
||||
@@ -254,7 +256,7 @@ pub(crate) enum NSISInstallerMode {
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) enum WebviewInstallMode {
|
||||
Skip,
|
||||
DownloadBootstrapper { silent: bool },
|
||||
@@ -270,7 +272,7 @@ impl Default for WebviewInstallMode {
|
||||
}
|
||||
|
||||
// Because all four fields must appear at the same time, there is no need for an Option
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct AndroidSettings {
|
||||
pub(crate) jks_file: PathBuf,
|
||||
pub(crate) jks_password: String,
|
||||
@@ -278,7 +280,7 @@ pub(crate) struct AndroidSettings {
|
||||
pub(crate) key_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CustomSignCommandSettings {
|
||||
/// The command to run to sign the binary.
|
||||
pub cmd: String,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::component::ComponentRegistry;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Configuration for the `dioxus component` commands
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct ComponentConfig {
|
||||
/// The component registry to default to when adding components
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::config::component::ComponentConfig;
|
||||
|
||||
use super::*;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct DioxusConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) application: ApplicationConfig,
|
||||
|
||||
#[serde(default)]
|
||||
@@ -15,6 +17,85 @@ pub(crate) struct DioxusConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) components: ComponentConfig,
|
||||
|
||||
/// Unified permissions configuration.
|
||||
/// Permissions declared here are automatically mapped to platform-specific
|
||||
/// identifiers (AndroidManifest.xml, Info.plist, etc.)
|
||||
#[serde(default)]
|
||||
pub(crate) permissions: PermissionsConfig,
|
||||
|
||||
/// Unified deep linking configuration.
|
||||
/// URL schemes and universal links declared here are mapped to platform-specific
|
||||
/// configurations. Use `[ios]`, `[android]`, `[macos]` sections for overrides.
|
||||
#[serde(default)]
|
||||
pub(crate) deep_links: DeepLinkConfig,
|
||||
|
||||
/// Unified background mode configuration.
|
||||
/// Background capabilities declared here are mapped to platform-specific
|
||||
/// configurations. Use `[ios]`, `[android]` sections for overrides.
|
||||
#[serde(default)]
|
||||
pub(crate) background: BackgroundConfig,
|
||||
|
||||
/// iOS-specific configuration.
|
||||
#[serde(default)]
|
||||
pub(crate) ios: IosConfig,
|
||||
|
||||
/// Android-specific configuration.
|
||||
#[serde(default)]
|
||||
pub(crate) android: AndroidConfig,
|
||||
|
||||
/// macOS-specific configuration.
|
||||
#[serde(default)]
|
||||
pub(crate) macos: MacosConfig,
|
||||
|
||||
/// Windows-specific configuration.
|
||||
#[serde(default)]
|
||||
pub(crate) windows: WindowsConfig,
|
||||
|
||||
/// Linux-specific configuration.
|
||||
#[serde(default)]
|
||||
pub(crate) linux: LinuxConfig,
|
||||
}
|
||||
|
||||
/// Platform identifier for bundle resolution.
|
||||
/// This is separate from the CLI's Platform enum which includes Server and Unknown variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BundlePlatform {
|
||||
Ios,
|
||||
Android,
|
||||
MacOS,
|
||||
Windows,
|
||||
Linux,
|
||||
Web,
|
||||
}
|
||||
|
||||
impl From<crate::BundleFormat> for BundlePlatform {
|
||||
fn from(format: crate::BundleFormat) -> Self {
|
||||
match format {
|
||||
crate::BundleFormat::Ios => BundlePlatform::Ios,
|
||||
crate::BundleFormat::Android => BundlePlatform::Android,
|
||||
crate::BundleFormat::MacOS => BundlePlatform::MacOS,
|
||||
crate::BundleFormat::Windows => BundlePlatform::Windows,
|
||||
crate::BundleFormat::Linux => BundlePlatform::Linux,
|
||||
crate::BundleFormat::Web | crate::BundleFormat::Server => BundlePlatform::Web,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DioxusConfig {
|
||||
/// Get the resolved bundle identifier for a specific platform.
|
||||
/// Platform-specific identifiers override the base bundle identifier.
|
||||
pub fn resolved_identifier(&self, platform: BundlePlatform) -> Option<&str> {
|
||||
let platform_override = match platform {
|
||||
BundlePlatform::Ios => self.ios.identifier.as_deref(),
|
||||
BundlePlatform::Android => self.android.identifier.as_deref(),
|
||||
BundlePlatform::MacOS => self.macos.identifier.as_deref(),
|
||||
BundlePlatform::Windows => self.windows.identifier.as_deref(),
|
||||
BundlePlatform::Linux => self.linux.identifier.as_deref(),
|
||||
BundlePlatform::Web => None,
|
||||
};
|
||||
platform_override.or(self.bundle.identifier.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DioxusConfig {
|
||||
@@ -60,6 +141,14 @@ impl Default for DioxusConfig {
|
||||
},
|
||||
bundle: BundleConfig::default(),
|
||||
components: ComponentConfig::default(),
|
||||
permissions: PermissionsConfig::default(),
|
||||
deep_links: DeepLinkConfig::default(),
|
||||
background: BackgroundConfig::default(),
|
||||
ios: IosConfig::default(),
|
||||
android: AndroidConfig::default(),
|
||||
macos: MacosConfig::default(),
|
||||
windows: WindowsConfig::default(),
|
||||
linux: LinuxConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Extract and merge inline Dioxus.toml configuration from Rust source files.
|
||||
//!
|
||||
//! This module allows examples and tests to embed configuration in their doc comments:
|
||||
//!
|
||||
//! ```rust
|
||||
//! #![allow(unused)]
|
||||
//! //! This example demonstrates iOS geolocation
|
||||
//! //!
|
||||
//! //! ```dioxus.toml
|
||||
//! //! [bundle]
|
||||
//! //! identifier = "com.example.geolocation"
|
||||
//! //!
|
||||
//! //! [permissions]
|
||||
//! //! location = { precision = "fine", description = "Track location" }
|
||||
//! //! ```
|
||||
//! ```
|
||||
//!
|
||||
//! The embedded config is deep-merged with the workspace/app Dioxus.toml,
|
||||
//! with inline values taking precedence.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Extract embedded Dioxus.toml configuration from a Rust source file.
|
||||
///
|
||||
/// Parses the file's module-level doc comments (`//!`) looking for a fenced
|
||||
/// code block marked as `dioxus.toml`. Returns the parsed TOML as a generic
|
||||
/// Value for merging.
|
||||
///
|
||||
/// Handles inner attributes (`#![...]`) that may appear before doc comments.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let source = r#"
|
||||
/// #![allow(unused)]
|
||||
/// //! Example showing permissions
|
||||
/// //!
|
||||
/// //! ```dioxus.toml
|
||||
/// //! [permissions]
|
||||
/// //! camera = { description = "Take photos" }
|
||||
/// //! ```
|
||||
///
|
||||
/// fn main() {}
|
||||
/// "#;
|
||||
///
|
||||
/// let config = extract_inline_config(source).unwrap();
|
||||
/// ```
|
||||
pub fn extract_inline_config(source: &str) -> Option<toml::Value> {
|
||||
let doc_content = extract_module_doc_comments(source)?;
|
||||
let toml_content = extract_toml_block(&doc_content)?;
|
||||
toml::from_str(&toml_content).ok()
|
||||
}
|
||||
|
||||
/// Extract inline config from a file path.
|
||||
pub fn extract_inline_config_from_file(path: &Path) -> Option<toml::Value> {
|
||||
let source = std::fs::read_to_string(path).ok()?;
|
||||
extract_inline_config(&source)
|
||||
}
|
||||
|
||||
/// Extract module-level doc comments (`//!`) from the beginning of a source file.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Inner attributes (`#![...]`) that may precede doc comments
|
||||
/// - Empty lines between attributes and doc comments
|
||||
/// - Multi-line inner attributes
|
||||
fn extract_module_doc_comments(source: &str) -> Option<String> {
|
||||
let mut doc_lines = Vec::new();
|
||||
let mut in_doc_section = false;
|
||||
let mut in_multiline_attr = false;
|
||||
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Handle multi-line inner attributes (tracking bracket depth would be
|
||||
// more robust, but this handles the common case)
|
||||
if in_multiline_attr {
|
||||
if trimmed.ends_with(']') || trimmed.contains(")]") {
|
||||
in_multiline_attr = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty lines before we've started collecting doc comments
|
||||
if !in_doc_section && trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip inner attributes (#![...])
|
||||
if trimmed.starts_with("#![") {
|
||||
// Check if it's a multi-line attribute (doesn't close on same line)
|
||||
if !trimmed.ends_with(']') {
|
||||
in_multiline_attr = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also handle inner attributes with the #! on one line and [ on another (rare but possible)
|
||||
// and block-style attributes like #![cfg(...)]
|
||||
if trimmed.starts_with("#!") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for module doc comment
|
||||
if let Some(content) = trimmed.strip_prefix("//!") {
|
||||
in_doc_section = true;
|
||||
// Remove the leading space if present
|
||||
let content = content.strip_prefix(' ').unwrap_or(content);
|
||||
doc_lines.push(content.to_string());
|
||||
} else if in_doc_section {
|
||||
// We've hit a non-doc-comment line after starting, stop collecting
|
||||
break;
|
||||
} else if !trimmed.is_empty() {
|
||||
// Hit actual code before any doc comments
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if doc_lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(doc_lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the content of a ```dioxus.toml fenced code block.
|
||||
fn extract_toml_block(doc_content: &str) -> Option<String> {
|
||||
let mut in_toml_block = false;
|
||||
let mut toml_lines = Vec::new();
|
||||
|
||||
for line in doc_content.lines() {
|
||||
if !in_toml_block {
|
||||
// Look for opening fence with dioxus.toml marker
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "```dioxus.toml" || trimmed == "```toml dioxus.toml" {
|
||||
in_toml_block = true;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Check for closing fence
|
||||
if line.trim() == "```" {
|
||||
break;
|
||||
}
|
||||
toml_lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if toml_lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(toml_lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep merge two TOML values, with `override_val` taking precedence.
|
||||
///
|
||||
/// - For tables: recursively merge, with override values winning
|
||||
/// - For arrays: override replaces base entirely
|
||||
/// - For other values: override replaces base
|
||||
pub fn merge_toml_values(base: toml::Value, override_val: toml::Value) -> toml::Value {
|
||||
match (base, override_val) {
|
||||
// Both are tables - deep merge
|
||||
(toml::Value::Table(mut base_table), toml::Value::Table(override_table)) => {
|
||||
for (key, override_value) in override_table {
|
||||
match base_table.remove(&key) {
|
||||
Some(base_value) => {
|
||||
// Recursively merge
|
||||
base_table.insert(key, merge_toml_values(base_value, override_value));
|
||||
}
|
||||
None => {
|
||||
// Key only in override, just insert
|
||||
base_table.insert(key, override_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
toml::Value::Table(base_table)
|
||||
}
|
||||
// Override is not a table, or base is not a table - override wins
|
||||
(_, override_val) => override_val,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge an inline config with a base DioxusConfig.
|
||||
///
|
||||
/// The inline config (from doc comments) takes precedence over the base config.
|
||||
pub fn merge_with_inline_config(
|
||||
base: &super::DioxusConfig,
|
||||
inline: toml::Value,
|
||||
) -> Result<super::DioxusConfig, toml::de::Error> {
|
||||
// Convert base to toml::Value
|
||||
let base_str = toml::to_string(base).expect("DioxusConfig should serialize");
|
||||
let base_value: toml::Value = toml::from_str(&base_str).expect("Should parse back");
|
||||
|
||||
// Merge
|
||||
let merged = merge_toml_values(base_value, inline);
|
||||
|
||||
// Convert back to DioxusConfig
|
||||
merged.try_into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_module_doc_comments() {
|
||||
let source = r#"//! This is an example
|
||||
//! with multiple lines
|
||||
//!
|
||||
//! And a blank line
|
||||
|
||||
fn main() {}
|
||||
"#;
|
||||
|
||||
let docs = extract_module_doc_comments(source).unwrap();
|
||||
assert!(docs.contains("This is an example"));
|
||||
assert!(docs.contains("with multiple lines"));
|
||||
assert!(docs.contains("And a blank line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_doc_comments_with_inner_attrs() {
|
||||
let source = r#"#![allow(unused)]
|
||||
#![cfg(feature = "something")]
|
||||
//! This is an example
|
||||
//! with inner attributes before it
|
||||
|
||||
fn main() {}
|
||||
"#;
|
||||
|
||||
let docs = extract_module_doc_comments(source).unwrap();
|
||||
assert!(docs.contains("This is an example"));
|
||||
assert!(docs.contains("with inner attributes before it"));
|
||||
assert!(!docs.contains("allow"));
|
||||
assert!(!docs.contains("cfg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_doc_comments_with_multiline_attr() {
|
||||
let source = r#"#![cfg(
|
||||
all(
|
||||
feature = "something",
|
||||
target_os = "ios"
|
||||
)
|
||||
)]
|
||||
//! This is an example
|
||||
|
||||
fn main() {}
|
||||
"#;
|
||||
|
||||
let docs = extract_module_doc_comments(source).unwrap();
|
||||
assert!(docs.contains("This is an example"));
|
||||
assert!(!docs.contains("cfg"));
|
||||
assert!(!docs.contains("feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toml_block() {
|
||||
let doc = r#"This is an example
|
||||
|
||||
```dioxus.toml
|
||||
[bundle]
|
||||
identifier = "com.test"
|
||||
```
|
||||
|
||||
More text here"#;
|
||||
|
||||
let toml = extract_toml_block(doc).unwrap();
|
||||
assert!(toml.contains("[bundle]"));
|
||||
assert!(toml.contains("identifier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_inline_config() {
|
||||
let source = r#"//! Example app
|
||||
//!
|
||||
//! ```dioxus.toml
|
||||
//! [bundle]
|
||||
//! identifier = "com.example.test"
|
||||
//!
|
||||
//! [permissions]
|
||||
//! camera = { description = "Take photos" }
|
||||
//! ```
|
||||
|
||||
fn main() {}
|
||||
"#;
|
||||
|
||||
let config = extract_inline_config(source).unwrap();
|
||||
let table = config.as_table().unwrap();
|
||||
|
||||
assert!(table.contains_key("bundle"));
|
||||
assert!(table.contains_key("permissions"));
|
||||
|
||||
let bundle = table["bundle"].as_table().unwrap();
|
||||
assert_eq!(bundle["identifier"].as_str().unwrap(), "com.example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_inline_config_with_attrs() {
|
||||
let source = r#"#![allow(unused)]
|
||||
#![cfg(target_os = "ios")]
|
||||
//! iOS Example
|
||||
//!
|
||||
//! ```dioxus.toml
|
||||
//! [bundle]
|
||||
//! identifier = "com.example.ios"
|
||||
//! ```
|
||||
|
||||
fn main() {}
|
||||
"#;
|
||||
|
||||
let config = extract_inline_config(source).unwrap();
|
||||
let table = config.as_table().unwrap();
|
||||
let bundle = table["bundle"].as_table().unwrap();
|
||||
assert_eq!(bundle["identifier"].as_str().unwrap(), "com.example.ios");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_inline_config() {
|
||||
let source = r#"//! Just a regular example
|
||||
//! No config here
|
||||
|
||||
fn main() {}
|
||||
"#;
|
||||
|
||||
assert!(extract_inline_config(source).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_toml_values_simple() {
|
||||
let base: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[bundle]
|
||||
identifier = "com.base"
|
||||
publisher = "Base Publisher"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let override_val: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[bundle]
|
||||
identifier = "com.override"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let merged = merge_toml_values(base, override_val);
|
||||
let table = merged.as_table().unwrap();
|
||||
let bundle = table["bundle"].as_table().unwrap();
|
||||
|
||||
// Override wins for identifier
|
||||
assert_eq!(bundle["identifier"].as_str().unwrap(), "com.override");
|
||||
// Base value preserved for publisher
|
||||
assert_eq!(bundle["publisher"].as_str().unwrap(), "Base Publisher");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_toml_values_deep() {
|
||||
let base: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[permissions]
|
||||
camera = { description = "Base camera" }
|
||||
location = { precision = "coarse", description = "Base location" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let override_val: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[permissions]
|
||||
location = { precision = "fine" }
|
||||
microphone = { description = "Record audio" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let merged = merge_toml_values(base, override_val);
|
||||
let perms = merged["permissions"].as_table().unwrap();
|
||||
|
||||
// camera untouched
|
||||
assert_eq!(
|
||||
perms["camera"]["description"].as_str().unwrap(),
|
||||
"Base camera"
|
||||
);
|
||||
|
||||
// location: precision overridden, description preserved
|
||||
assert_eq!(perms["location"]["precision"].as_str().unwrap(), "fine");
|
||||
assert_eq!(
|
||||
perms["location"]["description"].as_str().unwrap(),
|
||||
"Base location"
|
||||
);
|
||||
|
||||
// microphone added
|
||||
assert_eq!(
|
||||
perms["microphone"]["description"].as_str().unwrap(),
|
||||
"Record audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_adds_new_sections() {
|
||||
let base: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[bundle]
|
||||
identifier = "com.base"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let override_val: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[permissions]
|
||||
camera = { description = "Photos" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let merged = merge_toml_values(base, override_val);
|
||||
let table = merged.as_table().unwrap();
|
||||
|
||||
// Both sections present
|
||||
assert!(table.contains_key("bundle"));
|
||||
assert!(table.contains_key("permissions"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,11 +2,15 @@ mod app;
|
||||
mod bundle;
|
||||
mod component;
|
||||
mod dioxus_config;
|
||||
mod inline_config;
|
||||
mod manifest;
|
||||
mod serve;
|
||||
mod web;
|
||||
|
||||
pub(crate) use app::*;
|
||||
pub(crate) use bundle::*;
|
||||
pub(crate) use dioxus_config::*;
|
||||
pub(crate) use inline_config::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use serve::*;
|
||||
pub(crate) use web::*;
|
||||
@@ -1,7 +1,8 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) app: WebAppConfig,
|
||||
@@ -42,7 +43,7 @@ impl Default for WebConfig {
|
||||
}
|
||||
|
||||
/// The wasm-opt configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub(crate) struct WasmOptConfig {
|
||||
/// The wasm-opt level to use for release builds [default: s]
|
||||
/// Options:
|
||||
@@ -83,7 +84,7 @@ pub(crate) struct WasmOptConfig {
|
||||
}
|
||||
|
||||
/// The wasm-opt level to use for release web builds [default: Z]
|
||||
#[derive(Default, Debug, Copy, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Copy, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) enum WasmOptLevel {
|
||||
/// Optimize aggressively for size
|
||||
#[serde(rename = "z")]
|
||||
@@ -109,7 +110,7 @@ pub(crate) enum WasmOptLevel {
|
||||
Four,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebAppConfig {
|
||||
#[serde(default = "default_title")]
|
||||
pub(crate) title: String,
|
||||
@@ -125,12 +126,12 @@ impl Default for WebAppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebProxyConfig {
|
||||
pub(crate) backend: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebWatcherConfig {
|
||||
#[serde(default = "watch_path_default")]
|
||||
pub(crate) watch_path: Vec<PathBuf>,
|
||||
@@ -156,14 +157,14 @@ fn watch_path_default() -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("src"), PathBuf::from("examples")]
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebResourceConfig {
|
||||
pub(crate) dev: WebDevResourceConfig,
|
||||
pub(crate) style: Option<Vec<PathBuf>>,
|
||||
pub(crate) script: Option<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebDevResourceConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) style: Vec<PathBuf>,
|
||||
@@ -171,7 +172,7 @@ pub(crate) struct WebDevResourceConfig {
|
||||
pub(crate) script: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub(crate) struct WebHttpsConfig {
|
||||
pub(crate) enabled: Option<bool>,
|
||||
pub(crate) mkcert: Option<bool>,
|
||||
|
||||
@@ -878,6 +878,7 @@ impl TraceController {
|
||||
),
|
||||
Config::FormatPrint {} => ("config format-print".to_string(), json!({})),
|
||||
Config::CustomHtml {} => ("config custom-html".to_string(), json!({})),
|
||||
Config::Schema { out } => ("config schema".to_string(), json!({ "out": out })),
|
||||
Config::Set(setting) => (
|
||||
format!("config set {}", setting),
|
||||
match setting {
|
||||
|
||||
@@ -582,6 +582,15 @@ impl Output {
|
||||
BuildStage::CompressingAssets => lines.push("Compressing assets".yellow()),
|
||||
BuildStage::RunningBindgen => lines.push("Running wasm-bindgen".yellow()),
|
||||
BuildStage::RunningGradle => lines.push("Running gradle assemble".yellow()),
|
||||
BuildStage::CompilingNativePlugins { detail } => {
|
||||
// detail is "Swift build: name" — split into label (yellow) and name (white)
|
||||
if let Some((label, name)) = detail.split_once(": ") {
|
||||
lines.push(format!("{label}: ").yellow());
|
||||
lines.push(name.white());
|
||||
} else {
|
||||
lines.push(detail.clone().yellow());
|
||||
}
|
||||
}
|
||||
BuildStage::CodeSigning => lines.push("Code signing app".yellow()),
|
||||
BuildStage::Bundling => lines.push("Bundling app".yellow()),
|
||||
BuildStage::CopyingAssets {
|
||||
|
||||
@@ -390,7 +390,23 @@ impl Workspace {
|
||||
Ok(package)
|
||||
}
|
||||
|
||||
pub fn load_dioxus_config(&self, package: NodeId) -> Result<Option<DioxusConfig>> {
|
||||
/// Load the Dioxus.toml configuration for a package.
|
||||
///
|
||||
/// Optionally accepts a source file path to extract inline configuration from doc comments.
|
||||
/// Inline config is merged with the base Dioxus.toml, with inline values taking precedence.
|
||||
///
|
||||
/// This allows examples and binaries to embed config in their doc comments:
|
||||
/// ```rust,ignore
|
||||
/// //! ```dioxus.toml
|
||||
/// //! [bundle]
|
||||
/// //! identifier = "com.example.app"
|
||||
/// //! ```
|
||||
/// ```
|
||||
pub fn load_dioxus_config(
|
||||
&self,
|
||||
package: NodeId,
|
||||
source_file: Option<&Path>,
|
||||
) -> Result<Option<DioxusConfig>> {
|
||||
// Walk up from the cargo.toml to the root of the workspace looking for Dioxus.toml
|
||||
let mut current_dir = self.krates[package]
|
||||
.manifest_path
|
||||
@@ -426,15 +442,35 @@ impl Workspace {
|
||||
.to_path_buf();
|
||||
}
|
||||
|
||||
let Some(dioxus_conf_file) = dioxus_conf_file else {
|
||||
return Ok(None);
|
||||
// Load base config from Dioxus.toml (if it exists)
|
||||
let base_config: Option<DioxusConfig> = match &dioxus_conf_file {
|
||||
Some(path) => {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Some(toml::from_str(&content).map_err(|err| {
|
||||
anyhow::anyhow!("Failed to parse Dioxus.toml at {path:?}: {err}")
|
||||
})?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
toml::from_str::<DioxusConfig>(&std::fs::read_to_string(&dioxus_conf_file)?)
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!("Failed to parse Dioxus.toml at {dioxus_conf_file:?}: {err}")
|
||||
})
|
||||
.map(Some)
|
||||
// Extract inline config from source file (if provided)
|
||||
let inline_config = source_file.and_then(crate::config::extract_inline_config_from_file);
|
||||
|
||||
// Merge configs: inline overrides base
|
||||
match (base_config, inline_config) {
|
||||
(Some(base), Some(inline)) => crate::config::merge_with_inline_config(&base, inline)
|
||||
.map(Some)
|
||||
.map_err(|err| anyhow::anyhow!("Failed to merge inline config: {err}")),
|
||||
(Some(base), None) => Ok(Some(base)),
|
||||
(None, Some(inline)) => {
|
||||
// No Dioxus.toml, but we have inline config - use defaults + inline
|
||||
let base = DioxusConfig::default();
|
||||
crate::config::merge_with_inline_config(&base, inline)
|
||||
.map(Some)
|
||||
.map_err(|err| anyhow::anyhow!("Failed to merge inline config: {err}"))
|
||||
}
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new gitignore map for this target crate
|
||||
|
||||
@@ -108,6 +108,9 @@ pub enum BuildStage {
|
||||
},
|
||||
Bundling,
|
||||
RunningGradle,
|
||||
CompilingNativePlugins {
|
||||
detail: String,
|
||||
},
|
||||
CodeSigning,
|
||||
Success,
|
||||
Failed,
|
||||
|
||||
@@ -22,8 +22,10 @@ use std::{fmt::Debug, hash::Hash, path::PathBuf};
|
||||
pub struct BundledAsset {
|
||||
/// The absolute path of the asset
|
||||
absolute_source_path: ConstStr,
|
||||
|
||||
/// The bundled path of the asset
|
||||
bundled_path: ConstStr,
|
||||
|
||||
/// The options for the asset
|
||||
options: AssetOptions,
|
||||
}
|
||||
@@ -147,32 +149,32 @@ impl Asset {
|
||||
pub fn bundled(&self) -> BundledAsset {
|
||||
// Read the slice using volatile reads to prevent the compiler from optimizing
|
||||
// away the read at compile time
|
||||
fn read_slice_volatile(bundled: &'static [u8]) -> const_serialize_07::ConstVec<u8> {
|
||||
fn read_slice_volatile(bundled: &'static [u8]) -> Vec<u8> {
|
||||
let ptr = bundled as *const [u8] as *const u8;
|
||||
let len = bundled.len();
|
||||
if ptr.is_null() {
|
||||
panic!("Tried to use an asset that was not bundled. Make sure you are compiling dx as the linker");
|
||||
}
|
||||
let mut bytes = const_serialize_07::ConstVec::new();
|
||||
let mut bytes = Vec::with_capacity(len);
|
||||
for byte in 0..len {
|
||||
// SAFETY: We checked that the pointer was not null above. The pointer is valid for reads and
|
||||
// since we are reading a u8 there are no alignment requirements
|
||||
let byte = unsafe { std::ptr::read_volatile(ptr.add(byte)) };
|
||||
bytes = bytes.push(byte);
|
||||
bytes.push(byte);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
let bundled = (self.bundled)();
|
||||
let bytes = read_slice_volatile(bundled);
|
||||
let read = bytes.as_ref();
|
||||
let read = bytes.as_slice();
|
||||
let asset = deserialize_const!(BundledAsset, read).expect("Failed to deserialize asset. Make sure you built with the matching version of the Dioxus CLI").1;
|
||||
|
||||
// If the asset wasn't bundled with the newer format, try the legacy format
|
||||
if asset.bundled_path() == BundledAsset::PLACEHOLDER_HASH {
|
||||
let bundled = (self.legacy)();
|
||||
let bytes = read_slice_volatile(bundled);
|
||||
let read = bytes.read();
|
||||
let read = const_serialize_07::ConstReadBuffer::new(bytes.as_ref());
|
||||
let asset = const_serialize_07::deserialize_const!(BundledAsset, read).expect("Failed to deserialize asset. Make sure you built with the matching version of the Dioxus CLI").1;
|
||||
asset
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::BundledAsset;
|
||||
use const_serialize::{ConstStr, SerializeConst};
|
||||
use const_serialize_08 as const_serialize;
|
||||
use std::hash::Hash;
|
||||
|
||||
/// Unified symbol data that can represent both assets and permissions
|
||||
///
|
||||
/// This enum is used to serialize different types of metadata into the binary
|
||||
/// using the same `__ASSETS__` symbol prefix. The CBOR format allows for
|
||||
/// self-describing data, making it easy to add new variants in the future.
|
||||
///
|
||||
/// Variant order does NOT matter for CBOR enum serialization - variants are
|
||||
/// matched by name (string), not by position or tag value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerializeConst)]
|
||||
#[repr(C, u8)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[non_exhaustive]
|
||||
pub enum SymbolData {
|
||||
/// An asset that should be bundled with the application
|
||||
Asset(BundledAsset),
|
||||
|
||||
/// Android plugin metadata (prebuilt artifacts + Gradle deps)
|
||||
AndroidArtifact(AndroidArtifactMetadata),
|
||||
|
||||
/// Swift package metadata (SPM location + product)
|
||||
SwiftPackage(SwiftPackageMetadata),
|
||||
}
|
||||
|
||||
/// Platform categories for permission mapping
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerializeConst)]
|
||||
pub enum Platform {
|
||||
Android,
|
||||
Ios,
|
||||
Macos,
|
||||
}
|
||||
|
||||
/// Bit flags for supported platforms
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerializeConst)]
|
||||
pub struct PlatformFlags(u8);
|
||||
|
||||
impl PlatformFlags {
|
||||
pub const fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlatformFlags {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformFlags {
|
||||
pub const fn with_platform(mut self, platform: Platform) -> Self {
|
||||
self.0 |= 1 << platform as u8;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn supports(&self, platform: Platform) -> bool {
|
||||
(self.0 & (1 << platform as u8)) != 0
|
||||
}
|
||||
|
||||
pub const fn all() -> Self {
|
||||
Self(0b000111) // Android + iOS + macOS
|
||||
}
|
||||
|
||||
pub const fn mobile() -> Self {
|
||||
Self(0b000011) // Android + iOS
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific permission identifiers
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct PlatformIdentifiers {
|
||||
pub android: Option<ConstStr>,
|
||||
pub ios: Option<ConstStr>,
|
||||
pub macos: Option<ConstStr>,
|
||||
}
|
||||
|
||||
/// Metadata describing an Android plugin artifact (.aar) that must be copied into the host Gradle project.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerializeConst)]
|
||||
pub struct AndroidArtifactMetadata {
|
||||
pub plugin_name: ConstStr,
|
||||
pub artifact_path: ConstStr,
|
||||
pub gradle_dependencies: ConstStr,
|
||||
}
|
||||
|
||||
impl AndroidArtifactMetadata {
|
||||
pub const fn new(
|
||||
plugin_name: &'static str,
|
||||
artifact_path: &'static str,
|
||||
gradle_dependencies: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
plugin_name: ConstStr::new(plugin_name),
|
||||
artifact_path: ConstStr::new(artifact_path),
|
||||
gradle_dependencies: ConstStr::new(gradle_dependencies),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for a Swift package that needs to be linked into the app (iOS/macOS).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerializeConst)]
|
||||
pub struct SwiftPackageMetadata {
|
||||
pub plugin_name: ConstStr,
|
||||
pub package_path: ConstStr,
|
||||
pub product: ConstStr,
|
||||
}
|
||||
|
||||
impl SwiftPackageMetadata {
|
||||
pub const fn new(
|
||||
plugin_name: &'static str,
|
||||
package_path: &'static str,
|
||||
product: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
plugin_name: ConstStr::new(plugin_name),
|
||||
package_path: ConstStr::new(package_path),
|
||||
product: ConstStr::new(product),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,6 @@ pub use css_module::*;
|
||||
|
||||
mod css_module_parser;
|
||||
pub use css_module_parser::*;
|
||||
|
||||
mod ffi;
|
||||
pub use ffi::*;
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{resolve_path, AssetParseError};
|
||||
use crate::{linker::generate_link_section, resolve_path, AssetParseError};
|
||||
use macro_string::MacroString;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::{quote, ToTokens};
|
||||
@@ -88,7 +88,7 @@ impl AssetParser {
|
||||
// Generate the link section for the asset. The link section includes the source path and the
|
||||
// output path of the asset. We force the asset to be included in the binary even if it is unused
|
||||
// if the asset is unhashed
|
||||
let link_section = crate::generate_link_section(quote!(__ASSET), &asset_hash);
|
||||
let link_section = generate_link_section(quote!(__ASSET), &asset_hash);
|
||||
|
||||
// generate the asset::new method to deprecate the `./assets/blah.css` syntax
|
||||
let constructor = if asset.is_relative() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,10 +13,9 @@ use syn::{
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod css_module;
|
||||
pub(crate) mod ffi;
|
||||
pub(crate) mod linker;
|
||||
|
||||
use linker::generate_link_section;
|
||||
|
||||
use crate::css_module::{expand_css_module_struct, CssModuleAttribute};
|
||||
|
||||
/// The asset macro collects assets that will be included in the final binary
|
||||
@@ -226,6 +225,65 @@ pub fn css_module(input: TokenStream, item: TokenStream) -> TokenStream {
|
||||
tokens.into()
|
||||
}
|
||||
|
||||
/// Generate FFI bindings between Rust and native platforms (Swift/Kotlin)
|
||||
///
|
||||
/// This attribute macro parses an `extern "Swift"` or `extern "Kotlin"` block and generates:
|
||||
/// 1. Opaque type wrappers for foreign types
|
||||
/// 2. Function implementations with direct JNI/ObjC bindings
|
||||
/// 3. Linker metadata for the CLI to compile the native source
|
||||
///
|
||||
/// # Syntax
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[manganis::ffi("/src/ios")]
|
||||
/// extern "Swift" {
|
||||
/// pub type GeolocationPlugin;
|
||||
/// pub fn get_position(this: &GeolocationPlugin, high_accuracy: bool) -> Option<String>;
|
||||
/// }
|
||||
///
|
||||
/// #[manganis::ffi("/src/android")]
|
||||
/// extern "Kotlin" {
|
||||
/// pub type GeolocationPlugin;
|
||||
/// pub fn get_position(this: &GeolocationPlugin, high_accuracy: bool) -> Option<String>;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Path Parameter
|
||||
///
|
||||
/// The path in the attribute specifies the native source folder relative to `CARGO_MANIFEST_DIR`:
|
||||
/// - For Swift: A SwiftPM package folder containing `Package.swift`
|
||||
/// - For Kotlin: A Gradle project folder containing `build.gradle.kts`
|
||||
///
|
||||
/// # Type Declarations
|
||||
///
|
||||
/// Use `type Name;` to declare opaque foreign types. These become Rust structs wrapping
|
||||
/// the native object handle (GlobalRef for JNI, raw pointer for ObjC).
|
||||
///
|
||||
/// # Function Declarations
|
||||
///
|
||||
/// Functions can be:
|
||||
/// - **Instance methods**: First argument is `this: &TypeName`
|
||||
/// - **Static methods**: No `this` argument
|
||||
///
|
||||
/// # Supported Types
|
||||
///
|
||||
/// - Primitives: `bool`, `i8`-`i64`, `u8`-`u64`, `f32`, `f64`
|
||||
/// - Strings: `String`, `&str`
|
||||
/// - Options: `Option<T>` where T is supported
|
||||
/// - Opaque refs: `&TypeName` for foreign type references
|
||||
#[proc_macro_attribute]
|
||||
pub fn ffi(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
use ffi::{FfiAttribute, FfiBridgeParser};
|
||||
|
||||
let attr = parse_macro_input!(attr as FfiAttribute);
|
||||
let item = parse_macro_input!(item as syn::ItemForeignMod);
|
||||
|
||||
match FfiBridgeParser::parse_with_attr(attr, item) {
|
||||
Ok(parser) => parser.generate().into(),
|
||||
Err(err) => err.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(raw: &str, span: Span) -> Result<PathBuf, AssetParseError> {
|
||||
// Get the location of the root of the crate which is where all assets are relative to
|
||||
//
|
||||
|
||||
@@ -1,43 +1,83 @@
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::quote;
|
||||
use quote::ToTokens;
|
||||
|
||||
/// We store description of the assets an application uses in the executable.
|
||||
/// We use the `link_section` attribute embed an extra section in the executable.
|
||||
/// We force rust to store a serialized representation of the asset description
|
||||
/// inside a particular region of the binary, with the label "manganis".
|
||||
/// After linking, the "manganis" sections of the different object files will be merged.
|
||||
pub fn generate_link_section(asset: impl ToTokens, asset_hash: &str) -> TokenStream2 {
|
||||
/// Generate a linker section for embedding arbitrary data in the binary
|
||||
///
|
||||
/// This is a generic version that allows customizing the serialization function,
|
||||
/// buffer type, and copy bytes function. Used by both asset and FFI metadata embedding.
|
||||
pub fn generate_link_section_inner(
|
||||
item: TokenStream2,
|
||||
hash: &str,
|
||||
prefix: &str,
|
||||
serialize_fn: TokenStream2,
|
||||
copy_bytes_fn: TokenStream2,
|
||||
buffer_type: TokenStream2,
|
||||
) -> TokenStream2 {
|
||||
let position = proc_macro2::Span::call_site();
|
||||
let export_name = syn::LitStr::new(&format!("__ASSETS__{}", asset_hash), position);
|
||||
let legacy_export_name = syn::LitStr::new(&format!("__MANGANIS__{}", asset_hash), position);
|
||||
|
||||
quote::quote! {
|
||||
// We bundle both the legacy and new link sections for compatibility with older CLIs
|
||||
static __LEGACY_LINK_SECTION: &'static [u8] = {
|
||||
// First serialize the asset into a constant sized buffer
|
||||
const __BUFFER: manganis::macro_helpers::const_serialize_07::ConstVec<u8> = manganis::macro_helpers::serialize_asset_07(&#asset);
|
||||
// Then pull out the byte slice
|
||||
const __BYTES: &[u8] = __BUFFER.as_ref();
|
||||
// And the length of the byte slice
|
||||
const __LEN: usize = __BYTES.len();
|
||||
|
||||
// Now that we have the size of the asset, copy the bytes into a static array
|
||||
#[unsafe(export_name = #legacy_export_name)]
|
||||
static __LINK_SECTION: [u8; __LEN] = manganis::macro_helpers::copy_bytes(__BYTES);
|
||||
&__LINK_SECTION
|
||||
};
|
||||
let export_name = syn::LitStr::new(&format!("{}{}", prefix, hash), position);
|
||||
|
||||
quote! {
|
||||
#[used]
|
||||
static __LINK_SECTION: &'static [u8] = {
|
||||
// First serialize the asset into a constant sized buffer
|
||||
const __BUFFER: manganis::macro_helpers::const_serialize::ConstVec<u8> = manganis::macro_helpers::serialize_asset(&#asset);
|
||||
// Then pull out the byte slice
|
||||
const __BUFFER: #buffer_type = #serialize_fn(&#item);
|
||||
const __BYTES: &[u8] = __BUFFER.as_ref();
|
||||
// And the length of the byte slice
|
||||
const __LEN: usize = __BYTES.len();
|
||||
|
||||
// Now that we have the size of the asset, copy the bytes into a static array
|
||||
#[unsafe(export_name = #export_name)]
|
||||
static __LINK_SECTION: [u8; __LEN] = manganis::macro_helpers::copy_bytes(__BYTES);
|
||||
#[used]
|
||||
static __LINK_SECTION: [u8; __LEN] = #copy_bytes_fn(__BYTES);
|
||||
&__LINK_SECTION
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a linker section for embedding asset data in the binary
|
||||
///
|
||||
/// This function creates a static array containing the serialized asset data
|
||||
/// and exports it with the __ASSETS__ prefix for unified symbol collection.
|
||||
/// Uses the generic linker helper from dx-macro-helpers for consistency.
|
||||
pub fn generate_link_section(asset: impl ToTokens, asset_hash: &str) -> TokenStream2 {
|
||||
let item = asset;
|
||||
let hash: &str = asset_hash;
|
||||
let prefix_current: &str = "__ASSETS__";
|
||||
let prefix_legacy: &str = "__MANGANIS__";
|
||||
let serialize_fn_current = quote! { manganis::macro_helpers::serialize_asset };
|
||||
let serialize_fn_legacy = quote! { manganis::macro_helpers::serialize_asset_07 };
|
||||
let copy_bytes_fn = quote! { manganis::macro_helpers::copy_bytes };
|
||||
let buffer_type_current = quote! { manganis::macro_helpers::ConstVec<u8, 4096> };
|
||||
let buffer_type_legacy = quote! { manganis::macro_helpers::const_serialize_07::ConstVec<u8> };
|
||||
let position = proc_macro2::Span::call_site();
|
||||
let export_name = syn::LitStr::new(&format!("{}{}", prefix_current, hash), position);
|
||||
let legacy_export_name = syn::LitStr::new(&format!("{}{}", prefix_legacy, hash), position);
|
||||
|
||||
let used_attr = if false {
|
||||
quote! { #[used] }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
quote! {
|
||||
// We bundle both the legacy and new link sections for compatibility.
|
||||
static __LEGACY_LINK_SECTION: &'static [u8] = {
|
||||
const __BUFFER: #buffer_type_legacy = #serialize_fn_legacy(&#item);
|
||||
const __BYTES: &[u8] = __BUFFER.as_ref();
|
||||
const __LEN: usize = __BYTES.len();
|
||||
|
||||
#used_attr
|
||||
#[unsafe(export_name = #legacy_export_name)]
|
||||
static __LINK_SECTION: [u8; __LEN] = #copy_bytes_fn(__BYTES);
|
||||
&__LINK_SECTION
|
||||
};
|
||||
|
||||
static __LINK_SECTION: &'static [u8] = {
|
||||
const __BUFFER: #buffer_type_current = #serialize_fn_current(&#item);
|
||||
const __BYTES: &[u8] = __BUFFER.as_ref();
|
||||
const __LEN: usize = __BYTES.len();
|
||||
|
||||
#used_attr
|
||||
#[unsafe(export_name = #export_name)]
|
||||
static __LINK_SECTION: [u8; __LEN] = #copy_bytes_fn(__BYTES);
|
||||
&__LINK_SECTION
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,19 @@ keywords = ["assets"]
|
||||
const-serialize = { workspace = true }
|
||||
manganis-core = { workspace = true }
|
||||
manganis-macro = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
const-serialize-07 = { package = "const-serialize", version = "=0.7.2" }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
ndk-context = "0.1.1"
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
dioxus = ["manganis-core/dioxus"]
|
||||
@@ -1,23 +1,17 @@
|
||||
# Manganis
|
||||
|
||||
The Manganis allows you to submit assets to any build tool that supports collecting assets. It makes it easy to self-host assets that are distributed throughout your libraries. Manganis also handles optimizing, converting, and fetching assets.
|
||||
Manganis is a tool for submitting assets and native source files to the program linker. It makes it easy to self-host assets and native plugins that are distributed throughout your libraries.
|
||||
|
||||
If you defined this in a component library:
|
||||
## Assets
|
||||
|
||||
Including assets is as simple as using the `asset!()` macro:
|
||||
|
||||
```rust
|
||||
use manganis::{Asset, asset};
|
||||
const AVIF_ASSET: Asset = manganis::asset!("/assets/image.png");
|
||||
const STYLE: Asset = asset!("/assets/style.css");
|
||||
```
|
||||
|
||||
AVIF_ASSET will be set to a new file name that will be served by some CLI. That file can be collected by any package that depends on the component library.
|
||||
|
||||
If you have assets that may not always be bundled, you can fall back gracefully with `option_asset!`:
|
||||
|
||||
```rust
|
||||
use manganis::{Asset, asset, option_asset};
|
||||
const REQUIRED: Asset = asset!("/assets/style.css");
|
||||
const OPTIONAL: Option<Asset> = option_asset!("/assets/missing.css");
|
||||
```
|
||||
After cargo builds your app, the asset path is embedded directly in the data section. A tool like the Dioxus CLI can extract this metadata and post-process these assets.
|
||||
|
||||
```rust
|
||||
use manganis::{ImageFormat, Asset, asset, ImageSize, AssetOptions};
|
||||
@@ -34,6 +28,16 @@ pub const RESIZED_PNG_ASSET: Asset =
|
||||
pub const AVIF_ASSET: Asset = asset!("/assets/image.png", AssetOptions::image().with_format(ImageFormat::Avif));
|
||||
```
|
||||
|
||||
## Adding Support to Your CLI
|
||||
## option_asset
|
||||
|
||||
To add support for your CLI, you need to integrate with the [manganis_cli_support](https://github.com/DioxusLabs/manganis/tree/main/cli-support) crate. This crate provides utilities to collect assets that integrate with the Manganis macro. It makes it easy to integrate an asset collection and optimization system into a build tool.
|
||||
If you have assets that may not always be bundled, you can fall back gracefully with `option_asset!`:
|
||||
|
||||
```rust
|
||||
use manganis::{Asset, asset, option_asset};
|
||||
const REQUIRED: Asset = asset!("/assets/style.css");
|
||||
const OPTIONAL: Option<Asset> = option_asset!("/assets/missing.css");
|
||||
```
|
||||
|
||||
## Native FFI Bindings
|
||||
|
||||
Manganis provides the `#[ffi]` attribute macro for generating direct FFI bindings between Rust and native platforms (Swift/Kotlin). See the `geolocation-native-plugin` example for usage.
|
||||
@@ -0,0 +1,63 @@
|
||||
use jni::{objects::JObject, JNIEnv, JavaVM};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Cached reference to the Android activity.
|
||||
static ACTIVITY: OnceLock<jni::objects::GlobalRef> = OnceLock::new();
|
||||
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
|
||||
|
||||
/// Execute a JNI operation with a cached activity reference.
|
||||
///
|
||||
/// This function handles the boilerplate of getting the JavaVM and Activity
|
||||
/// references, caching them for subsequent calls. It's the foundation for
|
||||
/// most Android mobile API operations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `f` - A closure that receives a mutable JNIEnv and the Activity JObject
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(R)` if the operation succeeds, `None` if there's an error
|
||||
/// getting the VM or Activity references.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use dioxus_platform_bridge::android::with_activity;
|
||||
///
|
||||
/// let result = with_activity(|env, activity| {
|
||||
/// // Your JNI operations here
|
||||
/// Some(42)
|
||||
/// });
|
||||
/// ```
|
||||
pub fn with_activity<F, R>(f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&mut JNIEnv<'_>, &JObject<'_>) -> Option<R>,
|
||||
{
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = if let Some(vm) = JAVA_VM.get() {
|
||||
vm
|
||||
} else {
|
||||
let raw_vm = unsafe { JavaVM::from_raw(ctx.vm().cast()) }.ok()?;
|
||||
let _ = JAVA_VM.set(raw_vm);
|
||||
JAVA_VM.get()?
|
||||
};
|
||||
let mut env = vm.attach_current_thread().ok()?;
|
||||
|
||||
let activity = if let Some(activity) = ACTIVITY.get() {
|
||||
activity
|
||||
} else {
|
||||
let raw_activity = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
let global = env.new_global_ref(&raw_activity).ok()?;
|
||||
match ACTIVITY.set(global) {
|
||||
Ok(()) => ACTIVITY.get().unwrap(),
|
||||
Err(global) => {
|
||||
drop(global);
|
||||
ACTIVITY.get()?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let activity_obj = activity.as_obj();
|
||||
f(&mut env, &activity_obj)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use jni::{
|
||||
objects::{GlobalRef, JClass, JObject, JValue},
|
||||
sys::jlong,
|
||||
JNIEnv, NativeMethod,
|
||||
};
|
||||
|
||||
use crate::android::java::Result;
|
||||
|
||||
/// Generic callback system for loading DEX classes and registering native methods
|
||||
pub struct CallbackSystem {
|
||||
bytecode: &'static [u8],
|
||||
class_name: &'static str,
|
||||
callback_name: &'static str,
|
||||
callback_signature: &'static str,
|
||||
}
|
||||
|
||||
impl CallbackSystem {
|
||||
/// Create a new callback system
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bytecode` - The compiled DEX bytecode
|
||||
/// * `class_name` - The fully qualified Java class name
|
||||
/// * `callback_name` - The name of the native callback method
|
||||
/// * `callback_signature` - The JNI signature of the callback method
|
||||
pub fn new(
|
||||
bytecode: &'static [u8],
|
||||
class_name: &'static str,
|
||||
callback_name: &'static str,
|
||||
callback_signature: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
bytecode,
|
||||
class_name,
|
||||
callback_name,
|
||||
callback_signature,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the DEX class and register the native callback method
|
||||
///
|
||||
/// This function handles the boilerplate of:
|
||||
/// 1. Creating an InMemoryDexClassLoader
|
||||
/// 2. Loading the specified class
|
||||
/// 3. Registering the native callback method
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `GlobalRef` to the loaded class, or an error if loading fails
|
||||
pub fn load_and_register(&self, env: &mut JNIEnv<'_>) -> Result<GlobalRef> {
|
||||
let callback_class = self.load_dex_class(env)?;
|
||||
self.register_native_callback(env, &callback_class)?;
|
||||
let global = env.new_global_ref(callback_class)?;
|
||||
Ok(global)
|
||||
}
|
||||
|
||||
/// Load the DEX class from bytecode
|
||||
fn load_dex_class<'a>(&self, env: &mut JNIEnv<'a>) -> Result<JClass<'a>> {
|
||||
const IN_MEMORY_LOADER: &str = "dalvik/system/InMemoryDexClassLoader";
|
||||
|
||||
// Create a ByteBuffer from our DEX bytecode
|
||||
let byte_buffer = unsafe {
|
||||
env.new_direct_byte_buffer(self.bytecode.as_ptr() as *mut u8, self.bytecode.len())
|
||||
}?;
|
||||
|
||||
// Create an InMemoryDexClassLoader with our DEX bytecode
|
||||
let dex_class_loader = env.new_object(
|
||||
IN_MEMORY_LOADER,
|
||||
"(Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V",
|
||||
&[
|
||||
JValue::Object(&byte_buffer),
|
||||
JValue::Object(&JObject::null()),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Load our class
|
||||
let class_name = env.new_string(self.class_name)?;
|
||||
let class = env
|
||||
.call_method(
|
||||
&dex_class_loader,
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&class_name)],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
Ok(class.into())
|
||||
}
|
||||
|
||||
/// Register the native callback method with the Java class
|
||||
fn register_native_callback<'a>(
|
||||
&self,
|
||||
env: &mut JNIEnv<'a>,
|
||||
callback_class: &JClass<'a>,
|
||||
) -> Result<()> {
|
||||
env.register_native_methods(
|
||||
callback_class,
|
||||
&[NativeMethod {
|
||||
name: self.callback_name.into(),
|
||||
sig: self.callback_signature.into(),
|
||||
fn_ptr: rust_callback as *mut _,
|
||||
}],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic native callback function called from Java
|
||||
///
|
||||
/// SAFETY: This function is called from Java and must maintain proper memory safety.
|
||||
/// The handler pointer is valid as long as the Manager exists (see Drop implementation).
|
||||
#[no_mangle]
|
||||
unsafe extern "C" fn rust_callback<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JObject<'a>,
|
||||
handler_ptr_high: jlong,
|
||||
handler_ptr_low: jlong,
|
||||
location: JObject<'a>,
|
||||
) {
|
||||
// Reconstruct the pointer from two i64 values (for 64-bit pointers)
|
||||
#[cfg(not(target_pointer_width = "64"))]
|
||||
compile_error!("Only 64-bit Android targets are supported");
|
||||
|
||||
let handler_ptr_raw: usize =
|
||||
((handler_ptr_high as u64) << 32 | handler_ptr_low as u64) as usize;
|
||||
|
||||
// Convert to our callback function pointer
|
||||
let callback: fn(&mut JNIEnv, JObject) = unsafe { std::mem::transmute(handler_ptr_raw) };
|
||||
|
||||
// Use the local reference for this JNI frame; avoid leaking a global ref.
|
||||
callback(&mut env, location);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use jni::{
|
||||
objects::{JClass, JObject, JObjectArray, JString, JValue, JValueOwned},
|
||||
JNIEnv,
|
||||
};
|
||||
|
||||
/// Result type for JNI operations
|
||||
pub type Result<T> = std::result::Result<T, jni::errors::Error>;
|
||||
|
||||
/// Helper functions for common JNI operations
|
||||
|
||||
/// Create a new Java string tied to the current JNI frame
|
||||
pub fn new_string<'env>(env: &mut JNIEnv<'env>, s: &str) -> Result<JString<'env>> {
|
||||
env.new_string(s)
|
||||
}
|
||||
|
||||
/// Create a new object array
|
||||
pub fn new_object_array<'env>(
|
||||
env: &mut JNIEnv<'env>,
|
||||
len: i32,
|
||||
element_class: &str,
|
||||
) -> Result<JObjectArray<'env>> {
|
||||
env.new_object_array(len, element_class, &JObject::null())
|
||||
}
|
||||
|
||||
/// Set an element in an object array
|
||||
pub fn set_object_array_element<'env>(
|
||||
env: &mut JNIEnv<'env>,
|
||||
array: &JObjectArray<'env>,
|
||||
index: i32,
|
||||
element: JString<'env>,
|
||||
) -> Result<()> {
|
||||
env.set_object_array_element(array, index, element)
|
||||
}
|
||||
|
||||
/// Call a static method on a class
|
||||
pub fn call_static_method<'env, 'obj>(
|
||||
env: &mut JNIEnv<'env>,
|
||||
class: &JClass<'env>,
|
||||
method_name: &str,
|
||||
signature: &str,
|
||||
args: &[JValue<'env, 'obj>],
|
||||
) -> Result<JValueOwned<'env>> {
|
||||
env.call_static_method(class, method_name, signature, args)
|
||||
}
|
||||
|
||||
/// Call an instance method on an object
|
||||
pub fn call_method<'env, 'obj>(
|
||||
env: &mut JNIEnv<'env>,
|
||||
obj: &JObject<'env>,
|
||||
method_name: &str,
|
||||
signature: &str,
|
||||
args: &[JValue<'env, 'obj>],
|
||||
) -> Result<JValueOwned<'env>> {
|
||||
env.call_method(obj, method_name, signature, args)
|
||||
}
|
||||
|
||||
/// Find a Java class by name
|
||||
pub fn find_class<'env>(env: &mut JNIEnv<'env>, class_name: &str) -> Result<JClass<'env>> {
|
||||
env.find_class(class_name)
|
||||
}
|
||||
|
||||
/// Create a new object instance
|
||||
pub fn new_object<'env, 'obj>(
|
||||
env: &mut JNIEnv<'env>,
|
||||
class_name: &str,
|
||||
signature: &str,
|
||||
args: &[JValue<'env, 'obj>],
|
||||
) -> Result<JObject<'env>> {
|
||||
env.new_object(class_name, signature, args)
|
||||
}
|
||||
|
||||
/// Check if a permission is granted (Activity.checkSelfPermission)
|
||||
pub fn check_self_permission(
|
||||
env: &mut JNIEnv,
|
||||
activity: &JObject,
|
||||
permission: &str,
|
||||
) -> Result<bool> {
|
||||
let permission_string = new_string(env, permission)?;
|
||||
let status = env.call_method(
|
||||
activity,
|
||||
"checkSelfPermission",
|
||||
"(Ljava/lang/String;)I",
|
||||
&[JValue::Object(&permission_string)],
|
||||
)?;
|
||||
|
||||
const PERMISSION_GRANTED: i32 = 0;
|
||||
Ok(status.i()? == PERMISSION_GRANTED)
|
||||
}
|
||||
|
||||
/// Request permissions via a helper class's static method
|
||||
///
|
||||
/// This uses PermissionsHelper.requestPermissionsOnUiThread(pattern)
|
||||
/// to request permissions on the UI thread.
|
||||
pub fn request_permissions_via_helper(
|
||||
env: &mut JNIEnv,
|
||||
helper_class: &JClass,
|
||||
activity: &JObject,
|
||||
permissions: JObjectArray,
|
||||
request_code: i32,
|
||||
) -> Result<()> {
|
||||
env.call_static_method(
|
||||
helper_class,
|
||||
"requestPermissionsOnUiThread",
|
||||
"(Landroid/app/Activity;[Ljava/lang/String;I)V",
|
||||
&[
|
||||
JValue::Object(activity),
|
||||
JValue::Object(&permissions.into()),
|
||||
JValue::Int(request_code),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a Java class from the APK's classloader
|
||||
pub fn load_class_from_classloader<'env>(
|
||||
env: &mut JNIEnv<'env>,
|
||||
class_name: &str,
|
||||
) -> Result<JClass<'env>> {
|
||||
let class_name_jstring = new_string(env, class_name)?;
|
||||
let class = env.call_static_method(
|
||||
"java/lang/Class",
|
||||
"forName",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&class_name_jstring)],
|
||||
)?;
|
||||
Ok(class.l()?.into())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Android metadata wrappers for linker-based collection.
|
||||
|
||||
pub use manganis_core::AndroidArtifactMetadata;
|
||||
|
||||
pub type AndroidMetadataBuffer = crate::macro_helpers::ConstVec<u8, 4096>;
|
||||
|
||||
pub const fn serialize_android_metadata(meta: &AndroidArtifactMetadata) -> AndroidMetadataBuffer {
|
||||
crate::macro_helpers::serialize_android_artifact(meta)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Android-specific utilities for mobile APIs
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod activity;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod callback;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod java;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod metadata;
|
||||
|
||||
#[doc(hidden)]
|
||||
pub mod macro_helpers {
|
||||
//! Helper functions for macro expansion
|
||||
//!
|
||||
//! These functions are used internally by the `android_plugin!()` macro
|
||||
//! and should not be used directly.
|
||||
|
||||
/// Copy a slice into a constant sized buffer at compile time
|
||||
pub const fn copy_bytes<const N: usize>(bytes: &[u8]) -> [u8; N] {
|
||||
let mut out = [0; N];
|
||||
let mut i = 0;
|
||||
while i < N {
|
||||
out[i] = bytes[i];
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use activity::*;
|
||||
#[cfg(target_os = "android")]
|
||||
pub use callback::*;
|
||||
#[cfg(target_os = "android")]
|
||||
pub use java::*;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use metadata::*;
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Darwin (iOS/macOS) shared utilities for objc2-based APIs
|
||||
//!
|
||||
//! This module provides shared utilities for both iOS and macOS platforms
|
||||
//! since they share the same Objective-C runtime and threading requirements
|
||||
//! through objc2.
|
||||
|
||||
/// Re-export MainThreadMarker for convenience
|
||||
pub use objc2::MainThreadMarker;
|
||||
|
||||
/// Macro helpers for FFI code generation
|
||||
pub mod macro_helpers {
|
||||
pub use crate::macro_helpers::copy_bytes;
|
||||
}
|
||||
|
||||
pub use manganis_core::SwiftPackageMetadata;
|
||||
|
||||
pub mod metadata {
|
||||
use manganis_core::SwiftPackageMetadata;
|
||||
|
||||
/// Buffer type for serialized Swift metadata
|
||||
#[doc(hidden)]
|
||||
pub type SwiftMetadataBuffer = crate::macro_helpers::ConstVec<u8, 4096>;
|
||||
|
||||
/// Serialize Swift package metadata for linker embedding
|
||||
#[doc(hidden)]
|
||||
pub const fn serialize_swift_metadata(meta: &SwiftPackageMetadata) -> SwiftMetadataBuffer {
|
||||
crate::macro_helpers::serialize_swift_package(meta)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
#[doc(hidden)]
|
||||
pub mod macro_helpers;
|
||||
@@ -8,6 +7,56 @@ pub use manganis_macro::css_module;
|
||||
pub use manganis_macro::option_asset;
|
||||
|
||||
pub use manganis_core::{
|
||||
Asset, AssetOptions, AssetVariant, BundledAsset, CssAssetOptions, CssModuleAssetOptions,
|
||||
FolderAssetOptions, ImageAssetOptions, ImageFormat, ImageSize, JsAssetOptions,
|
||||
// Core asset types
|
||||
Asset,
|
||||
AssetOptions,
|
||||
AssetVariant,
|
||||
BundledAsset,
|
||||
// Standard asset options
|
||||
CssAssetOptions,
|
||||
CssModuleAssetOptions,
|
||||
FolderAssetOptions,
|
||||
ImageAssetOptions,
|
||||
ImageFormat,
|
||||
ImageSize,
|
||||
JsAssetOptions,
|
||||
};
|
||||
|
||||
// Re-export metadata types for FFI and sidecar macros
|
||||
pub use manganis_core::SwiftPackageMetadata;
|
||||
pub use manganis_core::SymbolData;
|
||||
|
||||
// FFI utilities and plugin metadata for Dioxus mobile platform APIs
|
||||
//
|
||||
// This crate provides common patterns and utilities for implementing
|
||||
// mobile platform APIs in Dioxus applications. It handles the
|
||||
// boilerplate for JNI (Android) and objc2 (iOS/macOS) bindings, build scripts,
|
||||
// and platform-specific resource management.
|
||||
|
||||
/// Android platform utilities
|
||||
#[doc(hidden)]
|
||||
pub mod android;
|
||||
|
||||
/// Darwin (iOS/macOS) platform utilities
|
||||
#[doc(hidden)]
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub mod darwin;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::*;
|
||||
|
||||
// Export darwin module for iOS, macOS, and when metadata feature is enabled (for FFI macro)
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub use darwin::*;
|
||||
|
||||
/// Re-export commonly used types for convenience
|
||||
#[cfg(target_os = "android")]
|
||||
pub use jni;
|
||||
|
||||
// Re-export objc2 for FFI macro generated code
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub use objc2;
|
||||
|
||||
/// Re-export the ffi attribute macro for native FFI bindings
|
||||
/// This macro generates direct FFI bindings between Rust and native platforms (Swift/Kotlin)
|
||||
pub use manganis_macro::ffi;
|
||||
@@ -1,11 +1,44 @@
|
||||
// Re-export const_serialize types for generated code.
|
||||
pub use const_serialize;
|
||||
use const_serialize::{serialize_const, ConstVec, SerializeConst};
|
||||
pub use const_serialize::{ConstStr, ConstVec, SerializeConst};
|
||||
pub use const_serialize_07;
|
||||
use const_serialize_07::{
|
||||
serialize_const as serialize_const_07, ConstVec as ConstVec07,
|
||||
SerializeConst as SerializeConst07,
|
||||
|
||||
use const_serialize_07::ConstVec as ConstVec07;
|
||||
use manganis_core::{
|
||||
AndroidArtifactMetadata, AssetOptions, BundledAsset, SwiftPackageMetadata, SymbolData,
|
||||
};
|
||||
use manganis_core::{AssetOptions, BundledAsset};
|
||||
|
||||
/// Copy a slice into a constant sized buffer at compile time
|
||||
///
|
||||
/// This is a generic utility that works with any byte slice and can be used
|
||||
/// in const contexts to create fixed-size arrays from dynamic slices.
|
||||
pub const fn copy_bytes<const N: usize>(bytes: &[u8]) -> [u8; N] {
|
||||
let mut out = [0; N];
|
||||
let mut i = 0;
|
||||
while i < N {
|
||||
out[i] = bytes[i];
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Serialize a SymbolData value into a const buffer
|
||||
///
|
||||
/// This is used by the widget!() macro and other symbol-based macros to embed
|
||||
/// metadata into the binary using the 4096-byte buffer format.
|
||||
pub const fn serialize_symbol_data(symbol_data: &SymbolData) -> ConstVec<u8, 4096> {
|
||||
dx_macro_helpers::serialize_to_const_with_max_padded::<4096>(symbol_data)
|
||||
}
|
||||
|
||||
/// Serialize Android artifact metadata (wrapped in `SymbolData::AndroidArtifact`).
|
||||
pub const fn serialize_android_artifact(meta: &AndroidArtifactMetadata) -> ConstVec<u8, 4096> {
|
||||
serialize_symbol_data(&SymbolData::AndroidArtifact(*meta))
|
||||
}
|
||||
|
||||
/// Serialize Swift package metadata (wrapped in `SymbolData::SwiftPackage`).
|
||||
pub const fn serialize_swift_package(meta: &SwiftPackageMetadata) -> ConstVec<u8, 4096> {
|
||||
serialize_symbol_data(&SymbolData::SwiftPackage(*meta))
|
||||
}
|
||||
|
||||
/// Create a bundled asset from the input path, the content hash, and the asset options
|
||||
pub const fn create_bundled_asset(input_path: &str, asset_config: AssetOptions) -> BundledAsset {
|
||||
@@ -26,25 +59,16 @@ pub const fn create_bundled_asset_relative(
|
||||
}
|
||||
|
||||
/// Serialize an asset to a const buffer
|
||||
pub const fn serialize_asset(asset: &BundledAsset) -> ConstVec<u8> {
|
||||
let data = ConstVec::new();
|
||||
let mut data = serialize_const(asset, data);
|
||||
// Reserve the maximum size of the asset
|
||||
while data.len() < <BundledAsset as SerializeConst>::MEMORY_LAYOUT.size() {
|
||||
data = data.push(0);
|
||||
}
|
||||
data
|
||||
///
|
||||
/// Serializes the asset directly (not wrapped in SymbolData) for simplicity.
|
||||
/// Uses a 4096-byte buffer and pads to the full size to match linker section size.
|
||||
pub const fn serialize_asset(asset: &BundledAsset) -> ConstVec<u8, 4096> {
|
||||
dx_macro_helpers::serialize_to_const_with_max_padded::<4096>(asset)
|
||||
}
|
||||
|
||||
/// Serialize an asset to a const buffer in the legacy 0.7 format
|
||||
pub const fn serialize_asset_07(asset: &BundledAsset) -> ConstVec07<u8> {
|
||||
let data = ConstVec07::new();
|
||||
let mut data = serialize_const_07(asset, data);
|
||||
// Reserve the maximum size of the asset
|
||||
while data.len() < <BundledAsset as SerializeConst07>::MEMORY_LAYOUT.size() {
|
||||
data = data.push(0);
|
||||
}
|
||||
data
|
||||
dx_macro_helpers::serialize_to_const_with_layout_padded_07(asset)
|
||||
}
|
||||
|
||||
/// Deserialize a const buffer into a BundledAsset
|
||||
@@ -55,13 +79,81 @@ pub const fn deserialize_asset(bytes: &[u8]) -> BundledAsset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a slice into a constant sized buffer at compile time
|
||||
pub const fn copy_bytes<const N: usize>(bytes: &[u8]) -> [u8; N] {
|
||||
let mut out = [0; N];
|
||||
let mut i = 0;
|
||||
while i < N {
|
||||
out[i] = bytes[i];
|
||||
i += 1;
|
||||
pub mod dx_macro_helpers {
|
||||
use const_serialize::{ConstVec, SerializeConst};
|
||||
|
||||
/// Serialize a value to a const buffer, padding to the specified size
|
||||
///
|
||||
/// This is a generic helper that works with any type implementing `SerializeConst`.
|
||||
/// It serializes the value and then pads the buffer to the specified memory layout size.
|
||||
pub const fn serialize_to_const<T: SerializeConst>(
|
||||
value: &T,
|
||||
memory_layout_size: usize,
|
||||
) -> ConstVec<u8> {
|
||||
let data = ConstVec::new();
|
||||
let mut data = const_serialize::serialize_const(value, data);
|
||||
// Reserve the maximum size of the type
|
||||
while data.len() < memory_layout_size {
|
||||
data = data.push(0);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// Serialize a value to a const buffer with a fixed maximum size, padding to the specified size
|
||||
///
|
||||
/// This variant uses a `ConstVec` with a fixed maximum size (e.g., `ConstVec<u8, 4096>`)
|
||||
/// and then pads to the specified memory layout size.
|
||||
///
|
||||
/// This function serializes directly into the larger buffer to avoid overflow issues
|
||||
/// when the serialized data exceeds the default 1024-byte buffer size.
|
||||
pub const fn serialize_to_const_with_max<const MAX_SIZE: usize>(
|
||||
value: &impl SerializeConst,
|
||||
memory_layout_size: usize,
|
||||
) -> ConstVec<u8, MAX_SIZE> {
|
||||
// Serialize using the default buffer, then copy into the larger buffer
|
||||
let serialized = const_serialize::serialize_const(value, ConstVec::new());
|
||||
let mut data: ConstVec<u8, MAX_SIZE> = ConstVec::new_with_max_size();
|
||||
data = data.extend(serialized.as_ref());
|
||||
// Reserve the maximum size of the type (pad to MEMORY_LAYOUT size)
|
||||
while data.len() < memory_layout_size {
|
||||
data = data.push(0);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// Serialize a value to a const buffer and pad to the full buffer size
|
||||
///
|
||||
/// This is useful for linker section generation that expects a fixed-size buffer.
|
||||
pub const fn serialize_to_const_with_max_padded<const MAX_SIZE: usize>(
|
||||
value: &impl SerializeConst,
|
||||
) -> ConstVec<u8, MAX_SIZE> {
|
||||
let serialized = const_serialize::serialize_const(value, ConstVec::new());
|
||||
let mut data: ConstVec<u8, MAX_SIZE> = ConstVec::new_with_max_size();
|
||||
data = data.extend(serialized.as_ref());
|
||||
while data.len() < MAX_SIZE {
|
||||
data = data.push(0);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// Serialize a value using the legacy 0.7 const-serialize format and pad to layout size
|
||||
///
|
||||
/// Note: The legacy ConstVec has a 1024-byte limit. If MEMORY_LAYOUT.size() exceeds this,
|
||||
/// we pad only up to the buffer limit to avoid overflow.
|
||||
pub const fn serialize_to_const_with_layout_padded_07<T: const_serialize_07::SerializeConst>(
|
||||
value: &T,
|
||||
) -> const_serialize_07::ConstVec<u8> {
|
||||
let data = const_serialize_07::ConstVec::new();
|
||||
let mut data = const_serialize_07::serialize_const(value, data);
|
||||
// Pad to MEMORY_LAYOUT size, but cap at 1024 bytes (the legacy buffer limit)
|
||||
let target_size = if T::MEMORY_LAYOUT.size() > 1024 {
|
||||
1024
|
||||
} else {
|
||||
T::MEMORY_LAYOUT.size()
|
||||
};
|
||||
while data.len() < target_size {
|
||||
data = data.push(0);
|
||||
}
|
||||
data
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user