8 changed files with 637 additions and 12 deletions
@ -0,0 +1,200 @@ |
|||
using System.Diagnostics.CodeAnalysis; |
|||
|
|||
using Avalonia.Platform.Storage; |
|||
|
|||
using Microsoft.JSInterop; |
|||
|
|||
namespace Avalonia.Web.Blazor.Interop.Storage |
|||
{ |
|||
internal record FilePickerAcceptType(string Description, IReadOnlyDictionary<string, IReadOnlyList<string>> Accept); |
|||
|
|||
internal record FileProperties(ulong Size, long LastModified, string? Type); |
|||
|
|||
internal class StorageProviderInterop : JSModuleInterop, IStorageProvider |
|||
{ |
|||
private const string JsFilename = "./_content/Avalonia.Web.Blazor/StorageProvider.js"; |
|||
private const string PickerCancelMessage = "The user aborted a request"; |
|||
|
|||
public static async Task<StorageProviderInterop> ImportAsync(IJSRuntime js) |
|||
{ |
|||
var interop = new StorageProviderInterop(js); |
|||
await interop.ImportAsync(); |
|||
return interop; |
|||
} |
|||
|
|||
public StorageProviderInterop(IJSRuntime js) |
|||
: base(js, JsFilename) |
|||
{ |
|||
} |
|||
|
|||
public bool CanOpen => Invoke<bool>("StorageProvider.canOpen"); |
|||
public bool CanSave => Invoke<bool>("StorageProvider.canSave"); |
|||
public bool CanPickFolder => Invoke<bool>("StorageProvider.canPickFolder"); |
|||
|
|||
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
try |
|||
{ |
|||
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
|||
|
|||
var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter); |
|||
var items = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openFileDialog", startIn, options.AllowMultiple, types, exludeAll); |
|||
var count = items.Invoke<int>("count"); |
|||
|
|||
return Enumerable.Range(0, count) |
|||
.Select(index => new JSStorageFile(items.Invoke<IJSInProcessObjectReference>("at", index))) |
|||
.ToArray(); |
|||
} |
|||
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
|||
{ |
|||
return Array.Empty<IStorageFile>(); |
|||
} |
|||
} |
|||
|
|||
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
try |
|||
{ |
|||
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
|||
|
|||
var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices); |
|||
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.saveFileDialog", startIn, options.SuggestedFileName, types, exludeAll); |
|||
|
|||
return item is not null ? new JSStorageFile(item) : null; |
|||
} |
|||
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
try |
|||
{ |
|||
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
|||
|
|||
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.selectFolderDialog", startIn); |
|||
|
|||
return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty<IStorageFolder>(); |
|||
} |
|||
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
|||
{ |
|||
return Array.Empty<IStorageFolder>(); |
|||
} |
|||
} |
|||
|
|||
public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
|||
{ |
|||
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark); |
|||
return item is not null ? new JSStorageFile(item) : null; |
|||
} |
|||
|
|||
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
|||
{ |
|||
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark); |
|||
return item is not null ? new JSStorageFolder(item) : null; |
|||
} |
|||
|
|||
private static (FilePickerAcceptType[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input) |
|||
{ |
|||
var types = input? |
|||
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All) |
|||
.Select(t => new FilePickerAcceptType(t.Name, t.MimeTypes! |
|||
.ToDictionary(m => m, _ => (IReadOnlyList<string>)Array.Empty<string>()))) |
|||
.ToArray(); |
|||
if (types?.Length == 0) |
|||
{ |
|||
types = null; |
|||
} |
|||
|
|||
var inlcudeAll = input?.Contains(FilePickerFileTypes.All) == true || types is null; |
|||
|
|||
return (types, !inlcudeAll); |
|||
} |
|||
} |
|||
|
|||
internal abstract class JSStorageItem : IStorageBookmarkItem |
|||
{ |
|||
internal IJSInProcessObjectReference? _fileHandle; |
|||
|
|||
protected JSStorageItem(IJSInProcessObjectReference fileHandle) |
|||
{ |
|||
_fileHandle = fileHandle ?? throw new ArgumentNullException(nameof(fileHandle)); |
|||
} |
|||
|
|||
internal IJSInProcessObjectReference FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem)); |
|||
|
|||
public string Name => FileHandle.Invoke<string>("getName"); |
|||
|
|||
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
|||
{ |
|||
uri = new Uri(Name, UriKind.Relative); |
|||
return false; |
|||
} |
|||
|
|||
public async Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties"); |
|||
|
|||
return new StorageItemProperties( |
|||
properties?.Size, |
|||
dateCreated: null, |
|||
dateModified: properties?.LastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(properties.LastModified) : null); |
|||
} |
|||
|
|||
public bool CanBookmark => true; |
|||
|
|||
public Task<string?> SaveBookmark() |
|||
{ |
|||
return FileHandle.InvokeAsync<string?>("saveBookmark").AsTask(); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public Task ReleaseBookmark() |
|||
{ |
|||
return FileHandle.InvokeAsync<string?>("deleteBookmark").AsTask(); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_fileHandle?.Dispose(); |
|||
_fileHandle = null; |
|||
} |
|||
} |
|||
|
|||
internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile |
|||
{ |
|||
public JSStorageFile(IJSInProcessObjectReference fileHandle) : base(fileHandle) |
|||
{ |
|||
} |
|||
|
|||
public bool CanOpenRead => true; |
|||
public async Task<Stream> OpenRead() |
|||
{ |
|||
var stream = await FileHandle.InvokeAsync<IJSStreamReference>("openRead"); |
|||
// Remove maxAllowedSize limit, as developer can decide if they read only small part or everything.
|
|||
return await stream.OpenReadStreamAsync(long.MaxValue, CancellationToken.None); |
|||
} |
|||
|
|||
public bool CanOpenWrite => true; |
|||
public async Task<Stream> OpenWrite() |
|||
{ |
|||
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties"); |
|||
var streamWriter = await FileHandle.InvokeAsync<IJSInProcessObjectReference>("openWrite"); |
|||
|
|||
return new JSWriteableStream(streamWriter, (long)(properties?.Size ?? 0)); |
|||
} |
|||
} |
|||
|
|||
internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder |
|||
{ |
|||
public JSStorageFolder(IJSInProcessObjectReference fileHandle) : base(fileHandle) |
|||
{ |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
using System.Buffers; |
|||
using System.Text.Json.Serialization; |
|||
|
|||
using Microsoft.JSInterop; |
|||
|
|||
namespace Avalonia.Web.Blazor.Interop.Storage |
|||
{ |
|||
// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream
|
|||
internal sealed class JSWriteableStream : Stream |
|||
{ |
|||
private IJSInProcessObjectReference? _jSReference; |
|||
|
|||
// Unfortunatelly we can't read current length/position, so we need to keep it C#-side only.
|
|||
private long _length, _position; |
|||
|
|||
internal JSWriteableStream(IJSInProcessObjectReference jSReference, long initialLength) |
|||
{ |
|||
_jSReference = jSReference; |
|||
_length = initialLength; |
|||
} |
|||
|
|||
private IJSInProcessObjectReference JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream)); |
|||
|
|||
public override bool CanRead => false; |
|||
|
|||
public override bool CanSeek => true; |
|||
|
|||
public override bool CanWrite => true; |
|||
|
|||
public override long Length => _length; |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _position; |
|||
set => Seek(_position, SeekOrigin.Begin); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
// no-op
|
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
var position = origin switch |
|||
{ |
|||
SeekOrigin.Current => _position + offset, |
|||
SeekOrigin.End => _length + offset, |
|||
_ => offset |
|||
}; |
|||
JSReference.InvokeVoid("seek", position); |
|||
return position; |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
{ |
|||
_length = value; |
|||
|
|||
// See https://docs.w3cub.com/dom/filesystemwritablefilestream/truncate
|
|||
// If the offset is smaller than the size, it remains unchanged. If the offset is larger than size, the offset is set to that size
|
|||
if (_position > _length) |
|||
{ |
|||
_position = _length; |
|||
} |
|||
|
|||
JSReference.InvokeVoid("truncate", value); |
|||
} |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException("Synchronous writes are not supported."); |
|||
} |
|||
|
|||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
|||
{ |
|||
if (offset != 0 || count != buffer.Length) |
|||
{ |
|||
// TODO, we need to pass prepared buffer to the JS
|
|||
// Can't use ArrayPool as it can return bigger array than requested
|
|||
// Can't use Span/Memory, as it's not supported by JS interop yet.
|
|||
// Alternatively we can pass original buffer and offset+count, so it can be trimmed on the JS side (but is it more efficient tho?)
|
|||
buffer = buffer.AsMemory(offset, count).ToArray(); |
|||
} |
|||
return WriteAsyncInternal(buffer, cancellationToken).AsTask(); |
|||
} |
|||
|
|||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) |
|||
{ |
|||
return WriteAsyncInternal(buffer.ToArray(), cancellationToken); |
|||
} |
|||
|
|||
private ValueTask WriteAsyncInternal(byte[] buffer, CancellationToken _) |
|||
{ |
|||
_position += buffer.Length; |
|||
|
|||
return JSReference.InvokeVoidAsync("write", buffer); |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (_jSReference is { } jsReference) |
|||
{ |
|||
_jSReference = null; |
|||
jsReference.InvokeVoid("close"); |
|||
jsReference.Dispose(); |
|||
} |
|||
} |
|||
|
|||
public override async ValueTask DisposeAsync() |
|||
{ |
|||
if (_jSReference is { } jsReference) |
|||
{ |
|||
_jSReference = null; |
|||
await jsReference.InvokeVoidAsync("close"); |
|||
await jsReference.DisposeAsync(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,292 @@ |
|||
// As we don't have proper package managing for Avalonia.Web project, declare types manually
|
|||
declare global { |
|||
interface FileSystemWritableFileStream { |
|||
write(position: number, data: BufferSource | Blob | string): Promise<void>; |
|||
truncate(size: number): Promise<void>; |
|||
close(): Promise<void>; |
|||
} |
|||
type PermissionsMode = "read" | "readwrite"; |
|||
interface FileSystemFileHandle { |
|||
name: string, |
|||
kind: "file" | "directory", |
|||
getFile(): Promise<File>; |
|||
createWritable(options?: { keepExistingData?: boolean }): Promise<FileSystemWritableFileStream>; |
|||
|
|||
queryPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">; |
|||
requestPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">; |
|||
} |
|||
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; |
|||
type StartInDirectory = WellKnownDirectory | FileSystemFileHandle; |
|||
interface FilePickerAcceptType { |
|||
description: string, |
|||
// mime -> ext[] array
|
|||
accept: { [mime: string]: string | string[] } |
|||
} |
|||
interface FilePickerOptions { |
|||
types?: FilePickerAcceptType[], |
|||
excludeAcceptAllOption: boolean, |
|||
id?: string, |
|||
startIn?: StartInDirectory |
|||
} |
|||
interface OpenFilePickerOptions extends FilePickerOptions { |
|||
multiple: boolean |
|||
} |
|||
interface SaveFilePickerOptions extends FilePickerOptions { |
|||
suggestedName?: string |
|||
} |
|||
interface DirectoryPickerOptions { |
|||
id?: string, |
|||
startIn?: StartInDirectory |
|||
} |
|||
|
|||
interface Window { |
|||
showOpenFilePicker: (options: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>; |
|||
showSaveFilePicker: (options: SaveFilePickerOptions) => Promise<FileSystemFileHandle>; |
|||
showDirectoryPicker: (options: DirectoryPickerOptions) => Promise<FileSystemFileHandle>; |
|||
} |
|||
} |
|||
|
|||
// TODO move to another file and use import
|
|||
class IndexedDbWrapper { |
|||
constructor(private databaseName: string, private objectStores: [ string ]) { |
|||
|
|||
} |
|||
|
|||
public connect(): Promise<InnerDbConnection> { |
|||
var conn = window.indexedDB.open(this.databaseName, 1); |
|||
|
|||
conn.onupgradeneeded = event => { |
|||
const db = (<IDBRequest<IDBDatabase>>event.target).result; |
|||
this.objectStores.forEach(store => { |
|||
db.createObjectStore(store); |
|||
}); |
|||
} |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
conn.onsuccess = event => { |
|||
resolve(new InnerDbConnection((<IDBRequest<IDBDatabase>>event.target).result)); |
|||
} |
|||
conn.onerror = event => { |
|||
reject((<IDBRequest<IDBDatabase>>event.target).error); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
class InnerDbConnection { |
|||
constructor(private database: IDBDatabase) { } |
|||
|
|||
private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore { |
|||
const tx = this.database.transaction(store, mode); |
|||
return tx.objectStore(store); |
|||
} |
|||
|
|||
public put(store: string, obj: any, key?: IDBValidKey): Promise<IDBValidKey> { |
|||
const os = this.openStore(store, "readwrite"); |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
var response = os.put(obj, key); |
|||
response.onsuccess = () => { |
|||
resolve(response.result); |
|||
}; |
|||
response.onerror = () => { |
|||
reject(response.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public get(store: string, key: IDBValidKey): any { |
|||
const os = this.openStore(store, "readonly"); |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
var response = os.get(key); |
|||
response.onsuccess = () => { |
|||
resolve(response.result); |
|||
}; |
|||
response.onerror = () => { |
|||
reject(response.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public delete(store: string, key: IDBValidKey): Promise<void> { |
|||
const os = this.openStore(store, "readwrite"); |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
var response = os.delete(key); |
|||
response.onsuccess = () => { |
|||
resolve(); |
|||
}; |
|||
response.onerror = () => { |
|||
reject(response.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public close() { |
|||
this.database.close(); |
|||
} |
|||
} |
|||
|
|||
const fileBookmarksStore: string = "fileBookmarks"; |
|||
const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ |
|||
fileBookmarksStore |
|||
]) |
|||
|
|||
class StorageItem { |
|||
constructor(private handle: FileSystemFileHandle, private bookmarkId?: string) { } |
|||
|
|||
public getName(): string { |
|||
return this.handle.name |
|||
} |
|||
|
|||
public async openRead(): Promise<Blob> { |
|||
await this.verityPermissions('read'); |
|||
|
|||
var file = await this.handle.getFile(); |
|||
return file; |
|||
} |
|||
|
|||
public async openWrite(): Promise<FileSystemWritableFileStream> { |
|||
await this.verityPermissions('readwrite'); |
|||
|
|||
return await this.handle.createWritable({ keepExistingData: true }); |
|||
} |
|||
|
|||
public async getProperties(): Promise<{ Size: number, LastModified: number, Type: string }> { |
|||
var file = this.handle.getFile && await this.handle.getFile(); |
|||
|
|||
return file && { |
|||
Size: file.size, |
|||
LastModified: file.lastModified, |
|||
Type: file.type |
|||
} |
|||
} |
|||
|
|||
private async verityPermissions(mode: PermissionsMode): Promise<void | never> { |
|||
if (await this.handle.queryPermission({ mode }) === 'granted') { |
|||
return; |
|||
} |
|||
|
|||
if (await this.handle.requestPermission({ mode }) === "denied") { |
|||
throw new Error("Read permissions denied"); |
|||
} |
|||
} |
|||
|
|||
public async saveBookmark(): Promise<string> { |
|||
// If file was previously bookmarked, just return old one.
|
|||
if (this.bookmarkId) { |
|||
return this.bookmarkId; |
|||
} |
|||
|
|||
const connection = await avaloniaDb.connect(); |
|||
try { |
|||
const key = await connection.put(fileBookmarksStore, this.handle, this.generateBookmarkId()); |
|||
return <string>key; |
|||
} |
|||
finally { |
|||
connection.close(); |
|||
} |
|||
} |
|||
|
|||
public async deleteBookmark(): Promise<void> { |
|||
if (!this.bookmarkId) { |
|||
return; |
|||
} |
|||
|
|||
const connection = await avaloniaDb.connect(); |
|||
try { |
|||
const key = await connection.delete(fileBookmarksStore, this.bookmarkId); |
|||
} |
|||
finally { |
|||
connection.close(); |
|||
} |
|||
} |
|||
|
|||
private generateBookmarkId(): string { |
|||
return Date.now().toString(36) + Math.random().toString(36).substring(2); |
|||
} |
|||
} |
|||
|
|||
class StorageItems { |
|||
constructor(private items: StorageItem[]) { } |
|||
|
|||
public count(): number { |
|||
return this.items.length; |
|||
} |
|||
|
|||
public at(index: number): StorageItem { |
|||
return this.items[index]; |
|||
} |
|||
} |
|||
|
|||
export class StorageProvider { |
|||
|
|||
public static canOpen(): boolean { |
|||
return typeof window.showOpenFilePicker !== 'undefined'; |
|||
} |
|||
|
|||
public static canSave(): boolean { |
|||
return typeof window.showSaveFilePicker !== 'undefined'; |
|||
} |
|||
|
|||
public static canPickFolder(): boolean { |
|||
return typeof window.showDirectoryPicker !== 'undefined'; |
|||
} |
|||
|
|||
public static async selectFolderDialog( |
|||
startIn: StartInDirectory | null) |
|||
: Promise<StorageItem> { |
|||
|
|||
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
|
|||
const options: DirectoryPickerOptions = { |
|||
startIn: (startIn || undefined) |
|||
}; |
|||
|
|||
const handle = await window.showDirectoryPicker(options); |
|||
return new StorageItem(handle); |
|||
} |
|||
|
|||
public static async openFileDialog( |
|||
startIn: StartInDirectory | null, multiple: boolean, |
|||
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean) |
|||
: Promise<StorageItems> { |
|||
|
|||
const options: OpenFilePickerOptions = { |
|||
startIn: (startIn || undefined), |
|||
multiple, |
|||
excludeAcceptAllOption, |
|||
types: (types || undefined) |
|||
}; |
|||
|
|||
const handles = await window.showOpenFilePicker(options); |
|||
return new StorageItems(handles.map(handle => new StorageItem(handle))); |
|||
} |
|||
|
|||
public static async saveFileDialog( |
|||
startIn: StartInDirectory | null, suggestedName: string | null, |
|||
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean) |
|||
: Promise<StorageItem> { |
|||
|
|||
const options: SaveFilePickerOptions = { |
|||
startIn: (startIn || undefined), |
|||
suggestedName: (suggestedName || undefined), |
|||
excludeAcceptAllOption, |
|||
types: (types || undefined) |
|||
}; |
|||
|
|||
const handle = await window.showSaveFilePicker(options); |
|||
return new StorageItem(handle); |
|||
} |
|||
|
|||
public static async openBookmark(key: string): Promise<StorageItem | null> { |
|||
const connection = await avaloniaDb.connect(); |
|||
try { |
|||
const handle = await connection.get(fileBookmarksStore, key); |
|||
return handle && new StorageItem(handle, key); |
|||
} |
|||
finally { |
|||
connection.close(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue