From 211639d650c9d26a14019ae78f947186909751c8 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 14 Jun 2026 14:05:37 -0700 Subject: [PATCH 1/3] feat(wearos): full Wear OS app target (--target wearos / perry run wearos) Wear OS is Android on a watch, so this reuses the perry-ui-android backend and the aarch64-linux-android toolchain; only the APK packaging differs. `Platform::Wearos` (+ `wearos`/`wear` aliases) cross-compiles the same .so as `android` and packages it through a watch form-factor overlay applied to the Android Gradle template: the android.hardware.type.watch feature, the com.google.android.wearable.standalone meta-data, the androidx.wear dependency, and minSdk 30 (the Google Play floor for watch APKs). Everything else (jniLibs, companion .so bundling, signing, install, logcat) is shared with the phone path, so no new UI crate or CI exclusion is needed. Also fixes three issues found while bringing the target up on a real Wear OS emulator, all of which also affect the existing Android path: - android_cross_env now sets AR=llvm-ar. NDK r27+ removed the per-target `aarch64-linux-android-ar` wrapper that cc-rs probes for, so the runtime/ stdlib C deps (mimalloc) failed to cross-build. - resolve_target_triple maps `wearos` -> aarch64-unknown-linux-android so codegen emits Android ELF objects (it was falling back to the host Mach-O triple, producing `.o: unknown file type` at link). - the `gradlew` invocation uses an absolute path. A relative `android-build/ gradlew` combined with `.current_dir(&build_dir)` resolved against the new cwd (`android-build/android-build/gradlew`) and failed `perry run` with ENOENT. Verified end-to-end on a Wear OS 5 (API 34) arm64 emulator: `perry run wearos` compiles the Android ELF .so, builds a watch APK (watch feature + standalone + bundled libperry_app.so), installs, and launches; libperry_app.so loads over JNI without crash and the perry/ui Text+Button tree renders as native Android views. Adds an apply_wear_overlay regression test, a docs page, and an example. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-codegen/src/codegen/helpers.rs | 2 + crates/perry/src/commands/compile.rs | 5 +- .../src/commands/compile/app_metadata.rs | 4 + .../src/commands/compile/library_search.rs | 22 +- crates/perry/src/commands/compile/link/mod.rs | 3 +- .../perry/src/commands/compile/lock_scan.rs | 3 +- .../src/commands/compile/optimized_libs.rs | 12 +- .../perry/src/commands/compile/post_link.rs | 3 + .../compile/resolve/native_library.rs | 5 +- crates/perry/src/commands/publish/mod.rs | 4 +- .../perry/src/commands/publish/preflight.rs | 1 + crates/perry/src/commands/run/android.rs | 197 +++++++++++++++++- crates/perry/src/commands/run/entry.rs | 22 ++ crates/perry/src/commands/run/launch.rs | 5 + crates/perry/src/commands/run/mod.rs | 7 +- crates/perry/src/main.rs | 3 + docs/examples/platforms/ui/wearos_app.ts | 27 +++ docs/src/SUMMARY.md | 1 + docs/src/platforms/android.md | 1 + docs/src/platforms/overview.md | 3 + docs/src/platforms/wearos.md | 144 +++++++++++++ docs/src/widgets/wearos.md | 2 + 22 files changed, 458 insertions(+), 18 deletions(-) create mode 100644 docs/examples/platforms/ui/wearos_app.ts create mode 100644 docs/src/platforms/wearos.md 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..87e8c4b1fa 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,14 @@ 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 +1111,8 @@ 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 +1279,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 +1302,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 +1373,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..b306185b5f 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,63 @@ 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 +983,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/entry.rs b/crates/perry/src/commands/run/entry.rs index b025fca7e2..8b02f306b0 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,26 @@ 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; the `wearos` target + // string routes packaging to the Wear Gradle template at launch. + let devices = detect_android_devices()?; + 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..bf982ad9af 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -18,7 +18,8 @@ mod remote; mod resign; pub use android::{ - build_and_run_android, debug_sign_apk, find_apksigner, find_latest_build_tool, get_android_pid, + 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, }; @@ -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..4518a339a2 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -77,6 +77,9 @@ 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. + 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..14120bca3d 100644 --- a/docs/src/platforms/overview.md +++ b/docs/src/platforms/overview.md @@ -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..f4503c89ec --- /dev/null +++ b/docs/src/platforms/wearos.md @@ -0,0 +1,144 @@ +# 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 + +Same toolchain as the [Android](android.md) target, plus a Wear OS system image: + +- **JDK 17** and **Gradle** (`brew install --cask temurin@17` / `brew install gradle`) +- **Android SDK + NDK** (set `ANDROID_HOME` and `ANDROID_NDK_HOME`) +- The Rust Android target (Wear is arm64-only — NaN-boxing needs 64-bit pointers): + ```bash + rustup target add aarch64-linux-android + ``` +- A **Wear OS** system image + AVD: + ```bash + sdkmanager "platforms;android-35" "build-tools;35.0.0" \ + "ndk;27.1.12297006" \ + "system-images;android-34;android-wear;arm64-v8a" + + 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 use the `arm64-v8a` Wear image (runs natively). On Intel +hosts use an `x86`/`x86_64` Wear image instead. + +## 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..bac9736dd7 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. From f1fb6bee1fc7f8bd77a4512d63e7b7c50ed1f7f8 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 14 Jun 2026 21:45:14 -0700 Subject: [PATCH 2/3] style(wearos): apply rustfmt to wearos changes Fixes the `lint` CI job (cargo fmt --all -- --check). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry/src/commands/compile/library_search.rs | 11 +++++++++-- crates/perry/src/commands/run/android.rs | 10 ++++++---- crates/perry/src/commands/run/mod.rs | 6 +++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index 87e8c4b1fa..1d8eb3facf 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -1097,7 +1097,11 @@ pub(super) fn android_cross_env(ndk_home: &Path, target: Option<&str>) -> Vec<(S // 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 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('-', "_"); @@ -1112,7 +1116,10 @@ pub(super) fn android_cross_env(ndk_home: &Path, target: Option<&str>) -> Vec<(S clangpp.display().to_string(), ), (format!("AR_{}", triple), llvm_ar.display().to_string()), - (format!("AR_{}", triple_under), llvm_ar.display().to_string()), + ( + format!("AR_{}", triple_under), + llvm_ar.display().to_string(), + ), ( format!("CARGO_TARGET_{}_LINKER", triple_upper), clang.display().to_string(), diff --git a/crates/perry/src/commands/run/android.rs b/crates/perry/src/commands/run/android.rs index b306185b5f..6a628b917b 100644 --- a/crates/perry/src/commands/run/android.rs +++ b/crates/perry/src/commands/run/android.rs @@ -323,7 +323,8 @@ fn apply_wear_overlay(build_dir: &Path, format: OutputFormat) -> Result<()> { let mut manifest = std::fs::read_to_string(&manifest_path)?; // 1. Watch hardware feature — required, right after the tag. - let manifest_open = ""; + let manifest_open = + ""; if manifest.contains(manifest_open) && !manifest.contains("android.hardware.type.watch") { manifest = manifest.replacen( manifest_open, @@ -336,7 +337,8 @@ fn apply_wear_overlay(build_dir: &Path, format: OutputFormat) -> Result<()> { // 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); @@ -1044,8 +1046,8 @@ mod tests { #[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 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(); diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index bf982ad9af..6e9d3c7ddd 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -19,9 +19,9 @@ mod resign; pub use android::{ 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, + 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, From 957246e448cbf3a394cae173fa476220f0f22e12 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 14 Jun 2026 23:53:40 -0700 Subject: [PATCH 3/3] docs(wearos): address review feedback + document toolchain; add README entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review: - main.rs: clap aliases `wear`/`wear-os` on Platform::Wearos (parity with the run command's manual parsing). - run/entry.rs: filter `perry run wearos` device detection to actual watches (ro.build.characteristics contains "watch") so a paired phone on the same adb isn't selected; add run/devices.rs::is_wear_os_device. - docs/platforms/overview.md: 9 -> 10 platform families (Wear OS row added). - docs/platforms/wearos.md: drop the incorrect "use x86/x86_64 Wear image on Intel hosts" guidance — Perry's Wear APKs are arm64-only (arm64-v8a .so). - docs/widgets/wearos.md: fix MD028 (blank line between blockquotes). (Skipped the suggestion to add android/wearos to the example's `// platforms:` header: that directive routes CI host lanes, and the example validates the perry/ui API on host backends exactly like watchos_app.ts.) Docs/README: - Flesh out the wearos.md Requirements with the full verified toolchain (JDK 17 for AGP 8.8.2, Gradle 8.x, NDK r27+, arm64 Wear image + AVD) and a one-time setup block. - Add Wear OS to the README UI-targets table and cross-platform compile examples. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- crates/perry/src/commands/run/devices.rs | 14 +++++ crates/perry/src/commands/run/entry.rs | 11 ++-- crates/perry/src/commands/run/mod.rs | 2 +- crates/perry/src/main.rs | 1 + docs/src/platforms/overview.md | 2 +- docs/src/platforms/wearos.md | 67 ++++++++++++++++-------- docs/src/widgets/wearos.md | 2 +- 8 files changed, 75 insertions(+), 28 deletions(-) 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/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 8b02f306b0..ae703f0c17 100644 --- a/crates/perry/src/commands/run/entry.rs +++ b/crates/perry/src/commands/run/entry.rs @@ -107,9 +107,14 @@ pub fn resolve_target( } Some(Platform::Wearos) => { // Wear OS runs over adb just like a phone — the connected device is - // a watch or a Wear emulator. Same detection; the `wearos` target - // string routes packaging to the Wear Gradle template at launch. - let devices = detect_android_devices()?; + // 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\ diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index 6e9d3c7ddd..0c8479a52e 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -26,7 +26,7 @@ pub use android::{ 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, diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index 4518a339a2..09ff4a9216 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -79,6 +79,7 @@ pub enum Platform { 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, diff --git a/docs/src/platforms/overview.md b/docs/src/platforms/overview.md index 14120bca3d..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 diff --git a/docs/src/platforms/wearos.md b/docs/src/platforms/wearos.md index f4503c89ec..db11dd14da 100644 --- a/docs/src/platforms/wearos.md +++ b/docs/src/platforms/wearos.md @@ -16,27 +16,52 @@ then packages it with a **watch form-factor overlay** — the ## Requirements -Same toolchain as the [Android](android.md) target, plus a Wear OS system image: - -- **JDK 17** and **Gradle** (`brew install --cask temurin@17` / `brew install gradle`) -- **Android SDK + NDK** (set `ANDROID_HOME` and `ANDROID_NDK_HOME`) -- The Rust Android target (Wear is arm64-only — NaN-boxing needs 64-bit pointers): - ```bash - rustup target add aarch64-linux-android - ``` -- A **Wear OS** system image + AVD: - ```bash - sdkmanager "platforms;android-35" "build-tools;35.0.0" \ - "ndk;27.1.12297006" \ - "system-images;android-34;android-wear;arm64-v8a" - - 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 use the `arm64-v8a` Wear image (runs natively). On Intel -hosts use an `x86`/`x86_64` Wear image instead. +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 diff --git a/docs/src/widgets/wearos.md b/docs/src/widgets/wearos.md index bac9736dd7..ca1f704037 100644 --- a/docs/src/widgets/wearos.md +++ b/docs/src/widgets/wearos.md @@ -2,7 +2,7 @@ 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). +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