diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs index b575bc6dbb..5ba37d6ef4 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs +++ b/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); diff --git a/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs index 524326f7c0..589b6b56bb 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs @@ -37,6 +37,12 @@ namespace Avalonia.Web.Blazor.Interop protected TValue Invoke(string identifier, params object?[]? args) => Module.Invoke(identifier, args); + protected ValueTask InvokeAsync(string identifier, params object?[]? args) => + Module.InvokeVoidAsync(identifier, args); + + protected ValueTask InvokeAsync(string identifier, params object?[]? args) => + Module.InvokeAsync(identifier, args); + protected virtual void OnDisposingModule() { } } } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs new file mode 100644 index 0000000000..14dc53d7b5 --- /dev/null +++ b/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> 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 ImportAsync(IJSRuntime js) + { + var interop = new StorageProviderInterop(js); + await interop.ImportAsync(); + return interop; + } + + public StorageProviderInterop(IJSRuntime js) + : base(js, JsFilename) + { + } + + public bool CanOpen => Invoke("StorageProvider.canOpen"); + public bool CanSave => Invoke("StorageProvider.canSave"); + public bool CanPickFolder => Invoke("StorageProvider.canPickFolder"); + + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + try + { + var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; + + var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter); + var items = await InvokeAsync("StorageProvider.openFileDialog", startIn, options.AllowMultiple, types, exludeAll); + var count = items.Invoke("count"); + + return Enumerable.Range(0, count) + .Select(index => new JSStorageFile(items.Invoke("at", index))) + .ToArray(); + } + catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) + { + return Array.Empty(); + } + } + + public async Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + try + { + var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; + + var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices); + var item = await InvokeAsync("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> OpenFolderPickerAsync(FolderPickerOpenOptions options) + { + try + { + var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; + + var item = await InvokeAsync("StorageProvider.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) + { + var item = await InvokeAsync("StorageProvider.openBookmark", bookmark); + return item is not null ? new JSStorageFile(item) : null; + } + + public async Task OpenFolderBookmarkAsync(string bookmark) + { + var item = await InvokeAsync("StorageProvider.openBookmark", bookmark); + return item is not null ? new JSStorageFolder(item) : null; + } + + private static (FilePickerAcceptType[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable? 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)Array.Empty()))) + .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("getName"); + + public bool TryGetUri([NotNullWhen(true)] out Uri? uri) + { + uri = new Uri(Name, UriKind.Relative); + return false; + } + + public async Task GetBasicPropertiesAsync() + { + var properties = await FileHandle.InvokeAsync("getProperties"); + + return new StorageItemProperties( + properties?.Size, + dateCreated: null, + dateModified: properties?.LastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(properties.LastModified) : null); + } + + public bool CanBookmark => true; + + public Task SaveBookmark() + { + return FileHandle.InvokeAsync("saveBookmark").AsTask(); + } + + public Task GetParentAsync() + { + return Task.FromResult(null); + } + + public Task ReleaseBookmark() + { + return FileHandle.InvokeAsync("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 OpenRead() + { + var stream = await FileHandle.InvokeAsync("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 OpenWrite() + { + var properties = await FileHandle.InvokeAsync("getProperties"); + var streamWriter = await FileHandle.InvokeAsync("openWrite"); + + return new JSWriteableStream(streamWriter, (long)(properties?.Size ?? 0)); + } + } + + internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder + { + public JSStorageFolder(IJSInProcessObjectReference fileHandle) : base(fileHandle) + { + } + } +} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs b/src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs new file mode 100644 index 0000000000..55a7831b1a --- /dev/null +++ b/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 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(); + } + } + } +} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts new file mode 100644 index 0000000000..c32eef3226 --- /dev/null +++ b/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; + truncate(size: number): Promise; + close(): Promise; + } + type PermissionsMode = "read" | "readwrite"; + interface FileSystemFileHandle { + name: string, + kind: "file" | "directory", + getFile(): Promise; + createWritable(options?: { keepExistingData?: boolean }): Promise; + + 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; + showSaveFilePicker: (options: SaveFilePickerOptions) => Promise; + showDirectoryPicker: (options: DirectoryPickerOptions) => Promise; + } +} + +// TODO move to another file and use import +class IndexedDbWrapper { + constructor(private databaseName: string, private objectStores: [ string ]) { + + } + + public connect(): Promise { + var conn = window.indexedDB.open(this.databaseName, 1); + + conn.onupgradeneeded = event => { + const db = (>event.target).result; + this.objectStores.forEach(store => { + db.createObjectStore(store); + }); + } + + return new Promise((resolve, reject) => { + conn.onsuccess = event => { + resolve(new InnerDbConnection((>event.target).result)); + } + conn.onerror = event => { + reject((>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 { + 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 { + 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 { + await this.verityPermissions('read'); + + var file = await this.handle.getFile(); + return file; + } + + public async openWrite(): Promise { + 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 { + 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 { + // 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 key; + } + finally { + connection.close(); + } + } + + public async deleteBookmark(): Promise { + 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 { + + // '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 { + + 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 { + + 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 { + const connection = await avaloniaDb.connect(); + try { + const handle = await connection.get(fileBookmarksStore, key); + return handle && new StorageItem(handle, key); + } + finally { + connection.close(); + } + } +} diff --git a/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs b/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs index a8a1a970dc..b8e4636b70 100644 --- a/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs +++ b/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(); } } diff --git a/src/Web/Avalonia.Web.Blazor/WinStubs.cs b/src/Web/Avalonia.Web.Blazor/WinStubs.cs index a1fecef10e..808d1526d3 100644 --- a/src/Web/Avalonia.Web.Blazor/WinStubs.cs +++ b/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 ShowFileDialogAsync(FileDialog dialog, Window parent) => - Task.FromResult((string[]?)null); - - public Task ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) => - Task.FromResult((string?)null); - } - internal class ScreenStub : IScreenImpl { public int ScreenCount => 1; diff --git a/src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs b/src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs index 0575533152..46c05d8e8c 100644 --- a/src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs +++ b/src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs @@ -41,7 +41,6 @@ namespace Avalonia.Web.Blazor .Bind().ToConstant(instance) .Bind().ToConstant(new RenderLoop()) .Bind().ToConstant(ManualTriggerRenderTimer.Instance) - .Bind().ToSingleton() .Bind().ToConstant(instance) .Bind().ToSingleton() .Bind().ToSingleton();