diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFile.cs b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs index 46aa6efa72..4aa84e3ec4 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFile.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs @@ -18,6 +18,7 @@ public interface IStorageFile : IStorageItem /// /// Opens a stream for read access. /// + /// Task OpenReadAsync(); /// @@ -28,5 +29,6 @@ public interface IStorageFile : IStorageItem /// /// Opens stream for writing to the file. /// + /// Task OpenWriteAsync(); } diff --git a/src/Avalonia.Base/Utilities/AvaloniaResourcesIndex.cs b/src/Avalonia.Base/Utilities/AvaloniaResourcesIndex.cs index bd3b86f06d..b8692bb771 100644 --- a/src/Avalonia.Base/Utilities/AvaloniaResourcesIndex.cs +++ b/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; diff --git a/src/Avalonia.Build.Tasks/GenerateAvaloniaResourcesTask.cs b/src/Avalonia.Build.Tasks/GenerateAvaloniaResourcesTask.cs index c20b2f656e..75758d1315 100644 --- a/src/Avalonia.Build.Tasks/GenerateAvaloniaResourcesTask.cs +++ b/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; diff --git a/src/Web/Avalonia.Web/Avalonia.Web.csproj b/src/Web/Avalonia.Web/Avalonia.Web.csproj index fd5f976f4b..04b31b1a2f 100644 --- a/src/Web/Avalonia.Web/Avalonia.Web.csproj +++ b/src/Web/Avalonia.Web/Avalonia.Web.csproj @@ -1,6 +1,7 @@ net7.0 + preview enable true diff --git a/src/Web/Avalonia.Web/AvaloniaView.cs b/src/Web/Avalonia.Web/AvaloniaView.cs index 6d7ddeb022..6cc5e42902 100644 --- a/src/Web/Avalonia.Web/AvaloniaView.cs +++ b/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 diff --git a/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs b/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs index 047b462a64..d52d7164ce 100644 --- a/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs +++ b/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(); } } diff --git a/src/Web/Avalonia.Web/Interop/InputHelper.cs b/src/Web/Avalonia.Web/Interop/InputHelper.cs index e728acdc52..b32b6566f2 100644 --- a/src/Web/Avalonia.Web/Interop/InputHelper.cs +++ b/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 pointerUp, [JSMarshalAs>] Func 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 ReadClipboardTextAsync(); - [JSImport("navigator.clipboard.writeText")] + [JSImport("globalThis.navigator.clipboard.writeText")] public static partial Task WriteClipboardTextAsync(string text); } diff --git a/src/Web/Avalonia.Web/Interop/StorageHelper.cs b/src/Web/Avalonia.Web/Interop/StorageHelper.cs new file mode 100644 index 0000000000..4dc55fd959 --- /dev/null +++ b/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 SelectFolderDialog(JSObject? startIn); + + [JSImport("StorageProvider.openFileDialog", "storage.ts")] + public static partial Task OpenFileDialog(JSObject? startIn, bool multiple, + [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption); + + [JSImport("StorageProvider.saveFileDialog", "storage.ts")] + public static partial Task SaveFileDialog(JSObject? startIn, string? suggestedName, + [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption); + + [JSImport("StorageProvider.openBookmark", "storage.ts")] + public static partial Task OpenBookmark(string key); + + [JSImport("StorageItem.saveBookmark", "storage.ts")] + public static partial Task SaveBookmark(JSObject item); + + [JSImport("StorageItem.deleteBookmark", "storage.ts")] + public static partial Task DeleteBookmark(JSObject item); + + [JSImport("StorageItem.getProperties", "storage.ts")] + public static partial Task GetProperties(JSObject item); + + [JSImport("StorageItem.openWrite", "storage.ts")] + public static partial Task OpenWrite(JSObject item); + + [JSImport("StorageItem.openRead", "storage.ts")] + public static partial Task OpenRead(JSObject item); + + [JSImport("StorageItem.getItems", "storage.ts")] + [return: JSMarshalAs>] + public static partial Task 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); +} diff --git a/src/Web/Avalonia.Web/Interop/StreamHelper.cs b/src/Web/Avalonia.Web/Interop/StreamHelper.cs index c77d87fbe4..0a83bbb871 100644 --- a/src/Web/Avalonia.Web/Interop/StreamHelper.cs +++ b/src/Web/Avalonia.Web/Interop/StreamHelper.cs @@ -13,43 +13,28 @@ internal static partial class StreamHelper public static partial void Seek(JSObject stream, [JSMarshalAs] long position); [JSImport("StreamHelper.truncate", "avalonia.ts")] - public static partial void Truncate(JSObject stream, [JSMarshalAs] long position); - - [JSImport("StreamHelper.write", "avalonia.ts")] - public static partial void Write(JSObject stream, [JSMarshalAs] Span data); + public static partial void Truncate(JSObject stream, [JSMarshalAs] long size); [JSImport("StreamHelper.write", "avalonia.ts")] public static partial Task WriteAsync(JSObject stream, [JSMarshalAs] ArraySegment 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] - public static partial long Size(JSObject stream); - [JSImport("StreamHelper.byteLength", "avalonia.ts")] [return: JSMarshalAs] public static partial long ByteLength(JSObject stream); - [JSImport("StreamHelper.sliceToArray", "avalonia.ts")] - [return: JSMarshalAs] - public static partial Span Slice(JSObject stream, [JSMarshalAs] long offset, int count); + [JSImport("StreamHelper.sliceArrayBuffer", "avalonia.ts")] + private static partial Task SliceToArrayBuffer(JSObject stream, [JSMarshalAs] long offset, int count); + + [JSImport("StreamHelper.toMemoryView", "avalonia.ts")] + [return: JSMarshalAs>] + private static partial byte[] ArrayBufferToMemoryView(JSObject stream); - public static async Task> SliceAsync(JSObject stream, long offset, int count) + public static async Task 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>] - private static partial Task SliceToBufferAsync(JSObject stream, [JSMarshalAs] long offset, int count); - - [JSImport("StreamHelper.toArray", "avalonia.ts")] - [return: JSMarshalAs] - private static partial ArraySegment BufferToArray(JSObject stream); } diff --git a/src/Web/Avalonia.Web/Storage/BlobReadableStream.cs b/src/Web/Avalonia.Web/Storage/BlobReadableStream.cs index a48f809c06..640c2fd745 100644 --- a/src/Web/Avalonia.Web/Storage/BlobReadableStream.cs +++ b/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 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 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) diff --git a/src/Web/Avalonia.Web/Storage/BrowserStorageProvider.cs b/src/Web/Avalonia.Web/Storage/BrowserStorageProvider.cs new file mode 100644 index 0000000000..55ae2bdef0 --- /dev/null +++ b/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> 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> _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> 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(); + } + + 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(); + } + finally + { + if (types is not null) + { + foreach (var type in types) + { + type.Dispose(); + } + } + } + } + + public async Task 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> 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(); + } + catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) + { + return Array.Empty(); + } + } + + public async Task OpenFileBookmarkAsync(string bookmark) + { + _ = await _lazyModule.Value; + var item = await StorageHelper.OpenBookmark(bookmark); + return item is not null ? new JSStorageFile(item) : null; + } + + public async Task 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? 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 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 SaveBookmarkAsync() + { + return StorageHelper.SaveBookmark(FileHandle); + } + + public Task GetParentAsync() + { + return Task.FromResult(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 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 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> GetItemsAsync() + { + using var items = await StorageHelper.GetItems(FileHandle); + if (items is null) + { + return Array.Empty(); + } + + 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()!; + } +} diff --git a/src/Web/Avalonia.Web/Storage/WriteableStream.cs b/src/Web/Avalonia.Web/Storage/WriteableStream.cs index 100b7ccfed..f9699fef7a 100644 --- a/src/Web/Avalonia.Web/Storage/WriteableStream.cs +++ b/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 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 { diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia.ts index 542d48cef5..796bae61de 100644 --- a/src/Web/Avalonia.Web/webapp/modules/avalonia.ts +++ b/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 { api.setModuleImports("avalonia.ts", { @@ -10,6 +12,8 @@ export async function createAvaloniaRuntime(api: RuntimeAPI): Promise { InputHelper, SizeWatcher, DpiWatcher, - AvaloniaDOM + AvaloniaDOM, + Caniuse, + StreamHelper }); } diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/caniuse.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/caniuse.ts new file mode 100644 index 0000000000..6dedcb724f --- /dev/null +++ b/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"; + } +} diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/stream.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/stream.ts new file mode 100644 index 0000000000..1f2c181edc --- /dev/null +++ b/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; + } +} diff --git a/src/Web/Avalonia.Web/webapp/modules/storage.ts b/src/Web/Avalonia.Web/webapp/modules/storage.ts index f6dfd9151b..8b9987afa0 100644 --- a/src/Web/Avalonia.Web/webapp/modules/storage.ts +++ b/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"; diff --git a/src/Web/Avalonia.Web/webapp/modules/storage/indexedDb.ts b/src/Web/Avalonia.Web/webapp/modules/storage/indexedDb.ts new file mode 100644 index 0000000000..54b095d39e --- /dev/null +++ b/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 { + 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 { + 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 { + const conn = window.indexedDB.open(this.databaseName, 1); + + conn.onupgradeneeded = event => { + const db = (event.target as IDBRequest).result; + this.objectStores.forEach(store => { + db.createObjectStore(store); + }); + }; + + return await new Promise((resolve, reject) => { + conn.onsuccess = event => { + resolve(new InnerDbConnection((event.target as IDBRequest).result)); + }; + conn.onerror = event => { + reject((event.target as IDBRequest).error); + }; + }); + } +} + +export const fileBookmarksStore: string = "fileBookmarks"; +export const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ + fileBookmarksStore +]); diff --git a/src/Web/Avalonia.Web/webapp/modules/storage/storageItem.ts b/src/Web/Avalonia.Web/webapp/modules/storage/storageItem.ts new file mode 100644 index 0000000000..b4cf3d278b --- /dev/null +++ b/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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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; + } +} diff --git a/src/Web/Avalonia.Web/webapp/modules/storage/storageProvider.ts b/src/Web/Avalonia.Web/webapp/modules/storage/storageProvider.ts new file mode 100644 index 0000000000..0198f92528 --- /dev/null +++ b/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 { + // '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 { + 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 { + 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 { + 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 = {}; + mimeTypes.forEach(a => { accept[a] = []; }); + return { description, accept }; + } +} diff --git a/src/Web/Avalonia.Web/webapp/types/dotnet.d.ts b/src/Web/Avalonia.Web/webapp/types/dotnet.d.ts index bc2068cb59..0067ee3e0e 100644 --- a/src/Web/Avalonia.Web/webapp/types/dotnet.d.ts +++ b/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 }; \ No newline at end of file +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; +}