Browse Source

Port StorageProvider to use new API

pull/9028/head
Max Katz 4 years ago
parent
commit
4dcbc24359
  1. 2
      src/Avalonia.Base/Platform/Storage/IStorageFile.cs
  2. 2
      src/Avalonia.Base/Utilities/AvaloniaResourcesIndex.cs
  3. 1
      src/Avalonia.Build.Tasks/GenerateAvaloniaResourcesTask.cs
  4. 1
      src/Web/Avalonia.Web/Avalonia.Web.csproj
  5. 8
      src/Web/Avalonia.Web/AvaloniaView.cs
  6. 5
      src/Web/Avalonia.Web/BrowserTopLevelImpl.cs
  7. 8
      src/Web/Avalonia.Web/Interop/InputHelper.cs
  8. 55
      src/Web/Avalonia.Web/Interop/StorageHelper.cs
  9. 35
      src/Web/Avalonia.Web/Interop/StreamHelper.cs
  10. 27
      src/Web/Avalonia.Web/Storage/BlobReadableStream.cs
  11. 257
      src/Web/Avalonia.Web/Storage/BrowserStorageProvider.cs
  12. 27
      src/Web/Avalonia.Web/Storage/WriteableStream.cs
  13. 6
      src/Web/Avalonia.Web/webapp/modules/avalonia.ts
  14. 13
      src/Web/Avalonia.Web/webapp/modules/avalonia/caniuse.ts
  15. 40
      src/Web/Avalonia.Web/webapp/modules/avalonia/stream.ts
  16. 7
      src/Web/Avalonia.Web/webapp/modules/storage.ts
  17. 84
      src/Web/Avalonia.Web/webapp/modules/storage/indexedDb.ts
  18. 111
      src/Web/Avalonia.Web/webapp/modules/storage/storageItem.ts
  19. 70
      src/Web/Avalonia.Web/webapp/modules/storage/storageProvider.ts
  20. 23
      src/Web/Avalonia.Web/webapp/types/dotnet.d.ts

2
src/Avalonia.Base/Platform/Storage/IStorageFile.cs

@ -18,6 +18,7 @@ public interface IStorageFile : IStorageItem
/// <summary>
/// Opens a stream for read access.
/// </summary>
/// <exception cref="System.UnauthorizedAccessException" />
Task<Stream> OpenReadAsync();
/// <summary>
@ -28,5 +29,6 @@ public interface IStorageFile : IStorageItem
/// <summary>
/// Opens stream for writing to the file.
/// </summary>
/// <exception cref="System.UnauthorizedAccessException" />
Task<Stream> OpenWriteAsync();
}

2
src/Avalonia.Base/Utilities/AvaloniaResourcesIndex.cs

@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Xml.Linq;
using System.Linq;

1
src/Avalonia.Build.Tasks/GenerateAvaloniaResourcesTask.cs

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
using Avalonia.Markup.Xaml.PortableXaml;
using Avalonia.Utilities;

1
src/Web/Avalonia.Web/Avalonia.Web.csproj

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

8
src/Web/Avalonia.Web/AvaloniaView.cs

@ -1,5 +1,4 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices.JavaScript;
using Avalonia.Controls;
using Avalonia.Controls.Embedding;
@ -7,7 +6,6 @@ using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Input.TextInput;
using Avalonia.Platform.Storage;
using Avalonia.Rendering.Composition;
using Avalonia.Threading;
using Avalonia.Web.Interop;
@ -337,12 +335,6 @@ namespace Avalonia.Web
//return _nativeControlHost ?? throw new InvalidOperationException("Blazor View wasn't initialized yet");
}
internal IStorageProvider GetStorageProvider()
{
throw new NotImplementedException();
//return _storageProvider ?? throw new InvalidOperationException("Blazor View wasn't initialized yet");
}
private void ForceBlit()
{
// Note: this is technically a hack, but it's a kinda unique use case when

5
src/Web/Avalonia.Web/BrowserTopLevelImpl.cs

@ -11,8 +11,7 @@ using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
#nullable enable
using Avalonia.Web.Storage;
namespace Avalonia.Web
{
@ -223,6 +222,6 @@ namespace Avalonia.Web
public ITextInputMethodImpl TextInputMethod => _avaloniaView;
public INativeControlHostImpl? NativeControlHost => _avaloniaView.GetNativeControlHostImpl();
public IStorageProvider StorageProvider => _avaloniaView.GetStorageProvider();
public IStorageProvider StorageProvider { get; } = new BrowserStorageProvider();
}
}

8
src/Web/Avalonia.Web/Interop/InputHelper.cs

@ -1,8 +1,6 @@
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
using static System.Net.Mime.MediaTypeNames;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Avalonia.Web.Interop;
@ -39,7 +37,7 @@ internal static partial class InputHelper
Func<JSObject, bool> pointerUp,
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>]
Func<JSObject, bool> wheel);
[JSImport("InputHelper.subscribeInputEvents", "avalonia.ts")]
public static partial void SubscribeInputEvents(
@ -72,9 +70,9 @@ internal static partial class InputHelper
[JSImport("InputHelper.setBounds", "avalonia.ts")]
public static partial void SetBounds(JSObject htmlElement, int x, int y, int width, int height, int caret);
[JSImport("navigator.clipboard.readText")]
[JSImport("globalThis.navigator.clipboard.readText")]
public static partial Task<string> ReadClipboardTextAsync();
[JSImport("navigator.clipboard.writeText")]
[JSImport("globalThis.navigator.clipboard.writeText")]
public static partial Task WriteClipboardTextAsync(string text);
}

55
src/Web/Avalonia.Web/Interop/StorageHelper.cs

@ -0,0 +1,55 @@
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
namespace Avalonia.Web.Interop;
internal static partial class StorageHelper
{
[JSImport("Caniuse.canShowOpenFilePicker", "avalonia.ts")]
public static partial bool CanShowOpenFilePicker();
[JSImport("Caniuse.canShowSaveFilePicker", "avalonia.ts")]
public static partial bool CanShowSaveFilePicker();
[JSImport("Caniuse.canShowDirectoryPicker", "avalonia.ts")]
public static partial bool CanShowDirectoryPicker();
[JSImport("StorageProvider.selectFolderDialog", "storage.ts")]
public static partial Task<JSObject?> SelectFolderDialog(JSObject? startIn);
[JSImport("StorageProvider.openFileDialog", "storage.ts")]
public static partial Task<JSObject?> OpenFileDialog(JSObject? startIn, bool multiple,
[JSMarshalAs<JSType.Array<JSType.Any>>] object[]? types, bool excludeAcceptAllOption);
[JSImport("StorageProvider.saveFileDialog", "storage.ts")]
public static partial Task<JSObject?> SaveFileDialog(JSObject? startIn, string? suggestedName,
[JSMarshalAs<JSType.Array<JSType.Any>>] object[]? types, bool excludeAcceptAllOption);
[JSImport("StorageProvider.openBookmark", "storage.ts")]
public static partial Task<JSObject?> OpenBookmark(string key);
[JSImport("StorageItem.saveBookmark", "storage.ts")]
public static partial Task<string?> SaveBookmark(JSObject item);
[JSImport("StorageItem.deleteBookmark", "storage.ts")]
public static partial Task DeleteBookmark(JSObject item);
[JSImport("StorageItem.getProperties", "storage.ts")]
public static partial Task<JSObject?> GetProperties(JSObject item);
[JSImport("StorageItem.openWrite", "storage.ts")]
public static partial Task<JSObject> OpenWrite(JSObject item);
[JSImport("StorageItem.openRead", "storage.ts")]
public static partial Task<JSObject> OpenRead(JSObject item);
[JSImport("StorageItem.getItems", "storage.ts")]
[return: JSMarshalAs<JSType.Promise<JSType.Object>>]
public static partial Task<JSObject> GetItems(JSObject item);
[JSImport("StorageItems.itemsArray", "storage.ts")]
public static partial JSObject[] ItemsArray(JSObject item);
[JSImport("StorageProvider.createAcceptType", "storage.ts")]
public static partial JSObject CreateAcceptType(string description, string[] mimeTypes);
}

35
src/Web/Avalonia.Web/Interop/StreamHelper.cs

@ -13,43 +13,28 @@ internal static partial class StreamHelper
public static partial void Seek(JSObject stream, [JSMarshalAs<JSType.Number>] long position);
[JSImport("StreamHelper.truncate", "avalonia.ts")]
public static partial void Truncate(JSObject stream, [JSMarshalAs<JSType.Number>] long position);
[JSImport("StreamHelper.write", "avalonia.ts")]
public static partial void Write(JSObject stream, [JSMarshalAs<JSType.MemoryView>] Span<byte> data);
public static partial void Truncate(JSObject stream, [JSMarshalAs<JSType.Number>] long size);
[JSImport("StreamHelper.write", "avalonia.ts")]
public static partial Task WriteAsync(JSObject stream, [JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> data);
[JSImport("StreamHelper.close", "avalonia.ts")]
public static partial void Close(JSObject stream);
[JSImport("StreamHelper.close", "avalonia.ts")]
public static partial Task CloseAsync(JSObject stream);
[JSImport("StreamHelper.size", "avalonia.ts")]
[return: JSMarshalAs<JSType.Number>]
public static partial long Size(JSObject stream);
[JSImport("StreamHelper.byteLength", "avalonia.ts")]
[return: JSMarshalAs<JSType.Number>]
public static partial long ByteLength(JSObject stream);
[JSImport("StreamHelper.sliceToArray", "avalonia.ts")]
[return: JSMarshalAs<JSType.MemoryView>]
public static partial Span<byte> Slice(JSObject stream, [JSMarshalAs<JSType.Number>] long offset, int count);
[JSImport("StreamHelper.sliceArrayBuffer", "avalonia.ts")]
private static partial Task<JSObject> SliceToArrayBuffer(JSObject stream, [JSMarshalAs<JSType.Number>] long offset, int count);
[JSImport("StreamHelper.toMemoryView", "avalonia.ts")]
[return: JSMarshalAs<JSType.Array<JSType.Number>>]
private static partial byte[] ArrayBufferToMemoryView(JSObject stream);
public static async Task<ArraySegment<byte>> SliceAsync(JSObject stream, long offset, int count)
public static async Task<byte[]> SliceAsync(JSObject stream, long offset, int count)
{
using var buffer = await SliceToBufferAsync(stream, offset, count);
return BufferToArray(buffer);
using var buffer = await SliceToArrayBuffer(stream, offset, count);
return ArrayBufferToMemoryView(buffer);
}
[JSImport("StreamHelper.slice", "avalonia.ts")]
[return: JSMarshalAs<JSType.Promise<JSType.Object>>]
private static partial Task<JSObject> SliceToBufferAsync(JSObject stream, [JSMarshalAs<JSType.Number>] long offset, int count);
[JSImport("StreamHelper.toArray", "avalonia.ts")]
[return: JSMarshalAs<JSType.MemoryView>]
private static partial ArraySegment<byte> BufferToArray(JSObject stream);
}

27
src/Web/Avalonia.Web/Storage/BlobReadableStream.cs

@ -6,7 +6,7 @@ using System.Threading.Tasks;
namespace Avalonia.Web.Storage;
[System.Runtime.Versioning.SupportedOSPlatform("browser")] // gets rid of callsite warnings
[System.Runtime.Versioning.SupportedOSPlatform("browser")]
internal class BlobReadableStream : Stream
{
private JSObject? _jSReference;
@ -20,7 +20,7 @@ internal class BlobReadableStream : Stream
_length = StreamHelper.ByteLength(JSReference);
}
private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream));
private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(WriteableStream));
public override bool CanRead => true;
@ -55,21 +55,8 @@ internal class BlobReadableStream : Stream
=> throw new NotSupportedException();
public override int Read(byte[] buffer, int offset, int count)
=> Read(buffer.AsSpan(offset, count));
public override int Read(Span<byte> buffer)
{
var numBytesToRead = (int)Math.Min(buffer.Length, Length - _position);
var bytesRead = StreamHelper.Slice(JSReference, _position, numBytesToRead);
if (bytesRead.Length != numBytesToRead)
{
throw new EndOfStreamException("Failed to read the requested number of bytes from the stream.");
}
_position += bytesRead.Length;
bytesRead.CopyTo(buffer);
return bytesRead.Length;
throw new InvalidOperationException("Browser supports only ReadAsync");
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
@ -79,15 +66,15 @@ internal class BlobReadableStream : Stream
{
var numBytesToRead = (int)Math.Min(buffer.Length, Length - _position);
var bytesRead = await StreamHelper.SliceAsync(JSReference, _position, numBytesToRead);
if (bytesRead.Count != numBytesToRead)
if (bytesRead.Length != numBytesToRead)
{
throw new EndOfStreamException("Failed to read the requested number of bytes from the stream.");
}
_position += bytesRead.Count;
bytesRead.AsMemory().CopyTo(buffer);
_position += bytesRead.Length;
bytesRead.CopyTo(buffer);
return bytesRead.Count;
return bytesRead.Length;
}
protected override void Dispose(bool disposing)

257
src/Web/Avalonia.Web/Storage/BrowserStorageProvider.cs

@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
using Avalonia.Web.Interop;
namespace Avalonia.Web.Storage;
internal record FilePickerAcceptType(string Description, IReadOnlyDictionary<string, IReadOnlyList<string>> Accept);
[SupportedOSPlatform("browser")]
internal class BrowserStorageProvider : IStorageProvider
{
internal const string PickerCancelMessage = "The user aborted a request";
internal const string NoPermissionsMessage = "Permissions denied";
private readonly Lazy<Task<JSObject>> _lazyModule = new(() => JSHost.ImportAsync("storage.ts", "./storage.js"));
public bool CanOpen => StorageHelper.CanShowOpenFilePicker();
public bool CanSave => StorageHelper.CanShowSaveFilePicker();
public bool CanPickFolder => StorageHelper.CanShowDirectoryPicker();
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
_ = await _lazyModule.Value;
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter);
try
{
using var items = await StorageHelper.OpenFileDialog(startIn, options.AllowMultiple, types, exludeAll);
if (items is null)
{
return Array.Empty<IStorageFile>();
}
var itemsArray = StorageHelper.ItemsArray(items);
return itemsArray.Select(item => new JSStorageFile(item)).ToArray();
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return Array.Empty<IStorageFile>();
}
finally
{
if (types is not null)
{
foreach (var type in types)
{
type.Dispose();
}
}
}
}
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
_ = await _lazyModule.Value;
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices);
try
{
var item = await StorageHelper.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;
}
finally
{
if (types is not null)
{
foreach (var type in types)
{
type.Dispose();
}
}
}
}
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
_ = await _lazyModule.Value;
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
try
{
var item = await StorageHelper.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)
{
_ = await _lazyModule.Value;
var item = await StorageHelper.OpenBookmark(bookmark);
return item is not null ? new JSStorageFile(item) : null;
}
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
_ = await _lazyModule.Value;
var item = await StorageHelper.OpenBookmark(bookmark);
return item is not null ? new JSStorageFolder(item) : null;
}
private static (JSObject[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input)
{
var types = input?
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All)
.Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray()))
.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 JSObject? _fileHandle;
protected JSStorageItem(JSObject fileHandle)
{
_fileHandle = fileHandle ?? throw new ArgumentNullException(nameof(fileHandle));
}
internal JSObject FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem));
public string Name => FileHandle.GetPropertyAsString("name") ?? string.Empty;
public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
{
uri = new Uri(Name, UriKind.Relative);
return false;
}
public async Task<StorageItemProperties> GetBasicPropertiesAsync()
{
using var properties = await StorageHelper.GetProperties(FileHandle);
var size = (long?)properties?.GetPropertyAsDouble("Size");
var lastModified = (long?)properties?.GetPropertyAsDouble("LastModified");
return new StorageItemProperties(
(ulong?)size,
dateCreated: null,
dateModified: lastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(lastModified.Value) : null);
}
public bool CanBookmark => true;
public Task<string?> SaveBookmarkAsync()
{
return StorageHelper.SaveBookmark(FileHandle);
}
public Task<IStorageFolder?> GetParentAsync()
{
return Task.FromResult<IStorageFolder?>(null);
}
public Task ReleaseBookmarkAsync()
{
return StorageHelper.DeleteBookmark(FileHandle);
}
public void Dispose()
{
_fileHandle?.Dispose();
_fileHandle = null;
}
}
internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile
{
public JSStorageFile(JSObject fileHandle) : base(fileHandle)
{
}
public bool CanOpenRead => true;
public async Task<Stream> OpenReadAsync()
{
try
{
var blob = await StorageHelper.OpenRead(FileHandle);
return new BlobReadableStream(blob);
}
catch (JSException ex) when (ex.Message == BrowserStorageProvider.NoPermissionsMessage)
{
throw new UnauthorizedAccessException("User denied permissions to open the file", ex);
}
}
public bool CanOpenWrite => true;
public async Task<Stream> OpenWriteAsync()
{
try
{
using var properties = await StorageHelper.GetProperties(FileHandle);
var streamWriter = await StorageHelper.OpenWrite(FileHandle);
var size = (long?)properties?.GetPropertyAsDouble("Size") ?? 0;
return new WriteableStream(streamWriter, size);
}
catch (JSException ex) when (ex.Message == BrowserStorageProvider.NoPermissionsMessage)
{
throw new UnauthorizedAccessException("User denied permissions to open the file", ex);
}
}
}
internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder
{
public JSStorageFolder(JSObject fileHandle) : base(fileHandle)
{
}
public async Task<IReadOnlyList<IStorageItem>> GetItemsAsync()
{
using var items = await StorageHelper.GetItems(FileHandle);
if (items is null)
{
return Array.Empty<IStorageItem>();
}
var itemsArray = StorageHelper.ItemsArray(items);
return itemsArray
.Select(reference => reference.GetPropertyAsString("kind") switch
{
"directory" => (IStorageItem)new JSStorageFolder(reference),
"file" => new JSStorageFile(reference),
_ => null
})
.Where(i => i is not null)
.ToArray()!;
}
}

27
src/Web/Avalonia.Web/Storage/WriteableStream.cs

@ -6,22 +6,22 @@ using System.Threading.Tasks;
namespace Avalonia.Web.Storage;
[System.Runtime.Versioning.SupportedOSPlatform("browser")] // gets rid of callsite warnings
[System.Runtime.Versioning.SupportedOSPlatform("browser")]
// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream
internal sealed class JSWriteableStream : Stream
internal sealed class WriteableStream : Stream
{
private JSObject? _jSReference;
// Unfortunatelly we can't read current length/position, so we need to keep it C#-side only.
private long _length, _position;
internal JSWriteableStream(JSObject jSReference, long initialLength)
internal WriteableStream(JSObject jSReference, long initialLength)
{
_jSReference = jSReference;
_length = initialLength;
}
private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream));
private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(WriteableStream));
public override bool CanRead => false;
@ -75,20 +75,7 @@ internal sealed class JSWriteableStream : Stream
public override void Write(byte[] buffer, int offset, int count)
{
StreamHelper.Write(JSReference, buffer.AsSpan(offset, count));
}
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);
throw new InvalidOperationException("Browser supports only WriteAsync");
}
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
@ -110,7 +97,7 @@ internal sealed class JSWriteableStream : Stream
_jSReference = null;
try
{
StreamHelper.Close(JSReference);
_ = StreamHelper.CloseAsync(jsReference);
}
finally
{
@ -126,7 +113,7 @@ internal sealed class JSWriteableStream : Stream
_jSReference = null;
try
{
await StreamHelper.CloseAsync(JSReference);
await StreamHelper.CloseAsync(jsReference);
}
finally
{

6
src/Web/Avalonia.Web/webapp/modules/avalonia.ts

@ -3,6 +3,8 @@ import { SizeWatcher, DpiWatcher, Canvas } from "./avalonia/canvas";
import { InputHelper } from "./avalonia/input";
import { AvaloniaDOM } from "./avalonia/dom";
import { Caniuse } from "./avalonia/caniuse";
import { StreamHelper } from "./avalonia/stream";
export async function createAvaloniaRuntime(api: RuntimeAPI): Promise<void> {
api.setModuleImports("avalonia.ts", {
@ -10,6 +12,8 @@ export async function createAvaloniaRuntime(api: RuntimeAPI): Promise<void> {
InputHelper,
SizeWatcher,
DpiWatcher,
AvaloniaDOM
AvaloniaDOM,
Caniuse,
StreamHelper
});
}

13
src/Web/Avalonia.Web/webapp/modules/avalonia/caniuse.ts

@ -0,0 +1,13 @@
export class Caniuse {
public static canShowOpenFilePicker(): boolean {
return typeof window.showOpenFilePicker !== "undefined";
}
public static canShowSaveFilePicker(): boolean {
return typeof window.showSaveFilePicker !== "undefined";
}
public static canShowDirectoryPicker(): boolean {
return typeof window.showDirectoryPicker !== "undefined";
}
}

40
src/Web/Avalonia.Web/webapp/modules/avalonia/stream.ts

@ -0,0 +1,40 @@
import { IMemoryView } from "../../types/dotnet";
export class StreamHelper {
public static async seek(stream: FileSystemWritableFileStream, position: number) {
return await stream.seek(position);
}
public static async truncate(stream: FileSystemWritableFileStream, size: number) {
return await stream.truncate(size);
}
public static async close(stream: FileSystemWritableFileStream) {
return await stream.close();
}
public static async write(stream: FileSystemWritableFileStream, span: IMemoryView) {
const array = new Uint8Array(span.byteLength);
span.copyTo(array);
const data: WriteParams = {
type: "write",
data: array
};
return await stream.write(data);
}
public static byteLength(stream: Blob) {
return stream.size;
}
public static async sliceArrayBuffer(stream: Blob, offset: number, count: number) {
const buffer = await stream.slice(offset, offset + count).arrayBuffer();
return new Uint8Array(buffer);
}
public static toMemoryView(buffer: Uint8Array): Uint8Array {
return buffer;
}
}

7
src/Web/Avalonia.Web/webapp/modules/storage.ts

@ -1,5 +1,2 @@
export class StorageProvider {
static isFileApiSupported(): boolean {
return (globalThis as any).showOpenFilePicker !== undefined;
}
}
export { StorageItem, StorageItems } from "./storage/storageItem";
export { StorageProvider } from "./storage/storageProvider";

84
src/Web/Avalonia.Web/webapp/modules/storage/indexedDb.ts

@ -0,0 +1,84 @@
class InnerDbConnection {
constructor(private readonly database: IDBDatabase) { }
private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore {
const tx = this.database.transaction(store, mode);
return tx.objectStore(store);
}
public async put(store: string, obj: any, key?: IDBValidKey): Promise<IDBValidKey> {
const os = this.openStore(store, "readwrite");
return await new Promise((resolve, reject) => {
const 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) => {
const response = os.get(key);
response.onsuccess = () => {
resolve(response.result);
};
response.onerror = () => {
reject(response.error);
};
});
}
public async delete(store: string, key: IDBValidKey): Promise<void> {
const os = this.openStore(store, "readwrite");
return await new Promise((resolve, reject) => {
const response = os.delete(key);
response.onsuccess = () => {
resolve();
};
response.onerror = () => {
reject(response.error);
};
});
}
public close() {
this.database.close();
}
}
class IndexedDbWrapper {
constructor(private readonly databaseName: string, private readonly objectStores: [string]) {
}
public async connect(): Promise<InnerDbConnection> {
const conn = window.indexedDB.open(this.databaseName, 1);
conn.onupgradeneeded = event => {
const db = (event.target as IDBRequest<IDBDatabase>).result;
this.objectStores.forEach(store => {
db.createObjectStore(store);
});
};
return await new Promise((resolve, reject) => {
conn.onsuccess = event => {
resolve(new InnerDbConnection((event.target as IDBRequest<IDBDatabase>).result));
};
conn.onerror = event => {
reject((event.target as IDBRequest<IDBDatabase>).error);
};
});
}
}
export const fileBookmarksStore: string = "fileBookmarks";
export const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [
fileBookmarksStore
]);

111
src/Web/Avalonia.Web/webapp/modules/storage/storageItem.ts

@ -0,0 +1,111 @@
import { avaloniaDb, fileBookmarksStore } from "./indexedDb";
export class StorageItem {
constructor(public handle: FileSystemHandle, private readonly bookmarkId?: string) { }
public get name(): string {
return this.handle.name;
}
public get kind(): string {
return this.handle.kind;
}
public static async openRead(item: StorageItem): Promise<Blob> {
if (!(item.handle instanceof FileSystemFileHandle)) {
throw new Error("StorageItem is not a file");
}
await item.verityPermissions("read");
const file = await item.handle.getFile();
return file;
}
public static async openWrite(item: StorageItem): Promise<FileSystemWritableFileStream> {
if (!(item.handle instanceof FileSystemFileHandle)) {
throw new Error("StorageItem is not a file");
}
await item.verityPermissions("readwrite");
return await item.handle.createWritable({ keepExistingData: true });
}
public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> {
const file = item.handle instanceof FileSystemFileHandle &&
await item.handle.getFile();
if (!file) {
return null;
}
return {
Size: file.size,
LastModified: file.lastModified,
Type: file.type
};
}
public static async getItems(item: StorageItem): Promise<StorageItems> {
if (item.handle.kind !== "directory") {
return new StorageItems([]);
}
const items: StorageItem[] = [];
for await (const [, value] of (item.handle as any).entries()) {
items.push(new StorageItem(value));
}
return new StorageItems(items);
}
private async verityPermissions(mode: FileSystemPermissionMode): Promise<void | never> {
if (await this.handle.queryPermission({ mode }) === "granted") {
return;
}
if (await this.handle.requestPermission({ mode }) === "denied") {
throw new Error("Permissions denied");
}
}
public static async saveBookmark(item: StorageItem): Promise<string> {
// If file was previously bookmarked, just return old one.
if (item.bookmarkId) {
return item.bookmarkId;
}
const connection = await avaloniaDb.connect();
try {
const key = await connection.put(fileBookmarksStore, item.handle, item.generateBookmarkId());
return key as string;
} finally {
connection.close();
}
}
public static async deleteBookmark(item: StorageItem): Promise<void> {
if (!item.bookmarkId) {
return;
}
const connection = await avaloniaDb.connect();
try {
await connection.delete(fileBookmarksStore, item.bookmarkId);
} finally {
connection.close();
}
}
private generateBookmarkId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}
export class StorageItems {
constructor(private readonly items: StorageItem[]) { }
public static itemsArray(instance: StorageItems): StorageItem[] {
return instance.items;
}
}

70
src/Web/Avalonia.Web/webapp/modules/storage/storageProvider.ts

@ -0,0 +1,70 @@
import { avaloniaDb, fileBookmarksStore } from "./indexedDb";
import { StorageItem, StorageItems } from "./storageItem";
declare global {
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos";
type StartInDirectory = WellKnownDirectory | FileSystemHandle;
interface OpenFilePickerOptions {
startIn?: StartInDirectory;
}
interface SaveFilePickerOptions {
startIn?: StartInDirectory;
}
}
export class StorageProvider {
public static async selectFolderDialog(
startIn: StorageItem | null): Promise<StorageItem> {
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
const options: DirectoryPickerOptions = {
startIn: (startIn?.handle ?? undefined)
};
const handle = await window.showDirectoryPicker(options);
return new StorageItem(handle);
}
public static async openFileDialog(
startIn: StorageItem | null, multiple: boolean,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItems> {
const options: OpenFilePickerOptions = {
startIn: (startIn?.handle ?? undefined),
multiple,
excludeAcceptAllOption,
types: (types ?? undefined)
};
const handles = await window.showOpenFilePicker(options);
return new StorageItems(handles.map((handle: FileSystemHandle) => new StorageItem(handle)));
}
public static async saveFileDialog(
startIn: StorageItem | null, suggestedName: string | null,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItem> {
const options: SaveFilePickerOptions = {
startIn: (startIn?.handle ?? 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();
}
}
public static createAcceptType(description: string, mimeTypes: string[]): FilePickerAcceptType {
const accept: Record<string, string[]> = {};
mimeTypes.forEach(a => { accept[a] = []; });
return { description, accept };
}
}

23
src/Web/Avalonia.Web/webapp/types/dotnet.d.ts

@ -246,4 +246,25 @@ declare global {
declare const dotnet: ModuleAPI["dotnet"];
declare const exit: ModuleAPI["exit"];
export { CreateDotnetRuntimeType, DotnetModuleConfig, EmscriptenModule, ModuleAPI, MonoConfig, RuntimeAPI, createDotnetRuntime as default, dotnet, exit };
export { CreateDotnetRuntimeType, DotnetModuleConfig, EmscriptenModule, ModuleAPI, MonoConfig, RuntimeAPI, createDotnetRuntime as default, dotnet, exit };
export interface IMemoryView {
/**
* copies elements from provided source to the wasm memory.
* target has to have the elements of the same type as the underlying C# array.
* same as TypedArray.set()
*/
set(source: TypedArray, targetOffset?: number): void;
/**
* copies elements from wasm memory to provided target.
* target has to have the elements of the same type as the underlying C# array.
*/
copyTo(target: TypedArray, sourceOffset?: number): void;
/**
* same as TypedArray.slice()
*/
slice(start?: number, end?: number): TypedArray;
get length(): number;
get byteLength(): number;
}

Loading…
Cancel
Save