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