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;
+}