diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 20b4a2f5a0..1e9c66a9ae 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -132,6 +132,7 @@ pub const NATIVE_MODULES: &[&str] = &[ "perry/tui", // terminal-UI framework "perry/yoga", // Yoga flexbox layout "perry/ui", // native UI (AppKit/UIKit/Win32/GTK4/…) + "perry/ios", // iOS-only UIKit/Foundation Models APIs "perry/system", // OS integration (keychain, notifications, …) "perry/plugin", // compile-time plugin surface "perry/widget", // home-screen widgets (WidgetKit/Glance) @@ -265,6 +266,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ // (registry + fs interception); no perry-stdlib surface needed. "perry", "perry/ui", + "perry/ios", "perry/system", "perry/widget", "perry/i18n", diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 010140382d..e54672ee18 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -593,6 +593,14 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("perry/media", "onTimeUpdate", false, None), method("perry/media", "setNowPlaying", false, None), method("perry/media", "destroy", false, None), + // --- perry/ios (issue #5536) — auto-derivable from PERRY_IOS_TABLE. --- + method("perry/ios", "getLayoutEnvironment", false, None), + method("perry/ios", "onLayoutChange", false, None), + method("perry/ios", "offLayoutChange", false, None), + method("perry/ios", "foundationModelAvailability", false, None), + method("perry/ios", "createLanguageModelSession", false, None), + method("perry/ios", "respond", false, None), + method("perry/ios", "destroyLanguageModelSession", false, None), // --- perry/audio (issue #1867) — auto-derivable from PERRY_AUDIO_TABLE. --- method("perry/audio", "loadSound", false, None), method("perry/audio", "unload", false, None), diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 4ebba36f4e..81ef383ff4 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -123,9 +123,9 @@ use ui_styling::apply_inline_style; // table-lookup family and `lower_perry_ui_table_call` unchanged. pub(super) use ui_tables::{ lower_perry_ui_table_call, perry_audio_table_lookup, perry_background_table_lookup, - perry_i18n_table_lookup, perry_media_table_lookup, perry_plugin_instance_method_lookup, - perry_plugin_table_lookup, perry_system_table_lookup, perry_ui_instance_method_lookup, - perry_ui_table_lookup, perry_updater_table_lookup, + perry_i18n_table_lookup, perry_ios_table_lookup, perry_media_table_lookup, + perry_plugin_instance_method_lookup, perry_plugin_table_lookup, perry_system_table_lookup, + perry_ui_instance_method_lookup, perry_ui_table_lookup, perry_updater_table_lookup, }; // Same for `native_module_dispatch.rs` — `native.rs` consumes both // `native_module_lookup` and `lower_native_module_dispatch` via diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index cdfdc4812b..e07ecbb1e8 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -41,10 +41,10 @@ pub(super) use super::{ find_outer_writes_stmt, find_thread_hazard_in_body, get_raw_string_ptr, hazardous_module_global_ids, lower_fetch_native_method, lower_native_module_dispatch, lower_notification_schedule, lower_perry_ui_table_call, native_module_lookup, - perry_audio_table_lookup, perry_i18n_table_lookup, perry_media_table_lookup, - perry_plugin_instance_method_lookup, perry_plugin_table_lookup, perry_system_table_lookup, - perry_ui_instance_method_lookup, perry_ui_table_lookup, perry_updater_table_lookup, - ThreadClosureHazard, + perry_audio_table_lookup, perry_i18n_table_lookup, perry_ios_table_lookup, + perry_media_table_lookup, perry_plugin_instance_method_lookup, perry_plugin_table_lookup, + perry_system_table_lookup, perry_ui_instance_method_lookup, perry_ui_table_lookup, + perry_updater_table_lookup, ThreadClosureHazard, }; mod box_style; diff --git a/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs b/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs index 9b48f0de28..67dac4fa4e 100644 --- a/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs @@ -346,6 +346,26 @@ ); } + // iOS-only adaptive scene geometry + Foundation Models (#5536). The + // module is deliberately platform-specific so other UI backends never + // receive unresolved UIKit/Swift symbols. + if module == "perry/ios" && object.is_none() { + if !ctx.target_triple.contains("apple-ios") { + bail!( + "perry/ios is only available for --target ios or ios-simulator (current target: {})", + ctx.target_triple + ); + } + if let Some(sig) = perry_ios_table_lookup(method) { + return lower_perry_ui_table_call(ctx, sig, args); + } + bail!( + "perry/ios: '{}' is not a known function (args: {}). Check types/perry/ios/index.d.ts for the supported API surface.", + method, + args.len() + ); + } + // perry/i18n format wrappers: Currency, Percent, FormatNumber, ShortDate, // LongDate, FormatTime, Raw. Without this, the call falls through to the // receiver-less early-out and returns NaN-boxed `undefined` (issue #188). diff --git a/crates/perry-codegen/src/lower_call/ui_tables.rs b/crates/perry-codegen/src/lower_call/ui_tables.rs index 31e824413a..7d99348c1f 100644 --- a/crates/perry-codegen/src/lower_call/ui_tables.rs +++ b/crates/perry-codegen/src/lower_call/ui_tables.rs @@ -16,8 +16,8 @@ use crate::types::{DOUBLE, I64}; use perry_dispatch::{ ArgKind as UiArgKind, MethodRow as UiSig, ReturnKind as UiReturnKind, PERRY_AUDIO_TABLE, - PERRY_BACKGROUND_TABLE, PERRY_I18N_TABLE, PERRY_MEDIA_TABLE, PERRY_SYSTEM_TABLE, - PERRY_UI_INSTANCE_TABLE, PERRY_UI_TABLE, PERRY_UPDATER_TABLE, + PERRY_BACKGROUND_TABLE, PERRY_I18N_TABLE, PERRY_IOS_TABLE, PERRY_MEDIA_TABLE, + PERRY_SYSTEM_TABLE, PERRY_UI_INSTANCE_TABLE, PERRY_UI_TABLE, PERRY_UPDATER_TABLE, }; use super::apply_inline_style; @@ -54,6 +54,14 @@ pub fn perry_media_table_lookup(method: &str) -> Option<&'static UiSig> { PERRY_MEDIA_TABLE.iter().find(|s| s.method == method) } +// ============================================================================= +// perry/ios dispatch table (issue #5536) +// ============================================================================= + +pub fn perry_ios_table_lookup(method: &str) -> Option<&'static UiSig> { + PERRY_IOS_TABLE.iter().find(|s| s.method == method) +} + // ============================================================================= // perry/i18n format-wrapper dispatch table // ============================================================================= diff --git a/crates/perry-codegen/tests/ios_platform_api_lowering.rs b/crates/perry-codegen/tests/ios_platform_api_lowering.rs new file mode 100644 index 0000000000..1ec033d175 --- /dev/null +++ b/crates/perry-codegen/tests/ios_platform_api_lowering.rs @@ -0,0 +1,166 @@ +//! Regression coverage for the iOS-only `perry/ios` table (#5536). + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, ModuleInitKind, Stmt}; + +fn options(target: Option<&str>) -> CompileOptions { + CompileOptions { + target: target.map(str::to_string), + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: Default::default(), + import_function_ffi_aliases: Default::default(), + import_function_origin_names: Default::default(), + import_function_v8_specifiers: Default::default(), + import_function_node_submodule: Default::default(), + namespace_node_submodules: Default::default(), + namespace_v8_specifiers: Default::default(), + namespace_member_prefixes: Default::default(), + namespace_member_origin_names: Default::default(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + namespace_member_nested: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: Default::default(), + type_aliases: Default::default(), + imported_func_param_counts: Default::default(), + imported_func_has_rest: Default::default(), + imported_func_synthetic_arguments: Default::default(), + imported_func_return_types: Default::default(), + imported_vars: Default::default(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: true, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: Default::default(), + nextjs_path_init_modules: Vec::new(), + deferred_module_prefixes: Default::default(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn call(method: &str, args: Vec) -> Stmt { + Stmt::Expr(Expr::NativeMethodCall { + module: "perry/ios".to_string(), + class_name: None, + object: None, + method: method.to_string(), + args, + }) +} + +fn module(body: Vec) -> Module { + Module { + name: "ios_platform_api_probe".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Number, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: Default::default(), + closure_display_names: Default::default(), + class_display_names: Default::default(), + closure_source_text: Default::default(), + async_generator_funcs: Default::default(), + local_source_spans: Default::default(), + gen_param_prologue_len: Default::default(), + } +} + +#[test] +fn ios_layout_and_foundation_model_calls_emit_runtime_symbols() { + let hir = module(vec![ + call("getLayoutEnvironment", vec![]), + call("onLayoutChange", vec![Expr::Number(0.0)]), + call("offLayoutChange", vec![Expr::Number(1.0)]), + call("foundationModelAvailability", vec![]), + // The optional instructions argument must pad to an empty runtime string. + call("createLanguageModelSession", vec![]), + call( + "respond", + vec![Expr::Number(1.0), Expr::String("Hello".to_string())], + ), + call("destroyLanguageModelSession", vec![Expr::Number(1.0)]), + ]); + let ir = + String::from_utf8(compile_module(&hir, options(Some("aarch64-apple-ios17.0"))).unwrap()) + .unwrap(); + + for symbol in [ + "@perry_ios_get_layout_environment", + "@perry_ios_on_layout_change", + "@perry_ios_off_layout_change", + "@perry_ios_foundation_model_availability", + "@perry_ios_foundation_model_session_create", + "@perry_ios_foundation_model_respond", + "@perry_ios_foundation_model_session_destroy", + ] { + assert!(ir.contains(symbol), "missing {symbol} in IR:\n{ir}"); + } +} + +#[test] +fn ios_module_is_rejected_for_non_ios_targets() { + let error = compile_module( + &module(vec![call("getLayoutEnvironment", vec![])]), + options(Some("aarch64-apple-darwin")), + ) + .unwrap_err(); + let error = format!("{error:#}"); + assert!( + error.contains("perry/ios is only available"), + "unexpected diagnostic: {error}" + ); +} diff --git a/crates/perry-dispatch/src/ios_table.rs b/crates/perry-dispatch/src/ios_table.rs new file mode 100644 index 0000000000..9bfe915a25 --- /dev/null +++ b/crates/perry-dispatch/src/ios_table.rs @@ -0,0 +1,51 @@ +//! `PERRY_IOS_TABLE` — iOS-specific adaptive layout and Foundation Models. + +use super::*; + +/// APIs that intentionally expose iOS-only platform capabilities. Keeping +/// these out of `PERRY_UI_TABLE` prevents other UI backends from having to +/// pretend that UIKit scene geometry or Foundation Models exist. +pub static PERRY_IOS_TABLE: &[MethodRow] = &[ + MethodRow { + method: "getLayoutEnvironment", + runtime: "perry_ios_get_layout_environment", + args: &[], + ret: ReturnKind::Widget, + }, + MethodRow { + method: "onLayoutChange", + runtime: "perry_ios_on_layout_change", + args: &[ArgKind::Closure], + ret: ReturnKind::I64AsF64, + }, + MethodRow { + method: "offLayoutChange", + runtime: "perry_ios_off_layout_change", + args: &[ArgKind::F64], + ret: ReturnKind::Void, + }, + MethodRow { + method: "foundationModelAvailability", + runtime: "perry_ios_foundation_model_availability", + args: &[], + ret: ReturnKind::Str, + }, + MethodRow { + method: "createLanguageModelSession", + runtime: "perry_ios_foundation_model_session_create", + args: &[ArgKind::Str], + ret: ReturnKind::I64AsF64, + }, + MethodRow { + method: "respond", + runtime: "perry_ios_foundation_model_respond", + args: &[ArgKind::F64, ArgKind::Str], + ret: ReturnKind::Promise, + }, + MethodRow { + method: "destroyLanguageModelSession", + runtime: "perry_ios_foundation_model_session_destroy", + args: &[ArgKind::F64], + ret: ReturnKind::Void, + }, +]; diff --git a/crates/perry-dispatch/src/lib.rs b/crates/perry-dispatch/src/lib.rs index 8e10c99d3e..7026ca6e7e 100644 --- a/crates/perry-dispatch/src/lib.rs +++ b/crates/perry-dispatch/src/lib.rs @@ -97,6 +97,7 @@ pub struct MethodRow { mod audio_table; mod background_table; mod i18n_table; +mod ios_table; mod media_table; mod system_table; mod ui_instance_table; @@ -106,6 +107,7 @@ mod updater_table; pub use audio_table::PERRY_AUDIO_TABLE; pub use background_table::PERRY_BACKGROUND_TABLE; pub use i18n_table::PERRY_I18N_TABLE; +pub use ios_table::PERRY_IOS_TABLE; pub use media_table::PERRY_MEDIA_TABLE; pub use system_table::PERRY_SYSTEM_TABLE; pub use ui_instance_table::PERRY_UI_INSTANCE_TABLE; @@ -134,6 +136,11 @@ pub fn perry_i18n_lookup(method: &str) -> Option<&'static MethodRow> { PERRY_I18N_TABLE.iter().find(|s| s.method == method) } +/// Look up a TS method name in the iOS-only platform table. +pub fn perry_ios_lookup(method: &str) -> Option<&'static MethodRow> { + PERRY_IOS_TABLE.iter().find(|s| s.method == method) +} + /// Look up a TS method name in the perry/updater table. pub fn perry_updater_lookup(method: &str) -> Option<&'static MethodRow> { PERRY_UPDATER_TABLE.iter().find(|s| s.method == method) diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 7f2ba922e1..e889b36a44 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -1599,6 +1599,15 @@ fn queue_thread_result( owner: crate::agent::AgentId, promise_usize: usize, result: SerializedValue, +) { + queue_thread_result_with_mode(owner, promise_usize, result, false); +} + +fn queue_thread_result_with_mode( + owner: crate::agent::AgentId, + promise_usize: usize, + result: SerializedValue, + is_rejection: bool, ) { // We need to interact with perry-stdlib's deferred resolution queue. // Since perry-runtime cannot depend on perry-stdlib, we use the same @@ -1616,6 +1625,7 @@ fn queue_thread_result( owner, promise_ptr: promise_usize, result, + is_rejection, }); } ACTIVE_THREAD_JOBS.fetch_sub(1, Ordering::SeqCst); @@ -1664,6 +1674,23 @@ pub fn queue_promise_string_result( ); } +/// Reject a pinned cross-thread promise with a UTF-8 message on its owning +/// agent. This is the error-side companion to +/// [`queue_promise_string_result`], used by native async framework bridges +/// whose completion may arrive on an arbitrary OS thread (#5536). +pub fn queue_promise_string_rejection( + owner: crate::agent::AgentId, + promise_usize: usize, + message: &str, +) { + queue_thread_result_with_mode( + owner, + promise_usize, + SerializedValue::String(message.as_bytes().to_vec()), + true, + ); +} + /// A pending thread result waiting to be resolved on the agent that spawned it. struct PendingThreadResult { /// #6185: the agent whose heap `promise_ptr` lives in — captured at spawn @@ -1674,6 +1701,8 @@ struct PendingThreadResult { owner: crate::agent::AgentId, promise_ptr: usize, result: SerializedValue, + /// Settle through `reject` rather than `resolve` after deserialization. + is_rejection: bool, } // Safety: SerializedValue is Send, usize is Send. `promise_ptr` is a raw @@ -1689,7 +1718,7 @@ static PENDING_THREAD_RESULTS: std::sync::Mutex> = /// (registered as a pump function, similar to js_stdlib_process_pending). /// /// Drains the queue, deserializes each result into the main thread's arena, -/// and resolves the corresponding Promise. +/// and resolves or rejects the corresponding Promise. /// /// # Returns /// Number of results processed. @@ -1737,9 +1766,13 @@ pub extern "C" fn js_thread_process_pending() -> i32 { continue; } - // Deserialize the result into the main thread's arena and resolve. + // Deserialize the result into the owning agent's arena and settle. let result_bits = deserialize_nanbox_on_current_thread(&item.result); - crate::promise::js_promise_resolve(promise, f64::from_bits(result_bits)); + if item.is_rejection { + crate::promise::js_promise_reject(promise, f64::from_bits(result_bits)); + } else { + crate::promise::js_promise_resolve(promise, f64::from_bits(result_bits)); + } } } diff --git a/crates/perry-ui-ios/Cargo.toml b/crates/perry-ui-ios/Cargo.toml index 7ebbb4fba5..473ace5027 100644 --- a/crates/perry-ui-ios/Cargo.toml +++ b/crates/perry-ui-ios/Cargo.toml @@ -55,6 +55,7 @@ objc2-ui-kit = { version = "0.3", features = [ "UIColor", "UIFont", "UIControl", + "UIGeometry", "UIPasteboard", "UIResponder", "UIScreen", diff --git a/crates/perry-ui-ios/src/adaptive_layout.rs b/crates/perry-ui-ios/src/adaptive_layout.rs new file mode 100644 index 0000000000..b7c3cb4f7e --- /dev/null +++ b/crates/perry-ui-ios/src/adaptive_layout.rs @@ -0,0 +1,403 @@ +//! Scene-relative adaptive-layout information for `perry/ios` (#5536). +//! +//! UIKit's window size, traits, and safe area are the stable public signals +//! for foldable-sized displays, iPad Split View, and Stage Manager. Device +//! model checks are intentionally avoided: a single scene can move through +//! all of these layouts without the hardware changing. + +use objc2::msg_send; +use objc2::runtime::{AnyObject, Sel}; +use objc2_core_foundation::CGRect; +use objc2_ui_kit::UIEdgeInsets; +use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::atomic::{AtomicI64, Ordering}; + +extern "C" { + fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void; + fn js_object_set_field_by_name( + obj: *mut c_void, + key: *const perry_runtime::string::StringHeader, + value: f64, + ); + fn js_string_from_bytes(data: *const u8, len: u32) -> *mut perry_runtime::string::StringHeader; + fn js_nanbox_pointer(ptr: i64) -> f64; + fn js_nanbox_string(ptr: i64) -> f64; + fn js_nanbox_get_pointer(value: f64) -> i64; + fn js_closure_call1(closure: *const u8, arg: f64) -> f64; + fn js_run_stdlib_pump(); + fn js_promise_run_microtasks() -> i32; +} + +const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; +const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; + +fn zero_insets() -> UIEdgeInsets { + UIEdgeInsets { + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + } +} + +#[derive(Clone, Debug, PartialEq)] +struct LayoutSnapshot { + width: f64, + height: f64, + aspect_ratio: f64, + display_scale: f64, + horizontal_size_class: &'static str, + vertical_size_class: &'static str, + orientation: &'static str, + window_mode: &'static str, + is_multitasking: bool, + is_four_by_three: bool, + system_frame_x: f64, + system_frame_y: f64, + system_frame_width: f64, + system_frame_height: f64, + is_interactively_resizing: bool, + is_interface_orientation_locked: bool, + safe_area: UIEdgeInsets, +} + +thread_local! { + static LISTENERS: RefCell> = RefCell::new(HashMap::new()); + static LAST_SNAPSHOT: RefCell> = const { RefCell::new(None) }; +} + +static NEXT_LISTENER_ID: AtomicI64 = AtomicI64::new(1); + +fn size_class_name(value: isize) -> &'static str { + match value { + 1 => "compact", + 2 => "regular", + _ => "unspecified", + } +} + +fn nearly_equal(a: f64, b: f64) -> bool { + (a - b).abs() <= 2.0 +} + +fn classify_window_mode( + width: f64, + height: f64, + screen_width: f64, + screen_height: f64, + is_pad: bool, +) -> (&'static str, bool) { + let fills_width = nearly_equal(width, screen_width); + let fills_height = nearly_equal(height, screen_height); + if fills_width && fills_height { + return ("fullScreen", false); + } + + let multitasking = is_pad && (!fills_width || !fills_height); + if multitasking && fills_height && width + 2.0 < screen_width { + ("sideBySide", true) + } else { + ("windowed", multitasking) + } +} + +fn is_four_by_three(width: f64, height: f64) -> bool { + let short = width.min(height); + let long = width.max(height); + short > 0.0 && ((long / short) - (4.0 / 3.0)).abs() <= 0.04 +} + +fn current_snapshot() -> Option { + crate::app::APPS.with(|apps| { + let apps = apps.borrow(); + let window = &apps.last()?.window; + unsafe { + let bounds: CGRect = msg_send![&**window, bounds]; + let screen: *mut AnyObject = msg_send![&**window, screen]; + let screen_bounds: CGRect = if screen.is_null() { + bounds + } else { + msg_send![screen, bounds] + }; + let scale: f64 = if screen.is_null() { + 1.0 + } else { + msg_send![screen, scale] + }; + + // iOS 27's effectiveGeometry is the authoritative scene frame and + // interactive-resize state. Selectors keep this binary compatible + // with earlier deployment targets and SDK-built UI archives. + let scene: *mut AnyObject = msg_send![&**window, windowScene]; + let effective_geometry_selector = Sel::register(c"effectiveGeometry"); + let has_effective_geometry = !scene.is_null() + && msg_send![scene, respondsToSelector: effective_geometry_selector]; + let effective_geometry: *mut AnyObject = if has_effective_geometry { + msg_send![scene, effectiveGeometry] + } else { + std::ptr::null_mut() + }; + let window_frame: CGRect = msg_send![&**window, frame]; + let system_frame: CGRect = if effective_geometry.is_null() { + window_frame + } else { + msg_send![effective_geometry, systemFrame] + }; + let interactive_selector = Sel::register(c"isInteractivelyResizing"); + let is_interactively_resizing = !effective_geometry.is_null() + && msg_send![effective_geometry, respondsToSelector: interactive_selector] + && msg_send![effective_geometry, isInteractivelyResizing]; + let orientation_locked_selector = Sel::register(c"isInterfaceOrientationLocked"); + let is_interface_orientation_locked = !effective_geometry.is_null() + && msg_send![effective_geometry, respondsToSelector: orientation_locked_selector] + && msg_send![effective_geometry, isInterfaceOrientationLocked]; + + let traits: *mut AnyObject = msg_send![&**window, traitCollection]; + let horizontal: isize = if traits.is_null() { + 0 + } else { + msg_send![traits, horizontalSizeClass] + }; + let vertical: isize = if traits.is_null() { + 0 + } else { + msg_send![traits, verticalSizeClass] + }; + let idiom: isize = if traits.is_null() { + -1 + } else { + msg_send![traits, userInterfaceIdiom] + }; + + let root: *mut AnyObject = msg_send![&**window, rootViewController]; + let safe_area = if root.is_null() { + zero_insets() + } else { + let view: *mut AnyObject = msg_send![root, view]; + if view.is_null() { + zero_insets() + } else { + msg_send![view, safeAreaInsets] + } + }; + + let width = bounds.size.width.max(0.0); + let height = bounds.size.height.max(0.0); + let aspect_ratio = if height > 0.0 { width / height } else { 0.0 }; + let orientation = if nearly_equal(width, height) { + "square" + } else if width > height { + "landscape" + } else { + "portrait" + }; + let (window_mode, is_multitasking) = classify_window_mode( + system_frame.size.width, + system_frame.size.height, + screen_bounds.size.width, + screen_bounds.size.height, + idiom == 1, // UIUserInterfaceIdiomPad + ); + + Some(LayoutSnapshot { + width, + height, + aspect_ratio, + display_scale: scale, + horizontal_size_class: size_class_name(horizontal), + vertical_size_class: size_class_name(vertical), + orientation, + window_mode, + is_multitasking, + is_four_by_three: is_four_by_three(width, height), + system_frame_x: system_frame.origin.x, + system_frame_y: system_frame.origin.y, + system_frame_width: system_frame.size.width, + system_frame_height: system_frame.size.height, + is_interactively_resizing, + is_interface_orientation_locked, + safe_area, + }) + } + }) +} + +unsafe fn string_value(value: &str) -> f64 { + let ptr = js_string_from_bytes(value.as_ptr(), value.len() as u32); + js_nanbox_string(ptr as i64) +} + +fn bool_value(value: bool) -> f64 { + f64::from_bits(if value { TAG_TRUE } else { TAG_FALSE }) +} + +unsafe fn set_field(object: *mut c_void, name: &str, value: f64) { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + js_object_set_field_by_name(object, key, value); +} + +unsafe fn snapshot_object(snapshot: &LayoutSnapshot) -> i64 { + let object = js_object_alloc(0, 20); + if object.is_null() { + return 0; + } + set_field(object, "width", snapshot.width); + set_field(object, "height", snapshot.height); + set_field(object, "aspectRatio", snapshot.aspect_ratio); + set_field(object, "displayScale", snapshot.display_scale); + set_field( + object, + "horizontalSizeClass", + string_value(snapshot.horizontal_size_class), + ); + set_field( + object, + "verticalSizeClass", + string_value(snapshot.vertical_size_class), + ); + set_field(object, "orientation", string_value(snapshot.orientation)); + set_field(object, "windowMode", string_value(snapshot.window_mode)); + set_field( + object, + "isMultitasking", + bool_value(snapshot.is_multitasking), + ); + set_field( + object, + "isFourByThree", + bool_value(snapshot.is_four_by_three), + ); + set_field(object, "systemFrameX", snapshot.system_frame_x); + set_field(object, "systemFrameY", snapshot.system_frame_y); + set_field(object, "systemFrameWidth", snapshot.system_frame_width); + set_field(object, "systemFrameHeight", snapshot.system_frame_height); + set_field( + object, + "isInteractivelyResizing", + bool_value(snapshot.is_interactively_resizing), + ); + set_field( + object, + "isInterfaceOrientationLocked", + bool_value(snapshot.is_interface_orientation_locked), + ); + set_field(object, "safeAreaTop", snapshot.safe_area.top); + set_field(object, "safeAreaRight", snapshot.safe_area.right); + set_field(object, "safeAreaBottom", snapshot.safe_area.bottom); + set_field(object, "safeAreaLeft", snapshot.safe_area.left); + object as i64 +} + +unsafe fn invoke_listener(callback: f64, snapshot: &LayoutSnapshot) { + let closure = js_nanbox_get_pointer(callback) as *const u8; + if closure.is_null() { + return; + } + let object = snapshot_object(snapshot); + if object == 0 { + return; + } + js_run_stdlib_pump(); + js_closure_call1(closure, js_nanbox_pointer(object)); + js_promise_run_microtasks(); +} + +/// Called after UIKit lays out the root controller and after scene creation. +/// Duplicate layouts are suppressed so animation/layout passes don't flood JS. +pub(crate) fn notify_if_changed() { + let Some(snapshot) = current_snapshot() else { + return; + }; + let changed = LAST_SNAPSHOT.with(|last| { + let mut last = last.borrow_mut(); + if last.as_ref() == Some(&snapshot) { + false + } else { + *last = Some(snapshot.clone()); + true + } + }); + if !changed { + return; + } + let callbacks = + LISTENERS.with(|listeners| listeners.borrow().values().copied().collect::>()); + for callback in callbacks { + unsafe { invoke_listener(callback, &snapshot) }; + } +} + +#[no_mangle] +pub extern "C" fn perry_ios_get_layout_environment() -> i64 { + let snapshot = current_snapshot().unwrap_or(LayoutSnapshot { + width: 0.0, + height: 0.0, + aspect_ratio: 0.0, + display_scale: 1.0, + horizontal_size_class: "unspecified", + vertical_size_class: "unspecified", + orientation: "square", + window_mode: "windowed", + is_multitasking: false, + is_four_by_three: false, + system_frame_x: 0.0, + system_frame_y: 0.0, + system_frame_width: 0.0, + system_frame_height: 0.0, + is_interactively_resizing: false, + is_interface_orientation_locked: false, + safe_area: zero_insets(), + }); + unsafe { snapshot_object(&snapshot) } +} + +#[no_mangle] +pub extern "C" fn perry_ios_on_layout_change(callback: f64) -> i64 { + let id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed); + LISTENERS.with(|listeners| { + listeners.borrow_mut().insert(id, callback); + }); + if let Some(snapshot) = current_snapshot() { + unsafe { invoke_listener(callback, &snapshot) }; + } + id +} + +#[no_mangle] +pub extern "C" fn perry_ios_off_layout_change(subscription: f64) { + if subscription.is_finite() && subscription > 0.0 { + LISTENERS.with(|listeners| { + listeners.borrow_mut().remove(&(subscription as i64)); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_fullscreen_split_and_windowed_scenes() { + assert_eq!( + classify_window_mode(1024.0, 1366.0, 1024.0, 1366.0, true), + ("fullScreen", false) + ); + assert_eq!( + classify_window_mode(507.0, 1366.0, 1024.0, 1366.0, true), + ("sideBySide", true) + ); + assert_eq!( + classify_window_mode(800.0, 1000.0, 1024.0, 1366.0, true), + ("windowed", true) + ); + } + + #[test] + fn detects_four_by_three_in_both_orientations() { + assert!(is_four_by_three(1024.0, 768.0)); + assert!(is_four_by_three(768.0, 1024.0)); + assert!(!is_four_by_three(390.0, 844.0)); + } +} diff --git a/crates/perry-ui-ios/src/app.rs b/crates/perry-ui-ios/src/app.rs index 44a086a0b5..6eb2d7a651 100644 --- a/crates/perry-ui-ios/src/app.rs +++ b/crates/perry-ui-ios/src/app.rs @@ -287,6 +287,10 @@ unsafe extern "C" fn scene_will_connect( a.borrow_mut().push(AppEntry { window }); }); + // Publish the initial scene-relative geometry after the UIWindow has a + // root controller and is visible (#5536). + crate::adaptive_layout::notify_if_changed(); + // Register for keyboard notifications register_keyboard_observers(); @@ -371,6 +375,17 @@ unsafe extern "C" fn scene_continue_user_activity( crate::deeplinks::dispatch_continue_user_activity(activity as *const AnyObject); } +/// iOS 27 scene-geometry callback. UIKit passes the previous geometry; the +/// public Perry snapshot always reads the scene's current effective geometry. +unsafe extern "C" fn scene_did_update_effective_geometry( + _this: *mut AnyObject, + _sel: *const std::ffi::c_void, + _scene: *mut AnyObject, + _previous_geometry: *mut AnyObject, +) { + crate::adaptive_layout::notify_if_changed(); +} + /// Register the PerrySceneDelegate class dynamically at runtime. fn register_scene_delegate() { unsafe { @@ -422,6 +437,17 @@ fn register_scene_delegate() { c"v@:@@".as_ptr(), ); + // iOS 27: receive every effective UIWindowScene geometry update, + // including continuous interactive resizing and screen moves. Older + // UIKit releases simply never invoke this optional delegate method. + let sel_geometry = sel_registerName(c"windowScene:didUpdateEffectiveGeometry:".as_ptr()); + class_addMethod( + cls, + sel_geometry, + scene_did_update_effective_geometry as *const std::ffi::c_void, + c"v@:@@".as_ptr(), + ); + objc_registerClassPair(cls); } } @@ -463,6 +489,19 @@ unsafe extern "C" fn vc_can_perform_action( action == perry_sel } +/// UIViewController layout hook used by `perry/ios.onLayoutChange`. Calling +/// super preserves UIKit's own controller layout before we snapshot bounds, +/// traits, and safe-area insets. +unsafe extern "C" fn vc_view_did_layout_subviews( + this: *mut AnyObject, + _sel: *const std::ffi::c_void, +) { + if let Some(superclass) = AnyClass::get(c"UIViewController") { + let _: () = msg_send![super(this, superclass), viewDidLayoutSubviews]; + } + crate::adaptive_layout::notify_if_changed(); +} + /// Register the PerryViewController class dynamically at runtime. fn register_view_controller() { unsafe { @@ -500,6 +539,14 @@ fn register_view_controller() { c"B@::@".as_ptr(), ); + let sel_layout = sel_registerName(c"viewDidLayoutSubviews".as_ptr()); + class_addMethod( + cls, + sel_layout, + vc_view_did_layout_subviews as *const std::ffi::c_void, + c"v@:".as_ptr(), + ); + objc_registerClassPair(cls); } } diff --git a/crates/perry-ui-ios/src/foundation_models.rs b/crates/perry-ui-ios/src/foundation_models.rs new file mode 100644 index 0000000000..5659e1fa49 --- /dev/null +++ b/crates/perry-ui-ios/src/foundation_models.rs @@ -0,0 +1,127 @@ +//! Swift Foundation Models bridge for `perry/ios` (#5536). +//! +//! Foundation Models is Swift-only, so the final iOS link compiles the small +//! companion in `swift/PerryFoundationModels.swift`. This Rust side owns the +//! Perry ABI, UTF-8 conversion, Promise lifetime, and owner-agent handoff. + +use perry_ffi::copy_string_from_raw as str_from_header; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +type Completion = unsafe extern "C" fn(i64, bool, *const u8, i32); + +extern "C" { + fn perry_swift_foundation_model_availability() -> i32; + fn perry_swift_foundation_model_session_create(bytes: *const u8, len: i32) -> i64; + fn perry_swift_foundation_model_session_destroy(session: i64); + fn perry_swift_foundation_model_respond( + session: i64, + bytes: *const u8, + len: i32, + context: i64, + completion: Completion, + ); + fn js_string_from_bytes(bytes: *const u8, len: u32) + -> *mut perry_runtime::string::StringHeader; +} + +/// Promise address → owner agent. The Promise itself is malloc-space pinned +/// until `js_thread_process_pending` settles the queued completion. +static PENDING_RESPONSES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn lock_pending() -> std::sync::MutexGuard<'static, HashMap> { + match PENDING_RESPONSES.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn runtime_string(value: &str) -> i64 { + unsafe { js_string_from_bytes(value.as_ptr(), value.len() as u32) as i64 } +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_availability() -> i64 { + let value = unsafe { + match perry_swift_foundation_model_availability() { + 1 => "available", + 2 => "deviceNotEligible", + 3 => "appleIntelligenceNotEnabled", + 4 => "modelNotReady", + _ => "unsupported", + } + }; + runtime_string(value) +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_session_create(instructions_ptr: i64) -> i64 { + let instructions = if instructions_ptr == 0 { + String::new() + } else { + unsafe { str_from_header(instructions_ptr as *const u8) }.to_string() + }; + unsafe { + perry_swift_foundation_model_session_create( + instructions.as_ptr(), + instructions.len() as i32, + ) + } +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_session_destroy(session: f64) { + if session.is_finite() && session > 0.0 { + unsafe { perry_swift_foundation_model_session_destroy(session as i64) }; + } +} + +unsafe extern "C" fn response_completion(context: i64, success: bool, bytes: *const u8, len: i32) { + let Some(owner) = lock_pending().remove(&context) else { + return; + }; + let value = if bytes.is_null() || len <= 0 { + String::new() + } else { + String::from_utf8_lossy(std::slice::from_raw_parts(bytes, len as usize)).into_owned() + }; + if success { + perry_runtime::thread::queue_promise_string_result(owner, context as usize, &value); + } else { + perry_runtime::thread::queue_promise_string_rejection(owner, context as usize, &value); + } +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_respond(session: f64, prompt_ptr: i64) -> i64 { + let prompt = if prompt_ptr == 0 { + String::new() + } else { + unsafe { str_from_header(prompt_ptr as *const u8) }.to_string() + }; + + // The Swift task can outlive every JS reference to the returned promise. + // Force malloc-space allocation and pin it until its owner-agent queue + // drains the result; this is the same protocol used by spawn/waitAsync. + let promise = perry_runtime::promise::js_promise_new_cross_thread(); + unsafe { perry_runtime::thread::pin_promise(promise) }; + perry_runtime::thread::thread_job_begin(); + let context = promise as i64; + lock_pending().insert(context, perry_runtime::agent::current_agent()); + + unsafe { + perry_swift_foundation_model_respond( + if session.is_finite() && session > 0.0 { + session as i64 + } else { + 0 + }, + prompt.as_ptr(), + prompt.len() as i32, + context, + response_completion, + ); + } + context +} diff --git a/crates/perry-ui-ios/src/lib.rs b/crates/perry-ui-ios/src/lib.rs index b37b4d4c59..142064c064 100644 --- a/crates/perry-ui-ios/src/lib.rs +++ b/crates/perry-ui-ios/src/lib.rs @@ -1,5 +1,6 @@ #![cfg(target_os = "ios")] +pub mod adaptive_layout; pub mod app; pub mod audio; pub mod audio_playback; @@ -10,6 +11,7 @@ pub mod crash_log; pub mod deeplinks; pub mod drag_drop; pub mod file_dialog; +pub mod foundation_models; pub mod geolocation; pub mod image_picker; pub mod keyboard; diff --git a/crates/perry-ui-ios/src/media_playback.rs b/crates/perry-ui-ios/src/media_playback.rs index 77b23a7ce0..3307c52c07 100644 --- a/crates/perry-ui-ios/src/media_playback.rs +++ b/crates/perry-ui-ios/src/media_playback.rs @@ -15,15 +15,18 @@ //! `AVPlayerItemDidPlayToEndTimeNotification`. A 10 Hz `NSTimer` drives //! both the state-change callback (on transition) and the time-update //! callback (every tick while playing/loading). -//! - Now Playing metadata uses `MPNowPlayingInfoCenter`. Lock-screen / Touch -//! Bar / Siri Remote play/pause/skip routes through `MPRemoteCommandCenter`. +//! - iOS 27 uses the Swift `NowPlaying` framework's observable `MediaSession`. +//! Earlier SDKs/OS versions retain the `MPNowPlayingInfoCenter` and +//! `MPRemoteCommandCenter` implementation as a compatibility fallback. use objc2::msg_send; use objc2::rc::Retained; use objc2::runtime::{AnyClass, AnyObject, Sel}; use std::cell::RefCell; +use std::ffi::CStr; use std::ffi::CString; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; extern "C" { fn js_nanbox_get_pointer(value: f64) -> i64; @@ -91,6 +94,18 @@ impl MediaState { MediaState::Error => "error", } } + + fn as_now_playing_code(self) -> i32 { + match self { + MediaState::Idle => 0, + MediaState::Loading => 1, + MediaState::Ready => 2, + MediaState::Playing => 3, + MediaState::Paused => 4, + MediaState::Ended => 5, + MediaState::Error => 6, + } + } } struct PlayerEntry { @@ -130,6 +145,120 @@ fn nsstring(s: &str) -> Retained { objc2_foundation::NSString::from_str(s) } +// --------------------------------------------------------------------------- +// iOS 27 NowPlaying bridge +// --------------------------------------------------------------------------- + +type NowPlayingIsAvailable = unsafe extern "C" fn() -> i32; +type NowPlayingPublish = unsafe extern "C" fn( + i64, + *const u8, + i32, + *const u8, + i32, + *const u8, + i32, + *const u8, + i32, + i32, + f64, + f64, +); +type NowPlayingUpdate = unsafe extern "C" fn(i64, i32, f64, f64); +type NowPlayingRemove = unsafe extern "C" fn(i64); + +static NOW_PLAYING_IS_AVAILABLE: OnceLock> = OnceLock::new(); +static NOW_PLAYING_PUBLISH: OnceLock> = OnceLock::new(); +static NOW_PLAYING_UPDATE: OnceLock> = OnceLock::new(); +static NOW_PLAYING_REMOVE: OnceLock> = OnceLock::new(); + +fn dynamic_symbol(cell: &OnceLock>, name: &CStr) -> Option { + *cell.get_or_init(|| unsafe { + let raw = libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()); + if raw.is_null() { + None + } else { + // Mach-O function pointers have pointer width. `T` is one of the + // concrete C function-pointer aliases above. + Some(std::mem::transmute_copy::<*mut libc::c_void, T>(&raw)) + } + }) +} + +fn now_playing_bridge_available() -> bool { + dynamic_symbol( + &NOW_PLAYING_IS_AVAILABLE, + c"perry_swift_now_playing_is_available", + ) + .is_some_and(|function| unsafe { function() != 0 }) +} + +fn publish_now_playing_session( + handle: i64, + title: &str, + artist: &str, + album: &str, + artwork: &str, + state: MediaState, + elapsed_time: f64, + duration: f64, +) -> bool { + if !now_playing_bridge_available() { + return false; + } + let Some(function) = dynamic_symbol(&NOW_PLAYING_PUBLISH, c"perry_swift_now_playing_publish") + else { + return false; + }; + unsafe { + function( + handle, + title.as_ptr(), + title.len() as i32, + artist.as_ptr(), + artist.len() as i32, + album.as_ptr(), + album.len() as i32, + artwork.as_ptr(), + artwork.len() as i32, + state.as_now_playing_code(), + elapsed_time, + duration, + ); + } + true +} + +fn update_now_playing_session(handle: i64, state: MediaState, elapsed_time: f64, duration: f64) { + if !now_playing_bridge_available() { + return; + } + if let Some(function) = dynamic_symbol(&NOW_PLAYING_UPDATE, c"perry_swift_now_playing_update") { + unsafe { function(handle, state.as_now_playing_code(), elapsed_time, duration) }; + } +} + +fn remove_now_playing_session(handle: i64) { + if !now_playing_bridge_available() { + return; + } + if let Some(function) = dynamic_symbol(&NOW_PLAYING_REMOVE, c"perry_swift_now_playing_remove") { + unsafe { function(handle) }; + } +} + +/// Command callback used by the iOS 27 Swift `MediaSession` bridge. +#[no_mangle] +pub extern "C" fn perry_ios_now_playing_command(handle: i64, command: i32, value: f64) { + match command { + 1 => play(handle as f64), + 2 => pause(handle as f64), + 3 => stop(handle as f64), + 4 => seek(handle as f64, value), + _ => {} + } +} + // --------------------------------------------------------------------------- // Public FFI — called from `crates/perry-ui-macos/src/lib.rs` thunks // --------------------------------------------------------------------------- @@ -423,12 +552,40 @@ pub fn set_now_playing( let artist = unsafe { str_from_header(artist_ptr) }; let album = unsafe { str_from_header(album_ptr) }; let artwork = unsafe { str_from_header(artwork_ptr) }; - // The handle is currently advisory — MPNowPlayingInfoCenter is a - // process-wide singleton, so the most recent setNowPlaying wins. - // Holding the handle in the API keeps room for multi-player apps to - // associate metadata with a specific player when we add a remote- - // command dispatch table keyed by handle. - let _ = handle; + + if let Some(index) = handle_to_index(handle) { + let snapshot = PLAYERS.with(|players| { + players.borrow().get(index).and_then(|slot| { + slot.as_ref().map(|entry| { + ( + entry.state, + unsafe { current_time_seconds(&entry.player) }, + entry.duration_seconds, + ) + }) + }) + }); + if let Some((state, elapsed_time, duration)) = snapshot { + if publish_now_playing_session( + handle as i64, + &title, + &artist, + &album, + &artwork, + state, + elapsed_time, + duration, + ) { + // Apple explicitly forbids mixing NowPlaying with the legacy + // MediaPlayer now-playing APIs in one local session. + return; + } + } + } + + // Compatibility path for apps built with an older SDK or running before + // iOS 27. MPNowPlayingInfoCenter is process-wide, so the most recent call + // wins here; the iOS 27 path above is handle-scoped. unsafe { let center_cls = match AnyClass::get(c"MPNowPlayingInfoCenter") { @@ -501,6 +658,7 @@ pub fn destroy(handle: f64) { } } }); + remove_now_playing_session((idx + 1) as i64); } // --------------------------------------------------------------------------- @@ -652,7 +810,7 @@ unsafe extern "C" fn poll_tick( ) { PLAYERS.with(|p| { let mut players = p.borrow_mut(); - for slot in players.iter_mut() { + for (index, slot) in players.iter_mut().enumerate() { let entry = match slot { Some(e) => e, None => continue, @@ -687,6 +845,8 @@ unsafe extern "C" fn poll_tick( let cur = current_time_seconds(&entry.player); let dur = entry.duration_seconds; + update_now_playing_session((index + 1) as i64, new_state, cur, dur); + if let Some(cb) = on_state { fire_state_callback(cb, new_state); } diff --git a/crates/perry-ui-ios/src/widgets/splitview.rs b/crates/perry-ui-ios/src/widgets/splitview.rs index 40dcf8e438..14806bc784 100644 --- a/crates/perry-ui-ios/src/widgets/splitview.rs +++ b/crates/perry-ui-ios/src/widgets/splitview.rs @@ -40,7 +40,12 @@ unsafe extern "C" fn frame_split_layout_subviews( ) { let bounds: objc2_core_foundation::CGRect = objc2::msg_send![this, bounds]; let tag: i64 = objc2::msg_send![this, tag]; - let left_width = tag as f64 / 100.0; + // Keep the detail pane usable when iPad Split View, Stage Manager, or a + // future resizable display makes the scene narrower than the preferred + // sidebar width. This is scene-relative; device-model checks would miss + // size changes while the app is already running. + let preferred_left_width = (tag as f64 / 100.0).max(0.0); + let left_width = preferred_left_width.min((bounds.size.width * 0.45).max(0.0)); let subviews: *mut AnyObject = objc2::msg_send![this, subviews]; let count: usize = objc2::msg_send![subviews, count]; @@ -151,7 +156,8 @@ pub fn frame_split_add_child(parent: &UIView, child: &UIView) { /// Create a plain UIView that lays out exactly two children side by side /// using Auto Layout constraints (not UIStackView). /// -/// The first child added gets a fixed width (left_width) pinned to the left. +/// The first child added gets its preferred width (`left_width`) pinned to the +/// left, capped at 45% of the current scene width for adaptive layouts. /// The second child fills the remaining space on the right. /// This avoids UIStackView layout conflicts with embedded native views. pub fn create(left_width: f64) -> i64 { @@ -169,7 +175,7 @@ pub fn create(left_width: f64) -> i64 { } /// Add a child to a split view container. The first child becomes the left panel -/// (fixed width from tag), the second becomes the right panel (fills remaining). +/// (preferred width from tag), the second becomes the right panel (fills remaining). pub fn add_child(parent: &UIView, child: &UIView, child_index: usize) { unsafe { let _: () = msg_send![child, setTranslatesAutoresizingMaskIntoConstraints: false]; @@ -192,7 +198,9 @@ pub fn add_child(parent: &UIView, child: &UIView, child_index: usize) { let _: () = msg_send![&*bc, setActive: true]; if child_index == 0 { - // Left panel: pin leading to parent, fixed width + // Left panel: pin leading to parent and prefer the requested width, + // but cap it relative to the live scene width. The lower-priority + // preferred constraint yields in narrow split-screen/window modes. let child_leading: Retained = msg_send![child, leadingAnchor]; let parent_leading: Retained = msg_send![parent, leadingAnchor]; let lc: Retained = @@ -200,9 +208,18 @@ pub fn add_child(parent: &UIView, child: &UIView, child_index: usize) { let _: () = msg_send![&*lc, setActive: true]; let child_width: Retained = msg_send![child, widthAnchor]; - let wc: Retained = + let preferred_width: Retained = msg_send![&*child_width, constraintEqualToConstant: left_width]; - let _: () = msg_send![&*wc, setActive: true]; + let _: () = msg_send![&*preferred_width, setPriority: 750.0f32]; + let _: () = msg_send![&*preferred_width, setActive: true]; + + let parent_width: Retained = msg_send![parent, widthAnchor]; + let adaptive_max: Retained = msg_send![ + &*child_width, + constraintLessThanOrEqualToAnchor: &*parent_width, + multiplier: 0.45f64 + ]; + let _: () = msg_send![&*adaptive_max, setActive: true]; } else { // Right panel: pin trailing to parent, leading to previous sibling's trailing let child_leading: Retained = msg_send![child, leadingAnchor]; diff --git a/crates/perry-ui-ios/swift/PerryFoundationModels.swift b/crates/perry-ui-ios/swift/PerryFoundationModels.swift new file mode 100644 index 0000000000..26f640e5cd --- /dev/null +++ b/crates/perry-ui-ios/swift/PerryFoundationModels.swift @@ -0,0 +1,119 @@ +import Foundation +@_weakLinked import FoundationModels + +public typealias PerryFoundationModelCompletion = @convention(c) ( + Int64, + Bool, + UnsafePointer?, + Int32 +) -> Void + +private func decodeUTF8(_ bytes: UnsafePointer?, _ length: Int32) -> String { + guard let bytes, length > 0 else { return "" } + return String(decoding: UnsafeBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) +} + +private func complete( + _ callback: PerryFoundationModelCompletion, + context: Int64, + success: Bool, + value: String +) { + let bytes = Array(value.utf8) + bytes.withUnsafeBufferPointer { buffer in + callback(context, success, buffer.baseAddress, Int32(buffer.count)) + } +} + +@available(iOS 26.0, *) +private final class PerryLanguageModelSessions: @unchecked Sendable { + static let shared = PerryLanguageModelSessions() + + private let lock = NSLock() + private var nextHandle: Int64 = 1 + private var sessions: [Int64: LanguageModelSession] = [:] + + func create(instructions: String) -> Int64 { + guard SystemLanguageModel.default.isAvailable else { return 0 } + let session = LanguageModelSession( + instructions: instructions.isEmpty ? nil : instructions + ) + lock.lock() + defer { lock.unlock() } + let handle = nextHandle + nextHandle += 1 + sessions[handle] = session + return handle + } + + func session(for handle: Int64) -> LanguageModelSession? { + lock.lock() + defer { lock.unlock() } + return sessions[handle] + } + + func destroy(_ handle: Int64) { + lock.lock() + sessions.removeValue(forKey: handle) + lock.unlock() + } +} + +@_cdecl("perry_swift_foundation_model_availability") +public func perrySwiftFoundationModelAvailability() -> Int32 { + guard #available(iOS 26.0, *) else { return 0 } + switch SystemLanguageModel.default.availability { + case .available: + return 1 + case .unavailable(.deviceNotEligible): + return 2 + case .unavailable(.appleIntelligenceNotEnabled): + return 3 + case .unavailable(.modelNotReady): + return 4 + @unknown default: + return 0 + } +} + +@_cdecl("perry_swift_foundation_model_session_create") +public func perrySwiftFoundationModelSessionCreate( + _ bytes: UnsafePointer?, + _ length: Int32 +) -> Int64 { + guard #available(iOS 26.0, *) else { return 0 } + return PerryLanguageModelSessions.shared.create(instructions: decodeUTF8(bytes, length)) +} + +@_cdecl("perry_swift_foundation_model_session_destroy") +public func perrySwiftFoundationModelSessionDestroy(_ session: Int64) { + guard #available(iOS 26.0, *) else { return } + PerryLanguageModelSessions.shared.destroy(session) +} + +@_cdecl("perry_swift_foundation_model_respond") +public func perrySwiftFoundationModelRespond( + _ sessionHandle: Int64, + _ bytes: UnsafePointer?, + _ length: Int32, + _ context: Int64, + _ callback: PerryFoundationModelCompletion +) { + guard #available(iOS 26.0, *) else { + complete(callback, context: context, success: false, value: "Foundation Models requires iOS 26 or later") + return + } + guard let session = PerryLanguageModelSessions.shared.session(for: sessionHandle) else { + complete(callback, context: context, success: false, value: "Invalid or unavailable Foundation Models session") + return + } + let prompt = decodeUTF8(bytes, length) + Task { + do { + let response = try await session.respond(to: prompt) + complete(callback, context: context, success: true, value: response.content) + } catch { + complete(callback, context: context, success: false, value: String(describing: error)) + } + } +} diff --git a/crates/perry-ui-ios/swift/PerryNowPlaying.swift b/crates/perry-ui-ios/swift/PerryNowPlaying.swift new file mode 100644 index 0000000000..eb42da14a6 --- /dev/null +++ b/crates/perry-ui-ios/swift/PerryNowPlaying.swift @@ -0,0 +1,239 @@ +import Foundation +import Observation +@_weakLinked import NowPlaying + +@_silgen_name("perry_ios_now_playing_command") +private func perryNowPlayingCommand(_ handle: Int64, _ command: Int32, _ value: Double) + +private func decodeNowPlayingUTF8(_ bytes: UnsafePointer?, _ length: Int32) -> String { + guard let bytes, length > 0 else { return "" } + return String(decoding: UnsafeBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) +} + +@available(iOS 27.0, *) +@Observable +@MainActor +private final class PerryNowPlayingModel: MediaSessionRepresentable { + let handle: Int64 + let id: String + var title: String + var artist: String + var album: String + var artworkURL: String + var stateCode: Int32 + var elapsedTime: TimeInterval + var duration: TimeInterval + var timestamp: Date + + init( + handle: Int64, + title: String, + artist: String, + album: String, + artworkURL: String, + stateCode: Int32, + elapsedTime: TimeInterval, + duration: TimeInterval + ) { + self.handle = handle + self.id = "perry-media-\(handle)" + self.title = title + self.artist = artist + self.album = album + self.artworkURL = artworkURL + self.stateCode = stateCode + self.elapsedTime = elapsedTime + self.duration = duration + self.timestamp = .now + } + + var content: (any MediaContentRepresentable)? { + let artwork: Artwork? = if artworkURL.isEmpty { + nil + } else { + Artwork(id: artworkURL) { [artworkURL] _ in + guard let url = URL(string: artworkURL) else { + throw URLError(.badURL) + } + return try ArtworkRepresentation(data: Data(contentsOf: url)) + } + } + return MusicContent( + id: "\(id)-\(title)-\(album)", + songTitle: title, + artistName: artist, + albumName: album, + type: .audio, + duration: duration > 0 ? .finite(duration) : nil, + artwork: artwork + ) + } + + var playbackSnapshot: MediaPlaybackSnapshot? { + MediaPlaybackSnapshot( + state: stateCode == 3 ? .playing(rate: 1.0) : .paused, + elapsedTime: elapsedTime, + timestamp: timestamp + ) + } + + var commands: [MediaCommand] { + [ + .play { perryNowPlayingCommand(self.handle, 1, 0) }, + .pause { perryNowPlayingCommand(self.handle, 2, 0) }, + .stop { perryNowPlayingCommand(self.handle, 3, 0) }, + .seekToPosition { value in + perryNowPlayingCommand(self.handle, 4, value) + }, + ] + } + + func updateMetadata(title: String, artist: String, album: String, artworkURL: String) { + self.title = title + self.artist = artist + self.album = album + self.artworkURL = artworkURL + } + + func updateSnapshot(stateCode: Int32, elapsedTime: TimeInterval, duration: TimeInterval) { + self.stateCode = stateCode + self.elapsedTime = elapsedTime + self.duration = duration + self.timestamp = .now + } +} + +@available(iOS 27.0, *) +@MainActor +private final class PerryNowPlayingSessions { + static let shared = PerryNowPlayingSessions() + + private struct Entry { + let model: PerryNowPlayingModel + let session: MediaSession + } + + private var entries: [Int64: Entry] = [:] + + func publish( + handle: Int64, + title: String, + artist: String, + album: String, + artworkURL: String, + stateCode: Int32, + elapsedTime: TimeInterval, + duration: TimeInterval + ) { + if let entry = entries[handle] { + entry.model.updateMetadata( + title: title, + artist: artist, + album: album, + artworkURL: artworkURL + ) + entry.model.updateSnapshot( + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + return + } + + let model = PerryNowPlayingModel( + handle: handle, + title: title, + artist: artist, + album: album, + artworkURL: artworkURL, + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + let session = MediaSession(model) + entries[handle] = Entry(model: model, session: session) + Task { + try? await session.requestToBecomeSystemPrimary() + } + } + + func update(handle: Int64, stateCode: Int32, elapsedTime: TimeInterval, duration: TimeInterval) { + entries[handle]?.model.updateSnapshot( + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + } + + func remove(handle: Int64) { + entries.removeValue(forKey: handle) + } +} + +@_cdecl("perry_swift_now_playing_is_available") +public func perrySwiftNowPlayingIsAvailable() -> Int32 { + if #available(iOS 27.0, *) { + return 1 + } + return 0 +} + +@_cdecl("perry_swift_now_playing_publish") +public func perrySwiftNowPlayingPublish( + _ handle: Int64, + _ titleBytes: UnsafePointer?, + _ titleLength: Int32, + _ artistBytes: UnsafePointer?, + _ artistLength: Int32, + _ albumBytes: UnsafePointer?, + _ albumLength: Int32, + _ artworkBytes: UnsafePointer?, + _ artworkLength: Int32, + _ stateCode: Int32, + _ elapsedTime: Double, + _ duration: Double +) { + guard #available(iOS 27.0, *) else { return } + let title = decodeNowPlayingUTF8(titleBytes, titleLength) + let artist = decodeNowPlayingUTF8(artistBytes, artistLength) + let album = decodeNowPlayingUTF8(albumBytes, albumLength) + let artworkURL = decodeNowPlayingUTF8(artworkBytes, artworkLength) + Task { @MainActor in + PerryNowPlayingSessions.shared.publish( + handle: handle, + title: title, + artist: artist, + album: album, + artworkURL: artworkURL, + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + } +} + +@_cdecl("perry_swift_now_playing_update") +public func perrySwiftNowPlayingUpdate( + _ handle: Int64, + _ stateCode: Int32, + _ elapsedTime: Double, + _ duration: Double +) { + guard #available(iOS 27.0, *) else { return } + Task { @MainActor in + PerryNowPlayingSessions.shared.update( + handle: handle, + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + } +} + +@_cdecl("perry_swift_now_playing_remove") +public func perrySwiftNowPlayingRemove(_ handle: Int64) { + guard #available(iOS 27.0, *) else { return } + Task { @MainActor in + PerryNowPlayingSessions.shared.remove(handle: handle) + } +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 7dc62fcff2..2cb6d9d572 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1094,6 +1094,19 @@ fn collect_module_one( // program uses no widgets. if import.source == "perry/media" { ctx.needs_ui = true; + // On Xcode 27 the final linker uses this marker to compile + // Perry's Swift NowPlaying MediaSession adapter. Older SDKs + // keep using the existing MediaPlayer fallback. + ctx.native_module_imports.insert("perry/media".to_string()); + } + // iOS 27 adoption surface (#5536). The Rust ABI lives in + // libperry_ui_ios.a, while Foundation Models also needs a tiny + // Swift bridge compiled at final-link time. Keep a marker in the + // existing import set so the linker can opt in without forcing + // Swift/Xcode 26+ on unrelated iOS builds. + if import.source == "perry/ios" { + ctx.needs_ui = true; + ctx.native_module_imports.insert("perry/ios".to_string()); } // perry/system: most bindings (preferences, locale, device // info) live in stdlib, but the audio-recording, geolocation, diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index c43dd22b99..3113b62bf0 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -766,6 +766,12 @@ pub(crate) fn build_and_run_link( .arg("-lresolv") .arg("-lobjc") .arg("-lSystem"); + if ctx.native_module_imports.contains("perry/ios") { + // FoundationModels is Swift-only. The bridge object is compiled + // in platform_cmd; weak-link so the app's iOS 17 deployment + // target still launches and reports `unsupported` before iOS 26. + cmd.arg("-weak_framework").arg("FoundationModels"); + } } else if is_visionos { cmd.arg("-framework") .arg("SwiftUI") diff --git a/crates/perry/src/commands/compile/link/platform_cmd.rs b/crates/perry/src/commands/compile/link/platform_cmd.rs index 19f0470ad8..802e6d8b9c 100644 --- a/crates/perry/src/commands/compile/link/platform_cmd.rs +++ b/crates/perry/src/commands/compile/link/platform_cmd.rs @@ -8,6 +8,106 @@ //! platform needs before any of the per-link-line code runs. use super::*; +use sha2::{Digest, Sha256}; + +const FOUNDATION_MODELS_SWIFT: &str = + include_str!("../../../../../perry-ui-ios/swift/PerryFoundationModels.swift"); +const NOW_PLAYING_SWIFT: &str = + include_str!("../../../../../perry-ui-ios/swift/PerryNowPlaying.swift"); + +fn needs_foundation_models_bridge(ctx: &CompilationContext) -> bool { + ctx.native_module_imports.contains("perry/ios") +} + +fn framework_exists(sysroot: &str, name: &str) -> bool { + Path::new(sysroot) + .join("System/Library/Frameworks") + .join(format!("{name}.framework")) + .exists() +} + +fn compile_swift_bridge( + ctx: &CompilationContext, + sdk: &str, + sysroot: &str, + triple: &str, + source_contents: &str, + source_stem: &str, + module_name: &str, +) -> Result { + let swiftc = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", sdk, "--find", "swiftc"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + if swiftc.is_empty() { + return Err(anyhow!("swiftc was not found for the {sdk} SDK")); + } + + let mut hasher = Sha256::new(); + hasher.update(source_contents.as_bytes()); + hasher.update(sysroot.as_bytes()); + hasher.update(triple.as_bytes()); + let digest = hex::encode(hasher.finalize()); + let bridge_dir = ctx.cache_dir.join("swift-bridges"); + fs::create_dir_all(&bridge_dir)?; + let source = bridge_dir.join(format!("{source_stem}-{}.swift", &digest[..16])); + let object = bridge_dir.join(format!("{source_stem}-{}.o", &digest[..16])); + + if !object.exists() { + fs::write(&source, source_contents)?; + let output = Command::new(&swiftc) + .arg("-parse-as-library") + .arg("-emit-object") + .arg("-O") + .arg("-module-name") + .arg(module_name) + .arg("-target") + .arg(triple) + .arg("-sdk") + .arg(sysroot) + .arg(&source) + .arg("-o") + .arg(&object) + .output()?; + if !output.status.success() { + return Err(anyhow!( + "swiftc failed compiling Perry's {source_stem} bridge:\n{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + } + Ok(object) +} + +/// Compile the Swift-only Foundation Models adapter to a content-addressed +/// object. Unrelated iOS builds stay on the existing clang-only path and keep +/// working with older Xcode installations. +fn compile_foundation_models_bridge( + ctx: &CompilationContext, + sdk: &str, + sysroot: &str, + triple: &str, +) -> Result { + if !framework_exists(sysroot, "FoundationModels") { + return Err(anyhow!( + "perry/ios Foundation Models support requires an Xcode SDK that contains FoundationModels.framework (Xcode 26 or later)" + )); + } + compile_swift_bridge( + ctx, + sdk, + sysroot, + triple, + FOUNDATION_MODELS_SWIFT, + "PerryFoundationModels", + "PerryFoundationModelsBridge", + ) +} /// Construct the platform-specific linker `Command` and prime it with the /// toolchain/sysroot/triple flags that every per-platform branch needs @@ -35,8 +135,12 @@ pub fn select_linker_command( is_tvos: bool, is_cross_tvos: bool, ) -> Result { - let _ = ctx; // reserved for future per-platform context-driven flags - // For cross-compilation targets, use the appropriate toolchain + if is_cross_ios && needs_foundation_models_bridge(ctx) { + return Err(anyhow!( + "perry/ios requires Apple's Swift compiler and Foundation Models SDK; build this target on macOS with Xcode 26 or later" + )); + } + // For cross-compilation targets, use the appropriate toolchain let cmd = if is_watchos { let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); @@ -474,6 +578,37 @@ pub fn select_linker_command( // explicitly. Mirrors the cross-iOS branch. .arg("-lc++") .arg("-lc++abi"); + if needs_foundation_models_bridge(ctx) { + c.arg(compile_foundation_models_bridge( + ctx, sdk, &sysroot, triple, + )?); + } + if ctx.native_module_imports.contains("perry/media") + && framework_exists(&sysroot, "NowPlaying") + { + c.arg(compile_swift_bridge( + ctx, + sdk, + &sysroot, + triple, + NOW_PLAYING_SWIFT, + "PerryNowPlaying", + "PerryNowPlayingBridge", + )?) + .arg("-weak_framework") + .arg("NowPlaying"); + // Rust locates these optional bridge entry points with `dlsym` + // so old-SDK binaries retain the MediaPlayer fallback. Preserve + // the string-referenced exports when the final link dead-strips. + for symbol in [ + "perry_swift_now_playing_is_available", + "perry_swift_now_playing_publish", + "perry_swift_now_playing_update", + "perry_swift_now_playing_remove", + ] { + c.arg(format!("-Wl,-u,_{symbol}")); + } + } c } else if is_tvos && is_cross_tvos { // Cross-compile tvOS from Linux using ld64.lld + Apple SDK sysroot. diff --git a/crates/perry/src/commands/types.rs b/crates/perry/src/commands/types.rs index 1e73a79090..6f93cb2071 100644 --- a/crates/perry/src/commands/types.rs +++ b/crates/perry/src/commands/types.rs @@ -20,6 +20,7 @@ pub struct TypesArgs { // Canonical `.d.ts` sources, embedded at compile time from `types/perry/`. const PERRY_UI_DTS: &str = include_str!("../../../../types/perry/ui/index.d.ts"); +const PERRY_IOS_DTS: &str = include_str!("../../../../types/perry/ios/index.d.ts"); const PERRY_THREAD_DTS: &str = include_str!("../../../../types/perry/thread/index.d.ts"); const PERRY_GC_DTS: &str = include_str!("../../../../types/perry/gc/index.d.ts"); const PERRY_I18N_DTS: &str = include_str!("../../../../types/perry/i18n/index.d.ts"); @@ -46,6 +47,7 @@ pub fn write_perry_type_stubs(project_path: &Path, quiet: bool) -> Result<()> { let modules: &[(&str, &str)] = &[ ("ui", PERRY_UI_DTS), + ("ios", PERRY_IOS_DTS), ("thread", PERRY_THREAD_DTS), ("gc", PERRY_GC_DTS), ("i18n", PERRY_I18N_DTS), @@ -84,7 +86,7 @@ pub fn write_perry_type_stubs(project_path: &Path, quiet: bool) -> Result<()> { if !quiet { println!( - " Created .perry/types/ type stubs (ui, thread, i18n, system, media, audio, tui, webassembly, build, native, stdlib)" + " Created .perry/types/ type stubs (ui, ios, thread, i18n, system, media, audio, tui, webassembly, build, native, stdlib)" ); } @@ -140,4 +142,16 @@ mod tests { assert!(source.contains("export type pod")); assert!(source.contains("export declare const NativeArena")); } + + #[test] + fn writes_perry_ios_type_stub() { + let project = tempfile::tempdir().expect("temporary project"); + write_perry_type_stubs(project.path(), true).expect("write type stubs"); + + let ios_stub = project.path().join(".perry/types/perry/ios/index.d.ts"); + let source = fs::read_to_string(ios_stub).expect("read iOS type stub"); + assert!(source.contains("export interface LayoutEnvironment")); + assert!(source.contains("foundationModelAvailability")); + assert!(source.contains("Promise")); + } } diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 977a98312c..944fb58009 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2051 entries across 133 modules +// Coverage: 2059 entries across 134 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2711,6 +2711,23 @@ declare module "perry/i18n" { export function t(...args: any[]): any; } +declare module "perry/ios" { + /** stdlib */ + export function createLanguageModelSession(...args: any[]): any; + /** stdlib */ + export function destroyLanguageModelSession(...args: any[]): any; + /** stdlib */ + export function foundationModelAvailability(...args: any[]): any; + /** stdlib */ + export function getLayoutEnvironment(...args: any[]): any; + /** stdlib */ + export function offLayoutChange(...args: any[]): any; + /** stdlib */ + export function onLayoutChange(...args: any[]): any; + /** stdlib */ + export function respond(...args: any[]): any; +} + declare module "perry/media" { /** stdlib */ export function createPlayer(...args: any[]): any; @@ -4019,6 +4036,8 @@ declare module "tls" { /** stdlib */ export function getCACertificates(type: any): any; /** stdlib */ + export function getCertificateCompressionAlgorithms(...args: any[]): any; + /** stdlib */ export function getCiphers(...args: any[]): any; /** stdlib */ export function setDefaultCACertificates(certs: any): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 579ca5f971..be1ff6ecc8 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2994 entries across 135 modules. +Total: 3009 entries across 136 modules. ## Modules @@ -91,6 +91,7 @@ Total: 2994 entries across 135 modules. - [`perry/container-compose`](#perrycontainer-compose) - [`perry/gc`](#perrygc) - [`perry/i18n`](#perryi18n) +- [`perry/ios`](#perryios) - [`perry/media`](#perrymedia) - [`perry/native`](#perrynative) - [`perry/plugin`](#perryplugin) @@ -2221,10 +2222,16 @@ Total: 2994 entries across 135 modules. - `getConnections` — instance *(class: `Server`)* - `getDefaultAutoSelectFamily` — module - `getDefaultAutoSelectFamilyAttemptTimeout` — module +- `getEphemeralKeyInfo` — instance *(class: `Socket`)* +- `getFinished` — instance *(class: `Socket`)* - `getPeerCertificate` — instance *(class: `Socket`)* +- `getPeerFinished` — instance *(class: `Socket`)* +- `getPeerX509Certificate` — instance *(class: `Socket`)* - `getProtocol` — instance *(class: `Socket`)* - `getSession` — instance *(class: `Socket`)* +- `getSharedSigalgs` — instance *(class: `Socket`)* - `getTypeOfService` — instance *(class: `Socket`)* +- `getX509Certificate` — instance *(class: `Socket`)* - `isBlockList` — module *(class: `BlockList`)* - `isIP` — module - `isIPv4` — module @@ -2268,6 +2275,7 @@ Total: 2994 entries across 135 modules. - `setDefaultEncoding` — instance *(class: `Socket`)* - `setEncoding` — instance *(class: `Socket`)* - `setKeepAlive` — instance *(class: `Socket`)* +- `setKeyCert` — instance *(class: `Socket`)* - `setMaxSendFragment` — instance *(class: `Socket`)* - `setNoDelay` — instance *(class: `Socket`)* - `setTimeout` — instance *(class: `Socket`)* @@ -2610,6 +2618,18 @@ Total: 2994 entries across 135 modules. - `ShortDate` — module - `t` — module +## `perry/ios` + +### Methods + +- `createLanguageModelSession` — module +- `destroyLanguageModelSession` — module +- `foundationModelAvailability` — module +- `getLayoutEnvironment` — module +- `offLayoutChange` — module +- `onLayoutChange` — module +- `respond` — module + ## `perry/media` ### Methods @@ -3639,6 +3659,7 @@ Total: 2994 entries across 135 modules. - `createServer` — module - `eventNames` — instance *(class: `Server`)* - `getCACertificates` — module +- `getCertificateCompressionAlgorithms` — module - `getCiphers` — module - `getTicketKeys` — instance *(class: `Server`)* - `listen` — instance *(class: `Server`)* diff --git a/docs/src/platforms/ios.md b/docs/src/platforms/ios.md index 203805474e..87cb7672a7 100644 --- a/docs/src/platforms/ios.md +++ b/docs/src/platforms/ios.md @@ -68,7 +68,77 @@ iOS apps use `UIApplicationMain` with a deferred creation pattern: {{#include ../../examples/platforms/ui/ios_app.ts:ios-app}} ``` -The `App()` call triggers `UIApplicationMain`, and your render function is called via `PerryAppDelegate` once the app is ready. +The `App()` call triggers `UIApplicationMain`, and your render function is called via `PerryAppDelegate` once the app is ready. Perry-generated apps use `UIWindowScene`, `PerrySceneDelegate`, and an `UIApplicationSceneManifest`, which also satisfies the scene-based lifecycle required for apps built with the iOS 27 SDK. + +## Adaptive layouts + +Use `perry/ios` to inspect the active scene rather than branching on a device model or physical screen size: + +```typescript,no-test +import { + getLayoutEnvironment, + onLayoutChange, + offLayoutChange, +} from "perry/ios"; + +const initial = getLayoutEnvironment(); +console.log(initial.width, initial.horizontalSizeClass, initial.windowMode); + +const subscription = onLayoutChange((layout) => { + if (layout.horizontalSizeClass === "compact") { + // Present a compact navigation treatment. + } + if (layout.isFourByThree || layout.windowMode === "sideBySide") { + // Reflow content for 4:3 or iPad side-by-side multitasking. + } + + // These insets describe display cutouts, rounded corners, and any future + // interrupted-display geometry exposed to the scene by UIKit. + console.log(layout.safeAreaTop, layout.safeAreaRight); +}); + +// When the observer is no longer needed: +offLayoutChange(subscription); +``` + +Snapshots contain the window dimensions and aspect ratio, display scale, horizontal and vertical size classes, orientation, window mode, multitasking and 4:3 flags, and all four safe-area insets. On iOS 27 they also contain the effective scene's system-space frame, interactive-resize state, and orientation-lock state. The callback fires once when a scene is available and then after meaningful bounds, trait, safe-area, or effective-geometry changes. + +UIKit does not expose a separate public hardware-model or hinge-state property. Safe areas, effective scene geometry, and trait collections are the supported adaptive signals, and they continue to work when one device moves among full-screen, side-by-side, and freeform window modes. Perry's `SplitView` and `FrameSplit` also cap their preferred sidebar at 45% of the current scene width so the detail pane remains usable in narrow layouts. + +## Foundation Models + +The simple, unstructured Foundation Models flow is available through `perry/ios`: + +```typescript,no-test +import { + foundationModelAvailability, + createLanguageModelSession, + respond, + destroyLanguageModelSession, +} from "perry/ios"; + +if (foundationModelAvailability() === "available") { + const session = createLanguageModelSession( + "Answer in one short, factual sentence.", + ); + try { + const answer = await respond(session, "Why is the sky blue?"); + console.log(answer); + } finally { + destroyLanguageModelSession(session); + } +} +``` + +The bridge uses Apple's default `LanguageModelSession`, preserves conversational context while a session handle is reused, and rejects the returned promise when generation fails. Check availability first: unsupported OS versions and unavailable Apple Intelligence configurations are reported without loading the framework. This surface intentionally returns plain strings; structured `@Generable` responses are outside the current API. + +Building a source file that imports `perry/ios` requires an Xcode SDK containing `FoundationModels.framework` (Xcode 26 or later). The framework is weak-linked, so the normal iOS 17 deployment target remains valid. + +## Now Playing on iOS 27 + +`perry/media.setNowPlaying(...)` is the public Perry API for Lock Screen, Control Center, Dynamic Island, CarPlay, artwork, playback progress, and play/pause/stop/seek commands. When an Xcode 27 SDK containing `NowPlaying.framework` is installed, Perry automatically compiles an observable `MediaSession` bridge and publishes each player through the new framework. Builds made with older SDKs, and devices before iOS 27, retain the existing `MPNowPlayingInfoCenter` / `MPRemoteCommandCenter` compatibility path. Perry never activates both paths for one local session. + +The iOS 27 SDK is beta software until Apple's GM release. This support does not change Perry's SDK build markers or version; distribution metadata should only be updated once the GM toolchain can submit to App Store Connect. ## iOS Widgets (WidgetKit) diff --git a/docs/src/system/media.md b/docs/src/system/media.md index fc8a1f5d2b..0e57d384d6 100644 --- a/docs/src/system/media.md +++ b/docs/src/system/media.md @@ -84,7 +84,7 @@ tick if the signal hasn't arrived. | Platform | Backend | Status | | --- | --- | --- | | macOS | AVPlayer + MPNowPlayingInfoCenter + MPRemoteCommandCenter | **Implemented** + lock-screen | -| iOS | AVPlayer + AVAudioSession Playback + UIImage artwork | **Implemented** + lock-screen | +| iOS | AVPlayer + NowPlaying MediaSession (iOS 27) or MediaPlayer fallback | **Implemented** + lock-screen | | tvOS | AVPlayer + Siri Remote play/pause/skip | **Implemented** + remote | | visionOS | AVPlayer + UIImage artwork | **Implemented** + lock-screen | | Android | `android.media.MediaPlayer` + `MediaSessionCompat` via JNI | **Implemented** + lock-screen | @@ -176,20 +176,28 @@ calling code. Implementation detail varies: ## Now Playing on Apple platforms -Apple's MPNowPlayingInfoCenter is a process-wide singleton — the most -recent `setNowPlaying` call wins. For a single-player app (Subsonic -client, podcast player) this matches user expectation. The -MPRemoteCommandCenter handlers route `play` / `pause` / `togglePlayPause` -events to the **first live player handle** — multi-player apps that -need an explicit "active player" should manage that themselves. +On iOS 27, an app built with an Xcode 27 SDK uses Apple's new NowPlaying +framework. Perry creates an observable `MediaSession` per player, keeps its +metadata, playback snapshot, elapsed time, and duration synchronized, and +routes play, pause, stop, and seek commands to the matching `AVPlayer` handle. +The bridge requests system-primary status so the session appears on the Lock +Screen, in Control Center and Dynamic Island, and on connected surfaces such +as CarPlay. + +On devices before iOS 27, builds made without the new framework, and other +Apple platforms, Perry uses `MPNowPlayingInfoCenter` and +`MPRemoteCommandCenter`. `MPNowPlayingInfoCenter` is process-wide, so the most +recent `setNowPlaying` call wins on that compatibility path. The remote command +handlers route events to the first live player handle. Perry selects exactly +one implementation for a local iOS session; Apple warns that mixing the new +NowPlaying and legacy MediaPlayer APIs has undefined behavior. `artworkUrl` accepts: -- `file://` paths — loaded synchronously via NSImage / UIImage -- `https://` URLs — fetched synchronously via NSData(contentsOf:) and - wrapped in UIImage. The synchronous fetch is acceptable for a one-off - artwork load (the MPNowPlayingInfoCenter dict is consumed - synchronously when set). +- `file://` paths — loaded via the platform image/artwork loader +- `https://` URLs — requested when the system needs artwork. The legacy + MediaPlayer path fetches once via `NSData(contentsOf:)`; iOS 27's + NowPlaying `Artwork` provider loads it asynchronously on demand. ### watchOS Info.plist requirements diff --git a/types/perry/ios/index.d.ts b/types/perry/ios/index.d.ts new file mode 100644 index 0000000000..ea92587b8f --- /dev/null +++ b/types/perry/ios/index.d.ts @@ -0,0 +1,91 @@ +// Type declarations for iOS-specific Perry APIs. + +/** UIKit size class for the active scene. */ +export type LayoutSizeClass = "compact" | "regular" | "unspecified"; + +/** How the active scene currently occupies its display. */ +export type WindowMode = "fullScreen" | "sideBySide" | "windowed"; + +/** + * A scene-relative layout snapshot. Values are expressed in UIKit points, + * not physical pixels. Use these values instead of device-model checks: the + * same iPad can move between full screen, Split View, and Stage Manager at + * runtime, and future display shapes can expose different safe areas. + */ +export interface LayoutEnvironment { + width: number; + height: number; + aspectRatio: number; + displayScale: number; + horizontalSizeClass: LayoutSizeClass; + verticalSizeClass: LayoutSizeClass; + orientation: "portrait" | "landscape" | "square"; + windowMode: WindowMode; + isMultitasking: boolean; + isFourByThree: boolean; + /** iOS 27 effective scene frame in the system display coordinate space. */ + systemFrameX: number; + systemFrameY: number; + systemFrameWidth: number; + systemFrameHeight: number; + /** Whether iOS 27 is currently delivering an interactive window resize. */ + isInteractivelyResizing: boolean; + /** Whether the scene's interface orientation is currently locked. */ + isInterfaceOrientationLocked: boolean; + safeAreaTop: number; + safeAreaRight: number; + safeAreaBottom: number; + safeAreaLeft: number; +} + +/** Return the current active UIWindowScene's adaptive-layout environment. */ +export function getLayoutEnvironment(): LayoutEnvironment; + +/** + * Subscribe to scene geometry, size-class, and safe-area changes. The handler + * receives an initial snapshot when a scene is available and then only when + * the snapshot changes. Returns a 1-based subscription handle. + */ +export function onLayoutChange( + callback: (environment: LayoutEnvironment) => void, +): number; + +/** Remove a layout subscription. Unknown handles are ignored. */ +export function offLayoutChange(subscription: number): void; + +/** Availability of Apple's default system language model. */ +export type FoundationModelAvailability = + | "available" + | "deviceNotEligible" + | "appleIntelligenceNotEnabled" + | "modelNotReady" + | "unsupported"; + +/** Opaque, process-local Foundation Models session handle. */ +export type LanguageModelSession = number & { + readonly __perryLanguageModelSession: unique symbol; +}; + +/** Query the default model before creating a session. */ +export function foundationModelAvailability(): FoundationModelAvailability; + +/** + * Create a conversational Foundation Models session. Reusing the handle keeps + * the session transcript/context between `respond` calls. An empty instruction + * string creates a session without system instructions. Returns `0` when the + * framework is unavailable on this OS. + */ +export function createLanguageModelSession( + instructions?: string, +): LanguageModelSession; + +/** Generate an unstructured string response for a prompt. */ +export function respond( + session: LanguageModelSession, + prompt: string, +): Promise; + +/** Destroy a session. Pending responses are allowed to finish. */ +export function destroyLanguageModelSession( + session: LanguageModelSession, +): void; diff --git a/types/perry/media/index.d.ts b/types/perry/media/index.d.ts index 296004a23b..64919f8df0 100644 --- a/types/perry/media/index.d.ts +++ b/types/perry/media/index.d.ts @@ -96,7 +96,8 @@ export function onTimeUpdate( * path to a local image or an `https://` URL — the platform backend caches * remote artwork before display. * - * Apple: backed by `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`. + * Apple: backed by an observable NowPlaying `MediaSession` on iOS 27, + * with `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter` as the fallback. * Android: backed by `MediaSessionCompat`. * Linux/GTK4: backed by MPRIS D-Bus. * Windows: backed by `SystemMediaTransportControls`.