Skip to content

Commit

Permalink
Add Browser implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
maxkatz6 committed Jun 24, 2022
1 parent eb403ea commit 347a36e
Show file tree
Hide file tree
Showing 8 changed files with 637 additions and 12 deletions.
13 changes: 12 additions & 1 deletion src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Input.TextInput;
using Avalonia.Platform.Storage;
using Avalonia.Rendering;
using Avalonia.Web.Blazor.Interop;
using Avalonia.Web.Blazor.Interop.Storage;

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;

using SkiaSharp;

namespace Avalonia.Web.Blazor
Expand All @@ -26,6 +30,7 @@ public partial class AvaloniaView : ITextInputMethodImpl
private InputHelperInterop? _inputHelper = null;
private InputHelperInterop? _canvasHelper = null;
private NativeControlHostInterop? _nativeControlHost = null;
private StorageProviderInterop? _storageProvider = null;
private ElementReference _htmlCanvas;
private ElementReference _inputElement;
private ElementReference _nativeControlsContainer;
Expand Down Expand Up @@ -57,6 +62,11 @@ internal INativeControlHostImpl GetNativeControlHostImpl()
return _nativeControlHost ?? throw new InvalidOperationException("Blazor View wasn't initialized yet");
}

internal IStorageProvider GetStorageProvider()
{
return _storageProvider ?? throw new InvalidOperationException("Blazor View wasn't initialized yet");
}

private void OnPointerCancel(Microsoft.AspNetCore.Components.Web.PointerEventArgs e)
{
if (e.PointerType == "touch")
Expand Down Expand Up @@ -256,7 +266,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
};

_nativeControlHost = await NativeControlHostInterop.ImportAsync(Js, _nativeControlsContainer);

_storageProvider = await StorageProviderInterop.ImportAsync(Js);

Console.WriteLine("starting html canvas setup");
_interop = await SKHtmlCanvasInterop.ImportAsync(Js, _htmlCanvas, OnRenderFrame);

Expand Down
6 changes: 6 additions & 0 deletions src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ protected void Invoke(string identifier, params object?[]? args) =>
protected TValue Invoke<TValue>(string identifier, params object?[]? args) =>
Module.Invoke<TValue>(identifier, args);

protected ValueTask InvokeAsync(string identifier, params object?[]? args) =>
Module.InvokeVoidAsync(identifier, args);

protected ValueTask<TValue> InvokeAsync<TValue>(string identifier, params object?[]? args) =>
Module.InvokeAsync<TValue>(identifier, args);

protected virtual void OnDisposingModule() { }
}
}
200 changes: 200 additions & 0 deletions src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System.Diagnostics.CodeAnalysis;

using Avalonia.Platform.Storage;

using Microsoft.JSInterop;

namespace Avalonia.Web.Blazor.Interop.Storage
{
internal record FilePickerAcceptType(string Description, IReadOnlyDictionary<string, IReadOnlyList<string>> Accept);

internal record FileProperties(ulong Size, long LastModified, string? Type);

internal class StorageProviderInterop : JSModuleInterop, IStorageProvider
{
private const string JsFilename = "./_content/Avalonia.Web.Blazor/StorageProvider.js";
private const string PickerCancelMessage = "The user aborted a request";

public static async Task<StorageProviderInterop> ImportAsync(IJSRuntime js)
{
var interop = new StorageProviderInterop(js);
await interop.ImportAsync();
return interop;
}

public StorageProviderInterop(IJSRuntime js)
: base(js, JsFilename)
{
}

public bool CanOpen => Invoke<bool>("StorageProvider.canOpen");
public bool CanSave => Invoke<bool>("StorageProvider.canSave");
public bool CanPickFolder => Invoke<bool>("StorageProvider.canPickFolder");

public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
try
{
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;

var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter);
var items = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openFileDialog", startIn, options.AllowMultiple, types, exludeAll);
var count = items.Invoke<int>("count");

return Enumerable.Range(0, count)
.Select(index => new JSStorageFile(items.Invoke<IJSInProcessObjectReference>("at", index)))
.ToArray();
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return Array.Empty<IStorageFile>();
}
}

public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
try
{
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;

var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices);
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.saveFileDialog", startIn, options.SuggestedFileName, types, exludeAll);

return item is not null ? new JSStorageFile(item) : null;
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return null;
}
}

public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
try
{
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;

var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.selectFolderDialog", startIn);

return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty<IStorageFolder>();
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return Array.Empty<IStorageFolder>();
}
}

public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark);
return item is not null ? new JSStorageFile(item) : null;
}

public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark);
return item is not null ? new JSStorageFolder(item) : null;
}

private static (FilePickerAcceptType[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input)
{
var types = input?
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All)
.Select(t => new FilePickerAcceptType(t.Name, t.MimeTypes!
.ToDictionary(m => m, _ => (IReadOnlyList<string>)Array.Empty<string>())))
.ToArray();
if (types?.Length == 0)
{
types = null;
}

var inlcudeAll = input?.Contains(FilePickerFileTypes.All) == true || types is null;

return (types, !inlcudeAll);
}
}

internal abstract class JSStorageItem : IStorageBookmarkItem
{
internal IJSInProcessObjectReference? _fileHandle;

protected JSStorageItem(IJSInProcessObjectReference fileHandle)
{
_fileHandle = fileHandle ?? throw new ArgumentNullException(nameof(fileHandle));
}

internal IJSInProcessObjectReference FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem));

public string Name => FileHandle.Invoke<string>("getName");

public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
{
uri = new Uri(Name, UriKind.Relative);
return false;
}

public async Task<StorageItemProperties> GetBasicPropertiesAsync()
{
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties");

return new StorageItemProperties(
properties?.Size,
dateCreated: null,
dateModified: properties?.LastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(properties.LastModified) : null);
}

public bool CanBookmark => true;

public Task<string?> SaveBookmark()
{
return FileHandle.InvokeAsync<string?>("saveBookmark").AsTask();
}

public Task<IStorageFolder?> GetParentAsync()
{
return Task.FromResult<IStorageFolder?>(null);
}

public Task ReleaseBookmark()
{
return FileHandle.InvokeAsync<string?>("deleteBookmark").AsTask();
}

public void Dispose()
{
_fileHandle?.Dispose();
_fileHandle = null;
}
}

internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile
{
public JSStorageFile(IJSInProcessObjectReference fileHandle) : base(fileHandle)
{
}

public bool CanOpenRead => true;
public async Task<Stream> OpenRead()
{
var stream = await FileHandle.InvokeAsync<IJSStreamReference>("openRead");
// Remove maxAllowedSize limit, as developer can decide if they read only small part or everything.
return await stream.OpenReadStreamAsync(long.MaxValue, CancellationToken.None);
}

public bool CanOpenWrite => true;
public async Task<Stream> OpenWrite()
{
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties");
var streamWriter = await FileHandle.InvokeAsync<IJSInProcessObjectReference>("openWrite");

return new JSWriteableStream(streamWriter, (long)(properties?.Size ?? 0));
}
}

internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder
{
public JSStorageFolder(IJSInProcessObjectReference fileHandle) : base(fileHandle)
{
}
}
}
124 changes: 124 additions & 0 deletions src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Buffers;
using System.Text.Json.Serialization;

using Microsoft.JSInterop;

namespace Avalonia.Web.Blazor.Interop.Storage
{
// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream
internal sealed class JSWriteableStream : Stream
{
private IJSInProcessObjectReference? _jSReference;

// Unfortunatelly we can't read current length/position, so we need to keep it C#-side only.
private long _length, _position;

internal JSWriteableStream(IJSInProcessObjectReference jSReference, long initialLength)
{
_jSReference = jSReference;
_length = initialLength;
}

private IJSInProcessObjectReference JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream));

public override bool CanRead => false;

public override bool CanSeek => true;

public override bool CanWrite => true;

public override long Length => _length;

public override long Position
{
get => _position;
set => Seek(_position, SeekOrigin.Begin);
}

public override void Flush()
{
// no-op
}

public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}

public override long Seek(long offset, SeekOrigin origin)
{
var position = origin switch
{
SeekOrigin.Current => _position + offset,
SeekOrigin.End => _length + offset,
_ => offset
};
JSReference.InvokeVoid("seek", position);
return position;
}

public override void SetLength(long value)
{
_length = value;

// See https://docs.w3cub.com/dom/filesystemwritablefilestream/truncate
// If the offset is smaller than the size, it remains unchanged. If the offset is larger than size, the offset is set to that size
if (_position > _length)
{
_position = _length;
}

JSReference.InvokeVoid("truncate", value);
}

public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Synchronous writes are not supported.");
}

public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (offset != 0 || count != buffer.Length)
{
// TODO, we need to pass prepared buffer to the JS
// Can't use ArrayPool as it can return bigger array than requested
// Can't use Span/Memory, as it's not supported by JS interop yet.
// Alternatively we can pass original buffer and offset+count, so it can be trimmed on the JS side (but is it more efficient tho?)
buffer = buffer.AsMemory(offset, count).ToArray();
}
return WriteAsyncInternal(buffer, cancellationToken).AsTask();
}

public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
return WriteAsyncInternal(buffer.ToArray(), cancellationToken);
}

private ValueTask WriteAsyncInternal(byte[] buffer, CancellationToken _)
{
_position += buffer.Length;

return JSReference.InvokeVoidAsync("write", buffer);
}

protected override void Dispose(bool disposing)
{
if (_jSReference is { } jsReference)
{
_jSReference = null;
jsReference.InvokeVoid("close");
jsReference.Dispose();
}
}

public override async ValueTask DisposeAsync()
{
if (_jSReference is { } jsReference)
{
_jSReference = null;
await jsReference.InvokeVoidAsync("close");
await jsReference.DisposeAsync();
}
}
}
}
Loading

0 comments on commit 347a36e

Please sign in to comment.