diff --git a/Packages/StandaloneFileBrowser/Plugins/StandaloneFileBrowser.jslib b/Packages/StandaloneFileBrowser/Plugins/StandaloneFileBrowser.jslib index 5f50050..2e8550a 100644 --- a/Packages/StandaloneFileBrowser/Plugins/StandaloneFileBrowser.jslib +++ b/Packages/StandaloneFileBrowser/Plugins/StandaloneFileBrowser.jslib @@ -1,87 +1,327 @@ var StandaloneFileBrowserWebGLPlugin = { - // Open file. - // gameObjectNamePtr: Unique GameObject name. Required for calling back unity with SendMessage. - // methodNamePtr: Callback method name on given GameObject. - // filter: Filter files. Example filters: - // Match all image files: "image/*" - // Match all video files: "video/*" - // Match all audio files: "audio/*" - // Custom: ".plist, .xml, .yaml" - // multiselect: Allows multiple file selection - UploadFile: function(gameObjectNamePtr, methodNamePtr, filterPtr, multiselect) { - gameObjectName = UTF8ToString(gameObjectNamePtr); - methodName = UTF8ToString(methodNamePtr); - filter = UTF8ToString(filterPtr); - - // Delete if element exist - var fileInput = document.getElementById(gameObjectName) - if (fileInput) { - document.body.removeChild(fileInput); + $SFBHelpers: { + initialized: false, + state: null, + + getGlobal: function() { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof window !== 'undefined') return window; + return this; + }, + + ensureState: function() { + if (this.initialized) return this.state; + var globalObj = this.getGlobal(); + if (!globalObj.__sfbWebGLState) { + globalObj.__sfbWebGLState = { + nextHandleId: 0, + activeOpenRequest: null, + activeSaveRequest: null, + filesByHandle: {} + }; + } + this.state = globalObj.__sfbWebGLState; + this.initialized = true; + return this.state; + }, + + getState: function() { + return this.ensureState(); + }, + + createHandleId: function() { + var state = this.getState(); + state.nextHandleId += 1; + return 'handle-' + state.nextHandleId; + }, + + createOpenResponse: function(requestId, status, files, errorMessage) { + return { + type: 'open', + requestId: requestId || '', + status: status || 'error', + files: files || [], + errorMessage: errorMessage || '' + }; + }, + + createSaveResponse: function(requestId, status, errorMessage) { + return { + type: 'save', + requestId: requestId || '', + status: status || 'error', + errorMessage: errorMessage || '' + }; + }, + + sendMessage: function(gameObjectName, methodName, payload) { + SendMessage(gameObjectName, methodName, JSON.stringify(payload)); + }, + + revokeHandle: function(handleId) { + var state = this.getState(); + if (!handleId || !state.filesByHandle[handleId]) return; + var fileEntry = state.filesByHandle[handleId]; + delete state.filesByHandle[handleId]; + if (fileEntry.objectUrl) { + URL.revokeObjectURL(fileEntry.objectUrl); + } + }, + + revokeAllHandles: function() { + var state = this.getState(); + for (var handleId in state.filesByHandle) { + if (state.filesByHandle.hasOwnProperty(handleId)) { + this.revokeHandle(handleId); + } + } + }, + + cleanupOpenRequest: function(request) { + if (!request) return; + + if (request.input) { + if (request.changeHandler) request.input.removeEventListener('change', request.changeHandler); + if (request.cancelHandler) request.input.removeEventListener('cancel', request.cancelHandler); + if (request.input.parentNode) request.input.parentNode.removeChild(request.input); + } + var state = this.getState(); + if (state.activeOpenRequest === request) state.activeOpenRequest = null; + }, + + completeOpenRequest: function(request, payload) { + if (!request || request.completed) return; + request.completed = true; + this.cleanupOpenRequest(request); + this.sendMessage(request.gameObjectName, request.methodName, payload); + }, + + cleanupSaveRequest: function(request, revokeObjectUrlAsync) { + if (!request) return; + if (request.anchor && request.anchor.parentNode) { + request.anchor.parentNode.removeChild(request.anchor); + } + var objectUrl = request.objectUrl; + request.anchor = null; + request.objectUrl = null; + var state = this.getState(); + if (state.activeSaveRequest === request) state.activeSaveRequest = null; + if (!objectUrl) return; + if (revokeObjectUrlAsync) { + window.setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 0); + return; + } + URL.revokeObjectURL(objectUrl); + }, + + completeSaveRequest: function(request, payload) { + if (!request || request.completed) return; + request.completed = true; + this.cleanupSaveRequest(request, payload && payload.status === 'success'); + this.sendMessage(request.gameObjectName, request.methodName, payload); } + }, + + StandaloneFileBrowserWebGLOpenFilePanel: function(gameObjectNamePtr, methodNamePtr, requestIdPtr, filterPtr, multiselect) { + var helpers = SFBHelpers; - fileInput = document.createElement('input'); - fileInput.setAttribute('id', gameObjectName); - fileInput.setAttribute('type', 'file'); - fileInput.setAttribute('style','display:none;'); - fileInput.setAttribute('style','visibility:hidden;'); - if (multiselect) { - fileInput.setAttribute('multiple', ''); + var gameObjectName = UTF8ToString(gameObjectNamePtr); + var methodName = UTF8ToString(methodNamePtr); + var requestId = UTF8ToString(requestIdPtr); + var filter = UTF8ToString(filterPtr); + var state = helpers.getState(); + + if (state.activeOpenRequest) { + helpers.sendMessage( + gameObjectName, + methodName, + helpers.createOpenResponse(requestId, 'busy', [], '') + ); + return; } - if (filter) { - fileInput.setAttribute('accept', filter); + + if (typeof navigator !== 'undefined' && navigator.userActivation && navigator.userActivation.isActive === false) { + var errorMessage = 'Browser file picker must be triggered from a user gesture.'; + if (typeof console !== 'undefined' && console.error) console.error(errorMessage); + helpers.sendMessage( + gameObjectName, + methodName, + helpers.createOpenResponse(requestId, 'error', [], errorMessage) + ); + return; } - fileInput.onclick = function (event) { - // File dialog opened - this.value = null; + + var request = { + gameObjectName: gameObjectName, + methodName: methodName, + requestId: requestId, + input: null, + changeHandler: null, + cancelHandler: null, + focusHandler: null, + completed: false }; - fileInput.onchange = function (event) { - // multiselect works - var urls = []; - for (var i = 0; i < event.target.files.length; i++) { - urls.push(URL.createObjectURL(event.target.files[i])); - } - // File selected - SendMessage(gameObjectName, methodName, urls.join()); + state.activeOpenRequest = request; - // Remove after file selected - document.body.removeChild(fileInput); - } - document.body.appendChild(fileInput); + try { + var fileInput = document.createElement('input'); + request.input = fileInput; + + fileInput.setAttribute('type', 'file'); + fileInput.setAttribute('style', 'position:fixed;left:-10000px;top:-10000px;opacity:0;pointer-events:none;'); + + if (multiselect) fileInput.setAttribute('multiple', ''); + if (filter) fileInput.setAttribute('accept', filter); + + fileInput.onclick = function() { this.value = ''; }; + + request.changeHandler = function(event) { + var selectedFiles = event.target && event.target.files ? event.target.files : []; + var files = []; + + for (var i = 0; i < selectedFiles.length; i++) { + var browserFile = selectedFiles[i]; + var handleId = helpers.createHandleId(); + var objectUrl = URL.createObjectURL(browserFile); + var fileState = helpers.getState(); + + fileState.filesByHandle[handleId] = { + objectUrl: objectUrl, + name: browserFile.name || '', + size: browserFile.size || 0, + mimeType: browserFile.type || '', + lastModifiedUnixMs: browserFile.lastModified || 0 + }; + + files.push({ + handleId: handleId, + objectUrl: objectUrl, + name: browserFile.name || '', + size: String(browserFile.size || 0), + mimeType: browserFile.type || '', + lastModifiedUnixMs: String(browserFile.lastModified || 0) + }); + } + + helpers.completeOpenRequest( + request, + helpers.createOpenResponse( + request.requestId, + files.length > 0 ? 'success' : 'cancelled', + files, + '' + ) + ); + }; + + request.cancelHandler = function() { + helpers.completeOpenRequest( + request, + helpers.createOpenResponse(request.requestId, 'cancelled', [], '') + ); + }; + + fileInput.addEventListener('change', request.changeHandler); + fileInput.addEventListener('cancel', request.cancelHandler); - fileInput.click(); + document.body.appendChild(fileInput); + fileInput.click(); + } catch (error) { + helpers.completeOpenRequest( + request, + helpers.createOpenResponse( + request.requestId, + 'error', + [], + error && error.message ? error.message : 'Browser file open failed.' + ) + ); + } }, - // Save file - // DownloadFile method does not open SaveFileDialog like standalone builds, its just allows user to download file - // gameObjectNamePtr: Unique GameObject name. Required for calling back unity with SendMessage. - // methodNamePtr: Callback method name on given GameObject. - // filenamePtr: Filename with extension - // byteArray: byte[] - // byteArraySize: byte[].Length - DownloadFile: function(gameObjectNamePtr, methodNamePtr, filenamePtr, byteArray, byteArraySize) { - gameObjectName = UTF8ToString(gameObjectNamePtr); - methodName = UTF8ToString(methodNamePtr); - filename = UTF8ToString(filenamePtr); - - var bytes = new Uint8Array(byteArraySize); - for (var i = 0; i < byteArraySize; i++) { - bytes[i] = HEAPU8[byteArray + i]; + StandaloneFileBrowserWebGLSaveFile: function(gameObjectNamePtr, methodNamePtr, requestIdPtr, fileNamePtr, mimeTypePtr, byteArray, byteArraySize) { + var helpers = SFBHelpers; + + var gameObjectName = UTF8ToString(gameObjectNamePtr); + var methodName = UTF8ToString(methodNamePtr); + var requestId = UTF8ToString(requestIdPtr); + var fileName = UTF8ToString(fileNamePtr); + var mimeType = UTF8ToString(mimeTypePtr); + var state = helpers.getState(); + + if (state.activeSaveRequest) { + helpers.sendMessage( + gameObjectName, + methodName, + helpers.createSaveResponse(requestId, 'busy', '') + ); + return; } - var downloader = window.document.createElement('a'); - downloader.setAttribute('id', gameObjectName); - downloader.href = window.URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' })); - downloader.download = filename; - document.body.appendChild(downloader); + var request = { + gameObjectName: gameObjectName, + methodName: methodName, + requestId: requestId, + anchor: null, + objectUrl: null, + completed: false + }; + state.activeSaveRequest = request; + + try { + if (typeof navigator !== 'undefined' && navigator.userActivation && navigator.userActivation.isActive === false) { + helpers.completeSaveRequest( + request, + helpers.createSaveResponse( + request.requestId, + 'blocked-by-browser', + 'Browser download must be triggered from a user gesture.' + ) + ); + return; + } + + var bytes = new Uint8Array(byteArraySize); + if (byteArraySize > 0) bytes.set(HEAPU8.subarray(byteArray, byteArray + byteArraySize)); - document.onmouseup = function() { - downloader.click(); - document.body.removeChild(downloader); - document.onmouseup = null; + var blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' }); + request.objectUrl = URL.createObjectURL(blob); + request.anchor = document.createElement('a'); + request.anchor.setAttribute('download', fileName); + request.anchor.setAttribute('style', 'display:none;'); + request.anchor.href = request.objectUrl; + document.body.appendChild(request.anchor); + request.anchor.click(); - SendMessage(gameObjectName, methodName); + helpers.completeSaveRequest( + request, + helpers.createSaveResponse(request.requestId, 'success', '') + ); + } catch (error) { + helpers.completeSaveRequest( + request, + helpers.createSaveResponse( + request.requestId, + 'error', + error && error.message ? error.message : 'Browser file save failed.' + ) + ); } + }, + + StandaloneFileBrowserWebGLReleaseFile: function(handleIdPtr) { + var helpers = SFBHelpers; + var handleId = UTF8ToString(handleIdPtr); + helpers.revokeHandle(handleId); + }, + + StandaloneFileBrowserWebGLReleaseAllFiles: function() { + var helpers = SFBHelpers; + helpers.revokeAllHandles(); } }; +autoAddDeps(StandaloneFileBrowserWebGLPlugin, '$SFBHelpers'); + mergeInto(LibraryManager.library, StandaloneFileBrowserWebGLPlugin); diff --git a/Packages/StandaloneFileBrowser/Samples~/BasicSample/BasicSample.cs b/Packages/StandaloneFileBrowser/Samples~/BasicSample/BasicSample.cs index 77e427f..62cdbc6 100644 --- a/Packages/StandaloneFileBrowser/Samples~/BasicSample/BasicSample.cs +++ b/Packages/StandaloneFileBrowser/Samples~/BasicSample/BasicSample.cs @@ -14,6 +14,11 @@ void OnGUI() { GUILayout.Space(20); GUILayout.BeginVertical(); +#if UNITY_WEBGL + GUILayout.Label("BasicSample uses the desktop StandaloneFileBrowser API."); + GUILayout.Space(5); + GUILayout.Label("WebGL builds must use StandaloneFileBrowserWebGL with explicit platform branching."); +#else // Open File Samples if (GUILayout.Button("Open File")) { @@ -95,6 +100,7 @@ void OnGUI() { }; _path = StandaloneFileBrowser.SaveFilePanel("Save File", "", "MySaveFile", extensionList); } +#endif GUILayout.EndVertical(); GUILayout.Space(20); diff --git a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileImage.cs b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileImage.cs index 20eb549..2ac63f6 100644 --- a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileImage.cs +++ b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileImage.cs @@ -1,7 +1,4 @@ -using System.Text; using System.Collections; -using System.Collections.Generic; -using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; @@ -12,20 +9,36 @@ public class CanvasSampleOpenFileImage : MonoBehaviour, IPointerDownHandler { public RawImage output; -#if UNITY_WEBGL && !UNITY_EDITOR - // - // WebGL - // - [DllImport("__Internal")] - private static extern void UploadFile(string gameObjectName, string methodName, string filter, bool multiple); + private static readonly ExtensionFilter[] ImageExtensions = { + new ExtensionFilter("Image Files", "png", "jpg", "jpeg") + }; +#if UNITY_WEBGL public void OnPointerDown(PointerEventData eventData) { - UploadFile(gameObject.name, "OnFileUpload", ".png, .jpg", false); + StandaloneFileBrowserWebGL.OpenFilePanelAsync(ImageExtensions, false, OnFilesSelected); } - // Called from browser - public void OnFileUpload(string url) { - StartCoroutine(OutputRoutine(url)); + private void OnFilesSelected(WebGLFileSelectionResult result) { + if (result.Status == WebGLFileSelectionStatus.Success && result.Files.Length > 0) { + var file = result.Files[0]; + Debug.Log($"File selected: {file.Name} , {file.Size} bytes , {file.MimeType} , {DateTimeOffset.FromUnixTimeMilliseconds(file.LastModifiedUnixMs)}"); + StartCoroutine(OutputRoutine(file.ObjectUrl, delegate { + StandaloneFileBrowserWebGL.Release(file); + })); + return; + } + + if (result.Status == WebGLFileSelectionStatus.Cancelled) { + Debug.Log("File selection cancelled"); + return; + } + + if (result.Status == WebGLFileSelectionStatus.Busy) { + Debug.LogWarning("Another file-open request is already in progress"); + return; + } + + Debug.LogError(string.IsNullOrEmpty(result.ErrorMessage) ? "Browser file open failed" : result.ErrorMessage); } #else // @@ -39,14 +52,14 @@ void Start() { } private void OnClick() { - var paths = StandaloneFileBrowser.OpenFilePanel("Title", "", "png", false); + var paths = StandaloneFileBrowser.OpenFilePanel("Title", "", ImageExtensions, false); if (paths.Length > 0) { StartCoroutine(OutputRoutine(new System.Uri(paths[0]).AbsoluteUri)); } } #endif - private IEnumerator OutputRoutine(string url) { + private IEnumerator OutputRoutine(string url, System.Action onCompleted = null) { UnityWebRequest www = UnityWebRequestTexture.GetTexture(url); yield return www.SendWebRequest(); @@ -58,5 +71,9 @@ private IEnumerator OutputRoutine(string url) { { output.texture = ((DownloadHandlerTexture)www.downloadHandler).texture; } + + if (onCompleted != null) { + onCompleted(); + } } } diff --git a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileText.cs b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileText.cs index 29b9de8..a8d395b 100644 --- a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileText.cs +++ b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileText.cs @@ -1,7 +1,5 @@ -using System.Text; +using System; using System.Collections; -using System.Collections.Generic; -using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; @@ -12,20 +10,32 @@ public class CanvasSampleOpenFileText : MonoBehaviour, IPointerDownHandler { public Text output; -#if UNITY_WEBGL && !UNITY_EDITOR - // - // WebGL - // - [DllImport("__Internal")] - private static extern void UploadFile(string gameObjectName, string methodName, string filter, bool multiple); - +#if UNITY_WEBGL public void OnPointerDown(PointerEventData eventData) { - UploadFile(gameObject.name, "OnFileUpload", ".txt", false); + StandaloneFileBrowserWebGL.OpenFilePanelAsync("txt", false, OnFilesSelected); } - // Called from browser - public void OnFileUpload(string url) { - StartCoroutine(OutputRoutine(url)); + private void OnFilesSelected(WebGLFileSelectionResult result) { + if (result.Status == WebGLFileSelectionStatus.Success && result.Files.Length > 0) { + var file = result.Files[0]; + Debug.Log($"File selected: {file.Name} , {file.Size} bytes , {file.MimeType} , {DateTimeOffset.FromUnixTimeMilliseconds(file.LastModifiedUnixMs)}"); + StartCoroutine(OutputRoutine(file.ObjectUrl, delegate { + StandaloneFileBrowserWebGL.Release(file); + })); + return; + } + + if (result.Status == WebGLFileSelectionStatus.Cancelled) { + output.text = "File selection cancelled"; + return; + } + + if (result.Status == WebGLFileSelectionStatus.Busy) { + output.text = "Another file-open request is already in progress"; + return; + } + + output.text = string.IsNullOrEmpty(result.ErrorMessage) ? "Browser file open failed" : result.ErrorMessage; } #else // @@ -46,17 +56,22 @@ private void OnClick() { } #endif - private IEnumerator OutputRoutine(string url) { + private IEnumerator OutputRoutine(string url, Action onCompleted = null) { UnityWebRequest www = UnityWebRequest.Get(url); yield return www.SendWebRequest(); if (www.result != UnityWebRequest.Result.Success) { Debug.LogError(www.error); + output.text = www.error; } else { output.text = www.downloadHandler.text; } + + if (onCompleted != null) { + onCompleted(); + } } -} \ No newline at end of file +} diff --git a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileTextMultiple.cs b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileTextMultiple.cs index cd03761..f9db738 100644 --- a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileTextMultiple.cs +++ b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleOpenFileTextMultiple.cs @@ -10,20 +10,50 @@ public class CanvasSampleOpenFileTextMultiple : MonoBehaviour, IPointerDownHandler { public Text output; -#if UNITY_WEBGL && !UNITY_EDITOR - // - // WebGL - // - [DllImport("__Internal")] - private static extern void UploadFile(string gameObjectName, string methodName, string filter, bool multiple); - +#if UNITY_WEBGL public void OnPointerDown(PointerEventData eventData) { - UploadFile(gameObject.name, "OnFileUpload", ".txt", true); + StandaloneFileBrowserWebGL.OpenFilePanelAsync("txt", true, OnFilesSelected); } - // Called from browser - public void OnFileUpload(string urls) { - StartCoroutine(OutputRoutine(urls.Split(','))); + private void OnFilesSelected(WebGLFileSelectionResult result) { + if (result.Status == WebGLFileSelectionStatus.Success && result.Files.Length > 0) { + StartCoroutine(OutputRoutine(result.Files)); + return; + } + + if (result.Status == WebGLFileSelectionStatus.Cancelled) { + output.text = "File selection cancelled"; + return; + } + + if (result.Status == WebGLFileSelectionStatus.Busy) { + output.text = "Another file-open request is already in progress"; + return; + } + + output.text = string.IsNullOrEmpty(result.ErrorMessage) ? "Browser file open failed" : result.ErrorMessage; + } + + private IEnumerator OutputRoutine(WebGLFileReference[] files) { + var outputText = ""; + for (int i = 0; i < files.Length; i++) + { + var file = files[i]; + UnityWebRequest www = UnityWebRequest.Get(file.ObjectUrl); + yield return www.SendWebRequest(); + + if (www.result != UnityWebRequest.Result.Success) + { + Debug.LogError(www.error); + } + else + { + outputText += www.downloadHandler.text; + } + + StandaloneFileBrowserWebGL.Release(file); + } + output.text = outputText; } #else // @@ -37,8 +67,7 @@ void Start() { } private void OnClick() { - // var paths = StandaloneFileBrowser.OpenFilePanel("Title", "", "txt", true); - var paths = StandaloneFileBrowser.OpenFilePanel("Open File", "", "", true); + var paths = StandaloneFileBrowser.OpenFilePanel("Open File", "", "txt", true); if (paths.Length > 0) { var urlArr = new List(paths.Length); for (int i = 0; i < paths.Length; i++) { @@ -67,4 +96,4 @@ private IEnumerator OutputRoutine(string[] urlArr) { } output.text = outputText; } -} \ No newline at end of file +} diff --git a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileImage.cs b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileImage.cs index 876ed5a..63c154e 100644 --- a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileImage.cs +++ b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileImage.cs @@ -1,6 +1,4 @@ using System.IO; -using System.Text; -using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; @@ -27,21 +25,29 @@ void Awake() { UnityEngine.Object.Destroy(tex); } -#if UNITY_WEBGL && !UNITY_EDITOR - // - // WebGL - // - [DllImport("__Internal")] - private static extern void DownloadFile(string gameObjectName, string methodName, string filename, byte[] byteArray, int byteArraySize); - - // Broser plugin should be called in OnPointerDown. +#if UNITY_WEBGL + // Browser plugin should be called in OnPointerDown. public void OnPointerDown(PointerEventData eventData) { - DownloadFile(gameObject.name, "OnFileDownload", "sample.png", _textureBytes, _textureBytes.Length); + StandaloneFileBrowserWebGL.SaveFileAsync("sample.png", _textureBytes, "image/png", OnFileSaved); } - // Called from browser - public void OnFileDownload() { - output.text = "File Successfully Downloaded"; + private void OnFileSaved(WebGLSaveResult result) { + if (result.Status == WebGLSaveStatus.Success) { + output.text = "File successfully downloaded"; + return; + } + + if (result.Status == WebGLSaveStatus.Busy) { + output.text = "Another save request is already in progress"; + return; + } + + if (result.Status == WebGLSaveStatus.BlockedByBrowser) { + output.text = string.IsNullOrEmpty(result.ErrorMessage) ? "Browser blocked the download request" : result.ErrorMessage; + return; + } + + output.text = string.IsNullOrEmpty(result.ErrorMessage) ? "Browser file save failed" : result.ErrorMessage; } #else // @@ -62,4 +68,4 @@ public void OnClick() { } } #endif -} \ No newline at end of file +} diff --git a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileText.cs b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileText.cs index 3386f8e..15e9581 100644 --- a/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileText.cs +++ b/Packages/StandaloneFileBrowser/Samples~/CanvasSample/CanvasSampleSaveFileText.cs @@ -1,6 +1,5 @@ using System.IO; using System.Text; -using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; @@ -13,22 +12,30 @@ public class CanvasSampleSaveFileText : MonoBehaviour, IPointerDownHandler { // Sample text data private string _data = "Example text created by StandaloneFileBrowser"; -#if UNITY_WEBGL && !UNITY_EDITOR - // - // WebGL - // - [DllImport("__Internal")] - private static extern void DownloadFile(string gameObjectName, string methodName, string filename, byte[] byteArray, int byteArraySize); - - // Broser plugin should be called in OnPointerDown. +#if UNITY_WEBGL + // Browser plugin should be called in OnPointerDown. public void OnPointerDown(PointerEventData eventData) { var bytes = Encoding.UTF8.GetBytes(_data); - DownloadFile(gameObject.name, "OnFileDownload", "sample.txt", bytes, bytes.Length); + StandaloneFileBrowserWebGL.SaveFileAsync("sample.txt", bytes, OnFileSaved); } - // Called from browser - public void OnFileDownload() { - output.text = "File Successfully Downloaded"; + private void OnFileSaved(WebGLSaveResult result) { + if (result.Status == WebGLSaveStatus.Success) { + output.text = "File successfully downloaded"; + return; + } + + if (result.Status == WebGLSaveStatus.Busy) { + output.text = "Another save request is already in progress"; + return; + } + + if (result.Status == WebGLSaveStatus.BlockedByBrowser) { + output.text = string.IsNullOrEmpty(result.ErrorMessage) ? "Browser blocked the download request" : result.ErrorMessage; + return; + } + + output.text = string.IsNullOrEmpty(result.ErrorMessage) ? "Browser file save failed" : result.ErrorMessage; } #else // @@ -49,4 +56,4 @@ public void OnClick() { } } #endif -} \ No newline at end of file +} diff --git a/Packages/StandaloneFileBrowser/StandaloneFileBrowser.cs b/Packages/StandaloneFileBrowser/StandaloneFileBrowser.cs index ac2ca5f..e5b0d84 100644 --- a/Packages/StandaloneFileBrowser/StandaloneFileBrowser.cs +++ b/Packages/StandaloneFileBrowser/StandaloneFileBrowser.cs @@ -11,6 +11,11 @@ public ExtensionFilter(string filterName, params string[] filterExtensions) { } } +#if !UNITY_WEBGL || UNITY_EDITOR + /// + /// Desktop/editor-only native file dialog API. + /// WebGL builds must use StandaloneFileBrowserWebGL with explicit platform branching. + /// public class StandaloneFileBrowser { private static IStandaloneFileBrowser _platformWrapper = null; @@ -150,4 +155,5 @@ public static void SaveFilePanelAsync(string title, string directory, string def _platformWrapper.SaveFilePanelAsync(title, directory, defaultName, extensions, cb); } } -} \ No newline at end of file +#endif +} diff --git a/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGL.cs b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGL.cs new file mode 100644 index 0000000..30ce7ef --- /dev/null +++ b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGL.cs @@ -0,0 +1,298 @@ +#if UNITY_WEBGL + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using UnityEngine; + +namespace SFB { + public enum WebGLFileSelectionStatus { + Success, + Cancelled, + Busy, + Error, + } + + public enum WebGLSaveStatus { + Success, + BlockedByBrowser, + Busy, + Error, + } + + [Serializable] + public sealed class WebGLFileReference { + public string HandleId; + public string ObjectUrl; + public string Name; + public long Size; + public string MimeType; + public long LastModifiedUnixMs; + } + + [Serializable] + public sealed class WebGLFileSelectionResult { + public WebGLFileSelectionStatus Status; + public WebGLFileReference[] Files; + public string ErrorMessage; + + public WebGLFileSelectionResult() { + Files = new WebGLFileReference[0]; + ErrorMessage = string.Empty; + } + } + + [Serializable] + public sealed class WebGLSaveResult { + public WebGLSaveStatus Status; + public string ErrorMessage; + + public WebGLSaveResult() { + ErrorMessage = string.Empty; + } + } + + public static class StandaloneFileBrowserWebGL { + public static void OpenFilePanelAsync(string extension, bool multiselect, Action cb) { + var extensions = string.IsNullOrEmpty(extension) ? null : new [] { new ExtensionFilter(string.Empty, extension) }; + OpenFilePanelAsync(extensions, multiselect, cb); + } + + public static void OpenFilePanelAsync(ExtensionFilter[] extensions, bool multiselect, Action cb) { +#if UNITY_EDITOR + StandaloneFileBrowserWebGLEditorBridge.OpenFilePanelAsync(extensions, multiselect, cb); +#else + StandaloneFileBrowserWebGLBridge.Instance.OpenFilePanelAsync(extensions, multiselect, cb); +#endif + } + + public static void SaveFileAsync(string fileName, byte[] data, Action cb) { + SaveFileAsync(fileName, data, string.Empty, cb); + } + + public static void SaveFileAsync(string fileName, byte[] data, string mimeType, Action cb) { +#if UNITY_EDITOR + StandaloneFileBrowserWebGLEditorBridge.SaveFileAsync(fileName, data, mimeType, cb); +#else + StandaloneFileBrowserWebGLBridge.Instance.SaveFileAsync(fileName, data, mimeType, cb); +#endif + } + + public static void Release(WebGLFileReference file) { +#if UNITY_EDITOR + StandaloneFileBrowserWebGLEditorBridge.Release(file); +#else + if (!StandaloneFileBrowserWebGLBridge.HasInstance) { + return; + } + + StandaloneFileBrowserWebGLBridge.Instance.Release(file); +#endif + } + + public static void Release(WebGLFileReference[] files) { +#if UNITY_EDITOR + StandaloneFileBrowserWebGLEditorBridge.Release(files); +#else + if (!StandaloneFileBrowserWebGLBridge.HasInstance) { + return; + } + + StandaloneFileBrowserWebGLBridge.Instance.Release(files); +#endif + } + + public static void ReleaseAll() { +#if UNITY_EDITOR + StandaloneFileBrowserWebGLEditorBridge.ReleaseAll(); +#else + if (!StandaloneFileBrowserWebGLBridge.HasInstance) { + return; + } + + StandaloneFileBrowserWebGLBridge.Instance.ReleaseAll(); +#endif + } + } + +#if UNITY_EDITOR + internal static class StandaloneFileBrowserWebGLEditorBridge { + private static readonly Dictionary _filesByHandle = new Dictionary(); + private static int _nextHandleId; + private static int _activeOpenRequests; + private static int _activeSaveRequests; + + public static void OpenFilePanelAsync(ExtensionFilter[] extensions, bool multiselect, Action cb) { + if (cb == null) { + cb = delegate { }; + } + + if (_activeOpenRequests > 0) { + cb.Invoke(CreateBusyOpenResult()); + return; + } + + _activeOpenRequests++; + try { + var paths = StandaloneFileBrowser.OpenFilePanel(string.Empty, string.Empty, extensions, multiselect); + cb.Invoke(CreateOpenResult(paths)); + } + catch (Exception exception) { + cb.Invoke(CreateErrorOpenResult(exception.Message)); + } + finally { + _activeOpenRequests--; + } + } + + public static void SaveFileAsync(string fileName, byte[] data, string mimeType, Action cb) { + if (cb == null) { + cb = delegate { }; + } + + if (_activeSaveRequests > 0) { + cb.Invoke(CreateBusySaveResult()); + return; + } + + if (string.IsNullOrEmpty(fileName)) { + cb.Invoke(CreateErrorSaveResult("File name cannot be empty.")); + return; + } + + _activeSaveRequests++; + try { + var extension = Path.GetExtension(fileName); + if (!string.IsNullOrEmpty(extension)) { + extension = extension.TrimStart('.'); + } + + var defaultName = Path.GetFileNameWithoutExtension(fileName); + var path = StandaloneFileBrowser.SaveFilePanel(string.Empty, string.Empty, defaultName, string.IsNullOrEmpty(extension) ? null : new [] { new ExtensionFilter(string.Empty, extension) }); + if (string.IsNullOrEmpty(path)) { + cb.Invoke(CreateSaveResult("cancelled", string.Empty)); + return; + } + + var buffer = data ?? new byte[0]; + File.WriteAllBytes(path, buffer); + cb.Invoke(CreateSaveResult("success", string.Empty)); + } + catch (Exception exception) { + cb.Invoke(CreateErrorSaveResult(exception.Message)); + } + finally { + _activeSaveRequests--; + } + } + + public static void Release(WebGLFileReference file) { + if (file == null || string.IsNullOrEmpty(file.HandleId)) { + return; + } + + _filesByHandle.Remove(file.HandleId); + } + + public static void Release(WebGLFileReference[] files) { + if (files == null) { + return; + } + + for (var i = 0; i < files.Length; i++) { + Release(files[i]); + } + } + + public static void ReleaseAll() { + _filesByHandle.Clear(); + } + + private static WebGLFileSelectionResult CreateOpenResult(string[] paths) { + var result = new WebGLFileSelectionResult(); + if (paths == null || paths.Length == 0) { + result.Status = WebGLFileSelectionStatus.Cancelled; + return result; + } + + result.Status = WebGLFileSelectionStatus.Success; + result.Files = new WebGLFileReference[paths.Length]; + for (var i = 0; i < paths.Length; i++) { + var path = paths[i]; + var handleId = CreateHandleId(); + var fileInfo = new FileInfo(path); + var file = new WebGLFileReference { + HandleId = handleId, + ObjectUrl = new Uri(path).AbsoluteUri, + Name = fileInfo.Exists ? fileInfo.Name : Path.GetFileName(path), + Size = fileInfo.Exists ? fileInfo.Length : 0, + MimeType = string.Empty, + LastModifiedUnixMs = fileInfo.Exists ? new DateTimeOffset(fileInfo.LastWriteTimeUtc).ToUnixTimeMilliseconds() : 0, + }; + result.Files[i] = file; + _filesByHandle[handleId] = file; + } + + return result; + } + + private static WebGLFileSelectionResult CreateBusyOpenResult() { + var result = new WebGLFileSelectionResult(); + result.Status = WebGLFileSelectionStatus.Busy; + return result; + } + + private static WebGLFileSelectionResult CreateErrorOpenResult(string message) { + var result = new WebGLFileSelectionResult(); + result.Status = WebGLFileSelectionStatus.Error; + result.ErrorMessage = string.IsNullOrEmpty(message) ? "Browser file open failed." : message; + return result; + } + + private static WebGLSaveResult CreateBusySaveResult() { + var result = new WebGLSaveResult(); + result.Status = WebGLSaveStatus.Busy; + return result; + } + + private static WebGLSaveResult CreateErrorSaveResult(string message) { + var result = new WebGLSaveResult(); + result.Status = WebGLSaveStatus.Error; + result.ErrorMessage = string.IsNullOrEmpty(message) ? "Browser file save failed." : message; + return result; + } + + private static WebGLSaveResult CreateSaveResult(string status, string errorMessage) { + var result = new WebGLSaveResult(); + result.Status = ParseSaveStatus(status); + result.ErrorMessage = errorMessage ?? string.Empty; + return result; + } + + private static string CreateHandleId() { + _nextHandleId++; + return "editor-handle-" + _nextHandleId; + } + + private static WebGLSaveStatus ParseSaveStatus(string status) { + if (string.Equals(status, "success", StringComparison.OrdinalIgnoreCase)) { + return WebGLSaveStatus.Success; + } + + if (string.Equals(status, "blocked-by-browser", StringComparison.OrdinalIgnoreCase)) { + return WebGLSaveStatus.BlockedByBrowser; + } + + if (string.Equals(status, "busy", StringComparison.OrdinalIgnoreCase)) { + return WebGLSaveStatus.Busy; + } + + return WebGLSaveStatus.Error; + } + } +#endif + +} + +#endif diff --git a/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGL.cs.meta b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGL.cs.meta new file mode 100644 index 0000000..4dc9071 --- /dev/null +++ b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGL.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: d0c899befa1e4af4ba21eae7e6adfd6f +timeCreated: 1781049600 +licenseType: Pro +MonoImporter: + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGLBridge.cs b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGLBridge.cs new file mode 100644 index 0000000..90da885 --- /dev/null +++ b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGLBridge.cs @@ -0,0 +1,545 @@ +#if UNITY_WEBGL + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using UnityEngine; + + +namespace SFB +{ + internal class StandaloneFileBrowserWebGLBridge : MonoBehaviour + { + private const string GameObjectName = "StandaloneFileBrowserWebGLBridge"; + private static StandaloneFileBrowserWebGLBridge _instance; + + [DllImport("__Internal")] + private static extern void StandaloneFileBrowserWebGLOpenFilePanel(string gameObjectName, string methodName, string requestId, string filter, bool multiselect); + + [DllImport("__Internal")] + private static extern void StandaloneFileBrowserWebGLSaveFile(string gameObjectName, string methodName, string requestId, string fileName, string mimeType, byte[] byteArray, int byteArraySize); + + [DllImport("__Internal")] + private static extern void StandaloneFileBrowserWebGLReleaseFile(string handleId); + + [DllImport("__Internal")] + private static extern void StandaloneFileBrowserWebGLReleaseAllFiles(); + + private readonly Dictionary> _openCallbacks = new Dictionary>(); + private readonly Dictionary> _saveCallbacks = new Dictionary>(); + private readonly Dictionary _filesByHandle = new Dictionary(); + private int _requestIdCounter; + + public static bool HasInstance + { + get { return _instance != null; } + } + + public static StandaloneFileBrowserWebGLBridge Instance + { + get + { + if (_instance == null) + { + var gameObject = new GameObject(GameObjectName); + _instance = gameObject.AddComponent(); + } + + return _instance; + } + } + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + + _instance = this; + DontDestroyOnLoad(gameObject); + } + + private void OnDestroy() + { + if (_instance != this) + { + return; + } + + ReleaseAll(); + _instance = null; + } + + private void OnApplicationQuit() + { + ReleaseAll(); + } + + public void OpenFilePanelAsync(ExtensionFilter[] extensions, bool multiselect, Action cb) + { + if (cb == null) + { + cb = delegate { }; + } + + if (_openCallbacks.Count > 0) + { + cb.Invoke(CreateBusyOpenResult()); + return; + } + + var requestId = CreateRequestId("open"); + _openCallbacks[requestId] = cb; + + try + { + StandaloneFileBrowserWebGLOpenFilePanel(gameObject.name, nameof(OnOpenRequestCompleted), requestId, GetFilterFromFileExtensionList(extensions), multiselect); + } + catch (Exception exception) + { + _openCallbacks.Remove(requestId); + cb.Invoke(CreateErrorOpenResult(exception.Message)); + } + } + + public void SaveFileAsync(string fileName, byte[] data, string mimeType, Action cb) + { + if (cb == null) + { + cb = delegate { }; + } + + if (_saveCallbacks.Count > 0) + { + cb.Invoke(CreateBusySaveResult()); + return; + } + + if (string.IsNullOrEmpty(fileName)) + { + cb.Invoke(CreateErrorSaveResult("File name cannot be empty.")); + return; + } + + var buffer = data ?? new byte[0]; + var requestId = CreateRequestId("save"); + _saveCallbacks[requestId] = cb; + + try + { + StandaloneFileBrowserWebGLSaveFile(gameObject.name, nameof(OnSaveRequestCompleted), requestId, fileName, mimeType ?? string.Empty, buffer, buffer.Length); + } + catch (Exception exception) + { + _saveCallbacks.Remove(requestId); + cb.Invoke(CreateErrorSaveResult(exception.Message)); + } + } + + public void Release(WebGLFileReference file) + { + if (file == null || string.IsNullOrEmpty(file.HandleId)) + { + return; + } + + ReleaseByHandle(file.HandleId); + } + + public void Release(WebGLFileReference[] files) + { + if (files == null) + { + return; + } + + for (var i = 0; i < files.Length; i++) + { + Release(files[i]); + } + } + + public void ReleaseAll() + { + _filesByHandle.Clear(); + StandaloneFileBrowserWebGLReleaseAllFiles(); + } + + public void OnOpenRequestCompleted(string message) + { + var response = DeserializeOpenResponse(message); + if (response == null || string.IsNullOrEmpty(response.requestId)) + { + FailPendingOpenRequest("Failed to parse browser open-file response."); + return; + } + + Action callback; + if (!_openCallbacks.TryGetValue(response.requestId, out callback)) + { + return; + } + + _openCallbacks.Remove(response.requestId); + callback.Invoke(CreateOpenResult(response)); + } + + public void OnSaveRequestCompleted(string message) + { + var response = DeserializeSaveResponse(message); + if (response == null || string.IsNullOrEmpty(response.requestId)) + { + FailPendingSaveRequest("Failed to parse browser save-file response."); + return; + } + + Action callback; + if (!_saveCallbacks.TryGetValue(response.requestId, out callback)) + { + return; + } + + _saveCallbacks.Remove(response.requestId); + callback.Invoke(CreateSaveResult(response)); + } + + private void ReleaseByHandle(string handleId) + { + if (!_filesByHandle.Remove(handleId)) + { + return; + } + + StandaloneFileBrowserWebGLReleaseFile(handleId); + } + + private void FailPendingOpenRequest(string message) + { + if (_openCallbacks.Count == 0) + { + return; + } + + using (var enumerator = _openCallbacks.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + return; + } + + var entry = enumerator.Current; + _openCallbacks.Remove(entry.Key); + entry.Value.Invoke(CreateErrorOpenResult(message)); + } + } + + private void FailPendingSaveRequest(string message) + { + if (_saveCallbacks.Count == 0) + { + return; + } + + using (var enumerator = _saveCallbacks.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + return; + } + + var entry = enumerator.Current; + _saveCallbacks.Remove(entry.Key); + entry.Value.Invoke(CreateErrorSaveResult(message)); + } + } + + private string CreateRequestId(string prefix) + { + _requestIdCounter++; + return prefix + "-" + _requestIdCounter; + } + + private WebGLFileSelectionResult CreateOpenResult(WebGLFileSelectionResponse response) + { + var result = new WebGLFileSelectionResult(); + result.Status = ParseSelectionStatus(response.status); + result.ErrorMessage = response.errorMessage ?? string.Empty; + result.Files = ConvertFiles(response.files); + + if (result.Status == WebGLFileSelectionStatus.Success && result.Files.Length == 0) + { + result.Status = WebGLFileSelectionStatus.Cancelled; + } + + for (var i = 0; i < result.Files.Length; i++) + { + var file = result.Files[i]; + if (file == null || string.IsNullOrEmpty(file.HandleId)) + { + continue; + } + + _filesByHandle[file.HandleId] = file; + } + + return result; + } + + private static WebGLSaveResult CreateSaveResult(WebGLSaveResponse response) + { + var result = new WebGLSaveResult(); + result.Status = ParseSaveStatus(response.status); + result.ErrorMessage = response.errorMessage ?? string.Empty; + return result; + } + + private static WebGLFileSelectionResult CreateBusyOpenResult() + { + var result = new WebGLFileSelectionResult(); + result.Status = WebGLFileSelectionStatus.Busy; + return result; + } + + private static WebGLFileSelectionResult CreateErrorOpenResult(string message) + { + var result = new WebGLFileSelectionResult(); + result.Status = WebGLFileSelectionStatus.Error; + result.ErrorMessage = string.IsNullOrEmpty(message) ? "Browser file open failed." : message; + return result; + } + + private static WebGLSaveResult CreateBusySaveResult() + { + var result = new WebGLSaveResult(); + result.Status = WebGLSaveStatus.Busy; + return result; + } + + private static WebGLSaveResult CreateErrorSaveResult(string message) + { + var result = new WebGLSaveResult(); + result.Status = WebGLSaveStatus.Error; + result.ErrorMessage = string.IsNullOrEmpty(message) ? "Browser file save failed." : message; + return result; + } + + private static WebGLFileReference[] ConvertFiles(WebGLFileResponse[] files) + { + if (files == null || files.Length == 0) + { + return new WebGLFileReference[0]; + } + + var result = new WebGLFileReference[files.Length]; + for (var i = 0; i < files.Length; i++) + { + var file = files[i]; + if (file == null) + { + continue; + } + + result[i] = new WebGLFileReference + { + HandleId = file.handleId ?? string.Empty, + ObjectUrl = file.objectUrl ?? string.Empty, + Name = file.name ?? string.Empty, + Size = ParseLong(file.size), + MimeType = file.mimeType ?? string.Empty, + LastModifiedUnixMs = ParseLong(file.lastModifiedUnixMs), + }; + } + + return result; + } + + private static long ParseLong(string value) + { + long parsedValue; + if (long.TryParse(value, out parsedValue)) + { + return parsedValue; + } + + return 0; + } + + private static WebGLFileSelectionStatus ParseSelectionStatus(string status) + { + if (string.Equals(status, "success", StringComparison.OrdinalIgnoreCase)) + { + return WebGLFileSelectionStatus.Success; + } + + if (string.Equals(status, "cancelled", StringComparison.OrdinalIgnoreCase)) + { + return WebGLFileSelectionStatus.Cancelled; + } + + if (string.Equals(status, "busy", StringComparison.OrdinalIgnoreCase)) + { + return WebGLFileSelectionStatus.Busy; + } + + return WebGLFileSelectionStatus.Error; + } + + private static WebGLSaveStatus ParseSaveStatus(string status) + { + if (string.Equals(status, "success", StringComparison.OrdinalIgnoreCase)) + { + return WebGLSaveStatus.Success; + } + + if (string.Equals(status, "blocked-by-browser", StringComparison.OrdinalIgnoreCase)) + { + return WebGLSaveStatus.BlockedByBrowser; + } + + if (string.Equals(status, "busy", StringComparison.OrdinalIgnoreCase)) + { + return WebGLSaveStatus.Busy; + } + + return WebGLSaveStatus.Error; + } + + private static WebGLFileSelectionResponse DeserializeOpenResponse(string message) + { + if (string.IsNullOrEmpty(message)) + { + return null; + } + + try + { + return JsonUtility.FromJson(message); + } + catch + { + return null; + } + } + + private static WebGLSaveResponse DeserializeSaveResponse(string message) + { + if (string.IsNullOrEmpty(message)) + { + return null; + } + + try + { + return JsonUtility.FromJson(message); + } + catch + { + return null; + } + } + + private static string GetFilterFromFileExtensionList(ExtensionFilter[] extensions) + { + if (extensions == null || extensions.Length == 0) + { + return string.Empty; + } + + var acceptedExtensions = new List(); + for (var i = 0; i < extensions.Length; i++) + { + var filterExtensions = extensions[i].Extensions; + if (filterExtensions == null || filterExtensions.Length == 0) + { + continue; + } + + for (var j = 0; j < filterExtensions.Length; j++) + { + var extension = NormalizeExtension(filterExtensions[j]); + if (extension == "*") + { + return string.Empty; + } + + if (string.IsNullOrEmpty(extension)) + { + continue; + } + + if (!acceptedExtensions.Contains(extension)) + { + acceptedExtensions.Add(extension); + } + } + } + + if (acceptedExtensions.Count == 0) + { + return string.Empty; + } + + return string.Join(",", acceptedExtensions.ToArray()); + } + + private static string NormalizeExtension(string extension) + { + if (string.IsNullOrEmpty(extension)) + { + return string.Empty; + } + + var normalizedExtension = extension.Trim(); + if (normalizedExtension.Length == 0) + { + return string.Empty; + } + + if (normalizedExtension == "*" || normalizedExtension == "*.*") + { + return "*"; + } + + if (!normalizedExtension.StartsWith(".")) + { + normalizedExtension = "." + normalizedExtension; + } + + return normalizedExtension; + } + + [Serializable] + private sealed class WebGLFileSelectionResponse + { + public string type; + public string requestId; + public string status; + public WebGLFileResponse[] files; + public string errorMessage; + } + + [Serializable] + private sealed class WebGLFileResponse + { + public string handleId; + public string objectUrl; + public string name; + public string size; + public string mimeType; + public string lastModifiedUnixMs; + } + + [Serializable] + private sealed class WebGLSaveResponse + { + public string type; + public string requestId; + public string status; + public string errorMessage; + } + } +} + +#endif \ No newline at end of file diff --git a/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGLBridge.cs.meta b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGLBridge.cs.meta new file mode 100644 index 0000000..4d43f15 --- /dev/null +++ b/Packages/StandaloneFileBrowser/StandaloneFileBrowserWebGLBridge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ef24b2c035427794cafa2b22fc5959cb \ No newline at end of file diff --git a/Packages/StandaloneFileBrowser/package.json b/Packages/StandaloneFileBrowser/package.json index c8fdc9a..50fcfa3 100644 --- a/Packages/StandaloneFileBrowser/package.json +++ b/Packages/StandaloneFileBrowser/package.json @@ -5,7 +5,7 @@ "name": "gkngkc", "url": "https://github.com/gkngkc" }, - "description": "A simple wrapper for native file dialogs on Windows/Mac/Linux.", + "description": "A simple wrapper for native file dialogs on Windows/Mac/Linux, plus a dedicated WebGL browser file API.", "version": "1.3.4", "license": "MIT", "samples": [ diff --git a/README.md b/README.md index 90e96de..f6c9047 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Unity Standalone File Browser [![openupm](https://img.shields.io/npm/v/io.github.gkngkc.unity-standalone-file-browser?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/io.github.gkngkc.unity-standalone-file-browser/) -A simple wrapper for native file dialogs on Windows/Mac/Linux. +A simple wrapper for native file dialogs on Windows/Mac/Linux, plus a dedicated WebGL browser file API. - Works in editor and runtime. - Open file/folder, save file dialogs supported. @@ -9,7 +9,7 @@ A simple wrapper for native file dialogs on Windows/Mac/Linux. - File extension filter. - Mono/IL2CPP backends supported. - Linux support by [Ricardo Rodrigues](https://github.com/RicardoEPRodrigues). -- Basic WebGL support. +- Dedicated WebGL browser file API. - Merged - https://github.com/gkngkc/UnityStandaloneFileBrowser/pull/76 - https://github.com/gkngkc/UnityStandaloneFileBrowser/pull/91 @@ -108,9 +108,50 @@ Notes: * Sync calls are throws an exception at development build after native panel loses and gains focus. Use async calls to avoid this. WebGL: - - Basic upload/download file support. - - File filter support. - - Not well tested, probably not much reliable. - - Since browsers require more work to do file operations, webgl isn't directly implemented to Open/Save calls. You can check CanvasSampleScene.unity and canvas sample scripts for example usages. - + - WebGL uses the dedicated `StandaloneFileBrowserWebGL` API instead of the desktop `StandaloneFileBrowser` path-based API. + - The desktop `StandaloneFileBrowser` API is intentionally unavailable in WebGL builds; use explicit platform branching. + - Opening files returns temporary browser file references (`WebGLFileReference`) with `ObjectUrl`, `Name`, `Size`, `MimeType`, and `LastModifiedUnixMs` metadata. + - Recommended read path is `ObjectUrl + UnityWebRequest` / `UnityWebRequestTexture`, then explicit `Release(...)` after the read completes. + - Saving uses `SaveFileAsync(fileName, data[, mimeType])` and triggers a browser download rather than a native save dialog. + - File filters are picker hints only. + - Browser limitations still apply: meaningful `title` / default-directory semantics are not available, and open/save should be triggered directly from a user gesture (OnPointerDown). + - If open is triggered outside a valid user gesture, it returns `Error` with an explanatory message; save returns `BlockedByBrowser`. + - First-version concurrency is one open request and one save request at a time. + + Example WebGL usage: + ```csharp + using System.Collections; + using SFB; + using UnityEngine; + using UnityEngine.Networking; + + public void OpenTextFile() { + StandaloneFileBrowserWebGL.OpenFilePanelAsync("txt", false, result => { + if (result.Status != WebGLFileSelectionStatus.Success || result.Files.Length == 0) { + return; + } + + var file = result.Files[0]; + StartCoroutine(ReadTextAndRelease(file)); + }); + } + + private IEnumerator ReadTextAndRelease(WebGLFileReference file) { + var request = UnityWebRequest.Get(file.ObjectUrl); + yield return request.SendWebRequest(); + + if (request.result == UnityWebRequest.Result.Success) { + Debug.Log(request.downloadHandler.text); + } + + StandaloneFileBrowserWebGL.Release(file); + } + + public void SaveTextFile(byte[] bytes) { + StandaloneFileBrowserWebGL.SaveFileAsync("sample.txt", bytes, result => { + Debug.Log(result.Status); + }); + } + ``` + Live Demo: https://gkngkc.github.io/