Browse Source

Merge pull request #3273 from AvaloniaUI/vscode

HTML/WebSocket previewer "transport" + file watcher "transport"
pull/3922/head
Nikita Tsukanov 6 years ago
committed by GitHub
parent
commit
8ce88e0da5
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      nukebuild/Build.cs
  2. 4
      src/Avalonia.Controls/Remote/Server/RemoteServerTopLevelImpl.cs
  3. 3
      src/Avalonia.DesignerSupport/Avalonia.DesignerSupport.csproj
  4. 4
      src/Avalonia.DesignerSupport/Remote/DetachableTransportConnection.cs
  5. 90
      src/Avalonia.DesignerSupport/Remote/FileWatcherTransport.cs
  6. 266
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/HtmlTransport.cs
  7. 472
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/SimpleWebSocketHttpServer.cs
  8. 2
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/.gitignore
  9. 8878
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/package-lock.json
  10. 41
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/package.json
  11. 57
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/FramePresenter.tsx
  12. 78
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/PreviewerServerConnection.ts
  13. 14
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/index.html
  14. 15
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/index.tsx
  15. 35
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/tsconfig.json
  16. 117
      src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/webpack.config.js
  17. 53
      src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs
  18. 4
      src/Avalonia.Remote.Protocol/BsonStreamTransport.cs
  19. 1
      src/Avalonia.Remote.Protocol/ITransport.cs
  20. 2
      src/Avalonia.Remote.Protocol/TransportConnectionWrapper.cs
  21. 9
      src/Avalonia.Remote.Protocol/TransportMessages.cs
  22. 2
      src/Avalonia.Remote.Protocol/ViewportMessages.cs

14
nukebuild/Build.cs

@ -12,6 +12,7 @@ using Nuke.Common.ProjectModel;
using Nuke.Common.Tooling;
using Nuke.Common.Tools.DotNet;
using Nuke.Common.Tools.MSBuild;
using Nuke.Common.Tools.Npm;
using Nuke.Common.Utilities;
using Nuke.Common.Utilities.Collections;
using static Nuke.Common.EnvironmentInfo;
@ -121,8 +122,21 @@ partial class Build : NukeBuild
EnsureCleanDirectory(Parameters.TestResultsRoot);
});
Target CompileHtmlPreviewer => _ => _
.DependsOn(Clean)
.Executes(() =>
{
var webappDir = RootDirectory / "src" / "Avalonia.DesignerSupport" / "Remote" / "HtmlTransport" / "webapp";
NpmTasks.NpmInstall(c => c.SetWorkingDirectory(webappDir));
NpmTasks.NpmRun(c => c
.SetWorkingDirectory(webappDir)
.SetCommand("dist"));
});
Target Compile => _ => _
.DependsOn(Clean)
.DependsOn(CompileHtmlPreviewer)
.Executes(() =>
{
if (Parameters.IsRunningOnWindows)

4
src/Avalonia.Controls/Remote/Server/RemoteServerTopLevelImpl.cs

@ -115,7 +115,7 @@ namespace Avalonia.Controls.Remote.Server
{
lock (_lock)
{
_lastReceivedFrame = lastFrame.SequenceId;
_lastReceivedFrame = Math.Max(lastFrame.SequenceId, _lastReceivedFrame);
}
Dispatcher.UIThread.Post(RenderIfNeeded);
}
@ -298,6 +298,8 @@ namespace Avalonia.Controls.Remote.Server
Width = width,
Height = height,
Stride = width * bpp,
DpiX = _dpi.X,
DpiY = _dpi.Y
};
}

3
src/Avalonia.DesignerSupport/Avalonia.DesignerSupport.csproj

@ -8,8 +8,11 @@
-->
<Version>0.7.0</Version>
<NoWarn>CS1591</NoWarn>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Remote\HtmlTransport\webapp\build\**\*.gz" />
<EmbeddedResource Condition="'$(Configuration)' == 'Debug'" Remove="Remote\HtmlTransport\webapp\build\**\*.map.gz"/>
<ProjectReference Include="..\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
<ProjectReference Include="..\Markup\Avalonia.Markup\Avalonia.Markup.csproj" />
<ProjectReference Include="..\Avalonia.Animation\Avalonia.Animation.csproj" />

4
src/Avalonia.DesignerSupport/Remote/DetachableTransportConnection.cs

@ -35,5 +35,7 @@ namespace Avalonia.DesignerSupport.Remote
add {}
remove {}
}
public void Start() => _inner?.Start();
}
}
}

90
src/Avalonia.DesignerSupport/Remote/FileWatcherTransport.cs

@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Remote.Protocol;
using Avalonia.Remote.Protocol.Designer;
using Avalonia.Threading;
namespace Avalonia.DesignerSupport.Remote
{
class FileWatcherTransport : IAvaloniaRemoteTransportConnection, ITransportWithEnforcedMethod
{
private string _path;
private string _lastContents;
private bool _disposed;
public FileWatcherTransport(Uri file)
{
_path = file.LocalPath;
}
public void Dispose()
{
_disposed = true;
}
void Dump(object o, string pad)
{
foreach (var p in o.GetType().GetProperties())
{
Console.Write($"{pad}{p.Name}: ");
var v = p.GetValue(o);
if (v == null || v.GetType().IsPrimitive || v is string || v is Guid)
Console.WriteLine(v);
else
{
Console.WriteLine();
Dump(v, pad + " ");
}
}
}
public Task Send(object data)
{
Console.WriteLine(data.GetType().Name);
Dump(data, " ");
return Task.CompletedTask;
}
private Action<IAvaloniaRemoteTransportConnection, object> _onMessage;
public event Action<IAvaloniaRemoteTransportConnection, object> OnMessage
{
add
{
_onMessage+=value;
}
remove { _onMessage -= value; }
}
public event Action<IAvaloniaRemoteTransportConnection, Exception> OnException;
public void Start()
{
UpdaterThread();
}
// I couldn't get FileSystemWatcher working on Linux, so I came up with this abomination
async void UpdaterThread()
{
while (!_disposed)
{
var data = File.ReadAllText(_path);
if (data != _lastContents)
{
Console.WriteLine("Triggering XAML update");
_lastContents = data;
_onMessage?.Invoke(this, new UpdateXamlMessage { Xaml = data });
}
await Task.Delay(100);
}
}
public string PreviewerMethod => RemoteDesignerEntryPoint.Methods.Html;
}
interface ITransportWithEnforcedMethod
{
string PreviewerMethod { get; }
}
}

266
src/Avalonia.DesignerSupport/Remote/HtmlTransport/HtmlTransport.cs

@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Remote.Protocol;
using Avalonia.Remote.Protocol.Viewport;
namespace Avalonia.DesignerSupport.Remote.HtmlTransport
{
public class HtmlWebSocketTransport : IAvaloniaRemoteTransportConnection
{
private readonly IAvaloniaRemoteTransportConnection _signalTransport;
private readonly SimpleWebSocketHttpServer _simpleServer;
private readonly Dictionary<string, byte[]> _resources;
private SimpleWebSocket _pendingSocket;
private bool _disposed;
private object _lock = new object();
private AutoResetEvent _wakeup = new AutoResetEvent(false);
private FrameMessage _lastFrameMessage = null;
private FrameMessage _lastSentFrameMessage = null;
private RequestViewportResizeMessage _lastViewportRequest;
private Action<IAvaloniaRemoteTransportConnection, object> _onMessage;
private Action<IAvaloniaRemoteTransportConnection, Exception> _onException;
private static readonly Dictionary<string, string> Mime = new Dictionary<string, string>
{
["html"] = "text/html", ["htm"] = "text/html", ["js"] = "text/javascript", ["css"] = "text/css"
};
private static readonly byte[] NotFound = Encoding.UTF8.GetBytes("404 - Not Found");
public HtmlWebSocketTransport(IAvaloniaRemoteTransportConnection signalTransport, Uri listenUri)
{
if (listenUri.Scheme != "http")
throw new ArgumentException("listenUri");
var resourcePrefix = "Avalonia.DesignerSupport.Remote.HtmlTransport.webapp.build.";
_resources = typeof(HtmlWebSocketTransport).Assembly.GetManifestResourceNames()
.Where(r => r.StartsWith(resourcePrefix) && r.EndsWith(".gz")).ToDictionary(
r => r.Substring(resourcePrefix.Length).Substring(0,r.Length-resourcePrefix.Length-3),
r =>
{
using (var s =
new GZipStream(typeof(HtmlWebSocketTransport).Assembly.GetManifestResourceStream(r),
CompressionMode.Decompress))
{
var ms = new MemoryStream();
s.CopyTo(ms);
return ms.ToArray();
}
});
_signalTransport = signalTransport;
var address = IPAddress.Parse(listenUri.Host);
_simpleServer = new SimpleWebSocketHttpServer(address, listenUri.Port);
_simpleServer.Listen();
Task.Run(AcceptWorker);
Task.Run(SocketWorker);
_signalTransport.Send(new HtmlTransportStartedMessage { Uri = "http://" + address + ":" + listenUri.Port + "/" });
}
async void AcceptWorker()
{
while (true)
{
using (var req = await _simpleServer.AcceptAsync())
{
if (!req.IsWebsocketRequest)
{
var key = req.Path == "/" ? "index.html" : req.Path.TrimStart('/').Replace('/', '.');
if (_resources.TryGetValue(key, out var data))
{
var ext = Path.GetExtension(key).Substring(1);
string mime = null;
if (ext == null || !Mime.TryGetValue(ext, out mime))
mime = "application/octet-stream";
await req.RespondAsync(200, data, mime);
}
else
{
await req.RespondAsync(404, NotFound, "text/plain");
}
}
else
{
var socket = await req.AcceptWebSocket();
SocketReceiveWorker(socket);
lock (_lock)
{
_pendingSocket?.Dispose();
_pendingSocket = socket;
}
}
}
}
}
async void SocketReceiveWorker(SimpleWebSocket socket)
{
try
{
while (true)
{
var msg = await socket.ReceiveMessage().ConfigureAwait(false);
if(msg == null)
return;
if (msg.IsText)
{
var s = Encoding.UTF8.GetString(msg.Data);
var parts = s.Split(':');
if (parts[0] == "frame-received")
_onMessage?.Invoke(this, new FrameReceivedMessage { SequenceId = long.Parse(parts[1]) });
}
}
}
catch(Exception e)
{
Console.Error.WriteLine(e.ToString());
}
}
async void SocketWorker()
{
try
{
SimpleWebSocket socket = null;
while (true)
{
if (_disposed)
{
socket?.Dispose();
return;
}
FrameMessage sendNow = null;
lock (_lock)
{
if (_pendingSocket != null)
{
socket?.Dispose();
socket = _pendingSocket;
_pendingSocket = null;
_lastSentFrameMessage = null;
}
if (_lastFrameMessage != _lastSentFrameMessage)
_lastSentFrameMessage = sendNow = _lastFrameMessage;
}
if (sendNow != null && socket != null)
{
await socket.SendMessage(
$"frame:{sendNow.SequenceId}:{sendNow.Width}:{sendNow.Height}:{sendNow.Stride}:{sendNow.DpiX}:{sendNow.DpiY}");
await socket.SendMessage(false, sendNow.Data);
}
_wakeup.WaitOne(TimeSpan.FromSeconds(1));
}
}
catch(Exception e)
{
Console.Error.WriteLine(e.ToString());
}
}
public void Dispose()
{
_pendingSocket?.Dispose();
_simpleServer.Dispose();
}
public Task Send(object data)
{
if (data is FrameMessage frame)
{
_lastFrameMessage = frame;
_wakeup.Set();
return Task.CompletedTask;
}
if (data is RequestViewportResizeMessage req)
{
return Task.CompletedTask;
}
return _signalTransport.Send(data);
}
public void Start()
{
_onMessage?.Invoke(this, new Avalonia.Remote.Protocol.Viewport.ClientSupportedPixelFormatsMessage
{
Formats = new []{PixelFormat.Rgba8888}
});
_signalTransport.Start();
}
#region Forward
public event Action<IAvaloniaRemoteTransportConnection, object> OnMessage
{
add
{
bool subscribeToInner;
lock (_lock)
{
subscribeToInner = _onMessage == null;
_onMessage += value;
}
if (subscribeToInner)
_signalTransport.OnMessage += OnSignalTransportMessage;
}
remove
{
lock (_lock)
{
_onMessage -= value;
if (_onMessage == null)
_signalTransport.OnMessage -= OnSignalTransportMessage;
}
}
}
private void OnSignalTransportMessage(IAvaloniaRemoteTransportConnection signal, object message) => _onMessage?.Invoke(this, message);
public event Action<IAvaloniaRemoteTransportConnection, Exception> OnException
{
add
{
lock (_lock)
{
var subscribeToInner = _onException == null;
_onException += value;
if (subscribeToInner)
_signalTransport.OnException += OnSignalTransportException;
}
}
remove
{
lock (_lock)
{
_onException -= value;
if(_onException==null)
_signalTransport.OnException -= OnSignalTransportException;
}
}
}
private void OnSignalTransportException(IAvaloniaRemoteTransportConnection arg1, Exception ex)
{
_onException?.Invoke(this, ex);
}
#endregion
}
}

472
src/Avalonia.DesignerSupport/Remote/HtmlTransport/SimpleWebSocketHttpServer.cs

@ -0,0 +1,472 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace Avalonia.DesignerSupport.Remote.HtmlTransport
{
public class SimpleWebSocketHttpServer : IDisposable
{
private readonly IPAddress _address;
private readonly int _port;
private TcpListener _listener;
public async Task<SimpleWebSocketHttpRequest> AcceptAsync()
{
while (true)
{
var res = await AcceptAsyncImpl();
if (res != null)
return res;
}
}
async Task<SimpleWebSocketHttpRequest> AcceptAsyncImpl()
{
if (_listener == null)
throw new InvalidOperationException("Currently not listening");
var socket = await _listener.AcceptSocketAsync();
var stream = new NetworkStream(socket);
bool error = true;
async Task<string> ReadLineAsync()
{
var readBuffer = new byte[1];
var lineBuffer = new byte[1024];
for (var c = 0; c < 1024; c++)
{
if (await stream.ReadAsync(readBuffer, 0, 1) == 0)
throw new EndOfStreamException();
if (readBuffer[0] == 10)
{
if (c == 0)
return "";
if (lineBuffer[c - 1] == 13)
c--;
if (c == 0)
return "";
return Encoding.UTF8.GetString(lineBuffer, 0, c);
}
lineBuffer[c] = readBuffer[0];
}
throw new InvalidDataException("Header is too large");
}
var headers = new Dictionary<string, string>();
string line = null;
try
{
line = await ReadLineAsync();
var sp = line.Split(' ');
if (sp.Length != 3 || !sp[2].StartsWith("HTTP") || sp[0] != "GET")
return null;
var path = sp[1];
while (true)
{
line = await ReadLineAsync();
if (line == "")
break;
sp = line.Split(new[] {':'}, 2);
headers[sp[0]] = sp[1].TrimStart();
}
error = false;
return new SimpleWebSocketHttpRequest(stream, path, headers);
}
catch
{
error = true;
return null;
}
finally
{
if (error)
stream.Dispose();
}
}
public void Listen()
{
var listener = new TcpListener(_address, _port);
listener.Start();
_listener = listener;
}
public SimpleWebSocketHttpServer(IPAddress address, int port)
{
_address = address;
_port = port;
}
public void Dispose()
{
_listener?.Stop();
_listener = null;
}
}
public class SimpleWebSocketHttpRequest : IDisposable
{
public Dictionary<string, string> Headers { get; }
public string Path { get; }
private NetworkStream _stream;
public bool IsWebsocketRequest { get; }
public IReadOnlyList<string> WebSocketProtocols { get; }
private string _websocketKey;
public SimpleWebSocketHttpRequest(NetworkStream stream, string path, Dictionary<string, string> headers)
{
Path = path;
Headers = headers;
_stream = stream;
if (headers.TryGetValue("Connection", out var h)
&& h.Contains("Upgrade")
&& headers.TryGetValue("Upgrade", out h) &&
h == "websocket"
&& headers.TryGetValue("Sec-WebSocket-Key", out _websocketKey))
{
IsWebsocketRequest = true;
if (headers.TryGetValue("Sec-WebSocket-Protocol", out h))
WebSocketProtocols = h.Split(',').Select(x => x.Trim()).ToArray();
else WebSocketProtocols = new string[0];
}
}
public async Task RespondAsync(int code, byte[] data, string contentType)
{
var headers = Encoding.UTF8.GetBytes($"HTTP/1.1 {code} {(HttpStatusCode)code}\r\nConnection: close\r\nContent-Type: {contentType}\r\nContent-Length: {data.Length}\r\n\r\n");
await _stream.WriteAsync(headers, 0, headers.Length);
await _stream.WriteAsync(data, 0, data.Length);
_stream.Dispose();
_stream = null;
}
public async Task<SimpleWebSocket> AcceptWebSocket(string protocol = null)
{
var handshakeSource = _websocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
string handshake;
using (var sha1 = SHA1.Create())
handshake = Convert.ToBase64String(sha1.ComputeHash(Encoding.UTF8.GetBytes(handshakeSource)));
var headers =
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "
+ handshake + "\r\n";
if (protocol != null)
headers += protocol + "\r\n";
headers += "\r\n";
var bheaders = Encoding.UTF8.GetBytes(headers);
await _stream.WriteAsync(bheaders, 0, bheaders.Length);
var s = _stream;
_stream = null;
return new SimpleWebSocket(s);
}
public void Dispose() => _stream?.Dispose();
}
public class SimpleWebSocket : IDisposable
{
class AsyncLock
{
private object _syncRoot = new object();
private Queue<TaskCompletionSource<IDisposable>> _queue = new Queue<TaskCompletionSource<IDisposable>>();
private bool _locked;
public Task<IDisposable> LockAsync()
{
lock (_syncRoot)
{
if (!_locked)
{
_locked = true;
return Task.FromResult<IDisposable>(new Lock(this));
}
else
{
var tcs = new TaskCompletionSource<IDisposable>();
_queue.Enqueue(tcs);
return tcs.Task;
}
}
}
private void Unlock()
{
lock (_syncRoot)
{
if (_queue.Count != 0)
_queue.Dequeue().SetResult(new Lock(this));
else
_locked = false;
}
}
class Lock : IDisposable
{
private AsyncLock _parent;
private object _syncRoot = new object();
public Lock(AsyncLock parent)
{
_parent = parent;
}
public void Dispose()
{
lock (_syncRoot)
{
if (_parent == null)
return;
var p = _parent;
_parent = null;
p.Unlock();
}
}
}
}
private Stream _stream;
private AsyncLock _sendLock = new AsyncLock();
private AsyncLock _recvLock = new AsyncLock();
private const int WebsocketInitialHeaderLength = 2;
private const int WebsocketLen16Length = 4;
private const int WebsocketLen64Length = 10;
private const int WebsocketLen16Code = 126;
private const int WebsocketLen64Code = 127;
[StructLayout(LayoutKind.Explicit)]
struct WebSocketHeader
{
[FieldOffset(0)] public byte Mask;
[FieldOffset(1)] public byte Length8;
[FieldOffset(2)] public ushort Length16;
[FieldOffset(2)] public ulong Length64;
}
readonly byte[] _sendHeaderBuffer = new byte[10];
readonly MemoryStream _receiveFrameStream = new MemoryStream();
readonly MemoryStream _receiveMessageStream = new MemoryStream();
private FrameType _currentMessageFrameType;
enum FrameType
{
Continue = 0x0,
Text = 0x1,
Binary = 0x2,
Close = 0x8,
Ping = 0x9,
Pong = 0xA
}
internal SimpleWebSocket(Stream stream)
{
_stream = stream;
}
public void Dispose()
{
_stream?.Dispose();
_stream = null;
}
public Task SendMessage(string text)
{
var data = Encoding.UTF8.GetBytes(text);
return SendMessage(true, data);
}
public Task SendMessage(bool isText, byte[] data) => SendMessage(isText, data, 0, data.Length);
public Task SendMessage(bool isText, byte[] data, int offset, int length)
=> SendFrame(isText ? FrameType.Text : FrameType.Binary, data, offset, length);
async Task SendFrame(FrameType type, byte[] data, int offset, int length)
{
using (var l = await _sendLock.LockAsync())
{
var header = new WebSocketHeader();
int headerLength;
if (data.Length <= 125)
{
headerLength = WebsocketInitialHeaderLength;
header.Length8 = (byte) length;
}
else if (length <= 0xffff)
{
headerLength = WebsocketLen16Length;
header.Length8 = WebsocketLen16Code;
header.Length16 = (ushort) IPAddress.HostToNetworkOrder((short) (ushort) length);
}
else
{
headerLength = WebsocketLen64Length;
header.Length8 = WebsocketLen64Code;
header.Length64 = (ulong) IPAddress.HostToNetworkOrder((long) length);
}
var endOfMessage = true;
header.Mask = (byte) (((endOfMessage ? 1u : 0u) << 7) | ((byte) (type) & 0xf));
unsafe
{
Marshal.Copy(new IntPtr(&header), _sendHeaderBuffer, 0, headerLength);
}
await _stream.WriteAsync(_sendHeaderBuffer, 0, headerLength);
await _stream.WriteAsync(data, offset, length);
}
}
struct Frame
{
public byte[] Data;
public bool EndOfMessage;
public FrameType FrameType;
}
byte[] _recvHeaderBuffer = new byte[8];
byte[] _maskBuffer = new byte[4];
async Task<Frame> ReadFrame()
{
_receiveFrameStream.Position = 0;
_receiveFrameStream.SetLength(0);
await ReadExact(_stream, _recvHeaderBuffer, 0, 2);
var masked = (_recvHeaderBuffer[1] & 0x80) != 0;
var len0 = (_recvHeaderBuffer[1] & 0x7F);
var endOfMessage = (_recvHeaderBuffer[0] & 0x80) != 0;
var frameType = (FrameType) (_recvHeaderBuffer[0] & 0xf);
int length;
if (len0 <= 125)
length = len0;
else if (len0 == WebsocketLen16Code)
{
await ReadExact(_stream, _recvHeaderBuffer, 0, 2);
length = (ushort) IPAddress.NetworkToHostOrder(BitConverter.ToInt16(_recvHeaderBuffer, 0));
}
else
{
await ReadExact(_stream, _recvHeaderBuffer, 0, 8);
length = (int) (ulong) IPAddress.NetworkToHostOrder((long) BitConverter.ToUInt64(_recvHeaderBuffer, 0));
}
if (masked)
await ReadExact(_stream, _maskBuffer, 0, 4);
await ReadExact(_stream, _receiveFrameStream, length);
var data = _receiveFrameStream.ToArray();
if(masked)
for (var c = 0; c < data.Length; c++)
data[c] = (byte) (data[c] ^ _maskBuffer[c % 4]);
return new Frame
{
Data = data,
EndOfMessage = endOfMessage,
FrameType = frameType
};
}
public async Task<SimpleWebSocketMessage> ReceiveMessage()
{
using (await _recvLock.LockAsync())
{
while (true)
{
var frame = await ReadFrame();
if (frame.FrameType == FrameType.Close)
return null;
if (frame.FrameType == FrameType.Ping)
await SendFrame(FrameType.Pong, frame.Data, 0, frame.Data.Length);
if (frame.FrameType == FrameType.Text || frame.FrameType == FrameType.Binary)
{
var isText = frame.FrameType == FrameType.Text;
if (_receiveMessageStream.Length == 0 && frame.EndOfMessage)
return new SimpleWebSocketMessage
{
IsText = isText,
Data = frame.Data
};
_receiveMessageStream.Write(frame.Data, 0, frame.Data.Length);
_currentMessageFrameType = frame.FrameType;
}
if (frame.FrameType == FrameType.Continue)
{
frame.FrameType = _currentMessageFrameType;
_receiveMessageStream.Write(frame.Data, 0, frame.Data.Length);
if (frame.EndOfMessage)
{
var isText = frame.FrameType == FrameType.Text;
var data = _receiveMessageStream.ToArray();
_receiveMessageStream.Position = 0;
_receiveMessageStream.SetLength(0);
return new SimpleWebSocketMessage
{
IsText = isText,
Data = data
};
}
}
}
}
}
byte[] _readExactBuffer = new byte[4096];
async Task ReadExact(Stream from, MemoryStream to, int length)
{
while (length>0)
{
var toRead = Math.Min(length, _readExactBuffer.Length);
var read = await from.ReadAsync(_readExactBuffer, 0, toRead);
to.Write(_readExactBuffer, 0, read);
if (read <= 0)
throw new EndOfStreamException();
length -= read;
}
}
async Task ReadExact(Stream from, byte[] to, int offset, int length)
{
while (length > 0)
{
var read = await from.ReadAsync(to, offset, length);
if (read <= 0)
throw new EndOfStreamException();
length -= read;
offset += read;
}
}
}
public class SimpleWebSocketMessage
{
public bool IsText { get; set; }
public byte[] Data { get; set; }
public string AsString()
{
return Encoding.UTF8.GetString(Data);
}
}
}

2
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/.gitignore

@ -0,0 +1,2 @@
build
node_modules

8878
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/package-lock.json

File diff suppressed because it is too large

41
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/package.json

@ -0,0 +1,41 @@
{
"name": "simple",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"webpack-ver": "cross-env NODE_ENV=production webpack --version",
"dist": "cross-env NODE_ENV=production webpack --display-error-details",
"watch": "cross-env NODE_ENV=development webpack --watch --display-error-details"
},
"author": "",
"license": "ISC",
"devDependencies": {
"awesome-typescript-loader": "^5.0.0",
"clean-webpack-plugin": "^0.1.19",
"compression-webpack-plugin": "^2.0.0",
"copy-webpack-plugin": "^4.6.0",
"cross-env": "^5.1.6",
"css-loader": "^1.0.0",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.4.1",
"source-map-loader": "^0.2.3",
"style-loader": "^0.21.0",
"to-string-loader": "^1.1.5",
"tsconfig-paths-webpack-plugin": "^3.2.0",
"typescript": "^2.9.2",
"url-loader": "^1.0.1",
"webpack": "~4.16.3",
"webpack-cli": "~2.1.3",
"webpack-livereload-plugin": "~2.1.1"
},
"dependencies": {
"@types/react": "^16.3.14",
"@types/react-dom": "^16.0.5",
"mobx": "4.3.0",
"mobx-react": "^5.1.2",
"react": "^16.3.2",
"react-dom": "^16.3.2"
}
}

57
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/FramePresenter.tsx

@ -0,0 +1,57 @@
import {PreviewerFrame, PreviewerServerConnection} from "src/PreviewerServerConnection";
import * as React from "react";
interface PreviewerPresenterProps {
conn: PreviewerServerConnection;
}
export class PreviewerPresenter extends React.Component<PreviewerPresenterProps> {
private canvasRef: React.RefObject<HTMLCanvasElement>;
constructor(props: PreviewerPresenterProps) {
super(props);
this.state = {width: 1, height: 1};
this.canvasRef = React.createRef()
this.componentDidUpdate({
conn: null!
}, this.state);
}
componentDidMount(): void {
this.updateCanvas(this.canvasRef.current, this.props.conn.currentFrame);
}
componentDidUpdate(prevProps: Readonly<PreviewerPresenterProps>, prevState: Readonly<{}>, snapshot?: any): void {
if(prevProps.conn != this.props.conn)
{
if(prevProps.conn)
prevProps.conn.removeFrameListener(this.frameHandler);
if(this.props.conn)
this.props.conn.addFrameListener(this.frameHandler);
}
}
private frameHandler = (frame: PreviewerFrame)=>{
this.updateCanvas(this.canvasRef.current, frame);
};
updateCanvas(canvas: HTMLCanvasElement | null, frame: PreviewerFrame | null) {
if (!canvas)
return;
if (frame == null){
canvas.width = canvas.height = 1;
canvas.getContext('2d')!.clearRect(0,0,1,1);
}
else {
canvas.width = frame.data.width;
canvas.height = frame.data.height;
const ctx = canvas.getContext('2d')!;
ctx.putImageData(frame.data, 0,0);
}
}
render() {
return <canvas ref={this.canvasRef}/>
}
}

78
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/PreviewerServerConnection.ts

@ -0,0 +1,78 @@
export interface PreviewerFrame {
data: ImageData;
dpiX: number;
dpiY: number;
}
export class PreviewerServerConnection {
private nextFrame = {
width: 0,
height: 0,
stride: 0,
dpiX: 0,
dpiY: 0,
sequenceId: "0"
};
public currentFrame: PreviewerFrame | null;
private handlers = new Set<(frame: PreviewerFrame | null) => void>();
private conn: WebSocket;
public addFrameListener(listener: (frame: PreviewerFrame | null) => void) {
this.handlers.add(listener);
if (this.currentFrame)
listener(this.currentFrame);
}
public removeFrameListener(listener: (frame: PreviewerFrame | null) => void) {
this.handlers.delete(listener);
}
constructor(uri: string) {
this.currentFrame = null;
var conn = this.conn = new WebSocket(uri);
conn.binaryType = 'arraybuffer';
const onMessage = this.onMessage;
conn.onmessage = msg => onMessage(msg);
const onClose = () => this.setFrame(null);
conn.onclose = () => onClose();
conn.onerror = (err: Event) => {
onClose();
console.log("Connection error: " + err);
}
}
private onMessage = (msg: MessageEvent) => {
if (typeof msg.data == 'string' || msg.data instanceof String) {
const parts = msg.data.split(':');
if (parts[0] == 'frame') {
this.nextFrame = {
sequenceId: parts[1],
width: parseInt(parts[2]),
height: parseInt(parts[3]),
stride: parseInt(parts[4]),
dpiX: parseInt(parts[5]),
dpiY: parseInt(parts[6])
}
}
} else if (msg.data instanceof ArrayBuffer) {
const arr = new Uint8ClampedArray(msg.data, 0);
const imageData = new ImageData(arr, this.nextFrame.width, this.nextFrame.height);
this.conn.send('frame-received:' + this.nextFrame.sequenceId);
this.setFrame({
data: imageData,
dpiX: this.nextFrame.dpiX,
dpiY: this.nextFrame.dpiY
});
}
};
private setFrame(frame: PreviewerFrame | null) {
this.currentFrame = frame;
this.handlers.forEach(h => h(this.currentFrame));
}
}

14
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/index.html

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Avalonia XAML previewer web edition</title>
</head>
<body>
<div id="app">
<center>Loading...</center>
</div>
<noscript>Javascript is required</noscript>
</body>
</html>

15
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/src/index.tsx

@ -0,0 +1,15 @@
import * as React from "react";
import {PreviewerPresenter} from './FramePresenter'
import {PreviewerServerConnection} from "src/PreviewerServerConnection";
import * as ReactDOM from "react-dom";
const loc = window.location;
const conn = new PreviewerServerConnection((loc.protocol === "https:" ? "wss" : "ws") + "://" + loc.host + "/ws");
const App = function(){
return <div style={{width: '100%'}}>
<PreviewerPresenter conn={conn}/>
</div>
};
ReactDOM.render(<App/>, document.getElementById("app"));

35
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/tsconfig.json

@ -0,0 +1,35 @@
{
"compilerOptions": {
"outDir": "build/dist",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom"],
"sourceMap": true,
"allowJs": true,
"jsx": "react",
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": false,
"baseUrl": ".",
"experimentalDecorators": true,
"paths": {
"*": ["./node_modules/@types/*", "./node_modules/*"],
"src/*": ["./src/*"]
}
},
"include": ["./src/**/*"],
"exclude": [
"node_modules",
"build",
"scripts",
"acceptance-tests",
"webpack",
"jest",
"src/setupTests.ts"
]
}

117
src/Avalonia.DesignerSupport/Remote/HtmlTransport/webapp/webpack.config.js

@ -0,0 +1,117 @@
const webpack = require('webpack');
const path = require('path');
const LiveReloadPlugin = require('webpack-livereload-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const prod = process.env.NODE_ENV == 'production';
class Printer {
apply(compiler) {
compiler.hooks.afterEmit.tap("Printer", ()=> console.log("Build completed at " + new Date().toString()));
compiler.hooks.watchRun.tap("Printer", ()=> console.log("Watch triggered at " + new Date().toString()));
}
}
const config = {
entry: {
bundle: './src/index.tsx'
},
output: {
path: path.resolve(__dirname, 'build'),
publicPath: '/',
filename: '[name].[chunkhash].js'
},
performance: { hints: false },
mode: prod ? "production" : "development",
module: {
rules: [
{
enforce: "pre",
test: /\.js$/,
loader: "source-map-loader",
exclude: [
path.resolve(__dirname, 'node_modules/mobx-state-router')
]
},
{
"oneOf": [
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: 'awesome-typescript-loader'
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
},
{
test: /\.(jpg|png)$/,
use: {
loader: "url-loader",
options: {
limit: 25000,
},
},
},
{
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
use: [{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/', // where the fonts will go
}
}]
},
{
loader: require.resolve('file-loader'),
exclude: [/\.(js|jsx|mjs|tsx|ts)$/, /\.html$/, /\.json$/],
options: {
name: 'assets/[name].[hash:8].[ext]',
},
}]
},
]
},
devtool: "source-map",
resolve: {
modules: [path.resolve(__dirname, 'node_modules')],
plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json", logLevel: 'info' })],
extensions: ['.ts', '.tsx', '.js', '.json'],
alias: {
'src': path.resolve(__dirname, 'src')
}
},
plugins:
[
new Printer(),
new CleanWebpackPlugin([path.resolve(__dirname, 'build')]),
new MiniCssExtractPlugin({
filename: "[name].[chunkhash]h" +
".css",
chunkFilename: "[id].[chunkhash].css"
}),
new LiveReloadPlugin({appendScriptTag: !prod}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './src/index.html'),
filename: 'index.html' //relative to root of the application
}),
new CopyWebpackPlugin([
// relative path from src
//{ from: './src/favicon.ico' },
//{ from: './src/assets' }
]),
new CompressionPlugin({
test: /(\?.*)?$/i
})
]
};
module.exports = config;

53
src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs

@ -5,6 +5,7 @@ using System.Reflection;
using System.Threading;
using System.Xml;
using Avalonia.Controls;
using Avalonia.DesignerSupport.Remote.HtmlTransport;
using Avalonia.Input;
using Avalonia.Remote.Protocol;
using Avalonia.Remote.Protocol.Designer;
@ -24,15 +25,16 @@ namespace Avalonia.DesignerSupport.Remote
{
public string AppPath { get; set; }
public Uri Transport { get; set; }
public Uri HtmlMethodListenUri { get; set; }
public string Method { get; set; } = Methods.AvaloniaRemote;
public string SessionId { get; set; } = Guid.NewGuid().ToString();
}
static class Methods
internal static class Methods
{
public const string AvaloniaRemote = "avalonia-remote";
public const string Win32 = "win32";
public const string Html = "html";
}
static Exception Die(string error)
@ -52,6 +54,19 @@ namespace Avalonia.DesignerSupport.Remote
{
Console.Error.WriteLine("Usage: --transport transport_spec --session-id sid --method method app");
Console.Error.WriteLine();
Console.Error.WriteLine("--transport: transport used for communication with the IDE");
Console.Error.WriteLine(" 'tcp-bson' (e. g. 'tcp-bson://127.0.0.1:30243/') - TCP-based transport with BSON serialization of messages defined in Avalonia.Remote.Protocol");
Console.Error.WriteLine(" 'file' (e. g. 'file://C://my/file.xaml' - pseudo-transport that triggers XAML updates on file changes, useful as a standalone previewer tool, always uses http preview method");
Console.Error.WriteLine();
Console.Error.WriteLine("--session-id: session id to be sent to IDE process");
Console.Error.WriteLine();
Console.Error.WriteLine("--method: the way the XAML is displayed");
Console.Error.WriteLine(" 'avalonia-remote' - binary image is sent via transport connection in FrameMessage");
Console.Error.WriteLine(" 'win32' - XAML is displayed in win32 window (handle could be obtained from UpdateXamlResultMessage), IDE is responsible to use user32!SetParent");
Console.Error.WriteLine(" 'html' - Previewer starts an HTML server and displays XAML previewer as a web page");
Console.Error.WriteLine();
Console.Error.WriteLine("--html-url - endpoint for HTML method to listen on, e. g. http://127.0.0.1:8081");
Console.Error.WriteLine();
Console.Error.WriteLine("Example: --transport tcp-bson://127.0.0.1:30243/ --session-id 123 --method avalonia-remote MyApp.exe");
Console.Error.Flush();
return Die(null);
@ -74,6 +89,8 @@ namespace Avalonia.DesignerSupport.Remote
next = a => rv.Transport = new Uri(a, UriKind.Absolute);
else if (arg == "--method")
next = a => rv.Method = a;
else if (arg == "--html-url")
next = a => rv.HtmlMethodListenUri = new Uri(a, UriKind.Absolute);
else if (arg == "--session-id")
next = a => rv.SessionId = a;
else if (rv.AppPath == null)
@ -89,6 +106,9 @@ namespace Avalonia.DesignerSupport.Remote
{
PrintUsage();
}
if (next != null)
PrintUsage();
return rv;
}
@ -98,27 +118,40 @@ namespace Avalonia.DesignerSupport.Remote
{
return new BsonTcpTransport().Connect(IPAddress.Parse(transport.Host), transport.Port).Result;
}
if (transport.Scheme == "file")
{
return new FileWatcherTransport(transport);
}
PrintUsage();
return null;
}
interface IAppInitializer
{
Application GetConfiguredApp(IAvaloniaRemoteTransportConnection transport, CommandLineArgs args, object obj);
IAvaloniaRemoteTransportConnection ConfigureApp(IAvaloniaRemoteTransportConnection transport, CommandLineArgs args, object obj);
}
class AppInitializer<T> : IAppInitializer where T : AppBuilderBase<T>, new()
{
public Application GetConfiguredApp(IAvaloniaRemoteTransportConnection transport,
public IAvaloniaRemoteTransportConnection ConfigureApp(IAvaloniaRemoteTransportConnection transport,
CommandLineArgs args, object obj)
{
var builder = (AppBuilderBase<T>) obj;
var builder = (AppBuilderBase<T>)obj;
if (args.Method == Methods.AvaloniaRemote)
builder.UseWindowingSubsystem(() => PreviewerWindowingPlatform.Initialize(transport));
if (args.Method == Methods.Html)
{
transport = new HtmlWebSocketTransport(transport,
args.HtmlMethodListenUri ?? new Uri("http://localhost:5000"));
builder.UseWindowingSubsystem(() =>
PreviewerWindowingPlatform.Initialize(transport));
}
if (args.Method == Methods.Win32)
builder.UseWindowingSubsystem("Avalonia.Win32");
builder.SetupWithoutStarting();
return builder.Instance;
return transport;
}
}
@ -128,6 +161,8 @@ namespace Avalonia.DesignerSupport.Remote
{
var args = ParseCommandLineArgs(cmdline);
var transport = CreateTransport(args.Transport);
if (transport is ITransportWithEnforcedMethod enforcedMethod)
args.Method = enforcedMethod.PreviewerMethod;
var asm = Assembly.LoadFile(System.IO.Path.GetFullPath(args.AppPath));
var entryPoint = asm.EntryPoint;
if (entryPoint == null)
@ -141,12 +176,14 @@ namespace Avalonia.DesignerSupport.Remote
var appBuilder = builderMethod.Invoke(null, null);
Log($"Initializing application in design mode");
var initializer =(IAppInitializer)Activator.CreateInstance(typeof(AppInitializer<>).MakeGenericType(appBuilder.GetType()));
var app = initializer.GetConfiguredApp(transport, args, appBuilder);
transport = initializer.ConfigureApp(transport, args, appBuilder);
s_transport = transport;
transport.OnMessage += OnTransportMessage;
transport.OnException += (t, e) => Die(e.ToString());
transport.Start();
Log("Sending StartDesignerSessionMessage");
transport.Send(new StartDesignerSessionMessage {SessionId = args.SessionId});
Dispatcher.UIThread.MainLoop(CancellationToken.None);
}

4
src/Avalonia.Remote.Protocol/BsonStreamTransport.cs

@ -144,5 +144,9 @@ namespace Avalonia.Remote.Protocol
public event Action<IAvaloniaRemoteTransportConnection, object> OnMessage;
public event Action<IAvaloniaRemoteTransportConnection, Exception> OnException;
public void Start()
{
}
}
}

1
src/Avalonia.Remote.Protocol/ITransport.cs

@ -8,5 +8,6 @@ namespace Avalonia.Remote.Protocol
Task Send(object data);
event Action<IAvaloniaRemoteTransportConnection, object> OnMessage;
event Action<IAvaloniaRemoteTransportConnection, Exception> OnException;
void Start();
}
}

2
src/Avalonia.Remote.Protocol/TransportConnectionWrapper.cs

@ -98,5 +98,7 @@ namespace Avalonia.Remote.Protocol
add => _onException.Add(value);
remove => _onException.Remove(value);
}
public void Start() => _conn.Start();
}
}

9
src/Avalonia.Remote.Protocol/TransportMessages.cs

@ -0,0 +1,9 @@
namespace Avalonia.Remote.Protocol
{
[AvaloniaRemoteMessageGuid("53778004-78fa-4381-8ec3-176a6f2328b6")]
public class HtmlTransportStartedMessage
{
public string Uri { get; set; }
}
}

2
src/Avalonia.Remote.Protocol/ViewportMessages.cs

@ -60,6 +60,8 @@
public int Width { get; set; }
public int Height { get; set; }
public int Stride { get; set; }
public double DpiX { get; set; }
public double DpiY { get; set; }
}
}

Loading…
Cancel
Save