Browse Source

Add avalonia service worker

pull/11085/head
Max Katz 3 years ago
parent
commit
49b560ced2
  1. 14
      src/Browser/Avalonia.Browser/BrowserAppBuilder.cs
  2. 9
      src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs
  3. 4
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  4. 8
      src/Browser/Avalonia.Browser/WindowingPlatform.cs
  5. 3
      src/Browser/Avalonia.Browser/webapp/build.js
  6. 9
      src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts
  7. 7
      src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts
  8. 78
      src/Browser/Avalonia.Browser/webapp/modules/sw.ts

14
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.
/// </summary>
public Func<string, string>? FrameworkAssetPathResolver { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool RegisterAvaloniaServiceWorker { get; set; }
/// <summary>
/// If <see cref="RegisterAvaloniaServiceWorker"/> is enabled, it is possible to redefine scope for the worker.
/// By default, current domain root is used as a scope.
/// </summary>
public string? AvaloniaServiceWorkerScope { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool PreferFileDialogPolyfill { get; set; }
}

9
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<BrowserPlatformOptions>() ?? 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);
}

4
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<string, IReadOnlyList<string>> 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 =>

8
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<IPlatformGraphics>().ToConstant(new BrowserSkiaGraphics())
.Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>()
.Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>();
if (AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() is { } options
&& options.RegisterAvaloniaServiceWorker)
{
var swPath = AvaloniaModule.ResolveServiceWorkerPath();
AvaloniaModule.RegisterServiceWorker(swPath, options.AvaloniaServiceWorkerScope);
}
}
public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick)

3
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,

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

7
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) {

78
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<any>;
constructor (private readonly port: MessagePort) {
this.port.onmessage = evt => this.onMessage(evt.data);
}
start (controller: ReadableStreamController<any>) {
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 {};
Loading…
Cancel
Save