From 69c82bab3dc92b33bd4fd3b13e38d73972906a77 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 13 Sep 2019 09:28:48 +0100 Subject: [PATCH] Initial state --- .gitignore | 5 + BlazorInputFile.sln | 86 ++++++++++ BlazorInputFile/BlazorInputFile.csproj | 16 ++ BlazorInputFile/FileListEntryImpl.cs | 40 +++++ BlazorInputFile/FileListEntryStream.cs | 80 +++++++++ BlazorInputFile/IFileListEntry.cs | 20 +++ BlazorInputFile/InputFile.razor | 47 ++++++ BlazorInputFile/PreFetchingSequence.cs | 58 +++++++ BlazorInputFile/RemoteFileListEntryStream.cs | 133 +++++++++++++++ .../SharedMemoryFileListEntryStream.cs | 87 ++++++++++ BlazorInputFile/_Imports.razor | 2 + BlazorInputFile/wwwroot/inputfile.js | 159 ++++++++++++++++++ Directory.Build.props | 6 + samples/Sample.Core/App.razor | 17 ++ .../Sample.Core/Pages/DragDropViewer.razor | 50 ++++++ samples/Sample.Core/Pages/MultiFile.razor | 57 +++++++ samples/Sample.Core/Pages/NativeUpload.razor | 13 ++ samples/Sample.Core/Pages/SingleFile.razor | 27 +++ samples/Sample.Core/Sample.Core.csproj | 17 ++ samples/Sample.Core/_Imports.razor | 5 + samples/Sample.Core/wwwroot/sample-styles.css | 108 ++++++++++++ samples/Sample.Server/Pages/_Host.cshtml | 19 +++ samples/Sample.Server/Program.cs | 28 +++ .../Properties/launchSettings.json | 27 +++ samples/Sample.Server/Sample.Server.csproj | 11 ++ samples/Sample.Server/Startup.cs | 69 ++++++++ .../appsettings.Development.json | 9 + samples/Sample.Server/appsettings.json | 10 ++ samples/Sample.Server/wwwroot/favicon.ico | Bin 0 -> 32038 bytes samples/Sample.WebAssembly/Program.cs | 16 ++ .../Properties/launchSettings.json | 27 +++ .../Sample.WebAssembly.csproj | 32 ++++ samples/Sample.WebAssembly/Startup.cs | 18 ++ samples/Sample.WebAssembly/_Imports.razor | 7 + samples/Sample.WebAssembly/wwwroot/index.html | 16 ++ 35 files changed, 1322 insertions(+) create mode 100644 .gitignore create mode 100644 BlazorInputFile.sln create mode 100644 BlazorInputFile/BlazorInputFile.csproj create mode 100644 BlazorInputFile/FileListEntryImpl.cs create mode 100644 BlazorInputFile/FileListEntryStream.cs create mode 100644 BlazorInputFile/IFileListEntry.cs create mode 100644 BlazorInputFile/InputFile.razor create mode 100644 BlazorInputFile/PreFetchingSequence.cs create mode 100644 BlazorInputFile/RemoteFileListEntryStream.cs create mode 100644 BlazorInputFile/SharedMemoryFileListEntryStream.cs create mode 100644 BlazorInputFile/_Imports.razor create mode 100644 BlazorInputFile/wwwroot/inputfile.js create mode 100644 Directory.Build.props create mode 100644 samples/Sample.Core/App.razor create mode 100644 samples/Sample.Core/Pages/DragDropViewer.razor create mode 100644 samples/Sample.Core/Pages/MultiFile.razor create mode 100644 samples/Sample.Core/Pages/NativeUpload.razor create mode 100644 samples/Sample.Core/Pages/SingleFile.razor create mode 100644 samples/Sample.Core/Sample.Core.csproj create mode 100644 samples/Sample.Core/_Imports.razor create mode 100644 samples/Sample.Core/wwwroot/sample-styles.css create mode 100644 samples/Sample.Server/Pages/_Host.cshtml create mode 100644 samples/Sample.Server/Program.cs create mode 100644 samples/Sample.Server/Properties/launchSettings.json create mode 100644 samples/Sample.Server/Sample.Server.csproj create mode 100644 samples/Sample.Server/Startup.cs create mode 100644 samples/Sample.Server/appsettings.Development.json create mode 100644 samples/Sample.Server/appsettings.json create mode 100644 samples/Sample.Server/wwwroot/favicon.ico create mode 100644 samples/Sample.WebAssembly/Program.cs create mode 100644 samples/Sample.WebAssembly/Properties/launchSettings.json create mode 100644 samples/Sample.WebAssembly/Sample.WebAssembly.csproj create mode 100644 samples/Sample.WebAssembly/Startup.cs create mode 100644 samples/Sample.WebAssembly/_Imports.razor create mode 100644 samples/Sample.WebAssembly/wwwroot/index.html 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 0000000000000000000000000000000000000000..a3a799985c43bc7309d701b2cad129023377dc71 GIT binary patch literal 32038 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 + 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... + + + + +