From e359960d8b1e4d3b15b15d33d79fd5b13b8e4484 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Wed, 30 Sep 2026 16:28:09 -0400 Subject: [PATCH] feat: metadata filtering from native-api-usage.json, as on Android and iOS An app (and its plugins) can now limit the native API it uses with App_Resources/Windows/native-api-usage.json. The CLI already writes it out as whitelist.mdg / blacklist.mdg in platforms/windows; nothing read them. Same format and rules as the Android and iOS metadata generators: one `namespace:type` pattern per line, `*` and `?` wildcards, a bare namespace covers every type in it; a whitelist is exclusive, the blacklist wins. - metadata-filter: the parser and matcher, shared by the runtime and the build tools. NativeScript.* (the runtime's widgets, sbg's proxies) is always whitelisted, as Android always allows com.tns.gen*. - Runtime: both engines load the files from next to the exe at startup. A type the filter leaves out is not there for JS (undefined), as it isn't in the Android/iOS metadata; namespaces stay traversable, and the runtime's own lookups (return types, bases, an instance's runtime class) are not filtered. Each type's verdict is computed once. - dotnet-tool no longer takes names that can't be types (minified `Ua.$`) for extensions, drops extensions of bases the filter leaves out, and publishes dotnet-bridge only for .NET usage or an extension that is surely real (named, or of a Windows/Microsoft/System/NativeScript base). A bundled library's classes (babylon.js, phaser) made it publish on every build. A stale sbg_metadata.json is removed when nothing is found. - sbg skips extensions whose base the filter leaves out (SBG_WHITELIST / SBG_BLACKLIST) and shares the base check with dotnet-tool. - Template: copies the .mdg files next to the exe (and removes a copy the app no longer has), and passes them to sbg. --- Cargo.toml | 2 +- metadata-filter/Cargo.toml | 6 + metadata-filter/src/lib.rs | 303 ++++++++++++++++++ metadata/Cargo.toml | 1 + metadata/src/meta_data_reader.rs | 83 ++++- runtime/src/lib.rs | 5 +- runtime/src/napi_engine/interop.rs | 12 +- runtime/src/napi_engine/ns_proxy.rs | 6 +- runtime/src/ns_proxy.rs | 4 +- sbg/Cargo.toml | 1 + sbg/src/main.rs | 105 ++++-- .../__PROJECT_NAME__/__PROJECT_NAME__.csproj | 21 +- tools/dotnet-tool/Cargo.toml | 1 + tools/dotnet-tool/src/main.rs | 108 ++++++- 14 files changed, 613 insertions(+), 45 deletions(-) create mode 100644 metadata-filter/Cargo.toml create mode 100644 metadata-filter/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 12a0598..4910515 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["metadata", "playground", "runtime", "runtime-binding-gen", "sbg", "nativescript", "typings-generator", "integration-tests", "runtime-devtools", "metadata-generator","tools/dotnet-tool", "windows-napi", "napi-v8-shim"] +members = ["metadata", "metadata-filter", "playground", "runtime", "runtime-binding-gen", "sbg", "nativescript", "typings-generator", "integration-tests", "runtime-devtools", "metadata-generator","tools/dotnet-tool", "windows-napi", "napi-v8-shim"] # Excluded so their C builds / prebuilt-engine links don't run on normal `cargo` invocations. exclude = ["packages/common", "packages/demo", "packages/windows-quickjs", "packages/windows-hermes", "packages/windows-jsc", "packages/windows-v8"] diff --git a/metadata-filter/Cargo.toml b/metadata-filter/Cargo.toml new file mode 100644 index 0000000..f786dad --- /dev/null +++ b/metadata-filter/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "metadata-filter" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/metadata-filter/src/lib.rs b/metadata-filter/src/lib.rs new file mode 100644 index 0000000..19b36ee --- /dev/null +++ b/metadata-filter/src/lib.rs @@ -0,0 +1,303 @@ +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; + +pub const WHITELIST_FILE: &str = "whitelist.mdg"; +pub const BLACKLIST_FILE: &str = "blacklist.mdg"; + +/// Always allowed, as Android always allows `com.tns.gen*`: the runtime's own types and sbg's +/// generated proxies (`NativeScript.Gen.*`). +const ALWAYS_ALLOWED_ROOT: &str = "NativeScript"; + +/// Roots a JS extension can derive from without WinRT metadata to check it against (the Windows +/// App SDK's `Microsoft.UI.*` lives in a framework package, not system metadata). +const EXTENDABLE_ROOTS: &[&str] = &["Windows", "Microsoft", "System", "NativeScript"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pattern { + namespace: String, + type_name: String, +} + +impl Pattern { + fn matches(&self, namespace: &str, type_name: &str) -> bool { + (self.namespace.is_empty() || wildcard_match(&self.namespace, namespace)) + && (self.type_name.is_empty() || wildcard_match(&self.type_name, type_name)) + } +} + +impl fmt::Display for Pattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.type_name.is_empty() { + f.write_str(&self.namespace) + } else { + write!(f, "{}:{}", self.namespace, self.type_name) + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct PatternList(Vec); + +impl PatternList { + pub fn parse(text: &str) -> Self { + let patterns = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("//")) + .map(|line| { + let (namespace, type_name) = line.split_once(':').unwrap_or((line, "")); + Pattern { + namespace: namespace.trim().to_owned(), + type_name: type_name.trim().to_owned(), + } + }) + .collect(); + Self(patterns) + } + + /// `None` when there is no such file. + pub fn from_file(path: &Path) -> io::Result> { + match fs::read_to_string(path) { + Ok(text) => Ok(Some(Self::parse(&text))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn find(&self, namespace: &str, type_name: &str) -> Option<&Pattern> { + self.0.iter().find(|pattern| pattern.matches(namespace, type_name)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict<'a> { + Allowed, + NotWhitelisted, + Blacklisted(&'a Pattern), +} + +impl fmt::Display for Verdict<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Verdict::Allowed => f.write_str("allowed"), + Verdict::NotWhitelisted => f.write_str("not whitelisted"), + Verdict::Blacklisted(pattern) => write!(f, "blacklisted by '{pattern}'"), + } + } +} + +/// The native API an app allows itself (`App_Resources/Windows/native-api-usage.json`, which the CLI +/// writes out as `whitelist.mdg` / `blacklist.mdg`, as for Android and iOS). Each line is a +/// `namespace:type` pattern (`*` and `?` wildcards; a bare `namespace` covers every type in it; `#` +/// and `//` start comments). A whitelist, when there is one, is exclusive; the blacklist wins. +#[derive(Debug, Clone, Default)] +pub struct MetadataFilter { + whitelist: Option, + blacklist: PatternList, +} + +impl MetadataFilter { + pub fn new(whitelist: Option, blacklist: PatternList) -> Self { + Self { whitelist, blacklist } + } + + /// `whitelist.mdg` and `blacklist.mdg` in `dir`, where present. + pub fn load(dir: &Path) -> io::Result { + Self::from_files(Some(&dir.join(WHITELIST_FILE)), Some(&dir.join(BLACKLIST_FILE))) + } + + pub fn from_files(whitelist: Option<&Path>, blacklist: Option<&Path>) -> io::Result { + let whitelist = match whitelist { + Some(path) => PatternList::from_file(path)?, + None => None, + }; + let blacklist = match blacklist { + Some(path) => PatternList::from_file(path)?.unwrap_or_default(), + None => PatternList::default(), + }; + Ok(Self::new(whitelist, blacklist)) + } + + /// Nothing is filtered. + pub fn is_empty(&self) -> bool { + self.whitelist.is_none() && self.blacklist.is_empty() + } + + /// `full_name`: a type's full name, generic (`` Windows.Foundation.IReference`1 ``) or not. + pub fn check(&self, full_name: &str) -> Verdict<'_> { + if self.is_empty() { + return Verdict::Allowed; + } + let (namespace, type_name) = split_type_name(full_name); + if let Some(pattern) = self.blacklist.find(namespace, type_name) { + return Verdict::Blacklisted(pattern); + } + let whitelisted = match &self.whitelist { + None => true, + Some(whitelist) => is_always_allowed(namespace) || whitelist.find(namespace, type_name).is_some(), + }; + if whitelisted { + Verdict::Allowed + } else { + Verdict::NotWhitelisted + } + } + + pub fn allows(&self, full_name: &str) -> bool { + self.check(full_name) == Verdict::Allowed + } +} + +fn is_always_allowed(namespace: &str) -> bool { + namespace == ALWAYS_ALLOWED_ROOT + || namespace + .strip_prefix(ALWAYS_ALLOWED_ROOT) + .is_some_and(|rest| rest.starts_with('.')) +} + +/// Namespace and type name, without generic arity or arguments. +fn split_type_name(full_name: &str) -> (&str, &str) { + let open = full_name.split('<').next().unwrap_or(full_name); + let open = open.split('`').next().unwrap_or(open); + open.rsplit_once('.').unwrap_or(("", open)) +} + +/// `*` matches any run of characters (none included), `?` any one. +pub fn wildcard_match(pattern: &str, input: &str) -> bool { + let pattern: Vec = pattern.chars().collect(); + let input: Vec = input.chars().collect(); + let (mut p, mut i) = (0, 0); + // The last `*` seen and where in `input` it started matching, to backtrack to. + let mut star: Option<(usize, usize)> = None; + while i < input.len() { + if p < pattern.len() && (pattern[p] == '?' || pattern[p] == input[i]) { + p += 1; + i += 1; + } else if p < pattern.len() && pattern[p] == '*' { + star = Some((p, i)); + p += 1; + } else if let Some((star_p, star_i)) = star { + p = star_p + 1; + i = star_i + 1; + star = Some((star_p, star_i + 1)); + } else { + return false; + } + } + pattern[p..].iter().all(|&c| c == '*') +} + +fn is_identifier(segment: &str) -> bool { + let mut chars = segment.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// A name that could be a WinRT/.NET type: two or more identifier segments (a generic arity +/// suffix aside). A bundled library's minified class (`Ua.$`) is not. +pub fn is_dotted_identifier(name: &str) -> bool { + let open = name.split('`').next().unwrap_or(name); + let mut segments = open.split('.'); + let first_two = segments.next().is_some_and(is_identifier) && segments.next().is_some_and(is_identifier); + first_two && segments.all(is_identifier) +} + +/// Whether a JS class's base can be a WinRT type without metadata to look it up in: a dotted +/// identifier under a root JS extends native types from. Library classes (`u.MaterialDefines`, +/// `Phaser.Utils`) are not; sbg also accepts bases it finds in WinRT metadata. +pub fn plausible_base(name: &str) -> bool { + is_dotted_identifier(name) && name.split('.').next().is_some_and(|root| EXTENDABLE_ROOTS.contains(&root)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filter(whitelist: Option<&str>, blacklist: &str) -> MetadataFilter { + MetadataFilter::new(whitelist.map(PatternList::parse), PatternList::parse(blacklist)) + } + + #[test] + fn parses_lines_like_android_and_ios() { + let list = PatternList::parse("# comment\n// comment\n\n Windows.Storage:StorageFile \nWindows.UI.*\n"); + assert_eq!(list.0.len(), 2); + assert_eq!(list.0[0].to_string(), "Windows.Storage:StorageFile"); + assert_eq!(list.0[1].to_string(), "Windows.UI.*"); + } + + #[test] + fn wildcards() { + assert!(wildcard_match("*", "")); + assert!(wildcard_match("*", "Windows")); + assert!(wildcard_match("Windows.*", "Windows.Storage")); + assert!(!wildcard_match("Windows.*", "Windows")); + assert!(wildcard_match("Windows*", "Windows")); + assert!(wildcard_match("W?ndows", "Windows")); + assert!(!wildcard_match("W?ndows", "Wndows")); + assert!(wildcard_match("*.Storage.*", "Windows.Storage.Pickers")); + assert!(wildcard_match("a*b*c", "axxbyyc")); + assert!(!wildcard_match("a*b*c", "axxbyy")); + assert!(!wildcard_match("Storage", "StorageFile")); + // A long mismatch stays linear-ish instead of blowing up. + assert!(!wildcard_match("*a*a*a*a*a*a*a*b", &"a".repeat(200))); + } + + #[test] + fn no_files_allow_everything() { + let f = MetadataFilter::default(); + assert!(f.is_empty()); + assert!(f.allows("Windows.Storage.StorageFile")); + } + + #[test] + fn a_whitelist_is_exclusive() { + let f = filter(Some("Windows.Storage:StorageFile\nMicrosoft.UI.Xaml*"), ""); + assert!(f.allows("Windows.Storage.StorageFile")); + assert!(f.allows("Microsoft.UI.Xaml.Controls.Button")); + assert_eq!(f.check("Windows.Storage.StorageFolder"), Verdict::NotWhitelisted); + } + + #[test] + fn the_blacklist_wins() { + let f = filter(Some("Windows.Storage*"), "Windows.Storage.Pickers"); + assert!(f.allows("Windows.Storage.StorageFile")); + let verdict = f.check("Windows.Storage.Pickers.FileOpenPicker"); + assert_eq!(verdict.to_string(), "blacklisted by 'Windows.Storage.Pickers'"); + } + + #[test] + fn generic_names_match_their_open_type() { + let f = filter(None, "Windows.Foundation:IReference"); + assert!(!f.allows("Windows.Foundation.IReference`1")); + assert!(!f.allows("Windows.Foundation.IReference`1")); + assert!(f.allows("Windows.Foundation.Uri")); + } + + #[test] + fn the_runtimes_own_types_pass_a_whitelist_but_not_the_blacklist() { + let f = filter(Some("Windows.Storage*"), ""); + assert!(f.allows("NativeScript.Gen.Button_1")); + assert!(f.allows("NativeScript.Widgets.StackLayout")); + assert!(!f.allows("NativeScriptish.Thing")); + let f = filter(Some("Windows.Storage*"), "NativeScript.Widgets"); + assert!(!f.allows("NativeScript.Widgets.StackLayout")); + } + + #[test] + fn plausible_bases() { + assert!(plausible_base("Microsoft.UI.Xaml.Controls.Button")); + assert!(plausible_base("Windows.Foundation.Collections.IVector`1")); + assert!(!plausible_base("Ua.$")); + assert!(!plausible_base("u.MaterialDefines")); + assert!(!plausible_base("Phaser.Utils")); + assert!(!plausible_base("Windows")); + assert!(is_dotted_identifier("CommunityToolkit.WinUI.Controls.Segmented")); + assert!(!is_dotted_identifier("Ua.$")); + } +} diff --git a/metadata/Cargo.toml b/metadata/Cargo.toml index 46b0a7b..032ea8b 100644 --- a/metadata/Cargo.toml +++ b/metadata/Cargo.toml @@ -12,6 +12,7 @@ ahash = "0.8" parking_lot = "0.12" dyn-clone = "1.0" sha1 = "0.10" +metadata-filter = { path = "../metadata-filter" } [dependencies.windows] workspace = true diff --git a/metadata/src/meta_data_reader.rs b/metadata/src/meta_data_reader.rs index 350ecb9..1c5dbda 100644 --- a/metadata/src/meta_data_reader.rs +++ b/metadata/src/meta_data_reader.rs @@ -1,5 +1,5 @@ use crate::declarations::class_declaration::ClassDeclaration; -use crate::declarations::declaration::Declaration; +use crate::declarations::declaration::{Declaration, DeclarationKind}; use crate::declarations::delegate_declaration::generic_delegate_declaration::GenericDelegateDeclaration; use crate::declarations::delegate_declaration::DelegateDeclaration; use crate::declarations::enum_declaration::EnumDeclaration; @@ -15,7 +15,7 @@ use std::cell::RefCell; use std::ffi::OsString; use std::mem::MaybeUninit; use std::os::windows::prelude::OsStringExt; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use windows::core::{Interface, HSTRING, PCWSTR}; use windows::Win32::Foundation::RO_E_METADATA_NAME_IS_NAMESPACE; use windows::Win32::System::WinRT::Metadata::{ @@ -37,8 +37,12 @@ thread_local! { // opened import scope plus its typedef names (for namespace synthesis). static SIDELOADED_SCOPES: RefCell> = RefCell::new(Vec::new()); static SIDELOADED_PATHS: RefCell> = RefCell::new(AHashSet::new()); + // What `FILTER` said about each type JS asked for, so each is matched once. + static FILTER_VERDICTS: RefCell> = RefCell::new(AHashMap::new()); } +static FILTER: OnceLock = OnceLock::new(); + struct SideloadedScope { import: IMetaDataImport2, type_names: Vec, @@ -70,6 +74,55 @@ impl MetadataReader { None } + /// The app's native API filter (`whitelist.mdg` / `blacklist.mdg`), set once at startup. + pub fn set_filter(filter: metadata_filter::MetadataFilter) { + let _ = FILTER.set(filter); + } + + /// Loads the filter from the first of `dirs` holding `whitelist.mdg` or `blacklist.mdg` (the build + /// copies them next to the exe). + pub fn load_filter(dirs: impl IntoIterator) { + use metadata_filter::{MetadataFilter, BLACKLIST_FILE, WHITELIST_FILE}; + let Some(dir) = dirs + .into_iter() + .find(|dir| [WHITELIST_FILE, BLACKLIST_FILE].iter().any(|file| dir.join(file).is_file())) + else { + return; + }; + match MetadataFilter::load(&dir) { + Ok(filter) => MetadataReader::set_filter(filter), + Err(error) => eprintln!( + "[NativeScript] metadata filter in {} not loaded: {error}", + dir.display() + ), + } + } + + /// Whether JS may reach `declaration` by name. A type the app's filter leaves out isn't there, + /// as on Android and iOS where it's left out of the metadata; namespaces always are, so JS can + /// still reach the types it may use. The runtime's own lookups (return types, bases, + /// interfaces, an instance's runtime class) are not filtered. + pub fn visible_to_js(declaration: &Arc>) -> bool { + let Some(filter) = FILTER.get().filter(|filter| !filter.is_empty()) else { + return true; + }; + let lock = declaration.read(); + if lock.kind() == DeclarationKind::Namespace { + return true; + } + let full_name = lock.full_name(); + if let Some(visible) = FILTER_VERDICTS.with(|verdicts| verdicts.borrow().get(full_name).copied()) { + return visible; + } + let verdict = filter.check(full_name); + let visible = verdict == metadata_filter::Verdict::Allowed; + if !visible { + eprintln!("[NativeScript] metadata filter: {full_name} is unavailable ({verdict})"); + } + FILTER_VERDICTS.with(|verdicts| verdicts.borrow_mut().insert(full_name.to_string(), visible)); + visible + } + pub fn find_by_name(full_name: &str) -> Option>> { let cached = DECLARATION_CACHE.with(|cache| cache.borrow().get(full_name).map(Arc::clone)); if let Some(arc) = cached { @@ -483,6 +536,32 @@ mod sideload_tests { } } +#[cfg(test)] +mod filter_tests { + use super::*; + use metadata_filter::{MetadataFilter, PatternList}; + + // The only test that sets the process-wide filter; the others never ask `visible_to_js`. + #[test] + fn filtered_types_are_hidden_from_js_but_resolve_for_the_runtime() { + MetadataReader::set_filter(MetadataFilter::new( + None, + PatternList::parse("Windows.Storage.Pickers:FileOpen*"), + )); + let picker = MetadataReader::find_by_name("Windows.Storage.Pickers.FileOpenPicker") + .expect("the runtime still resolves a filtered type"); + assert!(!MetadataReader::visible_to_js(&picker)); + // Twice: the second answer comes from the verdict cache. + assert!(!MetadataReader::visible_to_js(&picker)); + let save = MetadataReader::find_by_name("Windows.Storage.Pickers.FileSavePicker").unwrap(); + assert!(MetadataReader::visible_to_js(&save)); + let namespace = MetadataReader::find_by_name("Windows.Storage.Pickers").unwrap(); + assert!(MetadataReader::visible_to_js(&namespace)); + let handler = MetadataReader::find_by_name_or_generic("Windows.Foundation.EventHandler").unwrap(); + assert!(MetadataReader::visible_to_js(&handler)); + } +} + /// Collects every typedef's full name from an opened metadata scope; used to /// synthesize namespace declarations for dotted traversal of sideloaded types. fn enumerate_typedef_names(import: &IMetaDataImport2) -> Vec { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index c263d66..9ee0eb9 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -7477,7 +7477,9 @@ fn handle_named_property_getter( if let Some(dec) = dec { let full_name = format!("{}.{}", dec.full_name(), name.as_str()); - if let Some(dec) = MetadataReader::find_by_name_or_generic(full_name.as_str()) { + if let Some(dec) = MetadataReader::find_by_name_or_generic(full_name.as_str()) + .filter(MetadataReader::visible_to_js) + { let declaration = Arc::clone(&dec); let lock = dec.read(); @@ -8618,6 +8620,7 @@ impl Runtime { .and_then(|p| p.parent().map(|p| p.to_path_buf())), Some(std::path::PathBuf::from(app_root)), ]; + metadata::meta_data_reader::MetadataReader::load_filter(scan_dirs.iter().flatten().cloned()); for dir in scan_dirs.into_iter().flatten() { let Ok(entries) = std::fs::read_dir(&dir) else { continue; diff --git a/runtime/src/napi_engine/interop.rs b/runtime/src/napi_engine/interop.rs index 336ae30..bf8c312 100644 --- a/runtime/src/napi_engine/interop.rs +++ b/runtime/src/napi_engine/interop.rs @@ -67,10 +67,20 @@ pub fn scan_winmd_dir(dir: &str) -> usize { } /// Scan the default locations once (cwd + the addon/executable directory) for third-party -/// `.winmd` files, mirroring `Runtime::new`'s auto-scan for the napi path. +/// `.winmd` files and the app's metadata filter, mirroring `Runtime::new` for the napi path. pub fn scan_default_winmd_dirs() { static ONCE: Once = Once::new(); ONCE.call_once(|| { + metadata::meta_data_reader::MetadataReader::load_filter( + [ + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())), + std::env::current_dir().ok(), + ] + .into_iter() + .flatten(), + ); if let Ok(cwd) = std::env::current_dir() { if let Some(s) = cwd.to_str() { scan_winmd_dir(s); diff --git a/runtime/src/napi_engine/ns_proxy.rs b/runtime/src/napi_engine/ns_proxy.rs index 89f148f..a6b2cb0 100644 --- a/runtime/src/napi_engine/ns_proxy.rs +++ b/runtime/src/napi_engine/ns_proxy.rs @@ -581,7 +581,7 @@ fn instance_type_name(env: &Env, inst: &JsUnknown) -> Option { /// Resolve `full_name` and produce the right JS value: nested namespace proxy, class ctor /// proxy, or undefined for unknown/unported kinds. fn resolve_member(env: &Env, full_name: &str) -> napi::Result { - let Some(declaration) = MetadataReader::find_by_name(full_name) else { + let Some(declaration) = MetadataReader::find_by_name(full_name).filter(MetadataReader::visible_to_js) else { return undefined_js(env); }; let kind = declaration.read().kind(); @@ -648,7 +648,9 @@ pub fn create_namespace_proxy(env: &Env, full_name: &str) -> napi::Result, + + /// The app's native API filter (`SBG_WHITELIST` / `SBG_BLACKLIST`): extensions of a base it + /// leaves out are skipped, since JS can't reach that base at run time. + pub filter: metadata_filter::MetadataFilter, } impl Default for SbgConfig { @@ -59,6 +63,7 @@ impl Default for SbgConfig { target_platform_min_version: None, use_uwp: true, app_cs_sources_dirs: Vec::new(), + filter: metadata_filter::MetadataFilter::default(), } } } @@ -90,7 +95,7 @@ impl StaticBindingGenerator { extensions_metadata.len() ); - let extensions_metadata = retain_extendable(extensions_metadata); + let extensions_metadata = retain_extendable(extensions_metadata, &self.config.filter); // Proxies from an earlier run whose extension is gone (or now skipped) must not stay in // the build: the app compiles whatever is in the directory. @@ -337,6 +342,10 @@ fn main() -> Result<()> { "" | "false" | "0" | "no" ); } + let filter_file = |name: &str| std::env::var_os(name).filter(|path| !path.is_empty()).map(PathBuf::from); + let (whitelist, blacklist) = (filter_file("SBG_WHITELIST"), filter_file("SBG_BLACKLIST")); + config.filter = metadata_filter::MetadataFilter::from_files(whitelist.as_deref(), blacklist.as_deref()) + .context("reading the metadata filter")?; if let Ok(app_sources) = std::env::var("SBG_APP_CS_SOURCES_DIR") { let parsed = parse_source_dirs_from_env(app_sources.as_str()); if !parsed.is_empty() { @@ -369,50 +378,82 @@ fn remove_generated_proxies(project_dir: &Path) { } } -/// Root namespaces of types a JS extension can derive from without WinRT metadata sbg can read -/// (the Windows App SDK's `Microsoft.UI.*` lives in a framework package, not system metadata). -const EXTENDABLE_ROOTS: &[&str] = &["Windows", "Microsoft", "System", "NativeScript"]; - -fn is_identifier(segment: &str) -> bool { - let mut chars = segment.chars(); - matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - /// Whether an auto-captured extension's base can be a real WinRT/.NET type. fn is_extendable_base(base: &str) -> bool { - let generic_root = base.split('`').next().unwrap_or(base); - let segments: Vec<&str> = generic_root.split('.').collect(); - if segments.len() < 2 || !segments.iter().all(|s| is_identifier(s)) { - return false; - } - EXTENDABLE_ROOTS.contains(&segments[0]) || signature_resolver::is_known_type(base) + metadata_filter::plausible_base(base) + || (metadata_filter::is_dotted_identifier(base) && signature_resolver::is_known_type(base)) } /// The bundle scan (dotnet-tool) is syntactic: any class extending a dotted name looks like an /// extension, including ordinary classes of bundled libraries (minified `e.Foo`, `Ua.$`). A proxy /// for such a "base" cannot compile, so auto-captured ones are dropped; explicitly named ones /// (`@CSharpProxy`) are kept as asked. -fn retain_extendable(extensions: Vec) -> Vec { - let (kept, dropped): (Vec<_>, Vec<_>) = extensions.into_iter().partition(|ext| { - let base = ext.base_class.as_deref().map(str::trim).unwrap_or(""); - !ext.is_auto_generated_name || base.is_empty() || base == "object" || base == "Object" || is_extendable_base(base) +fn retain_extendable( + extensions: Vec, + filter: &metadata_filter::MetadataFilter, +) -> Vec { + let base_of = |ext: &metadata_reader::ExtensionMetadata| ext.base_class.as_deref().map(str::trim).unwrap_or("").to_owned(); + let (kept, not_winrt): (Vec<_>, Vec<_>) = extensions.into_iter().partition(|ext| { + let base = base_of(ext); + !ext.is_auto_generated_name || base.is_empty() || base == "object" || base == "Object" || is_extendable_base(&base) }); - if !dropped.is_empty() { - let names: Vec<&str> = dropped.iter().filter_map(|e| e.base_class.as_deref()).take(8).collect(); - println!( - "[SBG] Skipped {} captured extension(s) whose base is not a WinRT type ({}{})", - dropped.len(), - names.join(", "), - if dropped.len() > names.len() { ", ..." } else { "" } - ); - } + report_skipped(¬_winrt, "whose base is not a WinRT type"); + let (kept, filtered): (Vec<_>, Vec<_>) = kept.into_iter().partition(|ext| { + let base = base_of(ext); + base.is_empty() || base == "object" || base == "Object" || filter.allows(&base) + }); + report_skipped(&filtered, "whose base the app's metadata filter leaves out"); kept } +fn report_skipped(skipped: &[metadata_reader::ExtensionMetadata], why: &str) { + if skipped.is_empty() { + return; + } + let names: Vec<&str> = skipped.iter().filter_map(|e| e.base_class.as_deref()).take(8).collect(); + println!( + "[SBG] Skipped {} captured extension(s) {why} ({}{})", + skipped.len(), + names.join(", "), + if skipped.len() > names.len() { ", ..." } else { "" } + ); +} + #[cfg(test)] mod tests { - use super::is_extendable_base; + use super::{is_extendable_base, retain_extendable}; + use crate::metadata_reader::ExtensionMetadata; + use metadata_filter::{MetadataFilter, PatternList}; + + fn extension(base: &str, auto: bool) -> ExtensionMetadata { + ExtensionMetadata { + type_name: None, + class_name: format!("Ext_{base}"), + namespace: None, + base_class: Some(base.to_owned()), + methods: Vec::new(), + properties: Vec::new(), + interfaces: Vec::new(), + is_auto_generated_name: auto, + } + } + + #[test] + fn the_apps_filter_drops_extensions_of_bases_it_leaves_out() { + let filter = MetadataFilter::new(None, PatternList::parse("Microsoft.UI.Xaml.Controls:ListView")); + let kept = retain_extendable( + vec![ + extension("Microsoft.UI.Xaml.Controls.Button", true), + extension("Microsoft.UI.Xaml.Controls.ListView", true), + // Named (@CSharpProxy) but of a base JS can't reach. + extension("Microsoft.UI.Xaml.Controls.ListView", false), + extension("Phaser.Utils", true), + ], + &filter, + ); + let bases: Vec<_> = kept.iter().filter_map(|e| e.base_class.as_deref()).collect(); + assert_eq!(bases, ["Microsoft.UI.Xaml.Controls.Button"]); + } #[test] fn winrt_bases_are_extendable() { diff --git a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj index 02bf895..b33c6e5 100644 --- a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj +++ b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj @@ -101,6 +101,17 @@ PreserveNewest + + + whitelist.mdg + PreserveNewest + + + blacklist.mdg + PreserveNewest + + PreserveNewest @@ -175,6 +186,12 @@ SkipUnchangedFiles="true" /> + + + + + + @@ -193,11 +210,11 @@ Search order: pre-built tools/ binary, then cargo run for dev. --> diff --git a/tools/dotnet-tool/Cargo.toml b/tools/dotnet-tool/Cargo.toml index 936b7c5..ec1cec4 100644 --- a/tools/dotnet-tool/Cargo.toml +++ b/tools/dotnet-tool/Cargo.toml @@ -9,6 +9,7 @@ glob = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" +metadata-filter = { path = "../../metadata-filter" } oxc_allocator = "0.133" oxc_parser = "0.133" oxc_ast = "0.133" diff --git a/tools/dotnet-tool/src/main.rs b/tools/dotnet-tool/src/main.rs index 25d366d..4fffd97 100644 --- a/tools/dotnet-tool/src/main.rs +++ b/tools/dotnet-tool/src/main.rs @@ -4,7 +4,9 @@ use glob::glob; use serde::Serialize; use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +use metadata_filter::MetadataFilter; use oxc_allocator::Allocator; use oxc_ast::ast::*; @@ -33,6 +35,15 @@ struct Args { #[arg(long)] sbg_output: Option, + + /// The app's metadata filter (`whitelist.mdg`), by default next to the app root's project + /// directory, where the CLI writes it. + #[arg(long)] + whitelist: Option, + + /// The app's metadata filter (`blacklist.mdg`), found like `--whitelist`. + #[arg(long)] + blacklist: Option, } #[derive(Serialize)] @@ -67,12 +78,45 @@ struct DetectedExtension { interfaces: Vec, } +/// A dotted name a JS class could extend a WinRT type through. Syntactic, so a bundled library's +/// classes (`u.MaterialDefines`) pass too; sbg, with WinRT metadata, has the last word. Minified +/// names that can't be types at all (`Ua.$`) don't. fn is_winrt_type(s: &str) -> bool { - s.contains('.') + metadata_filter::is_dotted_identifier(s) && s.split('.') .any(|seg| seg.starts_with(|c: char| c.is_uppercase())) } +/// Drops extensions of a base the app's filter leaves out (JS can't reach it at run time), warning +/// about the ones that were surely meant, and says whether any left is surely real: named +/// (`@CSharpProxy`, `@NativeClass`, `.extend("Name", …)`) or of a base under a root JS extends +/// native types from. Only then does the build need the .NET bridge for them. +fn select_extensions(extensions: Vec, filter: &MetadataFilter) -> (Vec, bool) { + let surely_real = |ext: &DetectedExtension| ext.proxy_name.is_some() || metadata_filter::plausible_base(&ext.base_type); + let (kept, filtered): (Vec<_>, Vec<_>) = extensions.into_iter().partition(|ext| filter.allows(&ext.base_type)); + for ext in filtered.iter().filter(|ext| surely_real(ext)) { + eprintln!( + "[dotnet-tool] Skipped an extension of {}: {}", + ext.base_type, + filter.check(&ext.base_type) + ); + } + let any_real = kept.iter().any(surely_real); + (kept, any_real) +} + +/// `path`, or `name` in the directory above the app root's (`platforms/windows`), if there. +fn filter_file(path: Option<&str>, app_root: &Path, name: &str) -> Option { + match path { + Some(path) => Some(PathBuf::from(path)), + None => app_root + .canonicalize() + .ok() + .and_then(|root| root.parent().map(|parent| parent.join(name))) + .filter(|path| path.is_file()), + } +} + fn build_sbg_entries(extensions: &[DetectedExtension]) -> Vec { let mut counter: u32 = 0; let mut seen: HashMap = HashMap::new(); @@ -154,6 +198,14 @@ fn main() -> Result<()> { } } + let app_root = PathBuf::from(&args.app_root); + let filter = MetadataFilter::from_files( + filter_file(args.whitelist.as_deref(), &app_root, metadata_filter::WHITELIST_FILE).as_deref(), + filter_file(args.blacklist.as_deref(), &app_root, metadata_filter::BLACKLIST_FILE).as_deref(), + ) + .context("reading the metadata filter")?; + let (all_extensions, any_real_extension) = select_extensions(all_extensions, &filter); + let sbg_output_dir = args .sbg_output .as_deref() @@ -173,11 +225,13 @@ fn main() -> Result<()> { out_path.display() ); } else { + // One from an earlier build would have sbg generate proxies for extensions now gone. + let _ = fs::remove_file(dir.join("sbg_metadata.json")); eprintln!("[dotnet-tool] No WinRT extensions found; sbg_metadata.json not written"); } } - if has_dotnet || !all_extensions.is_empty() || args.force { + if has_dotnet || any_real_extension || args.force { if let Err(e) = publish_and_copy_bridge(PathBuf::from(&args.app_root)) { eprintln!("Warning: failed to publish/copy dotnet-bridge: {}", e); } else { @@ -696,3 +750,51 @@ fn find_bridge_dir(start: &PathBuf) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + use metadata_filter::PatternList; + + // What bundles hold: a minified library class, a library class under a capitalised name, an + // app class extending a WinUI control, and a named proxy. + const BUNDLE: &str = r#" + var t = (function (e) { return e; })(Ua.$); + var n = (function (e) { return e; })(Phaser.Utils); + var MyButton = (function (_super) { return _super; })(Microsoft.UI.Xaml.Controls.Button); + var Engine = Sa.ThinEngine.extend("NativeScript.Gen.Engine", {}); + "#; + + fn scan(filter: &MetadataFilter) -> (Vec, bool) { + let mut found = Vec::new(); + scan_file_ast(BUNDLE, &mut found); + let (kept, any_real) = select_extensions(found, filter); + let mut bases: Vec<_> = kept.into_iter().map(|ext| ext.base_type).collect(); + bases.sort(); + (bases, any_real) + } + + #[test] + fn minified_names_are_not_extensions() { + let (bases, any_real) = scan(&MetadataFilter::default()); + assert_eq!(bases, ["Microsoft.UI.Xaml.Controls.Button", "Phaser.Utils", "Sa.ThinEngine"]); + assert!(any_real); + } + + #[test] + fn library_classes_alone_need_no_bridge() { + let mut found = Vec::new(); + scan_file_ast("var n = (function (e) { return e; })(Phaser.Utils);", &mut found); + let (kept, any_real) = select_extensions(found, &MetadataFilter::default()); + assert_eq!(kept.len(), 1); + assert!(!any_real); + } + + #[test] + fn the_apps_filter_drops_bases_it_leaves_out() { + let filter = MetadataFilter::new(Some(PatternList::parse("Microsoft.UI.Xaml.Controls:Button")), PatternList::default()); + let (bases, any_real) = scan(&filter); + assert_eq!(bases, ["Microsoft.UI.Xaml.Controls.Button"]); + assert!(any_real); + } +}