From e16cce554531ff8f90bc6e7cf5780b5fe0d5e044 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 20 Apr 2023 22:18:10 -0400 Subject: [PATCH 1/4] Use polyfill for the showSaveFilePicker --- .../webapp/modules/storage/storageProvider.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts index 7a29992674..ce9e164735 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts @@ -1,6 +1,6 @@ import { avaloniaDb, fileBookmarksStore } from "./indexedDb"; import { StorageItem, StorageItems } from "./storageItem"; -import { showOpenFilePicker, showDirectoryPicker, FileSystemFileHandle } from "native-file-system-adapter"; +import { showOpenFilePicker, showDirectoryPicker, showSaveFilePicker, FileSystemFileHandle } from "native-file-system-adapter"; declare global { type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; @@ -46,8 +46,7 @@ export class StorageProvider { types: (types ?? undefined) }; - // Always prefer native save file picker, as polyfill solutions are not reliable. - const handle = await (globalThis as any).showSaveFilePicker(options); + const handle = await showSaveFilePicker(options); return StorageItem.createFromHandle(handle); } From 01a4ba5aa25bbad9074bd06f1301c16bdd36a9fe Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 20 Apr 2023 22:18:19 -0400 Subject: [PATCH 2/4] Add PreferFileDialogPolyfill option --- .../Avalonia.Browser/BrowserAppBuilder.cs | 7 +++++++ .../Avalonia.Browser/Interop/StorageHelper.cs | 6 +++--- .../Storage/BrowserStorageProvider.cs | 9 ++++++--- .../webapp/modules/storage/storageProvider.ts | 18 ++++++++++++------ 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs index 9bb471005b..77e8b1a5f6 100644 --- a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs +++ b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs @@ -11,6 +11,13 @@ public class BrowserPlatformOptions /// If null, default path resolved depending on the backend (browser or blazor) is used. /// public Func? FrameworkAssetPathResolver { get; set; } + + /// + /// Avalonia uses "native-file-system-adapter" polyfill for the file dialogs. + /// If native implementation is available, by default it is used. + /// This property forces polyfill to be always used. + /// + public bool PreferFileDialogPolyfill { get; set; } } public static class BrowserAppBuilder diff --git a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs index c56023c0f7..d95d4405ba 100644 --- a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs @@ -9,15 +9,15 @@ internal static partial class StorageHelper public static partial bool HasNativeFilePicker(); [JSImport("StorageProvider.selectFolderDialog", AvaloniaModule.StorageModuleName)] - public static partial Task SelectFolderDialog(JSObject? startIn); + public static partial Task SelectFolderDialog(JSObject? startIn, bool preferPolyfill); [JSImport("StorageProvider.openFileDialog", AvaloniaModule.StorageModuleName)] public static partial Task OpenFileDialog(JSObject? startIn, bool multiple, - [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption); + [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption, bool preferPolyfill); [JSImport("StorageProvider.saveFileDialog", AvaloniaModule.StorageModuleName)] public static partial Task SaveFileDialog(JSObject? startIn, string? suggestedName, - [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption); + [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption, bool preferPolyfill); [JSImport("StorageItem.createWellKnownDirectory", AvaloniaModule.StorageModuleName)] public static partial JSObject CreateWellKnownDirectory(string wellKnownDirectory); diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index e6849f2fcc..f1e90eaf3c 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -20,6 +20,9 @@ internal class BrowserStorageProvider : IStorageProvider public bool CanSave => StorageHelper.HasNativeFilePicker(); public bool CanPickFolder => true; + private bool PreferPolyfill => + AvaloniaLocator.Current.GetService()?.PreferFileDialogPolyfill ?? false; + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) { await AvaloniaModule.ImportStorage(); @@ -29,7 +32,7 @@ internal class BrowserStorageProvider : IStorageProvider try { - using var items = await StorageHelper.OpenFileDialog(startIn, options.AllowMultiple, types, excludeAll); + using var items = await StorageHelper.OpenFileDialog(startIn, options.AllowMultiple, types, excludeAll, PreferPolyfill); if (items is null) { return Array.Empty(); @@ -63,7 +66,7 @@ internal class BrowserStorageProvider : IStorageProvider try { - var item = await StorageHelper.SaveFileDialog(startIn, options.SuggestedFileName, types, excludeAll); + var item = await StorageHelper.SaveFileDialog(startIn, options.SuggestedFileName, types, excludeAll, PreferPolyfill); return item is not null ? new JSStorageFile(item) : null; } catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) @@ -89,7 +92,7 @@ internal class BrowserStorageProvider : IStorageProvider try { - var item = await StorageHelper.SelectFolderDialog(startIn); + var item = await StorageHelper.SelectFolderDialog(startIn, PreferPolyfill); return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty(); } catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts index ce9e164735..8c79f225ee 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts @@ -12,10 +12,12 @@ declare global { export class StorageProvider { public static async selectFolderDialog( - startIn: StorageItem | null): Promise { + startIn: StorageItem | null, + preferPolyfill: boolean): Promise { // 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined. const options = { - startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined) + startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), + _preferPolyfill: preferPolyfill }; const handle = await showDirectoryPicker(options as any); @@ -24,12 +26,14 @@ export class StorageProvider { public static async openFileDialog( startIn: StorageItem | null, multiple: boolean, - types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise { + types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean, + preferPolyfill: boolean): Promise { const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), multiple, excludeAcceptAllOption, - types: (types ?? undefined) + types: (types ?? undefined), + _preferPolyfill: preferPolyfill }; const handles = await showOpenFilePicker(options); @@ -38,12 +42,14 @@ export class StorageProvider { public static async saveFileDialog( startIn: StorageItem | null, suggestedName: string | null, - types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise { + types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean, + preferPolyfill: boolean): Promise { const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), suggestedName: (suggestedName ?? undefined), excludeAcceptAllOption, - types: (types ?? undefined) + types: (types ?? undefined), + _preferPolyfill: preferPolyfill }; const handle = await showSaveFilePicker(options); From 49b560ced2736da49dd092179985bf43e9480024 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 20 Apr 2023 23:54:21 -0400 Subject: [PATCH 3/4] Add avalonia service worker --- .../Avalonia.Browser/BrowserAppBuilder.cs | 14 ++++ .../Interop/AvaloniaModule.cs | 9 +++ .../Storage/BrowserStorageProvider.cs | 4 +- .../Avalonia.Browser/WindowingPlatform.cs | 8 ++ src/Browser/Avalonia.Browser/webapp/build.js | 3 +- .../webapp/modules/avalonia.ts | 9 ++- .../webapp/modules/avalonia/stream.ts | 7 +- .../Avalonia.Browser/webapp/modules/sw.ts | 78 +++++++++++++++++++ 8 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/sw.ts diff --git a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs index 77e8b1a5f6..38784871b1 100644 --- a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs +++ b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs @@ -11,11 +11,25 @@ public class BrowserPlatformOptions /// If null, default path resolved depending on the backend (browser or blazor) is used. /// public Func? FrameworkAssetPathResolver { get; set; } + + /// + /// Defines if the service worker used by Avalonia should be registered. + /// If registered, service worker can work as a save file picker fallback on the browsers that don't support native implementation. + /// For more details, see https://github.com/jimmywarting/native-file-system-adapter#a-note-when-downloading-with-the-polyfilled-version. + /// + public bool RegisterAvaloniaServiceWorker { get; set; } + + /// + /// If is enabled, it is possible to redefine scope for the worker. + /// By default, current domain root is used as a scope. + /// + public string? AvaloniaServiceWorkerScope { get; set; } /// /// Avalonia uses "native-file-system-adapter" polyfill for the file dialogs. /// If native implementation is available, by default it is used. /// This property forces polyfill to be always used. + /// For more details, see https://github.com/jimmywarting/native-file-system-adapter#a-note-when-downloading-with-the-polyfilled-version. /// public bool PreferFileDialogPolyfill { get; set; } } diff --git a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs index 394f191dab..cb7aabbc39 100644 --- a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs +++ b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs @@ -25,6 +25,15 @@ internal static partial class AvaloniaModule public static Task ImportStorage() => s_importStorage.Value; + public static string ResolveServiceWorkerPath() + { + var options = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); + return options.FrameworkAssetPathResolver!("sw.js"); + } + [JSImport("Caniuse.isMobile", AvaloniaModule.MainModuleName)] public static partial bool IsMobile(); + + [JSImport("registerServiceWorker", AvaloniaModule.MainModuleName)] + public static partial void RegisterServiceWorker(string path, string? scope); } diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index f1e90eaf3c..7c0b09cc16 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -9,15 +9,13 @@ using Avalonia.Platform.Storage; namespace Avalonia.Browser.Storage; -internal record FilePickerAcceptType(string Description, IReadOnlyDictionary> Accept); - internal class BrowserStorageProvider : IStorageProvider { internal const string PickerCancelMessage = "The user aborted a request"; internal const string NoPermissionsMessage = "Permissions denied"; public bool CanOpen => true; - public bool CanSave => StorageHelper.HasNativeFilePicker(); + public bool CanSave => true; public bool CanPickFolder => true; private bool PreferPolyfill => diff --git a/src/Browser/Avalonia.Browser/WindowingPlatform.cs b/src/Browser/Avalonia.Browser/WindowingPlatform.cs index be6e28f5cb..a33738079c 100644 --- a/src/Browser/Avalonia.Browser/WindowingPlatform.cs +++ b/src/Browser/Avalonia.Browser/WindowingPlatform.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using Avalonia.Browser.Interop; using Avalonia.Browser.Skia; using Avalonia.Input; using Avalonia.Input.Platform; @@ -46,6 +47,13 @@ namespace Avalonia.Browser .Bind().ToConstant(new BrowserSkiaGraphics()) .Bind().ToSingleton() .Bind().ToSingleton(); + + if (AvaloniaLocator.Current.GetService() is { } options + && options.RegisterAvaloniaServiceWorker) + { + var swPath = AvaloniaModule.ResolveServiceWorkerPath(); + AvaloniaModule.RegisterServiceWorker(swPath, options.AvaloniaServiceWorkerScope); + } } public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick) diff --git a/src/Browser/Avalonia.Browser/webapp/build.js b/src/Browser/Avalonia.Browser/webapp/build.js index c1cbc84709..e8e49554cd 100644 --- a/src/Browser/Avalonia.Browser/webapp/build.js +++ b/src/Browser/Avalonia.Browser/webapp/build.js @@ -1,7 +1,8 @@ require("esbuild").build({ entryPoints: [ "./modules/avalonia.ts", - "./modules/storage.ts" + "./modules/storage.ts", + "./modules/sw.ts" ], outdir: "../wwwroot", bundle: true, diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts index 80faca7a50..2b69254cf2 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts @@ -7,6 +7,12 @@ import { NativeControlHost } from "./avalonia/nativeControlHost"; import { NavigationHelper } from "./avalonia/navigationHelper"; import { GeneralHelpers } from "./avalonia/generalHelpers"; +async function registerServiceWorker(path: string, scope: string | undefined) { + if ("serviceWorker" in navigator) { + await globalThis.navigator.serviceWorker.register(path, scope ? { scope } : undefined); + } +} + export { Caniuse, Canvas, @@ -17,5 +23,6 @@ export { StreamHelper, NativeControlHost, NavigationHelper, - GeneralHelpers + GeneralHelpers, + registerServiceWorker }; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts index 7c7769ea36..2e160ec618 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts @@ -18,12 +18,7 @@ export class StreamHelper { const array = new Uint8Array(span.byteLength); span.copyTo(array); - const data = { - type: "write", - data: array - }; - - return await stream.write(data); + return await stream.write(array); } public static byteLength(stream: Blob) { diff --git a/src/Browser/Avalonia.Browser/webapp/modules/sw.ts b/src/Browser/Avalonia.Browser/webapp/modules/sw.ts new file mode 100644 index 0000000000..2d08ae2c15 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/sw.ts @@ -0,0 +1,78 @@ +const WRITE = 0; +const PULL = 0; +const ERROR = 1; +const ABORT = 1; +const CLOSE = 2; + +class MessagePortSource implements UnderlyingSource { + private controller?: ReadableStreamController; + + constructor (private readonly port: MessagePort) { + this.port.onmessage = evt => this.onMessage(evt.data); + } + + start (controller: ReadableStreamController) { + this.controller = controller; + } + + cancel (reason: Error) { + // Firefox can notify a cancel event, chrome can't + // https://bugs.chromium.org/p/chromium/issues/detail?id=638494 + this.port.postMessage({ type: ERROR, reason: reason.message }); + this.port.close(); + } + + onMessage (message: { type: number; chunk: Uint8Array; reason: any }) { + // enqueue() will call pull() if needed when there's no backpressure + if (!this.controller) { + return; + } + if (message.type === WRITE) { + this.controller.enqueue(message.chunk); + this.port.postMessage({ type: PULL }); + } + if (message.type === ABORT) { + this.controller.error(message.reason); + this.port.close(); + } + if (message.type === CLOSE) { + this.controller.close(); + this.port.close(); + } + } +} + +self.addEventListener("install", () => { + (self as any).skipWaiting(); +}); + +self.addEventListener("activate", event /* ExtendableEvent */ => { + (event as any).waitUntil((self as any).clients.claim()); +}); + +const map = new Map(); + +// This should be called once per download +// Each event has a dataChannel that the data will be piped through +globalThis.addEventListener("message", evt => { + const data = evt.data; + if (data.url && data.readablePort) { + data.rs = new ReadableStream( + new MessagePortSource(evt.data.readablePort), + new CountQueuingStrategy({ highWaterMark: 4 }) + ); + map.set(data.url, data); + } +}); + +globalThis.addEventListener("fetch", evt => { + const url = (evt as any).request.url; + const data = map.get(url); + if (!data) return null; + map.delete(url); + (evt as any).respondWith(new Response(data.rs, { + headers: data.headers + })); +}); + +export {}; From a6cd15bfdbe993809160a3216b15723954a260d1 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 20 Apr 2023 23:54:36 -0400 Subject: [PATCH 4/4] Support default extension --- .../Platform/Storage/FileIO/StorageProviderHelpers.cs | 3 ++- .../Avalonia.Browser/Storage/BrowserStorageProvider.cs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs index 608f924808..55aac6f3fa 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs @@ -50,7 +50,8 @@ internal static class StorageProviderHelpers } } - public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter) + [return: NotNullIfNotNull(nameof(path))] + public static string? NameWithExtension(string? path, string? defaultExtension, FilePickerFileType? filter) { var name = Path.GetFileName(path); if (name != null && !Path.HasExtension(name)) diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index 7c0b09cc16..a28fd4cbde 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -6,6 +6,7 @@ using System.Runtime.InteropServices.JavaScript; using System.Threading.Tasks; using Avalonia.Browser.Interop; using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; namespace Avalonia.Browser.Storage; @@ -64,7 +65,9 @@ internal class BrowserStorageProvider : IStorageProvider try { - var item = await StorageHelper.SaveFileDialog(startIn, options.SuggestedFileName, types, excludeAll, PreferPolyfill); + var suggestedName = + StorageProviderHelpers.NameWithExtension(options.SuggestedFileName, options.DefaultExtension, null); + var item = await StorageHelper.SaveFileDialog(startIn, suggestedName, types, excludeAll, PreferPolyfill); return item is not null ? new JSStorageFile(item) : null; } catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))