diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5ae870..5d7c8f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,15 +18,9 @@ jobs: - uses: actions/checkout@v7 with: repository: push-in/pam-native - ref: v0.5.34 + ref: v0.6.1 path: pam-native persist-credentials: false - - uses: actions/checkout@v7 - with: - repository: push-in/pam-mobile-ui-php - ref: v0.2.1 - path: pam-mobile-ui - persist-credentials: false - uses: dtolnay/rust-toolchain@1.97.1 with: components: clippy,rustfmt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ef136d..0c6ed47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v7 with: repository: push-in/pam-native - ref: v0.5.88 + ref: v0.6.1 path: pam-native persist-credentials: false - name: Install pinned Android NDK @@ -128,15 +128,9 @@ jobs: - uses: actions/checkout@v7 with: repository: push-in/pam-native - ref: v0.5.88 + ref: v0.6.1 path: pam-native persist-credentials: false - - uses: actions/checkout@v7 - with: - repository: push-in/pam-mobile-ui-php - ref: v0.2.1 - path: pam-mobile-ui - persist-credentials: false - uses: actions/download-artifact@v8 with: name: pam-android-runtime diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccd449..1f42f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.44 - 2026-08-01 + +- Autolink official and community PAM Native plugins on iOS, including typed + module/view registries, Swift Package dependencies, Apple frameworks, + resources, usage descriptions, entitlements and app extensions. +- Validate plugin compatibility and paths before generation, and emit a + reproducible iOS integration plan alongside the plugin lockfile. +- Bundle PAM Native 0.6.1 as the ecosystem plugin foundation. + ## 0.1.43 - 2026-07-30 - Bundle PAM Native 0.5.88 so mobile plugins can react to package-restricted diff --git a/Cargo.lock b/Cargo.lock index 48026ce..8e63521 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,7 +1269,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "pam" -version = "0.1.43" +version = "0.1.44" dependencies = [ "axum", "axum-server", diff --git a/Cargo.toml b/Cargo.toml index dc0e103..1660b4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pam" -version = "0.1.43" +version = "0.1.44" edition = "2024" rust-version = "1.94" description = "Pam — PHP, Always in Memory: a persistent PHP runtime powered by Rust and the Embed SAPI" diff --git a/README.md b/README.md index ad709d3..e91eea5 100644 --- a/README.md +++ b/README.md @@ -210,24 +210,16 @@ The executable [SaaS API reference](examples/laravel-saas-api) demonstrates the recommended controller, request, service, repository, resource, enum, migration and endpoint-test boundaries. -### Mobile presets +### Mobile preset -The CLI offers two native mobile starting points: +Create a native mobile app backed by PAM Native: ```bash pam init native-core --template mobile -pam init native-ui --template mobile-ui ``` -`mobile` starts with the explicit PAM Native PHP tree and no component-library -dependency. `mobile-ui` installs `pushinbr/pam-mobile-ui`, enables its system -theme and generates a `Hello.pam.php` screen using the official accessible -components. Both presets render through the same PAM Native element tree, Rust -diff engine and Android UI-thread commit path. - -See the complete [PAM Mobile guide](docs/mobile.md) for project structure, -screen generation, tags, typed PHP trees, reusable components, state, forms, -navigation, layout rules, builds, and device testing. +The `mobile` preset starts with the explicit PAM Native PHP tree and renders +through the native element tree, Rust diff engine, and platform UI thread. ### Desktop applications with Servo @@ -795,7 +787,7 @@ pam rpc validate|generate|wasi [arguments] typed SDK and WASI RPC bound pam top [admin URL] live cluster metrics pam doctor [directory] compare CLI, Embed, and Composer pam benchmark http://host/path built-in HTTP benchmark -pam init [directory] --template raw|api|laravel|desktop|mobile|mobile-ui +pam init [directory] --template raw|api|laravel|desktop|mobile scaffold and install a project pam init [directory] --template api --socket add native Socket support pam init [directory] --no-interaction accept the default API preset diff --git a/docs/mobile.md b/docs/mobile.md index c50a698..0e8e9de 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -23,14 +23,10 @@ API or dynamically built trees. Both styles can coexist in one application. ## Create and run an application -Choose one preset: +Create a project from the native preset: ```bash -# PAM Native primitives only pam init my-mobile-app --template mobile - -# PAM Native plus the official PAM UI component library -pam init my-mobile-app --template mobile-ui ``` Connect an Android device with USB debugging enabled, then run: @@ -537,11 +533,6 @@ the native RecyclerView window rather than mounting the entire data set. | Presentation | `Modal`, `ActivityIndicator`, `StatusBar`, `KeyboardAvoidingView` | | Android | `DrawerLayoutAndroid`, `TouchableNativeFeedback`, `InputAccessoryView` | -The `mobile-ui` preset adds PAM UI providers, cards, headings, badges, composed -inputs, checkboxes, radio groups, drawers, popovers, menus, tabs, and other -accessible controls. Prefer library components for product UI and core -primitives for custom layout or low-level integration. - ## Lifecycle Override only the hooks a component needs: diff --git a/scripts/package-runtime.sh b/scripts/package-runtime.sh index c13fdfd..e6078d8 100755 --- a/scripts/package-runtime.sh +++ b/scripts/package-runtime.sh @@ -13,8 +13,7 @@ pam_target=$3 output_directory=$4 package="pam-${pam_version}-${pam_target}" package_root="${output_directory}/${package}" -expected_native_version=0.5.88 -expected_mobile_ui_version=0.2.1 +expected_native_version=0.6.1 test -x "${pam_binary}" || { echo "PAM binary is not executable: ${pam_binary}" >&2 @@ -42,8 +41,7 @@ mkdir -p \ "${package_root}/etc/conf.d" \ "${package_root}/lib/php/extensions" \ "${package_root}/share/pam/runtime" \ - "${package_root}/share/pam/native" \ - "${package_root}/share/pam/mobile-ui" + "${package_root}/share/pam/native" cp "${pam_binary}" "${package_root}/bin/pam" cp "${php_library}" "${package_root}/lib/libphp.so" @@ -52,23 +50,14 @@ test -f pam-native/Cargo.toml || { echo "Pam Native SDK source is missing from the release checkout." >&2 exit 66 } -test -f pam-mobile-ui/composer.json || { - echo "PAM Mobile UI source is missing from the release checkout." >&2 - exit 66 -} native_version=$( sed -n 's/^version = "\([^"]*\)"$/\1/p' pam-native/Cargo.toml | head -n 1 ) -mobile_ui_version=$(tr -d '\r\n' &2 exit 66 } -test "${mobile_ui_version}" = "${expected_mobile_ui_version}" || { - echo "Expected PAM Mobile UI ${expected_mobile_ui_version}, found ${mobile_ui_version}." >&2 - exit 66 -} test -f runtime/catalog.json || { echo "PAM runtime catalog is missing." >&2 exit 66 @@ -126,16 +115,6 @@ for rust_target in aarch64-linux-android x86_64-linux-android; do "pam-native/target/${rust_target}/release/libpam_native_engine.a" \ "${engine_directory}/" done -tar \ - --exclude='./.git' \ - --exclude='./.build' \ - --exclude='./examples' \ - --exclude='./tools/phpstan/vendor' \ - --exclude='*/build' \ - --exclude='*/.gradle' \ - -C pam-mobile-ui -cf - . | - tar -C "${package_root}/share/pam/mobile-ui" -xf - - copy_dependencies() { ldd "$1" | awk '$2 == "=>" { print $1 "|" $3 }' | while IFS='|' read -r library_name library_path; do diff --git a/src/commands.rs b/src/commands.rs index 2c700cc..4ac4dcd 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -170,7 +170,7 @@ impl InitTemplate { "mobile" | "android" | "mobile-pure" => Ok(Self::Mobile), "mobile-ui" | "android-ui" | "mobile+ui" => Ok(Self::MobileUi), _ => Err(format!( - "unknown init template {value:?}; expected raw, api, laravel, desktop, mobile, or mobile-ui" + "unknown init template {value:?}; expected raw, api, laravel, desktop, or mobile" )), } } @@ -1991,12 +1991,6 @@ fn choose_template( ui.heading(format!("{:<25}", "Mobile · Core")), ui.muted("Pure PAM Native primitives") ); - println!( - " {} {} {}", - ui.accent("08"), - ui.heading(format!("{:<25}", "Mobile · Official UI")), - ui.muted("PAM Mobile UI design system · recommended for apps") - ); print!("\n{} ", ui.command("Choose a preset [02] ›")); std::io::stdout() .flush() @@ -2013,7 +2007,6 @@ fn choose_template( "5" => Ok((InitTemplate::Laravel, true)), "6" => Ok((InitTemplate::Desktop, false)), "7" => Ok((InitTemplate::Mobile, false)), - "8" => Ok((InitTemplate::MobileUi, false)), value => Err(format!("invalid init preset {value:?}")), } } diff --git a/src/main.rs b/src/main.rs index 3c55aed..89781f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,8 +638,7 @@ fn run() -> Result { "--template" => { let value = raw_args.next().ok_or_else(|| { CliError::Commands( - "--template requires raw, api, laravel, desktop, mobile, or mobile-ui" - .to_owned(), + "--template requires raw, api, laravel, desktop, or mobile".to_owned(), ) })?; template = Some( diff --git a/src/mobile.rs b/src/mobile.rs index 5d2ca06..81a6415 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -219,13 +219,17 @@ struct AndroidDeepLink { struct NativeModule { name: String, class: String, + #[serde(rename = "iosClass", default)] + ios_class: Option, } #[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct NativeView { name: String, class: String, + #[serde(rename = "iosClass", default)] + ios_class: Option, } #[derive(Debug, Deserialize)] @@ -280,6 +284,8 @@ struct PluginManifest { #[serde(default)] android: PluginAndroid, #[serde(default)] + ios: PluginIos, + #[serde(default)] modules: Vec, #[serde(default)] views: Vec, @@ -328,6 +334,27 @@ struct PluginAndroid { consumer_rules: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PluginIos { + #[serde(default = "default_ios_version")] + minimum_version: String, + #[serde(default)] + source_dirs: Vec, + #[serde(default)] + resource_dirs: Vec, +} + +impl Default for PluginIos { + fn default() -> Self { + Self { + minimum_version: default_ios_version(), + source_dirs: Vec::new(), + resource_dirs: Vec::new(), + } + } +} + #[derive(Debug)] struct NativePlugin { package: String, @@ -363,6 +390,10 @@ fn default_min_sdk() -> u32 { 26 } +fn default_ios_version() -> String { + "15.0".to_owned() +} + fn default_target_sdk() -> u32 { 36 } @@ -425,7 +456,13 @@ pub fn run(arguments: Vec) -> Result { )?; generate_modules(&project, &workspace)?; generate_views(&project, &workspace)?; - println!("Generated Android bindings for {}", project.manifest.name); + let ios_workspace = sync_ios_host(&project, &native_home)?; + generate_ios_modules(&project, &ios_workspace)?; + generate_ios_views(&project, &ios_workspace)?; + println!( + "Generated Android and iOS bindings for {}", + project.manifest.name, + ); Ok(0) } "build" => { @@ -1018,11 +1055,26 @@ fn validate_plugin_manifest( for path in plugin_paths(&manifest.android) { validate_plugin_path(package, root, path)?; } + if !valid_ios_version(&manifest.ios.minimum_version) { + return Err(format!( + "plugin {package} has invalid ios.minimumVersion {:?}", + manifest.ios.minimum_version, + )); + } + for path in plugin_ios_paths(&manifest.ios) { + validate_plugin_path(package, root, path)?; + } for binding in &manifest.modules { validate_binding(package, "module", &binding.name, &binding.class)?; + if let Some(class) = &binding.ios_class { + validate_ios_binding(package, "module", &binding.name, class)?; + } } for binding in &manifest.views { validate_binding(package, "view", &binding.name, &binding.class)?; + if let Some(class) = &binding.ios_class { + validate_ios_binding(package, "view", &binding.name, class)?; + } } Ok(()) } @@ -1071,6 +1123,38 @@ fn plugin_paths(android: &PluginAndroid) -> Vec<&PathBuf> { paths } +fn plugin_ios_paths(ios: &PluginIos) -> Vec<&PathBuf> { + ios.source_dirs.iter().chain(&ios.resource_dirs).collect() +} + +fn valid_ios_version(value: &str) -> bool { + let mut parts = value.split('.'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(major), Some(minor), None) + if major.parse::().is_ok() && minor.parse::().is_ok() + ) +} + +fn validate_ios_binding(package: &str, kind: &str, name: &str, class: &str) -> Result<(), String> { + if class.is_empty() + || class.len() > 255 + || !class.split('.').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + && characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) + }) + { + return Err(format!( + "plugin {package} {kind} {name:?} has invalid iOS class {class:?}", + )); + } + + Ok(()) +} + fn validate_plugin_path(package: &str, root: &Path, path: &Path) -> Result<(), String> { validate_relative_path(path) .map_err(|error| format!("plugin {package} path {}: {error}", path.display()))?; @@ -1672,6 +1756,9 @@ fn prepare(project: &Project, native_home: &Path, abis: &[AndroidAbi]) -> Result generate_modules(project, &workspace)?; generate_views(project, &workspace)?; stage_project(project, &workspace)?; + let ios_workspace = sync_ios_host(project, native_home)?; + generate_ios_modules(project, &ios_workspace)?; + generate_ios_views(project, &ios_workspace)?; Ok(workspace) } @@ -1693,6 +1780,32 @@ fn sync_android_host(project: &Project, native_home: &Path) -> Result Result { + let source = native_home.join("ios"); + let destination = project.root.join(".pam-native/ios"); + fs::create_dir_all(&destination) + .map_err(|error| format!("cannot create {}: {error}", destination.display()))?; + prune_tree(&source, &destination, &[".build"])?; + copy_tree(&source, &destination, &[".build"])?; + + let plugins_root = destination.join("Sources/PamNative/Plugins"); + fs::create_dir_all(&plugins_root) + .map_err(|error| format!("cannot create {}: {error}", plugins_root.display()))?; + for (index, plugin) in project.plugins.iter().enumerate() { + for (source_index, relative) in plugin.manifest.ios.source_dirs.iter().enumerate() { + let plugin_source = plugin.root.join(relative); + let plugin_destination = + plugins_root.join(format!("plugin-{index}/source-{source_index}")); + fs::create_dir_all(&plugin_destination).map_err(|error| { + format!("cannot create {}: {error}", plugin_destination.display()) + })?; + copy_tree(&plugin_source, &plugin_destination, &[])?; + } + } + + Ok(destination) +} + fn prune_tree(source: &Path, destination: &Path, ignored: &[&str]) -> Result<(), String> { for entry in fs::read_dir(destination) .map_err(|error| format!("cannot read {}: {error}", destination.display()))? @@ -2307,6 +2420,50 @@ fn generate_views(project: &Project, workspace: &Path) -> Result<(), String> { write_atomic(&target, source.as_bytes()) } +fn generate_ios_modules(project: &Project, workspace: &Path) -> Result<(), String> { + let target = workspace.join("Sources/PamNative/Modules/GeneratedPamModules.swift"); + let mut source = String::from( + "import Foundation\n\n\ + public enum GeneratedPamModules {\n\ + \x20 public static func create() -> [String: NativeModule] {\n\ + \x20 return [\n", + ); + for module in project.manifest.modules.iter().chain( + project + .plugins + .iter() + .flat_map(|plugin| plugin.manifest.modules.iter()), + ) { + if let Some(class) = &module.ios_class { + source.push_str(&format!(" {:?}: {}(),\n", module.name, class)); + } + } + source.push_str(" ]\n }\n}\n"); + write_atomic(&target, source.as_bytes()) +} + +fn generate_ios_views(project: &Project, workspace: &Path) -> Result<(), String> { + let target = workspace.join("Sources/PamNative/Views/GeneratedPamViews.swift"); + let mut source = String::from( + "import Foundation\nimport UIKit\n\n\ + public enum GeneratedPamViews {\n\ + \x20 public static func create() -> [String: NativeViewFactory] {\n\ + \x20 return [\n", + ); + for view in project.manifest.views.iter().chain( + project + .plugins + .iter() + .flat_map(|plugin| plugin.manifest.views.iter()), + ) { + if let Some(class) = &view.ios_class { + source.push_str(&format!(" {:?}: {}(),\n", view.name, class)); + } + } + source.push_str(" ]\n }\n}\n"); + write_atomic(&target, source.as_bytes()) +} + fn stage_project(project: &Project, workspace: &Path) -> Result<(), String> { let destination = workspace.join("app/src/main/assets/pam"); if destination.exists() { @@ -3694,9 +3851,32 @@ mod tests { let native_package = root.join("vendor/pushinbr/pam-native"); let composer = root.join("vendor/composer"); let source = package.join("android/src/main/kotlin"); + let ios_source = package.join("ios/Sources/Example"); fs::create_dir_all(&source).expect("plugin source"); + fs::create_dir_all(&ios_source).expect("plugin iOS source"); fs::create_dir_all(&native_package).expect("native package"); + fs::create_dir_all(native_package.join("ios/Sources/PamNative/Modules")) + .expect("native iOS modules"); + fs::create_dir_all(native_package.join("ios/Sources/PamNative/Views")) + .expect("native iOS views"); fs::create_dir_all(&composer).expect("composer"); + fs::write( + ios_source.join("ExampleFactories.swift"), + "final class ExampleBadgeFactory {}\n", + ) + .expect("plugin Swift source"); + fs::write(native_package.join("ios/Package.swift"), "// package\n") + .expect("native iOS package"); + fs::write( + native_package.join("ios/Sources/PamNative/Modules/GeneratedPamModules.swift"), + "// generated\n", + ) + .expect("native iOS modules registry"); + fs::write( + native_package.join("ios/Sources/PamNative/Views/GeneratedPamViews.swift"), + "// generated\n", + ) + .expect("native iOS views registry"); fs::write(root.join("vendor/autoload.php"), " bool { "Scaffold a production-ready Pam project.", "init [directory] [options]", &[ - ( - "--template PRESET", - "raw, api, laravel, desktop, mobile, or mobile-ui", - ), + ("--template PRESET", "raw, api, laravel, desktop, or mobile"), ("--socket", "Add Pam Socket support"), ("--no-install", "Create files without installing packages"), ("--no-interaction", "Use API when no preset is supplied"), @@ -343,7 +340,6 @@ pub fn print_command_help(executable: &OsStr, command: &str) -> bool { "init my-api --template api", "init my-app --template laravel --socket", "init native-app --template mobile --no-install", - "init polished-app --template mobile-ui --no-install", ], ), "build" => ( diff --git a/tests/cli.rs b/tests/cli.rs index c8b08b8..b0373f2 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1319,8 +1319,8 @@ fn exposes_inspect_routes_exec_help_and_version_commands() { let init_help = run_pam(&["init", "--help"]); assert!(init_help.status.success()); let init_help = String::from_utf8_lossy(&init_help.stderr); - assert!(init_help.contains("mobile, or mobile-ui")); - assert!(init_help.contains("--template mobile-ui")); + assert!(init_help.contains("desktop, or mobile")); + assert!(!init_help.contains("mobile-ui")); let mobile_help = run_pam(&["mobile", "--help"]); assert!(mobile_help.status.success());