Browse Source

Add Browser implementation

pull/8303/head
Max Katz 4 years ago
parent
commit
347a36e408
  1. 13
      src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs
  2. 6
      src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs
  3. 200
      src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs
  4. 124
      src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs
  5. 292
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts
  6. 4
      src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs
  7. 9
      src/Web/Avalonia.Web.Blazor/WinStubs.cs
  8. 1
      src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs

13
src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs

@ -4,11 +4,15 @@ using Avalonia.Controls.Platform;
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
@ -26,6 +30,7 @@ namespace Avalonia.Web.Blazor
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;
@ -57,6 +62,11 @@ namespace Avalonia.Web.Blazor
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")
@ -256,7 +266,8 @@ namespace Avalonia.Web.Blazor
};
_nativeControlHost = await NativeControlHostInterop.ImportAsync(Js, _nativeControlsContainer);
_storageProvider = await StorageProviderInterop.ImportAsync(Js);
Console.WriteLine("starting html canvas setup");
_interop = await SKHtmlCanvasInterop.ImportAsync(Js, _htmlCanvas, OnRenderFrame);

6
src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs

@ -37,6 +37,12 @@ namespace Avalonia.Web.Blazor.Interop
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
src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs

@ -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
src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs

@ -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();
}
}
}
}

292
src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts

@ -0,0 +1,292 @@
// As we don't have proper package managing for Avalonia.Web project, declare types manually
declare global {
interface FileSystemWritableFileStream {
write(position: number, data: BufferSource | Blob | string): Promise<void>;
truncate(size: number): Promise<void>;
close(): Promise<void>;
}
type PermissionsMode = "read" | "readwrite";
interface FileSystemFileHandle {
name: string,
kind: "file" | "directory",
getFile(): Promise<File>;
createWritable(options?: { keepExistingData?: boolean }): Promise<FileSystemWritableFileStream>;
queryPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">;
requestPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">;
}
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos";
type StartInDirectory = WellKnownDirectory | FileSystemFileHandle;
interface FilePickerAcceptType {
description: string,
// mime -> ext[] array
accept: { [mime: string]: string | string[] }
}
interface FilePickerOptions {
types?: FilePickerAcceptType[],
excludeAcceptAllOption: boolean,
id?: string,
startIn?: StartInDirectory
}
interface OpenFilePickerOptions extends FilePickerOptions {
multiple: boolean
}
interface SaveFilePickerOptions extends FilePickerOptions {
suggestedName?: string
}
interface DirectoryPickerOptions {
id?: string,
startIn?: StartInDirectory
}
interface Window {
showOpenFilePicker: (options: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>;
showSaveFilePicker: (options: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
showDirectoryPicker: (options: DirectoryPickerOptions) => Promise<FileSystemFileHandle>;
}
}
// TODO move to another file and use import
class IndexedDbWrapper {
constructor(private databaseName: string, private objectStores: [ string ]) {
}
public connect(): Promise<InnerDbConnection> {
var conn = window.indexedDB.open(this.databaseName, 1);
conn.onupgradeneeded = event => {
const db = (<IDBRequest<IDBDatabase>>event.target).result;
this.objectStores.forEach(store => {
db.createObjectStore(store);
});
}
return new Promise((resolve, reject) => {
conn.onsuccess = event => {
resolve(new InnerDbConnection((<IDBRequest<IDBDatabase>>event.target).result));
}
conn.onerror = event => {
reject((<IDBRequest<IDBDatabase>>event.target).error);
}
});
}
}
class InnerDbConnection {
constructor(private database: IDBDatabase) { }
private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore {
const tx = this.database.transaction(store, mode);
return tx.objectStore(store);
}
public put(store: string, obj: any, key?: IDBValidKey): Promise<IDBValidKey> {
const os = this.openStore(store, "readwrite");
return new Promise((resolve, reject) => {
var response = os.put(obj, key);
response.onsuccess = () => {
resolve(response.result);
};
response.onerror = () => {
reject(response.error);
};
});
}
public get(store: string, key: IDBValidKey): any {
const os = this.openStore(store, "readonly");
return new Promise((resolve, reject) => {
var response = os.get(key);
response.onsuccess = () => {
resolve(response.result);
};
response.onerror = () => {
reject(response.error);
};
});
}
public delete(store: string, key: IDBValidKey): Promise<void> {
const os = this.openStore(store, "readwrite");
return new Promise((resolve, reject) => {
var response = os.delete(key);
response.onsuccess = () => {
resolve();
};
response.onerror = () => {
reject(response.error);
};
});
}
public close() {
this.database.close();
}
}
const fileBookmarksStore: string = "fileBookmarks";
const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [
fileBookmarksStore
])
class StorageItem {
constructor(private handle: FileSystemFileHandle, private bookmarkId?: string) { }
public getName(): string {
return this.handle.name
}
public async openRead(): Promise<Blob> {
await this.verityPermissions('read');
var file = await this.handle.getFile();
return file;
}
public async openWrite(): Promise<FileSystemWritableFileStream> {
await this.verityPermissions('readwrite');
return await this.handle.createWritable({ keepExistingData: true });
}
public async getProperties(): Promise<{ Size: number, LastModified: number, Type: string }> {
var file = this.handle.getFile && await this.handle.getFile();
return file && {
Size: file.size,
LastModified: file.lastModified,
Type: file.type
}
}
private async verityPermissions(mode: PermissionsMode): Promise<void | never> {
if (await this.handle.queryPermission({ mode }) === 'granted') {
return;
}
if (await this.handle.requestPermission({ mode }) === "denied") {
throw new Error("Read permissions denied");
}
}
public async saveBookmark(): Promise<string> {
// If file was previously bookmarked, just return old one.
if (this.bookmarkId) {
return this.bookmarkId;
}
const connection = await avaloniaDb.connect();
try {
const key = await connection.put(fileBookmarksStore, this.handle, this.generateBookmarkId());
return <string>key;
}
finally {
connection.close();
}
}
public async deleteBookmark(): Promise<void> {
if (!this.bookmarkId) {
return;
}
const connection = await avaloniaDb.connect();
try {
const key = await connection.delete(fileBookmarksStore, this.bookmarkId);
}
finally {
connection.close();
}
}
private generateBookmarkId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}
class StorageItems {
constructor(private items: StorageItem[]) { }
public count(): number {
return this.items.length;
}
public at(index: number): StorageItem {
return this.items[index];
}
}
export class StorageProvider {
public static canOpen(): boolean {
return typeof window.showOpenFilePicker !== 'undefined';
}
public static canSave(): boolean {
return typeof window.showSaveFilePicker !== 'undefined';
}
public static canPickFolder(): boolean {
return typeof window.showDirectoryPicker !== 'undefined';
}
public static async selectFolderDialog(
startIn: StartInDirectory | null)
: Promise<StorageItem> {
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
const options: DirectoryPickerOptions = {
startIn: (startIn || undefined)
};
const handle = await window.showDirectoryPicker(options);
return new StorageItem(handle);
}
public static async openFileDialog(
startIn: StartInDirectory | null, multiple: boolean,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean)
: Promise<StorageItems> {
const options: OpenFilePickerOptions = {
startIn: (startIn || undefined),
multiple,
excludeAcceptAllOption,
types: (types || undefined)
};
const handles = await window.showOpenFilePicker(options);
return new StorageItems(handles.map(handle => new StorageItem(handle)));
}
public static async saveFileDialog(
startIn: StartInDirectory | null, suggestedName: string | null,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean)
: Promise<StorageItem> {
const options: SaveFilePickerOptions = {
startIn: (startIn || undefined),
suggestedName: (suggestedName || undefined),
excludeAcceptAllOption,
types: (types || undefined)
};
const handle = await window.showSaveFilePicker(options);
return new StorageItem(handle);
}
public static async openBookmark(key: string): Promise<StorageItem | null> {
const connection = await avaloniaDb.connect();
try {
const handle = await connection.get(fileBookmarksStore, key);
return handle && new StorageItem(handle, key);
}
finally {
connection.close();
}
}
}

4
src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs

@ -5,6 +5,7 @@ using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Input.TextInput;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering;
using Avalonia.Web.Blazor.Interop;
using SkiaSharp;
@ -13,7 +14,7 @@ using SkiaSharp;
namespace Avalonia.Web.Blazor
{
internal class RazorViewTopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost
internal class RazorViewTopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
{
private Size _clientSize;
private BlazorSkiaSurface? _currentSurface;
@ -185,5 +186,6 @@ namespace Avalonia.Web.Blazor
public ITextInputMethodImpl TextInputMethod => _avaloniaView;
public INativeControlHostImpl? NativeControlHost => _avaloniaView.GetNativeControlHostImpl();
public IStorageProvider StorageProvider => _avaloniaView.GetStorageProvider();
}
}

9
src/Web/Avalonia.Web.Blazor/WinStubs.cs

@ -25,15 +25,6 @@ namespace Avalonia.Web.Blazor
public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) => new IconStub();
}
internal class SystemDialogsStub : ISystemDialogImpl
{
public Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) =>
Task.FromResult((string[]?)null);
public Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) =>
Task.FromResult((string?)null);
}
internal class ScreenStub : IScreenImpl
{
public int ScreenCount => 1;

1
src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs

@ -41,7 +41,6 @@ namespace Avalonia.Web.Blazor
.Bind<IPlatformThreadingInterface>().ToConstant(instance)
.Bind<IRenderLoop>().ToConstant(new RenderLoop())
.Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance)
.Bind<ISystemDialogImpl>().ToSingleton<SystemDialogsStub>()
.Bind<IWindowingPlatform>().ToConstant(instance)
.Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>()
.Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>();

Loading…
Cancel
Save