diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72e10bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vs/ +bin/ +obj/ +*.user +samples/Sample.WebAssembly/wwwroot/_content/ diff --git a/BlazorInputFile.sln b/BlazorInputFile.sln new file mode 100644 index 0000000..fcd9614 --- /dev/null +++ b/BlazorInputFile.sln @@ -0,0 +1,86 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29230.61 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorInputFile", "BlazorInputFile\BlazorInputFile.csproj", "{C3C06DC6-FC66-4266-B9B9-16F2F233B87D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{EEEC40BE-3F2B-4B6E-9040-71FF3DC274AA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.Server", "samples\Sample.Server\Sample.Server.csproj", "{BE1CED5B-9590-4170-BB54-822555E920CF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.WebAssembly", "samples\Sample.WebAssembly\Sample.WebAssembly.csproj", "{28B157AE-2F2D-4438-8493-3FE9148E442B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Core", "samples\Sample.Core\Sample.Core.csproj", "{5D825DE4-4A0D-439F-A3BB-F16B33461F16}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Debug|x64.Build.0 = Debug|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Debug|x86.Build.0 = Debug|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Release|Any CPU.Build.0 = Release|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Release|x64.ActiveCfg = Release|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Release|x64.Build.0 = Release|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Release|x86.ActiveCfg = Release|Any CPU + {C3C06DC6-FC66-4266-B9B9-16F2F233B87D}.Release|x86.Build.0 = Release|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Debug|x64.Build.0 = Debug|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Debug|x86.Build.0 = Debug|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Release|Any CPU.Build.0 = Release|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Release|x64.ActiveCfg = Release|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Release|x64.Build.0 = Release|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Release|x86.ActiveCfg = Release|Any CPU + {BE1CED5B-9590-4170-BB54-822555E920CF}.Release|x86.Build.0 = Release|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Debug|x64.ActiveCfg = Debug|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Debug|x64.Build.0 = Debug|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Debug|x86.ActiveCfg = Debug|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Debug|x86.Build.0 = Debug|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Release|Any CPU.Build.0 = Release|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Release|x64.ActiveCfg = Release|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Release|x64.Build.0 = Release|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Release|x86.ActiveCfg = Release|Any CPU + {28B157AE-2F2D-4438-8493-3FE9148E442B}.Release|x86.Build.0 = Release|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Debug|x64.Build.0 = Debug|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Debug|x86.Build.0 = Debug|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Release|Any CPU.Build.0 = Release|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Release|x64.ActiveCfg = Release|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Release|x64.Build.0 = Release|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Release|x86.ActiveCfg = Release|Any CPU + {5D825DE4-4A0D-439F-A3BB-F16B33461F16}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BE1CED5B-9590-4170-BB54-822555E920CF} = {EEEC40BE-3F2B-4B6E-9040-71FF3DC274AA} + {28B157AE-2F2D-4438-8493-3FE9148E442B} = {EEEC40BE-3F2B-4B6E-9040-71FF3DC274AA} + {5D825DE4-4A0D-439F-A3BB-F16B33461F16} = {EEEC40BE-3F2B-4B6E-9040-71FF3DC274AA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {843608D9-10E0-41C6-9955-83621659FFE5} + EndGlobalSection +EndGlobal diff --git a/BlazorInputFile/BlazorInputFile.csproj b/BlazorInputFile/BlazorInputFile.csproj new file mode 100644 index 0000000..d9551e7 --- /dev/null +++ b/BlazorInputFile/BlazorInputFile.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0;netstandard2.1 + 3.0 + preview + 0.1.0-preview + Apache-2.0 + + + + + + + + diff --git a/BlazorInputFile/FileListEntryImpl.cs b/BlazorInputFile/FileListEntryImpl.cs new file mode 100644 index 0000000..4ec8883 --- /dev/null +++ b/BlazorInputFile/FileListEntryImpl.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; + +namespace BlazorInputFile +{ + // This is public only because it's used in a JSInterop method signature, + // but otherwise is intended as internal + public class FileListEntryImpl : IFileListEntry + { + internal InputFile Owner { get; set; } + + private Stream _stream; + + public event EventHandler OnDataRead; + + public int Id { get; set; } + + public DateTime LastModified { get; set; } + + public string Name { get; set; } + + public long Size { get; set; } + + public string Type { get; set; } + + public Stream Data + { + get + { + _stream ??= Owner.OpenFileStream(this); + return _stream; + } + } + + internal void RaiseOnDataRead() + { + OnDataRead?.Invoke(this, null); + } + } +} diff --git a/BlazorInputFile/FileListEntryStream.cs b/BlazorInputFile/FileListEntryStream.cs new file mode 100644 index 0000000..0919208 --- /dev/null +++ b/BlazorInputFile/FileListEntryStream.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace BlazorInputFile +{ + // TODO: When ReadAsync is called, don't just fetch the segment of data that's being requested. + // That will be very slow, as you may be doing a separate round-trip for each 1KB or so of data. + // Instead, have a larger buffer whose size == SignalR.MaxMessageSize and populate that. Then + // many of the ReadAsync calls can return immediately with already-loaded data. + // + // This is still not as fast as allowing the client to send as much data as it wants, and using + // TCP to apply backpressure. In the future we could achieve something closer to that by having + // an even larger buffer, and telling the client to send N messages in parallel. The ReadAsync + // calls would return whenever their portion of the buffer was populated. This is much more + // complicated to implement. + + public abstract class FileListEntryStream : Stream + { + protected readonly IJSRuntime _jsRuntime; + protected readonly ElementReference _inputFileElement; + protected readonly FileListEntryImpl _file; + private long _position; + + public FileListEntryStream(IJSRuntime jsRuntime, ElementReference inputFileElement, FileListEntryImpl file) + { + _jsRuntime = jsRuntime; + _inputFileElement = inputFileElement; + _file = file; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _file.Size; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException("Synchronous reads are not supported"); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var maxBytesToRead = (int)Math.Min(count, Length - Position); + if (maxBytesToRead == 0) + { + return 0; + } + + var actualBytesRead = await CopyFileDataIntoBuffer(_position, buffer, offset, maxBytesToRead, cancellationToken); + _position += actualBytesRead; + _file.RaiseOnDataRead(); + return actualBytesRead; + } + + protected abstract Task CopyFileDataIntoBuffer(long sourceOffset, byte[] destination, int destinationOffset, int maxBytes, CancellationToken cancellationToken); + } +} diff --git a/BlazorInputFile/IFileListEntry.cs b/BlazorInputFile/IFileListEntry.cs new file mode 100644 index 0000000..9a6f5fc --- /dev/null +++ b/BlazorInputFile/IFileListEntry.cs @@ -0,0 +1,20 @@ +using System; +using System.IO; + +namespace BlazorInputFile +{ + public interface IFileListEntry + { + DateTime LastModified { get; } + + string Name { get; } + + long Size { get; } + + string Type { get; } + + Stream Data { get; } + + event EventHandler OnDataRead; + } +} diff --git a/BlazorInputFile/InputFile.razor b/BlazorInputFile/InputFile.razor new file mode 100644 index 0000000..0b37811 --- /dev/null +++ b/BlazorInputFile/InputFile.razor @@ -0,0 +1,47 @@ +@implements IDisposable +@inject IJSRuntime JSRuntime + + + +@code { + [Parameter(CaptureUnmatchedValues = true)] public Dictionary UnmatchedParameters { get; set; } + [Parameter] public EventCallback OnChange { get; set; } + [Parameter] public int MaxMessageSize { get; set; } = 20 * 1024; // TODO: Use SignalR default + [Parameter] public int MaxBufferSize { get; set; } = 1024 * 1024; + + ElementReference inputFileElement; + IDisposable thisReference; + + [JSInvokable] + public Task NotifyChange(FileListEntryImpl[] files) + { + foreach (var file in files) + { + // So that method invocations on the file can be dispatched back here + file.Owner = (InputFile)(object)this; + } + + return OnChange.InvokeAsync(files); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + thisReference = DotNetObjectReference.Create(this); + await JSRuntime.InvokeAsync("BlazorInputFile.init", inputFileElement, thisReference); + } + } + + internal Stream OpenFileStream(FileListEntryImpl file) + { + return SharedMemoryFileListEntryStream.IsSupported(JSRuntime) + ? (Stream)new SharedMemoryFileListEntryStream(JSRuntime, inputFileElement, file) + : new RemoteFileListEntryStream(JSRuntime, inputFileElement, file, MaxMessageSize, MaxBufferSize); + } + + void IDisposable.Dispose() + { + thisReference?.Dispose(); + } +} diff --git a/BlazorInputFile/PreFetchingSequence.cs b/BlazorInputFile/PreFetchingSequence.cs new file mode 100644 index 0000000..a840a80 --- /dev/null +++ b/BlazorInputFile/PreFetchingSequence.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace BlazorInputFile +{ + internal class PreFetchingSequence + { + private readonly Func _fetchCallback; + private readonly int _maxBufferCapacity; + private readonly long _totalFetchableItems; + private readonly Queue _buffer; + private long _maxFetchedIndex; + + public PreFetchingSequence(Func fetchCallback, long totalFetchableItems, int maxBufferCapacity) + { + _fetchCallback = fetchCallback; + _buffer = new Queue(); + _maxBufferCapacity = maxBufferCapacity; + _totalFetchableItems = totalFetchableItems; + } + + public T ReadNext(CancellationToken cancellationToken) + { + EnqueueFetches(cancellationToken); + if (_buffer.Count == 0) + { + throw new InvalidOperationException("There are no more entries to read"); + } + + var next = _buffer.Dequeue(); + EnqueueFetches(cancellationToken); + return next; + } + + public bool TryPeekNext(out T result) + { + if (_buffer.Count > 0) + { + result = _buffer.Peek(); + return true; + } + else + { + result = default; + return false; + } + } + + private void EnqueueFetches(CancellationToken cancellationToken) + { + while (_buffer.Count < _maxBufferCapacity && _maxFetchedIndex < _totalFetchableItems) + { + _buffer.Enqueue(_fetchCallback(_maxFetchedIndex++, cancellationToken)); + } + } + } +} diff --git a/BlazorInputFile/RemoteFileListEntryStream.cs b/BlazorInputFile/RemoteFileListEntryStream.cs new file mode 100644 index 0000000..db33f37 --- /dev/null +++ b/BlazorInputFile/RemoteFileListEntryStream.cs @@ -0,0 +1,133 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorInputFile +{ + // This class streams data from JS within the existing API limits of IJSRuntime. + // To produce good throughput, it prefetches up to buffer_size data from JS + // even when the consumer isn't asking for that much data, and does so by making + // N parallel requests in parallel (N ~= buffer_size / max_message_size). + // + // This should be understood as a TEMPORARY way to achieve the desired API and + // reasonable performance. Longer term we can surely replace this with something + // simpler and cleaner, either: + // + // - Extending JS interop to allow streaming responses via SignalR's built-in + // binary streaming support. That should reduce all of this to triviality. + // - Or, failing that, at least use something like System.IO.Pipelines to manage + // the supply/consumption of byte data with less custom code. + + internal class RemoteFileListEntryStream : FileListEntryStream + { + private readonly int _maxMessageSize; + private readonly PreFetchingSequence _blockSequence; + private Block? _currentBlock; + private byte[] _currentBlockDecodingBuffer; + private int _currentBlockDecodingBufferConsumedLength; + + public RemoteFileListEntryStream(IJSRuntime jsRuntime, ElementReference inputFileElement, FileListEntryImpl file, int maxMessageSize, int maxBufferSize) + : base(jsRuntime, inputFileElement, file) + { + _maxMessageSize = maxMessageSize; + _blockSequence = new PreFetchingSequence( + FetchBase64Block, + (file.Size + _maxMessageSize - 1) / _maxMessageSize, + Math.Max(1, maxBufferSize / _maxMessageSize)); // Degree of parallelism on fetch + _currentBlockDecodingBuffer = new byte[_maxMessageSize]; + } + + protected override async Task CopyFileDataIntoBuffer(long sourceOffset, byte[] destination, int destinationOffset, int maxBytes, CancellationToken cancellationToken) + { + var totalBytesCopied = 0; + + while (maxBytes > 0) + { + // If we don't yet have a block, or it's fully consumed, get the next one + if (!_currentBlock.HasValue || _currentBlockDecodingBufferConsumedLength == _currentBlock.Value.LengthBytes) + { + // If we've already read some data, and the next block is still pending, + // then just return now rather than awaiting + if (totalBytesCopied > 0 + && _blockSequence.TryPeekNext(out var nextBlock) + && !nextBlock.Base64.IsCompleted) + { + break; + } + + _currentBlock = _blockSequence.ReadNext(cancellationToken); + var currentBlockBase64 = await _currentBlock.Value.Base64; + + // As a possible future optimisation, if we know the current block will fit entirely in + // the remaining destination space, we could decode directly into the destination without + // going via _currentBlockDecodingBuffer. However that complicates the logic a lot. + DecodeBase64ToBuffer(currentBlockBase64, _currentBlockDecodingBuffer, 0, _currentBlock.Value.LengthBytes); + _currentBlockDecodingBufferConsumedLength = 0; + } + + // How much of the current block can we fit into the destination? + var numUnconsumedBytesInBlock = _currentBlock.Value.LengthBytes - _currentBlockDecodingBufferConsumedLength; + var numBytesToTransfer = Math.Min(numUnconsumedBytesInBlock, maxBytes); + if (numBytesToTransfer == 0) + { + break; + } + + // Perform the copy + Array.Copy(_currentBlockDecodingBuffer, _currentBlockDecodingBufferConsumedLength, destination, destinationOffset, numBytesToTransfer); + maxBytes -= numBytesToTransfer; + destinationOffset += numBytesToTransfer; + _currentBlockDecodingBufferConsumedLength += numBytesToTransfer; + totalBytesCopied += numBytesToTransfer; + } + + return totalBytesCopied; + } + + private Block FetchBase64Block(long index, CancellationToken cancellationToken) + { + var sourceOffset = index * _maxMessageSize; + var blockLength = (int)Math.Min(_maxMessageSize, _file.Size - sourceOffset); + var task = _jsRuntime.InvokeAsync( + "BlazorInputFile.readFileData", + cancellationToken, + _inputFileElement, + _file.Id, + index * _maxMessageSize, + blockLength).AsTask(); + return new Block(task, blockLength); + } + + private int DecodeBase64ToBuffer(string base64, byte[] buffer, int offset, int maxBytesToRead) + { +#if NETSTANDARD2_1 + var bufferWithOffset = new Span(buffer, offset, maxBytesToRead); + return Convert.TryFromBase64String(base64, bufferWithOffset, out var actualBytesRead) + ? actualBytesRead + : throw new InvalidOperationException("Failed to decode base64 data"); +#else + var bytes = Convert.FromBase64String(base64); + if (bytes.Length > maxBytesToRead) + { + throw new InvalidOperationException($"Requested a maximum of {maxBytesToRead}, but received {bytes.Length}"); + } + Array.Copy(bytes, 0, buffer, offset, bytes.Length); + return bytes.Length; +#endif + } + + private readonly struct Block + { + public readonly Task Base64; + public readonly int LengthBytes; + + public Block(Task base64, int lengthBytes) + { + Base64 = base64; + LengthBytes = lengthBytes; + } + } + } +} diff --git a/BlazorInputFile/SharedMemoryFileListEntryStream.cs b/BlazorInputFile/SharedMemoryFileListEntryStream.cs new file mode 100644 index 0000000..64d555c --- /dev/null +++ b/BlazorInputFile/SharedMemoryFileListEntryStream.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace BlazorInputFile +{ + // This is used on WebAssembly + internal class SharedMemoryFileListEntryStream : FileListEntryStream + { + private readonly static Type MonoWebAssemblyJSRuntimeType + = Type.GetType("Mono.WebAssembly.Interop.MonoWebAssemblyJSRuntime, Mono.WebAssembly.Interop"); + private static MethodInfo _cachedInvokeUnmarshalledMethodInfo; + + public SharedMemoryFileListEntryStream(IJSRuntime jsRuntime, ElementReference inputFileElement, FileListEntryImpl file) + : base(jsRuntime, inputFileElement, file) + { + } + + public static bool IsSupported(IJSRuntime jsRuntime) + { + return MonoWebAssemblyJSRuntimeType != null + && MonoWebAssemblyJSRuntimeType.IsAssignableFrom(jsRuntime.GetType()); + } + + protected override async Task CopyFileDataIntoBuffer(long sourceOffset, byte[] destination, int destinationOffset, int maxBytes, CancellationToken cancellationToken) + { + await _jsRuntime.InvokeAsync( + "BlazorInputFile.ensureArrayBufferReadyForSharedMemoryInterop", + cancellationToken, + _inputFileElement, + _file.Id); + + var methodInfo = GetCachedInvokeUnmarshalledMethodInfo(); + return (int)methodInfo.Invoke(_jsRuntime, new object[] + { + "BlazorInputFile.readFileDataSharedMemory", + new ReadRequest + { + InputFileElementReferenceId = _inputFileElement.Id, + FileId = _file.Id, + SourceOffset = sourceOffset, + Destination = destination, + DestinationOffset = destinationOffset, + MaxBytes = maxBytes, + } + }); + } + + private static MethodInfo GetCachedInvokeUnmarshalledMethodInfo() + { + if (_cachedInvokeUnmarshalledMethodInfo == null) + { + foreach (var possibleMethodInfo in MonoWebAssemblyJSRuntimeType.GetMethods()) + { + if (possibleMethodInfo.Name == "InvokeUnmarshalled" && possibleMethodInfo.GetParameters().Length == 2) + { + _cachedInvokeUnmarshalledMethodInfo = possibleMethodInfo + .MakeGenericMethod(typeof(ReadRequest), typeof(int)); + break; + } + } + + if (_cachedInvokeUnmarshalledMethodInfo == null) + { + throw new InvalidOperationException("Could not find the 2-param overload of InvokeUnmarshalled"); + } + } + + return _cachedInvokeUnmarshalledMethodInfo; + } + + [StructLayout(LayoutKind.Explicit)] + struct ReadRequest + { + [FieldOffset(0)] public string InputFileElementReferenceId; + [FieldOffset(4)] public int FileId; + [FieldOffset(8)] public long SourceOffset; + [FieldOffset(16)] public byte[] Destination; + [FieldOffset(20)] public int DestinationOffset; + [FieldOffset(24)] public int MaxBytes; + } + } +} diff --git a/BlazorInputFile/_Imports.razor b/BlazorInputFile/_Imports.razor new file mode 100644 index 0000000..3693334 --- /dev/null +++ b/BlazorInputFile/_Imports.razor @@ -0,0 +1,2 @@ +@using System.IO +@using Microsoft.JSInterop diff --git a/BlazorInputFile/wwwroot/inputfile.js b/BlazorInputFile/wwwroot/inputfile.js new file mode 100644 index 0000000..13e7ab3 --- /dev/null +++ b/BlazorInputFile/wwwroot/inputfile.js @@ -0,0 +1,159 @@ +(function () { + window.BlazorInputFile = { + init: function init(elem, componentInstance) { + var nextFileId = 0; + + elem.addEventListener('change', function handleInputFileChange(event) { + // Reduce to purely serializable data, plus build an index by ID + elem._blazorFilesById = {}; + var fileList = Array.prototype.map.call(elem.files, function (file) { + var result = { + id: ++nextFileId, + lastModified: new Date(file.lastModified).toISOString(), + name: file.name, + size: file.size, + type: file.type + }; + elem._blazorFilesById[result.id] = result; + + // Attach the blob data itself as a non-enumerable property so it doesn't appear in the JSON + Object.defineProperty(result, 'blob', { value: file }); + + return result; + }); + + componentInstance.invokeMethodAsync('NotifyChange', fileList).then(null, function (err) { + throw new Error(err); + }); + }); + }, + + readFileData: function readFileData(elem, fileId, startOffset, count) { + var readPromise = getArrayBufferFromFileAsync(elem, fileId); + + return readPromise.then(function (arrayBuffer) { + var uint8Array = new Uint8Array(arrayBuffer, startOffset, count); + var base64 = uint8ToBase64(uint8Array); + return base64; + }); + }, + + ensureArrayBufferReadyForSharedMemoryInterop: function ensureArrayBufferReadyForSharedMemoryInterop(elem, fileId) { + return getArrayBufferFromFileAsync(elem, fileId).then(function (arrayBuffer) { + getFileById(elem, fileId).arrayBuffer = arrayBuffer; + }); + }, + + readFileDataSharedMemory: function readFileDataSharedMemory(readRequest) { + // This uses various unsupported internal APIs. Beware that if you also use them, + // your code could become broken by any update. + var inputFileElementReferenceId = Blazor.platform.readStringField(readRequest, 0); + var inputFileElement = document.querySelector('[_bl_' + inputFileElementReferenceId + ']'); + var fileId = Blazor.platform.readInt32Field(readRequest, 4); + var sourceOffset = Blazor.platform.readUint64Field(readRequest, 8); + var destination = Blazor.platform.readInt32Field(readRequest, 16); + var destinationOffset = Blazor.platform.readInt32Field(readRequest, 20); + var maxBytes = Blazor.platform.readInt32Field(readRequest, 24); + + var sourceArrayBuffer = getFileById(inputFileElement, fileId).arrayBuffer; + var bytesToRead = Math.min(maxBytes, sourceArrayBuffer.byteLength - sourceOffset); + var sourceUint8Array = new Uint8Array(sourceArrayBuffer, sourceOffset, bytesToRead); + + var destinationUint8Array = Blazor.platform.toUint8Array(destination); + destinationUint8Array.set(sourceUint8Array, destinationOffset); + + return bytesToRead; + } + }; + + function getFileById(elem, fileId) { + var file = elem._blazorFilesById[fileId]; + if (!file) { + throw new Error('There is no file with ID ' + fileId + '. The file list may have changed'); + } + + return file; + } + + function getArrayBufferFromFileAsync(elem, fileId) { + var file = getFileById(elem, fileId); + + // On the first read, convert the FileReader into a Promise + if (!file.readPromise) { + file.readPromise = new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function () { resolve(reader.result); }; + reader.onerror = function (err) { reject(err); }; + reader.readAsArrayBuffer(file.blob); + }); + } + + return file.readPromise; + } + + var uint8ToBase64 = (function () { + // Code from https://github.com/beatgammit/base64-js/ + // License: MIT + var lookup = []; + + var code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + for (var i = 0, len = code.length; i < len; ++i) { + lookup[i] = code[i]; + } + + function tripletToBase64(num) { + return lookup[num >> 18 & 0x3F] + + lookup[num >> 12 & 0x3F] + + lookup[num >> 6 & 0x3F] + + lookup[num & 0x3F]; + } + + function encodeChunk(uint8, start, end) { + var tmp; + var output = []; + for (var i = start; i < end; i += 3) { + tmp = + ((uint8[i] << 16) & 0xFF0000) + + ((uint8[i + 1] << 8) & 0xFF00) + + (uint8[i + 2] & 0xFF); + output.push(tripletToBase64(tmp)); + } + return output.join(''); + } + + return function fromByteArray(uint8) { + var tmp; + var len = uint8.length; + var extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes + var parts = []; + var maxChunkLength = 16383; // must be multiple of 3 + + // go through the array every three bytes, we'll deal with trailing stuff later + for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) { + parts.push(encodeChunk( + uint8, i, (i + maxChunkLength) > len2 ? len2 : (i + maxChunkLength) + )); + } + + // pad the end with zeros, but make sure to not forget the extra bytes + if (extraBytes === 1) { + tmp = uint8[len - 1]; + parts.push( + lookup[tmp >> 2] + + lookup[(tmp << 4) & 0x3F] + + '==' + ); + } else if (extraBytes === 2) { + tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + parts.push( + lookup[tmp >> 10] + + lookup[(tmp >> 4) & 0x3F] + + lookup[(tmp << 2) & 0x3F] + + '=' + ); + } + + return parts.join(''); + }; + })(); +})(); diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9339681 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + 3.0.0-preview9.19424.4 + 3.0.0-preview9.19424.4 + + diff --git a/samples/Sample.Core/App.razor b/samples/Sample.Core/App.razor new file mode 100644 index 0000000..2b4564e --- /dev/null +++ b/samples/Sample.Core/App.razor @@ -0,0 +1,17 @@ + +
+ + + + + +

Sorry, there's nothing here.

+
+
+
diff --git a/samples/Sample.Core/Pages/DragDropViewer.razor b/samples/Sample.Core/Pages/DragDropViewer.razor new file mode 100644 index 0000000..d4c731d --- /dev/null +++ b/samples/Sample.Core/Pages/DragDropViewer.razor @@ -0,0 +1,50 @@ +@page "/dragdrop-viewer" + +

Drag/drop file viewer

+ +

Shows how you can present a custom UI instead of the native file input.

+ +
+ + @status +
+ +@if (fileName != null) +{ +

@fileName

+
@fileTextContents
+} + +@code { + const string DefaultStatus = "Drop a text file here to view it, or click to choose a file"; + const int MaxFileSize = 5 * 1024 * 1024; // 5MB + string status = DefaultStatus; + + string fileName; + string fileTextContents; + + async Task ViewFile(IFileListEntry[] files) + { + var file = files.FirstOrDefault(); + if (file == null) + { + return; + } + else if (file.Size > MaxFileSize) + { + status = $"That's too big. Max size: {MaxFileSize} bytes."; + } + else + { + status = "Loading..."; + + using (var reader = new StreamReader(file.Data)) + { + fileTextContents = await reader.ReadToEndAsync(); + fileName = file.Name; + } + + status = DefaultStatus; + } + } +} diff --git a/samples/Sample.Core/Pages/MultiFile.razor b/samples/Sample.Core/Pages/MultiFile.razor new file mode 100644 index 0000000..085aecd --- /dev/null +++ b/samples/Sample.Core/Pages/MultiFile.razor @@ -0,0 +1,57 @@ +@page "/multi" + +

Multiple files

+ +

A multi-file picker that displays information about selection and shows progress as each one is loaded.

+ + + +@if (selectedFiles != null) +{ + foreach (var file in selectedFiles) + { + var isLoading = file.Data.Position > 0; + +
+ +
+

@file.Name

+ Size: @file.Size bytes; + Last modified: @file.LastModified.ToShortDateString(); + Type: @file.Type +
+ + + +
+ } +} + +@code { + IFileListEntry[] selectedFiles; + + void HandleSelection(IFileListEntry[] files) + { + selectedFiles = files; + } + + async Task LoadFile(IFileListEntry file) + { + // So the UI updates to show progress + file.OnDataRead += (sender, eventArgs) => InvokeAsync(StateHasChanged); + + // Just load into .NET memory to show it can be done + // Alternatively it could be saved to disk, or parsed in memory, or similar + var ms = new MemoryStream(); + await file.Data.CopyToAsync(ms); + } +} diff --git a/samples/Sample.Core/Pages/NativeUpload.razor b/samples/Sample.Core/Pages/NativeUpload.razor new file mode 100644 index 0000000..1a5ef4a --- /dev/null +++ b/samples/Sample.Core/Pages/NativeUpload.razor @@ -0,0 +1,13 @@ +@page "/native" + +

Native upload

+ +

+ This sample does not use InputFile. Instead, it's a plain HTML + <input type="file">, so you can compare the user experience. +

+ +
+ +

+
diff --git a/samples/Sample.Core/Pages/SingleFile.razor b/samples/Sample.Core/Pages/SingleFile.razor new file mode 100644 index 0000000..b06b2bc --- /dev/null +++ b/samples/Sample.Core/Pages/SingleFile.razor @@ -0,0 +1,27 @@ +@page "/" + +

Single file

+ +

A single file input that uploads automatically on file selection

+ + + +

@status

+ +@code { + string status; + + async Task HandleSelection(IFileListEntry[] files) + { + var file = files.FirstOrDefault(); + if (file != null) + { + // Just load into .NET memory to show it can be done + // Alternatively it could be saved to disk, or parsed in memory, or similar + var ms = new MemoryStream(); + await file.Data.CopyToAsync(ms); + + status = $"Finished loading {file.Size} bytes from {file.Name}"; + } + } +} diff --git a/samples/Sample.Core/Sample.Core.csproj b/samples/Sample.Core/Sample.Core.csproj new file mode 100644 index 0000000..2eff083 --- /dev/null +++ b/samples/Sample.Core/Sample.Core.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + 3.0 + + + + + + + + + + + + diff --git a/samples/Sample.Core/_Imports.razor b/samples/Sample.Core/_Imports.razor new file mode 100644 index 0000000..407272a --- /dev/null +++ b/samples/Sample.Core/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using BlazorInputFile +@using System.IO diff --git a/samples/Sample.Core/wwwroot/sample-styles.css b/samples/Sample.Core/wwwroot/sample-styles.css new file mode 100644 index 0000000..572315c --- /dev/null +++ b/samples/Sample.Core/wwwroot/sample-styles.css @@ -0,0 +1,108 @@ +body { + font-family: sans-serif; + margin: 0; +} + +main, nav { + padding: 1.25rem 2rem; +} + +h1, h2, h3, h4, h5 { + margin-top: 1rem; +} + +nav { + background-color: #c9c9c9; + display: flex; + align-items: center; + color: #3d3d3d; + box-shadow: 0 0 3px; +} + + nav a { + text-decoration: none; + background-color: #eeeeee; + color: black; + padding: 0.3rem 1rem; + display: inline-block; + box-shadow: 0 0 3px rgba(0,0,0,0.4); + } + + nav a:first-of-type { + margin-left: 0.6rem; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + nav a:last-of-type { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + + nav a:hover { + background-color: white; + } + + nav a.active, nav a.active:hover { + background-color: #3069f6; + color: white; + } + +.file-row { + background-color: #e4e4e4; + padding: 1rem 1.5rem; + margin-top: 1rem; + border-radius: 0.6rem; + color: #555; + display: flex; + align-items: center; +} + + .file-row h2 { + margin: 0.3rem 0 0.6rem 0; + font-weight: bold; + color: black; + font-size: 1.1rem; + } + + .file-row > div { + flex-grow: 1; + } + + .file-row button { + padding: 0.5rem 1rem; + } + +.drag-drop-zone { + border: 3px dashed #e68710; + padding: 3rem; + display: flex; + align-items: center; + justify-content: center; + background-color: #eee; + box-shadow: inset 0 0 8px rgba(0,0,0,0.2); + color: #aeaeae; + font-size: 1.5rem; + cursor: pointer; + margin: 1.5rem 0 2rem 0; + position: relative; +} + + .drag-drop-zone:hover { + background-color: #f5f5f5; + } + + .drag-drop-zone input[type=file] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + } + +pre { + background-color: #f0f0f0; + overflow: auto; + padding: 1rem; + height: 10rem; +} diff --git a/samples/Sample.Server/Pages/_Host.cshtml b/samples/Sample.Server/Pages/_Host.cshtml new file mode 100644 index 0000000..f078700 --- /dev/null +++ b/samples/Sample.Server/Pages/_Host.cshtml @@ -0,0 +1,19 @@ +@page "/" +@namespace Sample.Core +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + + + + + + + Sample.Server + + + + + @(await Html.RenderComponentAsync(RenderMode.ServerPrerendered)) + + + + diff --git a/samples/Sample.Server/Program.cs b/samples/Sample.Server/Program.cs new file mode 100644 index 0000000..e3e09e2 --- /dev/null +++ b/samples/Sample.Server/Program.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Sample.Server +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/samples/Sample.Server/Properties/launchSettings.json b/samples/Sample.Server/Properties/launchSettings.json new file mode 100644 index 0000000..0967e78 --- /dev/null +++ b/samples/Sample.Server/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:1744", + "sslPort": 44371 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Sample.Server": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Sample.Server/Sample.Server.csproj b/samples/Sample.Server/Sample.Server.csproj new file mode 100644 index 0000000..0808c05 --- /dev/null +++ b/samples/Sample.Server/Sample.Server.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp3.0 + + + + + + + diff --git a/samples/Sample.Server/Startup.cs b/samples/Sample.Server/Startup.cs new file mode 100644 index 0000000..08fdb76 --- /dev/null +++ b/samples/Sample.Server/Startup.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Sample.Server +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.AddRazorPages(); + services.AddServerSideBlazor(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapPost("/native-upload", async ctx => + { + var start = DateTime.Now; + var ms = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(ms); + var end = DateTime.Now; + await ctx.Response.WriteAsync($"Received {ms.Position} bytes in {(end - start).TotalMilliseconds} ms"); + }); + + endpoints.MapBlazorHub(); + endpoints.MapFallbackToPage("/_Host"); + }); + } + } +} diff --git a/samples/Sample.Server/appsettings.Development.json b/samples/Sample.Server/appsettings.Development.json new file mode 100644 index 0000000..e203e94 --- /dev/null +++ b/samples/Sample.Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/samples/Sample.Server/appsettings.json b/samples/Sample.Server/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/samples/Sample.Server/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Sample.Server/wwwroot/favicon.ico b/samples/Sample.Server/wwwroot/favicon.ico new file mode 100644 index 0000000..a3a7999 Binary files /dev/null and b/samples/Sample.Server/wwwroot/favicon.ico differ diff --git a/samples/Sample.WebAssembly/Program.cs b/samples/Sample.WebAssembly/Program.cs new file mode 100644 index 0000000..75bdf6b --- /dev/null +++ b/samples/Sample.WebAssembly/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Blazor.Hosting; + +namespace Sample.WebAssembly +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) => + BlazorWebAssemblyHost.CreateDefaultBuilder() + .UseBlazorStartup(); + } +} diff --git a/samples/Sample.WebAssembly/Properties/launchSettings.json b/samples/Sample.WebAssembly/Properties/launchSettings.json new file mode 100644 index 0000000..ae10aae --- /dev/null +++ b/samples/Sample.WebAssembly/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:59360/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Sample.WebAssembly": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:59361/" + } + } +} \ No newline at end of file diff --git a/samples/Sample.WebAssembly/Sample.WebAssembly.csproj b/samples/Sample.WebAssembly/Sample.WebAssembly.csproj new file mode 100644 index 0000000..e1f5dd8 --- /dev/null +++ b/samples/Sample.WebAssembly/Sample.WebAssembly.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0 + Exe + 7.3 + 3.0 + + + + + + + + + + + + + + + + + + <_BlazorInputFile_FilesToCopy Include="..\..\BlazorInputFile\wwwroot\**" /> + <_SampleCore_FilesToCopy Include="..\Sample.Core\wwwroot\**" /> + + + + + + diff --git a/samples/Sample.WebAssembly/Startup.cs b/samples/Sample.WebAssembly/Startup.cs new file mode 100644 index 0000000..325de9d --- /dev/null +++ b/samples/Sample.WebAssembly/Startup.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components.Builder; +using Microsoft.Extensions.DependencyInjection; +using Sample.Core; + +namespace Sample.WebAssembly +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + } + + public void Configure(IComponentsApplicationBuilder app) + { + app.AddComponent("app"); + } + } +} diff --git a/samples/Sample.WebAssembly/_Imports.razor b/samples/Sample.WebAssembly/_Imports.razor new file mode 100644 index 0000000..1e2af3d --- /dev/null +++ b/samples/Sample.WebAssembly/_Imports.razor @@ -0,0 +1,7 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using Sample.WebAssembly +@using BlazorInputFile diff --git a/samples/Sample.WebAssembly/wwwroot/index.html b/samples/Sample.WebAssembly/wwwroot/index.html new file mode 100644 index 0000000..b39152b --- /dev/null +++ b/samples/Sample.WebAssembly/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + + Sample.WebAssembly + + + + + Loading... + + + + +