What happens if you have a Unity WebGL application, and you want the user to be able to load files to the browser app from a file-upload dialog?
This post focuses on text files. For information on how to upload binary files to a Unity WebGL app, see this post.
The last post covered how to let the browser download content generated by a Unity WebGL app. Now we’re going to cover uploading.
The first thing I’m going to do is lay out some constraints and echo references from the last post:
- A Unity WebGL plugin will need to be created to implement this – for WebGL this means creating a *.jslib file in a Plugins folder in your project. For more information, see Unity’s documentation for creating WebGL plugins.
- This article will only cover uploading text content, binary data will not be covered.
- Because of the browser’s security sandbox, websites and JavaScript cannot dig through a user’s file system. All they can do is ask the browser to bring up a dialog for the user to select a file – and then receive the file if the user selects something.
The previous article involved creating DOM objects in JavaScript (through the *.jslib plugin) and letting that do our dirty work. The situation will be the same here, although a bit more involved.
Here’s the Plugin function:
mergeInto(LibraryManager.library, { BrowserTextUpload: function(extFilter, gameObjName, dataSinkFn) { // If this is the first time being called, create the reusable // input DOM object. if(typeof inputLoader == "undefined") { inputLoader = document.createElement("input"); // We need it to be of the "file" type to have to popup dialog // and file uploading features we need. inputLoader.setAttribute("type", "file"); // When we add it to the body, make sure it doesn't visually // affect the page. inputLoader.style.display = 'none'; // We need to add it to the body in order for it to be active. document.body.appendChild(inputLoader); // Setup the callback inputLoader.onchange = function(x) { // If empty, nothing was selected. if(this.value == "") return; // In this example, we assume only one file is selected var file = this.files[0]; // The file data isn't instantly available at this level. // In order to access the file contents, we need to run // it through the FileReader and process it in its onload // callback (this is callback-ception) var reader = new FileReader(); // Clear the value to empty. Because onchange only triggers if // the selection is different. So if the same file is selected again // afterwards for legitimate reasons, it wouldn't trigger an onchange // if we don't clear this. this.value = ""; // Giving reader.onload access to this input. var thisInput = this; reader.onload = function(evt) { if (evt.target.readyState != 2) return; if (evt.target.error) { alert("Error while reading file " + file.name + ": " + loadEvent.target.error); return; } // The text results are in evt.target.result, so just send that // back into our app with SendMessage(). Note that we DON'T need // to call Pointer_stringify() on it. gameInstance.SendMessage( inputLoader.gameObjName, inputLoader.dataSinkFn, evt.target.result); } reader.readAsText(file); } } // We need to turn these values into strings before the callback, because the // memory that these parameter-string-pointers point to will not be valid later. inputLoader.gameObjName = Pointer_stringify(gameObjName); inputLoader.dataSinkFn = Pointer_stringify(dataSinkFn); // Set the extension filtering for the upload dialog inputLoader.setAttribute("accept", Pointer_stringify(extFilter)) // Force the input object to activate and open the upload dialog. inputLoader.click(); }, });
This uploading solution is quite a bit longer and more involved than the previous downloading. Here are some of the issues:
- While I wish this could have been completely generic, notice how the WebGL app’s name (in this specific case,
gameInstance
) is hardcoded. While there are ways around this, it seems that in the end either special external JavaScript or a hardcoded variable is needed. - This function is not completely self-contained, it contains a global variable (
inputLoader
). Although we could probably tuck this variable in thegameInstance
object. In the previous downloading post, we instantly got rid of the DOM object; but for uploading it requires giving a callback (onchange
) the chance to execute, and then another callback from there – so there may not be the perfect edge-case-free moment to delete the input. - To make this function more generic, the name of the
GameObject
and theSendMessage
receiver are made into parameters (gameObjName
anddataSinkFn
) instead of a hardcoded string when callingSendMessage()
. But, we cache the string values into the input object (inputLoader
) instead of directly using the parameters passed intoBrowserTextUpload()
. There are issues with not hardcoding the strings that was causing major issues for me. When referenced in the callback those parameter strings would be garbage. I’m confident that it’s related to memory management issues. When theFileReader
callback is called at a later point, the execution of the WebAssembly app has moved on and freed the memory where those strings used to be, causingPointer_stringify()
to not return anything useful – so we cache it beforehand.
And again, we need to declare the function in our Unity C# code:
public class Application : MonoBehaviour { [DllImport("__Internal")] public static extern string BrowserTextUpload(string extFilter, string gameObjName, string dataSinkFn); }
And that’s it!
Here’s an example on how it’s used for the WebKeys app:
public class PaneWiring: PaneBase, // Derives from MonoBehaviour IGNParamUICreator { ... public void OnButton_UploadDocument() { Application.BrowserTextUpload(".phon", "Managers", "LoadDocumentString"); } ... }
And here’s the SendMessage receiver.
// This behaviour is placed in a GameObject called Managers. public class Application : MonoBehaviour { ... public void LoadDocumentString(string str) { try { System.Xml.XmlDocument doc = new System.Xml.XmlDocument(); doc.LoadXml(str); WiringDocument wdActive = null; this.wiringPane.LoadDocument(doc, ref wdActive); if(wdActive != null) this.wiringPane.SetActiveDocument(wdActive); } catch(System.Exception ex) { Debug.Log("Error loading document: " + ex.Message); } } ... }
Hopefully in the future I can find a way to make the jslib implementation more self-contained, where the JavaScript Plugin code can be deployed without needing to know the variable name of its host WebGL app.
– Stay strong, code on. William Leu
Explore more articles here.
Explore more articles about Unity WebGL here.