diff --git a/Ooui.AspNetCore/OouiMiddlewareExtensions.cs b/Ooui.AspNetCore/OouiMiddlewareExtensions.cs index 0d78459..d07f8e9 100644 --- a/Ooui.AspNetCore/OouiMiddlewareExtensions.cs +++ b/Ooui.AspNetCore/OouiMiddlewareExtensions.cs @@ -25,10 +25,13 @@ namespace Microsoft.AspNetCore.Builder }; app.UseWebSockets (webSocketOptions); + Ooui.UI.ServerEnabled = false; + app.Use (async (context, next) => { + var response = context.Response; + if (context.Request.Path == jsPath) { - var response = context.Response; var clientJsBytes = Ooui.UI.ClientJsBytes; var clientJsEtag = Ooui.UI.ClientJsEtag; if (context.Request.Headers.TryGetValue ("If-None-Match", out var inms) && inms.Count > 0 && inms[0] == clientJsEtag) { @@ -53,6 +56,21 @@ namespace Microsoft.AspNetCore.Builder context.Response.StatusCode = 400; } } + else if (Ooui.UI.TryGetFileContentAtPath (context.Request.Path, out var file)) { + if (context.Request.Headers.TryGetValue ("If-None-Match", out var inms) && inms.Count > 0 && inms[0] == file.Etag) { + response.StatusCode = 304; + } + else { + response.StatusCode = 200; + response.ContentLength = file.Content.Length; + response.ContentType = file.ContentType; + response.Headers.Add ("Cache-Control", "public, max-age=60"); + response.Headers.Add ("Etag", file.Etag); + using (var s = response.Body) { + await s.WriteAsync (file.Content, 0, file.Content.Length).ConfigureAwait (false); + } + } + } else { await next ().ConfigureAwait (false); } diff --git a/Ooui.Forms/Exports.cs b/Ooui.Forms/Exports.cs index a467bf5..b093983 100644 --- a/Ooui.Forms/Exports.cs +++ b/Ooui.Forms/Exports.cs @@ -12,9 +12,13 @@ using Xamarin.Forms.Internals; [assembly: ExportRenderer (typeof (Editor), typeof (EditorRenderer))] [assembly: ExportRenderer (typeof (Entry), typeof (EntryRenderer))] [assembly: ExportRenderer (typeof (Frame), typeof (FrameRenderer))] +[assembly: ExportRenderer (typeof (Image), typeof (ImageRenderer))] [assembly: ExportRenderer (typeof (Label), typeof (LabelRenderer))] [assembly: ExportRenderer (typeof (ProgressBar), typeof (ProgressBarRenderer))] [assembly: ExportRenderer (typeof (Switch), typeof (SwitchRenderer))] +[assembly: ExportImageSourceHandler (typeof (FileImageSource), typeof (FileImageSourceHandler))] +[assembly: ExportImageSourceHandler (typeof (StreamImageSource), typeof (StreamImagesourceHandler))] +[assembly: ExportImageSourceHandler (typeof (UriImageSource), typeof (ImageLoaderSourceHandler))] namespace Ooui.Forms { @@ -26,4 +30,13 @@ namespace Ooui.Forms { } } + + [AttributeUsage (AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class ExportImageSourceHandlerAttribute : HandlerAttribute + { + public ExportImageSourceHandlerAttribute (Type handler, Type target) + : base (handler, target) + { + } + } } diff --git a/Ooui.Forms/Forms.cs b/Ooui.Forms/Forms.cs index 991c264..0922ded 100644 --- a/Ooui.Forms/Forms.cs +++ b/Ooui.Forms/Forms.cs @@ -31,7 +31,7 @@ namespace Xamarin.Forms Registrar.RegisterAll (new[] { typeof(ExportRendererAttribute), //typeof(ExportCellAttribute), - //typeof(ExportImageSourceHandlerAttribute), + typeof(ExportImageSourceHandlerAttribute), }); } diff --git a/Ooui.Forms/Renderers/ImageRenderer.cs b/Ooui.Forms/Renderers/ImageRenderer.cs new file mode 100644 index 0000000..d955c47 --- /dev/null +++ b/Ooui.Forms/Renderers/ImageRenderer.cs @@ -0,0 +1,201 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Xamarin.Forms; + +namespace Ooui.Forms.Renderers +{ + public class ImageRenderer : ViewRenderer + { + bool _isDisposed; + + protected override void Dispose (bool disposing) + { + if (_isDisposed) + return; + + if (disposing) { + } + + _isDisposed = true; + + base.Dispose (disposing); + } + + protected override async void OnElementChanged (ElementChangedEventArgs e) + { + if (Control == null) { + var imageView = new Ooui.Image (); + SetNativeControl (imageView); + } + + if (e.NewElement != null) { + SetAspect (); + await TrySetImage (e.OldElement); + SetOpacity (); + } + + base.OnElementChanged (e); + } + + protected override async void OnElementPropertyChanged (object sender, PropertyChangedEventArgs e) + { + base.OnElementPropertyChanged (sender, e); + if (e.PropertyName == Xamarin.Forms.Image.SourceProperty.PropertyName) + await TrySetImage (); + else if (e.PropertyName == Xamarin.Forms.Image.IsOpaqueProperty.PropertyName) + SetOpacity (); + else if (e.PropertyName == Xamarin.Forms.Image.AspectProperty.PropertyName) + SetAspect (); + } + + void SetAspect () + { + if (_isDisposed || Element == null || Control == null) { + return; + } + } + + protected virtual async Task TrySetImage (Xamarin.Forms.Image previous = null) + { + // By default we'll just catch and log any exceptions thrown by SetImage so they don't bring down + // the application; a custom renderer can override this method and handle exceptions from + // SetImage differently if it wants to + + try { + await SetImage (previous).ConfigureAwait (false); + } + catch (Exception ex) { + System.Diagnostics.Debug.WriteLine ("Error loading image: {0}", ex); + } + finally { + ((IImageController)Element)?.SetIsLoading (false); + } + } + + protected async Task SetImage (Xamarin.Forms.Image oldElement = null) + { + if (_isDisposed || Element == null || Control == null) { + return; + } + + var source = Element.Source; + + if (oldElement != null) { + var oldSource = oldElement.Source; + if (Equals (oldSource, source)) + return; + + if (oldSource is FileImageSource && source is FileImageSource && ((FileImageSource)oldSource).File == ((FileImageSource)source).File) + return; + + Control.Source = ""; + } + + IImageSourceHandler handler; + + Element.SetIsLoading (true); + + if (source != null && + (handler = Xamarin.Forms.Internals.Registrar.Registered.GetHandler (source.GetType ())) != null) { + string uiimage; + try { + uiimage = await handler.LoadImageAsync (source, scale: 1.0f); + } + catch (OperationCanceledException) { + uiimage = null; + } + + if (_isDisposed) + return; + + var imageView = Control; + if (imageView != null) + imageView.Source = uiimage; + + ((IVisualElementController)Element).NativeSizeChanged (); + } + else { + Control.Source = ""; + } + + Element.SetIsLoading (false); + } + + void SetOpacity () + { + if (_isDisposed || Element == null || Control == null) { + return; + } + } + } + + public interface IImageSourceHandler : IRegisterable + { + Task LoadImageAsync (ImageSource imagesource, CancellationToken cancelationToken = default (CancellationToken), float scale = 1); + } + + public sealed class FileImageSourceHandler : IImageSourceHandler + { + public async Task LoadImageAsync (ImageSource imagesource, CancellationToken cancelationToken = default (CancellationToken), float scale = 1f) + { + string image = null; + var filesource = imagesource as FileImageSource; + var file = filesource?.File; + if (!string.IsNullOrEmpty (file)) { + var name = System.IO.Path.GetFileName (file); + image = "/images/" + name; + if (Ooui.UI.TryGetFileContentAtPath (image, out var f)) { + // Already published + } + else { + await Task.Run (() => Ooui.UI.PublishFile (image, file), cancelationToken); + } + } + return image; + } + } + + public sealed class StreamImagesourceHandler : IImageSourceHandler + { + public async Task LoadImageAsync (ImageSource imagesource, CancellationToken cancelationToken = default (CancellationToken), float scale = 1f) + { + string image = null; + var streamsource = imagesource as StreamImageSource; + if (streamsource?.Stream != null) { + using (var streamImage = await ((IStreamImageSource)streamsource).GetStreamAsync (cancelationToken).ConfigureAwait (false)) { + if (streamImage != null) { + var data = new byte[streamImage.Length]; + using (var outputStream = new System.IO.MemoryStream (data)) { + await streamImage.CopyToAsync (outputStream, 4096, cancelationToken).ConfigureAwait (false); + } + var hash = Ooui.UI.Hash (data); + var etag = "\"" + hash + "\""; + image = "/images/" + hash; + if (Ooui.UI.TryGetFileContentAtPath (image, out var file) && file.Etag == etag) { + // Already published + } + else { + Ooui.UI.PublishFile (image, data, etag, "image"); + } + } + } + } + + if (image == null) { + System.Diagnostics.Debug.WriteLine ("Could not load image: {0}", streamsource); + } + return image; + } + } + + public sealed class ImageLoaderSourceHandler : IImageSourceHandler + { + public Task LoadImageAsync (ImageSource imagesource, CancellationToken cancelationToken = default (CancellationToken), float scale = 1f) + { + var imageLoader = imagesource as UriImageSource; + return Task.FromResult (imageLoader?.Uri.ToString () ?? ""); + } + } +} diff --git a/Ooui/UI.cs b/Ooui/UI.cs index aa0f75e..33c39ac 100644 --- a/Ooui/UI.cs +++ b/Ooui/UI.cs @@ -73,6 +73,19 @@ namespace Ooui } } } + static bool serverEnabled = true; + public static bool ServerEnabled { + get => serverEnabled; + set { + if (serverEnabled != value) { + serverEnabled = value; + if (serverEnabled) + Restart (); + else + Stop (); + } + } + } static UI () { @@ -91,7 +104,7 @@ namespace Ooui clientJsEtag = "\"" + Hash (clientJsBytes) + "\""; } - static string Hash (byte[] bytes) + public static string Hash (byte[] bytes) { var sha = sha256; if (sha == null) { @@ -135,7 +148,47 @@ namespace Ooui if (contentType == null) { contentType = GuessContentType (path, filePath); } - Publish (path, new DataHandler (data, contentType)); + var etag = "\"" + Hash (data) + "\""; + Publish (path, new DataHandler (data, etag, contentType)); + } + + public static void PublishFile (string path, byte[] data, string contentType) + { + var etag = "\"" + Hash (data) + "\""; + Publish (path, new DataHandler (data, etag, contentType)); + } + + public static void PublishFile (string path, byte[] data, string etag, string contentType) + { + Publish (path, new DataHandler (data, etag, contentType)); + } + + public static bool TryGetFileContentAtPath (string path, out FileContent file) + { + RequestHandler handler; + lock (publishedPaths) { + if (!publishedPaths.TryGetValue (path, out handler)) { + file = null; + return false; + } + } + if (handler is DataHandler dh) { + file = new FileContent { + Etag = dh.Etag, + Content = dh.Data, + ContentType = dh.ContentType, + }; + return true; + } + file = null; + return false; + } + + public class FileContent + { + public string ContentType { get; set; } + public string Etag { get; set; } + public byte[] Content { get; set; } } public static void PublishJson (string path, Func ctor) @@ -146,7 +199,8 @@ namespace Ooui public static void PublishJson (string path, object value) { var data = JsonHandler.GetData (value); - Publish (path, new DataHandler (data, JsonHandler.ContentType)); + var etag = "\"" + Hash (data) + "\""; + Publish (path, new DataHandler (data, etag, JsonHandler.ContentType)); } public static void PublishCustomResponse (string path, Action responder) @@ -178,6 +232,7 @@ namespace Ooui static void Start () { + if (!serverEnabled) return; if (serverCts != null) return; serverCts = new CancellationTokenSource (); var token = serverCts.Token; @@ -343,11 +398,17 @@ namespace Ooui class DataHandler : RequestHandler { readonly byte[] data; + readonly string etag; readonly string contentType; - public DataHandler (byte[] data, string contentType = null) + public byte[] Data => data; + public string Etag => etag; + public string ContentType => contentType; + + public DataHandler (byte[] data, string etag, string contentType = null) { this.data = data; + this.etag = etag; this.contentType = contentType; } @@ -357,13 +418,20 @@ namespace Ooui var path = url.LocalPath; var response = listenerContext.Response; - response.StatusCode = 200; - if (!string.IsNullOrEmpty (contentType)) - response.ContentType = contentType; - response.ContentLength64 = data.LongLength; + var inm = listenerContext.Request.Headers.Get ("If-None-Match"); + if (!string.IsNullOrEmpty (inm) && inm == etag) { + response.StatusCode = 304; + } + else { + response.StatusCode = 200; + response.AddHeader ("Etag", etag); + if (!string.IsNullOrEmpty (contentType)) + response.ContentType = contentType; + response.ContentLength64 = data.LongLength; - using (var s = response.OutputStream) { - s.Write (data, 0, data.Length); + using (var s = response.OutputStream) { + s.Write (data, 0, data.Length); + } } response.Close (); }