diff --git a/.gitignore b/.gitignore index fd8fca6..d2a542c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ + +# Wasm SDK +/Ooui.Wasm/*.nupkg +/Ooui.Wasm.Test +/Ooui.Wasm.Old + # Social media files /Media diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fa91679 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Ooui.Wasm.Build.Tasks/linker"] + path = Ooui.Wasm.Build.Tasks/linker + url = git@github.com:mono/linker.git diff --git a/Ooui.AspNetCore/ElementResult.cs b/Ooui.AspNetCore/ElementResult.cs index e918d73..03f9975 100644 --- a/Ooui.AspNetCore/ElementResult.cs +++ b/Ooui.AspNetCore/ElementResult.cs @@ -22,6 +22,7 @@ namespace Ooui.AspNetCore var response = context.HttpContext.Response; response.StatusCode = 200; response.ContentType = "text/html; charset=utf-8"; + response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; if (element.WantsFullScreen) { element.Style.Width = GetCookieDouble (context.HttpContext.Request.Cookies, "oouiWindowWidth", 32, 640, 10000); diff --git a/Ooui.AspNetCore/WebSocketHandler.cs b/Ooui.AspNetCore/WebSocketHandler.cs index c93d3c4..03a7794 100644 --- a/Ooui.AspNetCore/WebSocketHandler.cs +++ b/Ooui.AspNetCore/WebSocketHandler.cs @@ -11,21 +11,21 @@ namespace Ooui.AspNetCore { public static string WebSocketPath { get; set; } = "/ooui.ws"; - public static TimeSpan SessionTimeout { get; set; } = TimeSpan.FromMinutes (5); + public static TimeSpan SessionTimeout { get; set; } = TimeSpan.FromMinutes (1); - static readonly ConcurrentDictionary activeSessions = - new ConcurrentDictionary (); + static readonly ConcurrentDictionary pendingSessions = + new ConcurrentDictionary (); public static string BeginSession (HttpContext context, Element element) { var id = Guid.NewGuid ().ToString ("N"); - var s = new ActiveSession { + var s = new PendingSession { Element = element, - LastConnectTimeUtc = DateTime.UtcNow, + CreateTimeUtc = DateTime.UtcNow, }; - if (!activeSessions.TryAdd (id, s)) { + if (!pendingSessions.TryAdd (id, s)) { throw new Exception ("Failed to schedule pending session"); } @@ -62,19 +62,18 @@ namespace Ooui.AspNetCore // // Clear old sessions // - var toClear = activeSessions.Where (x => (DateTime.UtcNow - x.Value.LastConnectTimeUtc) > SessionTimeout).ToList (); + var toClear = pendingSessions.Where (x => (DateTime.UtcNow - x.Value.CreateTimeUtc) > SessionTimeout).ToList (); foreach (var c in toClear) { - activeSessions.TryRemove (c.Key, out var _); + pendingSessions.TryRemove (c.Key, out var _); } // // Find the pending session // - if (!activeSessions.TryGetValue (id, out var activeSession)) { + if (!pendingSessions.TryRemove (id, out var activeSession)) { BadRequest ("Unknown `id`"); return; } - activeSession.LastConnectTimeUtc = DateTime.UtcNow; // // Set the element's dimensions @@ -98,14 +97,14 @@ namespace Ooui.AspNetCore // var token = CancellationToken.None; var webSocket = await context.WebSockets.AcceptWebSocketAsync ("ooui"); - var session = new Ooui.UI.Session (webSocket, activeSession.Element, w, h, token); + var session = new Ooui.WebSocketSession (webSocket, activeSession.Element, w, h, token); await session.RunAsync ().ConfigureAwait (false); } - class ActiveSession + class PendingSession { public Element Element; - public DateTime LastConnectTimeUtc; + public DateTime CreateTimeUtc; } } } diff --git a/Ooui.Forms/DisplayAlert.cs b/Ooui.Forms/DisplayAlert.cs index fae811a..0770cfa 100644 --- a/Ooui.Forms/DisplayAlert.cs +++ b/Ooui.Forms/DisplayAlert.cs @@ -1,4 +1,4 @@ -using System.Web; +using System; using Xamarin.Forms.Internals; namespace Ooui.Forms @@ -31,7 +31,7 @@ namespace Ooui.Forms ClassName = "close" }; - _closeButton.AppendChild(new Span(HttpUtility.HtmlDecode("×"))); + _closeButton.AppendChild(new Span("×")); var h4 = new Heading(4) { diff --git a/Ooui.Forms/Forms.cs b/Ooui.Forms/Forms.cs index a2b1f37..235927e 100644 --- a/Ooui.Forms/Forms.cs +++ b/Ooui.Forms/Forms.cs @@ -21,7 +21,7 @@ namespace Xamarin.Forms return; IsInitialized = true; - Log.Listeners.Add (new DelegateLogListener ((c, m) => Trace.WriteLine (m, c))); + Log.Listeners.Add (new DelegateLogListener ((c, m) => System.Diagnostics.Debug.WriteLine (m, c))); Device.SetIdiom (TargetIdiom.Desktop); Device.PlatformServices = new OouiPlatformServices (); @@ -133,7 +133,7 @@ namespace Xamarin.Forms { if (timer != null) return; - var interval = TimeSpan.FromSeconds (1.0 / Ooui.UI.Session.MaxFps); + var interval = TimeSpan.FromSeconds (1.0 / Ooui.UI.MaxFps); timer = new Timer ((_ => { this.SendSignals (); }), null, (int)interval.TotalMilliseconds, (int)interval.TotalMilliseconds); diff --git a/Ooui.Forms/Ooui.Forms.csproj b/Ooui.Forms/Ooui.Forms.csproj index 440e8f2..9552095 100644 --- a/Ooui.Forms/Ooui.Forms.csproj +++ b/Ooui.Forms/Ooui.Forms.csproj @@ -9,16 +9,14 @@ https://github.com/praeclarum/Ooui https://github.com/praeclarum/Ooui/blob/master/LICENSE https://github.com/praeclarum/Ooui.git - netstandard2.0 + netstandard2.0 + true - - true - - - - true + + PCL + diff --git a/Ooui.Forms/Platform.cs b/Ooui.Forms/Platform.cs index 96197cb..635fe97 100644 --- a/Ooui.Forms/Platform.cs +++ b/Ooui.Forms/Platform.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using Ooui.Forms.Renderers; using Xamarin.Forms; using Xamarin.Forms.Internals; -using System.Web; namespace Ooui.Forms { @@ -142,7 +141,7 @@ namespace Ooui.Forms void AddChild (VisualElement view) { if (!Application.IsApplicationOrNull (view.RealParent)) - Console.Error.WriteLine ("Tried to add parented view to canvas directly"); + System.Diagnostics.Debug.WriteLine ("Tried to add parented view to canvas directly"); if (GetRenderer (view) == null) { var viewRenderer = CreateRenderer (view); @@ -152,7 +151,7 @@ namespace Ooui.Forms viewRenderer.SetElementSize (new Size (640, 480)); } else - Console.Error.WriteLine ("Potential view double add"); + System.Diagnostics.Debug.WriteLine ("Potential view double add"); } void HandleRendererStyle_PropertyChanged (object sender, System.ComponentModel.PropertyChangedEventArgs e) diff --git a/Ooui.Forms/Renderers/ImageRenderer.cs b/Ooui.Forms/Renderers/ImageRenderer.cs index 3f9e6e2..30e6458 100644 --- a/Ooui.Forms/Renderers/ImageRenderer.cs +++ b/Ooui.Forms/Renderers/ImageRenderer.cs @@ -139,6 +139,7 @@ namespace Ooui.Forms.Renderers public sealed class FileImageSourceHandler : IImageSourceHandler { +#pragma warning disable 1998 public async Task LoadImageAsync (ImageSource imagesource, CancellationToken cancelationToken = default (CancellationToken), float scale = 1f) { string image = null; @@ -171,7 +172,7 @@ namespace Ooui.Forms.Renderers using (var outputStream = new System.IO.MemoryStream (data)) { await streamImage.CopyToAsync (outputStream, 4096, cancelationToken).ConfigureAwait (false); } - var hash = Ooui.UI.Hash (data); + var hash = Ooui.Utilities.Hash (data); var etag = "\"" + hash + "\""; image = "/images/" + hash; if (Ooui.UI.TryGetFileContentAtPath (image, out var file) && file.Etag == etag) { diff --git a/Ooui.Forms/Renderers/ListViewRenderer.cs b/Ooui.Forms/Renderers/ListViewRenderer.cs index da198fe..efaf229 100644 --- a/Ooui.Forms/Renderers/ListViewRenderer.cs +++ b/Ooui.Forms/Renderers/ListViewRenderer.cs @@ -48,6 +48,8 @@ namespace Ooui.Forms.Renderers protected override void Dispose(bool disposing) { + UnsubscribeCellClicks(); + base.Dispose(disposing); if (disposing && !_disposed) @@ -56,8 +58,17 @@ namespace Ooui.Forms.Renderers } } + private void UnsubscribeCellClicks() + { + foreach (var c in _cells) + { + c.Click -= ListItem_Click; + } + } + private void UpdateItems() { + UnsubscribeCellClicks(); _cells.Clear(); var items = TemplatedItemsView.TemplatedItems; @@ -82,6 +93,7 @@ namespace Ooui.Forms.Renderers listItem.Style["list-style-type"] = "none"; listItem.AppendChild(cell); + listItem.Click += ListItem_Click; _cells.Add(listItem); } @@ -92,6 +104,13 @@ namespace Ooui.Forms.Renderers } } + private void ListItem_Click(object sender, TargetEventArgs e) + { + var it = (ListItem)sender; + var ndx = _cells.IndexOf(it); + Element.NotifyRowTapped(ndx, null); + } + private void UpdateBackgroundColor() { var backgroundColor = Element.BackgroundColor.ToOouiColor(); diff --git a/Ooui.Wasm.Build.Tasks/BuildDistTask.cs b/Ooui.Wasm.Build.Tasks/BuildDistTask.cs new file mode 100644 index 0000000..363a921 --- /dev/null +++ b/Ooui.Wasm.Build.Tasks/BuildDistTask.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Mono.Cecil; +using Mono.Linker; +using Mono.Linker.Steps; + +namespace Ooui.Wasm.Build.Tasks +{ + public class BuildDistTask : Task + { + const string SdkUrl = "https://jenkins.mono-project.com/job/test-mono-mainline-webassembly/62/label=highsierra/Azure/processDownloadRequest/62/highsierra/sdks/wasm/mono-wasm-ddf4e7be31b.zip"; + + [Required] + public string Assembly { get; set; } + [Required] + public string OutputPath { get; set; } + public string ReferencePath { get; set; } + + public override bool Execute () + { + try { + InstallSdk (); + GetBcl (); + CreateDist (); + CopyRuntime (); + LinkAssemblies (); + ExtractClientJs (); + DiscoverEntryPoint (); + GenerateHtml (); + return true; + } + catch (Exception ex) { + Log.LogErrorFromException (ex); + return false; + } + } + + string sdkPath; + + void InstallSdk () + { + var sdkName = Path.GetFileNameWithoutExtension (new Uri (SdkUrl).AbsolutePath.Replace ('/', Path.DirectorySeparatorChar)); + Log.LogMessage ("SDK: " + sdkName); + sdkPath = Path.Combine (Path.GetTempPath (), sdkName); + Log.LogMessage ("SDK Path: " + sdkPath); + if (Directory.Exists (sdkPath)) + return; + + var client = new WebClient (); + var zipPath = sdkPath + ".zip"; + Log.LogMessage ($"Downloading {sdkName} to {zipPath}"); + client.DownloadFile (SdkUrl, zipPath); + + ZipFile.ExtractToDirectory (zipPath, sdkPath); + Log.LogMessage ($"Extracted {sdkName} to {sdkPath}"); + } + + string bclPath; + Dictionary bclAssemblies; + + void GetBcl () + { + bclPath = Path.Combine (sdkPath, "bcl"); + var reals = Directory.GetFiles (bclPath, "*.dll"); + var facades = Directory.GetFiles (Path.Combine (bclPath, "Facades"), "*.dll"); + var allFiles = reals.Concat (facades); + bclAssemblies = allFiles.ToDictionary (x => Path.GetFileName (x)); + } + + string distPath; + string managedPath; + + void CreateDist () + { + var outputPath = Path.GetFullPath (OutputPath); + distPath = Path.Combine (outputPath, "dist"); + managedPath = Path.Combine (distPath, "managed"); + Directory.CreateDirectory (managedPath); + } + + void CopyRuntime () + { + var rtPath = Path.Combine (sdkPath, "release"); + var files = new[] { "mono.wasm", "mono.js" }; + foreach (var f in files) { + var src = Path.Combine (rtPath, f); + var dest = Path.Combine (distPath, f); + Log.LogMessage ($"Runtime {src} -> {dest}"); + File.Copy (src, dest, true); + } + File.Copy (Path.Combine (sdkPath, "server.py"), Path.Combine (distPath, "server.py"), true); + } + + List linkedAsmPaths; + List refpaths; + + void LinkAssemblies () + { + var references = ReferencePath.Split (';').Select (x => x.Trim ()).Where (x => x.Length > 0).ToList (); + refpaths = new List (); + foreach (var r in references) { + var name = Path.GetFileName (r); + if (bclAssemblies.ContainsKey (name)) { + refpaths.Add (bclAssemblies[name]); + } + else { + refpaths.Add (r); + } + } + + var asmPath = Path.GetFullPath (Assembly); + + var pipeline = GetLinkerPipeline (); + using (var context = new LinkContext (pipeline)) { + context.CoreAction = AssemblyAction.CopyUsed; + context.UserAction = AssemblyAction.CopyUsed; + context.OutputDirectory = managedPath; + + pipeline.PrependStep (new ResolveFromAssemblyStep (asmPath, ResolveFromAssemblyStep.RootVisibility.Any)); + + var refdirs = refpaths.Select (x => Path.GetDirectoryName (x)).Distinct ().ToList (); + refdirs.Insert (0, Path.Combine (bclPath, "Facades")); + refdirs.Insert (0, bclPath); + foreach (var d in refdirs.Distinct ()) { + context.Resolver.AddSearchDirectory (d); + } + + pipeline.AddStepAfter (typeof (LoadReferencesStep), new LoadI18nAssemblies (I18nAssemblies.None)); + + foreach (var dll in Directory.GetFiles (managedPath, "*.dll")) { + File.Delete (dll); + } + pipeline.Process (context); + } + + linkedAsmPaths = Directory.GetFiles (managedPath, "*.dll").OrderBy (x => Path.GetFileName (x)).ToList (); + } + + class PreserveUsingAttributesStep : ResolveStep + { + readonly HashSet ignoreAsmNames; + + public PreserveUsingAttributesStep (IEnumerable ignoreAsmNames) + { + this.ignoreAsmNames = new HashSet (ignoreAsmNames); + } + + protected override void Process () + { + var asms = Context.GetAssemblies (); + foreach (var a in asms.Where (x => !ignoreAsmNames.Contains (x.Name.Name))) { + foreach (var m in a.Modules) { + foreach (var t in m.Types) { + PreserveTypeIfRequested (t); + } + } + } + } + + void PreserveTypeIfRequested (TypeDefinition type) + { + var typePreserved = IsTypePreserved (type); + if (IsTypePreserved (type)) { + MarkAndPreserveAll (type); + } + else { + foreach (var m in type.Methods.Where (IsMethodPreserved)) { + Annotations.AddPreservedMethod (type, m); + } + foreach (var t in type.NestedTypes) { + PreserveTypeIfRequested (t); + } + } + } + + static bool IsTypePreserved (TypeDefinition m) + { + return m.CustomAttributes.FirstOrDefault (x => x.AttributeType.Name.StartsWith ("Preserve", StringComparison.Ordinal)) != null; + } + + static bool IsMethodPreserved (MethodDefinition m) + { + return m.CustomAttributes.FirstOrDefault (x => x.AttributeType.Name.StartsWith ("Preserve", StringComparison.Ordinal)) != null; + } + + void MarkAndPreserveAll (TypeDefinition type) + { + Annotations.MarkAndPush (type); + Annotations.SetPreserve (type, TypePreserve.All); + if (!type.HasNestedTypes) { + Tracer.Pop (); + return; + } + foreach (TypeDefinition nested in type.NestedTypes) + MarkAndPreserveAll (nested); + Tracer.Pop (); + } + } + + Pipeline GetLinkerPipeline () + { + var p = new Pipeline (); + p.AppendStep (new LoadReferencesStep ()); + p.AppendStep (new PreserveUsingAttributesStep (bclAssemblies.Values.Select (Path.GetFileNameWithoutExtension))); + p.AppendStep (new BlacklistStep ()); + p.AppendStep (new TypeMapStep ()); + p.AppendStep (new MarkStep ()); + p.AppendStep (new SweepStep ()); + p.AppendStep (new CleanStep ()); + p.AppendStep (new RegenerateGuidStep ()); + p.AppendStep (new OutputStep ()); + return p; + } + + void ExtractClientJs () + { + var oouiPath = refpaths.FirstOrDefault (x => Path.GetFileName (x).Equals ("Ooui.dll", StringComparison.InvariantCultureIgnoreCase)); + if (oouiPath == null) { + Log.LogError ("Ooui.dll not included in the project"); + return; + } + var oouiAsm = AssemblyDefinition.ReadAssembly (oouiPath); + var clientJs = oouiAsm.MainModule.Resources.FirstOrDefault (x => x.Name.EndsWith ("Client.js", StringComparison.InvariantCultureIgnoreCase)) as EmbeddedResource; + if (clientJs == null) { + Log.LogError ("Ooui.dll missing client javascript"); + return; + } + var dest = Path.Combine (distPath, "ooui.js"); + using (var srcs = clientJs.GetResourceStream ()) { + using (var dests = new FileStream (dest, FileMode.Create, FileAccess.Write)) { + srcs.CopyTo (dests); + } + } + Log.LogMessage ($"Client JS {dest}"); + } + + MethodDefinition entryPoint; + + void DiscoverEntryPoint () + { + var asm = AssemblyDefinition.ReadAssembly (Assembly); + entryPoint = asm.EntryPoint; + if (entryPoint == null) { + throw new Exception ($"{Path.GetFileName (Assembly)} is missing an entry point"); + } + } + + void GenerateHtml () + { + var htmlPath = Path.Combine (distPath, "index.html"); + using (var w = new StreamWriter (htmlPath, false, new UTF8Encoding (false))) { + w.Write (@" + + + + + + + + +
+

Loading...

+
+ + + + +"); + } + Log.LogMessage ($"HTML {htmlPath}"); + } + } +} diff --git a/Ooui.Wasm.Build.Tasks/Ooui.Wasm.Build.Tasks.csproj b/Ooui.Wasm.Build.Tasks/Ooui.Wasm.Build.Tasks.csproj new file mode 100644 index 0000000..37231a9 --- /dev/null +++ b/Ooui.Wasm.Build.Tasks/Ooui.Wasm.Build.Tasks.csproj @@ -0,0 +1,30 @@ + + + + netstandard2.0 + NET_CORE + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ooui.Wasm.Build.Tasks/linker b/Ooui.Wasm.Build.Tasks/linker new file mode 160000 index 0000000..1dcc9af --- /dev/null +++ b/Ooui.Wasm.Build.Tasks/linker @@ -0,0 +1 @@ +Subproject commit 1dcc9afa256c8e94050b6a21f03b503508e47f05 diff --git a/Ooui.Wasm/Makefile b/Ooui.Wasm/Makefile new file mode 100644 index 0000000..f5fedc5 --- /dev/null +++ b/Ooui.Wasm/Makefile @@ -0,0 +1,4 @@ +all: + msbuild /p:Configuration=Release /t:Restore ../Ooui.Wasm.Build.Tasks/Ooui.Wasm.Build.Tasks.csproj + msbuild /p:Configuration=Release ../Ooui.Wasm.Build.Tasks/Ooui.Wasm.Build.Tasks.csproj + nuget pack diff --git a/Ooui.Wasm/Ooui.Wasm.nuspec b/Ooui.Wasm/Ooui.Wasm.nuspec new file mode 100644 index 0000000..6ed00fd --- /dev/null +++ b/Ooui.Wasm/Ooui.Wasm.nuspec @@ -0,0 +1,25 @@ + + + + Ooui.Wasm + 1.0.0 + Ooui.Wasm + praeclarum + praeclarum + https://github.com/praeclarum/Ooui/blob/master/LICENSE + https://github.com/praeclarum/Ooui + https://github.com/praeclarum/Ooui/raw/master/Documentation/Icon.png + false + WebAssembly support for Ooui apps + Ooui UI CrossPlatform WebAssembly Wasm + + + + + + + + + + + diff --git a/Ooui.Wasm/Ooui.Wasm.targets b/Ooui.Wasm/Ooui.Wasm.targets new file mode 100644 index 0000000..ed1ac2c --- /dev/null +++ b/Ooui.Wasm/Ooui.Wasm.targets @@ -0,0 +1,23 @@ + + + + + + + + $(CompileDependsOn); + BuildDist; + + + + + + <_BuildDistAlreadyExecuted>true + + + + + diff --git a/Ooui.sln b/Ooui.sln index 429aab2..26cd07d 100644 --- a/Ooui.sln +++ b/Ooui.sln @@ -2,22 +2,24 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27130.2010 MinimumVisualStudioVersion = 15.0.26124.0 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ooui", "Ooui\Ooui.csproj", "{DFDFD036-BF48-4D3A-BF99-88CA1EA8E4B9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ooui", "Ooui\Ooui.csproj", "{DFDFD036-BF48-4D3A-BF99-88CA1EA8E4B9}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples", "Samples\Samples.csproj", "{CDF8BB01-40BB-402F-8446-47AA6F1628F3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{CDF8BB01-40BB-402F-8446-47AA6F1628F3}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{78F6E9E7-4322-4F87-8CE9-1EEF1B16D268}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{78F6E9E7-4322-4F87-8CE9-1EEF1B16D268}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ooui.Forms", "Ooui.Forms\Ooui.Forms.csproj", "{DB819A2F-91E1-40FB-8D48-6544169966B8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ooui.Forms", "Ooui.Forms\Ooui.Forms.csproj", "{DB819A2F-91E1-40FB-8D48-6544169966B8}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ooui.AspNetCore", "Ooui.AspNetCore\Ooui.AspNetCore.csproj", "{2EDF0328-698B-458A-B10C-AB1B4786A6CA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ooui.AspNetCore", "Ooui.AspNetCore\Ooui.AspNetCore.csproj", "{2EDF0328-698B-458A-B10C-AB1B4786A6CA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PlatformSamples", "PlatformSamples", "{12ADF328-BBA8-48FC-9AF1-F11B7921D9EA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreMvc", "PlatformSamples\AspNetCoreMvc\AspNetCoreMvc.csproj", "{7C6D477C-3378-4A86-9C31-AAD51204120B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreMvc", "PlatformSamples\AspNetCoreMvc\AspNetCoreMvc.csproj", "{7C6D477C-3378-4A86-9C31-AAD51204120B}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OouiTemplates", "Templates\OouiTemplates\OouiTemplates.csproj", "{1DA10AAB-EB41-49CF-9441-B4D28D0A7F96}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ooui.Wasm.Build.Tasks", "Ooui.Wasm.Build.Tasks\Ooui.Wasm.Build.Tasks.csproj", "{6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -112,6 +114,18 @@ Global {1DA10AAB-EB41-49CF-9441-B4D28D0A7F96}.Release|x64.Build.0 = Release|Any CPU {1DA10AAB-EB41-49CF-9441-B4D28D0A7F96}.Release|x86.ActiveCfg = Release|Any CPU {1DA10AAB-EB41-49CF-9441-B4D28D0A7F96}.Release|x86.Build.0 = Release|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Debug|x64.Build.0 = Debug|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Debug|x86.Build.0 = Debug|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Release|Any CPU.Build.0 = Release|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Release|x64.ActiveCfg = Release|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Release|x64.Build.0 = Release|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Release|x86.ActiveCfg = Release|Any CPU + {6E9C9582-0DA8-4496-BAE0-23EFAF4A10C2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Ooui/Button.cs b/Ooui/Button.cs index 4102dcd..e11da52 100644 --- a/Ooui/Button.cs +++ b/Ooui/Button.cs @@ -7,7 +7,6 @@ namespace Ooui { public class Button : FormControl { - ButtonType typ = ButtonType.Submit; public ButtonType Type { get => GetAttribute ("type", ButtonType.Submit); set => SetAttributeProperty ("type", value); diff --git a/Ooui/Canvas.cs b/Ooui/Canvas.cs index 66ff2cb..edc3029 100644 --- a/Ooui/Canvas.cs +++ b/Ooui/Canvas.cs @@ -36,8 +36,10 @@ namespace Ooui { if (message.TargetId == Id) { switch (message.MessageType) { - case MessageType.Call when message.Key == "getContext" && message.Value is Array a && a.Length == 1 && "2d".Equals (a.GetValue (0)): - AddStateMessage (message); + case MessageType.Call: + if (message.Key == "getContext" && message.Value is Array a && a.Length == 1 && "2d".Equals (a.GetValue (0))) { + AddStateMessage (message); + } break; } } diff --git a/Ooui/Client.js b/Ooui/Client.js index e50d972..83b7203 100644 --- a/Ooui/Client.js +++ b/Ooui/Client.js @@ -6,6 +6,17 @@ const nodes = {}; const hasText = {}; let socket = null; +let wasmSession = null; + +function send (json) { + if (debug) console.log ("Send", json); + if (socket != null) { + socket.send (json); + } + else if (wasmSession != null) { + WebAssemblyApp.receiveMessagesJson (wasmSession, json); + } +} const mouseEvents = { click: true, @@ -94,34 +105,43 @@ function ooui (rootElementPath) { console.log("Web socket created"); - // Throttled window resize event - (function() { - window.addEventListener("resize", resizeThrottler, false); + monitorSizeChanges (1000/10); +} - var resizeTimeout; - function resizeThrottler() { - if (!resizeTimeout) { - resizeTimeout = setTimeout(function() { - resizeTimeout = null; - resizeHandler(); - }, 100); - } +function oouiWasm (mainAsmName, mainNamespace, mainClassName, mainMethodName, assemblies) +{ + Module.entryPoint = { "a": mainAsmName, "n": mainNamespace, "t": mainClassName, "m": mainMethodName }; + Module.assemblies = assemblies; + + monitorSizeChanges (1000/30); +} + +function monitorSizeChanges (millis) +{ + var resizeTimeout; + function resizeThrottler() { + if (!resizeTimeout) { + resizeTimeout = setTimeout(function() { + resizeTimeout = null; + resizeHandler(); + }, millis); } + } - function resizeHandler() { - const em = { - m: "event", - id: "window", - k: "resize", - v: getSize (), - }; - saveSize (em.v); - const ems = JSON.stringify (em); - if (socket != null) - socket.send (ems); - if (debug) console.log ("Event", em); - } - }()); + function resizeHandler() { + const em = { + m: "event", + id: "window", + k: "resize", + v: getSize (), + }; + saveSize (em.v); + const ems = JSON.stringify (em); + send (ems); + if (debug) console.log ("Event", em); + } + + window.addEventListener("resize", resizeThrottler, false); } function getNode (id) { @@ -244,8 +264,7 @@ function msgListen (m) { }; } const ems = JSON.stringify (em); - if (socket != null) - socket.send (ems); + send (ems); if (debug) console.log ("Event", em); if (em.k === "submit") e.preventDefault (); @@ -294,3 +313,167 @@ function fixupValue (v) { } return v; } + +// == WASM Support == + +window["__oouiReceiveMessages"] = function (sessionId, messages) +{ + if (debug) console.log ("WebAssembly Receive", messages); + if (wasmSession != null) { + messages.forEach (function (m) { + // console.log ('Raw value from server', m.v); + m.v = fixupValue (m.v); + processMessage (m); + }); + } +}; + +var Module = { + onRuntimeInitialized: function () { + if (debug) console.log ("Done with WASM module instantiation."); + + Module.FS_createPath ("/", "managed", true, true); + + var pending = 0; + this.assemblies.forEach (function(asm_name) { + if (debug) console.log ("Loading", asm_name); + ++pending; + fetch ("managed/" + asm_name, { credentials: 'same-origin' }).then (function (response) { + if (!response.ok) + throw "failed to load Assembly '" + asm_name + "'"; + return response['arrayBuffer'](); + }).then (function (blob) { + var asm = new Uint8Array (blob); + Module.FS_createDataFile ("managed/" + asm_name, null, asm, true, true, true); + --pending; + if (pending == 0) + Module.bclLoadingDone (); + }); + }); + }, + + bclLoadingDone: function () { + if (debug) console.log ("Done loading the BCL."); + MonoRuntime.init (); + } +}; + +var MonoRuntime = { + init: function () { + this.load_runtime = Module.cwrap ('mono_wasm_load_runtime', null, ['string', 'number']); + this.assembly_load = Module.cwrap ('mono_wasm_assembly_load', 'number', ['string']); + this.find_class = Module.cwrap ('mono_wasm_assembly_find_class', 'number', ['number', 'string', 'string']); + this.find_method = Module.cwrap ('mono_wasm_assembly_find_method', 'number', ['number', 'string', 'number']); + this.invoke_method = Module.cwrap ('mono_wasm_invoke_method', 'number', ['number', 'number', 'number']); + this.mono_string_get_utf8 = Module.cwrap ('mono_wasm_string_get_utf8', 'number', ['number']); + this.mono_string = Module.cwrap ('mono_wasm_string_from_js', 'number', ['string']); + + this.load_runtime ("managed", 1); + + if (debug) console.log ("Done initializing the runtime."); + + WebAssemblyApp.init (); + }, + + conv_string: function (mono_obj) { + if (mono_obj == 0) + return null; + var raw = this.mono_string_get_utf8 (mono_obj); + var res = Module.UTF8ToString (raw); + Module._free (raw); + + return res; + }, + + call_method: function (method, this_arg, args) { + var args_mem = Module._malloc (args.length * 4); + var eh_throw = Module._malloc (4); + for (var i = 0; i < args.length; ++i) + Module.setValue (args_mem + i * 4, args [i], "i32"); + Module.setValue (eh_throw, 0, "i32"); + + var res = this.invoke_method (method, this_arg, args_mem, eh_throw); + + var eh_res = Module.getValue (eh_throw, "i32"); + + Module._free (args_mem); + Module._free (eh_throw); + + if (eh_res != 0) { + var msg = this.conv_string (res); + throw new Error (msg); + } + + return res; + }, +}; + +var WebAssemblyApp = { + init: function () { + this.loading = document.getElementById ("loading"); + + this.findMethods (); + + this.runApp ("1", "2"); + + this.loading.hidden = true; + }, + + runApp: function (a, b) { + try { + var sessionId = "main"; + if (!!this.ooui_DisableServer_method) { + MonoRuntime.call_method (this.ooui_DisableServer_method, null, []); + } + MonoRuntime.call_method (this.main_method, null, [MonoRuntime.mono_string (a), MonoRuntime.mono_string (b)]); + wasmSession = sessionId; + if (!!this.ooui_StartWebAssemblySession_method) { + var initialSize = getSize (); + MonoRuntime.call_method (this.ooui_StartWebAssemblySession_method, null, [MonoRuntime.mono_string (sessionId), MonoRuntime.mono_string (""), MonoRuntime.mono_string (Math.round(initialSize.width) + " " + Math.round(initialSize.height))]); + } + } catch (e) { + console.error(e); + } + }, + + receiveMessagesJson: function (sessionId, json) { + if (!!this.ooui_ReceiveWebAssemblySessionMessageJson_method) { + MonoRuntime.call_method (this.ooui_ReceiveWebAssemblySessionMessageJson_method, null, [MonoRuntime.mono_string (sessionId), MonoRuntime.mono_string (json)]); + } + }, + + findMethods: function () { + this.main_module = MonoRuntime.assembly_load (Module.entryPoint.a); + if (!this.main_module) + throw "Could not find Main Module " + Module.entryPoint.a + ".dll"; + + this.main_class = MonoRuntime.find_class (this.main_module, Module.entryPoint.n, Module.entryPoint.t) + if (!this.main_class) + throw "Could not find Program class in main module"; + + this.main_method = MonoRuntime.find_method (this.main_class, Module.entryPoint.m, -1) + if (!this.main_method) + throw "Could not find Main method"; + + this.ooui_module = MonoRuntime.assembly_load ("Ooui"); + if (!!this.ooui_module) { + + this.ooui_class = MonoRuntime.find_class (this.ooui_module, "Ooui", "UI"); + if (!this.ooui_class) + throw "Could not find UI class in Ooui module"; + + this.ooui_DisableServer_method = MonoRuntime.find_method (this.ooui_class, "DisableServer", -1); + if (!this.ooui_DisableServer_method) + throw "Could not find DisableServer method"; + + this.ooui_StartWebAssemblySession_method = MonoRuntime.find_method (this.ooui_class, "StartWebAssemblySession", -1); + if (!this.ooui_StartWebAssemblySession_method) + throw "Could not find StartWebAssemblySession method"; + + this.ooui_ReceiveWebAssemblySessionMessageJson_method = MonoRuntime.find_method (this.ooui_class, "ReceiveWebAssemblySessionMessageJson", -1); + if (!this.ooui_ReceiveWebAssemblySessionMessageJson_method) + throw "Could not find ReceiveWebAssemblySessionMessageJson method"; + } + }, +}; + diff --git a/Ooui/Element.cs b/Ooui/Element.cs index ea203b1..5e21649 100644 --- a/Ooui/Element.cs +++ b/Ooui/Element.cs @@ -20,7 +20,6 @@ namespace Ooui set => SetAttributeProperty ("title", value); } - bool hidden = false; public bool IsHidden { get => GetBooleanAttribute ("hidden"); set => SetBooleanAttributeProperty ("hidden", value); @@ -232,6 +231,8 @@ namespace Ooui protected virtual bool HtmlNeedsFullEndElement => false; +#if !NO_XML + public override void WriteOuterHtml (System.Xml.XmlWriter w) { w.WriteStartElement (TagName); @@ -262,5 +263,7 @@ namespace Ooui c.WriteOuterHtml (w); } } + +#endif } } diff --git a/Ooui/EventTarget.cs b/Ooui/EventTarget.cs index a37b69a..f13a255 100644 --- a/Ooui/EventTarget.cs +++ b/Ooui/EventTarget.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; namespace Ooui { @@ -21,7 +22,7 @@ namespace Ooui public IReadOnlyList StateMessages { get { lock (stateMessages) { - return new List (stateMessages).AsReadOnly (); + return new ReadOnlyList (stateMessages); } } } @@ -242,7 +243,7 @@ namespace Ooui public override bool CanConvert (Type objectType) { - return typeof (EventTarget).IsAssignableFrom (objectType); + return typeof (EventTarget).GetTypeInfo ().IsAssignableFrom (objectType.GetTypeInfo ()); } } diff --git a/Ooui/Form.cs b/Ooui/Form.cs index 897a319..89aca0e 100644 --- a/Ooui/Form.cs +++ b/Ooui/Form.cs @@ -4,7 +4,6 @@ namespace Ooui { public class Form : Element { - string action = ""; public string Action { get => GetStringAttribute ("action", ""); set => SetAttributeProperty ("action", value ?? ""); diff --git a/Ooui/FormControl.cs b/Ooui/FormControl.cs index 461b301..16f3901 100644 --- a/Ooui/FormControl.cs +++ b/Ooui/FormControl.cs @@ -9,7 +9,6 @@ namespace Ooui set => SetAttributeProperty ("name", value); } - bool isDisabled = false; public bool IsDisabled { get => GetBooleanAttribute ("disabled"); set => SetBooleanAttributeProperty ("disabled", value); diff --git a/Ooui/JsonConvert.cs b/Ooui/JsonConvert.cs new file mode 100644 index 0000000..fc8e86a --- /dev/null +++ b/Ooui/JsonConvert.cs @@ -0,0 +1,161 @@ +using System; + +namespace Ooui +{ + class JsonConvert + { + static void WriteJsonString (System.IO.TextWriter w, string s) + { + w.Write ('\"'); + for (var i = 0; i < s.Length; i++) { + var c = s[i]; + if (c == '\"') { + w.Write ("\\\""); + } + else if (c == '\r') { + w.Write ("\\r"); + } + else if (c == '\n') { + w.Write ("\\n"); + } + else if (c == '\t') { + w.Write ("\\t"); + } + else if (c == '\b') { + w.Write ("\\b"); + } + else if (c == '\\') { + w.Write ("\\"); + } + else { + w.Write (c); + } + } + w.Write ('\"'); + } + + public static void WriteJsonValue (System.IO.TextWriter w, object value) + { + if (value == null) { + w.Write ("null"); + return; + } + var s = value as string; + if (s != null) { + WriteJsonString (w, s); + return; + } + + var a = value as Array; + if (a != null) { + w.Write ('['); + var head = ""; + foreach (var o in a) { + w.Write (head); + WriteJsonValue (w, o); + head = ","; + } + w.Write (']'); + return; + } + + var e = value as EventTarget; + if (e != null) { + w.Write ('\"'); + w.Write (e.Id); + w.Write ('\"'); + return; + } + + if (value is Color) { + WriteJsonString (w, ((Color)value).ToString ()); + return; + } + + var icult = System.Globalization.CultureInfo.InvariantCulture; + + if (value is double) { + w.Write (((double)value).ToString (icult)); + } + + if (value is int) { + w.Write (((int)value).ToString (icult)); + } + + if (value is float) { + w.Write (((float)value).ToString (icult)); + } + + w.Write (Newtonsoft.Json.JsonConvert.SerializeObject (value)); + } + + public static string SerializeObject (object value) + { + using (var sw = new System.IO.StringWriter ()) { + WriteJsonValue (sw, value); + return sw.ToString (); + } + } + + static object ReadJsonArray (string j, ref int i) + { + throw new NotImplementedException (); + } + + static object ReadJsonObject (string json, ref int i) + { + var e = json.Length; + while (i < e) { + while (i < e && char.IsWhiteSpace (json[i])) + i++; + if (i >= e) + throw new Exception ("JSON Unexpected end"); + var n = e - i; + i++; + } + throw new NotImplementedException (); + } + + static object ReadJsonString (string j, ref int i) + { + throw new NotImplementedException (); + } + + static object ReadJsonNumber (string j, ref int i) + { + throw new NotImplementedException (); + } + + static object ReadJsonValue (string json, ref int i) + { + var e = json.Length; + while (i < e && char.IsWhiteSpace (json[i])) + i++; + if (i >= e) + throw new Exception ("JSON Unexpected end"); + var n = e - i; + switch (json[i]) { + case '[': + return ReadJsonArray (json, ref i); + case '{': + return ReadJsonObject (json, ref i); + case '\"': + return ReadJsonString (json, ref i); + case 'f': + i += 5; + return false; + case 't': + i += 4; + return true; + default: + return ReadJsonNumber (json, ref i); + } + } + + public static object ReadJsonValue (string json, int startIndex) + { + var i = startIndex; + return ReadJsonValue (json, ref i); + } + } +} diff --git a/Ooui/Label.cs b/Ooui/Label.cs index ca27a15..8bb063b 100644 --- a/Ooui/Label.cs +++ b/Ooui/Label.cs @@ -13,5 +13,12 @@ namespace Ooui : base ("label") { } + + public Label (string text) + : this () + { + Text = text; + } + } } diff --git a/Ooui/Message.cs b/Ooui/Message.cs index 1e78fc9..0ba8798 100644 --- a/Ooui/Message.cs +++ b/Ooui/Message.cs @@ -35,6 +35,105 @@ namespace Ooui Key = eventType, Value = value, }; + + public void WriteJson (System.IO.TextWriter w) + { + w.Write ('{'); + switch (MessageType) { + case MessageType.Call: w.Write ("\"m\":\"call\",\"id\":\""); break; + case MessageType.Create: w.Write ("\"m\":\"create\",\"id\":\""); break; + case MessageType.Event: w.Write ("\"m\":\"event\",\"id\":\""); break; + case MessageType.Listen: w.Write ("\"m\":\"listen\",\"id\":\""); break; + case MessageType.Nop: w.Write ("\"m\":\"nop\",\"id\":\""); break; + case MessageType.RemoveAttribute: w.Write ("\"m\":\"remAttr\",\"id\":\""); break; + case MessageType.Set: w.Write ("\"m\":\"set\",\"id\":\""); break; + case MessageType.SetAttribute: w.Write ("\"m\":\"setAttr\",\"id\":\""); break; + } + w.Write (TargetId); + w.Write ("\",\"k\":\""); + w.Write (Key); + if (Value != null) { + w.Write ("\",\"v\":"); + JsonConvert.WriteJsonValue (w, Value); + w.Write ('}'); + } + else { + w.Write ("\"}"); + } + } + + public string ToJson () + { + using (var sw = new System.IO.StringWriter ()) { + WriteJson (sw); + return sw.ToString (); + } + } + + public static Message FromJson (string json) + { + var m = new Message (); + + var i = 0; + var e = json.Length; + + while (i < e) { + while (i < e && (json[i]==',' || json[i]=='{' || char.IsWhiteSpace (json[i]))) + i++; + if (i >= e) + throw new Exception ("JSON Unexpected end"); + var n = e - i; + if (json[i] == '}') + break; + if (n > 4 && json[i] == '\"' && json[i+2] == '\"' && json[i+3] == ':') { + switch (json[i + 1]) { + case 'm': + if (json[i + 4] == '\"' && json[i + 5] == 'e') { + m.MessageType = MessageType.Event; + } + i += 5; + while (i < e && json[i] != '\"') i++; + i++; + break; + case 'k': { + i += 5; + var se = i; + while (se < e && json[se] != '\"') + se++; + m.Key = json.Substring (i, se - i); + i = se + 1; + } + break; + case 'v': + m.Value = JsonConvert.ReadJsonValue (json, i + 4); + break; + } + } + else if (n > 5 && json[i] == '\"' && json[i + 3] == '\"' && json[i + 4] == ':' && json[i+5] == '\"') { + switch (json[i + 1]) { + case 'i': { + i += 6; + var se = i; + while (se < e && json[se] != '\"') + se++; + m.TargetId = json.Substring (i, se - i); + i = se + 1; + } + break; + } + } + else { + throw new Exception ("JSON Expected property"); + } + } + + return m; + } + + public override string ToString () + { + return ToJson (); + } } [JsonConverter (typeof (StringEnumConverter))] diff --git a/Ooui/Node.cs b/Ooui/Node.cs index 374c016..4d9ad8c 100644 --- a/Ooui/Node.cs +++ b/Ooui/Node.cs @@ -1,6 +1,6 @@ using System; +using System.Collections; using System.Collections.Generic; -using System.Linq; namespace Ooui { @@ -11,7 +11,7 @@ namespace Ooui public IReadOnlyList Children { get { lock (children) { - return new List (children).AsReadOnly (); + return new ReadOnlyList (children); } } } @@ -27,7 +27,13 @@ namespace Ooui } public virtual string Text { - get { return String.Join ("", from c in Children select c.Text); } + get { + var sb = new System.Text.StringBuilder (); + foreach (var c in Children) { + sb.Append (c.Text); + } + return sb.ToString (); + } set { ReplaceAll (new TextNode (value ?? "")); } @@ -35,7 +41,7 @@ namespace Ooui protected Node (string tagName) : base (tagName) - { + { } public override EventTarget GetElementById (string id) @@ -84,7 +90,7 @@ namespace Ooui return null; lock (children) { if (!children.Remove (child)) { - throw new ArgumentException ("Child not contained in this element", nameof(child)); + throw new ArgumentException ("Child not contained in this element", nameof (child)); } } child.MessageSent -= HandleChildMessageSent; @@ -123,6 +129,7 @@ namespace Ooui protected override bool SaveStateMessageIfNeeded (Message message) { if (message.TargetId == Id) { + var handled = false; switch (message.MessageType) { case MessageType.Call when message.Key == "insertBefore": AddStateMessage (message); @@ -130,8 +137,8 @@ namespace Ooui case MessageType.Call when message.Key == "removeChild" && message.Value is Array ma && ma.Length == 1: UpdateStateMessages (state => { var mchild = ma.GetValue (0); - Node nextChild = null; - for (var i = 0; i < state.Count; ) { + Node nextChild = null; + for (var i = 0; i < state.Count;) { var x = state[i]; if (x.Key == "insertBefore" && x.Value is Array xa && xa.Length == 2 && ReferenceEquals (xa.GetValue (0), mchild)) { // Remove any inserts for this node @@ -149,9 +156,9 @@ namespace Ooui } }); break; - default: - base.SaveStateMessageIfNeeded (message); - break; + } + if (!handled) { + base.SaveStateMessageIfNeeded (message); } return true; } @@ -181,6 +188,8 @@ namespace Ooui return false; } +#if !NO_XML + public virtual string OuterHtml { get { using (var stream = new System.IO.MemoryStream ()) { @@ -199,5 +208,31 @@ namespace Ooui } public abstract void WriteOuterHtml (System.Xml.XmlWriter w); + +#endif + } + + class ReadOnlyList : IReadOnlyList + { + readonly List list; + + public ReadOnlyList (List items) + { + list = new List (items); + } + + T IReadOnlyList.this[int index] => list[index]; + + int IReadOnlyCollection.Count => list.Count; + + IEnumerator IEnumerable.GetEnumerator () + { + return ((IEnumerable)list).GetEnumerator (); + } + + IEnumerator IEnumerable.GetEnumerator () + { + return ((IEnumerable)list).GetEnumerator (); + } } } diff --git a/Ooui/Ooui.csproj b/Ooui/Ooui.csproj index 98357fe..36171bb 100644 --- a/Ooui/Ooui.csproj +++ b/Ooui/Ooui.csproj @@ -8,12 +8,15 @@ https://github.com/praeclarum/Ooui https://github.com/praeclarum/Ooui/blob/master/LICENSE https://github.com/praeclarum/Ooui.git - netstandard2.0 + netstandard2.0 + true
+ + diff --git a/Ooui/Platform.cs b/Ooui/Platform.cs index 287bfa4..cab8313 100644 --- a/Ooui/Platform.cs +++ b/Ooui/Platform.cs @@ -1,137 +1,137 @@ using System; using System.Diagnostics; -using System.Linq; using System.Reflection; +using System.Linq; namespace Ooui { static class Platform { - static readonly Assembly iosAssembly; - static readonly Type iosUIViewControllerType; - static readonly Type iosUIApplicationType; - static readonly Type iosUIWebViewType; - static readonly Type iosNSUrl; - static readonly Type iosNSUrlRequest; + static readonly Assembly iosAssembly; + static readonly Type iosUIViewControllerType; + static readonly Type iosUIApplicationType; + static readonly Type iosUIWebViewType; + static readonly Type iosNSUrl; + static readonly Type iosNSUrlRequest; - static readonly Assembly androidAssembly; - static readonly Type androidActivityType; - static readonly Type androidWebViewType; + static readonly Assembly androidAssembly; + static readonly Type androidActivityType; + static readonly Type androidWebViewType; - static Platform () - { - var asms = AppDomain.CurrentDomain.GetAssemblies ().ToDictionary ( - x => x.GetName ().Name); + static Platform () + { + var asms = AppDomain.CurrentDomain.GetAssemblies ().ToDictionary ( + x => x.GetName ().Name); - asms.TryGetValue ("Xamarin.iOS", out iosAssembly); - if (iosAssembly != null) { - iosUIViewControllerType = iosAssembly.GetType ("UIKit.UIViewController"); - iosUIApplicationType = iosAssembly.GetType ("UIKit.UIApplication"); - iosUIWebViewType = iosAssembly.GetType ("UIKit.UIWebView"); - iosNSUrl = iosAssembly.GetType ("Foundation.NSUrl"); - iosNSUrlRequest = iosAssembly.GetType ("Foundation.NSUrlRequest"); - } + asms.TryGetValue ("Xamarin.iOS", out iosAssembly); + if (iosAssembly != null) { + iosUIViewControllerType = iosAssembly.GetType ("UIKit.UIViewController"); + iosUIApplicationType = iosAssembly.GetType ("UIKit.UIApplication"); + iosUIWebViewType = iosAssembly.GetType ("UIKit.UIWebView"); + iosNSUrl = iosAssembly.GetType ("Foundation.NSUrl"); + iosNSUrlRequest = iosAssembly.GetType ("Foundation.NSUrlRequest"); + } - asms.TryGetValue ("Mono.Android", out androidAssembly); - if (androidAssembly != null) { - androidActivityType = androidAssembly.GetType ("Android.App.Activity"); - androidWebViewType = androidAssembly.GetType ("Android.Webkit.WebView"); - } - } + asms.TryGetValue ("Mono.Android", out androidAssembly); + if (androidAssembly != null) { + androidActivityType = androidAssembly.GetType ("Android.App.Activity"); + androidWebViewType = androidAssembly.GetType ("Android.Webkit.WebView"); + } + } - public static void OpenBrowser (string url, object presenter) - { - if (iosAssembly != null) { - OpenBrowserOniOS (url, presenter); - } - else if (androidAssembly != null) { - OpenBrowserOnAndroid (url, presenter); - } - else { - StartBrowserProcess (url); - } - } + public static void OpenBrowser (string url, object presenter) + { + if (iosAssembly != null) { + OpenBrowserOniOS (url, presenter); + } + else if (androidAssembly != null) { + OpenBrowserOnAndroid (url, presenter); + } + else { + StartBrowserProcess (url); + } + } - static void OpenBrowserOnAndroid (string url, object presenter) - { - var presenterType = GetObjectType (presenter); + static void OpenBrowserOnAndroid (string url, object presenter) + { + var presenterType = GetObjectType (presenter); - object presenterWebView = null; - if (presenter != null && androidWebViewType.IsAssignableFrom (presenterType)) { - presenterWebView = presenter; - } + object presenterWebView = null; + if (presenter != null && androidWebViewType.IsAssignableFrom (presenterType)) { + presenterWebView = presenter; + } - if (presenterWebView == null) { - throw new ArgumentException ("Presenter must be a WebView", nameof(presenter)); - } + if (presenterWebView == null) { + throw new ArgumentException ("Presenter must be a WebView", nameof (presenter)); + } - var m = androidWebViewType.GetMethod ("LoadUrl", BindingFlags.Public|BindingFlags.Instance, null, CallingConventions.Any, new Type[] { typeof(string) }, null); - m.Invoke (presenterWebView, new object[] { url }); - } + var m = androidWebViewType.GetMethod ("LoadUrl", BindingFlags.Public | BindingFlags.Instance, null, CallingConventions.Any, new Type[] { typeof (string) }, null); + m.Invoke (presenterWebView, new object[] { url }); + } - static void OpenBrowserOniOS (string url, object presenter) - { - var presenterType = GetObjectType (presenter); + static void OpenBrowserOniOS (string url, object presenter) + { + var presenterType = GetObjectType (presenter); - // - // Find a presenter view controller - // 1. Try the given presenter - // 2. Find the key window vc - // 3. Create a window? - // - object presenterViewController = null; - if (presenter != null && iosUIViewControllerType.IsAssignableFrom (presenterType)) { - presenterViewController = presenter; - } + // + // Find a presenter view controller + // 1. Try the given presenter + // 2. Find the key window vc + // 3. Create a window? + // + object presenterViewController = null; + if (presenter != null && iosUIViewControllerType.IsAssignableFrom (presenterType)) { + presenterViewController = presenter; + } - if (presenterViewController == null) { - var app = iosUIApplicationType.GetProperty ("SharedApplication").GetValue (null, null); - var window = iosUIApplicationType.GetProperty ("KeyWindow").GetValue (app, null); - if (window != null) { - var rvc = window.GetType ().GetProperty ("RootViewController").GetValue (window, null); - if (rvc != null) { - var pvc = rvc.GetType ().GetProperty ("PresentedViewController").GetValue (rvc, null); - presenterViewController = pvc ?? rvc; - } - } - } + if (presenterViewController == null) { + var app = iosUIApplicationType.GetProperty ("SharedApplication").GetValue (null, null); + var window = iosUIApplicationType.GetProperty ("KeyWindow").GetValue (app, null); + if (window != null) { + var rvc = window.GetType ().GetProperty ("RootViewController").GetValue (window, null); + if (rvc != null) { + var pvc = rvc.GetType ().GetProperty ("PresentedViewController").GetValue (rvc, null); + presenterViewController = pvc ?? rvc; + } + } + } - if (presenterViewController == null) { - throw new InvalidOperationException ("Cannot find a view controller from which to present"); - } + if (presenterViewController == null) { + throw new InvalidOperationException ("Cannot find a view controller from which to present"); + } - // - // Create the browser - // - var browserVC = Activator.CreateInstance (iosUIViewControllerType); - var browserV = Activator.CreateInstance (iosUIWebViewType); + // + // Create the browser + // + var browserVC = Activator.CreateInstance (iosUIViewControllerType); + var browserV = Activator.CreateInstance (iosUIWebViewType); - var nsUrl = iosNSUrl.GetMethod ("FromString").Invoke (null, new object[] { url }); - var nsUrlRequest = iosNSUrlRequest.GetMethod ("FromUrl").Invoke (null, new object[] { nsUrl }); - iosUIWebViewType.GetMethod ("LoadRequest").Invoke (browserV, new object[] { nsUrlRequest }); - iosUIViewControllerType.GetProperty ("View").SetValue (browserVC, browserV, null); + var nsUrl = iosNSUrl.GetMethod ("FromString").Invoke (null, new object[] { url }); + var nsUrlRequest = iosNSUrlRequest.GetMethod ("FromUrl").Invoke (null, new object[] { nsUrl }); + iosUIWebViewType.GetMethod ("LoadRequest").Invoke (browserV, new object[] { nsUrlRequest }); + iosUIViewControllerType.GetProperty ("View").SetValue (browserVC, browserV, null); - var m = iosUIViewControllerType.GetMethod ("PresentViewController"); + var m = iosUIViewControllerType.GetMethod ("PresentViewController"); - // Console.WriteLine (presenterViewController); - // Console.WriteLine (browserVC); - m.Invoke (presenterViewController, new object[] { browserVC, false, null }); - } + // Console.WriteLine (presenterViewController); + // Console.WriteLine (browserVC); + m.Invoke (presenterViewController, new object[] { browserVC, false, null }); + } - static Type GetObjectType (object o) - { - var t = typeof (object); - if (o is IReflectableType rt) { - t = rt.GetTypeInfo ().AsType (); - } - else if (o != null) { - t = o.GetType (); - } - return t; - } + static Type GetObjectType (object o) + { + var t = typeof (object); + if (o is IReflectableType rt) { + t = rt.GetTypeInfo ().AsType (); + } + else if (o != null) { + t = o.GetType (); + } + return t; + } - static Process StartBrowserProcess (string url) - { + static void StartBrowserProcess (string url) + { // var vs = Environment.GetEnvironmentVariables (); // foreach (System.Collections.DictionaryEntry kv in vs) { // System.Console.WriteLine($"K={kv.Key}, V={kv.Value}"); @@ -139,9 +139,12 @@ namespace Ooui // Console.WriteLine ($"Process.Start {cmd} {args}"); - return Environment.OSVersion.Platform == PlatformID.Unix - ? Process.Start ("open", url) - : Process.Start (new ProcessStartInfo (url) { UseShellExecute = true }); + if (Environment.OSVersion.Platform == PlatformID.Unix) { + Process.Start ("open", url); + } + else { + Process.Start (new ProcessStartInfo (url) { UseShellExecute = true }); + } } } } diff --git a/Ooui/PreserveAttribute.cs b/Ooui/PreserveAttribute.cs new file mode 100644 index 0000000..a933881 --- /dev/null +++ b/Ooui/PreserveAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace Ooui +{ + class PreserveAttribute : Attribute + { + } +} diff --git a/Ooui/Session.cs b/Ooui/Session.cs new file mode 100644 index 0000000..07a1b0b --- /dev/null +++ b/Ooui/Session.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; + +namespace Ooui +{ + public abstract class Session + { + protected readonly Element element; + protected readonly double initialWidth; + protected readonly double initialHeight; + + protected readonly HashSet createdIds; + + protected readonly List queuedMessages = new List (); + + public Session (Element element, double initialWidth, double initialHeight) + { + this.element = element; + this.initialWidth = initialWidth; + this.initialHeight = initialHeight; + + // + // Keep a list of all the elements for which we've transmitted the initial state + // + createdIds = new HashSet { + "window", + "document", + "document.body", + }; + } + + void QueueStateMessagesLocked (EventTarget target) + { + if (target == null) return; + var created = false; + foreach (var m in target.StateMessages) { + if (m.MessageType == MessageType.Create) { + createdIds.Add (m.TargetId); + created = true; + } + if (created) { + QueueMessageLocked (m); + } + } + } + + protected void QueueMessageLocked (Message message) + { + // + // Make sure all the referenced objects have been created + // + if (!createdIds.Contains (message.TargetId)) { + QueueStateMessagesLocked (element.GetElementById (message.TargetId)); + } + if (message.Value is EventTarget ve) { + if (!createdIds.Contains (ve.Id)) { + QueueStateMessagesLocked (ve); + } + } + else if (message.Value is Array a) { + for (var i = 0; i < a.Length; i++) { + // Console.WriteLine ($"A{i} = {a.GetValue(i)}"); + if (a.GetValue (i) is EventTarget e && !createdIds.Contains (e.Id)) { + QueueStateMessagesLocked (e); + } + } + } + + // + // Add it to the queue + // + //Console.WriteLine ($"QM {message.MessageType} {message.TargetId} {message.Key} {message.Value}"); + queuedMessages.Add (message); + } + + protected virtual void QueueMessage (Message message) + { + lock (queuedMessages) { + QueueMessageLocked (message); + } + } + + protected void Error (string message, Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine ("{0}: {1}", message, ex); + Console.ResetColor (); + } + + protected void Info (string message) + { + Console.WriteLine (message); + } + } +} diff --git a/Ooui/Style.cs b/Ooui/Style.cs index 71f3177..13e11b5 100644 --- a/Ooui/Style.cs +++ b/Ooui/Style.cs @@ -412,8 +412,17 @@ namespace Ooui return null; if (val is string s) return s; + + if (val is int i) + return i + units; + if (val is double d) + return d.ToString (System.Globalization.CultureInfo.InvariantCulture) + units; + if (val is float f) + return f.ToString (System.Globalization.CultureInfo.InvariantCulture) + units; + if (val is IConvertible c) return c.ToString (System.Globalization.CultureInfo.InvariantCulture) + units; + return val.ToString (); } @@ -431,6 +440,13 @@ namespace Ooui return num; } + if (v is int i) + return i; + if (v is double d) + return d; + if (v is float f) + return f; + if (v is IConvertible c) return c.ToDouble (System.Globalization.CultureInfo.InvariantCulture); diff --git a/Ooui/TextArea.cs b/Ooui/TextArea.cs index f3f2d5f..8f0a075 100644 --- a/Ooui/TextArea.cs +++ b/Ooui/TextArea.cs @@ -25,7 +25,6 @@ namespace Ooui set => SetAttributeProperty ("rows", value); } - int cols = 20; public int Columns { get => GetAttribute ("cols", 20); set => SetAttributeProperty ("cols", value); @@ -55,9 +54,13 @@ namespace Ooui return base.TriggerEventFromMessage (message); } +#if !NO_XML + public override void WriteInnerHtml (System.Xml.XmlWriter w) { w.WriteString (val ?? ""); } + +#endif } } diff --git a/Ooui/TextNode.cs b/Ooui/TextNode.cs index 8fccf8c..b94625c 100644 --- a/Ooui/TextNode.cs +++ b/Ooui/TextNode.cs @@ -21,9 +21,13 @@ namespace Ooui Text = text; } +#if !NO_XML + public override void WriteOuterHtml (System.Xml.XmlWriter w) { w.WriteString (text); } + +#endif } } diff --git a/Ooui/UI.cs b/Ooui/UI.cs index 540f74c..a66ff59 100644 --- a/Ooui/UI.cs +++ b/Ooui/UI.cs @@ -6,52 +6,30 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Net; -using System.Net.WebSockets; +using System.Runtime.InteropServices; namespace Ooui { public static class UI { - static readonly ManualResetEvent started = new ManualResetEvent (false); + public const int MaxFps = 30; - [ThreadStatic] - static System.Security.Cryptography.SHA256 sha256; + static readonly ManualResetEvent started = new ManualResetEvent (false); static CancellationTokenSource serverCts; static readonly Dictionary publishedPaths = new Dictionary (); - static readonly Dictionary styles = - new Dictionary (); - static readonly StyleSelectors rules = new StyleSelectors (); - - public static StyleSelectors Styles => rules; - static readonly byte[] clientJsBytes; static readonly string clientJsEtag; public static byte[] ClientJsBytes => clientJsBytes; public static string ClientJsEtag => clientJsEtag; - public static string Template { get; set; } = $@" - - - @Title - - - - - - -
-@InitialHtml -
- - - - -"; + public static string HeadHtml { get; set; } = @""; + public static string BodyHeaderHtml { get; set; } = @""; + public static string BodyFooterHtml { get; set; } = @""; static string host = "*"; public static string Host { @@ -87,6 +65,12 @@ namespace Ooui } } + [Preserve] + static void DisableServer () + { + ServerEnabled = false; + } + static UI () { var asm = typeof(UI).Assembly; @@ -101,27 +85,12 @@ namespace Ooui clientJsBytes = Encoding.UTF8.GetBytes (r.ReadToEnd ()); } } - clientJsEtag = "\"" + Hash (clientJsBytes) + "\""; - } - - public static string Hash (byte[] bytes) - { - var sha = sha256; - if (sha == null) { - sha = System.Security.Cryptography.SHA256.Create (); - sha256 = sha; - } - var data = sha.ComputeHash (bytes); - StringBuilder sBuilder = new StringBuilder (); - for (int i = 0; i < data.Length; i++) { - sBuilder.Append (data[i].ToString ("x2")); - } - return sBuilder.ToString (); + clientJsEtag = "\"" + Utilities.Hash (clientJsBytes) + "\""; } static void Publish (string path, RequestHandler handler) { - Console.WriteLine ($"PUBLISH {path} {handler}"); + //Console.WriteLine ($"PUBLISH {path} {handler}"); lock (publishedPaths) publishedPaths[path] = handler; Start (); } @@ -148,13 +117,13 @@ namespace Ooui if (contentType == null) { contentType = GuessContentType (path, filePath); } - var etag = "\"" + Hash (data) + "\""; + var etag = "\"" + Utilities.Hash (data) + "\""; Publish (path, new DataHandler (data, etag, contentType)); } public static void PublishFile (string path, byte[] data, string contentType) { - var etag = "\"" + Hash (data) + "\""; + var etag = "\"" + Utilities.Hash (data) + "\""; Publish (path, new DataHandler (data, etag, contentType)); } @@ -199,7 +168,7 @@ namespace Ooui public static void PublishJson (string path, object value) { var data = JsonHandler.GetData (value); - var etag = "\"" + Hash (data) + "\""; + var etag = "\"" + Utilities.Hash (data) + "\""; Publish (path, new DataHandler (data, etag, JsonHandler.ContentType)); } @@ -392,7 +361,45 @@ namespace Ooui public static string RenderTemplate (string webSocketPath, string title = "", string initialHtml = "") { - return Template.Replace ("@WebSocketPath", webSocketPath).Replace ("@Styles", rules.ToString ()).Replace ("@Title", title).Replace ("@InitialHtml", initialHtml); + using (var w = new System.IO.StringWriter ()) { + RenderTemplate (w, webSocketPath, title, initialHtml); + return w.ToString (); + } + } + + static string EscapeHtml (string text) + { + return text.Replace ("&", "&").Replace ("<", "<"); + } + + public static void RenderTemplate (TextWriter writer, string webSocketPath, string title, string initialHtml) + { + writer.Write (@" + + + "); + writer.Write (EscapeHtml (title)); + writer.Write (@" + + "); + writer.WriteLine (HeadHtml); + writer.WriteLine (@" + +"); + writer.WriteLine (BodyHeaderHtml); + writer.WriteLine (@"
"); + writer.WriteLine (initialHtml); + writer.Write (@"
+ + +"); + writer.WriteLine (BodyFooterHtml); + writer.WriteLine (@" +"); } class DataHandler : RequestHandler @@ -450,8 +457,9 @@ namespace Ooui public static byte[] GetData (object obj) { - var r = Newtonsoft.Json.JsonConvert.SerializeObject (obj); - return System.Text.Encoding.UTF8.GetBytes (r); + var r = Ooui.JsonConvert.SerializeObject (obj); + var e = new UTF8Encoding (false); + return e.GetBytes (r); } public override void Respond (HttpListenerContext listenerContext, CancellationToken token) @@ -521,8 +529,8 @@ namespace Ooui // // Connect the web socket // - WebSocketContext webSocketContext = null; - WebSocket webSocket = null; + System.Net.WebSockets.WebSocketContext webSocketContext = null; + System.Net.WebSockets.WebSocket webSocket = null; try { webSocketContext = await listenerContext.AcceptWebSocketAsync (subProtocol: "ooui").ConfigureAwait (false); webSocket = webSocketContext.WebSocket; @@ -560,10 +568,10 @@ namespace Ooui // Create a new session and let it handle everything from here // try { - var session = new Session (webSocket, element, w, h, serverToken); + var session = new WebSocketSession (webSocket, element, w, h, serverToken); await session.RunAsync ().ConfigureAwait (false); } - catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { + catch (System.Net.WebSockets.WebSocketException ex) when (ex.WebSocketErrorCode == System.Net.WebSockets.WebSocketError.ConnectionClosedPrematurely) { // The remote party closed the WebSocket connection without completing the close handshake. } catch (Exception ex) { @@ -581,216 +589,51 @@ namespace Ooui Console.ResetColor (); } - public class Session + static readonly Dictionary globalElementSessions = new Dictionary (); + + [Preserve] + public static void StartWebAssemblySession (string sessionId, string elementPath, string initialSize) { - readonly WebSocket webSocket; - readonly Element element; - readonly Action handleElementMessageSent; - - readonly CancellationTokenSource sessionCts = new CancellationTokenSource (); - readonly CancellationTokenSource linkedCts; - readonly CancellationToken token; - - readonly HashSet createdIds; - readonly List queuedMessages = new List (); - - public const int MaxFps = 30; - - readonly System.Timers.Timer sendThrottle; - DateTime lastTransmitTime = DateTime.MinValue; - readonly TimeSpan throttleInterval = TimeSpan.FromSeconds (1.0 / MaxFps); - readonly double initialWidth; - readonly double initialHeight; - - public Session (WebSocket webSocket, Element element, double initialWidth, double initialHeight, CancellationToken serverToken) - { - this.webSocket = webSocket; - this.element = element; - this.initialWidth = initialWidth; - this.initialHeight = initialHeight; - - // - // Create a new session cancellation token that will trigger - // automatically if the server shutsdown or the session shutsdown. - // - linkedCts = CancellationTokenSource.CreateLinkedTokenSource (serverToken, sessionCts.Token); - token = linkedCts.Token; - - // - // Keep a list of all the elements for which we've transmitted the initial state - // - createdIds = new HashSet { - "window", - "document", - "document.body", - }; - - // - // Preparse handlers for the element - // - handleElementMessageSent = QueueMessage; - - // - // Create a timer to use as a throttle when sending messages - // - sendThrottle = new System.Timers.Timer (throttleInterval.TotalMilliseconds); - sendThrottle.Elapsed += (s, e) => { - // System.Console.WriteLine ("TICK SEND THROTTLE FOR {0}", element); - if ((e.SignalTime - lastTransmitTime) >= throttleInterval) { - sendThrottle.Enabled = false; - lastTransmitTime = e.SignalTime; - TransmitQueuedMessages (); - } - }; + Element element; + RequestHandler handler; + lock (publishedPaths) { + publishedPaths.TryGetValue (elementPath, out handler); + } + if (handler is ElementHandler eh) { + element = eh.GetElement (); + } + else { + element = new Div (); } - public async Task RunAsync () - { - // - // Start watching for changes in the element - // - element.MessageSent += handleElementMessageSent; - - try { - // - // Add it to the document body - // - if (element.WantsFullScreen) { - element.Style.Width = initialWidth; - element.Style.Height = initialHeight; - } - QueueMessage (Message.Call ("document.body", "appendChild", element)); - - // - // Start the Read Loop - // - var receiveBuffer = new byte[64*1024]; - - while (webSocket.State == WebSocketState.Open && !token.IsCancellationRequested) { - var receiveResult = await webSocket.ReceiveAsync(new ArraySegment(receiveBuffer), token).ConfigureAwait (false); - - if (receiveResult.MessageType == WebSocketMessageType.Close) { - await webSocket.CloseAsync (WebSocketCloseStatus.NormalClosure, "", token).ConfigureAwait (false); - sessionCts.Cancel (); - } - else if (receiveResult.MessageType == WebSocketMessageType.Binary) { - await webSocket.CloseAsync (WebSocketCloseStatus.InvalidMessageType, "Cannot accept binary frame", token).ConfigureAwait (false); - sessionCts.Cancel (); - } - else { - var size = receiveResult.Count; - while (!receiveResult.EndOfMessage) { - if (size >= receiveBuffer.Length) { - await webSocket.CloseAsync (WebSocketCloseStatus.MessageTooBig, "Message too big", token).ConfigureAwait (false); - return; - } - receiveResult = await webSocket.ReceiveAsync (new ArraySegment(receiveBuffer, size, receiveBuffer.Length - size), token).ConfigureAwait (false); - size += receiveResult.Count; - } - var receivedString = Encoding.UTF8.GetString (receiveBuffer, 0, size); - - try { - // Console.WriteLine ("RECEIVED: {0}", receivedString); - var message = Newtonsoft.Json.JsonConvert.DeserializeObject (receivedString); - element.Receive (message); - } - catch (Exception ex) { - Error ("Failed to process received message", ex); - } - } - } - } - finally { - element.MessageSent -= handleElementMessageSent; - } - } - - void QueueStateMessagesLocked (EventTarget target) - { - if (target == null) return; - var created = false; - foreach (var m in target.StateMessages) { - if (m.MessageType == MessageType.Create) { - createdIds.Add (m.TargetId); - created = true; - } - if (created) { - QueueMessageLocked (m); - } - } - } - - void QueueMessageLocked (Message message) - { - // - // Make sure all the referenced objects have been created - // - if (!createdIds.Contains (message.TargetId)) { - QueueStateMessagesLocked (element.GetElementById (message.TargetId)); - } - if (message.Value is EventTarget ve) { - if (!createdIds.Contains (ve.Id)) { - QueueStateMessagesLocked (ve); - } - } - else if (message.Value is Array a) { - for (var i = 0; i < a.Length; i++) { - // Console.WriteLine ($"A{i} = {a.GetValue(i)}"); - if (a.GetValue (i) is EventTarget e && !createdIds.Contains (e.Id)) { - QueueStateMessagesLocked (e); - } - } - } - - // - // Add it to the queue - // - //Console.WriteLine ($"QM {message.MessageType} {message.TargetId} {message.Key} {message.Value}"); - queuedMessages.Add (message); - } - - void QueueMessage (Message message) - { - lock (queuedMessages) { - QueueMessageLocked (message); - } - sendThrottle.Enabled = true; - } - - async void TransmitQueuedMessages () - { - try { - // - // Dequeue as many messages as we can - // - var messagesToSend = new List (); - System.Runtime.CompilerServices.ConfiguredTaskAwaitable task; - lock (queuedMessages) { - messagesToSend.AddRange (queuedMessages); - queuedMessages.Clear (); - - if (messagesToSend.Count == 0) - return; - - // - // Now actually send this message - // Do this while locked to make sure SendAsync is called in the right order - // - var json = Newtonsoft.Json.JsonConvert.SerializeObject (messagesToSend); - var outputBuffer = new ArraySegment (Encoding.UTF8.GetBytes (json)); - //Console.WriteLine ("TRANSMIT " + json); - task = webSocket.SendAsync (outputBuffer, WebSocketMessageType.Text, true, token).ConfigureAwait (false); - } - await task; - } - catch (Exception ex) { - Error ("Failed to send queued messages, aborting session", ex); - element.MessageSent -= handleElementMessageSent; - sessionCts.Cancel (); - } + var ops = initialSize.Split (' '); + var initialWidth = double.Parse (ops[0]); + var initialHeight = double.Parse (ops[1]); + var g = new WebAssemblySession (sessionId, element, initialWidth, initialHeight); + lock (globalElementSessions) { + globalElementSessions[sessionId] = g; } + g.StartSession (); } + [Preserve] + public static void ReceiveWebAssemblySessionMessageJson (string sessionId, string json) + { + WebAssemblySession g; + lock (globalElementSessions) { + if (!globalElementSessions.TryGetValue (sessionId, out g)) + return; + } + g.ReceiveMessageJson (json); + } + + + static readonly Dictionary styles = + new Dictionary (); + static readonly StyleSelectors rules = new StyleSelectors (); + + public static StyleSelectors Styles => rules; + public class StyleSelectors { public Style this[string selector] { diff --git a/Ooui/Utilities.cs b/Ooui/Utilities.cs new file mode 100644 index 0000000..af30793 --- /dev/null +++ b/Ooui/Utilities.cs @@ -0,0 +1,26 @@ +using System; +using System.Text; + +namespace Ooui +{ + public static class Utilities + { + [ThreadStatic] + static System.Security.Cryptography.SHA256 sha256; + + public static string Hash (byte[] bytes) + { + var sha = sha256; + if (sha == null) { + sha = System.Security.Cryptography.SHA256.Create (); + sha256 = sha; + } + var data = sha.ComputeHash (bytes); + StringBuilder sBuilder = new StringBuilder (); + for (int i = 0; i < data.Length; i++) { + sBuilder.Append (data[i].ToString ("x2")); + } + return sBuilder.ToString (); + } + } +} diff --git a/Ooui/WebAssemblySession.cs b/Ooui/WebAssemblySession.cs new file mode 100644 index 0000000..b7d53f4 --- /dev/null +++ b/Ooui/WebAssemblySession.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Ooui +{ + public class WebAssemblySession : Session + { + readonly string id; + readonly Action handleElementMessageSent; + + public WebAssemblySession (string id, Element element, double initialWidth, double initialHeight) + : base (element, initialWidth, initialHeight) + { + this.id = id; + handleElementMessageSent = QueueMessage; + } + + protected override void QueueMessage (Message message) + { + lock (queuedMessages) { + QueueMessageLocked (message); + var max = 1; + var i = 0; + while (i < queuedMessages.Count) { + TransmitQueuedMessagesLocked (queuedMessages, i, max); + i += max; + } + // WebAssembly.Runtime.InvokeJS("console.log('TRANSMITTED'," + queuedMessages.Count + ")"); + queuedMessages.Clear (); + } + } + + void TransmitQueuedMessagesLocked (List messagesToSend, int startIndex, int max) + { + if (messagesToSend.Count == 0) + return; + + // + // Now actually send the messages + // + var sb = new System.IO.StringWriter (); + sb.Write ("__oouiReceiveMessages(\""); + sb.Write (id); + sb.Write ("\","); + sb.Write ("["); + var head = ""; + int n = 0; + for (var i = startIndex; i < messagesToSend.Count && n < max; i++, n++) { + sb.Write (head); + messagesToSend[i].WriteJson (sb); + head = ","; + } + sb.Write ("])"); + var jsonp = sb.ToString (); + // WebAssembly.Runtime.InvokeJS("console.log('TRANSMIT',"+n+")"); + WebAssembly.Runtime.InvokeJS (jsonp); + } + + public void ReceiveMessageJson (string json) + { + try { + var message = Newtonsoft.Json.JsonConvert.DeserializeObject (json); + element.Receive (message); + } + catch (Exception ex) { + Error ("Failed to process received message", ex); + } + } + + public void StartSession () + { + // + // Start watching for changes in the element + // + element.MessageSent += handleElementMessageSent; + + // + // Add it to the document body + // + if (element.WantsFullScreen) { + element.Style.Width = initialWidth; + element.Style.Height = initialHeight; + } + QueueMessage (Message.Call ("document.body", "appendChild", element)); + } + + public void StopSession () + { + element.MessageSent -= handleElementMessageSent; + } + } +} + +namespace WebAssembly +{ + public sealed class Runtime + { + [System.Runtime.CompilerServices.MethodImplAttribute ((System.Runtime.CompilerServices.MethodImplOptions)4096)] + static extern string InvokeJS (string str, out int exceptional_result); + + public static string InvokeJS (string str) + { + return InvokeJS (str, out var _); + } + } +} diff --git a/Ooui/WebSocketSession.cs b/Ooui/WebSocketSession.cs new file mode 100644 index 0000000..7b40e4d --- /dev/null +++ b/Ooui/WebSocketSession.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Net; +using System.Net.WebSockets; + +namespace Ooui +{ + public class WebSocketSession : Session + { + readonly WebSocket webSocket; + readonly Action handleElementMessageSent; + + readonly CancellationTokenSource sessionCts = new CancellationTokenSource (); + readonly CancellationTokenSource linkedCts; + readonly CancellationToken token; + + readonly System.Timers.Timer sendThrottle; + DateTime lastTransmitTime = DateTime.MinValue; + readonly TimeSpan throttleInterval = TimeSpan.FromSeconds (1.0 / UI.MaxFps); + + public WebSocketSession (WebSocket webSocket, Element element, double initialWidth, double initialHeight, CancellationToken serverToken) + : base (element, initialWidth, initialHeight) + { + this.webSocket = webSocket; + + // + // Create a new session cancellation token that will trigger + // automatically if the server shutsdown or the session shutsdown. + // + linkedCts = CancellationTokenSource.CreateLinkedTokenSource (serverToken, sessionCts.Token); + token = linkedCts.Token; + + // + // Preparse handlers for the element + // + handleElementMessageSent = QueueMessage; + + // + // Create a timer to use as a throttle when sending messages + // + sendThrottle = new System.Timers.Timer (throttleInterval.TotalMilliseconds); + sendThrottle.Elapsed += (s, e) => { + // System.Console.WriteLine ("TICK SEND THROTTLE FOR {0}", element); + if ((e.SignalTime - lastTransmitTime) >= throttleInterval) { + sendThrottle.Enabled = false; + lastTransmitTime = e.SignalTime; + TransmitQueuedMessages (); + } + }; + } + + public async Task RunAsync () + { + // + // Start watching for changes in the element + // + element.MessageSent += handleElementMessageSent; + + try { + // + // Add it to the document body + // + if (element.WantsFullScreen) { + element.Style.Width = initialWidth; + element.Style.Height = initialHeight; + } + QueueMessage (Message.Call ("document.body", "appendChild", element)); + + // + // Start the Read Loop + // + var receiveBuffer = new byte[64 * 1024]; + + while (webSocket.State == WebSocketState.Open && !token.IsCancellationRequested) { + var receiveResult = await webSocket.ReceiveAsync (new ArraySegment (receiveBuffer), token).ConfigureAwait (false); + + if (receiveResult.MessageType == WebSocketMessageType.Close) { + await webSocket.CloseAsync (WebSocketCloseStatus.NormalClosure, "", token).ConfigureAwait (false); + sessionCts.Cancel (); + } + else if (receiveResult.MessageType == WebSocketMessageType.Binary) { + await webSocket.CloseAsync (WebSocketCloseStatus.InvalidMessageType, "Cannot accept binary frame", token).ConfigureAwait (false); + sessionCts.Cancel (); + } + else { + var size = receiveResult.Count; + while (!receiveResult.EndOfMessage) { + if (size >= receiveBuffer.Length) { + await webSocket.CloseAsync (WebSocketCloseStatus.MessageTooBig, "Message too big", token).ConfigureAwait (false); + return; + } + receiveResult = await webSocket.ReceiveAsync (new ArraySegment (receiveBuffer, size, receiveBuffer.Length - size), token).ConfigureAwait (false); + size += receiveResult.Count; + } + var receivedString = Encoding.UTF8.GetString (receiveBuffer, 0, size); + + try { + // Console.WriteLine ("RECEIVED: {0}", receivedString); + var message = Newtonsoft.Json.JsonConvert.DeserializeObject (receivedString); + element.Receive (message); + } + catch (Exception ex) { + Error ("Failed to process received message", ex); + } + } + } + } + finally { + element.MessageSent -= handleElementMessageSent; + } + } + + protected override void QueueMessage (Message message) + { + base.QueueMessage (message); + sendThrottle.Enabled = true; + } + + async void TransmitQueuedMessages () + { + try { + // + // Dequeue as many messages as we can + // + var messagesToSend = new List (); + System.Runtime.CompilerServices.ConfiguredTaskAwaitable task; + lock (queuedMessages) { + messagesToSend.AddRange (queuedMessages); + queuedMessages.Clear (); + + if (messagesToSend.Count == 0) + return; + + // + // Now actually send this message + // Do this while locked to make sure SendAsync is called in the right order + // + var json = Newtonsoft.Json.JsonConvert.SerializeObject (messagesToSend); + var outputBuffer = new ArraySegment (Encoding.UTF8.GetBytes (json)); + //Console.WriteLine ("TRANSMIT " + json); + task = webSocket.SendAsync (outputBuffer, WebSocketMessageType.Text, true, token).ConfigureAwait (false); + } + await task; + } + catch (Exception ex) { + Error ("Failed to send queued messages, aborting session", ex); + element.MessageSent -= handleElementMessageSent; + sessionCts.Cancel (); + } + } + } +} diff --git a/README.md b/README.md index ea5a098..c73bbb7 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ | Version | Package | Description | | ------- | ------- | ----------- | -| [![NuGet Package](https://img.shields.io/nuget/v/Ooui.svg)](https://www.nuget.org/packages/Ooui) | [Ooui](https://www.nuget.org/packages/Ooui) | Core library with HTML elements and a server. | +| [![NuGet Package](https://img.shields.io/nuget/v/Ooui.svg)](https://www.nuget.org/packages/Ooui) | [Ooui](https://www.nuget.org/packages/Ooui) | Core library with HTML elements and a server | +| [![NuGet Package](https://img.shields.io/nuget/v/Ooui.AspNetCore.svg)](https://www.nuget.org/packages/Ooui.AspNetCore) | [Ooui.AspNetCore](https://www.nuget.org/packages/Ooui.AspNetCore) | Integration with ASP.NET Core | | [![NuGet Package](https://img.shields.io/nuget/v/Ooui.Forms.svg)](https://www.nuget.org/packages/Ooui.Forms) | [Ooui.Forms](https://www.nuget.org/packages/Ooui.Forms) | Xamarin.Forms backend using Ooui | -| [![NuGet Package](https://img.shields.io/nuget/v/Ooui.AspNetCore.svg)](https://www.nuget.org/packages/Ooui.AspNetCore) | [Ooui.AspNetCore](https://www.nuget.org/packages/Ooui.AspNetCore) | Integration with ASP.NET Core MVC | +| [![NuGet Package](https://img.shields.io/nuget/v/Ooui.Wasm.svg)](https://www.nuget.org/packages/Ooui.Wasm) | [Ooui.Wasm](https://www.nuget.org/packages/Ooui.Wasm) | Package your app into a web assembly | -Ooui (pronounced *weeee!*) is a small cross-platform UI library for .NET that uses web technologies. +Ooui (pronounced *weee!*) is a small cross-platform UI library for .NET that uses web technologies. It presents a classic object-oriented UI API that controls a dumb browser. With Ooui, you get the full power of your favorite .NET programming language *plus* the ability to interact with your app using any device. @@ -15,6 +16,8 @@ It presents a classic object-oriented UI API that controls a dumb browser. With Head on over to [http://ooui.mecha.parts](http://ooui.mecha.parts) to tryout the samples. +You can also load [https://s3.amazonaws.com/praeclarum.org/wasm/ooui-sample.html](https://s3.amazonaws.com/praeclarum.org/wasm/ooui-sample.html) to try the WebAssembly mode of Ooui running Xamarin.Forms. (That's Xamarin.Forms running right in your browser!) + ## Try the Samples Locally @@ -31,11 +34,10 @@ dotnet run --project Samples/Samples.csproj --no-build This will open the default starting page for the Samples. Now point your browser at [http://localhost:8080/shared-button](http://localhost:8080/shared-button) -You should see a button that tracks the number of times it was clicked. -The source code for that button is shown in the example below. +You should see a button that tracks the number of times it was clicked. The source code for that button is shown in the example below. -## Example Use +## Example App Here is the complete source code to a fully collaborative button clicking app. @@ -58,7 +60,7 @@ class Program }; // Publishing makes an object available at a given URL - // The user should be directed to http://localhost:8080/button + // The user should be directed to http://localhost:8080/shared-button UI.Publish ("/shared-button", button); // Don't exit the app until someone hits return @@ -67,119 +69,60 @@ class Program } ``` -Make sure to add a reference to Ooui before you try running! +Make sure to add a reference to Ooui before you start running! ```bash dotnet add package Ooui +dotnet run ``` -With just that code, the user will be presented with a silly counting button. +With just that code, a web server that serves the HTML and web socket logic necessary for an interactive button will start. -In fact, any number of users can hit that URL and start interacting with the same button. That's right, automatic collaboration! -If you want each user to get their own button, then you will instead `Publish` a **function** to create it: -```csharp -Button MakeButton() -{ - var button = new Button("Click me!"); - var count = 0; - button.Click += (s, e) => { - count++; - button.Text = $"Clicked {count} times"; - }; - return button; -} +## The Many Ways to Ooui -UI.Publish("/button", MakeButton); -``` +Ooui has been broken up into several packages to increase the variety of ways that it can be used. Here are some combinations to help you decide which way is best for you. -Now every user (well, every load of the page) will get their own button. + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OouiOoui.AspNetCoreOoui.FormsOoui.Wasm
Web DOM with the built-in web server
Web DOM with ASP.NET Core
Xamarin.Forms with ASP.NET Core
Xamarin.Forms with the built-in web server
Web DOM with Web Assembly
Xamarin.Forms with Web Assembly
## How it works -When the user requests a page, Ooui will connect to the client using a Web Socket. This socket is used to keep an in-memory model of the UI (the one you work with as a programmer) in sync with the actual UI shown to the user in their browser. This is done using a simple messaging protocol with JSON packets. +When the user requests a page, the page will connect to the server using a web socket. This socket is used to keep the server's in-memory model of the UI (the one you work with as a programmer) in sync with the actual UI shown to the user in their browser. This is done using a simple messaging protocol with JSON packets. When the user clicks or otherwise interacts with the UI, those events are sent back over the web socket so that your code can deal with them. - -## Comparison - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +In the case of web assembly, this same dataflow takes place. However, sockets are not used as all communication is done locally in the browser process. +## Contributing -
UI LibraryOouiXamarin.FormsASP.NET MVC
How big is it?80 KB850 KB1,300 KB
Where does it run?EverywhereiOS, Android, Mac, WindowsEverywhere
How do I make a button?
new Button()
new Button()
<button />
Does it use native controls?No, HTML5 controlsYes!HTML5 controls
What controls are available?All of those in HTML5Xamarin.Forms controlsAll of those in HTML5
Which architecture will you force me to use?None, you're freeMVVMMVC/MVVM
What's the templating language?C#XAMLRazor
How do I style things?CSS baby!XAML resourcesCSS
Do I need to run a server?NopeHeck noYes
Is it web scale?Yes?What's the web?Yes!
- +Ooui is open source and I love merging PRs. Please fork away, and please obey the .editorconfig file. :-) Try to file issues for things that you want to work on *before* you start the work so that there's no duplicated effort. If you just want to help out, check out the issues and dive in! diff --git a/Samples/Samples.csproj b/Samples/Samples.csproj index 98ffe10..effd0d6 100644 --- a/Samples/Samples.csproj +++ b/Samples/Samples.csproj @@ -32,9 +32,6 @@ DisplayAlertPage.xaml - - XamlPreviewPage.xaml - @@ -56,9 +53,6 @@ MSBuild:Compile - - MSBuild:UpdateDesignTimeXaml - diff --git a/Samples/XamlPreviewPage.xaml b/Samples/XamlPreviewPage.xaml deleted file mode 100644 index a96fa29..0000000 --- a/Samples/XamlPreviewPage.xaml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/XamlPreviewPage.xaml.cs b/Samples/XamlPreviewPage.xaml.cs deleted file mode 100644 index 21f9318..0000000 --- a/Samples/XamlPreviewPage.xaml.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using Xamarin.Forms; - -namespace Samples -{ - public partial class XamlPreviewPage : ContentPage - { - public XamlPreviewPage () - { - InitializeComponent (); - - editor.Text = @" - - - - - - - - - - -