diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2f9252b7..668f8b17b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2f22286a5..be4a1fdd9 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock index 92db4fa5e..2ec09a0d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 541a52a00..1ce425651 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/examples/01-app-demos/geolocation-native-plugin/Cargo.toml b/examples/01-app-demos/geolocation-native-plugin/Cargo.toml new file mode 100644 index 000000000..eb04f63f1 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "geolocation-native-plugin" +version = "0.1.0" +authors = ["Sabin Regmi "] +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"] diff --git a/examples/01-app-demos/geolocation-native-plugin/Dioxus.toml b/examples/01-app-demos/geolocation-native-plugin/Dioxus.toml new file mode 100644 index 000000000..b7dfe9781 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/Dioxus.toml @@ -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" } diff --git a/examples/01-app-demos/geolocation-native-plugin/README.md b/examples/01-app-demos/geolocation-native-plugin/README.md new file mode 100644 index 000000000..e987a1d22 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/README.md @@ -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). diff --git a/examples/01-app-demos/geolocation-native-plugin/assets/favicon.ico b/examples/01-app-demos/geolocation-native-plugin/assets/favicon.ico new file mode 100644 index 000000000..eed0c0973 Binary files /dev/null and b/examples/01-app-demos/geolocation-native-plugin/assets/favicon.ico differ diff --git a/examples/01-app-demos/geolocation-native-plugin/assets/header.svg b/examples/01-app-demos/geolocation-native-plugin/assets/header.svg new file mode 100644 index 000000000..59c96f2f2 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/assets/header.svg @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/examples/01-app-demos/geolocation-native-plugin/assets/main.css b/examples/01-app-demos/geolocation-native-plugin/assets/main.css new file mode 100644 index 000000000..7783a6583 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/assets/main.css @@ -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; + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/android/build.gradle.kts b/examples/01-app-demos/geolocation-native-plugin/src/android/build.gradle.kts new file mode 100644 index 000000000..f980dba4a --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/android/build.gradle.kts @@ -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().configureEach { + archiveBaseName.set("geolocation-plugin") +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/android/consumer-rules.pro b/examples/01-app-demos/geolocation-native-plugin/src/android/consumer-rules.pro new file mode 100644 index 000000000..7b3a45552 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/android/consumer-rules.pro @@ -0,0 +1 @@ +# Intentionally empty; no consumer Proguard rules required for the geolocation plugin. diff --git a/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/AndroidManifest.xml b/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1cbe549e6 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/kotlin/com/dioxus/geolocation/Geolocation.kt b/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/kotlin/com/dioxus/geolocation/Geolocation.kt new file mode 100644 index 000000000..107794fd4 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/kotlin/com/dioxus/geolocation/Geolocation.kt @@ -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 + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/kotlin/com/dioxus/geolocation/GeolocationPlugin.kt b/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/kotlin/com/dioxus/geolocation/GeolocationPlugin.kt new file mode 100644 index 000000000..bff19dc5e --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/android/src/main/kotlin/com/dioxus/geolocation/GeolocationPlugin.kt @@ -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 { + val response = mutableMapOf() + 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) -> Unit) { + val permissionsToRequest = mutableListOf() + + 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() + } + +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/.gitignore b/examples/01-app-demos/geolocation-native-plugin/src/ios/.gitignore new file mode 100644 index 000000000..5922fdaa5 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Package.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Package.swift new file mode 100644 index 000000000..c0683104d --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Package.swift @@ -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])), + ] + ) + ] +) diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Sources/GeolocationPlugin.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Sources/GeolocationPlugin.swift new file mode 100644 index 000000000..756038e91 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Sources/GeolocationPlugin.swift @@ -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.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.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + return "{\"success\":true}" + } else { + return "{\"error\":\"Live Activities require iOS 16.2+\"}" + } + } + +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Sources/LocationActivityAttributes.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Sources/LocationActivityAttributes.swift new file mode 100644 index 000000000..a64009381 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Sources/LocationActivityAttributes.swift @@ -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 + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Tests/PluginTests/PluginTests.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Tests/PluginTests/PluginTests.swift new file mode 100644 index 000000000..99992ce4c --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/plugin/Tests/PluginTests/PluginTests.swift @@ -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() + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Package.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Package.swift new file mode 100644 index 000000000..4e5465e61 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Package.swift @@ -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"), + ] + ) + ] +) diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Sources/LocationActivityAttributes.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Sources/LocationActivityAttributes.swift new file mode 100644 index 000000000..a64009381 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Sources/LocationActivityAttributes.swift @@ -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 + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Sources/LocationWidget.swift b/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Sources/LocationWidget.swift new file mode 100644 index 000000000..5e603513b --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/ios/widget/Sources/LocationWidget.swift @@ -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)) + } + } + } + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/main.rs b/examples/01-app-demos/geolocation-native-plugin/src/main.rs new file mode 100644 index 000000000..fe40027a7 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/main.rs @@ -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::); + let mut last_position = use_signal(|| None::); + let mut error = use_signal(|| None::); + 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::().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) -> String { + value + .map(|inner| format!("{inner:.2}")) + .unwrap_or_else(|| "—".to_string()) +} + +#[cfg(target_os = "ios")] +#[component] +fn LiveActivityCard( + mut geolocation: Signal, + mut error: Signal>, +) -> Element { + let mut live_activity = use_signal(|| None::); + + 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, error: Signal>) -> Element { + VNode::empty() +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/plugin/error.rs b/examples/01-app-demos/geolocation-native-plugin/src/plugin/error.rs new file mode 100644 index 000000000..aa29b3fc2 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/plugin/error.rs @@ -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 = std::result::Result; + +#[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(&self, serializer: S) -> std::result::Result + 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 for Error { + fn from(s: String) -> Self { + Error::PlatformBridge(s) + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/plugin/mod.rs b/examples/01-app-demos/geolocation-native-plugin/src/plugin/mod.rs new file mode 100644 index 000000000..d521252fa --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/plugin/mod.rs @@ -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, +} + +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) -> Result { + 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 { + 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>, + ) -> Result { + 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 { + 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 { + 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 { + Err(Error::PlatformBridge( + "Geolocation is only supported on Android, iOS, and macOS".to_string(), + )) + } + } + + pub fn getCurrentPositionJson(_: &GeolocationPlugin, _: String) -> Result { + Err(Error::PlatformBridge( + "Geolocation is only supported on Android, iOS, and macOS".to_string(), + )) + } + + pub fn checkPermissionsJson(_: &GeolocationPlugin) -> Result { + Err(Error::PlatformBridge( + "Geolocation is only supported on Android, iOS, and macOS".to_string(), + )) + } + + pub fn requestPermissionsJson(_: &GeolocationPlugin, _: String) -> Result { + Err(Error::PlatformBridge( + "Geolocation is only supported on Android, iOS, and macOS".to_string(), + )) + } +} diff --git a/examples/01-app-demos/geolocation-native-plugin/src/plugin/models.rs b/examples/01-app-demos/geolocation-native-plugin/src/plugin/models.rs new file mode 100644 index 000000000..59581b053 --- /dev/null +++ b/examples/01-app-demos/geolocation-native-plugin/src/plugin/models.rs @@ -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, + /// The altitude the user is at, if available. + pub altitude: Option, + // The speed the user is traveling, if available. + pub speed: Option, + /// The heading the user is facing, if available. + pub heading: Option, +} + +#[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, +} diff --git a/notes/architecture/11-NATIVE-PLUGIN-FFI.md b/notes/architecture/11-NATIVE-PLUGIN-FFI.md new file mode 100644 index 000000000..373ff6d29 --- /dev/null +++ b/notes/architecture/11-NATIVE-PLUGIN-FFI.md @@ -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; +} + +// 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 diff --git a/notes/architecture/12-MANIFEST-SYSTEM.md b/notes/architecture/12-MANIFEST-SYSTEM.md new file mode 100644 index 000000000..036d9e85d --- /dev/null +++ b/notes/architecture/12-MANIFEST-SYSTEM.md @@ -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 = """ +CustomKey +custom-value +""" +``` + +### 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 = """ + +""" +``` + +### 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 = """ +CustomKey +custom-value +""" +``` + +## 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. diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 28dd6ff2d..842805f4d 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -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 } diff --git a/packages/cli/assets/android/MainActivity.kt.hbs b/packages/cli/assets/android/MainActivity.kt.hbs index 15cc9e386..92943dd16 100644 --- a/packages/cli/assets/android/MainActivity.kt.hbs +++ b/packages/cli/assets/android/MainActivity.kt.hbs @@ -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() diff --git a/packages/cli/assets/android/gen/app/build.gradle.kts.hbs b/packages/cli/assets/android/gen/app/build.gradle.kts.hbs index 882e4ff85..a560d5dbc 100644 --- a/packages/cli/assets/android/gen/app/build.gradle.kts.hbs +++ b/packages/cli/assets/android/gen/app/build.gradle.kts.hbs @@ -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}} } diff --git a/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs b/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs index 9fdcded4b..483aab06e 100644 --- a/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs +++ b/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs @@ -1,11 +1,29 @@ + - + {{#each permissions}} + + {{/each}} + + + {{#each features}} + + {{/each}} + + + {{{ raw_manifest }}} + + @@ -15,6 +33,33 @@ + + {{#each url_schemes}} + + + + + + + + {{/each}} + + {{#each app_link_hosts}} + + + + + + + + {{/each}} + + {{#if foreground_service_type}} + + {{/if}} diff --git a/packages/cli/assets/ios/ios.plist.hbs b/packages/cli/assets/ios/ios.plist.hbs index cde27d52c..16b7cec8b 100644 --- a/packages/cli/assets/ios/ios.plist.hbs +++ b/packages/cli/assets/ios/ios.plist.hbs @@ -1,4 +1,3 @@ - @@ -16,9 +15,9 @@ {{ bundle_name }} CFBundleVersion - 0.1.0 + {{ version }} CFBundleShortVersionString - 0.1.0 + {{ version }} CFBundleDevelopmentRegion en_US UILaunchStoryboardName @@ -42,8 +41,6 @@ iPhoneOS iPadOS - UILaunchStoryboardName - LaunchScreen UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -57,5 +54,42 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + + {{#each permissions}} + {{ this.key }} + {{ this.description }} + {{/each}} + + {{#if url_schemes}} + + CFBundleURLTypes + + + CFBundleURLSchemes + + {{#each url_schemes}} + {{ this }} + {{/each}} + + + + {{/if}} + + {{#if background_modes}} + + UIBackgroundModes + + {{#each background_modes}} + {{ this }} + {{/each}} + + {{/if}} + + + {{{ plist_entries }}} + + + {{{ raw_plist }}} diff --git a/packages/cli/assets/macos/mac.plist.hbs b/packages/cli/assets/macos/mac.plist.hbs index 921890c0d..999ffd84c 100644 --- a/packages/cli/assets/macos/mac.plist.hbs +++ b/packages/cli/assets/macos/mac.plist.hbs @@ -24,15 +24,39 @@ APPL CFBundleShortVersionString - 7.4.0 + {{ version }} CFBundleVersion - 1 - - LSApplicationCategoryType - public.app-category.social-networking + {{ version }} LSMinimumSystemVersion - 10.15 + {{ minimum_system_version }} + + + {{#each permissions}} + {{ this.key }} + {{ this.description }} + {{/each}} + + {{#if url_schemes}} + + CFBundleURLTypes + + + CFBundleURLSchemes + + {{#each url_schemes}} + {{ this }} + {{/each}} + + + + {{/if}} + + + {{{ plist_entries }}} + + + {{{ raw_plist }}} diff --git a/packages/cli/schema.json b/packages/cli/schema.json new file mode 100644 index 000000000..0f93edc69 --- /dev/null +++ b/packages/cli/schema.json @@ -0,0 +1,3285 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DioxusConfig", + "type": "object", + "properties": { + "android": { + "description": "Android-specific configuration.", + "$ref": "#/definitions/AndroidConfig" + }, + "application": { + "$ref": "#/definitions/ApplicationConfig" + }, + "background": { + "description": "Unified background mode configuration. Background capabilities declared here are mapped to platform-specific configurations. Use `[ios]`, `[android]` sections for overrides.", + "$ref": "#/definitions/BackgroundConfig" + }, + "bundle": { + "$ref": "#/definitions/BundleConfig" + }, + "components": { + "$ref": "#/definitions/ComponentConfig" + }, + "deep_links": { + "description": "Unified deep linking configuration. URL schemes and universal links declared here are mapped to platform-specific configurations. Use `[ios]`, `[android]`, `[macos]` sections for overrides.", + "$ref": "#/definitions/DeepLinkConfig" + }, + "ios": { + "description": "iOS-specific configuration.", + "$ref": "#/definitions/IosConfig" + }, + "linux": { + "description": "Linux-specific configuration.", + "$ref": "#/definitions/LinuxConfig" + }, + "macos": { + "description": "macOS-specific configuration.", + "$ref": "#/definitions/MacosConfig" + }, + "permissions": { + "description": "Unified permissions configuration. Permissions declared here are automatically mapped to platform-specific identifiers (AndroidManifest.xml, Info.plist, etc.)", + "$ref": "#/definitions/PermissionsConfig" + }, + "web": { + "$ref": "#/definitions/WebConfig" + }, + "windows": { + "description": "Windows-specific configuration.", + "$ref": "#/definitions/WindowsConfig" + } + }, + "definitions": { + "AndroidApplicationConfig": { + "type": "object", + "properties": { + "large_heap": { + "description": "Enable large heap.", + "type": [ + "boolean", + "null" + ] + }, + "supports_rtl": { + "description": "RTL layout support.", + "type": [ + "boolean", + "null" + ] + }, + "theme": { + "description": "Application theme.", + "type": [ + "string", + "null" + ] + }, + "uses_cleartext_traffic": { + "description": "Enable cleartext (HTTP) traffic.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "AndroidConfig": { + "description": "Android-specific configuration.\n\nExample: ```toml [android] min_sdk = 24 target_sdk = 34 identifier = \"com.example.myapp.android\" # Override bundle.identifier for Android features = [\"android.hardware.location.gps\"]\n\n# Android signing configuration (previously in [bundle.android]) [android.signing] jks_file = \"keystore.jks\" jks_password = \"password\" key_alias = \"mykey\" key_password = \"keypassword\"\n\n[android.permissions] \"android.permission.FOREGROUND_SERVICE\" = { description = \"Background service\" } ```", + "type": "object", + "properties": { + "application": { + "description": "Application-level config.", + "$ref": "#/definitions/AndroidApplicationConfig" + }, + "category": { + "description": "App category. Overrides `bundle.category` for Android builds.", + "type": [ + "string", + "null" + ] + }, + "compile_sdk": { + "description": "Compile SDK version.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "copyright": { + "description": "Copyright notice. Overrides `bundle.copyright` for Android builds.", + "type": [ + "string", + "null" + ] + }, + "features": { + "description": "Hardware/software features required.", + "type": "array", + "items": { + "type": "string" + } + }, + "foreground_service_types": { + "description": "Foreground service types for background operations. Valid values: \"camera\", \"connectedDevice\", \"dataSync\", \"health\", \"location\", \"mediaPlayback\", \"mediaProjection\", \"microphone\", \"phoneCall\", \"remoteMessaging\", \"shortService\", \"specialUse\", \"systemExempted\"", + "type": "array", + "items": { + "type": "string" + } + }, + "gradle_dependencies": { + "description": "Gradle dependencies to add.", + "type": "array", + "items": { + "type": "string" + } + }, + "gradle_plugins": { + "description": "Gradle plugins to apply.", + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "description": "Icons for the app. Overrides `bundle.icon` for Android builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "identifier": { + "description": "The app's identifier (e.g., \"com.example.myapp\"). Overrides `bundle.identifier` for Android builds.", + "type": [ + "string", + "null" + ] + }, + "intent_filters": { + "description": "Intent filters for deep linking. These extend the unified `[deep_links]` configuration with Android-specific options.", + "type": "array", + "items": { + "$ref": "#/definitions/AndroidIntentFilter" + } + }, + "long_description": { + "description": "Long description. Overrides `bundle.long_description` for Android builds.", + "type": [ + "string", + "null" + ] + }, + "manifest": { + "description": "Path to custom AndroidManifest.xml to merge.", + "type": [ + "string", + "null" + ] + }, + "min_sdk": { + "description": "Minimum SDK version.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "permissions": { + "description": "Additional Android permissions not in unified config.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/RawPermission" + } + }, + "proguard_rules": { + "description": "ProGuard rule files.", + "type": "array", + "items": { + "type": "string" + } + }, + "publisher": { + "description": "The app's publisher. Overrides `bundle.publisher` for Android builds.", + "type": [ + "string", + "null" + ] + }, + "queries": { + "description": "Queries for package visibility (required for Android 11+). Specify packages or intents your app needs to query.", + "$ref": "#/definitions/AndroidQueries" + }, + "raw": { + "description": "Raw XML injection points.", + "$ref": "#/definitions/AndroidRawConfig" + }, + "resources": { + "description": "Additional resources to bundle. Overrides `bundle.resources` for Android builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "short_description": { + "description": "Short description. Overrides `bundle.short_description` for Android builds.", + "type": [ + "string", + "null" + ] + }, + "signing": { + "description": "Android signing configuration for release builds. This replaces the deprecated `[bundle.android]` section.", + "anyOf": [ + { + "$ref": "#/definitions/AndroidSigningConfig" + }, + { + "type": "null" + } + ] + }, + "target_sdk": { + "description": "Target SDK version.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "url_schemes": { + "description": "Additional URL schemes beyond unified `[deep_links]`.schemes. These are merged with the unified schemes.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AndroidIntentData": { + "description": "Android intent data specification.", + "type": "object", + "properties": { + "host": { + "description": "Host (e.g., \"example.com\").", + "type": [ + "string", + "null" + ] + }, + "mime_type": { + "description": "MIME type.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Path (exact match).", + "type": [ + "string", + "null" + ] + }, + "path_pattern": { + "description": "Path pattern (with wildcards).", + "type": [ + "string", + "null" + ] + }, + "path_prefix": { + "description": "Path prefix.", + "type": [ + "string", + "null" + ] + }, + "port": { + "description": "Port number.", + "type": [ + "string", + "null" + ] + }, + "scheme": { + "description": "URL scheme (e.g., \"https\", \"myapp\").", + "type": [ + "string", + "null" + ] + } + } + }, + "AndroidIntentFilter": { + "description": "Android intent filter for deep linking.", + "type": "object", + "properties": { + "actions": { + "description": "Actions (e.g., \"android.intent.action.VIEW\").", + "type": "array", + "items": { + "type": "string" + } + }, + "auto_verify": { + "description": "Auto-verify for App Links (requires HTTPS and assetlinks.json).", + "type": "boolean" + }, + "categories": { + "description": "Categories (e.g., \"android.intent.category.DEFAULT\", \"android.intent.category.BROWSABLE\").", + "type": "array", + "items": { + "type": "string" + } + }, + "data": { + "description": "Data specifications.", + "type": "array", + "items": { + "$ref": "#/definitions/AndroidIntentData" + } + } + } + }, + "AndroidQueries": { + "description": "Android package visibility queries.", + "type": "object", + "properties": { + "intents": { + "description": "Intent actions to query.", + "type": "array", + "items": { + "$ref": "#/definitions/AndroidQueryIntent" + } + }, + "packages": { + "description": "Package names to query.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AndroidQueryIntent": { + "description": "Android query intent specification.", + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "description": "Action (e.g., \"android.intent.action.SEND\").", + "type": "string" + }, + "mime_type": { + "description": "MIME type (e.g., \"text/plain\").", + "type": [ + "string", + "null" + ] + }, + "scheme": { + "description": "Data scheme (e.g., \"mailto\").", + "type": [ + "string", + "null" + ] + } + } + }, + "AndroidRawConfig": { + "type": "object", + "properties": { + "application": { + "description": "Raw XML inside `` element.", + "type": [ + "string", + "null" + ] + }, + "application_attrs": { + "description": "Raw attributes for `` element.", + "type": [ + "string", + "null" + ] + }, + "manifest": { + "description": "Raw XML to inject into manifest (after permissions).", + "type": [ + "string", + "null" + ] + } + } + }, + "AndroidSettings": { + "type": "object", + "required": [ + "jks_file", + "jks_password", + "key_alias", + "key_password" + ], + "properties": { + "jks_file": { + "type": "string" + }, + "jks_password": { + "type": "string" + }, + "key_alias": { + "type": "string" + }, + "key_password": { + "type": "string" + } + } + }, + "AndroidSigningConfig": { + "description": "Android signing configuration for release builds.", + "type": "object", + "required": [ + "jks_file", + "jks_password", + "key_alias", + "key_password" + ], + "properties": { + "jks_file": { + "description": "Path to the Java keystore file.", + "type": "string" + }, + "jks_password": { + "description": "Password for the keystore.", + "type": "string" + }, + "key_alias": { + "description": "Alias of the key in the keystore.", + "type": "string" + }, + "key_password": { + "description": "Password for the key.", + "type": "string" + } + } + }, + "ApplicationConfig": { + "type": "object", + "properties": { + "android_main_activity": { + "description": "Use this file for the MainActivity.kt associated with the Android app.", + "type": [ + "string", + "null" + ] + }, + "android_manifest": { + "description": "Use this file for the AndroidManifest.xml associated with the Android app. `dx` will merge any required settings into this file required to build the app", + "type": [ + "string", + "null" + ] + }, + "android_min_sdk_version": { + "description": "Specified minimum sdk version for gradle to build the app with.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "asset_dir": { + "description": "The path where global assets will be added when components are added with `dx components add`", + "type": [ + "string", + "null" + ] + }, + "ios_entitlements": { + "description": "Use this file for the entitlements.plist associated with the iOS app.", + "type": [ + "string", + "null" + ] + }, + "ios_info_plist": { + "description": "Use this file for the info.plist associated with the iOS app. `dx` will merge any required settings into this file required to build the app", + "type": [ + "string", + "null" + ] + }, + "macos_entitlements": { + "description": "Use this file for the entitlements.plist associated with the macOS app.", + "type": [ + "string", + "null" + ] + }, + "macos_info_plist": { + "description": "Use this file for the info.plist associated with the macOS app. `dx` will merge any required settings into this file required to build the app", + "type": [ + "string", + "null" + ] + }, + "out_dir": { + "type": [ + "string", + "null" + ] + }, + "public_dir": { + "type": [ + "string", + "null" + ] + }, + "tailwind_input": { + "type": [ + "string", + "null" + ] + }, + "tailwind_output": { + "type": [ + "string", + "null" + ] + } + } + }, + "BackgroundConfig": { + "description": "Unified background execution configuration.\n\nThis provides a cross-platform interface for background capabilities. Platform-specific overrides can be configured in `[ios]` and `[android]` sections.\n\nExample: ```toml [background] location = true audio = true fetch = true ```", + "type": "object", + "properties": { + "audio": { + "description": "Background audio playback. iOS: UIBackgroundModes \"audio\" Android: FOREGROUND_SERVICE_MEDIA_PLAYBACK", + "type": "boolean" + }, + "bluetooth": { + "description": "Bluetooth LE accessories. iOS: UIBackgroundModes \"bluetooth-central\" and \"bluetooth-peripheral\" Android: FOREGROUND_SERVICE_CONNECTED_DEVICE", + "type": "boolean" + }, + "external-accessory": { + "description": "External accessory communication. iOS: UIBackgroundModes \"external-accessory\"", + "type": "boolean" + }, + "fetch": { + "description": "Background data fetch. iOS: UIBackgroundModes \"fetch\" Android: WorkManager or foreground service", + "type": "boolean" + }, + "location": { + "description": "Background location updates. iOS: UIBackgroundModes \"location\" Android: ACCESS_BACKGROUND_LOCATION permission", + "type": "boolean" + }, + "processing": { + "description": "Background processing tasks. iOS: UIBackgroundModes \"processing\" Android: WorkManager", + "type": "boolean" + }, + "remote-notifications": { + "description": "Remote push notifications. iOS: UIBackgroundModes \"remote-notification\" Android: Firebase Cloud Messaging", + "type": "boolean" + }, + "voip": { + "description": "VoIP calls. iOS: UIBackgroundModes \"voip\" Android: FOREGROUND_SERVICE_PHONE_CALL", + "type": "boolean" + } + } + }, + "BundleConfig": { + "type": "object", + "properties": { + "android": { + "anyOf": [ + { + "$ref": "#/definitions/AndroidSettings" + }, + { + "type": "null" + } + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "copyright": { + "type": [ + "string", + "null" + ] + }, + "deb": { + "anyOf": [ + { + "$ref": "#/definitions/DebianSettings" + }, + { + "type": "null" + } + ] + }, + "external_bin": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "icon": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "identifier": { + "type": [ + "string", + "null" + ] + }, + "long_description": { + "type": [ + "string", + "null" + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsSettings" + }, + { + "type": "null" + } + ] + }, + "publisher": { + "type": [ + "string", + "null" + ] + }, + "resources": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "short_description": { + "type": [ + "string", + "null" + ] + }, + "windows": { + "anyOf": [ + { + "$ref": "#/definitions/WindowsSettings" + }, + { + "type": "null" + } + ] + } + } + }, + "ComponentConfig": { + "description": "Configuration for the `dioxus component` commands", + "type": "object", + "properties": { + "components_dir": { + "description": "The path where components are stored when adding or removing components", + "type": [ + "string", + "null" + ] + }, + "registry": { + "description": "The component registry to default to when adding components", + "$ref": "#/definitions/ComponentRegistry" + } + } + }, + "ComponentRegistry": { + "description": "Arguments for a component registry Either a path to a local directory or a remote git repo (with optional rev)", + "type": "object", + "properties": { + "git": { + "description": "The url of the component registry", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "The path to the components directory", + "type": [ + "string", + "null" + ] + }, + "rev": { + "description": "The revision of the component registry", + "type": [ + "string", + "null" + ] + } + } + }, + "CustomSignCommandSettings": { + "type": "object", + "required": [ + "args", + "cmd" + ], + "properties": { + "args": { + "description": "The arguments to pass to the command.\n\n\"%1\" will be replaced with the path to the binary to be signed.", + "type": "array", + "items": { + "type": "string" + } + }, + "cmd": { + "description": "The command to run to sign the binary.", + "type": "string" + } + } + }, + "DebianSettings": { + "type": "object", + "properties": { + "changelog": { + "description": "Path of the uncompressed Changelog file, to be stored at /usr/share/doc/package-name/changelog.gz. See ", + "type": [ + "string", + "null" + ] + }, + "conflicts": { + "description": "the list of package conflicts.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "depends": { + "description": "the list of debian dependencies.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "desktop_template": { + "description": "Path to a custom desktop file Handlebars template.\n\nAvailable variables: `categories`, `comment` (optional), `exec`, `icon` and `name`.", + "type": [ + "string", + "null" + ] + }, + "files": { + "description": "List of custom files to add to the deb package. Maps the path on the debian package to the path of the file to include (relative to the current working directory).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "post_install_script": { + "description": "Path to script that will be executed after the package is unpacked. See ", + "type": [ + "string", + "null" + ] + }, + "post_remove_script": { + "description": "Path to script that will be executed after the package is removed. See ", + "type": [ + "string", + "null" + ] + }, + "pre_install_script": { + "description": "Path to script that will be executed before the package is unpacked. See ", + "type": [ + "string", + "null" + ] + }, + "pre_remove_script": { + "description": "Path to script that will be executed before the package is removed. See ", + "type": [ + "string", + "null" + ] + }, + "priority": { + "description": "Change the priority of the Debian Package. By default, it is set to `optional`. Recognized Priorities as of now are : `required`, `important`, `standard`, `optional`, `extra`", + "type": [ + "string", + "null" + ] + }, + "provides": { + "description": "the list of dependencies the package provides.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "recommends": { + "description": "the list of recommended debian dependencies.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "replaces": { + "description": "the list of package replaces.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "section": { + "description": "Define the section in Debian Control file. See : ", + "type": [ + "string", + "null" + ] + } + } + }, + "DeepLinkConfig": { + "description": "Unified deep linking configuration.\n\nThis provides a cross-platform interface for URL schemes and universal/app links. Platform-specific overrides can be configured in `[ios]` and `[android]` sections.\n\nExample: ```toml [deep_links] schemes = [\"myapp\", \"com.example.myapp\"] hosts = [\"example.com\", \"*.example.com\"] ```", + "type": "object", + "properties": { + "hosts": { + "description": "Universal link / App link hosts (e.g., \"example.com\"). Maps to Associated Domains on iOS and App Links on Android. Supports wildcards like \"*.example.com\".", + "type": "array", + "items": { + "type": "string" + } + }, + "paths": { + "description": "Path patterns for universal/app links (e.g., \"/app/*\", \"/share/*\"). If empty, all paths are matched.", + "type": "array", + "items": { + "type": "string" + } + }, + "schemes": { + "description": "Custom URL schemes (e.g., \"myapp\" for myapp://path). Maps to CFBundleURLSchemes on iOS/macOS and intent-filter on Android.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "IosConfig": { + "description": "iOS-specific configuration.\n\nExample: ```toml [ios] deployment_target = \"15.0\" identifier = \"com.example.myapp.ios\" # Override bundle.identifier for iOS\n\n[ios.entitlements] app-groups = [\"group.com.example.app\"]\n\n[ios.plist] UIBackgroundModes = [\"location\", \"fetch\"] ```", + "type": "object", + "properties": { + "background_modes": { + "description": "Additional background modes beyond unified `[background]`. Valid values: \"audio\", \"location\", \"voip\", \"fetch\", \"remote-notification\", \"newsstand-content\", \"external-accessory\", \"bluetooth-central\", \"bluetooth-peripheral\", \"processing\"", + "type": "array", + "items": { + "type": "string" + } + }, + "category": { + "description": "App category. Overrides `bundle.category` for iOS builds.", + "type": [ + "string", + "null" + ] + }, + "copyright": { + "description": "Copyright notice. Overrides `bundle.copyright` for iOS builds.", + "type": [ + "string", + "null" + ] + }, + "deployment_target": { + "description": "Minimum iOS deployment target (e.g., \"15.0\").", + "type": [ + "string", + "null" + ] + }, + "document_types": { + "description": "Document types the app can open.", + "type": "array", + "items": { + "$ref": "#/definitions/IosDocumentType" + } + }, + "entitlements": { + "description": "iOS entitlements configuration.", + "$ref": "#/definitions/IosEntitlements" + }, + "exported_type_identifiers": { + "description": "Exported type identifiers (custom UTIs).", + "type": "array", + "items": { + "$ref": "#/definitions/IosTypeIdentifier" + } + }, + "icon": { + "description": "Icons for the app. Overrides `bundle.icon` for iOS builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "identifier": { + "description": "The app's identifier (e.g., \"com.example.myapp\"). Overrides `bundle.identifier` for iOS builds.", + "type": [ + "string", + "null" + ] + }, + "imported_type_identifiers": { + "description": "Imported type identifiers.", + "type": "array", + "items": { + "$ref": "#/definitions/IosTypeIdentifier" + } + }, + "info_plist": { + "description": "Path to custom Info.plist to merge with generated.", + "type": [ + "string", + "null" + ] + }, + "long_description": { + "description": "Long description. Overrides `bundle.long_description` for iOS builds.", + "type": [ + "string", + "null" + ] + }, + "plist": { + "description": "Additional Info.plist keys to merge.", + "type": "object", + "additionalProperties": true + }, + "publisher": { + "description": "The app's publisher. Overrides `bundle.publisher` for iOS builds.", + "type": [ + "string", + "null" + ] + }, + "raw": { + "description": "Raw XML injection points.", + "$ref": "#/definitions/IosRawConfig" + }, + "resources": { + "description": "Additional resources to bundle. Overrides `bundle.resources` for iOS builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "short_description": { + "description": "Short description. Overrides `bundle.short_description` for iOS builds.", + "type": [ + "string", + "null" + ] + }, + "url_schemes": { + "description": "Additional URL schemes beyond unified `[deep_links]`.schemes. These are merged with the unified schemes.", + "type": "array", + "items": { + "type": "string" + } + }, + "widget_extensions": { + "description": "Widget extensions to compile and bundle. Each entry defines a Swift-based widget extension (.appex) that will be compiled and installed into the app's PlugIns folder.", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetExtensionConfig" + } + } + } + }, + "IosDocumentType": { + "description": "iOS document type declaration.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "extensions": { + "description": "File extensions (e.g., [\"txt\", \"md\"]).", + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "description": "Icon file name.", + "type": [ + "string", + "null" + ] + }, + "mime_types": { + "description": "MIME types.", + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "description": "Document type name.", + "type": "string" + }, + "role": { + "description": "Role: \"Editor\", \"Viewer\", \"Shell\", or \"None\".", + "type": [ + "string", + "null" + ] + }, + "types": { + "description": "UTI types.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "IosEntitlements": { + "type": "object", + "properties": { + "app-groups": { + "description": "App groups for shared data.", + "type": "array", + "items": { + "type": "string" + } + }, + "apple-pay": { + "description": "Enable Apple Pay.", + "type": "boolean" + }, + "aps-environment": { + "description": "Push notification environment: \"development\" or \"production\".", + "type": [ + "string", + "null" + ] + }, + "associated-domains": { + "description": "Associated domains for universal links.", + "type": "array", + "items": { + "type": "string" + } + }, + "healthkit": { + "description": "Enable HealthKit.", + "type": "boolean" + }, + "homekit": { + "description": "Enable HomeKit.", + "type": "boolean" + }, + "icloud": { + "description": "Enable iCloud container support.", + "type": "boolean" + }, + "keychain-access-groups": { + "description": "Keychain access groups.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "IosRawConfig": { + "type": "object", + "properties": { + "entitlements": { + "description": "Raw XML to inject into entitlements.plist.", + "type": [ + "string", + "null" + ] + }, + "info_plist": { + "description": "Raw XML to inject into Info.plist.", + "type": [ + "string", + "null" + ] + } + } + }, + "IosTypeIdentifier": { + "description": "iOS Uniform Type Identifier declaration.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "conforms_to": { + "description": "Conforms to these UTIs.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "description": "Human-readable description.", + "type": [ + "string", + "null" + ] + }, + "extensions": { + "description": "File extensions.", + "type": "array", + "items": { + "type": "string" + } + }, + "identifier": { + "description": "UTI identifier (e.g., \"com.example.myformat\").", + "type": "string" + }, + "mime_types": { + "description": "MIME types.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LinuxConfig": { + "description": "Linux-specific configuration.\n\nExample: ```toml [linux] identifier = \"com.example.myapp.linux\" # Override bundle.identifier for Linux categories = [\"Utility\"]\n\n# Debian package settings (previously in [bundle.deb]) [linux.deb] depends = [\"libwebkit2gtk-4.0-37\"] section = \"utils\" ```", + "type": "object", + "properties": { + "categories": { + "description": "Desktop entry categories.", + "type": "array", + "items": { + "type": "string" + } + }, + "category": { + "description": "App category. Overrides `bundle.category` for Linux builds.", + "type": [ + "string", + "null" + ] + }, + "copyright": { + "description": "Copyright notice. Overrides `bundle.copyright` for Linux builds.", + "type": [ + "string", + "null" + ] + }, + "dbus_access": { + "description": "D-Bus interfaces to access.", + "type": "array", + "items": { + "type": "string" + } + }, + "deb": { + "description": "Debian-specific package settings.", + "anyOf": [ + { + "$ref": "#/definitions/LinuxDebSettings" + }, + { + "type": "null" + } + ] + }, + "flatpak_permissions": { + "description": "Flatpak sandbox permissions.", + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "description": "Icons for the app. Overrides `bundle.icon` for Linux builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "identifier": { + "description": "The app's identifier (e.g., \"com.example.myapp\"). Overrides `bundle.identifier` for Linux builds.", + "type": [ + "string", + "null" + ] + }, + "keywords": { + "description": "Desktop entry keywords.", + "type": "array", + "items": { + "type": "string" + } + }, + "long_description": { + "description": "Long description. Overrides `bundle.long_description` for Linux builds.", + "type": [ + "string", + "null" + ] + }, + "mime_types": { + "description": "MIME types the app can handle.", + "type": "array", + "items": { + "type": "string" + } + }, + "publisher": { + "description": "The app's publisher. Overrides `bundle.publisher` for Linux builds.", + "type": [ + "string", + "null" + ] + }, + "resources": { + "description": "Additional resources to bundle. Overrides `bundle.resources` for Linux builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "short_description": { + "description": "Short description. Overrides `bundle.short_description` for Linux builds.", + "type": [ + "string", + "null" + ] + } + } + }, + "LinuxDebSettings": { + "description": "Debian package settings.", + "type": "object", + "properties": { + "changelog": { + "description": "Path to changelog file.", + "type": [ + "string", + "null" + ] + }, + "conflicts": { + "description": "Package conflicts.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "depends": { + "description": "Package dependencies.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "desktop_template": { + "description": "Path to custom desktop template.", + "type": [ + "string", + "null" + ] + }, + "files": { + "description": "Additional files to include. Maps package path to source path.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "post_install_script": { + "description": "Post-install script path.", + "type": [ + "string", + "null" + ] + }, + "post_remove_script": { + "description": "Post-remove script path.", + "type": [ + "string", + "null" + ] + }, + "pre_install_script": { + "description": "Pre-install script path.", + "type": [ + "string", + "null" + ] + }, + "pre_remove_script": { + "description": "Pre-remove script path.", + "type": [ + "string", + "null" + ] + }, + "priority": { + "description": "Package priority (\"required\", \"important\", \"standard\", \"optional\", \"extra\").", + "type": [ + "string", + "null" + ] + }, + "provides": { + "description": "Packages this provides.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "recommends": { + "description": "Recommended packages.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "replaces": { + "description": "Packages this replaces.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "section": { + "description": "Debian section (e.g., \"utils\", \"web\").", + "type": [ + "string", + "null" + ] + } + } + }, + "LocationPermission": { + "description": "Location permission with precision control.", + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "description": "User-facing description shown in permission dialogs.", + "type": "string" + }, + "precision": { + "description": "Precision level: \"fine\" (GPS) or \"coarse\" (network-based).", + "$ref": "#/definitions/LocationPrecision" + } + } + }, + "LocationPrecision": { + "type": "string", + "enum": [ + "fine", + "coarse" + ] + }, + "MacOsSettings": { + "type": "object", + "properties": { + "bundle_name": { + "type": [ + "string", + "null" + ] + }, + "bundle_version": { + "type": [ + "string", + "null" + ] + }, + "entitlements": { + "type": [ + "string", + "null" + ] + }, + "exception_domain": { + "type": [ + "string", + "null" + ] + }, + "files": { + "description": "List of custom files to add to the application bundle. Maps the path in the Contents directory in the app to the path of the file to include (relative to the current working directory).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "frameworks": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "hardened_runtime": { + "description": "Preserve the hardened runtime version flag, see \n\nSettings this to `false` is useful when using an ad-hoc signature, making it less strict.", + "type": "boolean" + }, + "info_plist_path": { + "type": [ + "string", + "null" + ] + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "minimum_system_version": { + "type": [ + "string", + "null" + ] + }, + "provider_short_name": { + "type": [ + "string", + "null" + ] + }, + "signing_identity": { + "type": [ + "string", + "null" + ] + } + } + }, + "MacosConfig": { + "description": "macOS-specific configuration.\n\nExample: ```toml [macos] minimum_system_version = \"11.0\" identifier = \"com.example.myapp.macos\" # Override bundle.identifier for macOS\n\n# macOS signing (previously in [bundle.macos]) signing_identity = \"Developer ID Application: My Company\" provider_short_name = \"MYCOMPANY\" ```", + "type": "object", + "properties": { + "bundle_name": { + "description": "The bundle short version string (CFBundleShortVersionString).", + "type": [ + "string", + "null" + ] + }, + "bundle_version": { + "description": "The bundle version string (CFBundleVersion).", + "type": [ + "string", + "null" + ] + }, + "category": { + "description": "App category for the Mac App Store. E.g., \"public.app-category.productivity\"", + "type": [ + "string", + "null" + ] + }, + "copyright": { + "description": "Copyright notice. Overrides `bundle.copyright` for macOS builds.", + "type": [ + "string", + "null" + ] + }, + "document_types": { + "description": "Document types the app can open (uses same format as iOS).", + "type": "array", + "items": { + "$ref": "#/definitions/IosDocumentType" + } + }, + "entitlements": { + "description": "macOS entitlements.", + "$ref": "#/definitions/MacosEntitlements" + }, + "entitlements_file": { + "description": "Path to custom entitlements file for code signing. This overrides the generated entitlements.", + "type": [ + "string", + "null" + ] + }, + "exception_domain": { + "description": "Exception domain for App Transport Security.", + "type": [ + "string", + "null" + ] + }, + "exported_type_identifiers": { + "description": "Exported type identifiers (custom UTIs).", + "type": "array", + "items": { + "$ref": "#/definitions/IosTypeIdentifier" + } + }, + "files": { + "description": "Additional files to include in the app bundle. Maps the path in the Contents directory to the source file path.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "frameworks": { + "description": "Frameworks to embed.", + "type": "array", + "items": { + "type": "string" + } + }, + "hardened_runtime": { + "description": "Preserve the hardened runtime version flag. Setting this to false is useful when using an ad-hoc signature.", + "type": [ + "boolean", + "null" + ] + }, + "icon": { + "description": "Icons for the app. Overrides `bundle.icon` for macOS builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "identifier": { + "description": "The app's identifier (e.g., \"com.example.myapp\"). Overrides `bundle.identifier` for macOS builds.", + "type": [ + "string", + "null" + ] + }, + "imported_type_identifiers": { + "description": "Imported type identifiers.", + "type": "array", + "items": { + "$ref": "#/definitions/IosTypeIdentifier" + } + }, + "info_plist": { + "description": "Path to custom Info.plist.", + "type": [ + "string", + "null" + ] + }, + "license": { + "description": "License file to include in DMG.", + "type": [ + "string", + "null" + ] + }, + "long_description": { + "description": "Long description. Overrides `bundle.long_description` for macOS builds.", + "type": [ + "string", + "null" + ] + }, + "minimum_system_version": { + "description": "Minimum macOS version (e.g., \"11.0\").", + "type": [ + "string", + "null" + ] + }, + "plist": { + "description": "Additional Info.plist keys.", + "type": "object", + "additionalProperties": true + }, + "provider_short_name": { + "description": "The provider short name for notarization.", + "type": [ + "string", + "null" + ] + }, + "publisher": { + "description": "The app's publisher. Overrides `bundle.publisher` for macOS builds.", + "type": [ + "string", + "null" + ] + }, + "raw": { + "description": "Raw injection points.", + "$ref": "#/definitions/MacosRawConfig" + }, + "resources": { + "description": "Additional resources to bundle. Overrides `bundle.resources` for macOS builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "short_description": { + "description": "Short description. Overrides `bundle.short_description` for macOS builds.", + "type": [ + "string", + "null" + ] + }, + "signing_identity": { + "description": "The signing identity to use for code signing. E.g., \"Developer ID Application: My Company (TEAMID)\"", + "type": [ + "string", + "null" + ] + }, + "url_schemes": { + "description": "Additional URL schemes beyond unified `[deep_links]`.schemes. These are merged with the unified schemes.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MacosEntitlements": { + "type": "object", + "properties": { + "addressbook": { + "description": "Address book access.", + "type": [ + "boolean", + "null" + ] + }, + "allow-jit": { + "description": "Allow JIT.", + "type": [ + "boolean", + "null" + ] + }, + "allow-unsigned-executable-memory": { + "description": "Allow unsigned executable memory.", + "type": [ + "boolean", + "null" + ] + }, + "app-sandbox": { + "description": "Enable App Sandbox.", + "type": [ + "boolean", + "null" + ] + }, + "bluetooth": { + "description": "Bluetooth access.", + "type": [ + "boolean", + "null" + ] + }, + "calendars": { + "description": "Calendars access.", + "type": [ + "boolean", + "null" + ] + }, + "camera": { + "description": "Camera access.", + "type": [ + "boolean", + "null" + ] + }, + "disable-library-validation": { + "description": "Disable library validation.", + "type": [ + "boolean", + "null" + ] + }, + "files-user-selected": { + "description": "User-selected file access (read-write).", + "type": [ + "boolean", + "null" + ] + }, + "files-user-selected-readonly": { + "description": "User-selected file access (read-only).", + "type": [ + "boolean", + "null" + ] + }, + "location": { + "description": "Location services.", + "type": [ + "boolean", + "null" + ] + }, + "microphone": { + "description": "Microphone access.", + "type": [ + "boolean", + "null" + ] + }, + "network-client": { + "description": "Outgoing network connections.", + "type": [ + "boolean", + "null" + ] + }, + "network-server": { + "description": "Incoming network connections.", + "type": [ + "boolean", + "null" + ] + }, + "print": { + "description": "Printing.", + "type": [ + "boolean", + "null" + ] + }, + "usb": { + "description": "USB access.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": true + }, + "MacosRawConfig": { + "type": "object", + "properties": { + "entitlements": { + "description": "Raw XML to inject into entitlements.plist.", + "type": [ + "string", + "null" + ] + }, + "info_plist": { + "description": "Raw XML to inject into Info.plist.", + "type": [ + "string", + "null" + ] + } + } + }, + "NSISInstallerMode": { + "type": "string", + "enum": [ + "CurrentUser", + "PerMachine", + "Both" + ] + }, + "NsisSettings": { + "type": "object", + "properties": { + "custom_language_files": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "display_language_selector": { + "type": "boolean" + }, + "header_image": { + "type": [ + "string", + "null" + ] + }, + "install_mode": { + "$ref": "#/definitions/NSISInstallerMode" + }, + "installer_hooks": { + "type": [ + "string", + "null" + ] + }, + "installer_icon": { + "type": [ + "string", + "null" + ] + }, + "languages": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "minimum_webview2_version": { + "description": "Try to ensure that the WebView2 version is equal to or newer than this version, if the user's WebView2 is older than this version, the installer will try to trigger a WebView2 update.", + "type": [ + "string", + "null" + ] + }, + "sidebar_image": { + "type": [ + "string", + "null" + ] + }, + "start_menu_folder": { + "type": [ + "string", + "null" + ] + }, + "template": { + "type": [ + "string", + "null" + ] + } + } + }, + "PermissionsConfig": { + "description": "Unified permission configuration that maps to platform-specific identifiers.\n\nExample: ```toml [permissions] location = { precision = \"fine\", description = \"Track your runs\" } camera = { description = \"Take photos for your profile\" } ```", + "type": "object", + "properties": { + "background-location": { + "description": "Background location updates.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "biometrics": { + "description": "Biometric authentication (Face ID, fingerprint).", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "bluetooth": { + "description": "Bluetooth connectivity.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "description": "Calendar access.", + "anyOf": [ + { + "$ref": "#/definitions/StoragePermission" + }, + { + "type": "null" + } + ] + }, + "camera": { + "description": "Camera access permission.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "contacts": { + "description": "Contacts access.", + "anyOf": [ + { + "$ref": "#/definitions/StoragePermission" + }, + { + "type": "null" + } + ] + }, + "health": { + "description": "Health data access.", + "anyOf": [ + { + "$ref": "#/definitions/StoragePermission" + }, + { + "type": "null" + } + ] + }, + "homekit": { + "description": "HomeKit integration (iOS only).", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "local-network": { + "description": "Local network access.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "location": { + "description": "Location permission with precision level. Maps to ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION on Android, NSLocationWhenInUseUsageDescription on iOS/macOS.", + "anyOf": [ + { + "$ref": "#/definitions/LocationPermission" + }, + { + "type": "null" + } + ] + }, + "media-library": { + "description": "Media library access.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "microphone": { + "description": "Microphone access permission.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "motion": { + "description": "Motion and fitness data.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "nearby-wifi": { + "description": "Nearby Wi-Fi devices (Android).", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "nfc": { + "description": "NFC access.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "notifications": { + "description": "Push notifications permission.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "photos": { + "description": "Photo library access.", + "anyOf": [ + { + "$ref": "#/definitions/StoragePermission" + }, + { + "type": "null" + } + ] + }, + "siri": { + "description": "Siri integration (iOS only).", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + }, + "speech": { + "description": "Speech recognition.", + "anyOf": [ + { + "$ref": "#/definitions/SimplePermission" + }, + { + "type": "null" + } + ] + } + } + }, + "RawPermission": { + "description": "Raw platform permission entry.", + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string" + } + } + }, + "SimplePermission": { + "description": "Simple permission with just a description.", + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "description": "User-facing description shown in permission dialogs.", + "type": "string" + } + } + }, + "StorageAccess": { + "type": "string", + "enum": [ + "read", + "write", + "read-write" + ] + }, + "StoragePermission": { + "description": "Storage permission with access level control.", + "type": "object", + "required": [ + "description" + ], + "properties": { + "access": { + "description": "Access level: \"read\", \"write\", or \"read-write\".", + "$ref": "#/definitions/StorageAccess" + }, + "description": { + "description": "User-facing description shown in permission dialogs.", + "type": "string" + } + } + }, + "WasmOptConfig": { + "description": "The wasm-opt configuration", + "type": "object", + "properties": { + "debug": { + "description": "Keep debug symbols in the wasm file", + "type": "boolean" + }, + "extra_features": { + "description": "Extra arguments to pass to wasm-opt\n\nFor example, to enable simd, you can set this to `[\"--enable-simd\"]`.\n\nYou can also disable features by prefixing them with `--disable-`, e.g. `[\"--disable-bulk-memory\"]`.\n\nCurrently only --enable and --disable flags are supported.", + "type": "array", + "items": { + "type": "string" + } + }, + "keep_names": { + "description": "Keep the wasm name section, useful for profiling and debugging\n\nUnlike `debug` which preserves DWARF debug symbols (requiring a browser extension to read), the name section allows tools like `console_error_panic_hook` to print backtraces with human-readable function names without any browser extension.", + "type": "boolean" + }, + "level": { + "description": "The wasm-opt level to use for release builds [default: s] Options: - z: optimize aggressively for size - s: optimize for size - 1: optimize for speed - 2: optimize for more for speed - 3: optimize for even more for speed - 4: optimize aggressively for speed", + "$ref": "#/definitions/WasmOptLevel" + }, + "memory_packing": { + "description": "Enable memory packing", + "type": "boolean" + } + } + }, + "WasmOptLevel": { + "description": "The wasm-opt level to use for release web builds [default: Z]", + "oneOf": [ + { + "description": "Optimize aggressively for size", + "type": "string", + "enum": [ + "z" + ] + }, + { + "description": "Optimize for size", + "type": "string", + "enum": [ + "s" + ] + }, + { + "description": "Don't optimize", + "type": "string", + "enum": [ + "0" + ] + }, + { + "description": "Optimize for speed", + "type": "string", + "enum": [ + "1" + ] + }, + { + "description": "Optimize for more for speed", + "type": "string", + "enum": [ + "2" + ] + }, + { + "description": "Optimize for even more for speed", + "type": "string", + "enum": [ + "3" + ] + }, + { + "description": "Optimize aggressively for speed", + "type": "string", + "enum": [ + "4" + ] + } + ] + }, + "WebAppConfig": { + "type": "object", + "properties": { + "base_path": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + } + } + }, + "WebConfig": { + "type": "object", + "properties": { + "app": { + "$ref": "#/definitions/WebAppConfig" + }, + "https": { + "$ref": "#/definitions/WebHttpsConfig" + }, + "pre_compress": { + "description": "Whether to enable pre-compression of assets and wasm during a web build in release mode", + "type": "boolean" + }, + "proxy": { + "type": "array", + "items": { + "$ref": "#/definitions/WebProxyConfig" + } + }, + "resource": { + "$ref": "#/definitions/WebResourceConfig" + }, + "wasm_opt": { + "description": "The wasm-opt configuration", + "$ref": "#/definitions/WasmOptConfig" + }, + "watcher": { + "$ref": "#/definitions/WebWatcherConfig" + } + } + }, + "WebDevResourceConfig": { + "type": "object", + "properties": { + "script": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "WebHttpsConfig": { + "type": "object", + "properties": { + "cert_path": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "key_path": { + "type": [ + "string", + "null" + ] + }, + "mkcert": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "WebProxyConfig": { + "type": "object", + "required": [ + "backend" + ], + "properties": { + "backend": { + "type": "string" + } + } + }, + "WebResourceConfig": { + "type": "object", + "required": [ + "dev" + ], + "properties": { + "dev": { + "$ref": "#/definitions/WebDevResourceConfig" + }, + "script": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "style": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + }, + "WebWatcherConfig": { + "type": "object", + "properties": { + "index_on_404": { + "type": "boolean" + }, + "reload_html": { + "type": "boolean" + }, + "watch_path": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "WebviewInstallMode": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Skip" + ] + }, + { + "type": "object", + "required": [ + "DownloadBootstrapper" + ], + "properties": { + "DownloadBootstrapper": { + "type": "object", + "required": [ + "silent" + ], + "properties": { + "silent": { + "type": "boolean" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "EmbedBootstrapper" + ], + "properties": { + "EmbedBootstrapper": { + "type": "object", + "required": [ + "silent" + ], + "properties": { + "silent": { + "type": "boolean" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "OfflineInstaller" + ], + "properties": { + "OfflineInstaller": { + "type": "object", + "required": [ + "silent" + ], + "properties": { + "silent": { + "type": "boolean" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "FixedRuntime" + ], + "properties": { + "FixedRuntime": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "WidgetExtensionConfig": { + "description": "Configuration for an iOS Widget Extension.\n\nWidget extensions are compiled as Swift executables and bundled as .appex bundles in the app's PlugIns folder.\n\nExample in Dioxus.toml: ```toml [[ios.widget_extensions]] source = \"src/ios/widget\" display_name = \"Location Widget\" bundle_id_suffix = \"location-widget\" deployment_target = \"16.2\" module_name = \"GeolocationPlugin\" ```", + "type": "object", + "required": [ + "bundle_id_suffix", + "display_name", + "module_name", + "source" + ], + "properties": { + "bundle_id_suffix": { + "description": "Bundle ID suffix appended to the app's bundle identifier. For example, if the app is \"com.example.app\" and suffix is \"location-widget\", the widget bundle ID will be \"com.example.app.location-widget\".", + "type": "string" + }, + "deployment_target": { + "description": "Minimum deployment target (e.g., \"16.2\"). Defaults to the app's iOS deployment target if not specified.", + "type": [ + "string", + "null" + ] + }, + "display_name": { + "description": "Display name for the widget (shown in system UI).", + "type": "string" + }, + "module_name": { + "description": "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.", + "type": "string" + }, + "source": { + "description": "Path to the Swift package source directory (relative to project root).", + "type": "string" + } + } + }, + "WindowsConfig": { + "description": "Windows-specific configuration.\n\nExample: ```toml [windows] identifier = \"com.example.myapp.windows\" # Override bundle.identifier for Windows\n\n# Windows installer settings (previously in [bundle.windows]) [windows.nsis] install_mode = \"PerMachine\"\n\n[windows.wix] language = [[\"en-US\", null]] ```", + "type": "object", + "properties": { + "allow_downgrades": { + "description": "Allow downgrades when installing.", + "type": [ + "boolean", + "null" + ] + }, + "capabilities": { + "description": "UWP/MSIX capabilities.", + "type": "array", + "items": { + "type": "string" + } + }, + "category": { + "description": "App category. Overrides `bundle.category` for Windows builds.", + "type": [ + "string", + "null" + ] + }, + "certificate_thumbprint": { + "description": "Certificate thumbprint for code signing.", + "type": [ + "string", + "null" + ] + }, + "copyright": { + "description": "Copyright notice. Overrides `bundle.copyright` for Windows builds.", + "type": [ + "string", + "null" + ] + }, + "device_capabilities": { + "description": "Device capabilities.", + "type": "array", + "items": { + "type": "string" + } + }, + "digest_algorithm": { + "description": "Digest algorithm for code signing.", + "type": [ + "string", + "null" + ] + }, + "icon": { + "description": "Icons for the app. Overrides `bundle.icon` for Windows builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "icon_path": { + "description": "Path to custom Windows icon.", + "type": [ + "string", + "null" + ] + }, + "identifier": { + "description": "The app's identifier (e.g., \"com.example.myapp\"). Overrides `bundle.identifier` for Windows builds.", + "type": [ + "string", + "null" + ] + }, + "long_description": { + "description": "Long description. Overrides `bundle.long_description` for Windows builds.", + "type": [ + "string", + "null" + ] + }, + "nsis": { + "description": "NSIS installer settings.", + "anyOf": [ + { + "$ref": "#/definitions/WindowsNsisSettings" + }, + { + "type": "null" + } + ] + }, + "publisher": { + "description": "The app's publisher. Overrides `bundle.publisher` for Windows builds.", + "type": [ + "string", + "null" + ] + }, + "resources": { + "description": "Additional resources to bundle. Overrides `bundle.resources` for Windows builds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "restricted_capabilities": { + "description": "Restricted capabilities.", + "type": "array", + "items": { + "type": "string" + } + }, + "short_description": { + "description": "Short description. Overrides `bundle.short_description` for Windows builds.", + "type": [ + "string", + "null" + ] + }, + "sign_command": { + "description": "Custom sign command.", + "anyOf": [ + { + "$ref": "#/definitions/WindowsSignCommand" + }, + { + "type": "null" + } + ] + }, + "timestamp_url": { + "description": "Timestamp server URL for code signing.", + "type": [ + "string", + "null" + ] + }, + "tsp": { + "description": "Use TSP (RFC 3161) timestamp.", + "type": [ + "boolean", + "null" + ] + }, + "webview_install_mode": { + "description": "WebView2 installation mode.", + "anyOf": [ + { + "$ref": "#/definitions/WindowsWebviewInstallMode" + }, + { + "type": "null" + } + ] + }, + "wix": { + "description": "WiX installer settings.", + "anyOf": [ + { + "$ref": "#/definitions/WindowsWixSettings" + }, + { + "type": "null" + } + ] + } + } + }, + "WindowsNsisSettings": { + "description": "NSIS installer settings.", + "type": "object", + "properties": { + "custom_language_files": { + "description": "Custom language files.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "display_language_selector": { + "description": "Display language selector.", + "type": [ + "boolean", + "null" + ] + }, + "header_image": { + "description": "Header image path.", + "type": [ + "string", + "null" + ] + }, + "install_mode": { + "description": "Installation mode: \"CurrentUser\", \"PerMachine\", or \"Both\".", + "type": [ + "string", + "null" + ] + }, + "installer_hooks": { + "description": "Installer hooks script path.", + "type": [ + "string", + "null" + ] + }, + "installer_icon": { + "description": "Installer icon path.", + "type": [ + "string", + "null" + ] + }, + "languages": { + "description": "Languages to include.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "license": { + "description": "License file path.", + "type": [ + "string", + "null" + ] + }, + "minimum_webview2_version": { + "description": "Minimum WebView2 version required.", + "type": [ + "string", + "null" + ] + }, + "sidebar_image": { + "description": "Sidebar image path.", + "type": [ + "string", + "null" + ] + }, + "start_menu_folder": { + "description": "Start menu folder name.", + "type": [ + "string", + "null" + ] + }, + "template": { + "description": "Path to custom NSIS template.", + "type": [ + "string", + "null" + ] + } + } + }, + "WindowsSettings": { + "type": "object", + "properties": { + "allow_downgrades": { + "type": "boolean" + }, + "certificate_thumbprint": { + "type": [ + "string", + "null" + ] + }, + "digest_algorithm": { + "type": [ + "string", + "null" + ] + }, + "icon_path": { + "type": [ + "string", + "null" + ] + }, + "nsis": { + "anyOf": [ + { + "$ref": "#/definitions/NsisSettings" + }, + { + "type": "null" + } + ] + }, + "sign_command": { + "description": "Specify a custom command to sign the binaries. This command needs to have a `%1` in it which is just a placeholder for the binary path, which we will detect and replace before calling the command.\n\nExample: ```text sign-cli --arg1 --arg2 %1 ```\n\nBy Default we use `signtool.exe` which can be found only on Windows so if you are on another platform and want to cross-compile and sign you will need to use another tool like `osslsigncode`.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandSettings" + }, + { + "type": "null" + } + ] + }, + "timestamp_url": { + "type": [ + "string", + "null" + ] + }, + "tsp": { + "type": "boolean" + }, + "webview_fixed_runtime_path": { + "type": [ + "string", + "null" + ] + }, + "webview_install_mode": { + "$ref": "#/definitions/WebviewInstallMode" + }, + "wix": { + "anyOf": [ + { + "$ref": "#/definitions/WixSettings" + }, + { + "type": "null" + } + ] + } + } + }, + "WindowsSignCommand": { + "description": "Custom sign command for Windows code signing.", + "type": "object", + "required": [ + "args", + "cmd" + ], + "properties": { + "args": { + "description": "Command arguments. Use \"%1\" as placeholder for binary path.", + "type": "array", + "items": { + "type": "string" + } + }, + "cmd": { + "description": "The command to run.", + "type": "string" + } + } + }, + "WindowsWebviewInstallMode": { + "description": "WebView2 installation mode.", + "oneOf": [ + { + "description": "Skip WebView2 installation.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Skip" + ] + } + } + }, + { + "description": "Download bootstrapper.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "silent": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "DownloadBootstrapper" + ] + } + } + }, + { + "description": "Embed bootstrapper.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "silent": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "EmbedBootstrapper" + ] + } + } + }, + { + "description": "Use offline installer.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "silent": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "OfflineInstaller" + ] + } + } + }, + { + "description": "Use fixed runtime from path.", + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "FixedRuntime" + ] + } + } + } + ] + }, + "WindowsWixSettings": { + "description": "WiX installer settings.", + "type": "object", + "properties": { + "banner_path": { + "description": "Banner image path.", + "type": [ + "string", + "null" + ] + }, + "component_group_refs": { + "description": "Component group references.", + "type": "array", + "items": { + "type": "string" + } + }, + "component_refs": { + "description": "Component references.", + "type": "array", + "items": { + "type": "string" + } + }, + "dialog_image_path": { + "description": "Dialog image path.", + "type": [ + "string", + "null" + ] + }, + "enable_elevated_update_task": { + "description": "Enable elevated update task.", + "type": [ + "boolean", + "null" + ] + }, + "feature_group_refs": { + "description": "Feature group references.", + "type": "array", + "items": { + "type": "string" + } + }, + "feature_refs": { + "description": "Feature references.", + "type": "array", + "items": { + "type": "string" + } + }, + "fips_compliant": { + "description": "FIPS compliant mode.", + "type": [ + "boolean", + "null" + ] + }, + "fragment_paths": { + "description": "WiX fragment files to include.", + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "description": "Languages and their locale paths.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": [ + "string", + "null" + ] + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "license": { + "description": "License file path.", + "type": [ + "string", + "null" + ] + }, + "merge_refs": { + "description": "Merge module references.", + "type": "array", + "items": { + "type": "string" + } + }, + "skip_webview_install": { + "description": "Skip WebView2 installation.", + "type": [ + "boolean", + "null" + ] + }, + "template": { + "description": "Path to custom WiX template.", + "type": [ + "string", + "null" + ] + }, + "upgrade_code": { + "description": "MSI upgrade code (GUID).", + "type": [ + "string", + "null" + ] + }, + "version": { + "description": "MSI version string.", + "type": [ + "string", + "null" + ] + } + } + }, + "WixSettings": { + "type": "object", + "properties": { + "banner_path": { + "type": [ + "string", + "null" + ] + }, + "component_group_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "component_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dialog_image_path": { + "type": [ + "string", + "null" + ] + }, + "enable_elevated_update_task": { + "type": "boolean" + }, + "feature_group_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "feature_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "fips_compliant": { + "type": "boolean" + }, + "fragment_paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": [ + "string", + "null" + ] + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "merge_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_webview_install": { + "type": "boolean" + }, + "template": { + "type": [ + "string", + "null" + ] + }, + "upgrade_code": { + "description": "A GUID upgrade code for MSI installer. This code **_must stay the same across all of your updates_**, otherwise, Windows will treat your update as a different app and your users will have duplicate versions of your app.\n\nBy default, tauri generates this code by generating a Uuid v5 using the string `.exe.app.x64` in the DNS namespace. You can use Tauri's CLI to generate and print this code for you by running `tauri inspect wix-upgrade-code`.\n\nIt 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.", + "type": [ + "string", + "null" + ] + }, + "version": { + "description": "MSI installer version in the format `major.minor.patch.build` (build is optional).\n\nBecause a valid version is required for MSI installer, it will be derived from [`tauri_bundler::PackageSettings::version`] if this field is not set.\n\nThe first field is the major version and has a maximum value of 255. The second field is the minor version and has a maximum value of 255. The third and fourth fields have a maximum value of 65,535.\n\nSee for more info.", + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/packages/cli/src/build/assets.rs b/packages/cli/src/build/assets.rs index e3e14ccb5..7824feccf 100644 --- a/packages/cli/src/build/assets.rs +++ b/packages/cli/src/build/assets.rs @@ -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 => { ::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 { + /// 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 { 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 { + fn serialize_asset(&self, asset: &BundledAsset) -> Vec { 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> { + 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), + + /// 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, + + /// Android plugin artifacts discovered in the binary + pub android_artifacts: Vec, + + /// Swift packages discovered in the binary + pub swift_packages: Vec, +} + /// 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) -> Result { +/// Also extracts Android/Swift plugin metadata for FFI bindings. +pub(crate) async fn extract_symbols_from_file( + path: impl AsRef, +) -> Result { 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) -> Result 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) -> Result { + 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) -> Result) -> Result { + 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(()) +} diff --git a/packages/cli/src/build/context.rs b/packages/cli/src/build/context.rs index 3810c9ca3..aedb0cc4f 100644 --- a/packages/cli/src/build/context.rs +++ b/packages/cli/src/build/context.rs @@ -109,6 +109,14 @@ impl BuildContext { }) } + pub(crate) fn status_compiling_native_plugins(&self, detail: impl Into) { + _ = 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, diff --git a/packages/cli/src/build/ios_swift.rs b/packages/cli/src/build/ios_swift.rs new file mode 100644 index 000000000..56394e161 --- /dev/null +++ b/packages/cli/src/build/ios_swift.rs @@ -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 { + 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#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + {framework_name} + CFBundleIdentifier + {bundle_identifier} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {framework_name} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + MinimumOSVersion + {min_os_version} + CFBundleSupportedPlatforms + + iPhoneOS + + +"# + ); + + 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#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + {framework_name} + CFBundleIdentifier + {bundle_identifier} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {framework_name} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + {min_os_version} + +"# + ); + + 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> { + 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///lib.dylib + // or .build//lib.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 { + 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 { + 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> { + 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> { + 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> { + 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> { + 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 { + 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#" MinimumOSVersion + {min_os_version} + CFBundleSupportedPlatforms + + iPhoneOS + + UIDeviceFamily + + 1 + 2 + "# + ) + } else { + format!( + r#" LSMinimumSystemVersion + {min_os_version} + CFBundleSupportedPlatforms + + MacOSX + "# + ) + }; + + let info_plist = format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + {display_name} + CFBundleExecutable + {widget_name} + CFBundleIdentifier + {widget_bundle_id} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {widget_name} + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 +{platform_info} + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + +"#, + 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) +} diff --git a/packages/cli/src/build/manifest_mapper.rs b/packages/cli/src/build/manifest_mapper.rs new file mode 100644 index 000000000..cd692ec39 --- /dev/null +++ b/packages/cli/src/build/manifest_mapper.rs @@ -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: `` 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, + pub android_features: Vec, + pub ios_plist_entries: Vec, + pub macos_plist_entries: Vec, + + /// URL schemes for iOS CFBundleURLTypes (merged from deep_links.schemes + ios.url_schemes) + pub ios_url_schemes: Vec, + /// URL schemes for macOS CFBundleURLTypes (merged from deep_links.schemes + macos.url_schemes) + pub macos_url_schemes: Vec, + /// URL schemes for Android intent-filter (merged from deep_links.schemes + android.url_schemes) + pub android_url_schemes: Vec, + /// Associated domains for iOS (from deep_links.hosts → "applinks:host") + pub ios_associated_domains: Vec, + /// Android intent filters from config (android.intent_filters) + pub android_intent_filters: Vec, + /// App link hosts for Android auto-verify (from deep_links.hosts) + pub android_app_link_hosts: Vec, + + /// iOS UIBackgroundModes (merged from BackgroundConfig + ios.background_modes) + pub ios_background_modes: Vec, + /// Android foreground service types (from BackgroundConfig + android.foreground_service_types) + pub android_foreground_service_types: Vec, +} + +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 = 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 = 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 = 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 = 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 = 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")); + } +} diff --git a/packages/cli/src/build/mod.rs b/packages/cli/src/build/mod.rs index f2a89ec40..2d5d71341 100644 --- a/packages/cli/src/build/mod.rs +++ b/packages/cli/src/build/mod.rs @@ -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; diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 501d942d1..ab629bc41 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -319,6 +319,7 @@ //! ## Extra links //! - xbuild: +use super::HotpatchModuleCache; use crate::{ AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction, LinkerFlavor, ObjectCache, Platform, Renderer, Result, RustcArgs, TargetArgs, @@ -333,8 +334,8 @@ use dioxus_cli_config::{APP_TITLE_ENV, ASSET_ROOT_ENV}; use dioxus_cli_opt::{process_file_to, AssetManifest}; use itertools::Itertools; use krates::{cm::TargetKind, NodeId}; -use manganis::{AssetOptions, BundledAsset}; -use manganis_core::{AssetOptionsBuilder, AssetVariant}; +use manganis::{AssetOptions, BundledAsset, SwiftPackageMetadata}; +use manganis_core::{AndroidArtifactMetadata, AssetVariant}; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, ffi::OsString}; @@ -355,8 +356,6 @@ use tempfile::TempDir; use tokio::{io::AsyncBufReadExt, process::Command}; use uuid::Uuid; -use super::HotpatchModuleCache; - /// This struct is used to plan the build process. /// /// The point here is to be able to take in the user's config from the CLI without modifying the @@ -473,6 +472,8 @@ pub struct BuildArtifacts { pub(crate) time_start: SystemTime, pub(crate) time_end: SystemTime, pub(crate) assets: AssetManifest, + pub(crate) android_artifacts: Vec, + pub(crate) swift_sources: Vec, pub(crate) mode: BuildMode, pub(crate) patch_cache: Option>, pub(crate) depinfo: RustcDepInfo, @@ -501,10 +502,6 @@ impl BuildRequest { pub(crate) async fn new(args: &TargetArgs, workspace: Arc) -> Result { let crate_package = workspace.find_main_package(args.package.clone())?; - let config = workspace - .load_dioxus_config(crate_package)? - .unwrap_or_default(); - let target_kind = match args.example.is_some() { true => TargetKind::Example, false => TargetKind::Bin, @@ -574,6 +571,12 @@ impl BuildRequest { })? .clone(); + // Load config from Dioxus.toml and/or inline config in the target's source file. + // Inline config in doc comments takes precedence over Dioxus.toml. + let config = workspace + .load_dioxus_config(crate_package, Some(crate_target.src_path.as_std_path()))? + .unwrap_or_default(); + // We usually use the simulator unless --device is passed *or* a device is detected by probing. // For now, though, since we don't have probing, it just defaults to false // Tools like xcrun/adb can detect devices @@ -917,7 +920,12 @@ impl BuildRequest { } // Make sure we set the sysroot for ios builds in the event the user doesn't have it set - if matches!(bundle, BundleFormat::Ios) { + if matches!(bundle, BundleFormat::Ios) + && matches!( + triple.operating_system, + target_lexicon::OperatingSystem::IOS(_) + ) + { let xcode_path = Workspace::get_xcode_path() .await .unwrap_or_else(|| "/Applications/Xcode.app".to_string().into()); @@ -1095,9 +1103,11 @@ impl BuildRequest { ctx.status_start_bundle(); self.strip_binary(&artifacts).await?; + self.write_executable(ctx, &artifacts.exe, &mut artifacts.assets) .await .context("Failed to write executable")?; + self.write_frameworks(ctx, &artifacts) .await .context("Failed to write frameworks")?; @@ -1107,43 +1117,86 @@ impl BuildRequest { self.write_metadata() .await .context("Failed to write metadata")?; + + // Install prebuilt Android plugin artifacts (AARs + Gradle deps) + if self.bundle == BundleFormat::Android && !artifacts.android_artifacts.is_empty() { + let names: Vec<_> = artifacts + .android_artifacts + .iter() + .map(|a| a.plugin_name.as_str().to_string()) + .collect(); + ctx.status_compiling_native_plugins(format!( + "Kotlin build: {}", + names.join(", ") + )); + self.install_android_artifacts(&artifacts.android_artifacts) + .context("Failed to install Android plugin artifacts")?; + } + + if matches!(self.bundle, BundleFormat::Ios | BundleFormat::MacOS) + && !artifacts.swift_sources.is_empty() + { + let names: Vec<_> = artifacts + .swift_sources + .iter() + .map(|s| s.plugin_name.as_str().to_string()) + .collect(); + ctx.status_compiling_native_plugins(format!( + "Swift build: {}", + names.join(", ") + )); + + // Compile Swift packages from source + self.compile_swift_sources(&artifacts.swift_sources) + .await + .context("Failed to compile Swift packages")?; + + // Then embed Swift standard libraries + self.embed_swift_stdlibs(&artifacts.swift_sources) + .await + .context("Failed to embed Swift standard libraries")?; + } + + // Compile and install Apple Widget Extensions from Dioxus.toml config + if matches!(self.bundle, BundleFormat::Ios | BundleFormat::MacOS) + && !self.config.ios.widget_extensions.is_empty() + { + let names: Vec<_> = self + .config + .ios + .widget_extensions + .iter() + .map(|w| w.display_name.clone()) + .collect(); + ctx.status_compiling_native_plugins(format!( + "Widget build: {}", + names.join(", ") + )); + self.compile_widget_extensions() + .await + .context("Failed to compile widget extensions")?; + } + self.optimize(ctx) .await .context("Failed to optimize build")?; + self.assemble(ctx) .await .context("Failed to assemble build")?; + // Populate the patch cache if we're in fat mode + if matches!(ctx.mode, BuildMode::Fat) { + artifacts.patch_cache = + Some(Arc::new(self.create_patch_cache(&artifacts.exe)?)); + } + tracing::debug!("Bundle created at {}", self.root_dir().display()); } } - // Populate the patch cache if we're in fat mode - if matches!(ctx.mode, BuildMode::Fat) { - artifacts.patch_cache = Some(Arc::new(self.create_patch_cache(&artifacts.exe)?)); - } - - // Calculate some final metadata for logging - let time_taken = SystemTime::now() - .duration_since(time_start) - .map(|d| d.as_millis()) - .unwrap_or_default(); - tracing::debug!( - telemetry = %serde_json::json!({ - "event": "build_and_bundle_complete", - "time_taken": time_taken, - "mode": match ctx.mode { - BuildMode::Base { .. } => "base", - BuildMode::Fat => "fat", - BuildMode::Thin { .. } => "thin", - }, - "blah": 123, - "triple": self.triple.to_string(), - "format": self.bundle.to_string(), - "num_dependencies": self.workspace.krates.len(), - }), - "Build completed in {time_taken}ms", - ); + // Record the build duration as a telemetry event + self.record_build_duration(time_start, ctx); Ok(artifacts) } @@ -1343,7 +1396,10 @@ impl BuildRequest { ); } - let assets = self.collect_assets(&exe, ctx).await?; + // Extract all linker metadata (assets, Android/iOS plugins, widget extensions) in a single pass. + let (assets, android_artifacts, swift_sources) = + self.collect_assets_and_metadata(&exe, ctx).await?; + let time_end = SystemTime::now(); let mode = ctx.mode.clone(); let depinfo = RustcDepInfo::from_file(&exe.with_extension("d")).unwrap_or_default(); @@ -1360,6 +1416,8 @@ impl BuildRequest { workspace_rustc_args, time_start, assets, + android_artifacts, + swift_sources, mode, depinfo, root_dir: self.root_dir(), @@ -1481,46 +1539,492 @@ impl BuildRequest { Ok(object_cache) } - /// Collect the assets from the final executable and modify the binary in place to point to the right - /// hashed asset location. - async fn collect_assets(&self, exe: &Path, ctx: &BuildContext) -> Result { - // And then add from the exe directly, just in case it's LTO compiled and has no incremental cache - if self.skip_assets { - return Ok(AssetManifest::default()); + /// Collect assets and plugin metadata from the final executable in one pass + /// + /// This method extracts assets and FFI plugin metadata (Android/Swift) from the + /// binary. Permissions are now read from Dioxus.toml, not extracted from the binary. + async fn collect_assets_and_metadata( + &self, + exe: &Path, + ctx: &BuildContext, + ) -> Result<( + AssetManifest, + Vec, + Vec, + )> { + use super::assets::extract_symbols_from_file; + + let skip_assets = self.skip_assets; + let needs_android_artifacts = self.bundle == BundleFormat::Android; + let needs_swift_packages = matches!(self.bundle, BundleFormat::Ios | BundleFormat::MacOS); + + if skip_assets && !needs_android_artifacts && !needs_swift_packages { + return Ok((AssetManifest::default(), Vec::new(), Vec::new())); } ctx.status_extracting_assets(); + let super::assets::SymbolExtractionResult { + assets: extracted_assets, + android_artifacts, + swift_packages, + } = extract_symbols_from_file(exe).await?; - let mut manifest = super::assets::extract_assets_from_file(exe).await?; + let asset_manifest = if skip_assets { + AssetManifest::default() + } else { + let mut manifest = AssetManifest::default(); + for asset in extracted_assets { + manifest.insert_asset(asset); + } - // If the user has a public dir, we submit all the entries there as assets too - // - // These don't receive a hash in their filename, since they're user-provided static assets - // We only do this for web builds - if matches!(self.bundle, BundleFormat::Web) - && matches!(ctx.mode, BuildMode::Base { .. } | BuildMode::Fat) - { - if let Some(dir) = self.user_public_dir() { - for entry in walkdir::WalkDir::new(&dir) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - { - let from = entry.path().to_path_buf(); - let relative_path = from.strip_prefix(&dir).unwrap(); - let to = format!("../{}", relative_path.display()); - manifest.insert_asset(BundledAsset::new( - from.to_string_lossy().as_ref(), - to.as_str(), - AssetOptionsBuilder::new() - .with_hash_suffix(false) - .into_asset_options(), - )); + if matches!(self.bundle, BundleFormat::Web) + && matches!(ctx.mode, BuildMode::Base { .. } | BuildMode::Fat) + { + if let Some(dir) = self.user_public_dir() { + for entry in walkdir::WalkDir::new(&dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let from = entry.path().to_path_buf(); + let relative_path = from.strip_prefix(&dir).unwrap(); + let to = format!("../{}", relative_path.display()); + manifest.insert_asset(BundledAsset::new( + from.to_string_lossy().as_ref(), + to.as_str(), + manganis_core::AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options(), + )); + } } } + + manifest + }; + + if !android_artifacts.is_empty() { + tracing::debug!( + "Found {} Android artifact declaration(s)", + android_artifacts.len() + ); + for artifact in android_artifacts.iter() { + tracing::debug!( + " Plugin: {} Artifact: {}", + artifact.plugin_name.as_str(), + artifact.artifact_path.as_str() + ); + } } - Ok(manifest) + if !swift_packages.is_empty() { + tracing::debug!( + "Found {} Swift package declaration(s) for {:?}", + swift_packages.len(), + self.bundle + ); + for source in &swift_packages { + tracing::debug!( + " Plugin: {} (Swift package path={} product={})", + source.plugin_name.as_str(), + source.package_path.as_str(), + source.product.as_str() + ); + } + } + + Ok((asset_manifest, android_artifacts, swift_packages)) + } + + /// Install Android plugin artifacts by bundling source folders as Gradle submodules. + /// + /// This function handles both prebuilt AARs and source folders: + /// - If `artifact_path` is a file (ends in .aar), copy it to libs/ and add file dependency + /// - If `artifact_path` is a directory, copy it as a Gradle submodule and add project dependency + /// + /// All sources are bundled first, then a single Gradle build compiles everything in `assemble()`. + fn install_android_artifacts( + &self, + android_artifacts: &[AndroidArtifactMetadata], + ) -> Result<()> { + let libs_dir = self.root_dir().join("app").join("libs"); + std::fs::create_dir_all(&libs_dir)?; + + let plugins_dir = self.root_dir().join("plugins"); + let build_gradle = self.root_dir().join("app").join("build.gradle.kts"); + let settings_gradle = self.root_dir().join("settings.gradle"); + + for artifact in android_artifacts { + let artifact_path = PathBuf::from(artifact.artifact_path.as_str()); + let plugin_name = artifact.plugin_name.as_str(); + + if artifact_path.is_dir() { + // It's a source folder - copy it as a Gradle submodule + tracing::debug!( + "Bundling Android plugin '{}' from source: {}", + plugin_name, + artifact_path.display() + ); + + // Create module directory + let module_dir = plugins_dir.join(plugin_name); + self.copy_build_dir_recursive(&artifact_path, &module_dir)?; + + // Strip version specifiers from build.gradle.kts to avoid conflicts with parent project + self.strip_gradle_plugin_versions(&module_dir)?; + + // Add to settings.gradle + self.ensure_settings_gradle_include(&settings_gradle, plugin_name)?; + + // Add project dependency to app/build.gradle.kts + let dep_line = format!("implementation(project(\":plugins:{}\"))", plugin_name); + self.ensure_gradle_dependency(&build_gradle, &dep_line)?; + + tracing::debug!( + "Added Android plugin module :plugins:{} from {}", + plugin_name, + artifact_path.display() + ); + } else if artifact_path.extension().is_some_and(|ext| ext == "aar") { + // It's a prebuilt AAR - copy directly to libs + if !artifact_path.exists() { + anyhow::bail!( + "Android plugin artifact not found: {}", + artifact_path.display() + ); + } + + let filename = artifact_path + .file_name() + .ok_or_else(|| { + anyhow::anyhow!( + "Android plugin artifact path has no filename: {}", + artifact_path.display() + ) + })? + .to_owned(); + let dest_file = libs_dir.join(&filename); + std::fs::copy(&artifact_path, &dest_file)?; + tracing::debug!( + "Copied Android artifact {} -> {}", + artifact_path.display(), + dest_file.display() + ); + + let dep_line = format!( + "implementation(files(\"libs/{}\"))", + filename.to_string_lossy() + ); + self.ensure_gradle_dependency(&build_gradle, &dep_line)?; + } else { + anyhow::bail!( + "Android artifact path is neither a directory nor an AAR file: {}", + artifact_path.display() + ); + } + + // Add any extra Gradle dependencies specified by the plugin + for dependency in artifact + .gradle_dependencies + .as_str() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + self.ensure_gradle_dependency(&build_gradle, dependency)?; + } + } + + Ok(()) + } + + /// Recursively copy a directory and its contents. + #[allow(clippy::only_used_in_recursion)] + fn copy_build_dir_recursive(&self, src: &Path, dst: &Path) -> Result<()> { + std::fs::create_dir_all(dst)?; + + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + // Skip build directories and hidden folders + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str == "build" || name_str == ".gradle" || name_str.starts_with('.') { + continue; + } + + self.copy_build_dir_recursive(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) + } + + /// Strip version specifiers from build.gradle.kts plugins block. + /// + /// When a plugin module is included as a subproject, having version specifiers in the + /// plugins block causes conflicts because the parent project already has the plugins + /// on the classpath. This function removes version specifications like: + /// - `version "8.4.2"` or `version "1.9.24"` + /// - Entire version calls from plugin declarations + fn strip_gradle_plugin_versions(&self, module_dir: &Path) -> Result<()> { + use std::fs; + + let build_gradle = module_dir.join("build.gradle.kts"); + if !build_gradle.exists() { + return Ok(()); + } + + let contents = fs::read_to_string(&build_gradle)?; + + // Remove version specifications from plugin declarations + // Matches: id("com.android.library") version "8.4.2" -> id("com.android.library") + // Matches: kotlin("android") version "1.9.24" -> kotlin("android") + let version_pattern = regex::Regex::new(r#"\s+version\s+"[^"]+""#).expect("Invalid regex"); + let cleaned = version_pattern.replace_all(&contents, ""); + + if cleaned != contents { + fs::write(&build_gradle, cleaned.as_ref())?; + tracing::debug!( + "Stripped version specifiers from {}", + build_gradle.display() + ); + } + + Ok(()) + } + + /// Add a module include to settings.gradle if not already present. + fn ensure_settings_gradle_include( + &self, + settings_gradle: &Path, + plugin_name: &str, + ) -> Result<()> { + use std::fs; + + let include_line = format!("include ':plugins:{}'", plugin_name); + let mut contents = fs::read_to_string(settings_gradle)?; + + if contents.contains(&include_line) { + return Ok(()); + } + + // Add the include at the end + contents.push_str(&format!("\n{}\n", include_line)); + fs::write(settings_gradle, contents)?; + + Ok(()) + } + + /// Bundle and compile Swift packages from source into dynamic frameworks. + /// + /// This function: + /// 1. Calls ios_swift::compile_swift_sources to compile Swift packages + /// 2. The function creates proper .framework bundles from the dylibs + /// 3. Installs the frameworks to the app's Frameworks folder + async fn compile_swift_sources(&self, swift_sources: &[SwiftPackageMetadata]) -> Result<()> { + if swift_sources.is_empty() { + return Ok(()); + } + + let build_dir = self.target_dir.join("swift-build"); + std::fs::create_dir_all(&build_dir)?; + + // Compile Swift sources and get the framework bundle path + let framework_path = super::ios_swift::compile_swift_sources( + swift_sources, + &self.triple, + &build_dir, + self.release, + ) + .await?; + + // If a framework was created, install it to the Frameworks folder + if let Some(framework) = framework_path { + self.install_swift_framework(&framework).await?; + } + + Ok(()) + } + + /// Install a Swift framework bundle into the app's Frameworks directory. + async fn install_swift_framework(&self, framework_path: &Path) -> Result<()> { + let frameworks_dir = self.frameworks_folder(); + std::fs::create_dir_all(&frameworks_dir)?; + + let framework_name = framework_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid framework path: no filename"))?; + let dest = frameworks_dir.join(framework_name); + + // Remove existing framework if present + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + + // Copy the entire framework bundle + self.copy_build_dir_recursive(framework_path, &dest)?; + + tracing::debug!( + "Installed Swift framework '{}' to {}", + framework_name.to_string_lossy(), + frameworks_dir.display() + ); + + Ok(()) + } + + /// Embed Swift standard libraries into the app bundle when Swift plugins are present. + async fn embed_swift_stdlibs(&self, swift_sources: &[SwiftPackageMetadata]) -> Result<()> { + if swift_sources.is_empty() { + return Ok(()); + } + + let platform_flag = match self.bundle { + BundleFormat::Ios => { + let triple_str = self.triple.to_string(); + if triple_str.contains("sim") || triple_str.contains("x86_64") { + "iphonesimulator" + } else { + "iphoneos" + } + } + BundleFormat::MacOS => "macosx", + _ => return Ok(()), + }; + + let frameworks_dir = self.frameworks_folder(); + std::fs::create_dir_all(&frameworks_dir)?; + + let exe_path = self.main_exe(); + if !exe_path.exists() { + anyhow::bail!( + "Expected executable at {} when embedding Swift stdlibs", + exe_path.display() + ); + } + + // Use swift-stdlib-tool to copy Swift runtime libraries needed by: + // 1. The main executable (--scan-executable) + // 2. Any Swift frameworks in the Frameworks folder (--scan-folder) + let output = Command::new("xcrun") + .arg("swift-stdlib-tool") + .arg("--copy") + .arg("--platform") + .arg(platform_flag) + .arg("--scan-executable") + .arg(&exe_path) + .arg("--scan-folder") + .arg(&frameworks_dir) + .arg("--destination") + .arg(&frameworks_dir) + .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-stdlib-tool failed: {}{}", + stderr.trim(), + if stdout.trim().is_empty() { + "".to_string() + } else { + format!(" | {}", stdout.trim()) + } + ); + } + + Ok(()) + } + + /// Compile and install Apple Widget Extensions from Dioxus.toml config. + /// + /// This processes widget extensions declared in `[[ios.widget_extensions]]` by: + /// 1. Compiling the Swift package as a Widget Extension executable + /// 2. Creating the .appex bundle structure with Info.plist + /// 3. Installing to the app's PlugIns folder + async fn compile_widget_extensions(&self) -> Result<()> { + let widget_configs = &self.config.ios.widget_extensions; + if widget_configs.is_empty() { + return Ok(()); + } + + tracing::debug!( + "Compiling {} Apple Widget Extension(s)", + widget_configs.len() + ); + + let build_dir = self.target_dir.join("widget-build"); + std::fs::create_dir_all(&build_dir)?; + + let app_bundle_id = self.bundle_identifier(); + let default_deployment_target = self + .config + .ios + .deployment_target + .as_deref() + .unwrap_or("16.0"); + + let plugins_dir = self.plugins_folder(); + std::fs::create_dir_all(&plugins_dir)?; + + for widget_config in widget_configs { + let source_path = self.package_manifest_dir().join(&widget_config.source); + let deployment_target = widget_config + .deployment_target + .as_deref() + .unwrap_or(default_deployment_target); + + let widget_source = super::ios_swift::AppleWidgetSource { + source_path, + display_name: widget_config.display_name.clone(), + bundle_id_suffix: widget_config.bundle_id_suffix.clone(), + deployment_target: deployment_target.to_string(), + module_name: widget_config.module_name.clone(), + }; + + let appex_path = super::ios_swift::compile_apple_widget( + &widget_source, + &self.triple, + &build_dir, + &app_bundle_id, + self.release, + ) + .await + .with_context(|| { + format!( + "Failed to compile widget extension '{}'", + widget_source.display_name + ) + })?; + + // Install the .appex bundle to PlugIns/ + let appex_name = appex_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Widget.appex".to_string()); + let dest_path = plugins_dir.join(&appex_name); + + if dest_path.exists() { + std::fs::remove_dir_all(&dest_path)?; + } + + self.copy_build_dir_recursive(&appex_path, &dest_path)?; + + tracing::debug!( + "Installed widget extension '{}' to {}", + widget_source.display_name, + dest_path.display() + ); + } + + Ok(()) } /// Take the output of rustc and make it into the main exe of the bundle @@ -1701,6 +2205,18 @@ impl BuildRequest { } } + /// Get the folder where Apple Widget Extensions (.appex bundles) are installed. + /// This is only applicable to iOS and macOS bundles. + fn plugins_folder(&self) -> PathBuf { + match self.triple.operating_system { + OperatingSystem::Darwin(_) | OperatingSystem::MacOSX(_) => { + self.root_dir().join("Contents").join("PlugIns") + } + OperatingSystem::IOS(_) => self.root_dir().join("PlugIns"), + _ => self.root_dir().join("PlugIns"), + } + } + /// Copy the assets out of the manifest and into the target location /// /// Should be the same on all platforms - just copy over the assets from the manifest into the output directory @@ -1740,7 +2256,7 @@ impl BuildRequest { // todo(jon): we also want to eventually include options for each asset's optimization and compression, which we currently aren't let mut assets_to_transfer = vec![]; - // Queue the bundled assets + // Queue the bundled assets (skip sidecar assets that require special processing) for bundled in assets.unique_assets() { let from = PathBuf::from(bundled.absolute_source_path()); let to = asset_dir.join(bundled.bundled_path()); @@ -2049,10 +2565,13 @@ impl BuildRequest { _ = std::fs::remove_file(PathBuf::from(args.link_args[idx + 1].as_str())); } - // Now extract the assets from the fat binary - artifacts.assets = self - .collect_assets(&self.patch_exe(artifacts.time_start), ctx) + // Now extract linker metadata from the fat binary (assets, plugin data) + let (assets, android_artifacts, swift_sources) = self + .collect_assets_and_metadata(&self.patch_exe(artifacts.time_start), ctx) .await?; + artifacts.assets = assets; + artifacts.android_artifacts = android_artifacts; + artifacts.swift_sources = swift_sources; // If this is a web build, reset the index.html file in case it was modified by SSG self.write_index_html(&artifacts.assets) @@ -2136,7 +2655,11 @@ impl BuildRequest { || *arg == "-arch" || *arg == "-L" || *arg == "-target" - || *arg == "-isysroot" + || (*arg == "-isysroot" + && matches!( + self.triple.operating_system, + target_lexicon::OperatingSystem::IOS(_) + )) { out_args.push(arg.to_string()); out_args.push(original_args[idx + 1].to_string()); @@ -2223,8 +2746,13 @@ impl BuildRequest { } if let Some(vale) = extract_value("-isysroot") { - out_args.push("-isysroot".to_string()); - out_args.push(vale); + if matches!( + self.triple.operating_system, + target_lexicon::OperatingSystem::IOS(_) + ) { + out_args.push("-isysroot".to_string()); + out_args.push(vale); + } } Ok(out_args) @@ -2516,6 +3044,28 @@ impl BuildRequest { args.remove(flavor_idx); } + // Note: Swift sources are now compiled as dynamic frameworks during the main build flow. + // Dynamic frameworks are loaded at runtime, not linked statically, so we don't add + // them to the linker args here. The framework will be installed to the Frameworks + // folder by compile_swift_sources() in the main bundle creation phase. + if matches!( + self.triple.operating_system, + OperatingSystem::IOS(_) | OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_) + ) { + let workspace_dir = self.workspace_dir(); + let swift_sources = super::ios_swift::extract_swift_metadata_from_link_args( + &rustc_args.link_args, + &workspace_dir, + ); + + if !swift_sources.is_empty() { + tracing::debug!( + "Found {} Swift plugin source(s) - will be compiled as dynamic framework during bundle creation", + swift_sources.len() + ); + } + } + // Set the output file match self.triple.operating_system { OperatingSystem::Windows => args.push(format!("/OUT:{}", exe.display())), @@ -2919,10 +3469,13 @@ impl BuildRequest { cargo_args.push("-Clink-args=--emit-relocs".to_string()); } - // dx *always* links android and thin builds - if self.custom_linker.is_some() - || matches!(build_mode, BuildMode::Thin { .. } | BuildMode::Fat) - { + // dx links android, thin builds, and fat builds with a custom linker. + // Note: We don't intercept Darwin Base builds since Swift plugins are compiled as dynamic + // frameworks that load at runtime, not linked statically into the binary. + let use_dx_linker = self.custom_linker.is_some() + || matches!(build_mode, BuildMode::Thin { .. } | BuildMode::Fat); + + if use_dx_linker { cargo_args.push(format!( "-Clinker={}", Workspace::path_to_dx().expect("can't find dx").display() @@ -2936,13 +3489,19 @@ impl BuildRequest { } // Handle frameworks/dylibs by setting the rpath - // This is dependent on the bundle structure - in this case, appimage and appbundle for mac/linux + // This is dependent on the bundle structure - iOS uses a flat structure while macOS uses nested // todo: we need to figure out what to do for windows match self.triple.operating_system { - OperatingSystem::Darwin(_) | OperatingSystem::IOS(_) => { + OperatingSystem::Darwin(_) | OperatingSystem::MacOSX { .. } => { + // macOS: App.app/Contents/MacOS/exe -> ../Frameworks/ cargo_args.push("-Clink-arg=-Wl,-rpath,@executable_path/../Frameworks".to_string()); cargo_args.push("-Clink-arg=-Wl,-rpath,@executable_path".to_string()); } + OperatingSystem::IOS(_) => { + // iOS: App.app/exe -> Frameworks/ (flat bundle structure) + cargo_args.push("-Clink-arg=-Wl,-rpath,@executable_path/Frameworks".to_string()); + cargo_args.push("-Clink-arg=-Wl,-rpath,@executable_path".to_string()); + } OperatingSystem::Linux => { cargo_args.push("-Clink-arg=-Wl,-rpath,$ORIGIN/../lib".to_string()); cargo_args.push("-Clink-arg=-Wl,-rpath,$ORIGIN".to_string()); @@ -3081,9 +3640,14 @@ impl BuildRequest { } // If we're either zero-linking or using a custom linker, make `dx` itself do the linking. - if self.custom_linker.is_some() - || matches!(build_mode, BuildMode::Thin { .. } | BuildMode::Fat) - { + // Note: We don't intercept Darwin Base builds since Swift plugins are compiled as dynamic + // frameworks that load at runtime, not linked statically into the binary. + let use_dx_linker = self.custom_linker.is_some() + || matches!(build_mode, BuildMode::Thin { .. } | BuildMode::Fat); + + if use_dx_linker { + // For Android, we pass the actual linker so cargo can still link normally. + // For Fat/Thin builds, we use no-link mode (linker = None). LinkAction { triple: self.triple.clone(), linker: self.custom_linker.clone(), @@ -3168,6 +3732,8 @@ impl BuildRequest { let target_cxx = tools.target_cxx(); let java_home = tools.java_home(); let ndk_home = tools.ndk.clone(); + let sdk_root = tools.sdk(); + let artifact_dir = self.android_artifact_dir()?; tracing::debug!( r#"Using android: min_sdk_version: {min_sdk_version} @@ -3176,14 +3742,37 @@ impl BuildRequest { target_cc: {target_cc:?} target_cxx: {target_cxx:?} java_home: {java_home:?} + sdk_root: {sdk_root:?} + artifact_dir: {artifact_dir:?} "# ); - if let Some(java_home) = java_home { + if let Some(java_home) = &java_home { tracing::debug!("Setting JAVA_HOME to {java_home:?}"); - env_vars.push(("JAVA_HOME".into(), java_home.into_os_string())); + env_vars.push(("JAVA_HOME".into(), java_home.clone().into_os_string())); + env_vars.push(( + "DX_ANDROID_JAVA_HOME".into(), + java_home.clone().into_os_string(), + )); } + env_vars.push(( + "DX_ANDROID_ARTIFACT_DIR".into(), + artifact_dir.into_os_string(), + )); + env_vars.push(( + "DX_ANDROID_NDK_HOME".into(), + ndk_home.clone().into_os_string(), + )); + env_vars.push(( + "DX_ANDROID_SDK_ROOT".into(), + sdk_root.clone().into_os_string(), + )); + env_vars.push(("ANDROID_NDK_HOME".into(), ndk_home.clone().into_os_string())); + env_vars.push(("ANDROID_SDK_ROOT".into(), sdk_root.clone().into_os_string())); + env_vars.push(("ANDROID_HOME".into(), sdk_root.into_os_string())); + env_vars.push(("NDK_HOME".into(), ndk_home.clone().into_os_string())); + let triple = self.triple.to_string(); // Environment variables for the `cc` crate @@ -3301,7 +3890,10 @@ impl BuildRequest { ), linker.into_os_string(), ), - ("ANDROID_NDK_ROOT".to_string(), ndk_home.into_os_string()), + ( + "ANDROID_NDK_ROOT".to_string(), + ndk_home.clone().into_os_string(), + ), ( "OPENSSL_LIB_DIR".to_string(), openssl_lib_dir.into_os_string(), @@ -3321,10 +3913,16 @@ impl BuildRequest { "WRY_ANDROID_LIBRARY".to_string(), "dioxusmain".to_string().into(), ), - ( - "WRY_ANDROID_KOTLIN_FILES_OUT_DIR".to_string(), - self.wry_android_kotlin_files_out_dir().into_os_string(), - ), + ("WRY_ANDROID_KOTLIN_FILES_OUT_DIR".to_string(), { + let kotlin_dir = self.wry_android_kotlin_files_out_dir(); + // Ensure the directory exists for WRY's canonicalize check + if let Err(e) = std::fs::create_dir_all(&kotlin_dir) { + tracing::error!("Failed to create kotlin directory {:?}: {}", kotlin_dir, e); + return Err(anyhow::anyhow!("Failed to create kotlin directory: {}", e)); + } + tracing::debug!("Created kotlin directory: {:?}", kotlin_dir); + kotlin_dir.into_os_string() + }), // Found this through a comment related to bindgen using the wrong clang for cross compiles // // https://github.com/rust-lang/rust-bindgen/issues/2962#issuecomment-2438297124 @@ -3347,6 +3945,17 @@ impl BuildRequest { Ok(env_vars) } + fn android_artifact_dir(&self) -> Result { + let dir = self + .internal_out_dir() + .join(&self.main_target) + .join(if self.release { "release" } else { "debug" }) + .join("android-artifacts") + .join(self.triple.to_string()); + std::fs::create_dir_all(&dir)?; + Ok(dir) + } + /// Get an estimate of the number of units in the crate. If nightly rustc is not available, this /// will return an estimate of the number of units in the crate based on cargo metadata. /// @@ -3512,12 +4121,14 @@ impl BuildRequest { let app = root.join("app"); let app_main = app.join("src").join("main"); let app_kotlin = app_main.join("kotlin"); + let app_java = app_main.join("java"); let app_jnilibs = app_main.join("jniLibs"); let app_assets = app_main.join("assets"); let app_kotlin_out = self.wry_android_kotlin_files_out_dir(); create_dir_all(&app)?; create_dir_all(&app_main)?; create_dir_all(&app_kotlin)?; + create_dir_all(&app_java)?; create_dir_all(&app_jnilibs)?; create_dir_all(&app_assets)?; create_dir_all(&app_kotlin_out)?; @@ -3539,12 +4150,81 @@ impl BuildRequest { struct AndroidHandlebarsObjects { application_id: String, app_name: String, + version: String, android_bundle: Option, + /// Android SDK version settings + min_sdk: u32, + target_sdk: u32, + compile_sdk: u32, + /// Android permission strings (e.g., "android.permission.CAMERA") + permissions: Vec, + /// Android hardware features (e.g., "android.hardware.location.gps") + features: Vec, + /// Raw manifest XML to inject + raw_manifest: String, + /// URL schemes for deep linking + url_schemes: Vec, + /// App link hosts for auto-verified deep links + app_link_hosts: Vec, + /// Pipe-joined foreground service type string (e.g., "location|mediaPlayback") + foreground_service_type: String, + /// Extra Gradle dependencies from [android] config + gradle_dependencies: Vec, + /// Extra Gradle plugins from [android] config + gradle_plugins: Vec, + /// Application-level manifest attributes from [android.application] + uses_cleartext_traffic: Option, + app_theme: Option, + supports_rtl: Option, + large_heap: Option, } + + // Get permission mapper from config + let mapper = super::manifest_mapper::ManifestMapper::from_config( + &self.config.permissions, + &self.config.deep_links, + &self.config.background, + &self.config.android, + &self.config.ios, + &self.config.macos, + ); + + // Collect Android permissions + let permissions: Vec = mapper + .android_permissions + .iter() + .map(|p| p.permission.clone()) + .collect(); + + // Collect Android features from config + let features = self.config.android.features.clone(); + + // Get raw manifest XML + let raw_manifest = self.config.android.raw.manifest.clone().unwrap_or_default(); + + // Foreground service types as pipe-separated string + let foreground_service_type = mapper.android_foreground_service_types.join("|"); + let hbs_data = AndroidHandlebarsObjects { application_id: self.bundle_identifier(), app_name: self.bundled_app_name(), + version: self.crate_version(), android_bundle: self.config.bundle.android.clone(), + min_sdk: self.config.android.min_sdk.unwrap_or(24), + target_sdk: self.config.android.target_sdk.unwrap_or(34), + compile_sdk: self.config.android.compile_sdk.unwrap_or(34), + permissions, + features, + raw_manifest, + url_schemes: mapper.android_url_schemes, + app_link_hosts: mapper.android_app_link_hosts, + foreground_service_type, + gradle_dependencies: self.config.android.gradle_dependencies.clone(), + gradle_plugins: self.config.android.gradle_plugins.clone(), + uses_cleartext_traffic: self.config.android.application.uses_cleartext_traffic, + app_theme: self.config.android.application.theme.clone(), + supports_rtl: self.config.android.application.supports_rtl, + large_heap: self.config.android.application.large_heap, }; let hbs = handlebars::Handlebars::new(); @@ -3593,6 +4273,22 @@ impl BuildRequest { include_bytes!("../../assets/android/gen/app/proguard-rules.pro"), )?; + // Copy additional ProGuard rule files from Dioxus.toml [android] config + for rule_file in &self.config.android.proguard_rules { + let src = self.package_manifest_dir().join(rule_file); + if src.exists() { + let dest_name = src + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + std::fs::copy(&src, app.join(&dest_name))?; + tracing::debug!("Copied ProGuard rules: {}", dest_name); + } else { + tracing::warn!("ProGuard rules file not found: {}", src.display()); + } + } + let manifest_xml = match self.config.application.android_manifest.as_deref() { Some(manifest) => std::fs::read_to_string(self.package_manifest_dir().join(manifest)) .context("Failed to locate custom AndroidManifest.xml")?, @@ -3725,6 +4421,25 @@ impl BuildRequest { kotlin_dir } + fn ensure_gradle_dependency(&self, build_gradle: &Path, dependency_line: &str) -> Result<()> { + use std::fs; + + let mut contents = fs::read_to_string(build_gradle)?; + if contents.contains(dependency_line) { + return Ok(()); + } + + if let Some(idx) = contents.find("dependencies {") { + let insert_pos = idx + "dependencies {".len(); + contents.insert_str(insert_pos, &format!("\n {dependency_line}")); + } else { + contents.push_str(&format!("\ndependencies {{\n {dependency_line}\n}}\n")); + } + + fs::write(build_gradle, contents)?; + Ok(()) + } + /// Get the directory where this app can write to for this session that's guaranteed to be stable /// for the same app. This is useful for emitting state like window position and size. /// @@ -4058,20 +4773,30 @@ impl BuildRequest { self.executable_name().to_case(Case::Pascal) } + /// Get the crate version from Cargo.toml (e.g., "0.1.0") + fn crate_version(&self) -> String { + self.workspace.krates[self.crate_package] + .version + .to_string() + } + pub(crate) fn bundle_identifier(&self) -> String { - if let Some(identifier) = &self.config.bundle.identifier { + use crate::config::BundlePlatform; + + // Check platform-specific identifier override first, then fall back to base bundle + let platform: BundlePlatform = self.bundle.into(); + if let Some(identifier) = self.config.resolved_identifier(platform) { + let identifier = identifier.to_string(); if identifier.contains('.') && !identifier.starts_with('.') && !identifier.ends_with('.') && !identifier.contains("..") { - return identifier.clone(); + return identifier; } else { - // The original `mobile_org` function used `expect` directly. - // Maybe it's acceptable for the CLI to panic directly when this error occurs. - // And if we change it to a Result type, the `client_connected` function in serve/runner.rs does not return a Result and cannot call `?`, - // We also need to handle the error in place, otherwise it will expand the scope of modifications further. - panic!("Invalid bundle identifier: {identifier:?}. E.g. `com.example`, `com.example.app`"); + tracing::error!( + "Invalid bundle identifier: {identifier:?}. Must contain at least one '.' and not start/end with '.'. E.g. `com.example.app`" + ); } } @@ -4559,12 +5284,33 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } fn info_plist_contents(&self, bundle: BundleFormat) -> Result { + /// A permission entry for plist (key + description) + #[derive(Serialize)] + struct PlistPermission { + key: String, + description: String, + } + #[derive(Serialize)] pub struct InfoPlistData { pub display_name: String, pub bundle_name: String, pub bundle_identifier: String, pub executable_name: String, + /// App version string (from Cargo.toml) + pub version: String, + /// Permission usage descriptions + pub permissions: Vec, + /// Additional plist entries as raw XML + pub plist_entries: String, + /// Raw plist XML to inject + pub raw_plist: String, + /// Minimum system version (macOS only) + pub minimum_system_version: String, + /// URL schemes for deep linking + pub url_schemes: Vec, + /// iOS UIBackgroundModes + pub background_modes: Vec, } // Attempt to use the user's manually specified @@ -4583,29 +5329,91 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ _ => {} } + // Get permission mapper from config + let mapper = super::manifest_mapper::ManifestMapper::from_config( + &self.config.permissions, + &self.config.deep_links, + &self.config.background, + &self.config.android, + &self.config.ios, + &self.config.macos, + ); + match bundle { - BundleFormat::MacOS => handlebars::Handlebars::new() - .render_template( - include_str!("../../assets/macos/mac.plist.hbs"), - &InfoPlistData { - display_name: self.bundled_app_name(), - bundle_name: self.bundled_app_name(), - executable_name: self.platform_exe_name(), - bundle_identifier: self.bundle_identifier(), - }, - ) - .map_err(|e| e.into()), - BundleFormat::Ios => handlebars::Handlebars::new() - .render_template( - include_str!("../../assets/ios/ios.plist.hbs"), - &InfoPlistData { - display_name: self.bundled_app_name(), - bundle_name: self.bundled_app_name(), - executable_name: self.platform_exe_name(), - bundle_identifier: self.bundle_identifier(), - }, - ) - .map_err(|e| e.into()), + BundleFormat::MacOS => { + // Convert macOS plist entries to permission structs + let permissions: Vec = mapper + .macos_plist_entries + .iter() + .map(|p| PlistPermission { + key: p.key.clone(), + description: p.value.clone(), + }) + .collect(); + + // Generate plist entries from config + let plist_entries = generate_plist_entries(&self.config.macos.plist); + let raw_plist = self.config.macos.raw.info_plist.clone().unwrap_or_default(); + let minimum_system_version = self + .config + .macos + .minimum_system_version + .clone() + .unwrap_or_else(|| "10.15".to_string()); + + handlebars::Handlebars::new() + .render_template( + include_str!("../../assets/macos/mac.plist.hbs"), + &InfoPlistData { + display_name: self.bundled_app_name(), + bundle_name: self.bundled_app_name(), + executable_name: self.platform_exe_name(), + bundle_identifier: self.bundle_identifier(), + version: self.crate_version(), + permissions, + plist_entries, + raw_plist, + minimum_system_version, + url_schemes: mapper.macos_url_schemes.clone(), + background_modes: Vec::new(), // macOS doesn't use UIBackgroundModes + }, + ) + .map_err(|e| e.into()) + } + BundleFormat::Ios => { + // Convert iOS plist entries to permission structs + let permissions: Vec = mapper + .ios_plist_entries + .iter() + .map(|p| PlistPermission { + key: p.key.clone(), + description: p.value.clone(), + }) + .collect(); + + // Generate plist entries from config + let plist_entries = generate_plist_entries(&self.config.ios.plist); + let raw_plist = self.config.ios.raw.info_plist.clone().unwrap_or_default(); + + handlebars::Handlebars::new() + .render_template( + include_str!("../../assets/ios/ios.plist.hbs"), + &InfoPlistData { + display_name: self.bundled_app_name(), + bundle_name: self.bundled_app_name(), + executable_name: self.platform_exe_name(), + bundle_identifier: self.bundle_identifier(), + version: self.crate_version(), + permissions, + plist_entries, + raw_plist, + minimum_system_version: String::new(), // Not used for iOS + url_schemes: mapper.ios_url_schemes.clone(), + background_modes: mapper.ios_background_modes.clone(), + }, + ) + .map_err(|e| e.into()) + } _ => Err(anyhow::anyhow!("Unsupported platform for Info.plist")), } } @@ -5706,6 +6514,10 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ let (entitlements_xml, profile_path) = Self::auto_provision_entitlements(&bundle_id) .await .context("Failed to auto-provision entitlements for Apple codesigning.")?; + + // Enrich with entitlements from Dioxus.toml config + let entitlements_xml = self.enrich_entitlements_from_config(entitlements_xml)?; + let entitlements_temp_file = tempfile::NamedTempFile::new()?; std::fs::write(entitlements_temp_file.path(), entitlements_xml)?; entitlements_file = Some(entitlements_temp_file.path().to_path_buf()); @@ -5792,6 +6604,232 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ Ok(app_dev_name.to_string()) } + /// Enrich auto-provisioned entitlements XML with config from Dioxus.toml. + /// + /// Injects entitlements from `[ios.entitlements]` or `[macos.entitlements]` sections + /// and associated domains from `[deep_links]` into the base entitlements XML. + fn enrich_entitlements_from_config(&self, base_xml: String) -> Result { + let mut extra_entries = String::new(); + + match self.bundle { + BundleFormat::Ios => { + let ent = &self.config.ios.entitlements; + + // Associated domains (from deep_links.hosts + ios.entitlements.associated-domains) + let mapper = super::manifest_mapper::ManifestMapper::from_config( + &self.config.permissions, + &self.config.deep_links, + &self.config.background, + &self.config.android, + &self.config.ios, + &self.config.macos, + ); + let mut domains: Vec = mapper.ios_associated_domains; + domains.extend(ent.associated_domains.clone()); + domains.dedup(); + if !domains.is_empty() { + extra_entries.push_str( + " com.apple.developer.associated-domains\n \n", + ); + for domain in &domains { + extra_entries.push_str(&format!(" {domain}\n")); + } + extra_entries.push_str(" \n"); + } + + // App groups + if !ent.app_groups.is_empty() { + extra_entries.push_str( + " com.apple.security.application-groups\n \n", + ); + for group in &ent.app_groups { + extra_entries.push_str(&format!(" {group}\n")); + } + extra_entries.push_str(" \n"); + } + + // APS environment (push notifications) + if let Some(env) = &ent.aps_environment { + extra_entries.push_str(&format!( + " aps-environment\n {env}\n" + )); + } + + // iCloud + if ent.icloud { + extra_entries.push_str( + " com.apple.developer.icloud-container-identifiers\n \n\ + com.apple.developer.icloud-services\n \n CloudDocuments\n \n" + ); + } + + // Keychain access groups + // (base entitlements already include one from provisioning profile, only add extras) + if !ent.keychain_access_groups.is_empty() { + extra_entries.push_str(" keychain-access-groups\n \n"); + for group in &ent.keychain_access_groups { + extra_entries.push_str(&format!(" {group}\n")); + } + extra_entries.push_str(" \n"); + } + + // Apple Pay + if ent.apple_pay { + extra_entries.push_str( + " com.apple.developer.in-app-payments\n \n merchant.*\n \n" + ); + } + + // HealthKit + if ent.healthkit { + extra_entries + .push_str(" com.apple.developer.healthkit\n \n"); + } + + // HomeKit + if ent.homekit { + extra_entries + .push_str(" com.apple.developer.homekit\n \n"); + } + + // Additional entitlements from the flat map + for (key, value) in &ent.additional { + extra_entries.push_str(&format!( + " {key}\n {}\n", + value_to_plist_xml(value, 1) + )); + } + + // Raw entitlements XML + if let Some(raw) = &self.config.ios.raw.entitlements { + extra_entries.push_str(raw); + extra_entries.push('\n'); + } + } + BundleFormat::MacOS => { + let ent = &self.config.macos.entitlements; + + // App Sandbox + if let Some(v) = ent.app_sandbox { + extra_entries.push_str(&format!( + " com.apple.security.app-sandbox\n <{v}/>\n" + )); + } + + // File access + if let Some(true) = ent.files_user_selected { + extra_entries.push_str( + " com.apple.security.files.user-selected.read-write\n \n" + ); + } + if let Some(true) = ent.files_user_selected_readonly { + extra_entries.push_str( + " com.apple.security.files.user-selected.read-only\n \n" + ); + } + + // Network + if let Some(true) = ent.network_client { + extra_entries.push_str( + " com.apple.security.network.client\n \n", + ); + } + if let Some(true) = ent.network_server { + extra_entries.push_str( + " com.apple.security.network.server\n \n", + ); + } + + // Device access + if let Some(true) = ent.camera { + extra_entries + .push_str(" com.apple.security.device.camera\n \n"); + } + if let Some(true) = ent.microphone { + extra_entries.push_str( + " com.apple.security.device.microphone\n \n", + ); + } + if let Some(true) = ent.usb { + extra_entries + .push_str(" com.apple.security.device.usb\n \n"); + } + if let Some(true) = ent.bluetooth { + extra_entries.push_str( + " com.apple.security.device.bluetooth\n \n", + ); + } + if let Some(true) = ent.print { + extra_entries + .push_str(" com.apple.security.print\n \n"); + } + + // Personal information + if let Some(true) = ent.location { + extra_entries.push_str( + " com.apple.security.personal-information.location\n \n" + ); + } + if let Some(true) = ent.addressbook { + extra_entries.push_str( + " com.apple.security.personal-information.addressbook\n \n" + ); + } + if let Some(true) = ent.calendars { + extra_entries.push_str( + " com.apple.security.personal-information.calendars\n \n" + ); + } + + // Runtime exceptions + if let Some(true) = ent.disable_library_validation { + extra_entries.push_str( + " com.apple.security.cs.disable-library-validation\n \n" + ); + } + if let Some(true) = ent.allow_jit { + extra_entries + .push_str(" com.apple.security.cs.allow-jit\n \n"); + } + if let Some(true) = ent.allow_unsigned_executable_memory { + extra_entries.push_str( + " com.apple.security.cs.allow-unsigned-executable-memory\n \n" + ); + } + + // Additional entitlements from the flat map + for (key, value) in &ent.additional { + extra_entries.push_str(&format!( + " {key}\n {}\n", + value_to_plist_xml(value, 1) + )); + } + + // Raw entitlements XML + if let Some(raw) = &self.config.macos.raw.entitlements { + extra_entries.push_str(raw); + extra_entries.push('\n'); + } + } + _ => {} + } + + if extra_entries.is_empty() { + return Ok(base_xml); + } + + // Insert before closing + if let Some(pos) = base_xml.rfind("") { + let mut enriched = base_xml[..pos].to_string(); + enriched.push_str(&extra_entries); + enriched.push_str(&base_xml[pos..]); + Ok(enriched) + } else { + tracing::warn!("Could not find in entitlements XML to inject config entries"); + Ok(base_xml) + } + } + async fn auto_provision_entitlements(bundle_id: &str) -> Result<(String, PathBuf)> { const CODESIGN_ERROR: &str = r#"This is likely because you haven't - Created a provisioning profile before @@ -6019,4 +7057,92 @@ We checked the folders: Ok(()) } + + /// Log the build duration and some metadata about the build, saving a telemetry event. + fn record_build_duration(&self, time_start: SystemTime, ctx: &BuildContext) { + // Calculate some final metadata for logging + let time_taken = SystemTime::now() + .duration_since(time_start) + .map(|d| d.as_millis()) + .unwrap_or_default(); + + tracing::debug!( + telemetry = %serde_json::json!({ + "event": "build_and_bundle_complete", + "time_taken": time_taken, + "mode": match ctx.mode { + BuildMode::Base { .. } => "base", + BuildMode::Fat => "fat", + BuildMode::Thin { .. } => "thin", + }, + "blah": 123, + "triple": self.triple.to_string(), + "format": self.bundle.to_string(), + "num_dependencies": self.workspace.krates.len(), + }), + "Build completed in {time_taken}ms", + ); + } +} + +/// Generate plist XML entries from a HashMap of key-value pairs +/// +/// Converts a HashMap like `{ "UIBackgroundModes" = ["location", "fetch"] }` to plist XML: +/// ```xml +/// UIBackgroundModes +/// +/// location +/// fetch +/// +/// ``` +fn generate_plist_entries(plist: &std::collections::HashMap) -> String { + let mut output = String::new(); + + for (key, value) in plist { + output.push_str(&format!("\t{}\n", key)); + output.push_str(&value_to_plist_xml(value, 1)); + } + + output +} + +/// Convert a serde_json::Value to plist XML format +fn value_to_plist_xml(value: &serde_json::Value, indent: usize) -> String { + let tabs = "\t".repeat(indent); + + match value { + serde_json::Value::String(s) => format!("{}{}\n", tabs, s), + serde_json::Value::Bool(b) => { + if *b { + format!("{}\n", tabs) + } else { + format!("{}\n", tabs) + } + } + serde_json::Value::Number(n) => { + if n.is_i64() { + format!("{}{}\n", tabs, n) + } else { + format!("{}{}\n", tabs, n) + } + } + serde_json::Value::Array(arr) => { + let mut output = format!("{}\n", tabs); + for item in arr { + output.push_str(&value_to_plist_xml(item, indent + 1)); + } + output.push_str(&format!("{}\n", tabs)); + output + } + serde_json::Value::Object(obj) => { + let mut output = format!("{}\n", tabs); + for (k, v) in obj { + output.push_str(&format!("{}\t{}\n", tabs, k)); + output.push_str(&value_to_plist_xml(v, indent + 1)); + } + output.push_str(&format!("{}\n", tabs)); + output + } + serde_json::Value::Null => String::new(), + } } diff --git a/packages/cli/src/cli/component.rs b/packages/cli/src/cli/component.rs index 1ac537d34..f6b5b40a3 100644 --- a/packages/cli/src/cli/component.rs +++ b/packages/cli/src/cli/component.rs @@ -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)] diff --git a/packages/cli/src/cli/config.rs b/packages/cli/src/cli/config.rs index 7a51f9a84..18d9d909e 100644 --- a/packages/cli/src/cli/config.rs +++ b/packages/cli/src/cli/config.rs @@ -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, + }, } #[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) diff --git a/packages/cli/src/config/app.rs b/packages/cli/src/config/app.rs index 80ec2041c..b7248fda0 100644 --- a/packages/cli/src/config/app.rs +++ b/packages/cli/src/config/app.rs @@ -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)] diff --git a/packages/cli/src/config/bundle.rs b/packages/cli/src/config/bundle.rs index 2a17b3af9..ac27b54e0 100644 --- a/packages/cli/src/config/bundle.rs +++ b/packages/cli/src/config/bundle.rs @@ -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, @@ -32,7 +33,7 @@ pub(crate) struct BundleConfig { pub(crate) android: Option, } -#[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, } -#[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)>, @@ -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")] pub upgrade_code: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] pub(crate) struct MacOsSettings { #[serde(default)] pub(crate) bundle_version: Option, @@ -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, @@ -215,7 +217,7 @@ pub(crate) struct WindowsSettings { pub sign_command: Option, } -#[derive(Default, Debug, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)] pub(crate) struct NsisSettings { #[serde(default)] pub(crate) template: Option, @@ -246,7 +248,7 @@ pub(crate) struct NsisSettings { pub minimum_webview2_version: Option, } -#[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, diff --git a/packages/cli/src/config/component.rs b/packages/cli/src/config/component.rs index 413f86bc4..2416cfbc3 100644 --- a/packages/cli/src/config/component.rs +++ b/packages/cli/src/config/component.rs @@ -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)] diff --git a/packages/cli/src/config/dioxus_config.rs b/packages/cli/src/config/dioxus_config.rs index d96afbb54..eaf1d8bc4 100644 --- a/packages/cli/src/config/dioxus_config.rs +++ b/packages/cli/src/config/dioxus_config.rs @@ -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 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(), } } } diff --git a/packages/cli/src/config/inline_config.rs b/packages/cli/src/config/inline_config.rs new file mode 100644 index 000000000..19b1ed60f --- /dev/null +++ b/packages/cli/src/config/inline_config.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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")); + } +} diff --git a/packages/cli/src/config/manifest.rs b/packages/cli/src/config/manifest.rs new file mode 100644 index 000000000..1eefbe57b --- /dev/null +++ b/packages/cli/src/config/manifest.rs @@ -0,0 +1,1796 @@ +//! Unified manifest configuration for cross-platform app packaging. +//! +//! This module provides configuration structs for permissions and platform-specific +//! manifest customization. Permissions declared here are automatically mapped to +//! platform-specific identifiers (AndroidManifest.xml, Info.plist, etc.) +//! +//! ## JSON Schema Generation +//! +//! Generate a JSON schema for IDE autocomplete: +//! ```bash +//! dx config --schema > dioxus-schema.json +//! ``` + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +// ============================================================================ +// Unified Permissions +// ============================================================================ + +/// Unified permission configuration that maps to platform-specific identifiers. +/// +/// Example: +/// ```toml +/// [permissions] +/// location = { precision = "fine", description = "Track your runs" } +/// camera = { description = "Take photos for your profile" } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct PermissionsConfig { + /// Location permission with precision level. + /// Maps to ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION on Android, + /// NSLocationWhenInUseUsageDescription on iOS/macOS. + #[serde(default)] + pub location: Option, + + /// Camera access permission. + #[serde(default)] + pub camera: Option, + + /// Microphone access permission. + #[serde(default)] + pub microphone: Option, + + /// Push notifications permission. + #[serde(default)] + pub notifications: Option, + + /// Photo library access. + #[serde(default)] + pub photos: Option, + + /// Bluetooth connectivity. + #[serde(default)] + pub bluetooth: Option, + + /// Background location updates. + #[serde(default, rename = "background-location")] + pub background_location: Option, + + /// Contacts access. + #[serde(default)] + pub contacts: Option, + + /// Calendar access. + #[serde(default)] + pub calendar: Option, + + /// Biometric authentication (Face ID, fingerprint). + #[serde(default)] + pub biometrics: Option, + + /// NFC access. + #[serde(default)] + pub nfc: Option, + + /// Motion and fitness data. + #[serde(default)] + pub motion: Option, + + /// Health data access. + #[serde(default)] + pub health: Option, + + /// Speech recognition. + #[serde(default)] + pub speech: Option, + + /// Media library access. + #[serde(default, rename = "media-library")] + pub media_library: Option, + + /// Siri integration (iOS only). + #[serde(default)] + pub siri: Option, + + /// HomeKit integration (iOS only). + #[serde(default)] + pub homekit: Option, + + /// Local network access. + #[serde(default, rename = "local-network")] + pub local_network: Option, + + /// Nearby Wi-Fi devices (Android). + #[serde(default, rename = "nearby-wifi")] + pub nearby_wifi: Option, +} + +/// Simple permission with just a description. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SimplePermission { + /// User-facing description shown in permission dialogs. + pub description: String, +} + +// ============================================================================ +// Unified Deep Linking +// ============================================================================ + +/// Unified deep linking configuration. +/// +/// This provides a cross-platform interface for URL schemes and universal/app links. +/// Platform-specific overrides can be configured in `[ios]` and `[android]` sections. +/// +/// Example: +/// ```toml +/// [deep_links] +/// schemes = ["myapp", "com.example.myapp"] +/// hosts = ["example.com", "*.example.com"] +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct DeepLinkConfig { + /// Custom URL schemes (e.g., "myapp" for myapp://path). + /// Maps to CFBundleURLSchemes on iOS/macOS and intent-filter on Android. + #[serde(default)] + pub schemes: Vec, + + /// Universal link / App link hosts (e.g., "example.com"). + /// Maps to Associated Domains on iOS and App Links on Android. + /// Supports wildcards like "*.example.com". + #[serde(default)] + pub hosts: Vec, + + /// Path patterns for universal/app links (e.g., "/app/*", "/share/*"). + /// If empty, all paths are matched. + #[serde(default)] + pub paths: Vec, +} + +// ============================================================================ +// Unified Background Modes +// ============================================================================ + +/// Unified background execution configuration. +/// +/// This provides a cross-platform interface for background capabilities. +/// Platform-specific overrides can be configured in `[ios]` and `[android]` sections. +/// +/// Example: +/// ```toml +/// [background] +/// location = true +/// audio = true +/// fetch = true +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct BackgroundConfig { + /// Background location updates. + /// iOS: UIBackgroundModes "location" + /// Android: ACCESS_BACKGROUND_LOCATION permission + #[serde(default)] + pub location: bool, + + /// Background audio playback. + /// iOS: UIBackgroundModes "audio" + /// Android: FOREGROUND_SERVICE_MEDIA_PLAYBACK + #[serde(default)] + pub audio: bool, + + /// Background data fetch. + /// iOS: UIBackgroundModes "fetch" + /// Android: WorkManager or foreground service + #[serde(default)] + pub fetch: bool, + + /// Remote push notifications. + /// iOS: UIBackgroundModes "remote-notification" + /// Android: Firebase Cloud Messaging + #[serde(default, rename = "remote-notifications")] + pub remote_notifications: bool, + + /// VoIP calls. + /// iOS: UIBackgroundModes "voip" + /// Android: FOREGROUND_SERVICE_PHONE_CALL + #[serde(default)] + pub voip: bool, + + /// Bluetooth LE accessories. + /// iOS: UIBackgroundModes "bluetooth-central" and "bluetooth-peripheral" + /// Android: FOREGROUND_SERVICE_CONNECTED_DEVICE + #[serde(default)] + pub bluetooth: bool, + + /// External accessory communication. + /// iOS: UIBackgroundModes "external-accessory" + #[serde(default, rename = "external-accessory")] + pub external_accessory: bool, + + /// Background processing tasks. + /// iOS: UIBackgroundModes "processing" + /// Android: WorkManager + #[serde(default)] + pub processing: bool, +} + +/// Location permission with precision control. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct LocationPermission { + /// Precision level: "fine" (GPS) or "coarse" (network-based). + #[serde(default)] + pub precision: LocationPrecision, + + /// User-facing description shown in permission dialogs. + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)] +pub enum LocationPrecision { + #[default] + #[serde(rename = "fine")] + Fine, + #[serde(rename = "coarse")] + Coarse, +} + +/// Storage permission with access level control. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct StoragePermission { + /// Access level: "read", "write", or "read-write". + #[serde(default)] + pub access: StorageAccess, + + /// User-facing description shown in permission dialogs. + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)] +pub enum StorageAccess { + #[serde(rename = "read")] + Read, + #[serde(rename = "write")] + Write, + #[default] + #[serde(rename = "read-write")] + ReadWrite, +} + +/// Raw platform permission entry. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RawPermission { + pub description: String, +} + +// ============================================================================ +// iOS Configuration +// ============================================================================ + +/// iOS-specific configuration. +/// +/// Example: +/// ```toml +/// [ios] +/// deployment_target = "15.0" +/// identifier = "com.example.myapp.ios" # Override bundle.identifier for iOS +/// +/// [ios.entitlements] +/// app-groups = ["group.com.example.app"] +/// +/// [ios.plist] +/// UIBackgroundModes = ["location", "fetch"] +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct IosConfig { + // === Bundle settings (override [bundle] section) === + /// The app's identifier (e.g., "com.example.myapp"). + /// Overrides `bundle.identifier` for iOS builds. + #[serde(default)] + pub identifier: Option, + + /// The app's publisher. + /// Overrides `bundle.publisher` for iOS builds. + #[serde(default)] + pub publisher: Option, + + /// Icons for the app. + /// Overrides `bundle.icon` for iOS builds. + #[serde(default)] + pub icon: Option>, + + /// Additional resources to bundle. + /// Overrides `bundle.resources` for iOS builds. + #[serde(default)] + pub resources: Option>, + + /// Copyright notice. + /// Overrides `bundle.copyright` for iOS builds. + #[serde(default)] + pub copyright: Option, + + /// App category. + /// Overrides `bundle.category` for iOS builds. + #[serde(default)] + pub category: Option, + + /// Short description. + /// Overrides `bundle.short_description` for iOS builds. + #[serde(default)] + pub short_description: Option, + + /// Long description. + /// Overrides `bundle.long_description` for iOS builds. + #[serde(default)] + pub long_description: Option, + + // === iOS-specific settings === + /// Minimum iOS deployment target (e.g., "15.0"). + #[serde(default)] + pub deployment_target: Option, + + /// Path to custom Info.plist to merge with generated. + #[serde(default)] + pub info_plist: Option, + + /// iOS entitlements configuration. + #[serde(default)] + pub entitlements: IosEntitlements, + + /// Additional Info.plist keys to merge. + #[serde(default)] + pub plist: HashMap, + + /// Raw XML injection points. + #[serde(default)] + pub raw: IosRawConfig, + + // === Platform-specific overrides (extend unified config) === + /// Additional URL schemes beyond unified `[deep_links]`.schemes. + /// These are merged with the unified schemes. + #[serde(default)] + pub url_schemes: Vec, + + /// Additional background modes beyond unified `[background]`. + /// Valid values: "audio", "location", "voip", "fetch", "remote-notification", + /// "newsstand-content", "external-accessory", "bluetooth-central", + /// "bluetooth-peripheral", "processing" + #[serde(default)] + pub background_modes: Vec, + + /// Document types the app can open. + #[serde(default)] + pub document_types: Vec, + + /// Exported type identifiers (custom UTIs). + #[serde(default)] + pub exported_type_identifiers: Vec, + + /// Imported type identifiers. + #[serde(default)] + pub imported_type_identifiers: Vec, + + /// Widget extensions to compile and bundle. + /// Each entry defines a Swift-based widget extension (.appex) that will be + /// compiled and installed into the app's PlugIns folder. + #[serde(default)] + pub widget_extensions: Vec, +} + +/// Configuration for an iOS Widget Extension. +/// +/// Widget extensions are compiled as Swift executables and bundled as .appex +/// bundles in the app's PlugIns folder. +/// +/// Example in Dioxus.toml: +/// ```toml +/// [[ios.widget_extensions]] +/// source = "src/ios/widget" +/// display_name = "Location Widget" +/// bundle_id_suffix = "location-widget" +/// deployment_target = "16.2" +/// module_name = "GeolocationPlugin" +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WidgetExtensionConfig { + /// Path to the Swift package source directory (relative to project root). + pub source: String, + + /// Display name for the widget (shown in system UI). + pub display_name: String, + + /// Bundle ID suffix appended to the app's bundle identifier. + /// For example, if the app is "com.example.app" and suffix is "location-widget", + /// the widget bundle ID will be "com.example.app.location-widget". + pub bundle_id_suffix: String, + + /// Minimum deployment target (e.g., "16.2"). + /// Defaults to the app's iOS deployment target if not specified. + #[serde(default)] + pub deployment_target: Option, + + /// 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. + pub module_name: String, +} + +/// iOS document type declaration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct IosDocumentType { + /// Document type name. + pub name: String, + + /// File extensions (e.g., ["txt", "md"]). + #[serde(default)] + pub extensions: Vec, + + /// MIME types. + #[serde(default)] + pub mime_types: Vec, + + /// UTI types. + #[serde(default)] + pub types: Vec, + + /// Icon file name. + #[serde(default)] + pub icon: Option, + + /// Role: "Editor", "Viewer", "Shell", or "None". + #[serde(default)] + pub role: Option, +} + +/// iOS Uniform Type Identifier declaration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct IosTypeIdentifier { + /// UTI identifier (e.g., "com.example.myformat"). + pub identifier: String, + + /// Human-readable description. + #[serde(default)] + pub description: Option, + + /// Conforms to these UTIs. + #[serde(default)] + pub conforms_to: Vec, + + /// File extensions. + #[serde(default)] + pub extensions: Vec, + + /// MIME types. + #[serde(default)] + pub mime_types: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct IosEntitlements { + /// App groups for shared data. + #[serde(default, rename = "app-groups")] + pub app_groups: Vec, + + /// Push notification environment: "development" or "production". + #[serde(default, rename = "aps-environment")] + pub aps_environment: Option, + + /// Associated domains for universal links. + #[serde(default, rename = "associated-domains")] + pub associated_domains: Vec, + + /// Enable iCloud container support. + #[serde(default)] + pub icloud: bool, + + /// Keychain access groups. + #[serde(default, rename = "keychain-access-groups")] + pub keychain_access_groups: Vec, + + /// Enable Apple Pay. + #[serde(default, rename = "apple-pay")] + pub apple_pay: bool, + + /// Enable HealthKit. + #[serde(default)] + pub healthkit: bool, + + /// Enable HomeKit. + #[serde(default)] + pub homekit: bool, + + /// Additional entitlements. + #[serde(flatten)] + pub additional: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct IosRawConfig { + /// Raw XML to inject into Info.plist. + #[serde(default)] + pub info_plist: Option, + + /// Raw XML to inject into entitlements.plist. + #[serde(default)] + pub entitlements: Option, +} + +// ============================================================================ +// Android Configuration +// ============================================================================ + +/// Android-specific configuration. +/// +/// Example: +/// ```toml +/// [android] +/// min_sdk = 24 +/// target_sdk = 34 +/// identifier = "com.example.myapp.android" # Override bundle.identifier for Android +/// features = ["android.hardware.location.gps"] +/// +/// # Android signing configuration (previously in [bundle.android]) +/// [android.signing] +/// jks_file = "keystore.jks" +/// jks_password = "password" +/// key_alias = "mykey" +/// key_password = "keypassword" +/// +/// [android.permissions] +/// "android.permission.FOREGROUND_SERVICE" = { description = "Background service" } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct AndroidConfig { + // === Bundle settings (override [bundle] section) === + /// The app's identifier (e.g., "com.example.myapp"). + /// Overrides `bundle.identifier` for Android builds. + #[serde(default)] + pub identifier: Option, + + /// The app's publisher. + /// Overrides `bundle.publisher` for Android builds. + #[serde(default)] + pub publisher: Option, + + /// Icons for the app. + /// Overrides `bundle.icon` for Android builds. + #[serde(default)] + pub icon: Option>, + + /// Additional resources to bundle. + /// Overrides `bundle.resources` for Android builds. + #[serde(default)] + pub resources: Option>, + + /// Copyright notice. + /// Overrides `bundle.copyright` for Android builds. + #[serde(default)] + pub copyright: Option, + + /// App category. + /// Overrides `bundle.category` for Android builds. + #[serde(default)] + pub category: Option, + + /// Short description. + /// Overrides `bundle.short_description` for Android builds. + #[serde(default)] + pub short_description: Option, + + /// Long description. + /// Overrides `bundle.long_description` for Android builds. + #[serde(default)] + pub long_description: Option, + + // === Android signing settings (previously in bundle.android) === + /// Android signing configuration for release builds. + /// This replaces the deprecated `[bundle.android]` section. + #[serde(default)] + pub signing: Option, + + // === Android-specific settings === + /// Minimum SDK version. + #[serde(default)] + pub min_sdk: Option, + + /// Target SDK version. + #[serde(default)] + pub target_sdk: Option, + + /// Compile SDK version. + #[serde(default)] + pub compile_sdk: Option, + + /// Hardware/software features required. + #[serde(default)] + pub features: Vec, + + /// Path to custom AndroidManifest.xml to merge. + #[serde(default)] + pub manifest: Option, + + /// Gradle dependencies to add. + #[serde(default)] + pub gradle_dependencies: Vec, + + /// Gradle plugins to apply. + #[serde(default)] + pub gradle_plugins: Vec, + + /// ProGuard rule files. + #[serde(default)] + pub proguard_rules: Vec, + + /// Additional Android permissions not in unified config. + #[serde(default)] + pub permissions: HashMap, + + /// Raw XML injection points. + #[serde(default)] + pub raw: AndroidRawConfig, + + /// Application-level config. + #[serde(default)] + pub application: AndroidApplicationConfig, + + // === Platform-specific overrides (extend unified config) === + /// Additional URL schemes beyond unified `[deep_links]`.schemes. + /// These are merged with the unified schemes. + #[serde(default)] + pub url_schemes: Vec, + + /// Intent filters for deep linking. + /// These extend the unified `[deep_links]` configuration with Android-specific options. + #[serde(default)] + pub intent_filters: Vec, + + /// Foreground service types for background operations. + /// Valid values: "camera", "connectedDevice", "dataSync", "health", "location", + /// "mediaPlayback", "mediaProjection", "microphone", "phoneCall", "remoteMessaging", + /// "shortService", "specialUse", "systemExempted" + #[serde(default)] + pub foreground_service_types: Vec, + + /// Queries for package visibility (required for Android 11+). + /// Specify packages or intents your app needs to query. + #[serde(default)] + pub queries: AndroidQueries, +} + +/// Android signing configuration for release builds. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AndroidSigningConfig { + /// Path to the Java keystore file. + pub jks_file: PathBuf, + + /// Password for the keystore. + pub jks_password: String, + + /// Alias of the key in the keystore. + pub key_alias: String, + + /// Password for the key. + pub key_password: String, +} + +/// Android intent filter for deep linking. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AndroidIntentFilter { + /// Actions (e.g., "android.intent.action.VIEW"). + #[serde(default)] + pub actions: Vec, + + /// Categories (e.g., "android.intent.category.DEFAULT", "android.intent.category.BROWSABLE"). + #[serde(default)] + pub categories: Vec, + + /// Data specifications. + #[serde(default)] + pub data: Vec, + + /// Auto-verify for App Links (requires HTTPS and assetlinks.json). + #[serde(default)] + pub auto_verify: bool, +} + +/// Android intent data specification. +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct AndroidIntentData { + /// URL scheme (e.g., "https", "myapp"). + #[serde(default)] + pub scheme: Option, + + /// Host (e.g., "example.com"). + #[serde(default)] + pub host: Option, + + /// Port number. + #[serde(default)] + pub port: Option, + + /// Path (exact match). + #[serde(default)] + pub path: Option, + + /// Path prefix. + #[serde(default)] + pub path_prefix: Option, + + /// Path pattern (with wildcards). + #[serde(default)] + pub path_pattern: Option, + + /// MIME type. + #[serde(default)] + pub mime_type: Option, +} + +/// Android package visibility queries. +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct AndroidQueries { + /// Package names to query. + #[serde(default)] + pub packages: Vec, + + /// Intent actions to query. + #[serde(default)] + pub intents: Vec, +} + +/// Android query intent specification. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AndroidQueryIntent { + /// Action (e.g., "android.intent.action.SEND"). + pub action: String, + + /// Data scheme (e.g., "mailto"). + #[serde(default)] + pub scheme: Option, + + /// MIME type (e.g., "text/plain"). + #[serde(default)] + pub mime_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct AndroidRawConfig { + /// Raw XML to inject into manifest (after permissions). + #[serde(default)] + pub manifest: Option, + + /// Raw attributes for `` element. + #[serde(default)] + pub application_attrs: Option, + + /// Raw XML inside `` element. + #[serde(default)] + pub application: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct AndroidApplicationConfig { + /// Enable cleartext (HTTP) traffic. + #[serde(default)] + pub uses_cleartext_traffic: Option, + + /// Application theme. + #[serde(default)] + pub theme: Option, + + /// RTL layout support. + #[serde(default)] + pub supports_rtl: Option, + + /// Enable large heap. + #[serde(default)] + pub large_heap: Option, +} + +// ============================================================================ +// macOS Configuration +// ============================================================================ + +/// macOS-specific configuration. +/// +/// Example: +/// ```toml +/// [macos] +/// minimum_system_version = "11.0" +/// identifier = "com.example.myapp.macos" # Override bundle.identifier for macOS +/// +/// # macOS signing (previously in [bundle.macos]) +/// signing_identity = "Developer ID Application: My Company" +/// provider_short_name = "MYCOMPANY" +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct MacosConfig { + // === Bundle settings (override [bundle] section) === + /// The app's identifier (e.g., "com.example.myapp"). + /// Overrides `bundle.identifier` for macOS builds. + #[serde(default)] + pub identifier: Option, + + /// The app's publisher. + /// Overrides `bundle.publisher` for macOS builds. + #[serde(default)] + pub publisher: Option, + + /// Icons for the app. + /// Overrides `bundle.icon` for macOS builds. + #[serde(default)] + pub icon: Option>, + + /// Additional resources to bundle. + /// Overrides `bundle.resources` for macOS builds. + #[serde(default)] + pub resources: Option>, + + /// Copyright notice. + /// Overrides `bundle.copyright` for macOS builds. + #[serde(default)] + pub copyright: Option, + + /// Short description. + /// Overrides `bundle.short_description` for macOS builds. + #[serde(default)] + pub short_description: Option, + + /// Long description. + /// Overrides `bundle.long_description` for macOS builds. + #[serde(default)] + pub long_description: Option, + + // === macOS bundle settings (previously in bundle.macos) === + /// The bundle version string (CFBundleVersion). + #[serde(default)] + pub bundle_version: Option, + + /// The bundle short version string (CFBundleShortVersionString). + #[serde(default)] + pub bundle_name: Option, + + /// The signing identity to use for code signing. + /// E.g., "Developer ID Application: My Company (TEAMID)" + #[serde(default)] + pub signing_identity: Option, + + /// The provider short name for notarization. + #[serde(default)] + pub provider_short_name: Option, + + /// Path to custom entitlements file for code signing. + /// This overrides the generated entitlements. + #[serde(default)] + pub entitlements_file: Option, + + /// Exception domain for App Transport Security. + #[serde(default)] + pub exception_domain: Option, + + /// License file to include in DMG. + #[serde(default)] + pub license: Option, + + /// Preserve the hardened runtime version flag. + /// Setting this to false is useful when using an ad-hoc signature. + #[serde(default)] + pub hardened_runtime: Option, + + /// Additional files to include in the app bundle. + /// Maps the path in the Contents directory to the source file path. + #[serde(default)] + pub files: HashMap, + + // === macOS-specific settings === + /// Minimum macOS version (e.g., "11.0"). + #[serde(default)] + pub minimum_system_version: Option, + + /// Path to custom Info.plist. + #[serde(default)] + pub info_plist: Option, + + /// Frameworks to embed. + #[serde(default)] + pub frameworks: Vec, + + /// macOS entitlements. + #[serde(default)] + pub entitlements: MacosEntitlements, + + /// Additional Info.plist keys. + #[serde(default)] + pub plist: HashMap, + + /// Raw injection points. + #[serde(default)] + pub raw: MacosRawConfig, + + // === Platform-specific overrides (extend unified config) === + /// Additional URL schemes beyond unified `[deep_links]`.schemes. + /// These are merged with the unified schemes. + #[serde(default)] + pub url_schemes: Vec, + + /// Document types the app can open (uses same format as iOS). + #[serde(default)] + pub document_types: Vec, + + /// Exported type identifiers (custom UTIs). + #[serde(default)] + pub exported_type_identifiers: Vec, + + /// Imported type identifiers. + #[serde(default)] + pub imported_type_identifiers: Vec, + + /// App category for the Mac App Store. + /// E.g., "public.app-category.productivity" + #[serde(default)] + pub category: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct MacosEntitlements { + /// Enable App Sandbox. + #[serde(default, rename = "app-sandbox")] + pub app_sandbox: Option, + + /// User-selected file access (read-write). + #[serde(default, rename = "files-user-selected")] + pub files_user_selected: Option, + + /// User-selected file access (read-only). + #[serde(default, rename = "files-user-selected-readonly")] + pub files_user_selected_readonly: Option, + + /// Outgoing network connections. + #[serde(default, rename = "network-client")] + pub network_client: Option, + + /// Incoming network connections. + #[serde(default, rename = "network-server")] + pub network_server: Option, + + /// Camera access. + #[serde(default)] + pub camera: Option, + + /// Microphone access. + #[serde(default)] + pub microphone: Option, + + /// USB access. + #[serde(default)] + pub usb: Option, + + /// Bluetooth access. + #[serde(default)] + pub bluetooth: Option, + + /// Printing. + #[serde(default)] + pub print: Option, + + /// Location services. + #[serde(default)] + pub location: Option, + + /// Address book access. + #[serde(default)] + pub addressbook: Option, + + /// Calendars access. + #[serde(default)] + pub calendars: Option, + + /// Disable library validation. + #[serde(default, rename = "disable-library-validation")] + pub disable_library_validation: Option, + + /// Allow JIT. + #[serde(default, rename = "allow-jit")] + pub allow_jit: Option, + + /// Allow unsigned executable memory. + #[serde(default, rename = "allow-unsigned-executable-memory")] + pub allow_unsigned_executable_memory: Option, + + /// Additional entitlements. + #[serde(flatten)] + pub additional: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct MacosRawConfig { + /// Raw XML to inject into Info.plist. + #[serde(default)] + pub info_plist: Option, + + /// Raw XML to inject into entitlements.plist. + #[serde(default)] + pub entitlements: Option, +} + +// ============================================================================ +// Windows Configuration +// ============================================================================ + +/// Windows-specific configuration. +/// +/// Example: +/// ```toml +/// [windows] +/// identifier = "com.example.myapp.windows" # Override bundle.identifier for Windows +/// +/// # Windows installer settings (previously in [bundle.windows]) +/// [windows.nsis] +/// install_mode = "PerMachine" +/// +/// [windows.wix] +/// language = [["en-US", null]] +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct WindowsConfig { + // === Bundle settings (override [bundle] section) === + /// The app's identifier (e.g., "com.example.myapp"). + /// Overrides `bundle.identifier` for Windows builds. + #[serde(default)] + pub identifier: Option, + + /// The app's publisher. + /// Overrides `bundle.publisher` for Windows builds. + #[serde(default)] + pub publisher: Option, + + /// Icons for the app. + /// Overrides `bundle.icon` for Windows builds. + #[serde(default)] + pub icon: Option>, + + /// Additional resources to bundle. + /// Overrides `bundle.resources` for Windows builds. + #[serde(default)] + pub resources: Option>, + + /// Copyright notice. + /// Overrides `bundle.copyright` for Windows builds. + #[serde(default)] + pub copyright: Option, + + /// App category. + /// Overrides `bundle.category` for Windows builds. + #[serde(default)] + pub category: Option, + + /// Short description. + /// Overrides `bundle.short_description` for Windows builds. + #[serde(default)] + pub short_description: Option, + + /// Long description. + /// Overrides `bundle.long_description` for Windows builds. + #[serde(default)] + pub long_description: Option, + + // === Windows bundle settings (previously in bundle.windows) === + /// Digest algorithm for code signing. + #[serde(default)] + pub digest_algorithm: Option, + + /// Certificate thumbprint for code signing. + #[serde(default)] + pub certificate_thumbprint: Option, + + /// Timestamp server URL for code signing. + #[serde(default)] + pub timestamp_url: Option, + + /// Use TSP (RFC 3161) timestamp. + #[serde(default)] + pub tsp: Option, + + /// WiX installer settings. + #[serde(default)] + pub wix: Option, + + /// NSIS installer settings. + #[serde(default)] + pub nsis: Option, + + /// Path to custom Windows icon. + #[serde(default)] + pub icon_path: Option, + + /// WebView2 installation mode. + #[serde(default)] + pub webview_install_mode: Option, + + /// Allow downgrades when installing. + #[serde(default)] + pub allow_downgrades: Option, + + /// Custom sign command. + #[serde(default)] + pub sign_command: Option, + + // === Windows-specific settings === + /// UWP/MSIX capabilities. + #[serde(default)] + pub capabilities: Vec, + + /// Restricted capabilities. + #[serde(default)] + pub restricted_capabilities: Vec, + + /// Device capabilities. + #[serde(default)] + pub device_capabilities: Vec, +} + +/// WiX installer settings. +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct WindowsWixSettings { + /// Languages and their locale paths. + #[serde(default)] + pub language: Vec<(String, Option)>, + + /// Path to custom WiX template. + #[serde(default)] + pub template: Option, + + /// WiX fragment files to include. + #[serde(default)] + pub fragment_paths: Vec, + + /// Component group references. + #[serde(default)] + pub component_group_refs: Vec, + + /// Component references. + #[serde(default)] + pub component_refs: Vec, + + /// Feature group references. + #[serde(default)] + pub feature_group_refs: Vec, + + /// Feature references. + #[serde(default)] + pub feature_refs: Vec, + + /// Merge module references. + #[serde(default)] + pub merge_refs: Vec, + + /// Skip WebView2 installation. + #[serde(default)] + pub skip_webview_install: Option, + + /// License file path. + #[serde(default)] + pub license: Option, + + /// Enable elevated update task. + #[serde(default)] + pub enable_elevated_update_task: Option, + + /// Banner image path. + #[serde(default)] + pub banner_path: Option, + + /// Dialog image path. + #[serde(default)] + pub dialog_image_path: Option, + + /// FIPS compliant mode. + #[serde(default)] + pub fips_compliant: Option, + + /// MSI version string. + #[serde(default)] + pub version: Option, + + /// MSI upgrade code (GUID). + #[serde(default)] + #[schemars(with = "Option")] + pub upgrade_code: Option, +} + +/// NSIS installer settings. +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct WindowsNsisSettings { + /// Path to custom NSIS template. + #[serde(default)] + pub template: Option, + + /// License file path. + #[serde(default)] + pub license: Option, + + /// Header image path. + #[serde(default)] + pub header_image: Option, + + /// Sidebar image path. + #[serde(default)] + pub sidebar_image: Option, + + /// Installer icon path. + #[serde(default)] + pub installer_icon: Option, + + /// Installation mode: "CurrentUser", "PerMachine", or "Both". + #[serde(default)] + pub install_mode: Option, + + /// Languages to include. + #[serde(default)] + pub languages: Option>, + + /// Custom language files. + #[serde(default)] + pub custom_language_files: Option>, + + /// Display language selector. + #[serde(default)] + pub display_language_selector: Option, + + /// Start menu folder name. + #[serde(default)] + pub start_menu_folder: Option, + + /// Installer hooks script path. + #[serde(default)] + pub installer_hooks: Option, + + /// Minimum WebView2 version required. + #[serde(default)] + pub minimum_webview2_version: Option, +} + +/// WebView2 installation mode. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type")] +pub enum WindowsWebviewInstallMode { + /// Skip WebView2 installation. + Skip, + /// Download bootstrapper. + DownloadBootstrapper { + #[serde(default)] + silent: bool, + }, + /// Embed bootstrapper. + EmbedBootstrapper { + #[serde(default)] + silent: bool, + }, + /// Use offline installer. + OfflineInstaller { + #[serde(default)] + silent: bool, + }, + /// Use fixed runtime from path. + FixedRuntime { path: PathBuf }, +} + +/// Custom sign command for Windows code signing. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WindowsSignCommand { + /// The command to run. + pub cmd: String, + /// Command arguments. Use "%1" as placeholder for binary path. + pub args: Vec, +} + +// ============================================================================ +// Linux Configuration +// ============================================================================ + +/// Linux-specific configuration. +/// +/// Example: +/// ```toml +/// [linux] +/// identifier = "com.example.myapp.linux" # Override bundle.identifier for Linux +/// categories = ["Utility"] +/// +/// # Debian package settings (previously in [bundle.deb]) +/// [linux.deb] +/// depends = ["libwebkit2gtk-4.0-37"] +/// section = "utils" +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct LinuxConfig { + // === Bundle settings (override [bundle] section) === + /// The app's identifier (e.g., "com.example.myapp"). + /// Overrides `bundle.identifier` for Linux builds. + #[serde(default)] + pub identifier: Option, + + /// The app's publisher. + /// Overrides `bundle.publisher` for Linux builds. + #[serde(default)] + pub publisher: Option, + + /// Icons for the app. + /// Overrides `bundle.icon` for Linux builds. + #[serde(default)] + pub icon: Option>, + + /// Additional resources to bundle. + /// Overrides `bundle.resources` for Linux builds. + #[serde(default)] + pub resources: Option>, + + /// Copyright notice. + /// Overrides `bundle.copyright` for Linux builds. + #[serde(default)] + pub copyright: Option, + + /// App category. + /// Overrides `bundle.category` for Linux builds. + #[serde(default)] + pub category: Option, + + /// Short description. + /// Overrides `bundle.short_description` for Linux builds. + #[serde(default)] + pub short_description: Option, + + /// Long description. + /// Overrides `bundle.long_description` for Linux builds. + #[serde(default)] + pub long_description: Option, + + // === Debian package settings (previously in bundle.deb) === + /// Debian-specific package settings. + #[serde(default)] + pub deb: Option, + + // === Linux-specific settings === + /// Flatpak sandbox permissions. + #[serde(default)] + pub flatpak_permissions: Vec, + + /// D-Bus interfaces to access. + #[serde(default)] + pub dbus_access: Vec, + + /// Desktop entry categories. + #[serde(default)] + pub categories: Vec, + + /// Desktop entry keywords. + #[serde(default)] + pub keywords: Vec, + + /// MIME types the app can handle. + #[serde(default)] + pub mime_types: Vec, +} + +/// Debian package settings. +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] +pub struct LinuxDebSettings { + /// Package dependencies. + #[serde(default)] + pub depends: Option>, + + /// Recommended packages. + #[serde(default)] + pub recommends: Option>, + + /// Packages this provides. + #[serde(default)] + pub provides: Option>, + + /// Package conflicts. + #[serde(default)] + pub conflicts: Option>, + + /// Packages this replaces. + #[serde(default)] + pub replaces: Option>, + + /// Additional files to include. Maps package path to source path. + #[serde(default)] + pub files: HashMap, + + /// Path to custom desktop template. + #[serde(default)] + pub desktop_template: Option, + + /// Debian section (e.g., "utils", "web"). + #[serde(default)] + pub section: Option, + + /// Package priority ("required", "important", "standard", "optional", "extra"). + #[serde(default)] + pub priority: Option, + + /// Path to changelog file. + #[serde(default)] + pub changelog: Option, + + /// Pre-install script path. + #[serde(default)] + pub pre_install_script: Option, + + /// Post-install script path. + #[serde(default)] + pub post_install_script: Option, + + /// Pre-remove script path. + #[serde(default)] + pub pre_remove_script: Option, + + /// Post-remove script path. + #[serde(default)] + pub post_remove_script: Option, +} + +// ============================================================================ +// Schema Generation +// ============================================================================ + +/// Generate a JSON schema for the complete Dioxus.toml configuration. +/// +/// This can be used for IDE autocomplete when editing Dioxus.toml files. +/// The schema includes all configuration: application, web, bundle, permissions, +/// platform-specific settings, and more. +/// +/// Note: Default values are stripped and allOf wrappers simplified to prevent +/// stack overflow in some TOML LSP implementations (e.g., Taplo's WASM build). +pub fn generate_manifest_schema() -> schemars::schema::RootSchema { + let mut schema = schemars::schema_for!(super::DioxusConfig); + + // Simplify schema to prevent Taplo WASM LSP stack overflow. + // 1. Strip default values (large nested objects cause issues) + // 2. Simplify allOf wrappers around single $refs + simplify_schema(&mut schema.schema); + for def in schema.definitions.values_mut() { + if let schemars::schema::Schema::Object(obj) = def { + simplify_schema(obj); + } + } + + schema +} + +/// Recursively simplify a schema object for LSP compatibility. +/// - Removes default values (large nested objects cause stack overflow) +/// - Simplifies `allOf: [$ref]` to just `$ref` (reduces recursion depth) +fn simplify_schema(schema: &mut schemars::schema::SchemaObject) { + // Remove the default value from this schema + schema.metadata().default = None; + + // Simplify allOf with single $ref: { allOf: [{ $ref: "..." }] } -> { $ref: "..." } + let mut ref_to_promote = None; + if let Some(subschemas) = &schema.subschemas { + if let Some(all_of) = &subschemas.all_of { + if all_of.len() == 1 { + if let schemars::schema::Schema::Object(inner) = &all_of[0] { + if inner.reference.is_some() + && inner.instance_type.is_none() + && inner.object.is_none() + && inner.array.is_none() + && inner.subschemas.is_none() + { + ref_to_promote = inner.reference.clone(); + } + } + } + } + } + if let Some(r) = ref_to_promote { + schema.subschemas = None; + schema.reference = Some(r); + } + + // Process remaining subschemas + if let Some(subschemas) = &mut schema.subschemas { + if let Some(all_of) = &mut subschemas.all_of { + for s in all_of { + if let schemars::schema::Schema::Object(obj) = s { + simplify_schema(obj); + } + } + } + if let Some(any_of) = &mut subschemas.any_of { + for s in any_of { + if let schemars::schema::Schema::Object(obj) = s { + simplify_schema(obj); + } + } + } + if let Some(one_of) = &mut subschemas.one_of { + for s in one_of { + if let schemars::schema::Schema::Object(obj) = s { + simplify_schema(obj); + } + } + } + } + + // Process object properties + if let Some(object) = &mut schema.object { + for prop in object.properties.values_mut() { + if let schemars::schema::Schema::Object(obj) = prop { + simplify_schema(obj); + } + } + if let Some(additional) = &mut object.additional_properties { + if let schemars::schema::Schema::Object(obj) = additional.as_mut() { + simplify_schema(obj); + } + } + } + + // Process array items + if let Some(array) = &mut schema.array { + if let Some(items) = &mut array.items { + match items { + schemars::schema::SingleOrVec::Single(s) => { + if let schemars::schema::Schema::Object(obj) = s.as_mut() { + simplify_schema(obj); + } + } + schemars::schema::SingleOrVec::Vec(v) => { + for s in v { + if let schemars::schema::Schema::Object(obj) = s { + simplify_schema(obj); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_permissions() { + let toml = r#" + [permissions] + location = { precision = "fine", description = "Track your runs" } + camera = { description = "Take photos" } + "#; + + #[derive(Deserialize)] + struct Config { + permissions: PermissionsConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + let loc = config.permissions.location.unwrap(); + assert_eq!(loc.precision, LocationPrecision::Fine); + assert_eq!(loc.description, "Track your runs"); + assert!(config.permissions.camera.is_some()); + } + + #[test] + fn test_parse_ios_config() { + let toml = r#" + [ios] + deployment_target = "15.0" + + [ios.entitlements] + app-groups = ["group.com.example.app"] + "#; + + #[derive(Deserialize)] + struct Config { + ios: IosConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ios.deployment_target, Some("15.0".to_string())); + assert_eq!( + config.ios.entitlements.app_groups, + vec!["group.com.example.app"] + ); + } + + #[test] + fn test_parse_android_config() { + let toml = r#" + [android] + min_sdk = 24 + target_sdk = 34 + + [android.permissions] + "android.permission.FOREGROUND_SERVICE" = { description = "Background" } + "#; + + #[derive(Deserialize)] + struct Config { + android: AndroidConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.android.min_sdk, Some(24)); + assert!(config + .android + .permissions + .contains_key("android.permission.FOREGROUND_SERVICE")); + } + + #[test] + fn test_parse_deep_links() { + let toml = r#" + [deep_links] + schemes = ["myapp", "com.example.app"] + hosts = ["example.com", "*.example.com"] + paths = ["/app/*", "/share/*"] + "#; + + #[derive(Deserialize)] + struct Config { + deep_links: DeepLinkConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.deep_links.schemes, vec!["myapp", "com.example.app"]); + assert_eq!( + config.deep_links.hosts, + vec!["example.com", "*.example.com"] + ); + assert_eq!(config.deep_links.paths, vec!["/app/*", "/share/*"]); + } + + #[test] + fn test_parse_background_modes() { + let toml = r#" + [background] + location = true + audio = true + fetch = true + remote-notifications = true + "#; + + #[derive(Deserialize)] + struct Config { + background: BackgroundConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.background.location); + assert!(config.background.audio); + assert!(config.background.fetch); + assert!(config.background.remote_notifications); + assert!(!config.background.voip); + } + + #[test] + fn test_parse_ios_url_schemes_and_background() { + let toml = r#" + [ios] + deployment_target = "15.0" + url_schemes = ["myapp-ios"] + background_modes = ["location", "fetch", "remote-notification"] + + [[ios.document_types]] + name = "My Document" + extensions = ["mydoc"] + role = "Editor" + "#; + + #[derive(Deserialize)] + struct Config { + ios: IosConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ios.url_schemes, vec!["myapp-ios"]); + assert_eq!( + config.ios.background_modes, + vec!["location", "fetch", "remote-notification"] + ); + assert_eq!(config.ios.document_types.len(), 1); + assert_eq!(config.ios.document_types[0].name, "My Document"); + } + + #[test] + fn test_parse_android_intent_filters() { + let toml = r#" + [android] + min_sdk = 24 + url_schemes = ["myapp-android"] + foreground_service_types = ["location", "mediaPlayback"] + + [[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" + path_prefix = "/app" + "#; + + #[derive(Deserialize)] + struct Config { + android: AndroidConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.android.url_schemes, vec!["myapp-android"]); + assert_eq!( + config.android.foreground_service_types, + vec!["location", "mediaPlayback"] + ); + assert_eq!(config.android.intent_filters.len(), 1); + assert!(config.android.intent_filters[0].auto_verify); + } + + #[test] + fn test_parse_macos_url_schemes() { + let toml = r#" + [macos] + minimum_system_version = "11.0" + url_schemes = ["myapp-macos"] + category = "public.app-category.productivity" + + [[macos.document_types]] + name = "My Format" + extensions = ["myfmt"] + "#; + + #[derive(Deserialize)] + struct Config { + macos: MacosConfig, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.macos.url_schemes, vec!["myapp-macos"]); + assert_eq!( + config.macos.category, + Some("public.app-category.productivity".to_string()) + ); + assert_eq!(config.macos.document_types.len(), 1); + } + + #[test] + fn test_generate_schema() { + let schema = generate_manifest_schema(); + let json = serde_json::to_string_pretty(&schema).unwrap(); + + // Verify the schema contains all top-level DioxusConfig types + assert!(json.contains("ApplicationConfig")); + assert!(json.contains("WebConfig")); + assert!(json.contains("BundleConfig")); + assert!(json.contains("ComponentConfig")); + assert!(json.contains("PermissionsConfig")); + assert!(json.contains("DeepLinkConfig")); + assert!(json.contains("BackgroundConfig")); + assert!(json.contains("IosConfig")); + assert!(json.contains("AndroidConfig")); + assert!(json.contains("MacosConfig")); + assert!(json.contains("WindowsConfig")); + assert!(json.contains("LinuxConfig")); + + // Verify some specific properties exist + assert!(json.contains("location")); + assert!(json.contains("camera")); + assert!(json.contains("deployment_target")); + assert!(json.contains("min_sdk")); + + // Verify application config properties + assert!(json.contains("asset_dir")); + assert!(json.contains("public_dir")); + + // Verify web config properties + assert!(json.contains("pre_compress")); + assert!(json.contains("wasm_opt")); + + // Verify bundle config properties + assert!(json.contains("identifier")); + assert!(json.contains("publisher")); + } +} diff --git a/packages/cli/src/config/mod.rs b/packages/cli/src/config/mod.rs index 0647a1dc5..686376a17 100644 --- a/packages/cli/src/config/mod.rs +++ b/packages/cli/src/config/mod.rs @@ -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::*; diff --git a/packages/cli/src/config/web.rs b/packages/cli/src/config/web.rs index de9781313..71f38396e 100644 --- a/packages/cli/src/config/web.rs +++ b/packages/cli/src/config/web.rs @@ -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, @@ -156,14 +157,14 @@ fn watch_path_default() -> Vec { 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>, pub(crate) script: Option>, } -#[derive(Default, Debug, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)] pub(crate) struct WebDevResourceConfig { #[serde(default)] pub(crate) style: Vec, @@ -171,7 +172,7 @@ pub(crate) struct WebDevResourceConfig { pub(crate) script: Vec, } -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] pub(crate) struct WebHttpsConfig { pub(crate) enabled: Option, pub(crate) mkcert: Option, diff --git a/packages/cli/src/logging.rs b/packages/cli/src/logging.rs index e20d60280..0ca34e596 100644 --- a/packages/cli/src/logging.rs +++ b/packages/cli/src/logging.rs @@ -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 { diff --git a/packages/cli/src/serve/output.rs b/packages/cli/src/serve/output.rs index e23334b96..d1c612c31 100644 --- a/packages/cli/src/serve/output.rs +++ b/packages/cli/src/serve/output.rs @@ -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 { diff --git a/packages/cli/src/workspace.rs b/packages/cli/src/workspace.rs index 4a5fdf7dc..49f7a4bb6 100644 --- a/packages/cli/src/workspace.rs +++ b/packages/cli/src/workspace.rs @@ -390,7 +390,23 @@ impl Workspace { Ok(package) } - pub fn load_dioxus_config(&self, package: NodeId) -> Result> { + /// 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> { // 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 = 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::(&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 diff --git a/packages/dx-wire-format/src/lib.rs b/packages/dx-wire-format/src/lib.rs index f3453f163..de8823614 100644 --- a/packages/dx-wire-format/src/lib.rs +++ b/packages/dx-wire-format/src/lib.rs @@ -108,6 +108,9 @@ pub enum BuildStage { }, Bundling, RunningGradle, + CompilingNativePlugins { + detail: String, + }, CodeSigning, Success, Failed, diff --git a/packages/manganis/manganis-core/src/asset.rs b/packages/manganis/manganis-core/src/asset.rs index 89c67799b..387fbf10c 100644 --- a/packages/manganis/manganis-core/src/asset.rs +++ b/packages/manganis/manganis-core/src/asset.rs @@ -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 { + fn read_slice_volatile(bundled: &'static [u8]) -> Vec { 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 { diff --git a/packages/manganis/manganis-core/src/ffi.rs b/packages/manganis/manganis-core/src/ffi.rs new file mode 100644 index 000000000..5e3479fd6 --- /dev/null +++ b/packages/manganis/manganis-core/src/ffi.rs @@ -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, + pub ios: Option, + pub macos: Option, +} + +/// 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), + } + } +} diff --git a/packages/manganis/manganis-core/src/lib.rs b/packages/manganis/manganis-core/src/lib.rs index d127c3efa..476aad0e4 100644 --- a/packages/manganis/manganis-core/src/lib.rs +++ b/packages/manganis/manganis-core/src/lib.rs @@ -21,3 +21,6 @@ pub use css_module::*; mod css_module_parser; pub use css_module_parser::*; + +mod ffi; +pub use ffi::*; diff --git a/packages/manganis/manganis-macro/src/asset.rs b/packages/manganis/manganis-macro/src/asset.rs index 9a32382f0..0a82bf3e9 100644 --- a/packages/manganis/manganis-macro/src/asset.rs +++ b/packages/manganis/manganis-macro/src/asset.rs @@ -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() { diff --git a/packages/manganis/manganis-macro/src/ffi.rs b/packages/manganis/manganis-macro/src/ffi.rs new file mode 100644 index 000000000..42dcf3e79 --- /dev/null +++ b/packages/manganis/manganis-macro/src/ffi.rs @@ -0,0 +1,1095 @@ +//! FFI bridge macro for native plugin interop. +//! +//! This module implements the `#[ffi]` attribute macro that generates direct FFI bindings +//! between Rust and native platforms (Swift/Kotlin). + +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use syn::{ + parse::{Parse, ParseStream}, + spanned::Spanned, + ForeignItem, ForeignItemFn, Ident, ItemForeignMod, LitStr, Pat, ReturnType, Type, +}; + +/// The foreign ABI being targeted +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ForeignAbi { + /// Swift (iOS/macOS) + Swift, + /// Kotlin (Android) + Kotlin, +} + +/// A foreign type declaration (`type Foo;`) +#[derive(Debug, Clone)] +pub struct ForeignTypeDecl { + pub name: Ident, +} + +/// A parsed foreign type in function signatures +#[derive(Debug, Clone)] +pub enum ForeignType { + Bool, + I8, + I16, + I32, + I64, + U8, + U16, + U32, + U64, + F32, + F64, + String, + StrRef, + Option(Box), + Result(Box, Box), + OpaqueRef(Ident), + Unit, +} + +impl ForeignType { + /// Parse a Rust type into a ForeignType + fn from_type(ty: &Type) -> syn::Result { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.len() == 1 { + let segment = &path.segments[0]; + let ident = segment.ident.to_string(); + match ident.as_str() { + "bool" => return Ok(ForeignType::Bool), + "i8" => return Ok(ForeignType::I8), + "i16" => return Ok(ForeignType::I16), + "i32" => return Ok(ForeignType::I32), + "i64" => return Ok(ForeignType::I64), + "u8" => return Ok(ForeignType::U8), + "u16" => return Ok(ForeignType::U16), + "u32" => return Ok(ForeignType::U32), + "u64" => return Ok(ForeignType::U64), + "f32" => return Ok(ForeignType::F32), + "f64" => return Ok(ForeignType::F64), + "String" => return Ok(ForeignType::String), + "Option" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + let inner_type = Self::from_type(inner)?; + return Ok(ForeignType::Option(Box::new(inner_type))); + } + } + return Err(syn::Error::new(ty.span(), "Invalid Option type")); + } + "Result" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let mut iter = args.args.iter(); + if let ( + Some(syn::GenericArgument::Type(ok_ty)), + Some(syn::GenericArgument::Type(err_ty)), + ) = (iter.next(), iter.next()) + { + let ok_type = Self::from_type(ok_ty)?; + let err_type = Self::from_type(err_ty)?; + return Ok(ForeignType::Result( + Box::new(ok_type), + Box::new(err_type), + )); + } + } + return Err(syn::Error::new(ty.span(), "Invalid Result type")); + } + _ => { + // Assume it's an opaque type reference + return Ok(ForeignType::OpaqueRef(segment.ident.clone())); + } + } + } + Err(syn::Error::new(ty.span(), "Unsupported type path")) + } + Type::Reference(type_ref) => { + if let Type::Path(path) = &*type_ref.elem { + if path.path.is_ident("str") { + return Ok(ForeignType::StrRef); + } + // Check for &OpaqueType + if path.path.segments.len() == 1 { + return Ok(ForeignType::OpaqueRef(path.path.segments[0].ident.clone())); + } + } + Err(syn::Error::new(ty.span(), "Unsupported reference type")) + } + Type::Tuple(tuple) if tuple.elems.is_empty() => Ok(ForeignType::Unit), + _ => Err(syn::Error::new(ty.span(), "Unsupported type")), + } + } + + /// Get the JNI signature for this type + fn jni_signature(&self) -> String { + match self { + ForeignType::Bool => "Z".into(), + ForeignType::I8 => "B".into(), + ForeignType::I16 => "S".into(), + ForeignType::I32 => "I".into(), + ForeignType::I64 => "J".into(), + ForeignType::U8 => "B".into(), // JNI doesn't have unsigned, use signed + ForeignType::U16 => "S".into(), + ForeignType::U32 => "I".into(), + ForeignType::U64 => "J".into(), + ForeignType::F32 => "F".into(), + ForeignType::F64 => "D".into(), + ForeignType::String | ForeignType::StrRef => "Ljava/lang/String;".into(), + ForeignType::Option(inner) => inner.jni_signature(), + ForeignType::Result(ok, _) => ok.jni_signature(), + ForeignType::OpaqueRef(name) => format!("L{};", name), + ForeignType::Unit => "V".into(), + } + } + + /// Generate Rust type tokens + fn to_rust_type(&self) -> TokenStream2 { + match self { + ForeignType::Bool => quote! { bool }, + ForeignType::I8 => quote! { i8 }, + ForeignType::I16 => quote! { i16 }, + ForeignType::I32 => quote! { i32 }, + ForeignType::I64 => quote! { i64 }, + ForeignType::U8 => quote! { u8 }, + ForeignType::U16 => quote! { u16 }, + ForeignType::U32 => quote! { u32 }, + ForeignType::U64 => quote! { u64 }, + ForeignType::F32 => quote! { f32 }, + ForeignType::F64 => quote! { f64 }, + ForeignType::String => quote! { String }, + ForeignType::StrRef => quote! { &str }, + ForeignType::Option(inner) => { + let inner_ty = inner.to_rust_type(); + quote! { Option<#inner_ty> } + } + ForeignType::Result(ok, err) => { + let ok_ty = ok.to_rust_type(); + let err_ty = err.to_rust_type(); + quote! { Result<#ok_ty, #err_ty> } + } + ForeignType::OpaqueRef(name) => quote! { #name }, + ForeignType::Unit => quote! { () }, + } + } +} + +/// A foreign function argument +#[derive(Debug, Clone)] +pub struct ForeignArg { + pub name: Ident, + pub ty: ForeignType, +} + +/// A foreign function declaration +#[derive(Debug, Clone)] +pub struct ForeignFunctionDecl { + pub name: Ident, + pub receiver: Option, // The type name if first arg is `this: &TypeName` + pub args: Vec, + pub return_type: ForeignType, +} + +impl ForeignFunctionDecl { + fn from_foreign_fn(func: &ForeignItemFn) -> syn::Result { + let name = func.sig.ident.clone(); + let mut receiver = None; + let mut args = Vec::new(); + + for (i, input) in func.sig.inputs.iter().enumerate() { + match input { + syn::FnArg::Typed(pat_type) => { + let arg_name = match &*pat_type.pat { + Pat::Ident(pat_ident) => pat_ident.ident.clone(), + _ => { + return Err(syn::Error::new( + pat_type.pat.span(), + "Expected identifier pattern", + )) + } + }; + + let arg_ty = ForeignType::from_type(&pat_type.ty)?; + + // Check if first arg is `this: &SomeType` + if i == 0 && arg_name == "this" { + if let ForeignType::OpaqueRef(type_name) = &arg_ty { + receiver = Some(type_name.clone()); + continue; // Don't add to args + } + } + + args.push(ForeignArg { + name: arg_name, + ty: arg_ty, + }); + } + syn::FnArg::Receiver(_) => { + return Err(syn::Error::new( + input.span(), + "Use `this: &Self` instead of `self`", + )); + } + } + } + + let return_type = match &func.sig.output { + ReturnType::Default => ForeignType::Unit, + ReturnType::Type(_, ty) => ForeignType::from_type(ty)?, + }; + + Ok(Self { + name, + receiver, + args, + return_type, + }) + } +} + +/// The main parser for the `#[ffi]` attribute macro +pub struct FfiBridgeParser { + /// Source folder path (relative to CARGO_MANIFEST_DIR) + pub source_path: String, + /// The foreign ABI (Swift or Kotlin) + pub abi: ForeignAbi, + /// Type declarations + pub types: Vec, + /// Function declarations + pub functions: Vec, +} + +/// Parser for the attribute: `#[ffi("/src/ios")]` +pub struct FfiAttribute { + pub source_path: String, +} + +impl Parse for FfiAttribute { + fn parse(input: ParseStream) -> syn::Result { + let lit: LitStr = input.parse()?; + Ok(FfiAttribute { + source_path: lit.value(), + }) + } +} + +impl FfiBridgeParser { + /// Parse the attribute and item together + pub fn parse_with_attr(attr: FfiAttribute, item: ItemForeignMod) -> syn::Result { + let source_path = attr.source_path; + + // Determine the ABI + let abi = match &item.abi.name { + Some(name) => match name.value().as_str() { + "Swift" => ForeignAbi::Swift, + "Kotlin" => ForeignAbi::Kotlin, + other => { + return Err(syn::Error::new( + name.span(), + format!("Unsupported ABI: {}. Expected 'Swift' or 'Kotlin'", other), + )) + } + }, + None => { + return Err(syn::Error::new( + item.abi.extern_token.span, + "Expected ABI string (e.g., extern \"Swift\")", + )) + } + }; + + let mut types = Vec::new(); + let mut functions = Vec::new(); + + for item in &item.items { + match item { + ForeignItem::Type(ty) => { + types.push(ForeignTypeDecl { + name: ty.ident.clone(), + }); + } + ForeignItem::Fn(func) => { + functions.push(ForeignFunctionDecl::from_foreign_fn(func)?); + } + _ => { + return Err(syn::Error::new( + item.span(), + "Only type and function declarations are supported in FFI blocks", + )); + } + } + } + + Ok(Self { + source_path, + abi, + types, + functions, + }) + } + + /// Generate all the code + pub fn generate(&self) -> TokenStream2 { + match self.abi { + ForeignAbi::Kotlin => self.generate_android(), + ForeignAbi::Swift => self.generate_ios(), + } + } + + /// Extract Android namespace from build.gradle.kts + /// Looks for `namespace = "com.example.foo"` and converts to JNI format `com/example/foo` + fn extract_android_namespace(&self) -> Option { + // Get the manifest dir from environment (set by cargo during compilation) + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let source_path = std::path::Path::new(&manifest_dir).join(&self.source_path); + let build_gradle = source_path.join("build.gradle.kts"); + + if !build_gradle.exists() { + return None; + } + + let contents = std::fs::read_to_string(&build_gradle).ok()?; + + // Look for namespace = "com.example.foo" pattern + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("namespace") { + // Extract the quoted string + if let Some(start) = trimmed.find('"') { + if let Some(end) = trimmed[start + 1..].find('"') { + let namespace = &trimmed[start + 1..start + 1 + end]; + // Convert dots to slashes for JNI format + return Some(namespace.replace('.', "/")); + } + } + } + } + + None + } + + /// Generate Android JNI code + fn generate_android(&self) -> TokenStream2 { + let mut output = TokenStream2::new(); + + // Try to extract namespace from build.gradle.kts + let namespace = self + .extract_android_namespace() + .unwrap_or_else(|| "com/example".to_string()); + + // Generate opaque type wrappers + for ty in &self.types { + let name = &ty.name; + // Use namespace from build.gradle.kts or default + let class_name = format!("{}/{}", namespace, name); + let class_name_lit = syn::LitStr::new(&class_name, proc_macro2::Span::call_site()); + + let type_def = quote! { + /// Opaque wrapper around a JNI GlobalRef + pub struct #name { + inner: manganis::jni::objects::GlobalRef, + } + + impl #name { + /// Create a new instance by looking up the Java class at runtime + pub fn new() -> Result { + // Use with_activity which returns Option, wrapping our Result in the Option + let inner_result: Option> = manganis::android::with_activity(|mut env, activity| { + // Find the class + let class_result = env.find_class(#class_name_lit); + let class = match class_result { + Ok(c) => c, + Err(e) => return Some(Err(format!("Failed to find class {}: {:?}", #class_name_lit, e))), + }; + + // Create a new instance with Activity parameter + // The Kotlin plugin constructor takes (Activity) as parameter + let instance = match env.new_object( + &class, + "(Landroid/app/Activity;)V", + &[manganis::jni::objects::JValue::Object(&activity)], + ) { + Ok(i) => i, + Err(e) => return Some(Err(format!("Failed to create instance of {}: {:?}", #class_name_lit, e))), + }; + + // Convert to global ref + let global = match env.new_global_ref(&instance) { + Ok(g) => g, + Err(e) => return Some(Err(format!("Failed to create global ref: {:?}", e))), + }; + + Some(Ok(Self { inner: global })) + }); + + // Convert Option> to Result + match inner_result { + Some(result) => result, + None => Err("Failed to get JNI environment".to_string()), + } + } + + /// Create from an existing GlobalRef + pub fn from_global_ref(global: manganis::jni::objects::GlobalRef) -> Self { + Self { inner: global } + } + + /// Get the underlying JObject + pub fn as_obj(&self) -> &manganis::jni::objects::JObject<'_> { + self.inner.as_obj() + } + } + }; + output.extend(type_def); + } + + // Generate function implementations + for func in &self.functions { + let func_code = self.generate_android_function(func); + output.extend(func_code); + } + + // Generate linker metadata + let metadata = self.generate_android_metadata(); + output.extend(metadata); + + // Wrap in cfg + quote! { + #[cfg(target_os = "android")] + mod __ffi_android { + #output + } + + #[cfg(target_os = "android")] + pub use __ffi_android::*; + } + } + + fn generate_android_function(&self, func: &ForeignFunctionDecl) -> TokenStream2 { + let fn_name = &func.name; + // Convert snake_case to camelCase for Kotlin/Java method name + let method_name = to_camel_case(&func.name.to_string()); + + // Build argument list for Rust function + let mut rust_args = Vec::new(); + if let Some(receiver_type) = &func.receiver { + rust_args.push(quote! { this: &#receiver_type }); + } + for arg in &func.args { + let name = &arg.name; + let ty = arg.ty.to_rust_type(); + rust_args.push(quote! { #name: #ty }); + } + + // Build return type + let return_type = func.return_type.to_rust_type(); + + // Build JNI signature + let jni_args: String = func.args.iter().map(|a| a.ty.jni_signature()).collect(); + let jni_ret = func.return_type.jni_signature(); + let jni_sig = format!("({}){}", jni_args, jni_ret); + let jni_sig_lit = syn::LitStr::new(&jni_sig, proc_macro2::Span::call_site()); + + // Build JNI call arguments - each arg needs separate binding before the call + let mut arg_bindings = Vec::new(); + let mut jni_call_args = Vec::new(); + for (i, arg) in func.args.iter().enumerate() { + let name = &arg.name; + let binding_name = format_ident!("__jni_arg_{}", i); + + let (binding, arg_expr) = match &arg.ty { + ForeignType::Bool => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Bool(if #name { 1 } else { 0 }); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::I8 | ForeignType::U8 => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Byte(#name as i8); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::I16 | ForeignType::U16 => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Short(#name as i16); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::I32 | ForeignType::U32 => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Int(#name as i32); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::I64 | ForeignType::U64 => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Long(#name as i64); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::F32 => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Float(#name); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::F64 => ( + quote! { let #binding_name = manganis::jni::objects::JValue::Double(#name); }, + quote! { #binding_name.borrow() }, + ), + ForeignType::String | ForeignType::StrRef => ( + quote! { + let #binding_name = match env.new_string(#name) { + Ok(s) => s, + Err(e) => return Some(Err(format!("Failed to create JNI string: {:?}", e))), + }; + }, + quote! { (&&#binding_name).into() }, + ), + _ => ( + quote! { let #binding_name = #name.inner.as_obj(); }, + quote! { (&#binding_name).into() }, + ), + }; + arg_bindings.push(binding); + jni_call_args.push(arg_expr); + } + + // Build the call expression + let call_target = if func.receiver.is_some() { + quote! { this.inner.as_obj() } + } else { + quote! { &class } + }; + + let method_name_lit = syn::LitStr::new(&method_name, proc_macro2::Span::call_site()); + + // Generate result conversion that takes env as a parameter + // Note: call_method returns JValueGen> (owned), not a reference + let result_conversion_fn = match &func.return_type { + ForeignType::Unit => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, _result: manganis::jni::objects::JValueGen>) -> Result<(), String> { + Ok(()) + } + }, + ForeignType::Bool => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result { + result.z() + .map(|v| v != 0) + .map_err(|e| format!("Failed to get boolean result: {:?}", e)) + } + }, + ForeignType::I32 | ForeignType::U32 => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result { + result.i() + .map_err(|e| format!("Failed to get int result: {:?}", e)) + } + }, + ForeignType::I64 | ForeignType::U64 => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result { + result.j() + .map_err(|e| format!("Failed to get long result: {:?}", e)) + } + }, + ForeignType::F32 => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result { + result.f() + .map_err(|e| format!("Failed to get float result: {:?}", e)) + } + }, + ForeignType::F64 => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result { + result.d() + .map_err(|e| format!("Failed to get double result: {:?}", e)) + } + }, + ForeignType::String => quote! { + fn convert_result<'a>(env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result { + let obj = result.l() + .map_err(|e| format!("Failed to get object result: {:?}", e))?; + if obj.is_null() { + return Ok(String::new()); + } + let jstr: manganis::jni::objects::JString = obj.into(); + let rust_str: String = env.get_string(&jstr) + .map_err(|e| format!("Failed to get string: {:?}", e))? + .into(); + Ok(rust_str) + } + }, + ForeignType::Option(inner) => match inner.as_ref() { + ForeignType::String => quote! { + fn convert_result<'a>(env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result, String> { + let obj = result.l() + .map_err(|e| format!("Failed to get object result: {:?}", e))?; + if obj.is_null() { + Ok(None) + } else { + let jstr: manganis::jni::objects::JString = obj.into(); + let rust_str: String = env.get_string(&jstr) + .map_err(|e| format!("Failed to get string: {:?}", e))? + .into(); + Ok(Some(rust_str)) + } + } + }, + _ => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, result: manganis::jni::objects::JValueGen>) -> Result, String> { + let obj = result.l() + .map_err(|e| format!("Failed to get object result: {:?}", e))?; + if obj.is_null() { + Ok(None) + } else { + Ok(Some(())) + } + } + }, + }, + _ => quote! { + fn convert_result<'a>(_env: &mut manganis::jni::JNIEnv<'a>, _result: manganis::jni::objects::JValueGen>) -> Result<(), String> { + Ok(()) + } + }, + }; + + quote! { + pub fn #fn_name(#(#rust_args),*) -> Result<#return_type, String> { + // Use with_activity which returns Option, wrapping our Result in the Option + let inner_result: Option> = manganis::android::with_activity(|mut env, _activity| { + // Define result conversion as a local function to avoid closure capture issues + #result_conversion_fn + + // Perform the JNI call directly in the closure + #(#arg_bindings)* + + let call_result = env.call_method( + #call_target, + #method_name_lit, + #jni_sig_lit, + &[#(#jni_call_args),*], + ); + + match call_result { + Ok(result) => Some(convert_result(&mut env, result)), + Err(e) => Some(Err(format!("JNI call failed: {:?}", e))), + } + }); + + // Convert Option> to Result + match inner_result { + Some(result) => result, + None => Err("Failed to get JNI environment".to_string()), + } + } + } + } + + fn generate_android_metadata(&self) -> TokenStream2 { + // Get the first type name or use "plugin" as default + let plugin_name = self + .types + .first() + .map(|t| t.name.to_string().to_lowercase()) + .unwrap_or_else(|| "plugin".to_string()); + + let source_path_lit = syn::LitStr::new(&self.source_path, proc_macro2::Span::call_site()); + let plugin_name_lit = syn::LitStr::new(&plugin_name, proc_macro2::Span::call_site()); + + let mut hash = DefaultHasher::new(); + self.source_path.hash(&mut hash); + plugin_name.hash(&mut hash); + let plugin_hash = format!("{:016x}", hash.finish()); + + let link_section = crate::linker::generate_link_section_inner( + quote! { __METADATA }, + &plugin_hash, + "__ASSETS__", + quote! { manganis::android::metadata::serialize_android_metadata }, + quote! { manganis::android::macro_helpers::copy_bytes }, + quote! { manganis::android::metadata::AndroidMetadataBuffer }, + ); + + quote! { + const _: () = { + const __METADATA: manganis::android::AndroidArtifactMetadata = + manganis::android::AndroidArtifactMetadata::new( + #plugin_name_lit, + concat!(env!("CARGO_MANIFEST_DIR"), "/", #source_path_lit), + "", // No extra dependencies by default + ); + + #link_section + }; + } + } + + /// Generate iOS Objective-C code + fn generate_ios(&self) -> TokenStream2 { + let mut output = TokenStream2::new(); + + // Generate opaque type wrappers + for ty in &self.types { + let name = &ty.name; + let class_name_bytes = format!("{}\0", name); + + let type_def = quote! { + /// Opaque wrapper around an Objective-C object pointer + /// The actual Swift class is looked up dynamically at runtime after dx links everything + pub struct #name { + inner: *mut manganis::objc2::runtime::AnyObject, + } + + unsafe impl Send for #name {} + unsafe impl Sync for #name {} + + impl #name { + /// Load the Swift framework bundle to make classes available. + /// + /// We use dlopen rather than build-time linking because Swift packages are compiled + /// after the Rust binary (we extract plugin metadata from the linker args). + /// + /// This is App Store compliant because: + /// - The framework is bundled inside the .app bundle (not downloaded) + /// - The framework is code-signed as part of the app + /// - No external code is loaded - only bundled, reviewed code + fn load_swift_framework() -> Result<(), &'static str> { + use std::sync::Once; + static LOAD_ONCE: Once = Once::new(); + static mut LOAD_RESULT: Result<(), &'static str> = Ok(()); + + #[link(name = "System")] + extern "C" { + fn dlopen(filename: *const std::ffi::c_char, flags: std::ffi::c_int) -> *mut std::ffi::c_void; + fn dlerror() -> *const std::ffi::c_char; + } + const RTLD_NOW: std::ffi::c_int = 0x2; + const RTLD_GLOBAL: std::ffi::c_int = 0x8; + + LOAD_ONCE.call_once(|| { + unsafe { + // Get the path to the executable + let exe_path = std::env::current_exe() + .map_err(|_| "Failed to get executable path") + .ok(); + + let framework_path = if let Some(exe) = exe_path { + // For macOS: App.app/Contents/MacOS/binary -> App.app/Contents/Frameworks/ + // For iOS: App.app/binary -> App.app/Frameworks/ + let parent = exe.parent().unwrap_or(&exe); + + #[cfg(target_os = "macos")] + let frameworks_dir = parent.parent().unwrap_or(parent).join("Frameworks"); + #[cfg(target_os = "ios")] + let frameworks_dir = parent.join("Frameworks"); + + let path = frameworks_dir.join("DioxusSwiftPlugins.framework/DioxusSwiftPlugins"); + if path.exists() { + Some(path) + } else { + // Try versioned path for macOS + let versioned = frameworks_dir.join("DioxusSwiftPlugins.framework/Versions/Current/DioxusSwiftPlugins"); + if versioned.exists() { + Some(versioned) + } else { + None + } + } + } else { + None + }; + + if let Some(path) = framework_path { + let path_cstr = std::ffi::CString::new(path.to_string_lossy().as_bytes()) + .expect("Invalid framework path"); + + // Use dlopen to load the framework + let handle = dlopen(path_cstr.as_ptr(), RTLD_NOW | RTLD_GLOBAL); + if handle.is_null() { + let err = dlerror(); + if !err.is_null() { + let msg = std::ffi::CStr::from_ptr(err).to_string_lossy(); + eprintln!("Failed to load Swift framework: {}", msg); + } + LOAD_RESULT = Err("Failed to load Swift framework with dlopen"); + } + } else { + LOAD_RESULT = Err("Swift framework not found at expected path"); + } + } + }); + + unsafe { LOAD_RESULT } + } + + /// Create a new instance by looking up the ObjC class dynamically at runtime + pub fn new() -> Result { + // First ensure the framework is loaded + Self::load_swift_framework()?; + + unsafe { + // Dynamic runtime lookup - the class will be available after the framework is loaded + let class_name = ::std::ffi::CStr::from_bytes_with_nul(#class_name_bytes.as_bytes()) + .expect("Invalid class name"); + let class = manganis::objc2::runtime::AnyClass::get(class_name) + .ok_or("Class not found - ensure Swift sources are compiled and linked")?; + + let instance: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![class, alloc]; + let instance: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![instance, init]; + + if instance.is_null() { + return Err("Failed to initialize instance"); + } + + Ok(Self { inner: instance }) + } + } + + /// Create from an existing object pointer + pub unsafe fn from_raw(ptr: *mut manganis::objc2::runtime::AnyObject) -> Self { + Self { inner: ptr } + } + } + }; + output.extend(type_def); + } + + // Generate function implementations + for func in &self.functions { + let func_code = self.generate_ios_function(func); + output.extend(func_code); + } + + // Generate linker metadata + let metadata = self.generate_ios_metadata(); + output.extend(metadata); + + // Wrap in cfg + quote! { + #[cfg(any(target_os = "ios", target_os = "macos"))] + mod __ffi_darwin { + #output + } + + #[cfg(any(target_os = "ios", target_os = "macos"))] + pub use __ffi_darwin::*; + } + } + + fn generate_ios_function(&self, func: &ForeignFunctionDecl) -> TokenStream2 { + let fn_name = &func.name; + + // Build Objective-C selector + let selector = self.rust_to_objc_selector(&func.name.to_string(), &func.args); + + // Build argument list for Rust function + let mut rust_args = Vec::new(); + if let Some(receiver_type) = &func.receiver { + rust_args.push(quote! { this: &#receiver_type }); + } + for arg in &func.args { + let name = &arg.name; + let ty = arg.ty.to_rust_type(); + rust_args.push(quote! { #name: #ty }); + } + + // Build return type + let return_type = func.return_type.to_rust_type(); + + // Build argument conversions (variable bindings before msg_send) + let mut arg_conversions = Vec::new(); + let mut arg_names = Vec::new(); + for (i, arg) in func.args.iter().enumerate() { + let name = &arg.name; + let converted_name = format_ident!("__arg_{}", i); + let conversion = match &arg.ty { + ForeignType::Bool => quote! { + let #converted_name = manganis::objc2::runtime::Bool::new(#name); + }, + ForeignType::String | ForeignType::StrRef => { + quote! { + let __cstr = ::std::ffi::CString::new(#name.as_bytes()).unwrap(); + let __nsstring_class = manganis::objc2::runtime::AnyClass::get( + ::std::ffi::CStr::from_bytes_with_nul(b"NSString\0").unwrap() + ).unwrap(); + let #converted_name: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![ + __nsstring_class, + stringWithUTF8String: __cstr.as_ptr() + ]; + } + } + _ => quote! { let #converted_name = #name; }, + }; + arg_conversions.push(conversion); + arg_names.push(converted_name); + } + + // Build result conversion + let result_conversion = match &func.return_type { + ForeignType::Unit => quote! { Ok(()) }, + ForeignType::Bool => quote! { + Ok(result.as_bool()) + }, + ForeignType::String => quote! { + { + if result.is_null() { + Ok(String::new()) + } else { + let cstr: *const ::std::os::raw::c_char = manganis::objc2::msg_send![result, UTF8String]; + let rust_str = ::std::ffi::CStr::from_ptr(cstr) + .to_str() + .map_err(|_| "Invalid UTF-8")?; + Ok(rust_str.to_owned()) + } + } + }, + ForeignType::Option(inner) => match inner.as_ref() { + ForeignType::String => quote! { + { + if result.is_null() { + Ok(None) + } else { + let cstr: *const ::std::os::raw::c_char = manganis::objc2::msg_send![result, UTF8String]; + let rust_str = ::std::ffi::CStr::from_ptr(cstr) + .to_str() + .map_err(|_| "Invalid UTF-8")?; + Ok(Some(rust_str.to_owned())) + } + } + }, + _ => quote! { + if result.is_null() { + Ok(None) + } else { + Ok(Some(Default::default())) + } + }, + }, + _ => quote! { Ok(Default::default()) }, + }; + + // Build the msg_send call + let this_expr = if func.receiver.is_some() { + quote! { this.inner } + } else { + // For static methods, we'd need the class + quote! { class } + }; + + // Build msg_send expression with proper selector syntax + // For Swift methods with `_` external labels, the selector is just `methodName:` + // and we call it as: msg_send![obj, methodName: arg0] + let msg_send_call = if func.args.is_empty() { + // No arguments - use the simple selector (no colons) + let selector_ident = format_ident!("{}", selector); + quote! { + manganis::objc2::msg_send![#this_expr, #selector_ident] + } + } else { + // With arguments - the selector is `methodName:` for one arg, `methodName::` for two, etc. + // The msg_send syntax is: msg_send![obj, methodName: arg0, _: arg1, _: arg2] + // where `_` is used for subsequent unlabeled parameters + + let method_name = to_camel_case(&func.name.to_string()); + let method_ident = format_ident!("{}", method_name); + + // Build the msg_send call tokens + let mut tokens = quote! { #this_expr, }; + + // First argument uses the method name + if !arg_names.is_empty() { + let first_arg = &arg_names[0]; + tokens.extend(quote! { #method_ident: #first_arg }); + } + + // Subsequent arguments use `_` as the label (for Swift's unlabeled parameters) + for arg in arg_names.iter().skip(1) { + let underscore = format_ident!("_"); + tokens.extend(quote! { , #underscore: #arg }); + } + + quote! { + manganis::objc2::msg_send![#tokens] + } + }; + + quote! { + pub fn #fn_name(#(#rust_args),*) -> Result<#return_type, &'static str> { + unsafe { + #(#arg_conversions)* + let result: *mut manganis::objc2::runtime::AnyObject = #msg_send_call; + + #result_conversion + } + } + } + } + + fn generate_ios_metadata(&self) -> TokenStream2 { + // Get the first type name or use "plugin" as default + let plugin_name = self + .types + .first() + .map(|t| t.name.to_string()) + .unwrap_or_else(|| "Plugin".to_string()); + + let source_path_lit = syn::LitStr::new(&self.source_path, proc_macro2::Span::call_site()); + let plugin_name_lit = + syn::LitStr::new(&plugin_name.to_lowercase(), proc_macro2::Span::call_site()); + let product_lit = syn::LitStr::new(&plugin_name, proc_macro2::Span::call_site()); + + let mut hash = DefaultHasher::new(); + self.source_path.hash(&mut hash); + plugin_name.hash(&mut hash); + let plugin_hash = format!("{:016x}", hash.finish()); + + let link_section = crate::linker::generate_link_section_inner( + quote! { __METADATA }, + &plugin_hash, + "__ASSETS__", + quote! { manganis::darwin::metadata::serialize_swift_metadata }, + quote! { manganis::darwin::macro_helpers::copy_bytes }, + quote! { manganis::darwin::metadata::SwiftMetadataBuffer }, + ); + + quote! { + const _: () = { + const __METADATA: manganis::darwin::SwiftPackageMetadata = + manganis::darwin::SwiftPackageMetadata::new( + #plugin_name_lit, + concat!(env!("CARGO_MANIFEST_DIR"), "/", #source_path_lit), + #product_lit, + ); + + #link_section + }; + } + } + + /// Convert a Rust function name to an Objective-C selector + /// + /// For Swift methods that use `_` as the external parameter label (like most FFI methods), + /// the selector is just the method name followed by colons for each parameter. + /// e.g., `func getCurrentPositionJson(_ optionsJson: String)` -> `getCurrentPositionJson:` + fn rust_to_objc_selector(&self, fn_name: &str, args: &[ForeignArg]) -> String { + let mut selector = to_camel_case(fn_name); + + // For each argument, just add a colon (assuming Swift uses _ for external labels) + for _ in args { + selector.push(':'); + } + + selector + } +} + +/// Convert snake_case to camelCase +fn to_camel_case(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = false; + + for (i, c) in s.chars().enumerate() { + if c == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(c.to_ascii_uppercase()); + capitalize_next = false; + } else if i == 0 { + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + + result +} diff --git a/packages/manganis/manganis-macro/src/lib.rs b/packages/manganis/manganis-macro/src/lib.rs index cd96ee758..4dc789917 100644 --- a/packages/manganis/manganis-macro/src/lib.rs +++ b/packages/manganis/manganis-macro/src/lib.rs @@ -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 @@ -208,7 +207,7 @@ pub fn option_asset(input: TokenStream) -> TokenStream { /// fn app() -> Element { /// #[css_module("/assets/styles.css")] /// struct Styles; -/// +/// /// rsx! { /// div { class: Styles::container, /// button { class: Styles::button, "Click me" } @@ -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; +/// } +/// +/// #[manganis::ffi("/src/android")] +/// extern "Kotlin" { +/// pub type GeolocationPlugin; +/// pub fn get_position(this: &GeolocationPlugin, high_accuracy: bool) -> Option; +/// } +/// ``` +/// +/// # 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` 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 { // Get the location of the root of the crate which is where all assets are relative to // diff --git a/packages/manganis/manganis-macro/src/linker.rs b/packages/manganis/manganis-macro/src/linker.rs index 09ba73f6d..c1b0e80d1 100644 --- a/packages/manganis/manganis-macro/src/linker.rs +++ b/packages/manganis/manganis-macro/src/linker.rs @@ -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 = 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 = 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 }; + let buffer_type_legacy = quote! { manganis::macro_helpers::const_serialize_07::ConstVec }; + 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 }; } diff --git a/packages/manganis/manganis/Cargo.toml b/packages/manganis/manganis/Cargo.toml index e3f019b9e..8762cc822 100644 --- a/packages/manganis/manganis/Cargo.toml +++ b/packages/manganis/manganis/Cargo.toml @@ -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"] diff --git a/packages/manganis/manganis/README.md b/packages/manganis/manganis/README.md index 01deab8ae..5740c0759 100644 --- a/packages/manganis/manganis/README.md +++ b/packages/manganis/manganis/README.md @@ -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 = 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 = 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. diff --git a/packages/manganis/manganis/src/android/activity.rs b/packages/manganis/manganis/src/android/activity.rs new file mode 100644 index 000000000..d73b1ef41 --- /dev/null +++ b/packages/manganis/manganis/src/android/activity.rs @@ -0,0 +1,63 @@ +use jni::{objects::JObject, JNIEnv, JavaVM}; +use std::sync::OnceLock; + +/// Cached reference to the Android activity. +static ACTIVITY: OnceLock = OnceLock::new(); +static JAVA_VM: OnceLock = 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: F) -> Option +where + F: FnOnce(&mut JNIEnv<'_>, &JObject<'_>) -> Option, +{ + 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) +} diff --git a/packages/manganis/manganis/src/android/callback.rs b/packages/manganis/manganis/src/android/callback.rs new file mode 100644 index 000000000..06455b212 --- /dev/null +++ b/packages/manganis/manganis/src/android/callback.rs @@ -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 { + 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> { + 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); +} diff --git a/packages/manganis/manganis/src/android/java.rs b/packages/manganis/manganis/src/android/java.rs new file mode 100644 index 000000000..fd44731ee --- /dev/null +++ b/packages/manganis/manganis/src/android/java.rs @@ -0,0 +1,127 @@ +use jni::{ + objects::{JClass, JObject, JObjectArray, JString, JValue, JValueOwned}, + JNIEnv, +}; + +/// Result type for JNI operations +pub type Result = std::result::Result; + +/// 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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> { + 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()) +} diff --git a/packages/manganis/manganis/src/android/metadata.rs b/packages/manganis/manganis/src/android/metadata.rs new file mode 100644 index 000000000..b4317682d --- /dev/null +++ b/packages/manganis/manganis/src/android/metadata.rs @@ -0,0 +1,9 @@ +//! Android metadata wrappers for linker-based collection. + +pub use manganis_core::AndroidArtifactMetadata; + +pub type AndroidMetadataBuffer = crate::macro_helpers::ConstVec; + +pub const fn serialize_android_metadata(meta: &AndroidArtifactMetadata) -> AndroidMetadataBuffer { + crate::macro_helpers::serialize_android_artifact(meta) +} diff --git a/packages/manganis/manganis/src/android/mod.rs b/packages/manganis/manganis/src/android/mod.rs new file mode 100644 index 000000000..e9109d4d0 --- /dev/null +++ b/packages/manganis/manganis/src/android/mod.rs @@ -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(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::*; diff --git a/packages/manganis/manganis/src/darwin/mod.rs b/packages/manganis/manganis/src/darwin/mod.rs new file mode 100644 index 000000000..76fead894 --- /dev/null +++ b/packages/manganis/manganis/src/darwin/mod.rs @@ -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; + + /// 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) + } +} diff --git a/packages/manganis/manganis/src/lib.rs b/packages/manganis/manganis/src/lib.rs index 62059445b..4ae9cca1d 100644 --- a/packages/manganis/manganis/src/lib.rs +++ b/packages/manganis/manganis/src/lib.rs @@ -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; diff --git a/packages/manganis/manganis/src/macro_helpers.rs b/packages/manganis/manganis/src/macro_helpers.rs index 1d3a5d3aa..d90c69fc2 100644 --- a/packages/manganis/manganis/src/macro_helpers.rs +++ b/packages/manganis/manganis/src/macro_helpers.rs @@ -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(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 { + 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 { + serialize_symbol_data(&SymbolData::AndroidArtifact(*meta)) +} + +/// Serialize Swift package metadata (wrapped in `SymbolData::SwiftPackage`). +pub const fn serialize_swift_package(meta: &SwiftPackageMetadata) -> ConstVec { + 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 { - let data = ConstVec::new(); - let mut data = serialize_const(asset, data); - // Reserve the maximum size of the asset - while data.len() < ::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 { + 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 { - let data = ConstVec07::new(); - let mut data = serialize_const_07(asset, data); - // Reserve the maximum size of the asset - while data.len() < ::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(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( + value: &T, + memory_layout_size: usize, + ) -> ConstVec { + 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`) + /// 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( + value: &impl SerializeConst, + memory_layout_size: usize, + ) -> ConstVec { + // Serialize using the default buffer, then copy into the larger buffer + let serialized = const_serialize::serialize_const(value, ConstVec::new()); + let mut data: ConstVec = 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( + value: &impl SerializeConst, + ) -> ConstVec { + let serialized = const_serialize::serialize_const(value, ConstVec::new()); + let mut data: ConstVec = 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( + value: &T, + ) -> const_serialize_07::ConstVec { + 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 }