diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f63ea5f14..9a7119717 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,5 +56,6 @@ jobs: IOS_TEST_TIMEOUT_MS: "600000" IOS_TEST_INACTIVITY_TIMEOUT_MS: "180000" IOS_LOG_JUNIT: "1" + IOS_TEST_VERBOSE_SPECS: "1" IOS_SIMCTL_QUERY_TIMEOUT_MS: "10000" run: npm run test:ios diff --git a/NativeScript/CMakeLists.txt b/NativeScript/CMakeLists.txt index b8748ce41..7515ffe29 100644 --- a/NativeScript/CMakeLists.txt +++ b/NativeScript/CMakeLists.txt @@ -323,6 +323,13 @@ if(ENABLE_JS_RUNTIME) runtime/apple/modules/web/Web.mm runtime/apple/NativeScript.mm runtime/apple/RuntimeConfig.cpp + # resolveMainPath() (cli/BundleLoader.h) is called directly from + # NativeScript.mm's runMainApplication, so it must be compiled into every + # framework/app target that includes NativeScript.mm -- not only the + # BUILD_CLI_BINARY executable. cli/main.cpp and cli/segappend.cpp stay + # CLI-binary-only below (main.cpp defines main(), which cannot also be + # linked into the NativeScript shared library). + cli/BundleLoader.mm runtime/modules/url/ada/ada.cpp runtime/modules/url/URL.cpp runtime/modules/url/URLSearchParams.cpp @@ -449,7 +456,6 @@ if(BUILD_CLI_BINARY) set(SOURCE_FILES ${SOURCE_FILES} cli/main.cpp cli/segappend.cpp - cli/BundleLoader.mm ) endif() diff --git a/NativeScript/cli/BundleLoader.mm b/NativeScript/cli/BundleLoader.mm index 6fc249579..14be4d875 100644 --- a/NativeScript/cli/BundleLoader.mm +++ b/NativeScript/cli/BundleLoader.mm @@ -1,50 +1,148 @@ #include "BundleLoader.h" #include +#include +#include // Check if Resources/app/ exists, then load package.json["main"] || app/index.js full file path -std::string resolveMainPath() { +static NSString* resourcesPathForExecutable(NSString* executablePath) { + if (executablePath == nil || [executablePath length] == 0) { + return nil; + } + + NSString* standardizedPath = [executablePath stringByStandardizingPath]; + NSString* macOSPath = [standardizedPath stringByDeletingLastPathComponent]; + NSString* contentsPath = [macOSPath stringByDeletingLastPathComponent]; + if ([[macOSPath lastPathComponent] isEqualToString:@"MacOS"] && + [[contentsPath lastPathComponent] isEqualToString:@"Contents"]) { + return [contentsPath stringByAppendingPathComponent:@"Resources"]; + } + + return nil; +} + +static bool shouldLogBundleResolution() { + return getenv("NS_BUNDLE_LOADER_DEBUG") != nullptr; +} + +static void addCandidatePath(NSMutableArray* candidates, NSString* path) { + if (path == nil || [path length] == 0) { + return; + } + + NSString* standardizedPath = [path stringByStandardizingPath]; + if (![candidates containsObject:standardizedPath]) { + [candidates addObject:standardizedPath]; + } +} + +static std::string resolveMainPathInResources(NSString* resourcesPath) { NSFileManager* fileManager = [NSFileManager defaultManager]; - NSString* resourcesPath = [[NSBundle mainBundle] resourcePath]; NSString* appPath = [resourcesPath stringByAppendingPathComponent:@"app"]; BOOL isDir; if ([fileManager fileExistsAtPath:appPath isDirectory:&isDir] && isDir) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader checking app path: %@", appPath); + } NSString* packageJsonPath = [appPath stringByAppendingPathComponent:@"package.json"]; if ([fileManager fileExistsAtPath:packageJsonPath]) { NSData* jsonData = [NSData dataWithContentsOfFile:packageJsonPath]; - NSError* error; + NSError* error = nil; NSDictionary* packageDict = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (error == nil) { NSString* mainEntry = packageDict[@"main"]; + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader package main: %@ from %@", mainEntry, packageJsonPath); + } if (mainEntry != nil) { NSString* mainPath = [appPath stringByAppendingPathComponent:mainEntry]; if ([fileManager fileExistsAtPath:mainPath]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved main: %@", mainPath); + } return std::string([mainPath UTF8String]); } if ([[mainEntry pathExtension] length] == 0) { NSString* mainPathMjs = [mainPath stringByAppendingPathExtension:@"mjs"]; if ([fileManager fileExistsAtPath:mainPathMjs]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved main: %@", mainPathMjs); + } return std::string([mainPathMjs UTF8String]); } NSString* mainPathJs = [mainPath stringByAppendingPathExtension:@"js"]; if ([fileManager fileExistsAtPath:mainPathJs]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved main: %@", mainPathJs); + } return std::string([mainPathJs UTF8String]); } } } + } else if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader failed to parse %@: %@", packageJsonPath, error); } } // Fallback to app/index.js NSString* indexPath = [appPath stringByAppendingPathComponent:@"index.js"]; if ([fileManager fileExistsAtPath:indexPath]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved fallback main: %@", indexPath); + } return std::string([indexPath UTF8String]); } + } else if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader skipped resources path: %@ appPath=%@ exists=%d isDir=%d", + resourcesPath, + appPath, + [fileManager fileExistsAtPath:appPath], + isDir); + } + + return ""; +} + +std::string resolveMainPath() { + NSMutableArray* candidates = [NSMutableArray array]; + addCandidatePath(candidates, [[NSBundle mainBundle] resourcePath]); + addCandidatePath(candidates, resourcesPathForExecutable([[NSBundle mainBundle] executablePath])); + + NSArray* arguments = [[NSProcessInfo processInfo] arguments]; + if ([arguments count] > 0) { + addCandidatePath(candidates, resourcesPathForExecutable([arguments objectAtIndex:0])); + } + + uint32_t executablePathLength = 0; + _NSGetExecutablePath(nullptr, &executablePathLength); + if (executablePathLength > 0) { + char* executablePathBuffer = static_cast(malloc(executablePathLength)); + if (executablePathBuffer != nullptr) { + if (_NSGetExecutablePath(executablePathBuffer, &executablePathLength) == 0) { + addCandidatePath(candidates, resourcesPathForExecutable([NSString stringWithUTF8String:executablePathBuffer])); + } + free(executablePathBuffer); + } + } + + NSString* currentDirectory = [[NSFileManager defaultManager] currentDirectoryPath]; + addCandidatePath(candidates, currentDirectory); + addCandidatePath(candidates, [currentDirectory stringByAppendingPathComponent:@"Resources"]); + addCandidatePath(candidates, [[currentDirectory stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"Resources"]); + + for (NSString* resourcesPath in candidates) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader candidate resources: %@", resourcesPath); + } + std::string mainPath = resolveMainPathInResources(resourcesPath); + if (!mainPath.empty()) { + return mainPath; + } } return ""; diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm index 091988a88..2e25cfdb3 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm +++ b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm @@ -145,7 +145,26 @@ NativeApiSelectorGroupState state( } if (state.boundReceiverState != nullptr) { receiverHostObject = state.boundReceiver.lock(); - } else if (thisValue.isObject()) { + if (receiverHostObject) { + return receiverHostObject; + } + // The bound receiver's wrapper has already been torn down (its + // owning JS proxy was collected) since this selector-group + // function was minted and cached as a native-object expando + // (Object.mm's `bridge_->setObjectExpando(..., methodFunction)`). + // The expando itself is keyed by the native pointer and survives + // wrapper churn, so a LATER crossing that re-wraps the SAME + // native object in a fresh `NativeApiObjectHostObject` (this + // runtime mints a new wrapper per crossing) finds the stale + // cached function still bound to the dead original -- every call + // through it then resolves a nil receiver and throws "Objective-C + // selector requires a native receiver" even though the method is + // being invoked on a perfectly live object right now. Fall + // through to `thisValue` exactly like the unbound path below: + // this IS a method call (`receiver.method(...)`), so `thisValue` + // is always the correct, live receiver for this invocation. + } + if (thisValue.isObject()) { Object receiverObject = thisValue.asObject(runtime); if (receiverObject.isHostObject( runtime)) { @@ -173,16 +192,21 @@ NativeApiSelectorGroupState state( // GSD fast path: read jsi args directly, call objc_msgSend with a // typed cast, produce the jsi return value — bypassing all generic // marshalling. Only engages for plain calls (no super dispatch, init - // disown handling, or implicit NSError-out argument). + // disown handling, implicit NSError-out argument, or appearance + // static selector — those need the generic path's proxy tagging). if (call.prepared->gsdEngineCallable && call.dispatchClass == Nil && count == call.prepared->gsdEngineArgumentCount && - !(!state.receiverIsClass && call.prepared->isInitMethod)) { + !(!state.receiverIsClass && call.prepared->isInitMethod) && + call.gsdAllowed) { auto invoker = reinterpret_cast(call.prepared->engineInvoker); GsdObjCContext ctx{runtime, state.bridge, call.receiver, call.prepared->selector, args, call.prepared->signature.returnType}; if (invoker(ctx)) { + cachePreparedAppearanceProxySetterValue( + runtime, state.bridge, call.receiver, *call.prepared, args, + count); return std::move(ctx.result); } } @@ -199,8 +223,15 @@ NativeApiSelectorGroupState state( throw JSError(runtime, "Objective-C selector requires a native receiver."); } - return receiverHostObject->callPreparedObjectSelector( + Value result = receiverHostObject->callPreparedObjectSelector( runtime, *call.prepared, args, count, call.dispatchClass); + if (!state.receiverIsClass && call.prepared->isInitMethod) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, state.bridge, call.receiver, result, thisValue)) { + return std::move(*preserved); + } + } + return result; }); } diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h b/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h index fe315d609..66fb752cc 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h +++ b/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h @@ -62,7 +62,11 @@ inline NativeApiJsiConfig MakeReactNativeNativeApiJsiConfig( config.metadataPath = metadataPath; config.metadataPtr = metadataPtr; config.globalName = globalName; - config.installGlobalSymbols = true; + // RN launch cost: don't eagerly install the aggregate global surface or + // realize every class/protocol runtime pointer at symbol-index time. + config.installGlobalSymbols = false; + config.indexRuntimePointers = false; + config.invokeCallbacksOnNativeCallerThread = true; config.scheduler = std::make_shared( std::move(jsInvoker), std::move(uiInvoker)); return config; diff --git a/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm b/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm index fde28cf11..bdc82ea53 100644 --- a/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm +++ b/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm @@ -67,14 +67,21 @@ throw JSError( // GSD fast path: the generated invoker reads args directly from the JSC // arguments, calls objc_msgSend with a typed cast, and produces the JS - // return value — bypassing all generic marshalling. + // return value — bypassing all generic marshalling. Excludes appearance + // static selectors — those need the generic path's proxy tagging. if (prepared.gsdEngineCallable && dispatchSuperClass == Nil && providedCount == prepared.gsdEngineArgumentCount && - !initializerClassWrapper && !isNSErrorOutMethod) { + !initializerClassWrapper && !isNSErrorOutMethod && + !isPreparedStaticAppearanceSelector(prepared)) { auto invoker = reinterpret_cast(prepared.engineInvoker); GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector, runtime.context(), arguments, signature.returnType}; if (invoker(ctx)) { + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } return ctx.result; } } @@ -89,6 +96,11 @@ throw JSError( if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, fastArgs, providedCount, Nil, &fastResult)) { + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, fastArgs, + providedCount); + fastResult = tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); return fastResult.local(runtime); } } @@ -159,6 +171,11 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } if (initializerClassWrapper) { id resultObject = nil; if (isObjectiveCObjectType(returnType)) { @@ -173,6 +190,8 @@ throw JSError( Value(runtime, *initializerClassWrapper)); } } + tagPreparedStaticAppearanceNativeReturn( + runtime, bridge, receiver, prepared, returnType, returnStorage.data()); return setJSCEngineReturnValue(runtime, bridge, returnType, returnStorage.data(), prepared.selectorName); } @@ -227,10 +246,19 @@ JSValueRef NativeApiSelectorGroupCall( if (call.hasImmediateResult) { return call.immediateResult.local(runtime); } - return setJSCEnginePreparedObjCResult( + JSValueRef result = setJSCEnginePreparedObjCResult( runtime, data->bridge, call.receiver, *call.prepared, call.receiverHostObject, call.initializerClassWrapper, argumentCount, arguments, call.dispatchClass); + if (!data->receiverIsClass && call.prepared->isInitMethod && + thisObject != nullptr) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, data->bridge, call.receiver, Value::borrowed(runtime, result), + Value::borrowed(runtime, thisObject))) { + return preserved->local(runtime); + } + } + return result; } catch (const std::exception& error) { engine::jscengine::setException(context, exception, error); return JSValueMakeUndefined(context); diff --git a/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm b/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm index 570e8ae0e..d6a621966 100644 --- a/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm +++ b/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm @@ -68,14 +68,21 @@ throw JSError( // GSD fast path: the generated invoker reads args directly from the QuickJS // arguments, calls objc_msgSend with a typed cast, and produces the JS - // return value — bypassing all generic marshalling. + // return value — bypassing all generic marshalling. Excludes appearance + // static selectors — those need the generic path's proxy tagging. if (prepared.gsdEngineCallable && dispatchSuperClass == Nil && providedCount == prepared.gsdEngineArgumentCount && - !initializerClassWrapper && !isNSErrorOutMethod) { + !initializerClassWrapper && !isNSErrorOutMethod && + !isPreparedStaticAppearanceSelector(prepared)) { auto invoker = reinterpret_cast(prepared.engineInvoker); GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector, runtime.context(), arguments, signature.returnType}; if (invoker(ctx)) { + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } return ctx.result; } } @@ -90,6 +97,11 @@ throw JSError( if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, fastArgs, providedCount, Nil, &fastResult)) { + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, fastArgs, + providedCount); + fastResult = tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); return fastResult.local(runtime); } } @@ -161,6 +173,11 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } if (initializerClassWrapper) { id resultObject = nil; if (isObjectiveCObjectType(returnType)) { @@ -175,6 +192,8 @@ throw JSError( Value(runtime, *initializerClassWrapper)); } } + tagPreparedStaticAppearanceNativeReturn( + runtime, bridge, receiver, prepared, returnType, returnStorage.data()); return setQuickJSEngineReturnValue(runtime, bridge, returnType, returnStorage.data(), prepared.selectorName); @@ -239,10 +258,19 @@ JSValue NativeApiSelectorGroupCall(JSContext* context, JSValue thisValue, if (call.hasImmediateResult) { return call.immediateResult.local(runtime); } - return setQuickJSEnginePreparedObjCResult( + JSValue result = setQuickJSEnginePreparedObjCResult( runtime, data->bridge, call.receiver, *call.prepared, call.receiverHostObject, call.initializerClassWrapper, count, argv, call.dispatchClass); + if (!data->receiverIsClass && call.prepared->isInitMethod) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, data->bridge, call.receiver, Value::borrowed(runtime, result), + Value::borrowed(runtime, thisValue))) { + JS_FreeValue(context, result); + return preserved->local(runtime); + } + } + return result; } catch (const std::exception& error) { return engine::quickjsengine::throwError(context, error); } diff --git a/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h b/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h index a6c2044e8..859526f05 100644 --- a/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h +++ b/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h @@ -23,8 +23,10 @@ struct NativeApiBackendConfig { std::function)> runtimeCallbackInvoker = nullptr; std::function)> jsThreadCallbackInvoker = nullptr; std::function)> jsThreadAsyncCallbackInvoker = nullptr; + std::function callbackInvocationAllowed = nullptr; bool invokeCallbacksOnNativeCallerThread = false; bool installGlobalSymbols = false; + bool indexRuntimePointers = true; }; } // namespace nativescript diff --git a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm index 4e8fb6146..8a42d1ec2 100644 --- a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm +++ b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm @@ -44,6 +44,21 @@ explicit NativeApiRuntimeScope(Runtime&) {} Runtime, }; +// Per-method callback policy, attached to a JS function via +// NativeScriptRuntime.nativeMethodPolicy(fn, policy) (a `__nativeScriptMethodPolicy` +// expando read back in readEngineMethodCallbackPolicy below). Deliberately +// small: the only live consumers are (a) calling the ObjC super +// implementation before the JS override runs, and (b) suppressing a +// re-entrant callback while a native accessor/construction call is already +// in flight (see nativeAccessorCallbackPolicy and +// shouldSkipConstructingMethodCallback). +struct NativeApiMethodCallbackPolicy { + bool callSuperBeforeCallback = false; + // Associated-object keys checked on the callback's receiver; if any is + // truthy, the callback is skipped entirely (zero-returned). + std::vector skipCallbackIfAssociatedObjectTruthy; +}; + NativeApiCallbackThreadPolicy readEngineCallbackThreadPolicy( Runtime& runtime, Object& functionObject) { constexpr const char* propertyName = "__nativeScriptCallbackThread"; @@ -67,6 +82,91 @@ NativeApiCallbackThreadPolicy readEngineCallbackThreadPolicy( return NativeApiCallbackThreadPolicy::Default; } +Value optionalObjectProperty(Runtime& runtime, Object& object, + const char* name) { + if (name == nullptr || !object.hasProperty(runtime, name)) { + return Value::undefined(); + } + return object.getProperty(runtime, name); +} + +void appendEngineMethodPolicyAssociatedObjectKeys( + Runtime& runtime, const Value& value, std::vector& keys) { + if (value.isString()) { + keys.push_back(value.asString(runtime).utf8(runtime)); + return; + } + if (!value.isObject()) { + return; + } + Object object = value.asObject(runtime); + if (!object.isArray(runtime)) { + return; + } + Array array = object.getArray(runtime); + size_t size = array.size(runtime); + for (size_t i = 0; i < size; i++) { + Value item = array.getValueAtIndex(runtime, i); + if (item.isString()) { + keys.push_back(item.asString(runtime).utf8(runtime)); + } + } +} + +NativeApiMethodCallbackPolicy readEngineMethodCallbackPolicyValue( + Runtime& runtime, const Value& policyValue) { + NativeApiMethodCallbackPolicy policy; + try { + if (!policyValue.isObject()) { + return policy; + } + + Object policyObject = policyValue.asObject(runtime); + Value callSuperBeforeValue = optionalObjectProperty( + runtime, policyObject, "callSuperBeforeCallback"); + if (callSuperBeforeValue.isBool() && callSuperBeforeValue.getBool()) { + policy.callSuperBeforeCallback = true; + } else { + Value callSuperValue = + optionalObjectProperty(runtime, policyObject, "callSuper"); + if (callSuperValue.isString()) { + policy.callSuperBeforeCallback = + callSuperValue.asString(runtime).utf8(runtime) == "before"; + } + } + + appendEngineMethodPolicyAssociatedObjectKeys( + runtime, + optionalObjectProperty( + runtime, policyObject, "skipCallbackIfAssociatedObjectTruthy"), + policy.skipCallbackIfAssociatedObjectTruthy); + } catch (const std::exception&) { + return NativeApiMethodCallbackPolicy{}; + } + return policy; +} + +// Reads the policy a JS function was tagged with via +// NativeScriptRuntime.nativeMethodPolicy(fn, policy). +NativeApiMethodCallbackPolicy readEngineMethodCallbackPolicy( + Runtime& runtime, Object& functionObject) { + constexpr const char* propertyName = "__nativeScriptMethodPolicy"; + try { + if (!functionObject.hasProperty(runtime, propertyName)) { + return NativeApiMethodCallbackPolicy{}; + } + return readEngineMethodCallbackPolicyValue( + runtime, functionObject.getProperty(runtime, propertyName)); + } catch (const std::exception&) { + return NativeApiMethodCallbackPolicy{}; + } +} + +bool isEmptyMethodCallbackPolicy(const NativeApiMethodCallbackPolicy& policy) { + return !policy.callSuperBeforeCallback && + policy.skipCallbackIfAssociatedObjectTruthy.empty(); +} + bool selectorEndsWithNSErrorParam(const std::string& selectorName) { constexpr const char* suffix = "error:"; size_t suffixLength = std::strlen(suffix); @@ -392,7 +492,9 @@ Function persistentEngineFunction(Runtime& runtime, const Function& function) { NativeApiCallbackThreadPolicy threadPolicy = NativeApiCallbackThreadPolicy::Default, bool bindThis = false, - uintptr_t roundTripValidationKey = 0) + uintptr_t roundTripValidationKey = 0, + NativeApiMethodCallbackPolicy methodPolicy = {}, + Class methodBaseClass = Nil) : runtimeOwner_(retainNativeApiRuntime(runtime)), runtime_(runtimeOwner_.get()), bridge_(std::move(bridge)), @@ -402,7 +504,9 @@ Function persistentEngineFunction(Runtime& runtime, const Function& function) { block_(block), threadPolicy_(threadPolicy), bindThis_(bindThis), - roundTripValidationKey_(roundTripValidationKey) { + roundTripValidationKey_(roundTripValidationKey), + methodPolicy_(std::move(methodPolicy)), + methodBaseClass_(methodBaseClass) { closure_ = static_cast( ffi_closure_alloc(sizeof(ffi_closure), &executable_)); if (closure_ == nullptr || executable_ == nullptr || @@ -581,6 +685,30 @@ void invoke(void* ret, void* args[]) { throwNativeApiCallbackException("Invalid callback."); } + // Teardown-safety gate: once the host has signaled that callbacks are no + // longer allowed (runtime shutting down/reloading), bail with a + // zeroed return instead of running JS against a dying runtime. + bool callbackAllowed = false; + if (bridge_ != nullptr) { + @try { + callbackAllowed = bridge_->callbackInvocationAllowed(); + } @catch (...) { + callbackAllowed = false; + } + } + + if (!callbackAllowed) { + zeroReturnValue(ret); + return; + } + + if (methodPolicy_.callSuperBeforeCallback) { + invokeMethodSuper(ret, args); + } + if (shouldSkipMethodCallback(args, ret)) { + return; + } + std::string error; auto call = [&]() { invokeOnCurrentThread(ret, args, &error); }; const auto& nativeCallbackInvoker = bridge_->nativeCallbackInvoker(); @@ -735,6 +863,158 @@ void invoke(void* ret, void* args[]) { } private: + // The ObjC class whose implementation `callSuperBeforeCallback`/ + // invokeMethodSuper() should dispatch against. A JS-subclass receiver + // (ClassBuilder instance) dispatches to its own runtime superclass; + // anything else falls back to methodBaseClass_ (the class the override was + // registered against). + Class dispatchSuperclassForMethodReceiver(id receiver) const { + if (receiver == nil) { + return Nil; + } + + Class receiverClass = object_getClass(receiver); + if (receiverClass != Nil && + class_conformsToProtocol(receiverClass, + @protocol(NativeApiClassBuilderProtocol))) { + Class superclass = class_getSuperclass(receiverClass); + if (superclass != Nil) { + return superclass; + } + } + + return methodBaseClass_; + } + + // Method callback policies only ever target the callback's receiver (no + // argument-index targeting), so this is just the bound `self`. + id methodCallbackReceiver(void* args[]) const { + if (!bindThis_ || args == nullptr) { + return nil; + } + return *static_cast(args[0]); + } + + id associatedObjectValue(id receiver, const std::string& key) const { + if (receiver == nil || key.empty()) { + return nil; + } + return objc_getAssociatedObject(receiver, sel_registerName(key.c_str())); + } + + bool associatedObjectIsTruthy(id receiver, const std::string& key) const { + id value = associatedObjectValue(receiver, key); + if (value == nil) { + return false; + } + + if ([value respondsToSelector:@selector(boolValue)]) { + return [value boolValue] == YES; + } + if ([value isKindOfClass:[NSString class]]) { + NSString* stringValue = (NSString*)value; + if (stringValue.length == 0) { + return false; + } + NSString* lowercase = [stringValue lowercaseString]; + return ![lowercase isEqualToString:@"0"] && + ![lowercase isEqualToString:@"false"] && + ![lowercase isEqualToString:@"no"]; + } + + return true; + } + + // Skips a re-entrant callback: an alloc/init construction already in + // flight (marked via __nativeApiConstructionState) must not have its + // non-init methods re-entered by a partially-constructed self. + bool shouldSkipConstructingMethodCallback(void* args[], void* ret) { + if (!bindThis_ || args == nullptr || signature_ == nullptr || + signature_->selectorName.rfind("init", 0) == 0) { + return false; + } + + id receiver = *static_cast(args[0]); + if (receiver == nil || + objc_getAssociatedObject( + receiver, sel_registerName("__nativeApiConstructionState")) == nil) { + return false; + } + + zeroReturnValue(ret); + return true; + } + + bool shouldSkipMethodCallback(void* args[], void* ret) { + if (args == nullptr) { + return false; + } + + if (shouldSkipConstructingMethodCallback(args, ret)) { + return true; + } + + id receiver = methodCallbackReceiver(args); + for (const auto& key : + methodPolicy_.skipCallbackIfAssociatedObjectTruthy) { + if (associatedObjectIsTruthy(receiver, key)) { + zeroReturnValue(ret); + return true; + } + } + + return false; + } + + // callSuperBeforeCallback: run the ObjC super implementation before the JS + // override does, via objc_msgSendSuper with the same arguments the JS + // callback is about to receive. + void invokeMethodSuper(void* ret, void* args[]) const { + if (!bindThis_ || args == nullptr || signature_ == nullptr || + methodBaseClass_ == Nil) { + return; + } + + id receiver = *static_cast(args[0]); + Class dispatchClass = dispatchSuperclassForMethodReceiver(receiver); + if (receiver == nil || dispatchClass == Nil) { + return; + } + + struct objc_super superReceiver = {receiver, dispatchClass}; + struct objc_super* superReceiverPtr = &superReceiver; + size_t nativeArgc = + signature_->implicitArgumentCount + signature_->argumentTypes.size(); + std::vector values(nativeArgc); + values[0] = &superReceiverPtr; + values[1] = args[1]; + for (size_t i = 2; i < nativeArgc; i++) { + values[i] = args[i]; + } + + std::vector returnStorage; + void* returnTarget = ret; + if (returnTarget == nullptr) { + returnStorage.resize( + std::max(nativeSizeForType(signature_->returnType), 1)); + returnTarget = returnStorage.data(); + } + + performNativeInvocation(*runtime_, bridge_->nativeInvocationInvoker(), [&]() { +#if defined(__x86_64__) + bool isStret = signature_->returnType.ffiType->size > 16 && + signature_->returnType.ffiType->type == FFI_TYPE_STRUCT; + void (*target)(void) = isStret ? FFI_FN(objc_msgSendSuper_stret) + : FFI_FN(objc_msgSendSuper); + ffi_call(const_cast(&signature_->cif), target, returnTarget, + values.data()); +#else + ffi_call(const_cast(&signature_->cif), + FFI_FN(objc_msgSendSuper), returnTarget, values.data()); +#endif + }); + } + void invokeOnCurrentThread(void* ret, void* args[], std::string* error) { try { NativeApiRuntimeScope runtimeScope(*runtime_); @@ -750,8 +1030,21 @@ void invokeOnCurrentThread(void* ret, void* args[], std::string* error) { Value result = Value::undefined(); if (bindThis_ && nativeArgOffset >= 1) { id self = *static_cast(args[0]); + // `this.super`/`$base` from inside this override should dispatch + // against the class ABOVE the one the override was registered on, + // not the receiver's own (possibly further-subclassed) runtime class. + // `methodBaseClass_` (threaded from ClassBuilder's addEngineOverrideMethod + // as `baseClass`, i.e. `class_getSuperclass(nativeClass)`) IS already + // that class -- it must be used directly, not further superclassed + // (which would skip straight past it to ITS superclass and make any + // member declared exactly on methodBaseClass_, e.g. a method the + // override shadows that isn't itself inherited, unreachable via + // `this.super`). dispatchSuperclassForMethodReceiver() above already + // relies on this same "use methodBaseClass_ as-is" convention. + Class superDispatchClass = methodBaseClass_; Value thisValue = - makeNativeObjectValue(*runtime_, bridge_, self, false); + makeNativeObjectValue( + *runtime_, bridge_, self, false, superDispatchClass); Object thisObject = thisValue.isObject() ? thisValue.asObject(*runtime_) : Object(*runtime_); @@ -881,6 +1174,8 @@ void storeReturnValue(const Value& result, void* ret) { NativeApiCallbackThreadPolicy::Default; bool bindThis_ = false; uintptr_t roundTripValidationKey_ = 0; + NativeApiMethodCallbackPolicy methodPolicy_; + Class methodBaseClass_ = Nil; ffi_closure* closure_ = nullptr; void* executable_ = nullptr; std::string blockSignature_; @@ -2229,7 +2524,8 @@ throw JSError( std::shared_ptr createEngineMethodCallback( Runtime& runtime, const std::shared_ptr& bridge, const std::string& selectorName, MDSectionOffset signatureOffset, - Function function, bool returnOwned) { + Function function, bool returnOwned, Class methodBaseClass = Nil, + NativeApiMethodCallbackPolicy methodPolicy = {}) { if (bridge == nullptr || bridge->metadata() == nullptr || signatureOffset == MD_SECTION_OFFSET_NULL) { throw JSError( @@ -2247,9 +2543,15 @@ throw JSError( auto signature = std::make_shared(std::move(*parsed)); auto threadPolicy = readEngineCallbackThreadPolicy(runtime, function); + // A policy passed explicitly by the caller (e.g. the auto-installed + // accessor/construction re-entry guards) wins; otherwise fall back to + // whatever the JS function itself was tagged with via nativeMethodPolicy(). + if (isEmptyMethodCallbackPolicy(methodPolicy)) { + methodPolicy = readEngineMethodCallbackPolicy(runtime, function); + } auto callback = std::make_shared( runtime, bridge, std::move(signature), std::move(function), false, - threadPolicy, true); + threadPolicy, true, 0, std::move(methodPolicy), methodBaseClass); bridge->retainEngineLifetime(callback); return callback; } @@ -2257,7 +2559,8 @@ throw JSError( std::shared_ptr createEngineMethodCallback( Runtime& runtime, const std::shared_ptr& bridge, const std::string& selectorName, NativeApiSignature signature, - Function function) { + Function function, Class methodBaseClass = Nil, + NativeApiMethodCallbackPolicy methodPolicy = {}) { signature.selectorName = selectorName; prepareEngineMethodSignature(&signature); if (!signatureSupportedForEngineCallback(signature)) { @@ -2268,9 +2571,12 @@ throw JSError( auto sharedSignature = std::make_shared(std::move(signature)); auto threadPolicy = readEngineCallbackThreadPolicy(runtime, function); + if (isEmptyMethodCallbackPolicy(methodPolicy)) { + methodPolicy = readEngineMethodCallbackPolicy(runtime, function); + } auto callback = std::make_shared( runtime, bridge, std::move(sharedSignature), std::move(function), false, - threadPolicy, true); + threadPolicy, true, 0, std::move(methodPolicy), methodBaseClass); bridge->retainEngineLifetime(callback); return callback; } diff --git a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm index 761fe1680..170d025d0 100644 --- a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm +++ b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm @@ -66,6 +66,82 @@ void rememberNativeApiKnownExposedMethod( return method; } +// An ObjC init convention lets an initializer return an object other than +// `self` (most commonly `self` itself, but a class cluster/singleton +// initializer can return a completely different receiver). When it returns +// the SAME receiver we already wrapped for this call, the JS side should keep +// resolving to that one preserved wrapper rather than creating (and briefly +// GC'ing) a second, divergent one for the identical native object — so the +// stale/duplicate wrapper here is detached (its bridge state, e.g. expandos, +// handed to the preserved wrapper) rather than left to tear down the shared +// native receiver's bridge state on its own destruction. +std::optional preservedNativeApiInitializerSelfReturn( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const Value& result, const Value& receiverValue) { + if (bridge == nullptr || receiver == nil || !receiverValue.isObject()) { + return std::nullopt; + } + + id resultObject = + NativeApiObjectHostObject::nativeObjectFromValue(runtime, result); + if (resultObject != receiver) { + return std::nullopt; + } + + Object receiverObject = receiverValue.asObject(runtime); + if (!receiverObject.isHostObject(runtime)) { + return std::nullopt; + } + + auto receiverHostObject = + receiverObject.getHostObject(runtime); + if (receiverHostObject == nullptr || + receiverHostObject->object() != receiver) { + return std::nullopt; + } + + std::shared_ptr resultHostObject; + if (result.isObject()) { + Object resultObjectValue = result.asObject(runtime); + if (resultObjectValue.isHostObject(runtime)) { + resultHostObject = + resultObjectValue.getHostObject(runtime); + } + } + + if (resultHostObject != nullptr && resultHostObject != receiverHostObject) { + resultHostObject->detachObjectPreservingBridgeState(receiver); + } + + Value preserved(runtime, receiverValue); + bridge->rememberNativeObjectRoundTripValue(runtime, receiver, preserved); + return preserved; +} + +// $base/super dispatch wrapper for ClassBuilder subclasses: calls the ObjC +// super implementation, then — for initializers only — applies the +// preserved-self-return handling above. +Value callNativeApiBaseObjectSelector( + Runtime& runtime, const std::shared_ptr& bridge, + const Object& receiverObject, + const std::shared_ptr& receiverHostObject, + id receiver, const std::string& selectorName, + const NativeApiMember* member, const Value* args, size_t count, + Class dispatchClass) { + Value result = receiverHostObject->callObjectSelector( + runtime, selectorName, member, args, count, dispatchClass); + + if (selectorName.rfind("init", 0) != 0) { + return result; + } + + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, bridge, receiver, result, Value(runtime, receiverObject))) { + return std::move(*preserved); + } + return result; +} + std::optional findNativeApiClassBuilder(id object) { Class cls = object != nil ? object_getClass(object) : Nil; @@ -261,14 +337,16 @@ void addEngineOverrideMethod(Runtime& runtime, Class nativeClass, Class baseClass, const std::string& selectorName, MDSectionOffset signatureOffset, - bool returnOwned, Function function) { + bool returnOwned, Function function, + NativeApiMethodCallbackPolicy methodPolicy = {}) { if (selectorName.empty() || signatureOffset == MD_SECTION_OFFSET_NULL) { return; } auto callback = createEngineMethodCallback(runtime, bridge, selectorName, signatureOffset, std::move(function), - returnOwned); + returnOwned, baseClass, + std::move(methodPolicy)); SEL selector = sel_registerName(selectorName.c_str()); std::string metadataEncoding = objcMethodSignatureForEngineSignature(callback->signature()); @@ -284,6 +362,16 @@ Value getObjectPropertyOrUndefined(Runtime& runtime, const Object& object, : Value::undefined(); } +// Auto-applied to native accessor (getter/setter) overrides: suppresses +// re-entrancy while the accessor machinery is already dispatching through +// this same receiver (set/cleared around JS-subclass accessor invocation). +NativeApiMethodCallbackPolicy nativeAccessorCallbackPolicy( + NativeApiMethodCallbackPolicy policy = {}) { + policy.skipCallbackIfAssociatedObjectTruthy.push_back( + "__nativeApiAccessorCallbackState"); + return policy; +} + Class dispatchSuperclassForEngineDerivedReceiver(id receiver, Class defaultSuperclass) { if (receiver == nil) { @@ -444,12 +532,16 @@ throw JSError( void addEngineExposedMethod(Runtime& runtime, const std::shared_ptr& bridge, Class nativeClass, const std::string& selectorName, - NativeApiSignature signature, Function function) { + NativeApiSignature signature, Function function, + Class methodBaseClass = Nil, + NativeApiMethodCallbackPolicy methodPolicy = {}) { if (selectorName.empty()) { return; } auto callback = createEngineMethodCallback(runtime, bridge, selectorName, - std::move(signature), std::move(function)); + std::move(signature), std::move(function), + methodBaseClass, + std::move(methodPolicy)); std::string encoding = objcMethodSignatureForEngineSignature(callback->signature()); class_replaceMethod(nativeClass, sel_registerName(selectorName.c_str()), reinterpret_cast(callback->functionPointer()), @@ -633,7 +725,8 @@ throw JSError(runtime, addEngineExposedMethod(runtime, bridge, nativeClass, known->selectorName, std::move(known->signature), - value.asObject(runtime).asFunction(runtime)); + value.asObject(runtime).asFunction(runtime), + baseClass); } } } @@ -649,7 +742,8 @@ throw JSError(runtime, runtime, bridge, nativeClass, baseClass, propertyMember->selectorName, propertyMember->signatureOffset, (propertyMember->flags & metagen::mdMemberReturnOwned) != 0, - getter.asObject(runtime).asFunction(runtime)); + getter.asObject(runtime).asFunction(runtime), + nativeAccessorCallbackPolicy()); } else if (propertyMember == nullptr && getter.isObject() && getter.asObject(runtime).isFunction(runtime)) { auto overrides = methodOverridesForName(members, propertyName); @@ -661,7 +755,8 @@ throw JSError(runtime, runtime, bridge, nativeClass, baseClass, member.selectorName, member.signatureOffset, (member.flags & metagen::mdMemberReturnOwned) != 0, - getter.asObject(runtime).asFunction(runtime)); + getter.asObject(runtime).asFunction(runtime), + nativeAccessorCallbackPolicy()); } } @@ -672,7 +767,8 @@ throw JSError(runtime, addEngineOverrideMethod(runtime, bridge, nativeClass, baseClass, propertyMember->setterSelectorName, propertyMember->setterSignatureOffset, false, - setter.asObject(runtime).asFunction(runtime)); + setter.asObject(runtime).asFunction(runtime), + nativeAccessorCallbackPolicy()); } } @@ -705,7 +801,8 @@ throw JSError(runtime, if (signature) { rememberNativeApiKnownExposedMethod(selectorName, *signature); addEngineExposedMethod(runtime, bridge, nativeClass, selectorName, - std::move(*signature), std::move(*function)); + std::move(*signature), std::move(*function), + baseClass); } } } @@ -767,8 +864,9 @@ throw JSError( if (actualArgc == 0) { Class dispatchClass = dispatchSuperclassForEngineDerivedReceiver(receiver, baseClass); - return receiverHostObject->callObjectSelector( - runtime, propertyMember->selectorName, propertyMember, nullptr, 0, + return callNativeApiBaseObjectSelector( + runtime, bridge, receiverObject, receiverHostObject, receiver, + propertyMember->selectorName, propertyMember, nullptr, 0, dispatchClass); } if (actualArgc == 1 && !propertyMember->setterSelectorName.empty() && @@ -778,9 +876,10 @@ throw JSError( NativeApiMember setterMember = *propertyMember; setterMember.selectorName = propertyMember->setterSelectorName; setterMember.signatureOffset = propertyMember->setterSignatureOffset; - return receiverHostObject->callObjectSelector( - runtime, setterMember.selectorName, &setterMember, args + 3, - actualArgc, dispatchClass); + return callNativeApiBaseObjectSelector( + runtime, bridge, receiverObject, receiverHostObject, receiver, + setterMember.selectorName, &setterMember, args + 3, actualArgc, + dispatchClass); } } } @@ -791,7 +890,7 @@ throw JSError( Class dispatchClass = dispatchSuperclassForEngineDerivedReceiver(receiver, baseClass); - return receiverHostObject->callObjectSelector(runtime, member->selectorName, - member, args + 3, actualArgc, - dispatchClass); + return callNativeApiBaseObjectSelector( + runtime, bridge, receiverObject, receiverHostObject, receiver, + member->selectorName, member, args + 3, actualArgc, dispatchClass); } diff --git a/NativeScript/ffi/objc/shared/bridge/HostObject.mm b/NativeScript/ffi/objc/shared/bridge/HostObject.mm index e9efdafd9..eef3d364f 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObject.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObject.mm @@ -344,6 +344,66 @@ throw JSError(runtime, return Value::undefined(); }); } + // Re-entry guards for JS-subclass instance construction/accessors, + // backed by an associated object (not a JS expando — see the + // memo/expando-proxy notes: expandos never round-trip on these + // proxies). ClassBuilder wires these around alloc/init and native + // accessor dispatch. + if (property == "__setObjectConstructionState") { + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, "__setObjectConstructionState"), + 2, + [](Runtime& runtime, const Value&, const Value* args, + size_t count) -> Value { + if (count < 1) { + return Value::undefined(); + } + id object = NativeApiObjectHostObject::nativeObjectFromValue( + runtime, args[0]); + if (object == nil) { + return Value::undefined(); + } + bool constructing = + count >= 2 && args[1].isBool() && args[1].getBool(); + objc_setAssociatedObject( + object, sel_registerName("__nativeApiConstructionState"), + constructing ? @YES : nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return Value::undefined(); + }); + } + if (property == "__setObjectAccessorCallbackState") { + // Depth-counted (not a bool) so nested/re-entrant accessor calls on the + // same object (e.g. a getter that reads another property) still clear + // correctly on unwind. + return Function::createFromHostFunction( + runtime, PropNameID::forAscii( + runtime, "__setObjectAccessorCallbackState"), + 2, + [](Runtime& runtime, const Value&, const Value* args, + size_t count) -> Value { + if (count < 1) { + return Value::undefined(); + } + id object = NativeApiObjectHostObject::nativeObjectFromValue( + runtime, args[0]); + if (object == nil) { + return Value::undefined(); + } + bool active = count >= 2 && args[1].isBool() && args[1].getBool(); + SEL key = sel_registerName("__nativeApiAccessorCallbackState"); + NSNumber* current = (NSNumber*)objc_getAssociatedObject(object, key); + NSInteger depth = current != nil ? current.integerValue : 0; + if (active) { + depth += 1; + } else if (depth > 0) { + depth -= 1; + } + objc_setAssociatedObject( + object, key, depth > 0 ? @(depth) : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return Value::undefined(); + }); + } if (property == "CC_SHA256") { auto bridge = bridge_; return Function::createFromHostFunction( @@ -554,6 +614,7 @@ throw JSError(runtime, addPropertyName(runtime, names, "__makeSelectorGroupFunction"); addPropertyName(runtime, names, "__rememberClassWrapper"); addPropertyName(runtime, names, "__rememberObjectClassWrapper"); + addPropertyName(runtime, names, "__setObjectConstructionState"); addPropertyName(runtime, names, "getFunction"); addPropertyName(runtime, names, "getConstant"); addPropertyName(runtime, names, "getEnum"); diff --git a/NativeScript/ffi/objc/shared/bridge/HostObjects.mm b/NativeScript/ffi/objc/shared/bridge/HostObjects.mm index d3e4dbfca..c54053916 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObjects.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObjects.mm @@ -47,6 +47,8 @@ void setObject(id object) { #include "host_objects/Struct.mm" +#include "host_objects/Appearance.mm" + #include "host_objects/Object.mm" #include "host_objects/Class.mm" diff --git a/NativeScript/ffi/objc/shared/bridge/Install.mm b/NativeScript/ffi/objc/shared/bridge/Install.mm index 64cad6a7d..22121ef64 100644 --- a/NativeScript/ffi/objc/shared/bridge/Install.mm +++ b/NativeScript/ffi/objc/shared/bridge/Install.mm @@ -249,6 +249,177 @@ function findPrototypeDescriptor(className, property) { return undefined; } + function setObjectAccessorCallbackState(instance, active) { + try { + if (typeof api.__setObjectAccessorCallbackState === 'function') { + api.__setObjectAccessorCallbackState(instance, !!active); + } + } catch (_) { + } + } + + function nativeExtensionAccessorWithCallbackState(fn) { + if (typeof fn !== 'function') { + return fn; + } + return function() { + setObjectAccessorCallbackState(this, true); + try { + var args = Array.prototype.slice.call(arguments); + return fn.apply(this, args); + } finally { + setObjectAccessorCallbackState(this, false); + } + }; + } + + // Wraps extend()'s methods object with: (a) the re-entry guard above around + // any accessor (get/set) so a native accessor invocation calling back into + // itself through the ObjC runtime is suppressed rather than recursing, and + // (b) NSFastEnumeration-flavored indexed-collection aliases + // (objectAtIndexedSubscript / setObjectAtIndexedSubscript / Symbol.iterator) + // when the methods object looks like an Obj-C indexed collection + // (objectAtIndex + count), matching what a hand-written ObjC subclass gets + // for free from the runtime. + function nativeExtensionMethodsWithIndexedCollectionAliases(methods) { + if (methods == null || typeof methods !== 'object') { + return methods; + } + + var descriptors = Object.getOwnPropertyDescriptors(methods); + var descriptorKeys = + typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + ? Reflect.ownKeys(descriptors) + : Object.keys(descriptors); + var needsAccessorCallbackState = false; + for (var descriptorIndex = 0; descriptorIndex < descriptorKeys.length; descriptorIndex++) { + var descriptor = descriptors[descriptorKeys[descriptorIndex]]; + if (descriptor && + (typeof descriptor.get === 'function' || + typeof descriptor.set === 'function')) { + needsAccessorCallbackState = true; + break; + } + } + + var hasObjectAtIndex = + Object.prototype.hasOwnProperty.call(methods, 'objectAtIndex'); + var hasCount = + Object.prototype.hasOwnProperty.call(methods, 'count'); + var hasSymbolIterator = + typeof Symbol === 'function' && Symbol.iterator && + Object.prototype.hasOwnProperty.call(methods, Symbol.iterator); + var needsObjectAtIndexedSubscript = + hasObjectAtIndex && + !Object.prototype.hasOwnProperty.call(methods, 'objectAtIndexedSubscript'); + var needsSetObjectAtIndexedSubscript = + Object.prototype.hasOwnProperty.call(methods, 'replaceObjectAtIndexWithObject') && + !Object.prototype.hasOwnProperty.call(methods, 'setObjectAtIndexedSubscript'); + var needsIndexedCollectionIterator = + typeof Symbol === 'function' && Symbol.iterator && + hasObjectAtIndex && hasCount && !hasSymbolIterator; + + if (!needsObjectAtIndexedSubscript && + !needsSetObjectAtIndexedSubscript && + !needsIndexedCollectionIterator && + !needsAccessorCallbackState) { + return methods; + } + + if (needsAccessorCallbackState) { + for (var accessorIndex = 0; accessorIndex < descriptorKeys.length; accessorIndex++) { + var accessorKey = descriptorKeys[accessorIndex]; + var accessorDescriptor = descriptors[accessorKey]; + if (!accessorDescriptor) { + continue; + } + if (typeof accessorDescriptor.get === 'function') { + accessorDescriptor.get = + nativeExtensionAccessorWithCallbackState(accessorDescriptor.get); + } + if (typeof accessorDescriptor.set === 'function') { + accessorDescriptor.set = + nativeExtensionAccessorWithCallbackState(accessorDescriptor.set); + } + } + } + + var prepared = Object.create(Object.getPrototypeOf(methods)); + Object.defineProperties(prepared, descriptors); + + if (needsObjectAtIndexedSubscript) { + Object.defineProperty(prepared, 'objectAtIndexedSubscript', { + configurable: true, + enumerable: false, + writable: true, + value: function(index) { + return this.objectAtIndex(index); + } + }); + } + + if (needsSetObjectAtIndexedSubscript) { + Object.defineProperty(prepared, 'setObjectAtIndexedSubscript', { + configurable: true, + enumerable: false, + writable: true, + value: function(anObject, index) { + return this.replaceObjectAtIndexWithObject(index, anObject); + } + }); + } + + if (needsIndexedCollectionIterator) { + Object.defineProperty(prepared, Symbol.iterator, { + configurable: true, + enumerable: false, + writable: true, + value: function() { + var receiver = this; + var index = 0; + return { + next: function() { + var countValue = receiver.count; + var count = typeof countValue === 'function' + ? countValue.call(receiver) + : countValue; + if (!(index < count)) { + return { done: true }; + } + return { + value: receiver.objectAtIndex(index++), + done: false + }; + } + }; + } + }); + } + + return prepared; + } + + function nativeExtensionMethodsHaveIterator(methods) { + return typeof Symbol === 'function' && Symbol.iterator && + methods != null && typeof methods === 'object' && + Object.prototype.hasOwnProperty.call(methods, Symbol.iterator); + } + + function nativeExtensionOptionsWithIterator(options, methods) { + var extendOptions = options || {}; + if (!nativeExtensionMethodsHaveIterator(methods)) { + return extendOptions; + } + try { + return Object.assign({}, extendOptions, { + __hasIterator: true + }); + } catch (_) { + extendOptions.__hasIterator = true; + return extendOptions; + } + } + Object.defineProperty(globalThis, '__nativeScriptCreateNativeApiIterator', { configurable: false, enumerable: false, @@ -534,7 +705,31 @@ function unavailableInitializerError(error) { /Objective-C selector is not available/.test(String(error.message || error)); } - function constructNativeInstance(nativeClass, args, rememberInstance) { + // markConstructing is true for JS-subclass (ClassBuilder) instances: their + // alloc/init sequence marks the receiver as "under construction" so a + // non-init method callback landing on a partially-constructed self (e.g. + // from within an ObjC framework's own init machinery) is suppressed + // instead of re-entering JS with an object that isn't fully set up yet + // (see shouldSkipConstructingMethodCallback). + function shouldUseAllocInitConstructor(constructable, wrapper) { + var target = wrapper || constructable; + try { + return !!(target && target.__nativeApiUseAllocInitConstructor); + } catch (_) { + return false; + } + } + + function setObjectConstructionState(instance, constructing) { + try { + if (api && typeof api.__setObjectConstructionState === 'function') { + api.__setObjectConstructionState(instance, !!constructing); + } + } catch (_) { + } + } + + function constructNativeInstance(nativeClass, args, rememberInstance, markConstructing) { if (args.length === 1 && args[0] && typeof args[0] === 'object' && @@ -573,13 +768,16 @@ function constructNativeInstance(nativeClass, args, rememberInstance) { if (typeof rememberInstance === 'function') { instance = rememberInstance(instance); } - if (initializer.selectorName === 'init') { - if (typeof instance.init !== 'function') { - throw new Error('No initializer found that matches constructor invocation.'); - } - return instance.init(); + if (markConstructing) { + setObjectConstructionState(instance, true); } try { + if (initializer.selectorName === 'init') { + if (typeof instance.init !== 'function') { + throw new Error('No initializer found that matches constructor invocation.'); + } + return instance.init(); + } if (initializer.name && typeof instance[initializer.name] === 'function') { return instance[initializer.name](...actualArgs); } @@ -593,6 +791,73 @@ function constructNativeInstance(nativeClass, args, rememberInstance) { throw new Error('No initializer found that matches constructor invocation.'); } throw error; + } finally { + if (markConstructing) { + setObjectConstructionState(instance, false); + } + } + } + + function nativeClassForInstance(instance, classFallback, baseConstructor) { + var constructor = instance && instance.constructor; + if (constructor && constructor !== baseConstructor && + constructor !== classFallback) { + return constructor; + } + return classFallback || baseConstructor; + } + + // Gives extend()ed/TypeScript-native-subclass instances a `class`/ + // `superclass` identity that resolves to the ACTUAL (possibly further + // JS-subclassed) constructor rather than always reporting the class the + // extension was originally built against. + function installInstanceClassIdentity(target, classFallback, baseConstructor) { + if (!target || typeof Object.create !== 'function' || + typeof Object.setPrototypeOf !== 'function') { + return; + } + var parent = null; + try { + parent = Object.getPrototypeOf(target); + } catch (_) { + } + var identityPrototype = Object.create(parent || null); + try { + Object.defineProperty(identityPrototype, 'class', { + configurable: true, + enumerable: false, + writable: true, + value: function() { + return nativeClassForInstance(this, classFallback, baseConstructor); + } + }); + } catch (_) { + } + try { + Object.defineProperty(identityPrototype, 'superclass', { + configurable: true, + enumerable: false, + get: function() { + var constructor = nativeClassForInstance( + this, + classFallback, + baseConstructor + ); + if (!constructor) { + return undefined; + } + var superclass = constructor.superclass; + if (typeof superclass === 'function' && superclass.kind !== 'class') { + return superclass.call(constructor); + } + return superclass; + } + }); + } catch (_) { + } + try { + Object.setPrototypeOf(target, identityPrototype); + } catch (_) { } } @@ -634,8 +899,14 @@ function wrapNativeClass(nativeClass) { ); } } - if (args.length > 0) { - return rememberInstanceClass(constructNativeInstance(nativeClass, args, rememberInstanceClass)); + if (args.length > 0 || + shouldUseAllocInitConstructor(constructable, wrapper)) { + return rememberInstanceClass(constructNativeInstance( + nativeClass, + args, + rememberInstanceClass, + shouldUseAllocInitConstructor(constructable, wrapper) + )); } if (typeof nativeClass.new !== 'function') { throw new Error('Native class cannot be initialized'); @@ -675,29 +946,30 @@ function derivedClassWrapper(target) { if (methods == null || typeof methods !== 'object') { throw new Error('extend() first parameter must be an object'); } - var extendOptions = options || {}; - if (typeof Symbol === 'function' && - Object.prototype.hasOwnProperty.call(methods, Symbol.iterator)) { - try { - extendOptions = Object.assign({}, extendOptions, { - __hasIterator: true - }); - } catch (_) { - extendOptions.__hasIterator = true; - } - } - var extendedNativeClass = api.__extendClass(nativeClass, methods, extendOptions); + var extensionMethods = nativeExtensionMethodsWithIndexedCollectionAliases(methods); + var extendOptions = + nativeExtensionOptionsWithIterator(options, extensionMethods); + var extendedNativeClass = api.__extendClass(nativeClass, extensionMethods, extendOptions); var extended = wrapNativeClass(extendedNativeClass); + try { + Object.defineProperty(extended, '__nativeApiUseAllocInitConstructor', { + configurable: false, + enumerable: false, + writable: false, + value: true + }); + } catch (_) { + } try { Object.setPrototypeOf(extended, wrapper || constructable); } catch (_) { } var extendedPrototype = Object.create(constructable.prototype || null); try { - Object.defineProperties(extendedPrototype, Object.getOwnPropertyDescriptors(methods)); + Object.defineProperties(extendedPrototype, Object.getOwnPropertyDescriptors(extensionMethods)); } catch (_) { - Object.keys(methods).forEach(function(key) { - extendedPrototype[key] = methods[key]; + Object.keys(extensionMethods).forEach(function(key) { + extendedPrototype[key] = extensionMethods[key]; }); } try { @@ -709,6 +981,7 @@ function derivedClassWrapper(target) { }); } catch (_) { } + installInstanceClassIdentity(extendedPrototype, extended, constructable); extended.prototype = extendedPrototype; try { api.__rememberClassWrapper(extendedNativeClass, extended, extendedPrototype); @@ -1356,16 +1629,28 @@ function materializeTypeScriptNativeClass(constructor) { } var nativeBase = nativeClassLikeHandle(baseWrapper); - var nativeClass = api.__extendClass(nativeBase, constructor.prototype || {}, options); + var extensionMethods = + nativeExtensionMethodsWithIndexedCollectionAliases(constructor.prototype || {}); + options = nativeExtensionOptionsWithIterator(options, extensionMethods); + var nativeClass = api.__extendClass(nativeBase, extensionMethods, options); var wrapper = wrapNativeClass(nativeClass); state.wrapper = wrapper; + try { + Object.defineProperty(wrapper, '__nativeApiUseAllocInitConstructor', { + configurable: false, + enumerable: false, + writable: false, + value: true + }); + } catch (_) { + } try { Object.setPrototypeOf(constructor, wrapper); } catch (_) { } try { - api.__rememberClassWrapper(nativeClass, constructor, constructor.prototype || {}); + api.__rememberClassWrapper(nativeClass, constructor, extensionMethods); } catch (_) { } return wrapper; @@ -1483,6 +1768,8 @@ function installTypeScriptNativeClassSupport(constructor, base) { } catch (_) { } + installInstanceClassIdentity(constructor.prototype || {}, constructor, null); + ['alloc', 'new', 'class', 'superclass', 'extend'].forEach(function(name) { defineTypeScriptStaticForwarder(constructor, name, false, false); }); @@ -1871,8 +2158,9 @@ void InstallNativeApi(Runtime& runtime, const NativeApiConfig& config) { NativeApiWriteSmokeStage("engine:install-globals"); InstallNativeApiGlobalSymbols(runtime, globalName); } else { - NativeApiWriteSmokeStage("engine:install-aggregate-globals"); - InstallAggregateGlobals(runtime, api, "protocolNames"); + // RN doesn't install the aggregate global surface: unused, and building + // it eagerly costs launch time. + NativeApiWriteSmokeStage("engine:skip-globals"); } NativeApiWriteSmokeStage("engine:installed"); } diff --git a/NativeScript/ffi/objc/shared/bridge/Invocation.mm b/NativeScript/ffi/objc/shared/bridge/Invocation.mm index af32e2d3e..b0230c892 100644 --- a/NativeScript/ffi/objc/shared/bridge/Invocation.mm +++ b/NativeScript/ffi/objc/shared/bridge/Invocation.mm @@ -521,6 +521,11 @@ bool signatureSupportedForEngineInvocation( SEL selector = nullptr; Class receiverClass = Nil; std::string selectorName; + // Set when this prepared invocation IS a metadata property's setter + // selector (one arg, matches member->setterSelectorName): lets a + // successful call cache the value into the UIAppearance proxy cache + // without re-deriving the property name from the selector. + std::string propertySetterName; NativeApiSignature signature; ObjCPreparedInvoker preparedInvoker = nullptr; void* engineInvoker = nullptr; // Engine-neutral GSD invoker (ObjCGsdInvoker) @@ -539,6 +544,17 @@ bool preparedObjCInvocationIsInit( return prepared.isInitMethod; } +void cachePreparedAppearanceProxySetterValue( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const NativeApiPreparedObjCInvocation& prepared, + const Value* args, size_t count) { + if (prepared.propertySetterName.empty() || args == nullptr || count == 0) { + return; + } + cacheAppearanceProxyPropertyValue(runtime, bridge, receiver, + prepared.propertySetterName, args[0]); +} + bool isFastEngineObjectType(const NativeApiType& type) { switch (type.kind) { case metagen::mdTypeAnyObject: @@ -1472,6 +1488,12 @@ throw JSError( prepared->selector = selector; prepared->receiverClass = receiverIsClass ? lookupClass : Nil; prepared->selectorName = selectorName; + if (member != nullptr && member->property && !member->name.empty() && + !member->setterSelectorName.empty() && + selectorName == member->setterSelectorName && + selectorArgumentCount(selectorName) == 1) { + prepared->propertySetterName = member->name; + } prepared->signature = std::move(*signature); prepared->preparedInvoker = lookupObjCPreparedInvoker( dispatchIdForEngineSignature(prepared->signature, @@ -1487,6 +1509,34 @@ throw JSError( return prepared; } +bool isPreparedStaticAppearanceSelector( + const NativeApiPreparedObjCInvocation& prepared) { + return prepared.receiverClass != Nil && + prepared.selectorName.rfind("appearance", 0) == 0; +} + +Value tagPreparedStaticAppearanceSelectorResult( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const NativeApiPreparedObjCInvocation& prepared, + Value result) { + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, isPreparedStaticAppearanceSelector(prepared), + prepared.selectorName, std::move(result)); +} + +void tagPreparedStaticAppearanceNativeReturn( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const NativeApiPreparedObjCInvocation& prepared, + const NativeApiType& returnType, void* returnData) { + if (!isPreparedStaticAppearanceSelector(prepared) || + !isObjectiveCObjectType(returnType) || returnData == nullptr) { + return; + } + tagStaticAppearanceNativeResult( + runtime, bridge, static_cast(receiver), + *static_cast(returnData)); +} + Value callPreparedObjCSelector( Runtime& runtime, const std::shared_ptr& bridge, id receiver, bool receiverIsClass, @@ -1503,11 +1553,17 @@ throw JSError(runtime, if (tryCallGeneratedEngineObjCSelector(runtime, bridge, receiver, prepared, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, args, count); + return tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); } if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, args, count); + return tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); } NativeApiArgumentFrame frame(signature.argumentTypes.size()); @@ -1594,8 +1650,12 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } - return convertNativeReturnValue(runtime, bridge, returnType, - returnStorage.data()); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, prepared, + args, count); + Value result = convertNativeReturnValue(runtime, bridge, returnType, + returnStorage.data()); + return tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(result)); } Value callObjCSelector(Runtime& runtime, @@ -1617,7 +1677,22 @@ throw JSError(runtime, Class lookupClass = dispatchSuperClass != Nil ? dispatchSuperClass : receiverClass; Method method = receiverIsClass ? class_getClassMethod(lookupClass, selector) : class_getInstanceMethod(lookupClass, selector); + // A UIAppearance proxy is opaque to class_getInstanceMethod (it forwards + // selectors dynamically, so no Method exists for them) — allow through the + // exact property getter/setter selectors respondsToSelector: already + // vetted elsewhere, since -respondsToSelector: on the proxy itself can + // still return NO for a selector it will happily forward. + bool allowForwardedAppearancePropertySelector = false; + if (method == nullptr && !receiverIsClass && member != nullptr && + member->property && bridge != nullptr && + taggedAppearanceProxyClass(runtime, bridge, receiver) != Nil) { + allowForwardedAppearancePropertySelector = + (count == 0 && selectorName == member->selectorName) || + (count == 1 && !member->setterSelectorName.empty() && + selectorName == member->setterSelectorName); + } if (method == nullptr && + !allowForwardedAppearancePropertySelector && (dispatchSuperClass != Nil || ![receiver respondsToSelector:selector])) { throw JSError(runtime, "Objective-C selector is not available: " + @@ -1666,12 +1741,16 @@ throw JSError( if (tryCallGeneratedEngineObjCSelector(runtime, bridge, receiver, engineInvocation, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, receiverIsClass, selectorName, + std::move(fastResult)); } if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, engineInvocation, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, receiverIsClass, selectorName, + std::move(fastResult)); } NativeApiArgumentFrame frame(signature->argumentTypes.size()); @@ -1759,6 +1838,9 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } - return convertNativeReturnValue(runtime, bridge, returnType, - returnStorage.data()); + Value result = convertNativeReturnValue(runtime, bridge, returnType, + returnStorage.data()); + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, receiverIsClass, selectorName, + std::move(result)); } diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..b371acbb6 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -509,6 +509,21 @@ inline uintptr_t normalizeRuntimePointer(uintptr_t pointer) { #endif } +// One bridge is shared by every Runtime that touches the same process (a +// worklet spins up an additional Runtime on its own thread). Expandos are +// per-runtime: a Value created in one Runtime must never be handed back out +// of a different one, so every expando read/write is keyed on the owning +// runtime's identity, not just the native pointer. +uintptr_t runtimeObjectExpandoKey(Runtime& runtime) { +#if defined(TARGET_ENGINE_V8) || defined(TARGET_ENGINE_JSC) || \ + defined(TARGET_ENGINE_QUICKJS) + return normalizeRuntimePointer( + reinterpret_cast(runtime.state().get())); +#else + return normalizeRuntimePointer(reinterpret_cast(&runtime)); +#endif +} + class NativeApiBridge { struct NativeApiRoundTripValue { std::shared_ptr value; @@ -533,8 +548,10 @@ explicit NativeApiBridge(const NativeApiConfig& config) runtimeCallbackInvoker_(config.runtimeCallbackInvoker), jsThreadCallbackInvoker_(config.jsThreadCallbackInvoker), jsThreadAsyncCallbackInvoker_(config.jsThreadAsyncCallbackInvoker), + callbackInvocationAllowed_(config.callbackInvocationAllowed), invokeCallbacksOnNativeCallerThread_( - config.invokeCallbacksOnNativeCallerThread) { + config.invokeCallbacksOnNativeCallerThread), + indexRuntimePointers_(config.indexRuntimePointers) { selfDl_ = dlopen(nullptr, RTLD_NOW); buildSymbolIndexes(); } @@ -642,6 +659,16 @@ void rememberRoundTripValue(Runtime& runtime, const void* native, #endif } + // Convenience for preservedNativeApiInitializerSelfReturn: remembers an id + // (rather than an arbitrary native pointer) with the class-derived + // validation key, the same key an ordinary object wrapping would use. + void rememberNativeObjectRoundTripValue(Runtime& runtime, id object, + const Value& value, + bool stringLikeNative = false) { + rememberRoundTripValue(runtime, object, value, stringLikeNative, + nativeObjectClassKey(object)); + } + void rememberScopedRoundTripValue(Runtime& runtime, const void* native, const Value& value, bool stringLikeNative = false, @@ -929,24 +956,38 @@ Value findClassPrototype(Runtime& runtime, Class cls) const { return Value(runtime, *it->second); } + // Expandos are keyed native-pointer -> property -> owning-runtime, all + // guarded by objectExpandosMutex_: worklet runtimes run on their own + // thread but share this bridge, and a host-object dtor releasing its + // expando owner count can run on either thread relative to a get/set. void setObjectExpando(Runtime& runtime, const void* native, const std::string& property, const Value& value) { if (native == nullptr || property.empty()) { return; } - objectExpandos_[normalizeRuntimePointer(reinterpret_cast(native))] - [property] = std::make_shared(runtime, value); - objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + const uintptr_t key = + normalizeRuntimePointer(reinterpret_cast(native)); + const uintptr_t runtimeKey = runtimeObjectExpandoKey(runtime); + { + std::lock_guard lock(objectExpandosMutex_); + objectExpandos_[key][property][runtimeKey] = + std::make_shared(runtime, value); + objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + } } void retainObjectExpandoOwner(const void* native) { if (native == nullptr) { return; } + std::lock_guard lock(objectExpandosMutex_); objectExpandoOwnerCounts_[ normalizeRuntimePointer(reinterpret_cast(native))] += 1; } + // preserveExpandos keeps the stored values around after the last owner + // releases (used when a wrapper is being replaced/detached but the + // underlying native receiver's expando state must survive the swap). void releaseObjectExpandoOwner(const void* native, bool preserveExpandos = false) { if (native == nullptr) { @@ -954,15 +995,20 @@ void releaseObjectExpandoOwner(const void* native, } uintptr_t key = normalizeRuntimePointer(reinterpret_cast(native)); - auto ownerIt = objectExpandoOwnerCounts_.find(key); - if (ownerIt != objectExpandoOwnerCounts_.end()) { - if (ownerIt->second > 1) { - ownerIt->second -= 1; - return; + bool shouldForget = false; + { + std::lock_guard lock(objectExpandosMutex_); + auto ownerIt = objectExpandoOwnerCounts_.find(key); + if (ownerIt != objectExpandoOwnerCounts_.end()) { + if (ownerIt->second > 1) { + ownerIt->second -= 1; + return; + } + objectExpandoOwnerCounts_.erase(ownerIt); } - objectExpandoOwnerCounts_.erase(ownerIt); + shouldForget = !preserveExpandos; } - if (!preserveExpandos) { + if (shouldForget) { forgetObjectExpandos(native); } } @@ -975,6 +1021,7 @@ Value findObjectExpando(Runtime& runtime, const void* native, struct ObjectExpandoCacheEntry { const NativeApiBridge* bridge = nullptr; uintptr_t key = 0; + uintptr_t runtimeKey = 0; uint64_t generation = 0; std::string property; std::weak_ptr value; @@ -985,11 +1032,13 @@ Value findObjectExpando(Runtime& runtime, const void* native, const uintptr_t key = normalizeRuntimePointer(reinterpret_cast(native)); + const uintptr_t runtimeKey = runtimeObjectExpandoKey(runtime); const uint64_t generation = objectExpandosGeneration_.load(std::memory_order_acquire); for (auto& entry : cache) { if (entry.bridge == this && entry.key == key && - entry.generation == generation && entry.property == property) { + entry.runtimeKey == runtimeKey && entry.generation == generation && + entry.property == property) { if (entry.miss) { return Value::undefined(); } @@ -1000,32 +1049,44 @@ Value findObjectExpando(Runtime& runtime, const void* native, } } - auto objectIt = objectExpandos_.find(key); + std::shared_ptr storedValue; const size_t slot = nextSlot++ & 7; - if (objectIt == objectExpandos_.end()) { - cache[slot] = - ObjectExpandoCacheEntry{this, key, generation, property, {}, true}; - return Value::undefined(); - } - auto propertyIt = objectIt->second.find(property); - if (propertyIt == objectIt->second.end() || propertyIt->second == nullptr) { - cache[slot] = - ObjectExpandoCacheEntry{this, key, generation, property, {}, true}; - return Value::undefined(); + { + std::lock_guard lock(objectExpandosMutex_); + auto objectIt = objectExpandos_.find(key); + if (objectIt != objectExpandos_.end()) { + auto propertyIt = objectIt->second.find(property); + if (propertyIt != objectIt->second.end()) { + auto runtimeIt = propertyIt->second.find(runtimeKey); + if (runtimeIt != propertyIt->second.end() && + runtimeIt->second != nullptr) { + storedValue = runtimeIt->second; + } + } + } + if (storedValue == nullptr) { + cache[slot] = ObjectExpandoCacheEntry{ + this, key, runtimeKey, generation, property, {}, true}; + return Value::undefined(); + } + cache[slot] = ObjectExpandoCacheEntry{ + this, key, runtimeKey, generation, property, storedValue, false}; } - cache[slot] = ObjectExpandoCacheEntry{ - this, key, generation, property, propertyIt->second, false}; - return Value(runtime, *propertyIt->second); + return Value(runtime, *storedValue); } + // Erases every runtime's stored values for this native (codex semantics): + // the native object itself is gone, so no runtime should keep seeing it. void forgetObjectExpandos(const void* native) { if (native == nullptr) { return; } auto key = normalizeRuntimePointer(reinterpret_cast(native)); - objectExpandos_.erase( - key); - objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + { + std::lock_guard lock(objectExpandosMutex_); + objectExpandos_.erase(key); + objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + } } // Per-class cache of resolved metadata property-getter members. Lets the @@ -1187,6 +1248,26 @@ void forgetPointerValue(const void* native) { jsThreadAsyncCallbackInvoker() const { return jsThreadAsyncCallbackInvoker_; } + // Teardown-safety gate: RN sets this so callbacks can be refused once the + // host is tearing down/reloading, without every call site needing to know + // why. Both a C++ try and an @try wrap the call — they catch different + // exception families (std::exception-derived vs. NSException), and either + // one escaping here would otherwise cross into caller frames that aren't + // set up to catch it. + bool callbackInvocationAllowed() const noexcept { + if (!callbackInvocationAllowed_) { + return true; + } + @try { + try { + return callbackInvocationAllowed_(); + } catch (...) { + return false; + } + } @catch (...) { + return false; + } + } bool invokeCallbacksOnNativeCallerThread() const { return invokeCallbacksOnNativeCallerThread_; } @@ -1465,10 +1546,12 @@ void addSymbol(NativeApiSymbolKind kind, MDSectionOffset offset, if (kind == NativeApiSymbolKind::Class) { classSymbolsByOffset_[symbol.offset] = symbol; classSymbolsByRuntimeName_[symbol.runtimeName] = symbol; - Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); - if (cls != Nil) { - classSymbolsByRuntimePointer_[normalizeRuntimePointer( - reinterpret_cast(cls))] = symbol; + if (indexRuntimePointers_) { + Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); + if (cls != Nil) { + classSymbolsByRuntimePointer_[normalizeRuntimePointer( + reinterpret_cast(cls))] = symbol; + } } } else if (kind == NativeApiSymbolKind::Protocol) { protocolSymbolsByOffset_[symbol.offset] = symbol; @@ -1478,10 +1561,12 @@ void addSymbol(NativeApiSymbolKind kind, MDSectionOffset offset, return; } protocolSymbolsByRuntimeName_[runtimeName] = symbol; - Protocol* runtimeProtocol = lookupProtocolByNativeName(runtimeName); - if (runtimeProtocol != nullptr) { - protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( - reinterpret_cast(runtimeProtocol))] = symbol; + if (indexRuntimePointers_) { + Protocol* runtimeProtocol = lookupProtocolByNativeName(runtimeName); + if (runtimeProtocol != nullptr) { + protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( + reinterpret_cast(runtimeProtocol))] = symbol; + } } }; if (symbol.name.size() > 9 && @@ -1500,13 +1585,15 @@ void addSymbol(NativeApiSymbolKind kind, MDSectionOffset offset, symbol.name.substr(0, digitsStart - protocolSuffixLength)); } } - Protocol* protocol = lookupProtocolByNativeName(symbol.runtimeName); - if (protocol == nullptr && symbol.runtimeName != symbol.name) { - protocol = lookupProtocolByNativeName(symbol.name); - } - if (protocol != nullptr) { - protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( - reinterpret_cast(protocol))] = symbol; + if (indexRuntimePointers_) { + Protocol* protocol = lookupProtocolByNativeName(symbol.runtimeName); + if (protocol == nullptr && symbol.runtimeName != symbol.name) { + protocol = lookupProtocolByNativeName(symbol.name); + } + if (protocol != nullptr) { + protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( + reinterpret_cast(protocol))] = symbol; + } } } else if (kind == NativeApiSymbolKind::Struct) { structSymbolsByOffset_[symbol.offset] = symbol; @@ -2128,8 +2215,12 @@ static void appendSurfaceMember( std::unordered_map> classValues_; std::unordered_map> classPrototypes_; std::unordered_map> pointerValues_; - std::unordered_map>> + mutable std::mutex objectExpandosMutex_; + std::unordered_map< + uintptr_t, + std::unordered_map< + std::string, + std::unordered_map>>> objectExpandos_; std::unordered_map objectExpandoOwnerCounts_; std::atomic objectExpandosGeneration_{1}; @@ -2151,7 +2242,9 @@ static void appendSurfaceMember( std::function)> runtimeCallbackInvoker_; std::function)> jsThreadCallbackInvoker_; std::function)> jsThreadAsyncCallbackInvoker_; + std::function callbackInvocationAllowed_; bool invokeCallbacksOnNativeCallerThread_ = false; + bool indexRuntimePointers_ = true; mutable std::unordered_map> membersByClassOffset_; mutable std::unordered_map> @@ -2210,10 +2303,26 @@ bool nativeObjectReturnMayCoerceToString(const NativeApiType& type) { type.kind == metagen::mdTypeNSStringObject; } -bool nativeObjectIsStringLike(id object) { +// Guards against treating a misread register value as an object pointer: +// low addresses can't be valid ObjC objects, but a register holding e.g. an +// unboxed integer or a non-object primitive read as `id` can land there. +// Without this guard, dereferencing it (object_getClass, isKindOfClass:, +// etc. below) can crash on garbage. Do not remove — this is what stops +// crashes on AnyObject-typed returns from selectors whose actual return +// isn't an object. +bool nativeObjectPointerMayBeObject(id object) { if (object == nil) { return false; } + + const uintptr_t raw = reinterpret_cast(object); + return raw > 0x1000; +} + +bool nativeObjectIsStringLike(id object) { + if (!nativeObjectPointerMayBeObject(object)) { + return false; + } Class cls = object_getClass(object); struct StringLikeClassCacheEntry { Class cls = Nil; @@ -2235,6 +2344,10 @@ bool nativeObjectIsStringLike(id object) { Value findCachedNativeObjectReturn(Runtime& runtime, const std::shared_ptr& bridge, const NativeApiType& type, id object) { + if (!nativeObjectPointerMayBeObject(object)) { + return Value::undefined(); + } + bool roundTripStringLike = false; const bool stringReturnCandidate = nativeObjectReturnMayCoerceToString(type); // AnyObject/NSString returns intentionally coerce string-like native objects @@ -2358,7 +2471,8 @@ Function CreateNativeApiBoundSelectorGroupFunction( Value makeNativeObjectValue(Runtime& runtime, const std::shared_ptr& bridge, - id object, bool ownsObject); + id object, bool ownsObject, + Class superDispatchClass = Nil); Value makeNativeClassValue(Runtime& runtime, const std::shared_ptr& bridge, diff --git a/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h b/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h index ba7f3887b..907aef1cb 100644 --- a/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h +++ b/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h @@ -10,6 +10,11 @@ struct NativeApiResolvedSelectorGroupCall { Class dispatchClass = Nil; bool hasImmediateResult = false; Value immediateResult; + // False when the prepared invocation is a `[SomeClass appearance...]` + // static selector: the GSD fast path bypasses tagStaticAppearance*, so it + // must be excluded from GSD eligibility to keep proxy tagging/caching + // working. + bool gsdAllowed = true; }; template object() - : resolveReceiver(); + : nil; + if (result.receiver == nil) { + // Either this call is unbound (the common case -- `resolveReceiver()` + // resolves the live `thisValue`), OR it IS bound but the bound + // receiver's `NativeApiObjectHostObject` wrapper has already been torn + // down: the cached selector-group function itself survives as a + // native-object expando (keyed by the native pointer, see Object.mm's + // `bridge_->setObjectExpando(..., methodFunction)`), which outlives the + // specific wrapper instance it was bound to when this runtime later + // mints a FRESH wrapper for the same native pointer on another + // crossing. Re-resolve from the actual call-site receiver in both + // cases -- for a bound call this is exactly the live object the method + // is being invoked on right now, so it is always correct. + result.receiver = resolveReceiver(); + } } if (result.receiver == nil) { throw JSError(runtime, @@ -45,6 +64,15 @@ inline NativeApiResolvedSelectorGroupCall resolveNativeApiSelectorGroupCall( const bool propertyGetterCall = entry.hasMember && entry.member.property && argumentCount == 0; + if (propertyGetterCall) { + Value appearanceExpando = cachedAppearanceProxyPropertyValue( + runtime, data.bridge, result.receiver, entry.member.name); + if (!appearanceExpando.isUndefined()) { + result.immediateResult = std::move(appearanceExpando); + result.hasImmediateResult = true; + return result; + } + } const std::string* selectorNamePtr = &entry.selectorName; const NativeApiMember* selectedMember = entry.hasMember ? &entry.member : nullptr; @@ -112,6 +140,7 @@ inline NativeApiResolvedSelectorGroupCall resolveNativeApiSelectorGroupCall( } } result.prepared = prepared.get(); + result.gsdAllowed = !isPreparedStaticAppearanceSelector(*prepared); if constexpr (PrepareInitializer) { if (!data.receiverIsClass && prepared->isInitMethod) { diff --git a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm index 98ecf6994..37b2c35e1 100644 --- a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm +++ b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm @@ -475,6 +475,68 @@ bool readPointerLikeValue(Runtime& runtime, const Value& value, void** pointer) return readNativePointerProperty(runtime, object, pointer); } +// interop.set/getAssociatedObject's target parameter accepts either a live +// wrapped native object/pointer, or (for cases the engine can't hold a +// reference to, e.g. round-tripping a raw address from logs/debugging) the +// decimal text of a pointer value. +id nativeAssociatedObjectTargetFromValue(Runtime& runtime, const Value& value) { + if (value.isNull() || value.isUndefined()) { + return nil; + } + + if (value.isString()) { + uintptr_t address = 0; + if (!parseIntegerTextToUintptr(value.asString(runtime).utf8(runtime), &address)) { + throw JSError(runtime, "Associated object target expects a native object or object pointer."); + } + return static_cast(reinterpret_cast(address)); + } + + if (!value.isObject()) { + throw JSError(runtime, "Associated object target expects a native object or object pointer."); + } + + void* pointer = nullptr; + if (readPointerLikeValue(runtime, value, &pointer)) { + return static_cast(pointer); + } + + throw JSError(runtime, "Associated object target expects a native object or object pointer."); +} + +objc_AssociationPolicy associatedObjectPolicyFromValue(Runtime& runtime, const Value& value) { + if (value.isUndefined() || value.isNull()) { + return OBJC_ASSOCIATION_RETAIN_NONATOMIC; + } + + if (value.isNumber()) { + return static_cast(static_cast(value.getNumber())); + } + + if (!value.isString()) { + throw JSError(runtime, "Associated object policy expects a string or numeric objc_AssociationPolicy."); + } + + std::string policy = value.asString(runtime).utf8(runtime); + if (policy == "assign") { + return OBJC_ASSOCIATION_ASSIGN; + } + if (policy == "retain") { + return OBJC_ASSOCIATION_RETAIN; + } + if (policy == "retainNonatomic" || policy == "strong" || policy == "strongNonatomic") { + return OBJC_ASSOCIATION_RETAIN_NONATOMIC; + } + if (policy == "copy") { + return OBJC_ASSOCIATION_COPY; + } + if (policy == "copyNonatomic") { + return OBJC_ASSOCIATION_COPY_NONATOMIC; + } + + throw JSError(runtime, "Unknown associated object policy."); +} + template void writeNumericArgument(Runtime& runtime, const Value& value, void* target, const char* typeName) { @@ -1058,6 +1120,9 @@ throw JSError(runtime, "This native return type is not supported by " if (object == nil) { return Value::null(); } + if (!nativeObjectPointerMayBeObject(object)) { + return Value::undefined(); + } Value roundTrip = findCachedNativeObjectReturn(runtime, bridge, type, object); if (!roundTrip.isUndefined()) { if (type.returnOwned) { @@ -1091,6 +1156,37 @@ throw JSError(runtime, "This native return type is not supported by " } return result; } + if (object_isClass(object)) { + Class cls = static_cast(object); + Value cachedClass = bridge->findClassValue(runtime, cls); + if (!cachedClass.isUndefined()) { + if (type.returnOwned) { + [object release]; + } + return cachedClass; + } + + const char* className = class_getName(cls); + NativeApiSymbol symbol{ + .kind = NativeApiSymbolKind::Class, + .offset = MD_SECTION_OFFSET_NULL, + .name = className != nullptr ? className : "", + .runtimeName = className != nullptr ? className : "", + }; + if (const NativeApiSymbol* found = + bridge->findClassForRuntimePointer(cls)) { + symbol = *found; + } else if (const NativeApiSymbol* found = + bridge->findClassForRuntimeClass(cls)) { + symbol = *found; + } + Value classValue = + makeNativeClassValue(runtime, bridge, std::move(symbol)); + if (type.returnOwned) { + [object release]; + } + return classValue; + } if (const NativeApiSymbol* classSymbol = bridge->findClassForRuntimePointer((void*)object)) { return makeNativeClassValue(runtime, bridge, *classSymbol); } @@ -1761,8 +1857,20 @@ Object createInteropObject(Runtime& runtime, const std::shared_ptr Value { + if (count < 3) { + throw JSError(runtime, + "interop.setAssociatedObject expects target, key, and value."); + } + id target = nativeAssociatedObjectTargetFromValue(runtime, args[0]); + if (target == nil || !args[1].isString()) { + throw JSError(runtime, + "interop.setAssociatedObject expects target, key, and value."); + } + + std::string key = args[1].asString(runtime).utf8(runtime); + NativeApiArgumentFrame frame(1); + id value = nil; + if (!args[2].isNull() && !args[2].isUndefined()) { + value = objectFromEngineValue(runtime, bridge, args[2], frame, false); + } + objc_AssociationPolicy policy = + count > 3 ? associatedObjectPolicyFromValue(runtime, args[3]) + : OBJC_ASSOCIATION_RETAIN_NONATOMIC; + objc_setAssociatedObject(target, sel_registerName(key.c_str()), value, policy); + return Value::undefined(); + })); + + interop.setProperty( + runtime, "getAssociatedObject", + Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, "getAssociatedObject"), 2, + [bridge](Runtime& runtime, const Value&, const Value* args, size_t count) -> Value { + if (count < 2 || !args[1].isString()) { + throw JSError(runtime, + "interop.getAssociatedObject expects target and key."); + } + id target = nativeAssociatedObjectTargetFromValue(runtime, args[0]); + if (target == nil) { + return Value::null(); + } + + std::string key = args[1].asString(runtime).utf8(runtime); + id associated = objc_getAssociatedObject(target, sel_registerName(key.c_str())); + if (associated == nil) { + return Value::null(); + } + + NativeApiType type = nativeObjectReturnTypeForClass(object_getClass(associated)); + return convertNativeReturnValue(runtime, bridge, type, &associated); + })); + interop.setProperty( runtime, "stringFromCString", Function::createFromHostFunction( diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Appearance.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Appearance.mm new file mode 100644 index 000000000..961c1b0f0 --- /dev/null +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Appearance.mm @@ -0,0 +1,321 @@ +// UIAppearance proxy primitives. +// +// `[SomeView appearance]` (and the whenContainedIn:/appearanceWhenContainedIn: +// variants) hands back an opaque `_UIAppearance` proxy, not a real instance of +// the class — UIKit forwards whatever selectors it recognizes to an internal +// invocation-recording store instead of actually executing them. There is no +// public, introspectable way to ask one of these proxies "what class are you +// a proxy for" other than parsing its `-description`, which UIKit formats as +// ``. Everything below exists to (a) recover +// that class from the description once, tag it onto the wrapped object as an +// expando so we don't have to re-parse on every access, and (b) cache +// get/set values in a class-keyed expando store (keyed on the customizable +// class, not the proxy instance — UIAppearance state is effectively global +// per class/containment-chain, not per proxy object) so repeated reads see +// the value a set() just wrote instead of round-tripping back into UIKit's +// opaque recording machinery. Setters ALSO cache: an appearance proxy setter +// doesn't reliably support read-your-write, so we do it ourselves. + +// Forward declaration: runtimeWritablePropertySetter is defined later in +// Object.mm (this file is included before it), but is needed here by +// makeAppearanceProxyPropertySetter's non-metadata-setter fallback. +std::optional runtimeWritablePropertySetter( + id object, const std::string& property); + +constexpr const char* kNativeApiAppearanceClassNameExpando = + "__nativeApiAppearanceClassName"; + +// Parses UIKit's `` description format. +Class appearanceProxyCustomizableClassFromExactDescription(id object) { +#if TARGET_OS_IPHONE + if (object == nil) { + return Nil; + } + + NSString* description = [object description]; + NSString* prefix = @""]) { + return Nil; + } + + NSRange classNameRange = + NSMakeRange(prefix.length, description.length - prefix.length - 1); + NSString* className = [description substringWithRange:classNameRange]; + return NSClassFromString(className); +#else + return Nil; +#endif +} + +// The customizable class an appearance proxy wraps, preferring the tagged +// expando (set once by tagStaticAppearanceNativeResult) over re-parsing the +// description on every access. +// +// The description-parsing fallback below sends a real `-description` message +// to `object` -- an arbitrary, potentially-overridden Objective-C method, not +// a safe runtime introspection call. It must NEVER run while `object` is a +// callback argument native code just handed to JS reentrantly (tracked by +// gNativeCallerThreadEngineCallbackDepth, incremented for the duration of +// exactly that kind of invocation -- see callOnNativeCallerThread in +// Callbacks.mm and its use in performNativeInvocation's skipInvoker check +// above): a real UIAppearance proxy is only ever obtained by JS calling an +// `+appearance`-family method itself (an OUTBOUND call this engine makes, +// never something delivered inbound as a callback argument), so skipping the +// fallback in that situation never regresses genuine appearance-proxy +// detection. It matters for callback arguments that are NOT appearance +// proxies but are transient/live objects UIKit hands to a block while its own +// internal machinery is still on the stack -- e.g. the +// UISheetPresentationControllerDetentResolutionContext passed to a custom +// detent's resolver block. Calling `-description` on that object from inside +// the resolver deadlocks (confirmed: an otherwise-identical resolver that +// never touches the context argument returns cleanly; the hang is inside +// this function, specifically in the `-description` send, per an os_log +// breadcrumb trace -- see item 2 of the parity-tail investigation). This +// guard trades a rare caching miss (a brand-new, untagged appearance proxy +// obtained for the first time from inside some other callback) for +// eliminating that deadlock class entirely. +Class taggedAppearanceProxyClass( + Runtime& runtime, const std::shared_ptr& bridge, + id object) { + if (object == nil || bridge == nullptr) { + return Nil; + } + + Value classNameValue = bridge->findObjectExpando( + runtime, object, kNativeApiAppearanceClassNameExpando); + if (!classNameValue.isString()) { + if (gNativeCallerThreadEngineCallbackDepth > 0) { + return Nil; + } + return appearanceProxyCustomizableClassFromExactDescription(object); + } + + std::string className = + classNameValue.asString(runtime).utf8(runtime); + return objc_lookUpClass(className.c_str()); +} + +std::string appearanceProxyExpandoPropertyKey( + const std::string& property) { + return "__nativeApiAppearance:" + property; +} + +// Cached class-keyed (not proxy-instance-keyed — see file header) UIAppearance +// property value. +Value cachedAppearanceProxyPropertyValue( + Runtime& runtime, const std::shared_ptr& bridge, + id object, const std::string& property) { + if (Class appearanceClass = + taggedAppearanceProxyClass(runtime, bridge, object)) { + return bridge->findObjectExpando( + runtime, appearanceClass, appearanceProxyExpandoPropertyKey(property)); + } + return Value::undefined(); +} + +void cacheAppearanceProxyPropertyValue( + Runtime& runtime, const std::shared_ptr& bridge, + id object, const std::string& property, const Value& value) { + if (Class appearanceClass = + taggedAppearanceProxyClass(runtime, bridge, object)) { + bridge->setObjectExpando(runtime, appearanceClass, + appearanceProxyExpandoPropertyKey(property), + value); + } +} + +// Picks the more capable of two candidate members exposing the same property +// name (prefers writable over readonly, a member with a known setter +// selector, a member with resolved signature metadata). +const NativeApiMember* betterAppearanceProxyAccessorMember( + const NativeApiMember* current, const NativeApiMember& candidate) { + if (current == nullptr) { + return &candidate; + } + if (current->readonly != candidate.readonly) { + return candidate.readonly ? current : &candidate; + } + if (current->setterSelectorName.empty() && + !candidate.setterSelectorName.empty()) { + return &candidate; + } + if (current->signatureOffset == MD_SECTION_OFFSET_NULL && + candidate.signatureOffset != MD_SECTION_OFFSET_NULL) { + return &candidate; + } + return current; +} + +const NativeApiMember* selectAppearanceProxyPropertyMember( + const std::vector& members, const std::string& property) { + const NativeApiMember* selected = nullptr; + for (const auto& member : members) { + if (!member.property || member.name != property) { + continue; + } + bool memberIsStatic = (member.flags & metagen::mdMemberStatic) != 0; + if (memberIsStatic) { + continue; + } + selected = betterAppearanceProxyAccessorMember(selected, member); + } + return selected; +} + +// True for a `[SomeClass appearance...]` static-selector call — the class +// receiver + selector-name convention UIKit uses for all of the appearance +// proxy factory methods. +bool isStaticAppearanceSelector(bool receiverIsClass, + const std::string& selectorName) { + return receiverIsClass && selectorName.rfind("appearance", 0) == 0; +} + +// Recovers the customizable class from an appearance proxy's description and +// tags it onto the proxy as an expando (so future accesses don't need to +// re-parse the description). +Class tagStaticAppearanceNativeResult( + Runtime& runtime, const std::shared_ptr& bridge, + Class appearanceClass, id native) { + if (bridge == nullptr || appearanceClass == Nil || native == nil) { + return Nil; + } + Class customizableClass = + appearanceProxyCustomizableClassFromExactDescription(native); + if (customizableClass == Nil) { + return Nil; + } + const char* className = class_getName(customizableClass); + if (className == nullptr || className[0] == '\0') { + return Nil; + } + bridge->setObjectExpando(runtime, native, kNativeApiAppearanceClassNameExpando, + makeString(runtime, className)); + return customizableClass; +} + +std::shared_ptr retainAppearanceProxyForAccessor(id native) { + id retained = [native retain]; + return std::shared_ptr(static_cast(retained), [](void* value) { + [(id)value release]; + }); +} + +bool shouldInstallAppearanceProxyAccessor(const NativeApiMember& member) { + if (!member.property || member.name.empty()) { + return false; + } + bool memberIsStatic = (member.flags & metagen::mdMemberStatic) != 0; + if (memberIsStatic) { + return false; + } + return member.name != "superclass" && member.name != "class" && + member.name != "constructor" && member.name != "debugDescription" && + member.name != "className" && member.name != "description"; +} + +Function makeAppearanceProxyPropertyGetter( + Runtime& runtime, std::shared_ptr bridge, id native, + std::shared_ptr retainedNative, std::string property) { + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, property.c_str()), 0, + [bridge = std::move(bridge), native, retainedNative = std::move(retainedNative), + property = std::move(property)](Runtime& runtime, const Value&, + const Value*, size_t) -> Value { + return cachedAppearanceProxyPropertyValue(runtime, bridge, native, + property); + }); +} + +Function makeAppearanceProxyPropertySetter( + Runtime& runtime, std::shared_ptr bridge, id native, + std::shared_ptr retainedNative, NativeApiMember member) { + std::string functionName = member.setterSelectorName.empty() + ? member.name + : member.setterSelectorName; + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, functionName.c_str()), 1, + [bridge = std::move(bridge), native, retainedNative = std::move(retainedNative), + member = std::move(member)](Runtime& runtime, const Value&, const Value* args, + size_t count) -> Value { + if (count < 1) { + throw JSError(runtime, + "UIAppearance property setter expects a value."); + } + Value setterArgs[] = {Value(runtime, args[0])}; + if (!member.setterSelectorName.empty()) { + NativeApiMember setterMember = member; + setterMember.selectorName = member.setterSelectorName; + setterMember.signatureOffset = member.setterSignatureOffset; + callObjCSelector(runtime, bridge, native, false, + setterMember.selectorName, &setterMember, + setterArgs, 1); + } else if (auto setterSelectorName = + runtimeWritablePropertySetter(native, member.name)) { + callObjCSelector(runtime, bridge, native, false, *setterSelectorName, + nullptr, setterArgs, 1); + } else { + throw JSError(runtime, + "UIAppearance property setter is unavailable."); + } + cacheAppearanceProxyPropertyValue(runtime, bridge, native, member.name, + args[0]); + return Value::undefined(); + }); +} + +// Installs get/set accessor descriptors for every writable metadata property +// of `customizableClass` onto `resultObject` (the JS wrapper for the +// appearance proxy) — this is what makes `View.appearance().tintColor = ...` +// resolve as a real property assignment instead of requiring `.invoke(...)`. +void installAppearanceProxyPropertyAccessors( + Runtime& runtime, const std::shared_ptr& bridge, + Class customizableClass, id native, Object& resultObject) { + if (bridge == nullptr || customizableClass == Nil) { + return; + } + const NativeApiSymbol* symbol = + bridge->findClassForRuntimeClass(customizableClass); + if (symbol == nullptr) { + return; + } + + Object objectConstructor = + runtime.global().getPropertyAsObject(runtime, "Object"); + Function defineProperty = + objectConstructor.getPropertyAsFunction(runtime, "defineProperty"); + std::shared_ptr retainedNative = + retainAppearanceProxyForAccessor(native); + const auto& members = bridge->membersForClass(*symbol); + std::unordered_map accessors; + for (const auto& member : members) { + if (!shouldInstallAppearanceProxyAccessor(member)) { + continue; + } + accessors[member.name] = + betterAppearanceProxyAccessorMember(accessors[member.name], member); + } + + for (const auto& accessor : accessors) { + const NativeApiMember& member = *accessor.second; + + try { + Object descriptor(runtime); + descriptor.setProperty(runtime, "configurable", true); + descriptor.setProperty(runtime, "enumerable", false); + descriptor.setProperty( + runtime, "get", + makeAppearanceProxyPropertyGetter(runtime, bridge, native, + retainedNative, member.name)); + if (!member.readonly) { + descriptor.setProperty( + runtime, "set", + makeAppearanceProxyPropertySetter(runtime, bridge, native, + retainedNative, member)); + } + defineProperty.call(runtime, resultObject, makeString(runtime, member.name), + descriptor); + } catch (const std::exception&) { + } + } +} diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm index e74c4032b..500bd901c 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm @@ -213,6 +213,57 @@ throw JSError( } const auto& members = bridge_->membersForClass(symbol_); + // `[SomeClass appearance]` (and the whenContainedIn:/ + // appearanceWhenContainedIn: overloads) intercepted here so the returned + // opaque UIAppearance proxy gets tagged with the class it represents and + // has its property accessors installed — otherwise it would just be a + // callable selector-group function, not the property-settable object + // callers expect (see host_objects/Appearance.mm). + if (property == "appearance" && + selectorGroupEntriesForMethod(members, property, true) != nullptr) { + auto bridge = bridge_; + auto symbol = symbol_; + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, property.c_str()), 0, + [bridge, symbol](Runtime& runtime, const Value&, + const Value* args, size_t count) -> Value { + Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); + if (cls == Nil) { + throw JSError( + runtime, "Objective-C class is not available: " + + symbol.name); + } + + const auto& members = bridge->membersForClass(symbol); + const NativeApiMember* selected = + selectMethodMember(members, "appearance", true, count); + if (selected == nullptr) { + throw JSError(runtime, + "Objective-C selector is not available: appearance"); + } + + Value result = callObjCSelector( + runtime, bridge, static_cast(cls), true, + selected->selectorName, selected, args, count); + if (result.isObject()) { + Object resultObject = result.asObject(runtime); + if (resultObject.isHostObject( + runtime)) { + id native = resultObject + .getHostObject( + runtime) + ->object(); + Class customizableClass = + tagStaticAppearanceNativeResult(runtime, bridge, cls, + native); + installAppearanceProxyPropertyAccessors( + runtime, bridge, customizableClass, native, resultObject); + } + } + return result; + }); + } + if (const NativeApiMember* propertyMember = selectWritablePropertyMember(members, property, true)) { auto bridge = bridge_; @@ -307,7 +358,8 @@ throw JSError(runtime, Value makeNativeObjectValue(Runtime& runtime, const std::shared_ptr& bridge, - id object, bool ownsObject) { + id object, bool ownsObject, + Class superDispatchClass) { if (object == nil) { return Value::null(); } @@ -321,6 +373,9 @@ Value makeNativeObjectValue(Runtime& runtime, ? cached.asObject(runtime).getHostObject(runtime) : nullptr; if (cachedHost != nullptr && cachedHost->object() != nil) { + if (superDispatchClass != Nil) { + cachedHost->setSuperDispatchClass(superDispatchClass); + } if (ownsObject) { [object release]; } @@ -331,7 +386,8 @@ Value makeNativeObjectValue(Runtime& runtime, Object result = createNativeInstanceHostObject( runtime, - std::make_shared(bridge, object, ownsObject)); + std::make_shared( + bridge, object, ownsObject, superDispatchClass)); Value prototypeValue = Value::undefined(); Value classWrapperValue = bridge->findObjectExpando(runtime, object, "__nativeApiClassWrapper"); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm index afe14d2c5..7c7cc49a3 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm @@ -130,12 +130,8 @@ NativeApiSymbol nativeApiSymbolForRuntimeClass( return std::nullopt; } -std::optional runtimeReadablePropertyGetter(id object, - const std::string& property) { - if (object == nil || property.empty()) { - return std::nullopt; - } - +std::optional resolveRuntimeReadablePropertyGetter( + id object, const std::string& property) { Class current = object_getClass(object); while (current != Nil) { objc_property_t prop = class_getProperty(current, property.c_str()); @@ -158,6 +154,71 @@ NativeApiSymbol nativeApiSymbolForRuntimeClass( return respondingPropertyGetterSelector(object, property, property); } +// Caches the resolved getter selector per (class, property): this path only +// serves JS-subclass instances (the hot metadata-getter path already has +// findCachedPropertyGetter in front, see below), but the objc-runtime class +// walk above is still worth amortizing across repeated accesses. +std::optional runtimeReadablePropertyGetter(id object, + const std::string& property) { + if (object == nil || property.empty()) { + return std::nullopt; + } + + Class cls = object_getClass(object); + static std::mutex cacheMutex; + static std::unordered_map>> + cache; + + { + std::lock_guard lock(cacheMutex); + auto classIt = cache.find(cls); + if (classIt != cache.end()) { + auto propertyIt = classIt->second.find(property); + if (propertyIt != classIt->second.end()) { + return propertyIt->second; + } + } + } + + std::optional resolved = + resolveRuntimeReadablePropertyGetter(object, property); + + std::lock_guard lock(cacheMutex); + cache[cls][property] = resolved; + return resolved; +} + +// True if `property` resolves to a real runtime getter (metadata property or +// a JS-subclass instance's own class-builder-registered getter) — used to +// decide whether a successful runtime SET also needs an expando write so a +// subsequent GET (which may not consult the same runtime path) sees it. +bool objectGetPathCanReadRuntimeProperty(id object, + const std::string& property) { + if (object == nil || property.empty()) { + return false; + } + + if (class_conformsToProtocol(object_getClass(object), + @protocol(NativeApiClassBuilderProtocol))) { + return runtimeReadablePropertyGetter(object, property).has_value(); + } + + if (objc_property_t prop = + class_getProperty(object_getClass(object), property.c_str())) { + std::string getter = property; + if (char* customGetter = property_copyAttributeValue(prop, "G")) { + getter = customGetter; + free(customGetter); + } + return respondingPropertyGetterSelector(object, property, getter) + .has_value(); + } + + return false; +} + class NativeApiSuperHostObject final : public HostObject { public: NativeApiSuperHostObject(std::shared_ptr bridge, @@ -467,10 +528,12 @@ Array runtimeMembersArray(Runtime& runtime, Class cls, bool staticMembers) { public std::enable_shared_from_this { public: NativeApiObjectHostObject(std::shared_ptr bridge, - id object, bool ownsObject) + id object, bool ownsObject, + Class superDispatchClass = Nil) : bridge_(std::move(bridge)), object_(object), ownsObject_(ownsObject), + superDispatchClass_(superDispatchClass), lifetimeState_(std::make_shared(object)) { if (bridge_ != nullptr && object_ != nil) { bridge_->retainObjectExpandoOwner(object_); @@ -499,6 +562,9 @@ Array runtimeMembersArray(Runtime& runtime, Class cls, bool staticMembers) { } id object() const { return object_; } + void setSuperDispatchClass(Class superDispatchClass) { + superDispatchClass_ = superDispatchClass; + } std::shared_ptr lifetimeState() const { return lifetimeState_; } @@ -528,6 +594,36 @@ void disownObject(id expected, bool preserveExpandos = false) { } } + // Disown without forgetting the round-trip value: used when a wrapper is + // being replaced (e.g. an initializer returned a different/self receiver) + // but the native receiver itself is staying alive and must keep resolving + // to the SAME preserved engine value on the next lookup. Unlike + // disownObject(), this does not call forgetRoundTripValue — losing that + // would let a second, divergent wrapper get created for the same native + // receiver. releaseObjectExpandoOwner still runs (to keep the refcount + // balanced against the matching retain in the constructor), but with + // preserveExpandos=true so the receiver's expando state also survives. + void detachObjectPreservingBridgeState(id expected) { + if (object_ != expected) { + return; + } + + id object = object_; + bool releaseObject = ownsObject_; + if (bridge_ != nullptr && expected != nil) { + bridge_->releaseObjectExpandoOwner(expected, /*preserveExpandos=*/true); + } + ownsObject_ = false; + wrapperRetainedObject_ = false; + object_ = nil; + if (lifetimeState_ != nullptr) { + lifetimeState_->clear(); + } + if (releaseObject && object != nil) { + [object release]; + } + } + static bool isInitializerSelector(const std::string& selectorName) { return selectorName.rfind("init", 0) == 0; } @@ -679,7 +775,22 @@ Value classPrototypeForObject(Runtime& runtime) { return prototypeValue; } } - return bridge_->findClassPrototype(runtime, object_getClass(object_)); + Value prototypeValue = + bridge_->findClassPrototype(runtime, object_getClass(object_)); + if (prototypeValue.isObject()) { + return prototypeValue; + } + // Fallback for classes only known by symbol name (not yet indexed by + // runtime pointer — RN disables eager runtime-pointer indexing). + if (const NativeApiSymbol* symbol = + bridge_->findClassForRuntimeClass(object_getClass(object_))) { + prototypeValue = bridge_->findClassPrototype( + runtime, objc_lookUpClass(symbol->runtimeName.c_str())); + if (prototypeValue.isObject()) { + return prototypeValue; + } + } + return Value::undefined(); } Value engineThisValueForObject(Runtime& runtime) { @@ -835,6 +946,13 @@ Value get(Runtime& runtime, const PropNameID& name) override { if (!expando.isUndefined()) { return expando; } + // If this receiver is a UIAppearance proxy, its properties live in the + // class-keyed appearance cache, not on the object itself. + Value appearanceExpando = + cachedAppearanceProxyPropertyValue(runtime, bridge_, object_, property); + if (!appearanceExpando.isUndefined()) { + return appearanceExpando; + } // Fast path: cached metadata property-getter resolution. Skips the // special-name chain + per-access metadata discovery for hot getters @@ -970,8 +1088,15 @@ Value get(Runtime& runtime, const PropNameID& name) override { return makeNativeClassValue(runtime, bridge_, std::move(symbol)); } if (property == "super") { + // A JS-subclass instance dispatches `$base`/super against the ObjC + // class it was constructed to extend, not necessarily the receiver's + // immediate runtime superclass (which can differ, e.g. after a + // preserved initializer self-return re-seated the wrapper). Class dispatchClass = - object_ != nil ? class_getSuperclass(object_getClass(object_)) : Nil; + superDispatchClass_ != Nil + ? superDispatchClass_ + : (object_ != nil ? class_getSuperclass(object_getClass(object_)) + : Nil); return Object::createFromHostObject( runtime, std::make_shared(bridge_, object_, @@ -1222,20 +1347,50 @@ throw JSError( // methods); defer so the engine resolves them instead of the bridge // returning a registered getter IMP as a raw callable. if (isEngineExtendedInstance) { -#ifdef NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE - // Engines whose exotic property handler invokes prototype accessors with - // the wrong receiver need the JS-prototype getter resolved here with this - // instance as the receiver. + // Prefer JS prototype accessors before falling back to runtime ObjC + // getters; otherwise an ObjC getter implemented by the JS subclass can + // re-enter the same JS accessor recursively. bool found = false; Value resolved = resolveEnginePrototypeGetter(runtime, property, &found); if (found) { return resolved; } -#endif if (auto selector = runtimeReadablePropertyGetter(object_, property)) { return callObjectSelector(runtime, *selector, nullptr, nullptr, 0); } + // Inherited ObjC selectors on a ClassBuilder subclass have no JS + // prototype entry (the engine may register an empty prototype for the + // subclass) and no runtime ObjC property, so the getter probes above + // miss. Fall through to metadata METHOD resolution using the nearest + // metadata ancestor (findClassForRuntimeClass walks up from the concrete + // subclass, which itself carries no metadata) so first-access inherited + // selectors resolve as bound selector-group functions instead of hard- + // returning undefined. This mirrors the non-extended method path above. + // Property accessors stay deferred here on purpose (accessor shadowing / + // reentry suppression), so resolve METHODS ONLY — JS-overridden methods + // are already handled earlier via the JS prototype chain. + if (const NativeApiSymbol* symbol = + bridge_->findClassForRuntimeClass(object_getClass(object_))) { + const auto& members = bridge_->membersForClass(*symbol); + if (hasMethodMember(members, property, false)) { + auto selectors = + selectorGroupEntriesForMethod(members, property, false); + if (selectors != nullptr) { + auto preparedInvocations = std::make_shared>>( + selectors->size()); + Value methodFunction = CreateNativeApiBoundSelectorGroupFunction( + runtime, bridge_, object_getClass(object_), shared_from_this(), + selectors, preparedInvocations); + // Cache the resolved host function so repeated method access does + // not reallocate it on every call (hot path). + bridge_->setObjectExpando(runtime, object_, property, + methodFunction); + return methodFunction; + } + } + } return Value::undefined(); } @@ -1290,6 +1445,54 @@ NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value throw JSError(runtime, "Cannot set property on nil object."); } + bool isEngineExtendedInstance = + class_conformsToProtocol(object_getClass(object_), + @protocol(NativeApiClassBuilderProtocol)); + // A JS accessor override must win over a native ObjC setter: try it + // first, before any of the metadata/runtime setter paths below. + if (isEngineExtendedInstance) { + if (invokeEnginePrototypeSetter(runtime, property, value)) { + NATIVE_API_SET_RETURN(true); + } + } + + // If this receiver is a UIAppearance proxy, its properties are recorded + // in the class-keyed appearance cache, not set through the metadata/ + // runtime setter paths below (an appearance proxy setter doesn't + // reliably support read-your-write, so we cache it ourselves too). + if (Class appearanceClass = + taggedAppearanceProxyClass(runtime, bridge_, object_)) { + if (const NativeApiSymbol* symbol = + bridge_->findClassForRuntimeClass(appearanceClass)) { + const auto& members = bridge_->membersForClass(*symbol); + if (const NativeApiMember* propertyMember = + selectAppearanceProxyPropertyMember(members, property)) { + if (propertyMember->readonly) { + throw JSError( + runtime, "Attempted to assign to readonly property."); + } + Value args[] = {Value(runtime, value)}; + if (!propertyMember->setterSelectorName.empty()) { + NativeApiMember setterMember = *propertyMember; + setterMember.selectorName = propertyMember->setterSelectorName; + setterMember.signatureOffset = propertyMember->setterSignatureOffset; + callObjCSelector(runtime, bridge_, object_, false, + setterMember.selectorName, &setterMember, args, 1); + } else if (auto setterSelectorName = + runtimeWritablePropertySetter(object_, property)) { + callObjCSelector(runtime, bridge_, object_, false, + *setterSelectorName, nullptr, args, 1); + } else { + throw JSError( + runtime, "UIAppearance property setter is unavailable."); + } + cacheAppearanceProxyPropertyValue(runtime, bridge_, object_, + property, value); + NATIVE_API_SET_RETURN(true); + } + } + } + if (const NativeApiSymbol* symbol = bridge_->findClassForRuntimeClass(object_getClass(object_))) { const auto& members = bridge_->membersForClass(*symbol); @@ -1305,6 +1508,8 @@ throw JSError( Value args[] = {Value(runtime, value)}; callObjCSelector(runtime, bridge_, object_, false, setterMember.selectorName, &setterMember, args, 1); + cacheAppearanceProxyPropertyValue(runtime, bridge_, object_, property, + value); NATIVE_API_SET_RETURN(true); } } @@ -1314,14 +1519,22 @@ throw JSError( Value args[] = {Value(runtime, value)}; callObjCSelector(runtime, bridge_, object_, false, *setterSelectorName, nullptr, args, 1); + cacheAppearanceProxyPropertyValue(runtime, bridge_, object_, property, + value); + // The property was set through a runtime-discovered setter, but the + // GET path may not find a matching readable getter (e.g. a + // write-only or asymmetrically-named property) — write an expando + // too so a subsequent get() still sees this value. + if (!objectGetPathCanReadRuntimeProperty(object_, property)) { + bridge_->setObjectExpando(runtime, object_, property, value); + } NATIVE_API_SET_RETURN(true); } // For JS-subclassed instances, an unknown property is owned by the JS // prototype (e.g. a JS-defined accessor); defer so the engine runs it instead of // shadowing it with a bridge expando. - if (class_conformsToProtocol(object_getClass(object_), - @protocol(NativeApiClassBuilderProtocol))) { + if (isEngineExtendedInstance) { #ifdef NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE // Engines whose exotic property storage doesn't fall back to own // properties need the JS-owned set resolved here: invoke a JS-prototype @@ -1333,6 +1546,10 @@ throw JSError( } NATIVE_API_SET_RETURN(true); #else + // The prototype-setter attempt at the top of set() already ran and + // didn't return — reaching here means no JS setter fired, so store the + // expando unconditionally instead of re-probing for one. + storeOwnExpando(runtime, property, value); NATIVE_API_SET_RETURN(false); #endif } @@ -1364,5 +1581,39 @@ throw JSError( bool ownsObject_ = false; bool wrapperRetainedObject_ = false; bool consumed_ = false; + // Set when this wrapper represents a JS-subclass instance whose `$base`/ + // super dispatch must resolve against a specific ObjC superclass (rather + // than the receiver's own class) — see the "super" property handling in + // get() below. + Class superDispatchClass_ = Nil; std::shared_ptr lifetimeState_; }; + +// Tags a `[SomeClass appearance]`-family call's result (a wrapped +// UIAppearance proxy) with the class it proxies for, and installs the +// property accessors that make it behave like a real object rather than +// requiring `.invoke(...)`. Lives here (not host_objects/Appearance.mm) +// because it needs the complete NativeApiObjectHostObject type; Class.mm, +// Protocol.mm, Invocation.mm, and the per-engine selector-group code all +// consume it and are included later in the bridge. +Value tagStaticAppearanceSelectorResult( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, bool receiverIsClass, const std::string& selectorName, + Value result) { + if (!isStaticAppearanceSelector(receiverIsClass, selectorName) || + !result.isObject()) { + return result; + } + Object resultObject = result.asObject(runtime); + if (!resultObject.isHostObject(runtime)) { + return result; + } + Class customizableClass = tagStaticAppearanceNativeResult( + runtime, bridge, static_cast(receiver), + resultObject.getHostObject(runtime)->object()); + installAppearanceProxyPropertyAccessors( + runtime, bridge, customizableClass, + resultObject.getHostObject(runtime)->object(), + resultObject); + return result; +} diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm index 5c689f627..cf480883b 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm @@ -210,6 +210,11 @@ Value makeProtocolPropertyGetter(Runtime& runtime, NativeApiMember member, throw JSError( runtime, "Protocol property requires a native receiver."); } + Value appearanceExpando = cachedAppearanceProxyPropertyValue( + runtime, bridge, receiver, member.name); + if (!appearanceExpando.isUndefined()) { + return appearanceExpando; + } NativeApiMember getterMember = member; if (auto selector = respondingPropertyGetterSelector( receiver, member.name, member.selectorName)) { @@ -255,9 +260,12 @@ throw JSError( NativeApiMember setterMember = member; setterMember.selectorName = member.setterSelectorName; setterMember.signatureOffset = member.setterSignatureOffset; - return callObjCSelector(runtime, bridge, receiver, receiverIsClass, - setterMember.selectorName, &setterMember, - args, 1); + Value result = callObjCSelector( + runtime, bridge, receiver, receiverIsClass, + setterMember.selectorName, &setterMember, args, 1); + cacheAppearanceProxyPropertyValue(runtime, bridge, receiver, + member.name, args[0]); + return result; }); } diff --git a/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm b/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm index e6ff8f37a..bb34dd398 100644 --- a/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm +++ b/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm @@ -10,6 +10,106 @@ Value valueFromLocal(Runtime& runtime, v8::Local value) { return Value(runtime, value); } +// V8's named-property interceptors run BEFORE prototype-chain lookup, so a +// JS-subclass accessor defined on the prototype (not the host object itself) +// would otherwise be shadowed by the interceptor. These let the get/set +// interceptors below check the prototype chain first and defer to it when a +// descriptor is found there. +bool findPrototypeDescriptor(Runtime& runtime, v8::Local object, + v8::Local property, + v8::Local* descriptorOut) { + v8::TryCatch tryCatch(runtime.isolate()); + v8::Local currentValue = object->GetPrototypeV2(); + for (size_t depth = 0; depth < 64 && currentValue->IsObject(); depth++) { + v8::Local current = currentValue.As(); + v8::Local descriptorValue; + if (!current->GetOwnPropertyDescriptor(runtime.context(), property) + .ToLocal(&descriptorValue)) { + throw JSError(runtime, + currentExceptionMessage(runtime.isolate(), tryCatch)); + } + if (descriptorValue->IsObject()) { + *descriptorOut = descriptorValue.As(); + return true; + } + currentValue = current->GetPrototypeV2(); + } + return false; +} + +bool tryResolvePrototypeGet(Runtime& runtime, v8::Local object, + v8::Local receiver, + v8::Local property, + v8::Local* resultOut) { + v8::Local descriptor; + if (!findPrototypeDescriptor(runtime, object, property, &descriptor)) { + return false; + } + + v8::TryCatch tryCatch(runtime.isolate()); + v8::Local getKey = makeV8String(runtime.isolate(), "get"); + v8::Local getterValue; + if (!descriptor->Get(runtime.context(), getKey).ToLocal(&getterValue)) { + throw JSError(runtime, currentExceptionMessage(runtime.isolate(), tryCatch)); + } + if (getterValue->IsFunction()) { + v8::Local result; + if (!getterValue.As() + ->Call(runtime.context(), receiver, 0, nullptr) + .ToLocal(&result)) { + throw JSError(runtime, + currentExceptionMessage(runtime.isolate(), tryCatch)); + } + *resultOut = result; + return true; + } + + v8::Local valueKey = makeV8String(runtime.isolate(), "value"); + bool hasValue = + descriptor->HasOwnProperty(runtime.context(), valueKey).FromMaybe(false); + if (hasValue) { + v8::Local value; + if (!descriptor->Get(runtime.context(), valueKey).ToLocal(&value)) { + throw JSError(runtime, + currentExceptionMessage(runtime.isolate(), tryCatch)); + } + *resultOut = value; + return true; + } + + *resultOut = v8::Undefined(runtime.isolate()); + return true; +} + +bool tryInvokePrototypeSetter(Runtime& runtime, v8::Local object, + v8::Local receiver, + v8::Local property, + v8::Local value) { + v8::Local descriptor; + if (!findPrototypeDescriptor(runtime, object, property, &descriptor)) { + return false; + } + + v8::TryCatch tryCatch(runtime.isolate()); + v8::Local setKey = makeV8String(runtime.isolate(), "set"); + v8::Local setterValue; + if (!descriptor->Get(runtime.context(), setKey).ToLocal(&setterValue)) { + throw JSError(runtime, currentExceptionMessage(runtime.isolate(), tryCatch)); + } + if (!setterValue->IsFunction()) { + return false; + } + + v8::Local args[] = {value}; + v8::Local ignored; + if (!setterValue.As() + ->Call(runtime.context(), receiver, 1, args) + .ToLocal(&ignored)) { + throw JSError(runtime, currentExceptionMessage(runtime.isolate(), tryCatch)); + } + return true; +} + v8::Local hostObjectTemplate(Runtime& runtime) { auto state = runtime.state(); if (state->hostObjectTemplate.IsEmpty()) { @@ -214,6 +314,15 @@ if (*utf8 == nullptr) { return v8::Intercepted::kNo; } + v8::Local holderObject = info.Holder(); + v8::Local receiver = + info.This()->IsObject() ? info.This().As() : holderObject; + v8::Local prototypeResult; + if (tryResolvePrototypeGet(runtime, holderObject, receiver, + property, &prototypeResult)) { + info.GetReturnValue().Set(prototypeResult); + return v8::Intercepted::kYes; + } Value result = holder->hostObject->get( runtime, PropNameID(std::string(*utf8, utf8.length()))); if (!result.isUndefined()) { @@ -243,6 +352,13 @@ if (*utf8 == nullptr) { return v8::Intercepted::kNo; } + v8::Local holderObject = info.Holder(); + v8::Local receiver = + info.This()->IsObject() ? info.This().As() : holderObject; + if (tryInvokePrototypeSetter(runtime, holderObject, receiver, + property, value)) { + return v8::Intercepted::kYes; + } bool handled = holder->hostObject->set( runtime, PropNameID(std::string(*utf8, utf8.length())), Value(runtime, value)); diff --git a/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm b/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm index d4928e766..259df2425 100644 --- a/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm +++ b/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm @@ -223,9 +223,12 @@ void NativeApiSelectorGroupCallback( // Inline GSD fast path: skip the setV8EnginePreparedObjCResult call and its // argument-count/NSError preamble entirely for the common case. The // generated invoker reads args, calls objc_msgSend, and sets the return. + // Excludes appearance static selectors (gsdAllowed) — those need the + // generic path's proxy tagging. if (call.prepared->gsdEngineCallable && call.dispatchClass == Nil && !call.prepared->isInitMethod && - count == call.prepared->gsdEngineArgumentCount) { + count == call.prepared->gsdEngineArgumentCount && + call.gsdAllowed) { auto invoker = reinterpret_cast(call.prepared->engineInvoker); GsdObjCContext ctx{runtime, @@ -237,6 +240,12 @@ void NativeApiSelectorGroupCallback( runtime.context(), call.prepared->signature.returnType}; if (invoker(ctx)) { + if (count > 0) { + Value setterValue = Value::borrowed(runtime, info[0]); + cachePreparedAppearanceProxySetterValue(runtime, data->bridge, + call.receiver, *call.prepared, + &setterValue, 1); + } return; } } @@ -244,6 +253,14 @@ void NativeApiSelectorGroupCallback( runtime, data->bridge, call.receiver, *call.prepared, call.receiverHostObject, call.initializerClassWrapper, info, call.dispatchClass); + if (!data->receiverIsClass && call.prepared->isInitMethod) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, data->bridge, call.receiver, + Value(runtime, info.GetReturnValue().Get()), + Value(runtime, info.This()))) { + info.GetReturnValue().Set(preserved->local(runtime)); + } + } } catch (const std::exception& exception) { engine::v8engine::throwV8Exception(info.GetIsolate(), exception); } diff --git a/NativeScript/runtime/apple/NativeScript.mm b/NativeScript/runtime/apple/NativeScript.mm index 9f026a241..0e3e41730 100644 --- a/NativeScript/runtime/apple/NativeScript.mm +++ b/NativeScript/runtime/apple/NativeScript.mm @@ -1,10 +1,12 @@ #include "NativeScript.h" #include "Runtime.h" #include "RuntimeConfig.h" +#include "cli/BundleLoader.h" #include "runtime/apple/NativeScriptException.h" #include "ffi/objc/shared/Tasks.h" #include "js_native_api.h" #include "jsr.h" +#include using namespace nativescript; @@ -21,7 +23,18 @@ @implementation NativeScript extern char defaultStartOfMetadataSection __asm("section$start$__DATA$__TNSMetadata"); -std::unique_ptr runtime_; +// Raw pointer, not unique_ptr: at process exit, static-destruction order is +// unspecified relative to the ObjC runtime/other statics, so an implicit +// unique_ptr destructor can run after dependencies it needs are already torn +// down. resetRuntime() gives us an explicit, ordered teardown point, and +// restartWithConfig: needs the old runtime to stay alive until the new one +// has finished Init() (see below). +Runtime* runtime_ = nullptr; + +static void resetRuntime() { + delete runtime_; + runtime_ = nullptr; +} - (void)runScriptString:(NSString*)script runLoop:(BOOL)runLoop { std::string cppScript = [script UTF8String]; @@ -33,10 +46,16 @@ - (void)runScriptString:(NSString*)script runLoop:(BOOL)runLoop { } - (void)runMainApplication { - // Boot from the application directory so the entry resolves through its - // package.json "main" (falling back to index.*) — a literal index.js is not - // guaranteed to exist (CLI-built apps ship bundle.js). - std::string spec = RuntimeConfig.ApplicationPath; + // Try CLI/test-runner bundle resolution first (bundle resourcePath, + // executable-relative Contents/Resources, argv[0], _NSGetExecutablePath, + // cwd); fall back to the configured application directory so the entry + // resolves through its package.json "main" (falling back to index.*) — a + // literal index.js is not guaranteed to exist (CLI-built apps ship + // bundle.js). + std::string spec = resolveMainPath(); + if (spec.empty()) { + spec = RuntimeConfig.ApplicationPath; + } try { runtime_->RunModule(spec); } catch (const NativeScriptException& e) { @@ -121,7 +140,7 @@ - (bool)liveSync { } - (void)shutdownRuntime { - runtime_ = nullptr; + resetRuntime(); } - (instancetype)initWithConfig:(Config*)config { @@ -157,10 +176,15 @@ - (instancetype)initWithConfig:(Config*)config { RuntimeConfig.LogToSystemConsole = [config LogToSystemConsole]; RuntimeConfig.CustomLogCallback = [config CustomLogCallback]; - runtime_ = std::make_unique(); + // Build and Init the new runtime before tearing down the old one — the + // old runtime (and anything it holds live, e.g. in-flight callbacks) + // must outlive the new runtime's Init() on restartWithConfig:. + std::unique_ptr runtime(new Runtime()); // TODO: separate runtime init and measure the time - runtime_->Init(); + runtime->Init(); + resetRuntime(); + runtime_ = runtime.release(); if (RuntimeConfig.IsDebug) { // TODO: Inspector for debugging diff --git a/NativeScript/runtime/apple/ThreadSafeFunction.mm b/NativeScript/runtime/apple/ThreadSafeFunction.mm index 336057b65..ae1aa7098 100644 --- a/NativeScript/runtime/apple/ThreadSafeFunction.mm +++ b/NativeScript/runtime/apple/ThreadSafeFunction.mm @@ -98,10 +98,20 @@ typedef void(NAPI_CDECL* napi_async_cleanup_hook)( bool draining_async_hooks = false; }; -static std::mutex g_cleanup_hooks_mutex; -static std::condition_variable g_cleanup_hooks_cv; -static std::unordered_map - g_cleanup_hooks; +static std::mutex& CleanupHooksMutex() { + static auto* mutex = new std::mutex(); + return *mutex; +} + +static std::condition_variable& CleanupHooksCV() { + static auto* cv = new std::condition_variable(); + return *cv; +} + +static std::unordered_map& CleanupHooks() { + static auto* hooks = new std::unordered_map(); + return *hooks; +} static bool IsCleanupStateEmpty(const EnvCleanupState& state) { return state.env_hooks.empty() && state.async_hooks.empty() && @@ -109,9 +119,10 @@ static bool IsCleanupStateEmpty(const EnvCleanupState& state) { } static void EraseCleanupStateIfUnused(node_api_basic_env env) { - auto it = g_cleanup_hooks.find(env); - if (it != g_cleanup_hooks.end() && IsCleanupStateEmpty(it->second)) { - g_cleanup_hooks.erase(it); + auto& cleanupHooks = CleanupHooks(); + auto it = cleanupHooks.find(env); + if (it != cleanupHooks.end() && IsCleanupStateEmpty(it->second)) { + cleanupHooks.erase(it); } } @@ -472,8 +483,8 @@ static void ExecuteTSFNCall(const std::shared_ptr& call) { return napi_invalid_arg; } - std::lock_guard lock(g_cleanup_hooks_mutex); - auto& state = g_cleanup_hooks[env]; + std::lock_guard lock(CleanupHooksMutex()); + auto& state = CleanupHooks()[env]; state.env_hooks.emplace_back(fun, arg); return napi_ok; } @@ -490,9 +501,10 @@ napi_status js_add_env_cleanup_hook(napi_env env, js_env_cleanup_hook hook, return napi_invalid_arg; } - std::lock_guard lock(g_cleanup_hooks_mutex); - auto it = g_cleanup_hooks.find(env); - if (it == g_cleanup_hooks.end()) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto it = cleanupHooks.find(env); + if (it == cleanupHooks.end()) { return napi_invalid_arg; } @@ -526,8 +538,8 @@ napi_status js_remove_env_cleanup_hook(napi_env env, js_env_cleanup_hook hook, handle->hook = hook; handle->data = arg; - std::lock_guard lock(g_cleanup_hooks_mutex); - auto& state = g_cleanup_hooks[env]; + std::lock_guard lock(CleanupHooksMutex()); + auto& state = CleanupHooks()[env]; state.async_hooks.push_back(handle); if (remove_handle != nullptr) { *remove_handle = handle; @@ -544,9 +556,10 @@ napi_status js_remove_env_cleanup_hook(napi_env env, js_env_cleanup_hook hook, auto* handle = static_cast(remove_handle); node_api_basic_env env = handle->env; - std::lock_guard lock(g_cleanup_hooks_mutex); - auto it = g_cleanup_hooks.find(env); - if (it == g_cleanup_hooks.end() || handle->removed) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto it = cleanupHooks.find(env); + if (it == cleanupHooks.end() || handle->removed) { return napi_invalid_arg; } @@ -563,7 +576,7 @@ napi_status js_remove_env_cleanup_hook(napi_env env, js_env_cleanup_hook hook, if (state.draining_async_hooks) { state.deferred_delete_async_hooks.push_back(handle); if (state.async_hooks.empty()) { - g_cleanup_hooks_cv.notify_all(); + CleanupHooksCV().notify_all(); } } else { delete handle; @@ -581,9 +594,10 @@ void js_run_env_cleanup_hooks(napi_env env) { std::vector> env_hooks_to_run; std::vector async_hooks_to_run; { - std::lock_guard lock(g_cleanup_hooks_mutex); - auto state_it = g_cleanup_hooks.find(env); - if (state_it == g_cleanup_hooks.end()) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + if (state_it == cleanupHooks.end()) { return; } @@ -606,9 +620,10 @@ void js_run_env_cleanup_hooks(napi_env env) { void* data = nullptr; bool should_invoke = false; { - std::lock_guard lock(g_cleanup_hooks_mutex); - auto state_it = g_cleanup_hooks.find(env); - if (state_it == g_cleanup_hooks.end()) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + if (state_it == cleanupHooks.end()) { break; } @@ -629,15 +644,17 @@ void js_run_env_cleanup_hooks(napi_env env) { std::vector handles_to_delete; { - std::unique_lock lock(g_cleanup_hooks_mutex); - g_cleanup_hooks_cv.wait(lock, [&]() { - auto state_it = g_cleanup_hooks.find(env); - return state_it == g_cleanup_hooks.end() || + std::unique_lock lock(CleanupHooksMutex()); + CleanupHooksCV().wait(lock, [&]() { + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + return state_it == cleanupHooks.end() || state_it->second.async_hooks.empty(); }); - auto state_it = g_cleanup_hooks.find(env); - if (state_it == g_cleanup_hooks.end()) { + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + if (state_it == cleanupHooks.end()) { return; } @@ -646,7 +663,7 @@ void js_run_env_cleanup_hooks(napi_env env) { handles_to_delete.swap(state.deferred_delete_async_hooks); if (IsCleanupStateEmpty(state)) { - g_cleanup_hooks.erase(state_it); + cleanupHooks.erase(state_it); } } diff --git a/packages/objc-node-api/index.d.ts b/packages/objc-node-api/index.d.ts index ad491cde3..e6eef9d9c 100644 --- a/packages/objc-node-api/index.d.ts +++ b/packages/objc-node-api/index.d.ts @@ -122,6 +122,16 @@ declare global { export type Enum<_T extends Record> = number; + export type AssociationPolicy = + | "assign" + | "retain" + | "retainNonatomic" + | "strong" + | "strongNonatomic" + | "copy" + | "copyNonatomic" + | number; + export function addMethod< T extends abstract new (...args: unknown[]) => unknown, >( @@ -137,6 +147,16 @@ declare global { export function sizeof(obj: unknown): number; export function alloc(size: number): Pointer; export function handleof(obj: unknown): Pointer; + export function setAssociatedObject( + target: NativeObject | Pointer | string | number, + key: string, + value: unknown, + policy?: AssociationPolicy, + ): void; + export function getAssociatedObject( + target: NativeObject | Pointer | string | number | null | undefined, + key: string, + ): T | null; export function bufferFromData(data: NativeObject): ArrayBuffer; } }