diff --git a/README.md b/README.md index 5418def26f..6711830019 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,7 @@ splitViewAddChild(split, content); App({ title: 'My App', width: 800, height: 500, body: split }); ``` -**10 target outputs from one codebase:** +**11 target outputs from one codebase:** | Platform | Backend | Target Flag | |----------|---------|-------------| @@ -362,6 +362,7 @@ App({ title: 'My App', width: 800, height: 500, body: split }); | tvOS | UIKit | `--target tvos` / `--target tvos-simulator` | | watchOS | WatchKit | `--target watchos` / `--target watchos-simulator` | | Android | Android Views (JNI) | `--target android` | +| Wear OS | Android Views (JNI) | `--target wearos` | | Windows | Win32 | *(default on Windows)* | | Linux | GTK4 | *(default on Linux)* | | Web | DOM (JS codegen) | `--target web` | @@ -446,6 +447,7 @@ perry compile src/main.ts --target android -o MyApp # TV / Watch perry compile src/main.ts --target tvos -o MyApp perry compile src/main.ts --target watchos -o MyApp +perry compile src/main.ts --target wearos -o MyApp # Wear OS (Android on a watch) # Web perry compile src/main.ts --target web -o app.html # JavaScript output diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index eed8183e15..69e8b3599d 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -410,6 +410,8 @@ pub fn resolve_target_triple(name: &str) -> Option { "harmonyos" => Some("aarch64-unknown-linux-ohos".to_string()), "harmonyos-simulator" => Some("x86_64-unknown-linux-ohos".to_string()), "android" => Some("aarch64-unknown-linux-android".to_string()), + // Wear OS is Android-on-a-watch: same arm64 Android object format. + "wearos" => Some("aarch64-unknown-linux-android".to_string()), "linux" => Some("x86_64-unknown-linux-gnu".to_string()), "linux-aarch64" => Some("aarch64-unknown-linux-gnu".to_string()), // musl targets — fully static binaries that run on Lambda diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index f503319387..b937f5f16f 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -1703,6 +1703,7 @@ pub fn run_with_parse_cache( | Some("visionos") | Some("visionos-simulator") | Some("android") + | Some("wearos") | Some("watchos") | Some("watchos-simulator") | Some("tvos") @@ -4499,7 +4500,7 @@ pub fn run_with_parse_cache( } // Platform detection for nm tool and symbol prefix let _is_ios = matches!(target.as_deref(), Some("ios-simulator") | Some("ios")); - let is_android = matches!(target.as_deref(), Some("android")); + let is_android = matches!(target.as_deref(), Some("android") | Some("wearos")); let is_harmonyos = matches!( target.as_deref(), Some("harmonyos") | Some("harmonyos-simulator") @@ -5051,7 +5052,7 @@ pub fn run_with_parse_cache( target.as_deref(), Some("visionos-simulator") | Some("visionos") ); - let is_android = matches!(target.as_deref(), Some("android")); + let is_android = matches!(target.as_deref(), Some("android") | Some("wearos")); let is_harmonyos = matches!( target.as_deref(), Some("harmonyos") | Some("harmonyos-simulator") diff --git a/crates/perry/src/commands/compile/app_metadata.rs b/crates/perry/src/commands/compile/app_metadata.rs index 3a7adcdebe..62619f14a9 100644 --- a/crates/perry/src/commands/compile/app_metadata.rs +++ b/crates/perry/src/commands/compile/app_metadata.rs @@ -16,6 +16,8 @@ pub(super) fn target_bundle_section(target: Option<&str>) -> Option<&'static str Some("watchos") | Some("watchos-simulator") => Some("watchos"), Some("tvos") | Some("tvos-simulator") => Some("tvos"), Some("android") => Some("android"), + // Wear OS reuses the [android] perry.toml section (bundle_id, etc.). + Some("wearos") => Some("android"), Some("macos") => Some("macos"), // WinUI shares the [windows] perry.toml section (#4680). Some("windows") | Some("windows-winui") => Some("windows"), @@ -171,6 +173,8 @@ pub(super) fn rust_target_triple(target: Option<&str>) -> Option<&'static str> { Some("harmonyos") => Some("aarch64-unknown-linux-ohos"), Some("harmonyos-simulator") => Some("x86_64-unknown-linux-ohos"), Some("android") => Some("aarch64-linux-android"), + // Wear OS is Android-on-a-watch: same arm64 Android .so + toolchain. + Some("wearos") => Some("aarch64-linux-android"), Some("linux") | Some("linux-x86_64") => Some("x86_64-unknown-linux-gnu"), Some("linux-arm64") | Some("linux-aarch64") => Some("aarch64-unknown-linux-gnu"), // Fully-static musl targets (#4826). The perry-runtime / perry-stdlib diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index 4ae90fd4cb..1d8eb3facf 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -951,7 +951,8 @@ pub(super) fn find_ui_library(target: Option<&str>) -> Option { let lib_name = match target { Some("ios-simulator") | Some("ios") => "libperry_ui_ios.a", Some("visionos-simulator") | Some("visionos") => "libperry_ui_visionos.a", - Some("android") => "libperry_ui_android.a", + // Wear OS reuses the Android View backend. + Some("android") | Some("wearos") => "libperry_ui_android.a", Some("watchos-simulator") | Some("watchos") => "libperry_ui_watchos.a", Some("tvos-simulator") | Some("tvos") => "libperry_ui_tvos.a", Some("linux") => "libperry_ui_gtk4.a", @@ -1090,6 +1091,18 @@ pub(super) fn android_cross_env(ndk_home: &Path, target: Option<&str>) -> Vec<(S }; let clang = bin.join(format!("{}-clang{}", clang_target, ext)); let clangpp = bin.join(format!("{}-clang++{}", clang_target, ext)); + // NDK r27+ removed the per-target `aarch64-linux-android-ar` wrapper that + // cc-rs probes for by default; the archiver is now the unprefixed + // `llvm-ar`. Without an explicit `AR_` the runtime/stdlib C + // dependencies (e.g. mimalloc) fail to build with + // `failed to find tool "aarch64-linux-android-ar"`. `llvm-ar` has no `.cmd` + // wrapper on Windows — it's the bare executable (+`.exe`). + let ar_ext = if cfg!(target_os = "windows") { + ".exe" + } else { + "" + }; + let llvm_ar = bin.join(format!("llvm-ar{}", ar_ext)); let triple_upper = triple.to_uppercase().replace('-', "_"); let triple_under = triple.replace('-', "_"); @@ -1102,6 +1115,11 @@ pub(super) fn android_cross_env(ndk_home: &Path, target: Option<&str>) -> Vec<(S format!("CXX_{}", triple_under), clangpp.display().to_string(), ), + (format!("AR_{}", triple), llvm_ar.display().to_string()), + ( + format!("AR_{}", triple_under), + llvm_ar.display().to_string(), + ), ( format!("CARGO_TARGET_{}_LINKER", triple_upper), clang.display().to_string(), @@ -1268,7 +1286,7 @@ pub(super) fn find_geisterhand_ui(target: Option<&str>) -> Option { "libperry_ui_ios.a" } else if matches!(target, Some("visionos-simulator") | Some("visionos")) { return None; - } else if matches!(target, Some("android")) { + } else if matches!(target, Some("android") | Some("wearos")) { "libperry_ui_android.a" } else if matches!(target, Some("linux")) || cfg!(target_os = "linux") { "libperry_ui_gtk4.a" @@ -1291,7 +1309,7 @@ pub(super) fn build_geisterhand_libs(target: Option<&str>, format: OutputFormat) // Determine which UI crate to build based on target platform let ui_crate = match target { Some("ios-simulator") | Some("ios") => "perry-ui-ios", - Some("android") => "perry-ui-android", + Some("android") | Some("wearos") => "perry-ui-android", Some("linux") => "perry-ui-gtk4", Some("windows-winui") => "perry-ui-windows-winui", Some("windows") => "perry-ui-windows", @@ -1362,7 +1380,10 @@ pub(super) fn build_geisterhand_libs(target: Option<&str>, format: OutputFormat) // `libperry_app.so` on Android; force global-dynamic TLS so the IE // model doesn't crash at load. (RUSTFLAGS scopes to the cross target, // so host build-scripts/proc-macros are unaffected.) - if matches!(target, Some("android") | Some("android-x86_64")) { + if matches!( + target, + Some("android") | Some("android-x86_64") | Some("wearos") + ) { let tls_flag = super::optimized_libs::android_global_dynamic_tls_rustflag(&mut cargo_cmd); cargo_cmd.env("RUSTFLAGS", tls_flag); } diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 787f7f938e..9b1bc8019e 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -271,7 +271,8 @@ pub(super) fn build_and_run_link( let is_ios = matches!(target, Some("ios-simulator") | Some("ios")); let is_visionos = matches!(target, Some("visionos-simulator") | Some("visionos")); - let is_android = matches!(target, Some("android")); + // Wear OS links exactly like Android (same triple, NDK, cdylib + TLS model). + let is_android = matches!(target, Some("android") | Some("wearos")); let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); let is_linux = matches!(target, Some(t) if t.starts_with("linux")) || (target.is_none() && cfg!(target_os = "linux")); diff --git a/crates/perry/src/commands/compile/lock_scan.rs b/crates/perry/src/commands/compile/lock_scan.rs index c0059ceace..ebee30985c 100644 --- a/crates/perry/src/commands/compile/lock_scan.rs +++ b/crates/perry/src/commands/compile/lock_scan.rs @@ -159,7 +159,8 @@ fn derive_target_key(target: Option<&str>) -> String { Some("watchos-simulator") => "watchos-simulator".to_string(), Some("visionos") => "visionos".to_string(), Some("visionos-simulator") => "visionos-simulator".to_string(), - Some("android") => "android".to_string(), + // Wear OS reuses Android's resolved native-dependency set. + Some("android") | Some("wearos") => "android".to_string(), Some("harmonyos") => "harmonyos".to_string(), Some("harmonyos-simulator") => "harmonyos-simulator".to_string(), Some("web") => "web".to_string(), diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index 37b707bdfc..561b2b2dcb 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -793,7 +793,10 @@ pub(super) fn build_optimized_libs( // #1508: same shape for Android — cc-rs can't find the NDK clang // otherwise (silent on Unix where `clang` happens to exist, hard fail // on Windows with `clang.exe not found`). - if matches!(target, Some("android") | Some("android-x86_64")) { + if matches!( + target, + Some("android") | Some("android-x86_64") | Some("wearos") + ) { if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { for (k, v) in super::library_search::android_cross_env(std::path::Path::new(&ndk), target) @@ -821,7 +824,10 @@ pub(super) fn build_optimized_libs( // shadow stack), so those IE TLS relocations get baked into the final // cdylib. Force global-dynamic so the dynamic linker can resolve TLS // slots after the process has started. - if matches!(target, Some("android") | Some("android-x86_64")) { + if matches!( + target, + Some("android") | Some("android-x86_64") | Some("wearos") + ) { rustflags.push(android_global_dynamic_tls_rustflag(&mut cargo_cmd)); } if !rustflags.is_empty() { @@ -1108,7 +1114,7 @@ pub(super) fn build_optimized_libs( | Some("ios-widget") | Some("ios-widget-simulator") => "perry-ui-ios", Some("visionos-simulator") | Some("visionos") => "perry-ui-visionos", - Some("android") => "perry-ui-android", + Some("android") | Some("wearos") => "perry-ui-android", Some("watchos-simulator") | Some("watchos") => "perry-ui-watchos", Some("tvos-simulator") | Some("tvos") => "perry-ui-tvos", Some("linux") => "perry-ui-gtk4", diff --git a/crates/perry/src/commands/compile/post_link.rs b/crates/perry/src/commands/compile/post_link.rs index 389a34b29c..2b56302909 100644 --- a/crates/perry/src/commands/compile/post_link.rs +++ b/crates/perry/src/commands/compile/post_link.rs @@ -42,6 +42,9 @@ pub(super) fn strip_final_binary( || is_watchos || is_harmonyos || target == Some("android") + // Wear OS ships the same dlopen'd .so — stripping would drop the + // no_mangle JNI/FFI symbols PerryActivity resolves at load. + || target == Some("wearos") || std::env::var("PERRY_DEBUG_SYMBOLS").is_ok() { return; diff --git a/crates/perry/src/commands/compile/resolve/native_library.rs b/crates/perry/src/commands/compile/resolve/native_library.rs index 2b05899791..e123f432c8 100644 --- a/crates/perry/src/commands/compile/resolve/native_library.rs +++ b/crates/perry/src/commands/compile/resolve/native_library.rs @@ -55,7 +55,8 @@ pub(super) fn native_manifest_target_key(target: Option<&str>) -> &'static str { match target { Some("ios-simulator") | Some("ios") => "ios", Some("visionos-simulator") | Some("visionos") => "visionos", - Some("android") => "android", + // Wear OS resolves native-addon config from the [android] target. + Some("android") | Some("wearos") => "android", Some("tvos-simulator") | Some("tvos") => "tvos", Some("watchos-simulator") | Some("watchos") => "watchos", Some("harmonyos-simulator") | Some("harmonyos") => "harmonyos", @@ -1314,7 +1315,7 @@ fn arch_for_target_key(target: Option<&str>) -> Option<&'static str> { Some("macos") => Some("arm64"), Some("linux") => Some("x64"), Some("windows") | Some("windows-winui") => Some("x64"), - Some("android") => Some("arm64"), + Some("android") | Some("wearos") => Some("arm64"), Some("harmonyos") => Some("arm64"), Some("harmonyos-simulator") => Some("x64"), // ios/tvos/watchos/visionos: device builds are always arm64 (or diff --git a/crates/perry/src/commands/publish/mod.rs b/crates/perry/src/commands/publish/mod.rs index e3304da2f3..47ca5ff136 100644 --- a/crates/perry/src/commands/publish/mod.rs +++ b/crates/perry/src/commands/publish/mod.rs @@ -59,7 +59,7 @@ pub fn run(args: PublishArgs, format: OutputFormat, use_color: bool, _verbose: u let target_hint = match args.platform { Some(Platform::Ios) => Some("ios"), Some(Platform::Visionos) => Some("visionos"), - Some(Platform::Android) => Some("android"), + Some(Platform::Android) | Some(Platform::Wearos) => Some("android"), Some(Platform::Linux) => Some("linux"), Some(Platform::Windows) => Some("windows"), Some(Platform::Web) => Some("web"), @@ -150,6 +150,8 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> Platform::Watchos => "watchos".to_string(), Platform::Tvos => "tvos".to_string(), Platform::Android => "android".to_string(), + // Wear OS ships through Google Play exactly like an Android app. + Platform::Wearos => "android".to_string(), Platform::Linux => "linux".to_string(), Platform::Windows => "windows".to_string(), Platform::Web => "web".to_string(), diff --git a/crates/perry/src/commands/publish/preflight.rs b/crates/perry/src/commands/publish/preflight.rs index 4cd1f552e9..b265cb8617 100644 --- a/crates/perry/src/commands/publish/preflight.rs +++ b/crates/perry/src/commands/publish/preflight.rs @@ -48,6 +48,7 @@ pub(super) async fn run_security_audit_step( Some(Platform::Ios) | Some(Platform::Visionos) | Some(Platform::Android) + | Some(Platform::Wearos) | Some(Platform::Macos) | Some(Platform::Tvos) | Some(Platform::Watchos) diff --git a/crates/perry/src/commands/run/android.rs b/crates/perry/src/commands/run/android.rs index 489944b6e4..6a628b917b 100644 --- a/crates/perry/src/commands/run/android.rs +++ b/crates/perry/src/commands/run/android.rs @@ -15,6 +15,31 @@ pub fn build_and_run_android( bundle_id: &str, serial: &str, format: OutputFormat, +) -> Result<()> { + build_and_run_android_impl(so_path, bundle_id, serial, format, false) +} + +/// Build + install a Wear OS APK. Wear OS is Android-on-a-watch, so this reuses +/// the exact same Gradle project, Kotlin bridge, and compiled `.so` as the phone +/// path (`build_and_run_android`) — the only differences are the watch +/// form-factor declarations applied by `apply_wear_overlay`: the +/// `android.hardware.type.watch` feature + the standalone meta-data + the +/// `androidx.wear` dependency. +pub fn build_and_run_wearos( + so_path: &Path, + bundle_id: &str, + serial: &str, + format: OutputFormat, +) -> Result<()> { + build_and_run_android_impl(so_path, bundle_id, serial, format, true) +} + +fn build_and_run_android_impl( + so_path: &Path, + bundle_id: &str, + serial: &str, + format: OutputFormat, + wear: bool, ) -> Result<()> { // Find the perry workspace root to locate the Android template let workspace_root = super::super::compile::find_perry_workspace_root() @@ -42,6 +67,14 @@ pub fn build_and_run_android( copy_dir_recursive(&template_dir, &build_dir) .map_err(|e| anyhow!("Failed to copy Android template: {}", e))?; + // Wear OS: overlay the watch form-factor onto the copied phone template + // (manifest feature + standalone meta-data, Wear minSdk, androidx.wear dep). + if wear { + if let Err(e) = apply_wear_overlay(&build_dir, format) { + bail!("Failed to apply Wear OS template overlay: {}", e); + } + } + // Create jniLibs directory and copy .so let jni_dir = build_dir.join("app/src/main/jniLibs/arm64-v8a"); std::fs::create_dir_all(&jni_dir)?; @@ -235,8 +268,15 @@ pub fn build_and_run_android( println!("Running Gradle assembleDebug..."); } - // Run gradle build - let gradle_status = Command::new(&gradlew) + // Run gradle build. `gradlew` is `build_dir.join("gradlew")`, which is a + // RELATIVE path when the compile output (and thus build_dir) is relative — + // e.g. `android-build/gradlew`. Spawning a relative program path that + // contains a `/` while also setting `.current_dir(&build_dir)` resolves the + // program against the *new* cwd, i.e. `android-build/android-build/gradlew`, + // which fails with ENOENT. Canonicalize to an absolute path so the spawn is + // independent of the child's working directory. + let gradlew_abs = std::fs::canonicalize(&gradlew).unwrap_or_else(|_| gradlew.clone()); + let gradle_status = Command::new(&gradlew_abs) .arg("assembleDebug") .current_dir(&build_dir) .status() @@ -265,6 +305,65 @@ pub fn build_and_run_android( install_and_launch_android(&apk_path, bundle_id, serial, format) } +/// Turn the copied phone Gradle project into a Wear OS app in place. +/// +/// Wear OS apps are ordinary Android apps with three extra declarations: +/// 1. `` — marks +/// the APK as a watch app (Play Store filtering + launcher placement). +/// 2. `` — the app +/// runs without a companion phone app. +/// 3. `androidx.wear:wear` — pulls in `BoxInsetLayout` / swipe-to-dismiss so +/// round screens and the back gesture behave like a native Wear app. +/// minSdk is also raised to 30 (Wear OS 3), the floor Google Play requires for +/// watch APKs. +fn apply_wear_overlay(build_dir: &Path, format: OutputFormat) -> Result<()> { + // --- AndroidManifest.xml --- + let manifest_path = build_dir.join("app/src/main/AndroidManifest.xml"); + if manifest_path.exists() { + let mut manifest = std::fs::read_to_string(&manifest_path)?; + + // 1. Watch hardware feature — required, right after the tag. + let manifest_open = + ""; + if manifest.contains(manifest_open) && !manifest.contains("android.hardware.type.watch") { + manifest = manifest.replacen( + manifest_open, + &format!( + "{manifest_open}\n\n " + ), + 1, + ); + } + + // 2. Standalone meta-data + the wearable shared library, inserted just + // before the existing Maps API key meta-data inside . + let maps_anchor = + " \n \n"; + manifest = manifest.replacen(maps_anchor, &format!("{wear_meta}{maps_anchor}"), 1); + } + + std::fs::write(&manifest_path, &manifest)?; + } + + // --- app/build.gradle.kts --- + let gradle_path = build_dir.join("app/build.gradle.kts"); + if gradle_path.exists() { + let mut gradle = std::fs::read_to_string(&gradle_path)?; + // Wear OS 3 is the minSdk floor for watch APKs on Google Play. + gradle = gradle.replace("minSdk = 24", "minSdk = 30"); + // Add the Wear support library (BoxInsetLayout, swipe-to-dismiss). + gradle = inject_gradle_dependencies(&gradle, &["androidx.wear:wear:1.3.0".to_string()]); + std::fs::write(&gradle_path, gradle)?; + } + + if let OutputFormat::Text = format { + println!(" Wear OS overlay: watch feature + standalone + androidx.wear (minSdk 30)"); + } + Ok(()) +} + /// Issue #583 — read `package.json` `perry.deepLinks` and rewrite the /// AndroidManifest.xml inside the materialized template directory. /// @@ -886,3 +985,99 @@ pub fn get_android_pid(serial: &str, bundle_id: &str) -> String { } String::new() } + +#[cfg(test)] +mod tests { + use super::*; + + /// `apply_wear_overlay` must transform a copy of the *real* Android template + /// into a Wear OS project: watch feature + standalone meta-data in the + /// manifest, and `androidx.wear` + `minSdk = 30` in the Gradle build. This + /// runs against the checked-in template so anchor drift (a renamed tag or a + /// changed minSdk) fails here instead of silently producing a phone APK. + #[test] + fn wear_overlay_applies_to_real_template() { + let template = Path::new(env!("CARGO_MANIFEST_DIR")).join("../perry-ui-android/template"); + let manifest_src = template.join("app/src/main/AndroidManifest.xml"); + let gradle_src = template.join("app/build.gradle.kts"); + assert!( + manifest_src.exists() && gradle_src.exists(), + "android template not found at {}", + template.display() + ); + + // Materialize a throwaway build dir with just the two files the overlay + // touches, mirroring the layout build_and_run_android_impl produces. + let build_dir = + std::env::temp_dir().join(format!("perry_wear_overlay_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&build_dir); + let app_main = build_dir.join("app/src/main"); + std::fs::create_dir_all(&app_main).unwrap(); + std::fs::copy(&manifest_src, app_main.join("AndroidManifest.xml")).unwrap(); + std::fs::copy(&gradle_src, build_dir.join("app/build.gradle.kts")).unwrap(); + + apply_wear_overlay(&build_dir, OutputFormat::Json).unwrap(); + + let manifest = std::fs::read_to_string(app_main.join("AndroidManifest.xml")).unwrap(); + let gradle = std::fs::read_to_string(build_dir.join("app/build.gradle.kts")).unwrap(); + + assert!( + manifest.contains("android.hardware.type.watch"), + "watch uses-feature not injected" + ); + assert!( + manifest.contains("com.google.android.wearable.standalone"), + "standalone meta-data not injected" + ); + assert!( + gradle.contains("androidx.wear:wear"), + "androidx.wear dependency not injected" + ); + assert!( + gradle.contains("minSdk = 30") && !gradle.contains("minSdk = 24"), + "minSdk not raised to 30 for Wear OS" + ); + + let _ = std::fs::remove_dir_all(&build_dir); + } + + /// The overlay must be idempotent — running it twice (e.g. a rebuild into a + /// reused dir) must not double-inject the watch feature or wear deps. + #[test] + fn wear_overlay_is_idempotent() { + let template = Path::new(env!("CARGO_MANIFEST_DIR")).join("../perry-ui-android/template"); + let build_dir = + std::env::temp_dir().join(format!("perry_wear_overlay_idem_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&build_dir); + let app_main = build_dir.join("app/src/main"); + std::fs::create_dir_all(&app_main).unwrap(); + std::fs::copy( + template.join("app/src/main/AndroidManifest.xml"), + app_main.join("AndroidManifest.xml"), + ) + .unwrap(); + std::fs::copy( + template.join("app/build.gradle.kts"), + build_dir.join("app/build.gradle.kts"), + ) + .unwrap(); + + apply_wear_overlay(&build_dir, OutputFormat::Json).unwrap(); + apply_wear_overlay(&build_dir, OutputFormat::Json).unwrap(); + + let manifest = std::fs::read_to_string(app_main.join("AndroidManifest.xml")).unwrap(); + let gradle = std::fs::read_to_string(build_dir.join("app/build.gradle.kts")).unwrap(); + assert_eq!( + manifest.matches("android.hardware.type.watch").count(), + 1, + "watch feature injected more than once" + ); + assert_eq!( + gradle.matches("androidx.wear:wear").count(), + 1, + "androidx.wear dependency injected more than once" + ); + + let _ = std::fs::remove_dir_all(&build_dir); + } +} diff --git a/crates/perry/src/commands/run/devices.rs b/crates/perry/src/commands/run/devices.rs index f788db3001..25239e1980 100644 --- a/crates/perry/src/commands/run/devices.rs +++ b/crates/perry/src/commands/run/devices.rs @@ -254,6 +254,20 @@ pub fn detect_android_devices() -> Result> { Ok(devices) } +/// True if the adb device with this serial is a Wear OS watch, i.e. its +/// `ro.build.characteristics` property contains `watch`. Used to keep +/// `perry run wearos` from selecting a paired phone connected over the same +/// adb. Returns `false` if the property can't be read (treat as non-watch). +pub fn is_wear_os_device(serial: &str) -> bool { + Command::new("adb") + .args(["-s", serial, "shell", "getprop", "ro.build.characteristics"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).contains("watch")) + .unwrap_or(false) +} + /// Pick a device from a list using dialoguer, or auto-select if non-interactive pub fn pick_device(devices: &[DeviceInfo], label: &str) -> Result { let names: Vec = devices diff --git a/crates/perry/src/commands/run/entry.rs b/crates/perry/src/commands/run/entry.rs index b025fca7e2..ae703f0c17 100644 --- a/crates/perry/src/commands/run/entry.rs +++ b/crates/perry/src/commands/run/entry.rs @@ -32,6 +32,8 @@ pub fn rust_target_triple(target: Option<&str>) -> Option<&'static str> { Some("tvos-simulator") => Some("aarch64-apple-tvos-sim"), Some("tvos") => Some("aarch64-apple-tvos"), Some("android") => Some("aarch64-linux-android"), + // Wear OS is Android-on-a-watch: same arm64 Android toolchain/.so. + Some("wearos") => Some("aarch64-linux-android"), _ => None, } } @@ -103,6 +105,31 @@ pub fn resolve_target( }; Ok((Some("android".to_string()), Some(serial))) } + Some(Platform::Wearos) => { + // Wear OS runs over adb just like a phone — the connected device is + // a watch or a Wear emulator. Same detection, but filter to actual + // watches (`ro.build.characteristics` contains `watch`) so a paired + // phone on the same adb isn't selected. The `wearos` target string + // routes packaging to the Wear Gradle template at launch. + let devices: Vec = detect_android_devices()? + .into_iter() + .filter(|d| is_wear_os_device(&d.udid)) + .collect(); + if devices.is_empty() { + return Err(anyhow!( + "No Wear OS devices found. Pair a watch over adb or start a Wear OS emulator, then try again.\n\ + Create one: avdmanager create avd -n perry_wear \\\n \ + -k \"system-images;android-34;android-wear;arm64-v8a\" -d wearos_large_round\n\ + Boot it: emulator -avd perry_wear" + )); + } + let serial = if devices.len() == 1 { + devices[0].udid.clone() + } else { + pick_device(&devices, "Wear OS device")? + }; + Ok((Some("wearos".to_string()), Some(serial))) + } Some(Platform::Ios) => { if let Some(ref udid) = args.simulator { return Ok((Some("ios-simulator".to_string()), Some(udid.clone()))); diff --git a/crates/perry/src/commands/run/launch.rs b/crates/perry/src/commands/run/launch.rs index 39549ec1ef..127b6904d7 100644 --- a/crates/perry/src/commands/run/launch.rs +++ b/crates/perry/src/commands/run/launch.rs @@ -72,6 +72,11 @@ pub fn launch( let serial = device_udid.unwrap_or(""); build_and_run_android(&result.output_path, bundle_id, serial, format) } + "wearos" => { + let bundle_id = result.bundle_id.as_deref().unwrap_or("com.perry.app"); + let serial = device_udid.unwrap_or(""); + build_and_run_wearos(&result.output_path, bundle_id, serial, format) + } _ => launch_native(&result.output_path, program_args, format), } } diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index 4f1f6d0862..0c8479a52e 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -18,14 +18,15 @@ mod remote; mod resign; pub use android::{ - build_and_run_android, debug_sign_apk, find_apksigner, find_latest_build_tool, get_android_pid, - inject_android_deeplinks, inject_google_auth_android_resources, inject_gradle_dependencies, - install_and_launch_android, wire_native_lib_kotlin_sources, + build_and_run_android, build_and_run_wearos, debug_sign_apk, find_apksigner, + find_latest_build_tool, get_android_pid, inject_android_deeplinks, + inject_google_auth_android_resources, inject_gradle_dependencies, install_and_launch_android, + wire_native_lib_kotlin_sources, }; pub use devices::{ detect_android_devices, detect_booted_simulators, detect_booted_tv_simulators, detect_booted_visionos_simulators, detect_booted_watch_simulators, detect_ios_devices, - pick_device, pick_from_list, DeviceInfo, + is_wear_os_device, pick_device, pick_from_list, DeviceInfo, }; pub use entry::{ can_compile_locally, read_perry_toml_entry, resolve_entry_file, resolve_target, @@ -51,7 +52,7 @@ pub use resign::{ #[derive(Args, Debug)] pub struct RunArgs { /// Positional args: [platform] [input]. Platform is one of: macos, ios, - /// visionos, watchos, tvos, android, linux, windows, web. If the first arg is not a + /// visionos, watchos, tvos, android, wearos, linux, windows, web. If the first arg is not a /// known platform it is treated as the input file/directory. pub positional: Vec, @@ -129,6 +130,7 @@ pub fn parse_platform(s: &str) -> Option { "watchos" => Some(Platform::Watchos), "tvos" => Some(Platform::Tvos), "android" => Some(Platform::Android), + "wearos" | "wear" | "wear-os" => Some(Platform::Wearos), "linux" => Some(Platform::Linux), "windows" => Some(Platform::Windows), "web" => Some(Platform::Web), @@ -154,6 +156,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> | Some("visionos-simulator") | Some("visionos") | Some("android") + | Some("wearos") | Some("watchos-simulator") | Some("watchos") | Some("tvos-simulator") diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index 75bf26baa8..09ff4a9216 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -77,6 +77,10 @@ pub enum Platform { Watchos, Tvos, Android, + /// Wear OS — Android on a watch. Shares the perry-ui-android backend and + /// `aarch64-linux-android` toolchain; only the APK packaging differs. + #[value(alias = "wear", alias = "wear-os")] + Wearos, Linux, Windows, Web, diff --git a/docs/examples/platforms/ui/wearos_app.ts b/docs/examples/platforms/ui/wearos_app.ts new file mode 100644 index 0000000000..3101c2099d --- /dev/null +++ b/docs/examples/platforms/ui/wearos_app.ts @@ -0,0 +1,27 @@ +// demonstrates: minimal Wear OS App() lifecycle snippet from the Wear OS docs page +// docs: docs/src/platforms/wearos.md +// platforms: macos, linux, windows + +// On Wear OS this lowers through the same Android View backend (perry-ui-android, +// JNI → TextView/LinearLayout/Button/...) used for phone apps — Wear OS *is* +// Android on a watch. `perry run wearos` builds the same `.so`, then packages it +// with the watch form-factor overlay (uses-feature android.hardware.type.watch, +// standalone, androidx.wear). On macOS / Linux / Windows the same TypeScript +// lowers through the host's native UI library. + +// ANCHOR: wearos-app +import { App, Text, VStack, Button, State } from "perry/ui" + +const count = State(0) + +App({ + title: "My Watch App", + width: 200, + height: 200, + body: VStack(8, [ + Text("Hello, Wear OS!"), + Text(`Taps: ${count.value}`), + Button("Tap me", () => count.set(count.value + 1)), + ]), +}) +// ANCHOR_END: wearos-app diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c5c52b2be6..e246778445 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -70,6 +70,7 @@ - [watchOS](platforms/watchos.md) - [Publishing to the App Store](platforms/watchos-app-store.md) - [Android](platforms/android.md) +- [Wear OS](platforms/wearos.md) - [HarmonyOS NEXT](platforms/harmonyos.md) - [Windows](platforms/windows.md) - [Windows 7 Compatibility](platforms/windows-7.md) diff --git a/docs/src/platforms/android.md b/docs/src/platforms/android.md index ae7cf8d135..62b5d48a28 100644 --- a/docs/src/platforms/android.md +++ b/docs/src/platforms/android.md @@ -91,5 +91,6 @@ See [Project Configuration](../getting-started/project-config.md#splash) for the ## Next Steps +- [Wear OS](wearos.md) — full watch apps on the same Android backend - [Platform Overview](overview.md) — All platforms - [UI Overview](../ui/overview.md) — UI system diff --git a/docs/src/platforms/overview.md b/docs/src/platforms/overview.md index 452a29383e..85bf890790 100644 --- a/docs/src/platforms/overview.md +++ b/docs/src/platforms/overview.md @@ -1,6 +1,6 @@ # Platform Overview -Perry compiles TypeScript to native executables for 9 platform families from the same source code. +Perry compiles TypeScript to native executables for 10 platform families from the same source code. ## Supported Platforms @@ -12,6 +12,7 @@ Perry compiles TypeScript to native executables for 9 platform families from the | tvOS | `--target tvos` / `--target tvos-simulator` | UIKit | Full support (focus engine + game controllers) | | watchOS | `--target watchos` / `--target watchos-simulator` | SwiftUI (data-driven) | Core support (15 widgets) | | Android | `--target android` | JNI/Android SDK | Full support (112/112) | +| Wear OS | `--target wearos` | JNI/Android SDK (shared) | Android backend on a watch | | Windows | `--target windows` | Win32 | Full support (112/112) | | Linux | `--target linux` | GTK4 | Full support (112/112) | | Web / WebAssembly | `--target web` *(alias `--target wasm`)* | DOM/CSS via WASM bridge | Full support (168 widgets) | @@ -31,6 +32,7 @@ perry app.ts -o app --target web # alias: --target wasm perry app.ts -o app --target windows perry app.ts -o app --target linux perry app.ts -o app --target android +perry app.ts -o app --target wearos # Wear OS — Android on a watch ``` ## Platform Detection @@ -64,6 +66,7 @@ Use the `__platform__` compile-time constant to branch by platform: - [tvOS](tvos.md) - [watchOS](watchos.md) - [Android](android.md) +- [Wear OS](wearos.md) - [Windows](windows.md) - [Linux (GTK4)](linux.md) - [Web](web.md) diff --git a/docs/src/platforms/wearos.md b/docs/src/platforms/wearos.md new file mode 100644 index 0000000000..db11dd14da --- /dev/null +++ b/docs/src/platforms/wearos.md @@ -0,0 +1,169 @@ +# Wear OS + +Perry can compile TypeScript apps for Wear OS watches and the Wear OS emulator. + +Wear OS **is Android on a watch**, so Perry reuses the exact same backend as the +[Android](android.md) target: your `perry/ui` tree lowers through `perry-ui-android` +(JNI → `TextView` / `LinearLayout` / `Button` / …), and the compiled +`aarch64-linux-android` `.so` is identical to a phone build. `perry run wearos` +then packages it with a **watch form-factor overlay** — the +`android.hardware.type.watch` feature, the standalone meta-data, and the +`androidx.wear` support library — and installs it over `adb` like any other APK. + +> This page is about **full Wear OS apps**. For glanceable Wear OS **Tiles** +> (the swipe-left surfaces, built from `Widget({...})` declarations), see +> [Wear OS Tiles](../widgets/wearos.md). + +## Requirements + +Wear OS uses the same toolchain as the [Android](android.md) target (the +`.so` is cross-compiled and the APK is built with Gradle), plus a Wear OS system +image. You need all of: + +| Tool | Why | Notes | +|---|---|---| +| **JDK 17** | Runs Gradle / the Android Gradle Plugin | The template uses **AGP 8.8.2**, which targets **JDK 17**. Newer JDKs (21/26) can fail the Gradle build — point `JAVA_HOME` at a 17 if your default is newer. | +| **Gradle 8.x** | `perry run wearos` bootstraps a Gradle wrapper from the system `gradle` | AGP 8.8.2 requires **Gradle 8.10.2+** and is **not** compatible with Gradle 9.x. | +| **Android SDK** | `adb`, `aapt`, signing, the `android-35` platform | `platform-tools`, `build-tools;35.0.0`, `platforms;android-35`. Set `ANDROID_HOME`. | +| **Android NDK (r27+)** | Cross-compiles the runtime/`.so` for `aarch64-linux-android` | Set `ANDROID_NDK_HOME`. r27+ is fine — Perry points `cc-rs` at the NDK `llvm-ar`. | +| **Rust Android target** | The `.so` is `aarch64-linux-android` | `rustup target add aarch64-linux-android`. Wear is **arm64-only** — NaN-boxing needs 64-bit pointers. | +| **Wear OS system image + emulator** | A watch to install onto | Use an **`arm64-v8a`** image (Perry packages an `arm64-v8a` `libperry_app.so`). | + +### One-time setup (macOS example) + +```bash +# 1. JDK 17 + Gradle 8.x (Homebrew's `gradle` may be 9.x — pin 8.x if so) +brew install --cask temurin@17 +brew install gradle@8 # or any Gradle 8.10.2+ + +# 2. Point the env at the SDK / NDK / JDK 17 (add to your shell profile) +export ANDROID_HOME="$HOME/Library/Android/sdk" +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/" # e.g. 28.2.13676358 +export JAVA_HOME="$(/usr/libexec/java_home -v 17)" +export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH" + +# 3. SDK packages + a Wear OS arm64 image +sdkmanager --licenses +sdkmanager "platform-tools" "emulator" \ + "platforms;android-35" "build-tools;35.0.0" \ + "ndk;28.2.13676358" \ + "system-images;android-34;android-wear;arm64-v8a" + +# 4. The Rust cross target +rustup target add aarch64-linux-android + +# 5. Create + boot a Wear OS emulator (round watch) +avdmanager create avd -n perry_wear \ + -k "system-images;android-34;android-wear;arm64-v8a" -d wearos_large_round +emulator -avd perry_wear +``` + +On Apple-silicon Macs the `arm64-v8a` image runs natively; on Intel hosts it runs +under the emulator's arm translation. The first `perry run wearos` build also +downloads the AGP and `androidx.wear` dependencies from Google's Maven repo, so +the initial build needs network access. + +## Building + +```bash +perry compile app.ts -o app --target wearos +``` + +This cross-compiles to `aarch64-linux-android` and emits `libperry_app.so` — the +same artifact as `--target android`. Packaging into a watch APK happens at run +time (below). + +## Running with `perry run` + +```bash +perry run wearos # Auto-detect a connected watch / booted Wear emulator +``` + +`perry run wearos`: + +1. Cross-compiles the `.so` (identical to the Android path). +2. Copies the Android Gradle template and applies the Wear overlay: + - adds `` + - adds `` + - adds `implementation("androidx.wear:wear:1.3.0")` + - raises `minSdk` to 30 (Wear OS 3, the Google Play floor for watch APKs) +3. Runs `./gradlew assembleDebug`, debug-signs, then `adb install` + launches and + streams `logcat`. + +`wear` and `wear-os` are accepted as aliases for `wearos`. + +## UI Toolkit + +Identical to [Android](android.md) — the same `perry/ui` widgets map to the same +Android `View` classes (`Text` → `TextView`, `VStack` → vertical `LinearLayout`, +`Button` → `Button`, `ScrollView` → `ScrollView`, and so on). No Wear-specific +widget API is required: an Android UI tree renders directly on a watch. + +The `androidx.wear` dependency in the overlay brings in `BoxInsetLayout` and +swipe-to-dismiss so round screens and the back gesture behave like a native Wear +app. + +## App Lifecycle + +A Wear OS app uses the same `App({...})` entry point as every other Perry UI +target: + +```typescript +{{#include ../../examples/platforms/ui/wearos_app.ts:wearos-app}} +``` + +Under the hood this is the Android lifecycle: `PerryActivity` loads +`libperry_app.so`, calls into your compiled `main()` over JNI, and the +`perry/ui` tree is realized as Android `View`s on the watch. + +## State Management + +Reactive state works exactly as on Android and the other platforms: + +```typescript +{{#include ../../examples/platforms/ui/counter_app.ts:counter}} +``` + +## Platform Detection + +Because the runtime target triple is `aarch64-linux-android`, a Wear OS app +reports the **Android** platform number — `__platform__ === 2`: + +```typescript +{{#include ../../examples/platforms/platform_detect.ts:overview-detect}} +``` + +There is intentionally no separate Wear OS platform constant: at runtime a Wear +app *is* an Android app. Branch on screen size at runtime if you need +watch-specific layout. + +## Configuration + +Wear OS reuses the `[android]` section of `perry.toml` (bundle id, etc.): + +```toml +[android] +bundle_id = "com.example.mywatch" +``` + +## Limitations + +Wear OS inherits Android's widget set but the watch form factor imposes the usual +constraints: + +- **Small round screens** — design for ~1.2–1.4" displays; prefer `ScrollView` + and short labels. Use the `androidx.wear` `BoxInsetLayout` insets for round + bezels. +- **Touch-only** — no hover or right-click. +- **Single window** — modal flows map to `Dialog` views, same as Android. +- **Battery / memory** — keep apps lightweight; Wear devices have far less RAM + than phones. +- **Publishing** — Wear apps ship through Google Play as Android APK/AABs (the + `perry publish` flow treats Wear like Android). + +## Next Steps + +- [Android](android.md) — the shared backend, UI mapping, and APK details +- [Wear OS Tiles](../widgets/wearos.md) — glanceable Tile surfaces +- [Platform Overview](overview.md) — all platforms +- [UI Overview](../ui/overview.md) — the `perry/ui` system diff --git a/docs/src/widgets/wearos.md b/docs/src/widgets/wearos.md index b41f04a29e..ca1f704037 100644 --- a/docs/src/widgets/wearos.md +++ b/docs/src/widgets/wearos.md @@ -2,6 +2,8 @@ Perry widgets can compile to Wear OS Tiles using `--target wearos-tile`. Tiles are glanceable surfaces in the Wear OS tile carousel and watch face complications. +For full **Wear OS apps** (not glanceable Tiles), see [Wear OS](../platforms/wearos.md). + > **Status:** the snippet on this page compile-links cleanly on the host LLVM > target via [`docs/examples/widgets/snippets.ts`](https://github.com/PerryTS/perry/blob/main/docs/examples/widgets/snippets.ts), so the > `Widget({...})` shape is verified against the codegen.