Files
UnrealEngine/Engine/Source/Runtime/WebBrowser/Private/Native/NativeJSScripting.cpp
2025-05-18 13:04:45 +08:00

655 lines
20 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "NativeJSScripting.h"
#include "NativeJSStructSerializerBackend.h"
#include "NativeJSStructDeserializerBackend.h"
#include "StructSerializer.h"
#include "StructDeserializer.h"
#include "UObject/UnrealType.h"
#include "NativeWebBrowserProxy.h"
namespace NativeFuncs
{
const FString ExecuteMethodCommand = TEXT("ExecuteUObjectMethod");
typedef TSharedRef<TJsonWriter<>> FJsonWriterRef;
template<typename ValueType> void WriteValue(FJsonWriterRef Writer, const FString& Key, const ValueType& Value)
{
Writer->WriteValue(Key, Value);
}
void WriteNull(FJsonWriterRef Writer, const FString& Key)
{
Writer->WriteNull(Key);
}
void WriteArrayStart(FJsonWriterRef Writer, const FString& Key)
{
Writer->WriteArrayStart(Key);
}
void WriteObjectStart(FJsonWriterRef Writer, const FString& Key)
{
Writer->WriteObjectStart(Key);
}
void WriteRaw(FJsonWriterRef Writer, const FString& Key, const FString& Value)
{
Writer->WriteRawJSONValue(Key, Value);
}
template<typename ValueType> void WriteValue(FJsonWriterRef Writer, const int, const ValueType& Value)
{
Writer->WriteValue(Value);
}
void WriteNull(FJsonWriterRef Writer, int)
{
Writer->WriteNull();
}
void WriteArrayStart(FJsonWriterRef Writer, int)
{
Writer->WriteArrayStart();
}
void WriteObjectStart(FJsonWriterRef Writer, int)
{
Writer->WriteObjectStart();
}
void WriteRaw(FJsonWriterRef Writer, int, const FString& Value)
{
Writer->WriteRawJSONValue(Value);
}
template<typename KeyType>
bool WriteJsParam(FNativeJSScriptingRef Scripting, FJsonWriterRef Writer, const KeyType& Key, FWebJSParam& Param)
{
switch (Param.Tag)
{
case FWebJSParam::PTYPE_NULL:
WriteNull(Writer, Key);
break;
case FWebJSParam::PTYPE_BOOL:
WriteValue(Writer, Key, Param.BoolValue);
break;
case FWebJSParam::PTYPE_DOUBLE:
WriteValue(Writer, Key, Param.DoubleValue);
break;
case FWebJSParam::PTYPE_INT:
WriteValue(Writer, Key, Param.IntValue);
break;
case FWebJSParam::PTYPE_STRING:
WriteValue(Writer, Key, *Param.StringValue);
break;
case FWebJSParam::PTYPE_OBJECT:
{
if (Param.ObjectValue == nullptr)
{
WriteNull(Writer, Key);
}
else
{
FString ConvertedObject = Scripting->ConvertObject(Param.ObjectValue);
WriteRaw(Writer, Key, ConvertedObject);
}
break;
}
case FWebJSParam::PTYPE_STRUCT:
{
FString ConvertedStruct = Scripting->ConvertStruct(Param.StructValue->GetTypeInfo(), Param.StructValue->GetData());
WriteRaw(Writer, Key, ConvertedStruct);
break;
}
case FWebJSParam::PTYPE_ARRAY:
{
WriteArrayStart(Writer, Key);
for(int i=0; i < Param.ArrayValue->Num(); ++i)
{
WriteJsParam(Scripting, Writer, i, (*Param.ArrayValue)[i]);
}
Writer->WriteArrayEnd();
break;
}
case FWebJSParam::PTYPE_MAP:
{
WriteObjectStart(Writer, Key);
for(auto& Pair : *Param.MapValue)
{
WriteJsParam(Scripting, Writer, *Pair.Key, Pair.Value);
}
Writer->WriteObjectEnd();
break;
}
default:
return false;
}
return true;
}
}
FString GetObjectPostInitScript(const FString& Name, const FString& FullyQualifiedName)
{
return FString::Printf(TEXT("(function(){document.dispatchEvent(new CustomEvent('%s:ready', {details: %s}));})();"), *Name, *FullyQualifiedName);
}
void FNativeJSScripting::BindUObject(const FString& Name, UObject* Object, bool bIsPermanent )
{
const FString ExposedName = GetBindingName(Name, Object);
FString Converted = ConvertObject(Object);
if (bIsPermanent)
{
// Existing permanent objects must be removed first and each object can only have one permanent binding
if (PermanentUObjectsByName.Contains(ExposedName) || BoundObjects[Object].bIsPermanent)
{
return;
}
BoundObjects[Object]={true, -1};
PermanentUObjectsByName.Add(ExposedName, Object);
}
if(!bLoaded)
{
PageLoaded();
}
else
{
const FString& EscapedName = ExposedName.ReplaceCharWithEscapedChar();
FString SetValueScript = FString::Printf(TEXT("window.ue['%s'] = %s;"), *EscapedName, *Converted);
SetValueScript.Append(GetObjectPostInitScript(EscapedName, FString::Printf(TEXT("window.ue['%s']"), *EscapedName)));
ExecuteJavascript(SetValueScript);
}
}
void FNativeJSScripting::ExecuteJavascript(const FString& Javascript)
{
TSharedPtr<FNativeWebBrowserProxy> Window = WindowPtr.Pin();
if (Window.IsValid())
{
Window->ExecuteJavascript(Javascript);
}
}
void FNativeJSScripting::UnbindUObject(const FString& Name, UObject* Object, bool bIsPermanent)
{
const FString ExposedName = GetBindingName(Name, Object);
if (bIsPermanent)
{
// If overriding an existing permanent object, make it non-permanent
if (PermanentUObjectsByName.Contains(ExposedName) && (Object == nullptr || PermanentUObjectsByName[ExposedName] == Object))
{
Object = PermanentUObjectsByName.FindAndRemoveChecked(ExposedName);
BoundObjects.Remove(Object);
return;
}
else
{
return;
}
}
FString DeleteValueScript = FString::Printf(TEXT("delete window.ue['%s'];"), *ExposedName.ReplaceCharWithEscapedChar());
ExecuteJavascript(DeleteValueScript);
}
int32 ParseParams(const FString& ParamStr, TArray<FString>& OutArray)
{
OutArray.Reset();
const TCHAR *Start = *ParamStr;
if (Start && *Start != TEXT('\0'))
{
int32 DelimLimit = 4;
while (const TCHAR *At = FCString::Strstr(Start, TEXT("/")))
{
OutArray.Emplace(FString::ConstructFromPtrSize(Start, At - Start));
Start = At + 1;
if (--DelimLimit == 0)
{
break;
}
}
if (*Start)
{
OutArray.Emplace(Start);
}
}
return OutArray.Num();
}
bool FNativeJSScripting::OnJsMessageReceived(const FString& Message)
{
check(IsInGameThread());
bool Result = false;
TArray<FString> Params;
if (ParseParams(Message, Params))
{
FString Command = Params[0];
Params.RemoveAt(0, 1);
if (Command == NativeFuncs::ExecuteMethodCommand)
{
Result = HandleExecuteUObjectMethodMessage(Params);
}
}
return Result;
}
FString FNativeJSScripting::ConvertStruct(UStruct* TypeInfo, const void* StructPtr)
{
TArray<uint8> ReturnBuffer;
FMemoryWriter Writer(ReturnBuffer);
FNativeJSStructSerializerBackend ReturnBackend = FNativeJSStructSerializerBackend(SharedThis(this), Writer);
FStructSerializer::Serialize(StructPtr, *TypeInfo, ReturnBackend);
// Extract the result value from the serialized JSON object:
ReturnBuffer.Add(0);
ReturnBuffer.Add(0); // Add two as we're dealing with UTF-16, so 2 bytes
return UTF16_TO_TCHAR((UTF16CHAR*)ReturnBuffer.GetData());
}
FString FNativeJSScripting::ConvertObject(UObject* Object)
{
RetainBinding(Object);
UClass* Class = Object->GetClass();
bool first = true;
FString Result = TEXT("(function(){ return Object.create({");
for (TFieldIterator<UFunction> FunctionIt(Class, EFieldIteratorFlags::IncludeSuper); FunctionIt; ++FunctionIt)
{
UFunction* Function = *FunctionIt;
if(!first)
{
Result.Append(TEXT(","));
}
else
{
first = false;
}
Result.Append(*GetBindingName(Function));
Result.Append(TEXT(": function "));
Result.Append(*GetBindingName(Function));
Result.Append(TEXT(" ("));
bool firstArg = true;
for ( TFieldIterator<FProperty> It(Function); It; ++It )
{
FProperty* Param = *It;
if (Param->PropertyFlags & CPF_Parm && ! (Param->PropertyFlags & CPF_ReturnParm) )
{
FStructProperty *StructProperty = CastField<FStructProperty>(Param);
if (!StructProperty || !StructProperty->Struct->IsChildOf(FWebJSResponse::StaticStruct()))
{
if(!firstArg)
{
Result.Append(TEXT(", "));
}
else
{
firstArg = false;
}
Result.Append(*GetBindingName(Param));
}
}
}
Result.Append(TEXT(")"));
// We hijack the RPCResponseId and use it for our priority value. 0 means it has not been assigned and we default to 2. 1-5 is high-low priority which we map to the 0-4 range used by EmbeddedCommunication.
int32 Priority = Function->RPCResponseId == 0 ? 2 : FMath::Clamp((int32)Function->RPCResponseId, 1, 5) - 1;
Result.Append(TEXT(" {return window.ue.$.executeMethod('"));
Result.Append(FString::FromInt(Priority));
Result.Append(TEXT("',this.$id, arguments)}"));
}
Result.Append(TEXT("},{"));
Result.Append(TEXT("$id: {writable: false, configurable:false, enumerable: false, value: '"));
Result.Append(*PtrToGuid(Object).ToString(EGuidFormats::Digits));
Result.Append(TEXT("'}})})()"));
return Result;
}
void FNativeJSScripting::InvokeJSFunction(FGuid FunctionId, int32 ArgCount, FWebJSParam Arguments[], bool bIsError)
{
if (!IsValid())
{
return;
}
FString CallbackScript = FString::Printf(TEXT("window.ue.$.invokeCallback('%s', %s, "), *FunctionId.ToString(EGuidFormats::Digits), (bIsError) ? TEXT("true") : TEXT("false"));
{
TArray<uint8> Buffer;
FMemoryWriter MemoryWriter(Buffer);
NativeFuncs::FJsonWriterRef JsonWriter = TJsonWriter<>::Create(&MemoryWriter);
JsonWriter->WriteArrayStart();
for (int i = 0; i < ArgCount; i++)
{
NativeFuncs::WriteJsParam(SharedThis(this), JsonWriter, i, Arguments[i]);
}
JsonWriter->WriteArrayEnd();
CallbackScript.Append((TCHAR*)Buffer.GetData(), Buffer.Num() / sizeof(TCHAR));
}
CallbackScript.Append(TEXT(")"));
ExecuteJavascript(CallbackScript);
}
void FNativeJSScripting::InvokeJSFunctionRaw(FGuid FunctionId, const FString& RawJSValue, bool bIsError)
{
if (!IsValid())
{
return;
}
FString CallbackScript = FString::Printf(TEXT("window.ue.$.invokeCallback('%s', %s, [%s])"),
*FunctionId.ToString(EGuidFormats::Digits), (bIsError)?TEXT("true"):TEXT("false"), *RawJSValue);
ExecuteJavascript(CallbackScript);
}
void FNativeJSScripting::InvokeJSErrorResult(FGuid FunctionId, const FString& Error)
{
FWebJSParam Args[1] = {FWebJSParam(Error)};
InvokeJSFunction(FunctionId, 1, Args, true);
}
bool FNativeJSScripting::HandleExecuteUObjectMethodMessage(const TArray<FString>& MessageArgs)
{
if (MessageArgs.Num() != 4)
{
return false;
}
const FString& ObjectIdStr = MessageArgs[0];
FGuid ObjectKey;
UObject* Object = nullptr;
if (FGuid::Parse(ObjectIdStr, ObjectKey))
{
Object = GuidToPtr(ObjectKey);
}
else if(PermanentUObjectsByName.Contains(ObjectIdStr))
{
Object = PermanentUObjectsByName[ObjectIdStr];
}
if(Object == nullptr)
{
// Unknown uobject id/name
return false;
}
// Get the promise callback and use that to report any results from executing this function.
FGuid ResultCallbackId;
if (!FGuid::Parse(MessageArgs[1], ResultCallbackId))
{
// Invalid GUID
return false;
}
FName MethodName = FName(*MessageArgs[2]);
UFunction* Function = Object->FindFunction(MethodName);
if (!Function)
{
InvokeJSErrorResult(ResultCallbackId, TEXT("Unknown UObject Function"));
return true;
}
// Coerce arguments to function arguments.
uint16 ParamsSize = Function->ParmsSize;
TArray<uint8> Params;
FProperty* ReturnParam = nullptr;
FProperty* PromiseParam = nullptr;
if (ParamsSize > 0)
{
// Find return parameter and a promise argument if present, as we need to handle them differently
for ( TFieldIterator<FProperty> It(Function); It; ++It )
{
FProperty* Param = *It;
if (Param->PropertyFlags & CPF_Parm)
{
if (Param->PropertyFlags & CPF_ReturnParm)
{
ReturnParam = Param;
}
else
{
FStructProperty *StructProperty = CastField<FStructProperty>(Param);
if (StructProperty && StructProperty->Struct->IsChildOf(FWebJSResponse::StaticStruct()))
{
PromiseParam = Param;
}
}
if (ReturnParam && PromiseParam)
{
break;
}
}
}
// UFunction is a subclass of UStruct, so we can treat the arguments as a struct for deserialization
Params.AddUninitialized(ParamsSize);
Function->InitializeStruct(Params.GetData());
// Note: This is a no-op on platforms that are using a 16-bit TCHAR
FTCHARToUTF16 UTF16String(*MessageArgs[3], MessageArgs[3].Len());
TArray<uint8> JsonData;
JsonData.Append((uint8*)UTF16String.Get(), UTF16String.Length() * sizeof(UTF16CHAR));
FMemoryReader Reader(JsonData);
FNativeJSStructDeserializerBackend Backend = FNativeJSStructDeserializerBackend(SharedThis(this), Reader);
FStructDeserializer::Deserialize(Params.GetData(), *Function, Backend);
}
if (PromiseParam)
{
FWebJSResponse* PromisePtr = PromiseParam->ContainerPtrToValuePtr<FWebJSResponse>(Params.GetData());
if (PromisePtr)
{
*PromisePtr = FWebJSResponse(SharedThis(this), ResultCallbackId);
}
}
Object->ProcessEvent(Function, Params.GetData());
if ( ! PromiseParam ) // If PromiseParam is set, we assume that the UFunction will ensure it is called with the result
{
if ( ReturnParam )
{
FStructSerializerPolicies ReturnPolicies;
ReturnPolicies.PropertyFilter = [&ReturnParam](const FProperty* CandidateProperty, const FProperty* ParentProperty)
{
return ParentProperty != nullptr || CandidateProperty == ReturnParam;
};
TArray<uint8> ReturnBuffer;
FMemoryWriter Writer(ReturnBuffer);
FNativeJSStructSerializerBackend ReturnBackend = FNativeJSStructSerializerBackend(SharedThis(this), Writer);
FStructSerializer::Serialize(Params.GetData(), *Function, ReturnBackend, ReturnPolicies);
// Extract the result value from the serialized JSON object:
ReturnBuffer.Add(0);
ReturnBuffer.Add(0); // Add two as we're dealing with UTF-16, so 2 bytes
const FString ResultJS = UTF16_TO_TCHAR((UTF16CHAR*)ReturnBuffer.GetData());
InvokeJSFunctionRaw(ResultCallbackId, ResultJS, false);
}
else
{
InvokeJSFunction(ResultCallbackId, 0, nullptr, false);
}
}
return true;
}
FString FNativeJSScripting::GetInitializeScript()
{
const FString NativeScriptingInit =
TEXT("(function() {")
TEXT("var util = Object.create({")
// Simple random-based (RFC-4122 version 4) UUID generator.
// Version 4 UUIDs have the form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx where x is any hexadecimal digit and y is one of 8, 9, a, or b
// This function returns the UUID as a hex string without the dashes
TEXT("uuid: function()")
TEXT("{")
TEXT(" var b = new Uint8Array(16); window.crypto.getRandomValues(b);")
TEXT(" b[6] = b[6]&0xf|0x40; b[8]=b[8]&0x3f|0x80;") // Set the reserved bits to the correct values
TEXT(" return Array.prototype.reduce.call(b, function(a,i){return a+((0x100|i).toString(16).substring(1))},'').toUpperCase();")
TEXT("}, ")
// save a callback function in the callback registry
// returns the uuid of the callback for passing to the host application
// ensures that each function object is only stored once.
// (Closures executed multiple times are considered separate objects.)
TEXT("registerCallback: function(callback)")
TEXT("{")
TEXT(" var key;")
TEXT(" for(key in this.callbacks)")
TEXT(" {")
TEXT(" if (!this.callbacks[key].isOneShot && this.callbacks[key].accept === callback)")
TEXT(" {")
TEXT(" return key;")
TEXT(" }")
TEXT(" }")
TEXT(" key = this.uuid();")
TEXT(" this.callbacks[key] = {accept:callback, reject:callback, bIsOneShot:false};")
TEXT(" return key;")
TEXT("}, ")
TEXT("registerPromise: function(accept, reject, name)")
TEXT("{")
TEXT(" var key = this.uuid();")
TEXT(" this.callbacks[key] = {accept:accept, reject:reject, bIsOneShot:true, name:name};")
TEXT(" return key;")
TEXT("}, ")
// strip ReturnValue object wrapper if present
TEXT("returnValToObj: function(args)")
TEXT("{")
TEXT(" return Array.prototype.map.call(args, function(item){return item.ReturnValue || item});")
TEXT("}, ")
// invoke a callback method or promise by uuid
TEXT("invokeCallback: function(key, bIsError, args)")
TEXT("{")
TEXT(" var callback = this.callbacks[key];")
TEXT(" if (typeof callback === 'undefined')")
TEXT(" {")
TEXT(" console.error('Unknown callback id', key);")
TEXT(" return;")
TEXT(" }")
TEXT(" if (callback.bIsOneShot)")
TEXT(" {")
TEXT(" callback.iwanttodeletethis=true;")
TEXT(" delete this.callbacks[key];")
TEXT(" }")
TEXT(" callback[bIsError?'reject':'accept'].apply(window, this.returnValToObj(args));")
TEXT("}, ")
// convert an argument list to a dictionary of arguments.
// The args argument must be an argument object as it uses the callee member to deduce the argument names
TEXT("argsToDict: function(args)")
TEXT("{")
TEXT(" var res = {};")
TEXT(" args.callee.toString().match(/\\((.+?)\\)/)[1].split(/\\s*,\\s*/).forEach(function(name, idx){res[name]=args[idx]});")
TEXT(" return res;")
TEXT("}, ")
// encodes and sends a message to the host application
TEXT("sendMessage: function()")
TEXT("{")
// @todo: Each kairos native browser will have a different way of passing a message out, here we use webkit postmessage but we'll need
// to be aware of our target platform when generating this script and adjust accordingly
TEXT(" var delimiter = '/';")
#if PLATFORM_ANDROID
TEXT(" if(window.JSBridge){")
TEXT(" window.JSBridge.postMessage('', 'browserProxy', 'handlejs', Array.prototype.slice.call(arguments).join(delimiter));")
TEXT(" }")
#else
TEXT(" if(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.browserProxy){")
TEXT(" window.webkit.messageHandlers.browserProxy.postMessage(Array.prototype.slice.call(arguments).join(delimiter));")
TEXT(" }")
#endif
TEXT("}, ")
// custom replacer function passed into JSON.stringify to handle cases where there are function objects in the argument list
// of the executeMethod call. In those cases we want to be able to pass them as callbacks.
TEXT("customReplacer: function(key, value)")
TEXT("{")
TEXT(" if (typeof value === 'function')")
TEXT(" {")
TEXT(" return window.ue.$.registerCallback(value);")
TEXT(" }")
TEXT(" return value;")
TEXT("},")
// uses the above helper methods to execute a method on a uobject instance.
// the method set as callee on args needs to be a named function, as the name of the method to invoke is taken from it
TEXT("executeMethod: function(priority, id, args)")
TEXT("{")
TEXT(" var self = this;") // the closures need access to the outer this object
// Create a promise object to return back to the caller and create a callback function to handle the response
TEXT(" var promiseID;")
TEXT(" var promise = new Promise(function (accept, reject) ")
TEXT(" {")
TEXT(" promiseID = self.registerPromise(accept, reject, args.callee.name)")
TEXT(" });")
// Actually invoke the method by sending a message to the host app
TEXT(" this.sendMessage(priority, '") + NativeFuncs::ExecuteMethodCommand + TEXT("', id, promiseID, args.callee.name, JSON.stringify(this.argsToDict(args), this.customReplacer));")
// Return the promise object to the caller
TEXT(" return promise;")
TEXT("}")
TEXT("},{callbacks: {value:{}}});")
// Create the global window.ue variable
TEXT("window.ue = Object.create({}, {'$': {writable: false, configurable:false, enumerable: false, value:util}});")
TEXT("})();")
;
return NativeScriptingInit;
}
void FNativeJSScripting::PageLoaded()
{
// Expunge temporary objects.
for (decltype(BoundObjects)::TIterator It(BoundObjects); It; ++It)
{
if (!It->Value.bIsPermanent)
{
It.RemoveCurrent();
}
}
FString Script = GetInitializeScript();
for(auto& Item : PermanentUObjectsByName)
{
Script.Append(*FString::Printf(TEXT("window.ue['%s'] = %s;"), *Item.Key.ReplaceCharWithEscapedChar(), *ConvertObject(Item.Value)));
}
// Append postinit for each object we added.
for (auto& Item : PermanentUObjectsByName)
{
const FString& Name = Item.Key.ReplaceCharWithEscapedChar();
Script.Append(GetObjectPostInitScript(Name, FString::Printf(TEXT("window.ue['%s']"), *Name)));
}
// Append postinit for window.ue
Script.Append(GetObjectPostInitScript(TEXT("ue"), TEXT("window.ue")));
bLoaded = true;
ExecuteJavascript(Script);
}
FNativeJSScripting::FNativeJSScripting(bool bJSBindingToLoweringEnabled, TSharedRef<FNativeWebBrowserProxy> Window)
: FWebJSScripting(bJSBindingToLoweringEnabled)
, bLoaded(false)
{
WindowPtr = Window;
}