diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 516a2bc..30fb3b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,8 @@ jobs: build-vscode: - runs-on: ubuntu-latest + # macOS so the package includes the Eto.macOS preview host + runs-on: macos-latest steps: - uses: actions/checkout@v7 @@ -67,6 +68,14 @@ jobs: ${{ env.DotNetVersion }} 8.0.x + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install macOS workload + run: dotnet workload install macos + - uses: actions/setup-node@v4 with: node-version: '20' diff --git a/Directory.Build.props b/Directory.Build.props index c6967b4..a39a96d 100755 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,12 +20,15 @@ $(MSBuildThisFileDirectory) $(BasePath)artifacts\ + + true + false - 2.7.5 + 2.12.0 - 2.7.5.0 + 2.12.0.0 $(SetVersion) diff --git a/src/Eto.Designer/AppDomainDesignHost.cs b/src/Eto.Designer/AppDomainDesignHost.cs deleted file mode 100755 index ac7928d..0000000 --- a/src/Eto.Designer/AppDomainDesignHost.cs +++ /dev/null @@ -1,394 +0,0 @@ -#if NETFRAMEWORK -using Eto.Drawing; -using Eto.Forms; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.Serialization; -using System.Security.Permissions; -using System.Text; -using System.Threading.Tasks; - -namespace Eto.Designer -{ - - class DomainPlatformThemeHandler : IPlatformTheme - { - AppDomainProxy proxy; - Dictionary colors = new Dictionary(); - public DomainPlatformThemeHandler(AppDomainProxy proxy) - { - this.proxy = proxy; - } - Color GetColor(string name) - { - if (colors.TryGetValue(name, out var value)) - return value; - - return colors[name] = Color.FromArgb(proxy.GetThemeColor(name)); - } - - public Color ProjectBackground => GetColor("ProjectBackground"); - public Color ProjectForeground => GetColor("ProjectForeground"); - public Color ProjectDialogBackground => GetColor("ProjectDialogBackground"); - public Color ErrorForeground => GetColor("ErrorForeground"); - public Color SummaryBackground => GetColor("SummaryBackground"); - public Color SummaryForeground => GetColor("SummaryForeground"); - public Color DesignerBackground => GetColor("DesignerBackground"); - public Color DesignerPanel => GetColor("DesignerPanel"); - public IEnumerable AllColors { get; } = Enumerable.Empty(); - } - - class AppDomainProxy : MarshalByRefObject - { - string mainAssembly; - List references; - IDesignHost designPanel; - #pragma warning disable 414 - IDisposable resolver; - #pragma warning restore 414 - - public Func GetThemeColor { get; set; } - - public void Init(string platformType, string initializeAssembly, string mainAssembly, IEnumerable references) - { - this.mainAssembly = mainAssembly; - this.references = references.ToList(); - if (Platform.Instance == null) - { - var refs = new[] { Path.GetDirectoryName(typeof(AppDomainProxy).Assembly.Location), mainAssembly }; - resolver = AssemblyResolver.Register(refs.Union(this.references)); - - var plat = Activator.CreateInstance(Type.GetType(platformType)) as Platform; - Platform.Initialize(plat); - if (!string.IsNullOrEmpty(initializeAssembly)) - { - plat.LoadAssembly(initializeAssembly); - } - plat.Add(typeof(IPlatformTheme), () => new DomainPlatformThemeHandler(this)); - var app = new Application(); - app.Attach(); - app.Terminating += App_Terminating; - - app.UnhandledException += App_UnhandledException; - - AppDomain.CurrentDomain.DomainUnload += CurrentDomain_DomainUnload; - } - } - public void HookupEvents(AppDomainEventSink eventSink) - { - GetThemeColor = eventSink.GetThemeColor; - - designPanel = new DesignPanel(); - designPanel.MainAssembly = mainAssembly; - designPanel.References = references; - - designPanel.Error = eventSink.Error; - designPanel.ControlCreating = eventSink.ControlCreating; - designPanel.ControlCreated = eventSink.ControlCreated; - } - - void App_UnhandledException(object sender, UnhandledExceptionEventArgs e) - { - // error! - } - - void App_Terminating(object sender, CancelEventArgs e) - { - e.Cancel = true; - } - - void CurrentDomain_DomainUnload(object sender, EventArgs e) - { - //EtoAdapter.Unload(); - } - - Control container; - public object GetContainer() - { - // keep ref otherwise it gets gc'd in the app domain - container = designPanel.GetContainer(); - return container.ToContract(); - } - - public void Invalidate() - { - designPanel.Invalidate(); - } - - public bool SetBuilder(string fileName) - { - return designPanel.SetBuilder(fileName); - } - - public void Update(string code) - { - designPanel.Update(code); - } - - public string GetCodeFile(string fileName) - { - return designPanel.GetCodeFile(fileName); - } - - public void Dispose() - { - EtoAdapter.Unload(); - designPanel.Dispose(); - } - - public override object InitializeLifetimeService() - { - return null; - } - } - - class AppDomainEventSink : MarshalByRefObject - { - public AppDomainDesignHost Host { get; set; } - - public void ControlCreated() => Host.ControlCreated?.Invoke(); - public void ControlCreating() => Host.ControlCreating?.Invoke(); - public void Error(DesignError ex) => Host.Error?.Invoke(ex); - public override object InitializeLifetimeService() => null; - - public int GetThemeColor(string name) - { - switch (name) - { - case "DesignerPanel": return Global.Theme.DesignerPanel.ToArgb(); - case "DesignerBackground": return Global.Theme.DesignerBackground.ToArgb(); - case "ErrorForeground": return Global.Theme.ErrorForeground.ToArgb(); - case "ProjectBackground": return Global.Theme.ProjectBackground.ToArgb(); - case "ProjectDialogBackground": return Global.Theme.ProjectDialogBackground.ToArgb(); - case "ProjectForeground": return Global.Theme.ProjectForeground.ToArgb(); - case "SummaryBackground": return Global.Theme.SummaryBackground.ToArgb(); - case "SummaryForeground": return Global.Theme.SummaryForeground.ToArgb(); - default: - return Colors.White.ToArgb(); - } - } - } - - public class AppDomainDesignHost : IDesignHost, IDisposable - { - List references; - - public IEnumerable References - { - get { return references; } - set { references = value.ToList(); } - } - - public string MainAssembly { get; set; } - - public AppDomainDesignHost() - { - eventSink = new AppDomainEventSink { Host = this }; - timer = new UITimer { Interval = 1 }; - timer.Elapsed += Timer_Elapsed; - } - - ~AppDomainDesignHost() - { - Dispose(false); - } - - void Timer_Elapsed(object sender, EventArgs e) - { - timer.Stop(); - - if (domain != null) - { - var oldDomain = domain; - requiresNewDomain = true; - var newDomain = SetupAppDomain(true); - ContainerChanged?.Invoke(); - if (newDomain && !ReferenceEquals(oldDomain, domain)) - Task.Run(() => UnloadDomain(oldDomain)); - } - } - - bool requiresNewDomain; - AppDomain domain; - FileSystemWatcher watcher; - AppDomainProxy proxy; - AppDomainEventSink eventSink; - static int domainCount = 0; - UITimer timer; - - IEnumerable GetShadowCopyDirs() - { - if (!string.IsNullOrEmpty(MainAssembly)) - yield return Path.GetDirectoryName(MainAssembly); - if (References != null) - { - foreach (var r in References) - { - if (!string.IsNullOrEmpty(r)) - yield return Path.GetDirectoryName(r); - } - } - } - - bool SetupAppDomain(bool setBuilder) - { - if (!requiresNewDomain && domain != null) - { - EnsureWatcher(); - return false; - } - - - requiresNewDomain = false; -#pragma warning disable 618 - // doesn't work without for some reason, and there's no non-obsolete alternative. - if (!AppDomain.CurrentDomain.ShadowCopyFiles) - AppDomain.CurrentDomain.SetShadowCopyFiles(); -#pragma warning restore 618 - - var baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - var initializeAssembly = Builders.BaseCompiledInterfaceBuilder.InitializeAssembly; - - var shadowCopyDirs = string.Join(";", GetShadowCopyDirs().Distinct()); - var setup = new AppDomainSetup - { - ApplicationBase = AppDomain.CurrentDomain.BaseDirectory, - PrivateBinPath = $"{baseDir};{shadowCopyDirs}", - - ShadowCopyFiles = "true", - ShadowCopyDirectories = shadowCopyDirs, - - LoaderOptimization = LoaderOptimization.MultiDomain, - //LoaderOptimization = LoaderOptimization.NotSpecified - }; - if (!string.IsNullOrEmpty(MainAssembly)) - setup.CachePath = Path.Combine(Path.GetDirectoryName(MainAssembly), "Eto.Designer"); - - - proxy = null; - domain = AppDomain.CreateDomain("eto.designer." + domainCount++, null, setup); - try - { - using (AssemblyResolver.Register(baseDir)) - { - var proxyObject = domain.CreateInstanceFromAndUnwrap(typeof(AppDomainProxy).Assembly.Location, typeof(AppDomainProxy).FullName) as AppDomainProxy; - proxy = proxyObject as AppDomainProxy; - if (proxy == null) - throw new InvalidOperationException($"Could not create proxy for domain\nApplicationBase: {AppDomain.CurrentDomain.BaseDirectory}\nBaseDir: {baseDir}\nShadowCopyDirs: {shadowCopyDirs}"); - } - proxy.Init(Platform.Instance.GetType().AssemblyQualifiedName, initializeAssembly, MainAssembly, references); - - proxy.HookupEvents(eventSink); - - if (setBuilder) - proxy.SetBuilder(fileName); - if (!string.IsNullOrEmpty(code)) - proxy.Update(code); - } - catch (Exception ex) - { - UnloadDomain(domain); - domain = null; - proxy = null; - throw new InvalidOperationException($"Could not set up proxy for domain: {ex.GetBaseException().Message}", ex); - } - - EnsureWatcher(); - - return true; - } - - private void EnsureWatcher() - { - if (watcher == null && !string.IsNullOrEmpty(MainAssembly)) - { - var mainAssemblyPath = Path.GetDirectoryName(MainAssembly); - if (!Directory.Exists(mainAssemblyPath)) - return; - watcher = new FileSystemWatcher(mainAssemblyPath, "*.dll"); - watcher.Changed += (sender, e) => Application.Instance.AsyncInvoke(() => timer.Start()); - watcher.Created += (sender, e) => Application.Instance.AsyncInvoke(() => timer.Start()); - watcher.EnableRaisingEvents = true; - } - } - - public Action ContainerChanged { get; set; } - - public Action ControlCreated { get; set; } - public Action ControlCreating { get; set; } - - public Action Error { get; set; } - - public Control GetContainer() - { - SetupAppDomain(true); - return EtoAdapter.ToControl(proxy?.GetContainer()); - } - - public void Invalidate() - { - proxy?.Invalidate(); - } - - string fileName; - public bool SetBuilder(string fileName) - { - this.fileName = fileName; - SetupAppDomain(false); - return proxy?.SetBuilder(fileName) == true; - } - - string code; - public void Update(string code) - { - SetupAppDomain(true); - - this.code = code; - proxy.Update(code); - } - - public string GetCodeFile(string fileName) - { - return proxy.GetCodeFile(fileName); - } - - void UnloadDomain(AppDomain domain) - { - try - { - if (domain != null && !domain.IsFinalizingForUnload()) - { - AppDomain.Unload(domain); - } - } - catch - { - Debug.WriteLine("Could not unload domain"); - // ignore - } - } - - public void Dispose() - { - Dispose(true); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - proxy?.Dispose(); - - UnloadDomain(domain); - domain = null; - proxy = null; - } - } - } -} -#endif \ No newline at end of file diff --git a/src/Eto.Designer/AssemblyResolver.cs b/src/Eto.Designer/AssemblyResolver.cs deleted file mode 100644 index f4ad385..0000000 --- a/src/Eto.Designer/AssemblyResolver.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace Eto.Designer -{ - class AssemblyResolver : IDisposable - { - public List Files { get; set; } - - public AppDomain AppDomain { get; set; } - - public static IDisposable Register(string file, AppDomain domain = null) - { - return Register(new string[] { file }, domain); - } - - public static IDisposable Register(IEnumerable files, AppDomain domain = null) - { - if (files == null) - return null; - if (domain == null) - domain = AppDomain.CurrentDomain; - var resolver = new AssemblyResolver { AppDomain = domain, Files = files.ToList() }; - domain.AssemblyResolve += resolver.Resolve; - return resolver; - } - - public void Dispose() - { - AppDomain.AssemblyResolve -= Resolve; - } - - Assembly Resolve(object sender, ResolveEventArgs args) - { - var assemblyName = new AssemblyName(args.Name).Name; - foreach (var path in Files) - { - var filePath = Path.GetFileNameWithoutExtension(path); - if (string.Equals(filePath, assemblyName, StringComparison.OrdinalIgnoreCase) && File.Exists(path)) - return Assembly.LoadFrom(path); - if (!Directory.Exists(path)) - continue; - filePath = Path.Combine(path, assemblyName + ".dll"); - if (File.Exists(filePath)) - return Assembly.LoadFrom(filePath); - } - return null; - } - } -} diff --git a/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs b/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs index ad4f9f8..90042ba 100755 --- a/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs @@ -15,7 +15,8 @@ public abstract class BaseCompiledInterfaceBuilder : IInterfaceBuilder, IDisposa { string output; - public static string InitializeAssembly { get; set; } + /// Eto.dll to compile against, for when it was loaded from memory and has no location. + public static string EtoAssemblyPath { get; set; } ~BaseCompiledInterfaceBuilder() { @@ -72,7 +73,7 @@ public IBuildToken Create(string text, string mainAssembly, IEnumerable if (!string.IsNullOrEmpty(mainAssembly)) refs.Insert(0, mainAssembly); refs.RemoveAll(r => Path.GetFileName(r).ToLowerInvariant() == "eto.dll"); - refs.Add(typeof(Control).Assembly.Location); + refs.Add(EtoAssemblyPath ?? typeof(Control).Assembly.Location); ThreadPool.QueueUserWorkItem(state => { diff --git a/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs b/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs index 5896848..812a97e 100644 --- a/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs @@ -21,6 +21,8 @@ public IBuildToken Create(string text, string mainAssembly, IEnumerable var control = JsonReader.Load(stream); if (control != null) controlCreated(control); + else + error(new FormatException("The document did not create a control.")); } } catch (Exception ex) diff --git a/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs b/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs index 793f8ee..ce1a5d6 100755 --- a/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs @@ -13,6 +13,16 @@ public abstract class RoslynInterfaceBuilder : BaseCompiledInterfaceBuilder { protected virtual IEnumerable GetReferences() { +#if NETCOREAPP + // no reference assemblies on disk to rely on, so compile against the runtime itself + var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (!string.IsNullOrEmpty(trusted)) + { + foreach (var file in trusted.Split(Path.PathSeparator)) + yield return file; + yield break; + } +#endif string referenceDir = GetReferenceAssembliesFolder(); if (string.IsNullOrEmpty(referenceDir)) diff --git a/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs b/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs index 7529262..f766754 100755 --- a/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs @@ -21,6 +21,8 @@ public IBuildToken Create(string text, string mainAssembly, IEnumerable var control = XamlReader.Load(new StringReader(text), null); if (control != null) controlCreated(control); + else + error(new FormatException("The document did not create a control.")); } catch (Exception ex) { diff --git a/src/Eto.Designer/DesignSurface.cs b/src/Eto.Designer/DesignSurface.cs index 8d5d2c7..03c8a76 100644 --- a/src/Eto.Designer/DesignSurface.cs +++ b/src/Eto.Designer/DesignSurface.cs @@ -24,6 +24,12 @@ public class DesignSurface : Drawable public event EventHandler InvalidateContent; + /// Raised when the user drags the content to a size, or resets it to auto size. + public event EventHandler RequestedSizeChanged; + + /// Size the user dragged the content to, or null for its own size. + public Size? RequestedSize => _sizeBounds != null ? Size.Round(_sizeBounds.Value) : (Size?)null; + public DesignSurface() { if (!Platform.Instance.IsGtk) // doesn't work correctly on Gtk2 due to lack of control transparency @@ -82,6 +88,7 @@ RectangleF SizeBounds _sizeBounds = value.Size; _content.Size = Size.Round(value.Size); Invalidate(); + RequestedSizeChanged?.Invoke(this, EventArgs.Empty); } } @@ -191,6 +198,7 @@ IEnumerable CreateGrips() _sizeBounds = null; _content.Size = _originalContentSize ?? new Size(-1, -1); InvalidateContent?.Invoke(this, EventArgs.Empty); + RequestedSizeChanged?.Invoke(this, EventArgs.Empty); }, ShouldDraw = location => _sizeBounds != null, ToolTip = "Click to reset to auto size", diff --git a/src/Eto.Designer/Eto.Designer.csproj b/src/Eto.Designer/Eto.Designer.csproj index 25c923b..f7a32ff 100755 --- a/src/Eto.Designer/Eto.Designer.csproj +++ b/src/Eto.Designer/Eto.Designer.csproj @@ -4,9 +4,9 @@ Roslyn;RoslynCS;RoslynVB - - - + + + - + diff --git a/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj b/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj new file mode 100644 index 0000000..2021264 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj @@ -0,0 +1,57 @@ + + + + Exe + net10.0-macos + 12.0 + osx-x64;osx-arm64 + disable + disable + Eto.DevExtension.PreviewHost + Eto.DevExtension.PreviewHost + ca.picoe.eto.previewhost + Eto Preview Host + en + + dynamic + None + false + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Eto.DevExtension.PreviewHost.macOS/Info.plist b/src/Eto.DevExtension.PreviewHost.macOS/Info.plist new file mode 100644 index 0000000..fc7c47c --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost.macOS/Info.plist @@ -0,0 +1,9 @@ + + + + + + LSUIElement + + + diff --git a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj new file mode 100644 index 0000000..f485ccf --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj @@ -0,0 +1,102 @@ + + + Exe + + net8.0-windows;net8.0 + disable + disable + LatestMajor + + false + + Eto.DevExtension.PreviewHost + Eto.DevExtension.PreviewHost + en + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Eto.DevExtension.PreviewHost/HostTheme.cs b/src/Eto.DevExtension.PreviewHost/HostTheme.cs new file mode 100644 index 0000000..27fa74f --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/HostTheme.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Linq; +using Eto.Designer; +using Eto.Drawing; + +namespace Eto.DevExtension.PreviewHost +{ + /// Theme colors sent by the editor, so previews match it. + class HostTheme : IPlatformTheme + { + readonly IReadOnlyDictionary colors; + + public HostTheme(IReadOnlyDictionary colors) => this.colors = colors; + + Color Get(string name, Color fallback) => + colors.TryGetValue(name, out var value) && Color.TryParse(value, out var color) ? color : fallback; + + public Color ProjectBackground => Get(nameof(ProjectBackground), SystemColors.Control); + public Color ProjectForeground => Get(nameof(ProjectForeground), SystemColors.ControlText); + public Color ProjectDialogBackground => Get(nameof(ProjectDialogBackground), SystemColors.Control); + public Color ErrorForeground => Get(nameof(ErrorForeground), Colors.Red); + public Color SummaryBackground => Get(nameof(SummaryBackground), SystemColors.Control); + public Color SummaryForeground => Get(nameof(SummaryForeground), SystemColors.ControlText); + public Color DesignerBackground => Get(nameof(DesignerBackground), SystemColors.Control); + public Color DesignerPanel => Get(nameof(DesignerPanel), Color.FromRgb(0xF0F0F0)); + public IEnumerable AllColors => Enumerable.Empty(); + } +} diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs new file mode 100644 index 0000000..6114e86 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs @@ -0,0 +1,86 @@ +#if !WINDOWS && !MACOS +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class GtkPreviewPlatform : PreviewPlatform + { + public override string Name => "Gtk"; + public override string AssemblyName => "Eto.Gtk"; + public override string PackageId => "eto.platform.gtk"; + + public override IEnumerable SharedNames => new[] { "GtkSharp", "GdkSharp", "GLibSharp", "GioSharp", "AtkSharp", "PangoSharp", "CairoSharp" }; + + public override IEnumerable GetFrameworks(IEnumerable available) => + ProjectAssemblies.RuntimeFrameworks(); + + public override object CreatePlatform() => new Eto.GtkSharp.Platform(); + + // otherwise the first text box shows with its text selected, as the offscreen window focuses it + public override void Initialized() => Gtk.Settings.Default.SetProperty("gtk-entry-select-on-focus", new GLib.Value(false)); + + public override Task CaptureAsync(object control, RenderRequest request) + { + var etoControl = (Control)control; + var content = Eto.Designer.DesignPanel.GetContent(etoControl); + if (request.Width != null || request.Height != null) + content.Size = new Eto.Drawing.Size(request.Width ?? -1, request.Height ?? -1); + + // an offscreen window, since Wayland won't let a real one be moved out of sight + var window = new Gtk.OffscreenWindow(); + Gtk.Widget widget = null; + try + { + widget = Gtk3Helpers.ToNative(content, true); + window.Add(widget); + // GTK would give wrapping labels their narrowest width, unlike an Eto form + if (request.Width == null) + content.Width = (int)Math.Ceiling(content.GetPreferredSize().Width); + window.ShowAll(); + window.Focus = null; + for (var i = 0; i < 10 && Gtk.Application.EventsPending(); i++) + Gtk.Application.RunIteration(false); + + var width = widget.AllocatedWidth; + var height = widget.AllocatedHeight; + if (width <= 0 || height <= 0) + return Task.FromResult(RenderResult.Empty(width, height)); + + var scale = request.Scale > 0 ? request.Scale : 1; + using (var surface = new Cairo.ImageSurface(Cairo.Format.Argb32, (int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale))) + { + using (var context = new Cairo.Context(surface)) + { + context.Scale(scale, scale); + widget.Draw(context); + } + surface.Flush(); + // CairoSharp can only write PNGs to a file + var file = Path.GetTempFileName(); + try + { + surface.WriteToPng(file); + return Task.FromResult(RenderResult.Png(File.ReadAllBytes(file), width, height)); + } + finally + { + File.Delete(file); + } + } + } + finally + { + if (widget?.Parent == window) + window.Remove(widget); + window.Destroy(); + etoControl.Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs new file mode 100644 index 0000000..fbe554e --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs @@ -0,0 +1,90 @@ +#if !WINDOWS +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Eto.Forms; +#if MACOS +using AppKit; +using Foundation; +using CoreGraphics; +#else +using MonoMac.AppKit; +using MonoMac.Foundation; +using MonoMac.CoreGraphics; +#endif + +namespace Eto.DevExtension.PreviewHost +{ + /// Eto.macOS in the app bundle host, or Eto.Mac64 when run with dotnet. + class MacPreviewPlatform : PreviewPlatform + { +#if MACOS + public override string Name => "macOS"; + public override string AssemblyName => "Eto.macOS"; + public override string PackageId => "eto.platform.macos"; + + // folders are versioned, e.g. net10.0-macos26.0 + public override IEnumerable GetFrameworks(IEnumerable available) => + Majors().SelectMany(major => available + .Where(r => r.StartsWith($"net{major}.0-macos", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r, StringComparer.OrdinalIgnoreCase)); +#else + public override string Name => "Mac64"; + public override string AssemblyName => "Eto.Mac64"; + public override string PackageId => "eto.platform.mac64"; + public override IEnumerable CompanionNames => new[] { "MonoMac" }; + + public override IEnumerable GetFrameworks(IEnumerable available) => + ProjectAssemblies.RuntimeFrameworks(); +#endif + + public override object CreatePlatform() => new Eto.Mac.Platform(); + + // no dock icon or menu bar + public override void Initialized() => NSApplication.SharedApplication.ActivationPolicy = NSApplicationActivationPolicy.Accessory; + + public override Task CaptureAsync(object control, RenderRequest request) + { + var form = OffscreenForm.Create((Control)control, request, out var content); + try + { + form.Show(); + Application.Instance.RunIteration(); + + #if MACOS + var view = MacOSHelpers.ToNative(content); +#else + var view = MonoMac64Helpers.ToNative(content); +#endif + var width = (int)Math.Ceiling(view.Frame.Width); + var height = (int)Math.Ceiling(view.Frame.Height); + if (width <= 0 || height <= 0) + return Task.FromResult(RenderResult.Empty(width, height)); + + // sized by hand, since the offscreen window has no screen to take a backing scale from + var scale = request.Scale > 0 ? request.Scale : 1; + var bounds = view.Bounds; + var rep = view.BitmapImageRepForCachingDisplayInRect(bounds); + if (Math.Abs(rep.PixelsWide - width * scale) > 1) + rep = new NSBitmapImageRep(IntPtr.Zero, (int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale), 8, 4, true, false, NSColorSpace.DeviceRGB, 0, 32) { Size = bounds.Size }; + view.CacheDisplay(bounds, rep); + + var data = rep.RepresentationUsingTypeProperties(NSBitmapImageFileType.Png, new NSDictionary()); + using (var input = data.AsStream()) + using (var stream = new MemoryStream()) + { + input.CopyTo(stream); + return Task.FromResult(RenderResult.Png(stream.ToArray(), width, height)); + } + } + finally + { + form.Close(); + ((Control)control).Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs new file mode 100644 index 0000000..e1a29f5 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Eto.DevExtension.PreviewHost +{ + /// + /// The Eto platform a preview is drawn with. Pick one with . + /// + /// + /// The name members are read before Eto is loaded, so only the methods taking or returning objects may touch Eto types. + /// + abstract class PreviewPlatform + { + /// Name passed with --platform. + public abstract string Name { get; } + + /// Eto platform assembly, e.g. Eto.Wpf. + public abstract string AssemblyName { get; } + + /// NuGet package id holding . + public abstract string PackageId { get; } + + /// Assemblies that ship beside in its package and must match it. + public virtual IEnumerable CompanionNames => Enumerable.Empty(); + + /// Other libraries the platform needs, taken from the project when it has them. + public virtual IEnumerable SharedNames => Enumerable.Empty(); + + /// Package lib folders to look in, best first. + public abstract IEnumerable GetFrameworks(IEnumerable available); + + /// Creates the Eto.Platform. + public abstract object CreatePlatform(); + + /// Called once the application is running. + public virtual void Initialized() { } + + /// Draws the control to a PNG. is an Eto.Forms.Control, and is disposed after. + public abstract Task CaptureAsync(object control, RenderRequest request); + + // newest first, but never newer than the runtime we're on + protected static IEnumerable Majors() + { + for (var major = Environment.Version.Major; major >= 5; major--) + yield return major; + } + + public static PreviewPlatform Get(string name) + { + if (string.IsNullOrEmpty(name)) + name = Default; + switch (name.ToLowerInvariant()) + { +#if WINDOWS + case "wpf": + return new WpfPreviewPlatform(); + case "winforms": + return new WinFormsPreviewPlatform(); +#elif MACOS + case "macos": + return new MacPreviewPlatform(); +#else + case "gtk": + return new GtkPreviewPlatform(); + case "mac64": + return new MacPreviewPlatform(); +#endif + default: + throw new NotSupportedException($"The {name} platform isn't supported by this preview host"); + } + } + +#if WINDOWS + static string Default => "Wpf"; +#elif MACOS + static string Default => "macOS"; +#else + static string Default => OperatingSystem.IsMacOS() ? "Mac64" : "Gtk"; +#endif + } +} diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs new file mode 100644 index 0000000..0754835 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs @@ -0,0 +1,60 @@ +#if WINDOWS +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class WinFormsPreviewPlatform : PreviewPlatform + { + public override string Name => "WinForms"; + public override string AssemblyName => "Eto.WinForms"; + public override string PackageId => "eto.platform.windows"; + + public override IEnumerable GetFrameworks(IEnumerable available) + { + foreach (var major in Majors()) + { + yield return $"net{major}.0-windows7.0"; + yield return $"net{major}.0-windows"; + } + yield return "netcoreapp3.1"; + } + + public override object CreatePlatform() => new Eto.WinForms.Platform(); + + public override Task CaptureAsync(object control, RenderRequest request) + { + var form = OffscreenForm.Create((Control)control, request, out var content); + try + { + form.Show(); + System.Windows.Forms.Application.DoEvents(); + + var native = WinFormsHelpers.ToNative(content); + var width = native.Width; + var height = native.Height; + if (width <= 0 || height <= 0) + return Task.FromResult(RenderResult.Empty(width, height)); + + // WinForms can only draw at the screen's own scale, so report the size in logical pixels + var dpiScale = native.DeviceDpi / 96.0; + using (var bitmap = new System.Drawing.Bitmap(width, height)) + using (var stream = new MemoryStream()) + { + native.DrawToBitmap(bitmap, new System.Drawing.Rectangle(0, 0, width, height)); + bitmap.Save(stream, System.Drawing.Imaging.ImageFormat.Png); + return Task.FromResult(RenderResult.Png(stream.ToArray(), (int)Math.Round(width / dpiScale), (int)Math.Round(height / dpiScale))); + } + } + finally + { + form.Close(); + ((Control)control).Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs new file mode 100644 index 0000000..ecb6be8 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs @@ -0,0 +1,74 @@ +#if WINDOWS +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class WpfPreviewPlatform : PreviewPlatform + { + public override string Name => "Wpf"; + public override string AssemblyName => "Eto.Wpf"; + public override string PackageId => "eto.platform.wpf"; + + public override IEnumerable GetFrameworks(IEnumerable available) + { + foreach (var major in Majors()) + { + yield return $"net{major}.0-windows7.0"; + yield return $"net{major}.0-windows"; + } + yield return "netcoreapp3.1"; + } + + public override object CreatePlatform() => new Eto.Wpf.Platform(); + + // the offscreen forms come and go, and must not end the process + public override void Initialized() => System.Windows.Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + public override async Task CaptureAsync(object control, RenderRequest request) + { + var form = OffscreenForm.Create((Control)control, request, out var content); + try + { + form.Show(); + await Dispatcher.Yield(DispatcherPriority.ContextIdle); + + var element = WpfHelpers.ToNative(content); + element.UpdateLayout(); + var width = (int)Math.Ceiling(element.ActualWidth); + var height = (int)Math.Ceiling(element.ActualHeight); + if (width <= 0 || height <= 0) + return RenderResult.Empty(width, height); + + var scale = request.Scale > 0 ? request.Scale : 1; + var bitmap = new RenderTargetBitmap((int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32); + // through a brush, since rendering the element directly includes its offset in the window + var visual = new DrawingVisual(); + using (var context = visual.RenderOpen()) + context.DrawRectangle(new VisualBrush(element), null, new Rect(0, 0, width, height)); + bitmap.Render(visual); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using (var stream = new MemoryStream()) + { + encoder.Save(stream); + return RenderResult.Png(stream.ToArray(), width, height); + } + } + finally + { + form.Close(); + ((Control)control).Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs new file mode 100644 index 0000000..526579a --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Eto.Designer; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class RenderRequest + { + public string FileName; + public string Text; + public int? Width; + public int? Height; + public double Scale = 1; + } + + class RenderResult + { + public string Image { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public RenderError Error { get; set; } + public bool? RestartRequired { get; set; } + + public static RenderResult Png(byte[] png, int width, int height) => + new RenderResult { Image = Convert.ToBase64String(png), Width = width, Height = height }; + + /// For a control with nothing to draw. + public static RenderResult Empty(int width, int height) => + new RenderResult { Width = Math.Max(width, 0), Height = Math.Max(height, 0) }; + } + + class RenderError + { + public string Message { get; set; } + public string Details { get; set; } + } + + /// Builds a designer file into a control and draws it to a PNG. Use from the UI thread only. + class PreviewRenderer + { + readonly PreviewPlatform platform; + string builderFile; + IInterfaceBuilder builder; + IBuildToken token; + + public PreviewRenderer(PreviewPlatform platform) => this.platform = platform; + + public Task RenderAsync(RenderRequest request) + { + var completion = new TaskCompletionSource(); + try + { + if (!string.Equals(builderFile, request.FileName, StringComparison.OrdinalIgnoreCase)) + { + builder = BuilderInfo.Find(request.FileName)?.CreateBuilder(); + builderFile = request.FileName; + } + if (builder == null) + return Task.FromResult(Error(new NotSupportedException($"No designer for {Path.GetFileName(request.FileName)}"))); + + token?.Cancel(); + token = builder.Create( + request.Text, + ProjectAssemblies.MainAssembly, + ProjectAssemblies.Paths, + control => Capture(control, request).ContinueWith(r => completion.TrySetResult(r.IsFaulted ? Error(r.Exception) : r.Result)), + ex => completion.TrySetResult(Error(ex))); + } + catch (Exception ex) + { + completion.TrySetResult(Error(ex)); + } + return completion.Task; + } + + Task Capture(Control control, RenderRequest request) + { + try + { + return platform.CaptureAsync(control, request); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + static RenderResult Error(Exception ex) + { + ex = (ex as AggregateException)?.Flatten().InnerException ?? ex; + var root = ex.GetBaseException(); + return new RenderResult { Error = new RenderError { Message = root.Message, Details = ex.ToString() } }; + } + } + + static class OffscreenForm + { + /// A form out of sight holding the control, shown for real so load events fire and templates apply like in the app. + public static Form Create(Control control, RenderRequest request, out Control content) + { + content = DesignPanel.GetContent(control); + if (request.Width != null || request.Height != null) + content.Size = new Eto.Drawing.Size(request.Width ?? -1, request.Height ?? -1); + + return new Form + { + Content = content, + WindowStyle = WindowStyle.None, + ShowInTaskbar = false, + ShowActivated = false, + Resizable = false, + Location = new Eto.Drawing.Point(-32000, -32000) + }; + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/PreviewServer.cs b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs new file mode 100644 index 0000000..8ea4ed3 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Eto.DevExtension.LanguageServer; +using Eto.DevExtension.LanguageServer.Lsp; + +namespace Eto.DevExtension.PreviewHost +{ + /// + /// Handles the preview protocol. Eto only starts on the first render, once it's known which project + /// (and so which Eto) this process is for. + /// + class PreviewServer + { + readonly LspConnection connection; + readonly PreviewPlatform platform; + readonly ManualResetEventSlim startUI = new ManualResetEventSlim(); + readonly TaskCompletionSource uiReady = new TaskCompletionSource(); + Dictionary theme = new Dictionary(); + bool configured; + object renderer; + + public PreviewServer(LspConnection connection, PreviewPlatform platform) + { + this.connection = connection; + this.platform = platform; + connection.OnRequest("initialize", Initialize); + connection.OnRequest("shutdown", _ => null); + connection.OnNotification("initialized", _ => { }); + connection.OnNotification("exit", _ => Environment.Exit(0)); + connection.OnRequest("preview/render", Render); + } + + object Initialize(JsonNode parameters) + { + if (parameters?["theme"] is JsonObject colors) + theme = colors.Where(r => r.Value != null).ToDictionary(r => r.Key, r => r.Value.GetValue(), StringComparer.OrdinalIgnoreCase); + return new { capabilities = new { }, serverInfo = new { name = "Eto Preview Host" } }; + } + + object Render(JsonNode parameters) + { + var request = new RenderRequest + { + FileName = parameters?["fileName"]?.GetValue(), + Text = parameters?["text"]?.GetValue() ?? string.Empty, + Width = parameters?["width"]?.GetValue(), + Height = parameters?["height"]?.GetValue(), + Scale = parameters?["scale"]?.GetValue() ?? 1 + }; + var assemblies = (parameters?["assemblies"] as JsonArray)?.Select(r => r?.GetValue()).Where(r => !string.IsNullOrEmpty(r)).ToList() + ?? ProjectAssemblyLocator.Find(request.FileName, connection.Log); + + if (!configured) + { + ProjectAssemblies.Configure(platform, assemblies, request.FileName, connection.Log); + configured = true; + startUI.Set(); + uiReady.Task.Wait(); + } + else if (ProjectAssemblies.GetKey(assemblies) != ProjectAssemblies.Key) + return new RenderResult { RestartRequired = true }; + + return RenderOnUI(request); + } + + /// Runs the UI on the calling thread, which must be the process's main thread. + public void RunUI() + { + startUI.Wait(); + StartEto(); + } + + // kept apart so no Eto type is touched before the project's Eto is chosen + [MethodImpl(MethodImplOptions.NoInlining)] + void StartEto() + { + var etoPlatform = (Eto.Platform)platform.CreatePlatform(); + etoPlatform.Add(() => new HostTheme(theme)); + Eto.Designer.Builders.BaseCompiledInterfaceBuilder.EtoAssemblyPath = ProjectAssemblies.EtoFile; + + var app = new Eto.Forms.Application(etoPlatform); + app.Initialized += (sender, e) => + { + platform.Initialized(); + ProjectAssemblies.LoadProject(); + ProjectAssemblies.WatchForChanges(Restart); + renderer = new PreviewRenderer(platform); + uiReady.TrySetResult(true); + }; + app.UnhandledException += (sender, e) => connection.Log($"Unhandled exception: {e.ExceptionObject}"); + app.Run(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + RenderResult RenderOnUI(RenderRequest request) + { + var result = Eto.Forms.Application.Instance.Invoke(() => ((PreviewRenderer)renderer).RenderAsync(request)); + return result.GetAwaiter().GetResult(); + } + + void Restart() + { + connection.Log("Project assemblies changed, restarting the preview host."); + connection.Notify("preview/restart", new { }); + Environment.Exit(0); + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/Program.cs b/src/Eto.DevExtension.PreviewHost/Program.cs new file mode 100644 index 0000000..9caae27 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Program.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using System.Threading; +using Eto.DevExtension.LanguageServer.Lsp; + +namespace Eto.DevExtension.PreviewHost +{ + static class Program + { + /// Pass --platform Wpf|Gtk|Mac64|macOS to pick the Eto platform, otherwise the OS's default is used. + [STAThread] + static int Main(string[] args) + { + var index = Array.FindIndex(args, r => r == "--platform"); + var platform = PreviewPlatform.Get(index >= 0 && index + 1 < args.Length ? args[index + 1] : null); + + var connection = new LspConnection(Console.OpenStandardInput(), Console.OpenStandardOutput()); + + // stdout is the protocol channel + Console.SetOut(TextWriter.Null); + + var server = new PreviewServer(connection, platform); + var reader = new Thread(() => + { + try + { + connection.Run(); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + // the editor went away + Environment.Exit(0); + }) + { IsBackground = true, Name = "Preview protocol" }; + reader.Start(); + + server.RunUI(); + return 0; + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs b/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs new file mode 100644 index 0000000..459343a --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using Eto.DevExtension.LanguageServer; + +namespace Eto.DevExtension.PreviewHost +{ + /// + /// Resolves the project's assemblies and a matching Eto + Eto platform for this process. + /// Call once, before anything touches an Eto type. + /// + /// + /// A process only ever serves one project, since assemblies can't be swapped out once loaded. + /// + static class ProjectAssemblies + { + // Eto and the libraries it shares with the project, which must all come from the same place + static readonly string[] SerializationNames = { "Eto.Serialization.Xaml", "Eto.Serialization.Json" }; + static readonly string[] SharedNames = { "Portable.Xaml", "Newtonsoft.Json" }; + + static readonly Dictionary namedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + static readonly ConcurrentDictionary loaded = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + static readonly List probeDirectories = new List(); + static readonly List watchedFiles = new List(); + static readonly List watchers = new List(); + static System.Threading.Timer changeTimer; + static readonly object loadLock = new object(); + static AssemblyDependencyResolver dependencyResolver; + static Action log; + static PreviewPlatform platform; + +#if MACOS + // Contents/Resources/eto in the app bundle, beside Contents/MonoBundle + static readonly string FallbackDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Resources", "eto")); +#else + static readonly string FallbackDirectory = Path.Combine(AppContext.BaseDirectory, "eto"); +#endif + + /// Project assembly files, its own first. + public static IReadOnlyList Paths { get; private set; } = Array.Empty(); + + /// Identifies the assembly set, so a render for a different project can be refused. + public static string Key { get; private set; } + + public static string MainAssembly => Paths.Count > 0 ? Paths[0] : null; + + /// File Eto.dll was loaded from, since a copy loaded from memory has no location. + public static string EtoFile => namedFiles.TryGetValue("Eto", out var file) ? file : null; + + public static string GetKey(IEnumerable paths) => string.Join("|", paths ?? Enumerable.Empty()); + + public static void Configure(PreviewPlatform previewPlatform, IList paths, string documentPath, Action logger) + { + platform = previewPlatform; + log = logger; + Paths = (paths ?? new List()).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + Key = GetKey(paths); + + foreach (var directory in Paths.Select(Path.GetDirectoryName).Distinct(StringComparer.OrdinalIgnoreCase)) + probeDirectories.Add(directory); + + if (MainAssembly != null) + { + try + { + dependencyResolver = new AssemblyDependencyResolver(MainAssembly); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + // no deps.json, as with .NET Framework projects + } + } + + ChooseEto(documentPath); + + AssemblyLoadContext.Default.Resolving += Resolve; + AssemblyLoadContext.Default.ResolvingUnmanagedDll += ResolveUnmanaged; + } + + /// Loads the project's own assemblies, so the designer can find their types. + public static void LoadProject() + { + foreach (var path in Paths) + { + try + { + Load(path); + } + catch (Exception ex) when (ex is IOException || ex is BadImageFormatException || ex is UnauthorizedAccessException) + { + log?.Invoke($"Could not load {path}: {ex.Message}"); + } + } + } + + /// Calls once any project file loaded so far is rebuilt. + public static void WatchForChanges(Action changed) + { + List files; + lock (loadLock) + files = watchedFiles.ToList(); + + var timer = changeTimer = new System.Threading.Timer(_ => changed()); + foreach (var group in files.GroupBy(Path.GetDirectoryName, StringComparer.OrdinalIgnoreCase)) + { + var names = new HashSet(group.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase); + var watcher = new FileSystemWatcher(group.Key) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size }; + FileSystemEventHandler handler = (sender, e) => + { + // a build writes several files, so wait for it to settle + if (names.Contains(e.Name)) + timer.Change(1000, System.Threading.Timeout.Infinite); + }; + watcher.Changed += handler; + watcher.Created += handler; + watcher.EnableRaisingEvents = true; + watchers.Add(watcher); + } + } + + static void ChooseEto(string documentPath) + { + // the project's own copy, so its custom controls bind to the Eto they were built against + var etoDirectory = probeDirectories.FirstOrDefault(r => File.Exists(Path.Combine(r, "Eto.dll"))) + ?? EtoAssemblyLocator.Find(documentPath, log); + var eto = etoDirectory != null ? Path.Combine(etoDirectory, "Eto.dll") : null; + var version = eto != null && File.Exists(eto) ? GetPackageVersion(eto) : null; + var platformFile = version != null ? FindPlatform(version, etoDirectory) : null; + + if (platformFile == null) + { + if (eto != null) + log?.Invoke($"No {platform.AssemblyName} {version} found for {eto}, so using Eto bundled with the extension."); + foreach (var name in new[] { "Eto", platform.AssemblyName }.Concat(platform.CompanionNames).Concat(SerializationNames).Concat(SharedNames).Concat(platform.SharedNames)) + SetFallback(name); + return; + } + + // the located copy may be built for a newer runtime than this one + namedFiles["Eto"] = FindPackage("Eto", version, RuntimeFrameworks(), "eto.forms") ?? eto; + namedFiles[platform.AssemblyName] = platformFile; + foreach (var name in platform.CompanionNames) + { + var file = FindBeside(name, Path.GetDirectoryName(platformFile)); + if (file != null) + namedFiles[name] = file; + else + SetFallback(name); + } + foreach (var name in SerializationNames) + { + var file = FindBeside(name, etoDirectory) ?? FindPackage(name, version, RuntimeFrameworks()); + if (file != null) + namedFiles[name] = file; + else + SetFallback(name); + } + foreach (var name in SharedNames.Concat(platform.SharedNames)) + { + var file = probeDirectories.Select(r => FindBeside(name, r)).FirstOrDefault(r => r != null); + if (file != null) + namedFiles[name] = file; + else + SetFallback(name); + } + log?.Invoke($"Using Eto {version} from {namedFiles["Eto"]} with {platformFile}"); + } + + static void SetFallback(string name) + { + var file = Path.Combine(FallbackDirectory, name + ".dll"); + if (File.Exists(file)) + namedFiles[name] = file; + } + + static string FindBeside(string name, string directory) + { + if (directory == null) + return null; + var file = Path.Combine(directory, name + ".dll"); + return File.Exists(file) ? file : null; + } + + /// Eto platform of the same version as Eto, preferring a build for this runtime from the package over one next to the project. + static string FindPlatform(string version, string etoDirectory) + { + var name = platform.AssemblyName; + var fromPackage = FindPackage(name, version, platform.GetFrameworks, platform.PackageId); + if (fromPackage != null) + return fromPackage; + + var beside = FindBeside(name, etoDirectory) ?? probeDirectories.Select(r => FindBeside(name, r)).FirstOrDefault(r => r != null); + return beside != null && GetPackageVersion(beside) == version ? beside : null; + } + + // newest first, but never newer than the runtime we're on + public static IEnumerable RuntimeFrameworks() + { + for (var major = Environment.Version.Major; major >= 5; major--) + yield return $"net{major}.0"; + yield return "netstandard2.0"; + } + + static string FindPackage(string name, string version, IEnumerable frameworks, string packageId = null) => + FindPackage(name, version, _ => frameworks, packageId); + + /// Given the lib folders in the package, returns the ones to use, best first. + static string FindPackage(string name, string version, Func, IEnumerable> getFrameworks, string packageId = null) + { + packageId = (packageId ?? name).ToLowerInvariant(); + foreach (var folder in GetPackageFolders()) + { + var lib = Path.Combine(folder, packageId, version.ToLowerInvariant(), "lib"); + if (!Directory.Exists(lib)) + continue; + var available = Directory.EnumerateDirectories(lib).Select(Path.GetFileName).ToList(); + foreach (var framework in getFrameworks(available)) + { + var file = Path.Combine(lib, framework, name + ".dll"); + if (File.Exists(file)) + return file; + } + } + return null; + } + + static IEnumerable GetPackageFolders() + { + var configured = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (!string.IsNullOrEmpty(configured)) + yield return configured; + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + } + + static string GetPackageVersion(string file) + { + var version = FileVersionInfo.GetVersionInfo(file).ProductVersion; + if (string.IsNullOrEmpty(version)) + return null; + var plus = version.IndexOf('+'); + return plus >= 0 ? version.Substring(0, plus) : version; + } + + static Assembly Resolve(AssemblyLoadContext context, AssemblyName name) + { + if (loaded.TryGetValue(name.Name, out var assembly)) + return assembly; + + var file = FindFile(name); + if (file == null) + return null; + try + { + return Load(file); + } + catch (Exception ex) when (ex is IOException || ex is BadImageFormatException || ex is UnauthorizedAccessException) + { + log?.Invoke($"Could not load {file}: {ex.Message}"); + return null; + } + } + + static string FindFile(AssemblyName name) + { + if (namedFiles.TryGetValue(name.Name, out var named)) + return named; + + var own = Paths.FirstOrDefault(r => string.Equals(Path.GetFileNameWithoutExtension(r), name.Name, StringComparison.OrdinalIgnoreCase)); + if (own != null) + return own; + + var resolved = dependencyResolver?.ResolveAssemblyToPath(name); + if (resolved != null && File.Exists(resolved)) + return resolved; + + foreach (var directory in probeDirectories) + { + foreach (var extension in new[] { ".dll", ".exe" }) + { + var candidate = Path.Combine(directory, name.Name + extension); + if (File.Exists(candidate)) + return candidate; + } + } + + var fallback = Path.Combine(FallbackDirectory, name.Name + ".dll"); + return File.Exists(fallback) ? fallback : null; + } + + static Assembly Load(string file) + { + lock (loadLock) + { + var name = AssemblyName.GetAssemblyName(file).Name; + if (loaded.TryGetValue(name, out var existing)) + return existing; + + Assembly assembly; + if (IsImmutable(file)) + assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(file); + else + { + // read into memory so the project can still be rebuilt while the preview is open + using (var stream = new MemoryStream(File.ReadAllBytes(file))) + assembly = AssemblyLoadContext.Default.LoadFromStream(stream); + watchedFiles.Add(file); + } + loaded[name] = assembly; + return assembly; + } + } + + // package and bundled copies never change, so they can be loaded in place + static bool IsImmutable(string file) => + file.StartsWith(FallbackDirectory, StringComparison.OrdinalIgnoreCase) + || GetPackageFolders().Any(r => file.StartsWith(r, StringComparison.OrdinalIgnoreCase)); + + static IntPtr ResolveUnmanaged(Assembly assembly, string name) + { + var path = dependencyResolver?.ResolveUnmanagedDllToPath(name); + return path != null ? NativeLibrary.Load(path) : IntPtr.Zero; + } + } +} diff --git a/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj b/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj index e566754..4dbfdf6 100755 --- a/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj +++ b/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj @@ -4,8 +4,8 @@ - - + + \ No newline at end of file diff --git a/src/Eto.DevExtension.VSCode.sln b/src/Eto.DevExtension.VSCode.sln index fd6b9b5..ae8d9ce 100644 --- a/src/Eto.DevExtension.VSCode.sln +++ b/src/Eto.DevExtension.VSCode.sln @@ -5,6 +5,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Eto.DevExtension.LanguageSe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Eto.Designer", "Eto.Designer\Eto.Designer.csproj", "{7FAA1841-0869-474C-9D89-87E1B4BAB190}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Eto.DevExtension.PreviewHost", "Eto.DevExtension.PreviewHost\Eto.DevExtension.PreviewHost.csproj", "{5AF057D2-FE4A-428D-A704-BCC02BFF24C3}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{F132CF00-BA1F-41E0-BFD9-0F691C47C5FE}" ProjectSection(SolutionItems) = preProject ..\Directory.Build.props = ..\Directory.Build.props @@ -24,6 +26,10 @@ Global {7FAA1841-0869-474C-9D89-87E1B4BAB190}.Debug|Any CPU.Build.0 = Debug|Any CPU {7FAA1841-0869-474C-9D89-87E1B4BAB190}.Release|Any CPU.ActiveCfg = Release|Any CPU {7FAA1841-0869-474C-9D89-87E1B4BAB190}.Release|Any CPU.Build.0 = Release|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/src/Eto.DevExtension.VSCode/.gitignore b/src/Eto.DevExtension.VSCode/.gitignore index 2115027..a101196 100644 --- a/src/Eto.DevExtension.VSCode/.gitignore +++ b/src/Eto.DevExtension.VSCode/.gitignore @@ -1,4 +1,5 @@ node_modules/ out/ server/ +preview/ *.vsix diff --git a/src/Eto.DevExtension.VSCode/.vscodeignore b/src/Eto.DevExtension.VSCode/.vscodeignore index c668793..1333960 100644 --- a/src/Eto.DevExtension.VSCode/.vscodeignore +++ b/src/Eto.DevExtension.VSCode/.vscodeignore @@ -5,3 +5,5 @@ src/** tsconfig.json .gitignore server/**/*.pdb +preview/**/*.pdb +scripts/** diff --git a/src/Eto.DevExtension.VSCode/README.md b/src/Eto.DevExtension.VSCode/README.md index 5caab6e..9bb7b91 100644 --- a/src/Eto.DevExtension.VSCode/README.md +++ b/src/Eto.DevExtension.VSCode/README.md @@ -1,9 +1,14 @@ # Eto.Forms Designer for VS Code -Autocompletion and hover documentation for [Eto.Forms](https://github.com/picoe/Eto) designer files. +Autocompletion, hover documentation and a live preview for [Eto.Forms](https://github.com/picoe/Eto) designer files. ## Features +- A live preview of `.xeto`, `.jeto` and `.eto.cs` files beside the editor. Run + **Eto: Open Preview to the Side**, or use the preview button in the editor title bar. Your own + controls show up once the project is built, and the preview redraws after each build. Drag the + corner handle to try the form at other sizes; click the size label to go back to its own size + - Completion of control names, properties, events and property values in `.xeto` (xaml) and `.jeto` (json) files - Completion of your own controls from the project and the projects it references, once built. In `.xeto` files picking one adds the `xmlns` it needs; in `.jeto` files they complete as @@ -19,6 +24,13 @@ the copy bundled with the extension is used. ## Requirements The [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer, used to run the language server. +On Windows the preview also needs the .NET Desktop Runtime 8 or newer, and on Linux it needs GTK 3. + +The preview runs your project's code in a separate process, drawing with the Eto.Forms version the +project uses. Pick the platform to draw with from the drop down at the top of the preview: WPF or +WinForms on Windows, macOS or Mac64 on macOS, and Gtk anywhere GTK 3 is installed (on macOS, from +Homebrew or MacPorts). **Auto** uses the first of these that a project in the solution references, +otherwise WPF, macOS or Gtk. ## Settings @@ -26,6 +38,7 @@ The [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer, used to ru | --- | --- | | `eto.dotnetPath` | Path to the `dotnet` executable. Uses `dotnet` from `PATH` when empty. | | `eto.languageServer.path` | Path to `Eto.DevExtension.LanguageServer.dll`. Uses the bundled copy when empty. | +| `eto.previewHost.path` | Path to `Eto.DevExtension.PreviewHost.dll`. Uses the bundled copy when empty. Not used for Eto.macOS previews. | | `eto.etoAssemblyPath` | Folder containing the `Eto.dll` to complete against. Detected from the project when empty. | | `eto.trace.server` | Logs the traffic between VS Code and the language server. | @@ -36,9 +49,13 @@ a different project, run **Eto: Restart Language Server**. ```sh npm install -npm run build:server # publishes the .NET language server into ./server +npm run build:server # publishes the .NET language server into ./server and the preview hosts into ./preview npm run compile ``` +The Eto.macOS preview host is an app bundle, so it's only built when packaging on a Mac with the .NET +macOS workload. Without it, Mac previews use Eto.Mac64. If the workload rejects your Xcode version, pass +`npm run build:server -- -p:ValidateXcodeVersion=false`. + Press F5 to launch an extension development host. `npm run package` produces a `.vsix` in `artifacts/vscode`. diff --git a/src/Eto.DevExtension.VSCode/package-lock.json b/src/Eto.DevExtension.VSCode/package-lock.json index 6fb6a12..d1b2942 100644 --- a/src/Eto.DevExtension.VSCode/package-lock.json +++ b/src/Eto.DevExtension.VSCode/package-lock.json @@ -1,14 +1,15 @@ { "name": "eto-forms-designer", - "version": "2.7.5", + "version": "2.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eto-forms-designer", - "version": "2.7.5", + "version": "2.12.0", "license": "MIT", "dependencies": { + "vscode-jsonrpc": "^8.2.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/src/Eto.DevExtension.VSCode/package.json b/src/Eto.DevExtension.VSCode/package.json index 80a32be..ba5ce8e 100644 --- a/src/Eto.DevExtension.VSCode/package.json +++ b/src/Eto.DevExtension.VSCode/package.json @@ -1,8 +1,8 @@ { "name": "eto-forms-designer", "displayName": "Eto.Forms Designer", - "description": "Autocompletion and hover documentation for Eto.Forms .xeto and .jeto designer files.", - "version": "2.7.5", + "description": "Autocompletion, hover documentation and a live preview for Eto.Forms .xeto and .jeto designer files.", + "version": "2.12.0", "publisher": "picoe", "license": "MIT", "repository": { @@ -76,6 +76,11 @@ "default": "", "description": "Path to Eto.DevExtension.LanguageServer.dll. Uses the copy bundled with the extension when empty." }, + "eto.previewHost.path": { + "type": "string", + "default": "", + "description": "Path to Eto.DevExtension.PreviewHost.dll. Uses the copy bundled with the extension when empty. Not used for Eto.macOS previews." + }, "eto.etoAssemblyPath": { "type": "string", "default": "", @@ -97,17 +102,38 @@ { "command": "eto.restartLanguageServer", "title": "Eto: Restart Language Server" + }, + { + "command": "eto.openPreview", + "title": "Eto: Open Preview to the Side", + "icon": "$(open-preview)" } - ] + ], + "menus": { + "editor/title": [ + { + "command": "eto.openPreview", + "group": "navigation", + "when": "resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" + } + ], + "commandPalette": [ + { + "command": "eto.openPreview", + "when": "resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" + } + ] + } }, "scripts": { "vscode:prepublish": "npm run build:server && npm run compile", - "build:server": "dotnet publish ../Eto.DevExtension.LanguageServer/Eto.DevExtension.LanguageServer.csproj -c Release -o server", + "build:server": "node scripts/build-server.js", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "package": "node -e \"require('fs').mkdirSync('../../artifacts/vscode',{recursive:true})\" && vsce package --out ../../artifacts/vscode/" }, "dependencies": { + "vscode-jsonrpc": "^8.2.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/src/Eto.DevExtension.VSCode/scripts/build-server.js b/src/Eto.DevExtension.VSCode/scripts/build-server.js new file mode 100644 index 0000000..18488f9 --- /dev/null +++ b/src/Eto.DevExtension.VSCode/scripts/build-server.js @@ -0,0 +1,39 @@ +// Publishes the language server to server/ and the preview hosts to preview/ for packaging. +// Extra arguments go to the Eto.macOS host build, e.g. `npm run build:server -- -p:ValidateXcodeVersion=false`. +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const src = path.resolve(root, '..'); +const artifacts = path.resolve(src, '..', 'artifacts'); +const host = path.join(src, 'Eto.DevExtension.PreviewHost', 'Eto.DevExtension.PreviewHost.csproj'); +const preview = path.join(root, 'preview'); + +function dotnet(...args) { + execFileSync('dotnet', args, { stdio: 'inherit', cwd: root }); +} + +fs.rmSync(preview, { recursive: true, force: true }); + +dotnet('publish', path.join(src, 'Eto.DevExtension.LanguageServer', 'Eto.DevExtension.LanguageServer.csproj'), '-c', 'Release', '-o', path.join(root, 'server')); +dotnet('publish', host, '-c', 'Release', '-f', 'net8.0-windows', '-o', path.join(preview, 'win')); +dotnet('publish', host, '-c', 'Release', '-f', 'net8.0', '-o', path.join(preview, 'net')); + +if (process.platform === 'darwin') { + try { + dotnet('build', path.join(src, 'Eto.DevExtension.PreviewHost.macOS', 'Eto.DevExtension.PreviewHost.macOS.csproj'), '-c', 'Release', ...process.argv.slice(2)); + const app = 'Eto Preview Host.app'; + fs.mkdirSync(path.join(preview, 'macos'), { recursive: true }); + // ditto keeps the bundle's signature intact + execFileSync('ditto', [path.join(artifacts, 'Eto.DevExtension.PreviewHost.macOS', 'Release', 'net10.0-macos', app), path.join(preview, 'macos', app)], { stdio: 'inherit' }); + } catch (e) { + // CI packages must always ship it + if (process.env.CI) + throw e; + // usually a missing macOS workload or mismatched Xcode, which shouldn't stop the rest from packaging + console.warn('Could not build the Eto.macOS preview host, so Mac previews will use Eto.Mac64.'); + } +} else { + console.log('Skipping the Eto.macOS preview host, which only builds on macOS. Mac previews will use Eto.Mac64.'); +} diff --git a/src/Eto.DevExtension.VSCode/src/extension.ts b/src/Eto.DevExtension.VSCode/src/extension.ts index a0ae5c3..d402929 100644 --- a/src/Eto.DevExtension.VSCode/src/extension.ts +++ b/src/Eto.DevExtension.VSCode/src/extension.ts @@ -7,18 +7,26 @@ import { ServerOptions, TransportKind } from 'vscode-languageclient/node'; +import { AUTO, HostLauncher } from './hostLaunch'; +import { isPreviewable, PlatformPicker, PreviewPanel } from './preview'; +import { PreviewHost } from './previewHost'; const SERVER_DLL = 'Eto.DevExtension.LanguageServer.dll'; +const PLATFORM_KEY = 'eto.preview.platform'; let client: LanguageClient | undefined; let output: vscode.OutputChannel; +let previewHost: PreviewHost | undefined; +let platformPicker: PlatformPicker | undefined; export async function activate(context: vscode.ExtensionContext): Promise { output = vscode.window.createOutputChannel('Eto.Forms Designer'); context.subscriptions.push(output); context.subscriptions.push( - vscode.commands.registerCommand('eto.restartLanguageServer', () => restart(context)) + vscode.commands.registerCommand('eto.restartLanguageServer', () => restart(context)), + vscode.commands.registerCommand('eto.openPreview', (uri?: vscode.Uri) => openPreview(context, uri)), + { dispose: () => previewHost?.dispose() } ); // the server pins one Eto version per session, so these need a fresh process @@ -93,12 +101,40 @@ async function start(context: vscode.ExtensionContext): Promise { } } +async function openPreview(context: vscode.ExtensionContext, uri?: vscode.Uri): Promise { + const document = uri + ? await vscode.workspace.openTextDocument(uri) + : vscode.window.activeTextEditor?.document; + if (!document || !isPreviewable(document)) { + vscode.window.showInformationMessage('Open a .xeto, .jeto or .eto.cs file to preview it.'); + return; + } + + if (!previewHost || !platformPicker) { + const launcher = new HostLauncher(context.extensionPath, output); + const picker: PlatformPicker = platformPicker = { + getPlatforms: () => launcher.getPlatforms(), + get: () => context.workspaceState.get(PLATFORM_KEY, AUTO), + set: id => context.workspaceState.update(PLATFORM_KEY, id) + }; + previewHost = new PreviewHost(fileName => launcher.resolve(fileName, picker.get()), output); + } + PreviewPanel.show(previewHost, platformPicker, document); +} + function resolveServerPath(context: vscode.ExtensionContext, configured?: string): string | undefined { + return resolvePath(context, configured, 'server', SERVER_DLL, path.join('Eto.DevExtension.LanguageServer', 'Debug', 'net8.0')); +} + +/** + * @param folder Where the dll is bundled in the extension. + * @param buildFolder Where a debug build puts it under artifacts, for running out of the source tree. + */ +function resolvePath(context: vscode.ExtensionContext, configured: string | undefined, folder: string, dll: string, buildFolder: string): string | undefined { const candidates = [ configured?.trim(), - path.join(context.extensionPath, 'server', SERVER_DLL), - // running out of the source tree during development - path.join(context.extensionPath, '..', '..', 'artifacts', 'Eto.DevExtension.LanguageServer', 'Debug', 'net8.0', SERVER_DLL) + path.join(context.extensionPath, folder, dll), + path.join(context.extensionPath, '..', '..', 'artifacts', buildFolder, dll) ]; for (const candidate of candidates) { diff --git a/src/Eto.DevExtension.VSCode/src/hostLaunch.ts b/src/Eto.DevExtension.VSCode/src/hostLaunch.ts new file mode 100644 index 0000000..c20b20d --- /dev/null +++ b/src/Eto.DevExtension.VSCode/src/hostLaunch.ts @@ -0,0 +1,216 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const HOST_DLL = 'Eto.DevExtension.PreviewHost.dll'; +const MAC_APP = 'Eto Preview Host.app'; +const MAC_EXECUTABLE = path.join(MAC_APP, 'Contents', 'MacOS', 'Eto.DevExtension.PreviewHost'); + +// scanning projects on every keystroke is too slow, but an edited project should still be noticed +const CACHE_MS = 10000; + +export const AUTO = 'auto'; + +/** A platform the preview can draw with. */ +export interface PlatformOption { + id: string; + label: string; +} + +/** How to start a preview host process. */ +export interface HostLaunch { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + /** Label of the platform it draws with. */ + platform: string; + /** Shown when the process can't be started. */ + requirement: string; +} + +interface Platform extends PlatformOption { + /** Matches a project that uses this platform, from a package or project reference. */ + reference: RegExp; + launch(): HostLaunch | undefined; +} + +/** + * Picks the preview host for a document. The platforms offered depend on the OS and what's installed, + * and Auto picks the first one the solution references, otherwise the OS's usual one. + */ +export class HostLauncher { + private readonly autoCache = new Map(); + private lastMessage: string | undefined; + + constructor(private readonly extensionPath: string, private readonly output: vscode.OutputChannel) { } + + /** Platforms available on this machine, best first. */ + getPlatforms(): PlatformOption[] { + return this.getAvailable().map(r => ({ id: r.id, label: r.label })); + } + + /** @param choice A platform id, or {@link AUTO}. */ + async resolve(fileName: string, choice: string): Promise { + const platforms = this.getAvailable(); + const platform = platforms.find(r => r.id === choice) ?? await this.detect(fileName, platforms); + return platform?.launch(); + } + + private getAvailable(): Platform[] { + const config = vscode.workspace.getConfiguration('eto'); + const dotnet = config.get('dotnetPath')?.trim() || 'dotnet'; + const configured = config.get('previewHost.path')?.trim(); + const netHost = () => this.find(configured, path.join('preview', 'net', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0', HOST_DLL)); + const platforms: Platform[] = []; + + if (process.platform === 'win32') { + const windowsHost = () => this.find(configured, path.join('preview', 'win', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0-windows', HOST_DLL)); + const requirement = 'The preview needs the .NET 8 Desktop Runtime (or newer).'; + platforms.push( + { id: 'Wpf', label: 'WPF', reference: /Eto\.Platform\.Wpf\b|Eto\.Wpf\.csproj/i, launch: () => withDotnet(dotnet, windowsHost(), 'Wpf', 'WPF', requirement) }, + { id: 'WinForms', label: 'WinForms', reference: /Eto\.Platform\.Windows\b|Eto\.WinForms\.csproj/i, launch: () => withDotnet(dotnet, windowsHost(), 'WinForms', 'WinForms', requirement) } + ); + } + if (process.platform === 'darwin') { + const app = this.find(undefined, path.join('preview', 'macos', MAC_EXECUTABLE), path.join('Eto.DevExtension.PreviewHost.macOS', 'Debug', 'net10.0-macos', MAC_EXECUTABLE)); + if (app) { + platforms.push({ + id: 'macOS', label: 'macOS', reference: /Eto\.Platform\.macOS\b|Eto\.macOS\.csproj/i, + launch: () => ({ command: app, args: [], platform: 'macOS', requirement: 'The Eto.macOS preview host could not be started.' }) + }); + } + platforms.push({ + id: 'Mac64', label: 'Mac64', reference: /Eto\.Platform\.Mac64\b|Eto\.Mac64\.csproj/i, + launch: () => withDotnet(dotnet, netHost(), 'Mac64', 'Mac64', 'The preview needs the .NET 8 runtime (or newer).') + }); + } + + const gtk = findGtk(); + if (gtk) { + platforms.push({ + id: 'Gtk', label: 'Gtk', reference: /Eto\.Platform\.Gtk\b|Eto\.Gtk\.csproj/i, + launch: () => { + const launch = withDotnet(dotnet, netHost(), 'Gtk', 'Gtk', 'The preview needs the .NET 8 runtime (or newer) and GTK 3.'); + return launch && { ...launch, env: gtk.env }; + } + }); + } + return platforms; + } + + private async detect(fileName: string, platforms: Platform[]): Promise { + const key = path.dirname(fileName); + const cached = this.autoCache.get(key); + if (cached && Date.now() - cached.time < CACHE_MS && platforms.some(r => r.id === cached.platform.id)) { + return cached.platform; + } + if (platforms.length === 0) { + return undefined; + } + + const texts = (findSolutionProjects(fileName) ?? await findWorkspaceProjects()).map(readText); + const referenced = platforms.find(platform => texts.some(text => platform.reference.test(text))); + const platform = referenced ?? platforms[0]; + this.autoCache.set(key, { time: Date.now(), platform }); + this.log(referenced + ? `Previewing with ${platform.label}, as the solution uses it.` + : `Previewing with ${platform.label}, as the solution doesn't reference an Eto platform available here.`); + return platform; + } + + /** + * @param bundled Where the host is in the packaged extension. + * @param build Where a debug build puts it under artifacts, for running out of the source tree. + */ + private find(configured: string | undefined, bundled: string, build: string): string | undefined { + const candidates = [ + configured, + path.join(this.extensionPath, bundled), + path.join(this.extensionPath, '..', '..', 'artifacts', build) + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const resolved = path.isAbsolute(candidate) ? candidate : path.join(this.extensionPath, candidate); + if (fs.existsSync(resolved)) { + return resolved; + } + } + return undefined; + } + + private log(message: string): void { + if (message !== this.lastMessage) { + this.lastMessage = message; + this.output.appendLine(message); + } + } +} + +function withDotnet(dotnet: string, dll: string | undefined, id: string, label: string, requirement: string): HostLaunch | undefined { + return dll ? { command: dotnet, args: [dll, '--platform', id], platform: label, requirement } : undefined; +} + +/** GTK 3, and the environment the host needs to find it, or undefined when it isn't installed. */ +function findGtk(): { env?: NodeJS.ProcessEnv } | undefined { + if (process.platform === 'darwin') { + // Homebrew (Apple silicon, then Intel) or MacPorts, none of which are on the default library path + const dir = ['/opt/homebrew/lib', '/usr/local/lib', '/opt/local/lib'].find(r => fs.existsSync(path.join(r, 'libgtk-3.0.dylib'))); + return dir ? { env: { ...process.env, DYLD_FALLBACK_LIBRARY_PATH: joinPath(dir, process.env.DYLD_FALLBACK_LIBRARY_PATH) } } : undefined; + } + if (process.platform === 'win32') { + // where GtkSharp's build installs it, or anywhere on PATH such as MSYS2 + const installed = process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Gtk', '3.24.24') : undefined; + const dir = [installed, ...(process.env.PATH ?? '').split(path.delimiter)].find(r => r && fs.existsSync(path.join(r, 'libgtk-3-0.dll'))); + return dir ? { env: { ...process.env, PATH: joinPath(dir, process.env.PATH) } } : undefined; + } + // the desktop's own toolkit, so assume it's there + return {}; +} + +function joinPath(first: string, rest: string | undefined): string { + return rest ? `${first}${path.delimiter}${rest}` : first; +} + +/** Projects in the nearest solution above the file, or undefined when there's none. */ +function findSolutionProjects(fileName: string): string[] | undefined { + let dir = path.dirname(fileName); + for (; ;) { + const solution = readDir(dir).find(r => /\.slnx?$/i.test(r)); + if (solution) { + const file = path.join(dir, solution); + const pattern = solution.toLowerCase().endsWith('.slnx') + ? / path.resolve(dir, r[1].replace(/\\/g, path.sep))); + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +async function findWorkspaceProjects(): Promise { + const files = await vscode.workspace.findFiles('**/*.{csproj,vbproj,fsproj}', '**/{bin,obj,node_modules}/**', 500); + return files.map(r => r.fsPath); +} + +function readDir(dir: string): string[] { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +function readText(file: string): string { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return ''; + } +} diff --git a/src/Eto.DevExtension.VSCode/src/preview.ts b/src/Eto.DevExtension.VSCode/src/preview.ts new file mode 100644 index 0000000..f89aa94 --- /dev/null +++ b/src/Eto.DevExtension.VSCode/src/preview.ts @@ -0,0 +1,287 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AUTO, PlatformOption } from './hostLaunch'; +import { PreviewHost, RenderResult } from './previewHost'; + +const REFRESH_DELAY_MS = 500; + +/** Designer files the preview can draw. */ +export function isPreviewable(document: vscode.TextDocument): boolean { + return document.uri.scheme === 'file' && /\.(xeto|jeto|eto\.cs|eto\.vb)$/i.test(document.fileName); +} + +/** The platform chosen in the preview's drop down. */ +export interface PlatformPicker { + getPlatforms(): PlatformOption[]; + /** A platform id, or {@link AUTO}. */ + get(): string; + set(id: string): Thenable; +} + +/** + * A single preview beside the editor that follows the active designer file and redraws as it is edited. + */ +export class PreviewPanel implements vscode.Disposable { + private static current: PreviewPanel | undefined; + + private readonly panel: vscode.WebviewPanel; + private readonly disposables: vscode.Disposable[] = []; + private document: vscode.TextDocument | undefined; + private size: { width: number; height: number } | undefined; + private scale = 1; + private ready = false; + private rendering = false; + private renderPending = false; + private timer: NodeJS.Timeout | undefined; + + static show(host: PreviewHost, picker: PlatformPicker, document: vscode.TextDocument): void { + if (PreviewPanel.current) { + PreviewPanel.current.panel.reveal(vscode.ViewColumn.Beside, true); + } else { + PreviewPanel.current = new PreviewPanel(host, picker); + } + PreviewPanel.current.setDocument(document); + } + + private constructor(private readonly host: PreviewHost, private readonly picker: PlatformPicker) { + this.panel = vscode.window.createWebviewPanel('eto.preview', 'Eto Preview', { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { + enableScripts: true, + localResourceRoots: [] + }); + this.panel.webview.html = getHtml(); + + this.disposables.push( + this.panel.onDidDispose(() => this.dispose()), + this.panel.webview.onDidReceiveMessage(message => this.onMessage(message)), + vscode.workspace.onDidChangeTextDocument(e => { + if (e.document === this.document) { + this.schedule(); + } + }), + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor && isPreviewable(editor.document) && editor.document !== this.document) { + this.setDocument(editor.document); + } + }), + host.onDidRequestRedraw(() => this.render()) + ); + } + + dispose(): void { + PreviewPanel.current = undefined; + clearTimeout(this.timer); + this.panel.dispose(); + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private setDocument(document: vscode.TextDocument): void { + this.document = document; + // a size picked for one file rarely suits the next + this.size = undefined; + this.panel.title = `Preview ${path.basename(document.fileName)}`; + this.render(); + } + + private onMessage(message: { type: string; scale?: number; width?: number; height?: number; id?: string }): void { + switch (message.type) { + case 'ready': { + this.ready = true; + this.scale = message.scale || 1; + const options = [{ id: AUTO, label: 'Auto' }, ...this.picker.getPlatforms()]; + const selected = options.some(r => r.id === this.picker.get()) ? this.picker.get() : AUTO; + this.panel.webview.postMessage({ type: 'platforms', options, selected }); + this.render(); + break; + } + case 'platform': + this.picker.set(message.id || AUTO).then(() => this.render()); + break; + case 'scale': + this.scale = message.scale || 1; + this.render(); + break; + case 'resize': + this.size = { width: Math.round(message.width!), height: Math.round(message.height!) }; + this.render(); + break; + case 'reset': + this.size = undefined; + this.render(); + break; + } + } + + private schedule(): void { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.render(), REFRESH_DELAY_MS); + } + + private async render(): Promise { + if (!this.ready || !this.document) { + return; + } + if (this.rendering) { + this.renderPending = true; + return; + } + + this.rendering = true; + try { + do { + this.renderPending = false; + const document: vscode.TextDocument = this.document; + const result: RenderResult = await this.host.render({ + fileName: document.fileName, + text: document.getText(), + width: this.size?.width, + height: this.size?.height, + scale: this.scale + }); + if (PreviewPanel.current !== this) { + return; + } + // results for a file the user already moved away from would only flicker + if (document === this.document) { + this.panel.webview.postMessage(result.error + ? { type: 'error', message: result.error.message, details: result.error.details, platform: result.platform } + : { type: 'image', image: result.image, width: result.width, height: result.height, sized: !!this.size, platform: result.platform }); + } + } while (this.renderPending); + } finally { + this.rendering = false; + } + } +} + +function getHtml(): string { + const nonce = [...Array(32)].map(() => Math.floor(Math.random() * 36).toString(36)).join(''); + return /* html */ ` + + + + + + + + +
+
+
Drawing preview…
+
+
+
+ + +`; +} diff --git a/src/Eto.DevExtension.VSCode/src/previewHost.ts b/src/Eto.DevExtension.VSCode/src/previewHost.ts new file mode 100644 index 0000000..5b0d24f --- /dev/null +++ b/src/Eto.DevExtension.VSCode/src/previewHost.ts @@ -0,0 +1,164 @@ +import { ChildProcess, spawn } from 'child_process'; +import * as vscode from 'vscode'; +import { + createMessageConnection, + MessageConnection, + StreamMessageReader, + StreamMessageWriter +} from 'vscode-jsonrpc/node'; +import { HostLaunch } from './hostLaunch'; + +// long enough for a first render that compiles code, short enough to recover from a hung control +const RENDER_TIMEOUT_MS = 30000; + +export interface RenderRequest { + fileName: string; + text: string; + width?: number; + height?: number; + scale: number; +} + +export interface RenderResult { + image?: string; + width?: number; + height?: number; + error?: { message: string; details?: string }; + /** Label of the platform it was drawn with. */ + platform?: string; +} + +/** + * The .NET process that draws designer files to images, so project code runs outside VS Code. + * Renders one request at a time, and restarts the process whenever it exits or stops responding. + */ +export class PreviewHost implements vscode.Disposable { + private process: ChildProcess | undefined; + private connection: MessageConnection | undefined; + private starting: Promise | undefined; + private queue: Promise = Promise.resolve(); + private launchKey: string | undefined; + // hosts that couldn't start, usually for want of a runtime, so they aren't tried again + private readonly unavailable = new Set(); + private readonly restarted = new vscode.EventEmitter(); + + /** Fires after the project is rebuilt, so previews can be redrawn. */ + readonly onDidRequestRedraw = this.restarted.event; + + constructor( + private readonly resolveLaunch: (fileName: string) => Promise, + private readonly output: vscode.OutputChannel + ) { } + + render(request: RenderRequest): Promise { + const next = this.queue.then(() => this.renderNow(request)); + this.queue = next.catch(() => undefined); + return next; + } + + dispose(): void { + this.stop(); + this.restarted.dispose(); + } + + private async renderNow(request: RenderRequest): Promise { + // a second try covers a host that exited after a rebuild, or that serves another project + for (let attempt = 0; attempt < 2; attempt++) { + const launch = await this.resolveLaunch(request.fileName); + if (!launch) { + return error('Could not find the Eto preview host. Set "eto.previewHost.path" to point at it.'); + } + const connection = await this.getConnection(launch); + if (!connection) { + return error(`${launch.requirement} See the Eto.Forms Designer output for details.`); + } + + let timer: NodeJS.Timeout | undefined; + try { + const timeout = new Promise<'timeout'>(resolve => timer = setTimeout(() => resolve('timeout'), RENDER_TIMEOUT_MS)); + const result = await Promise.race([ + connection.sendRequest('preview/render', { ...request, assemblies: null }), + timeout + ]); + if (result === 'timeout') { + this.stop(); + return error('The preview took too long to draw, so it was stopped.'); + } + if (result?.restartRequired) { + this.stop(); + continue; + } + return { ...result, platform: launch.platform }; + } catch (e) { + this.stop(); + if (attempt > 0) { + return error('The preview host stopped unexpectedly.', `${e}`); + } + } finally { + clearTimeout(timer); + } + } + return error('The preview host could not load the project.'); + } + + private getConnection(launch: HostLaunch): Promise { + const key = JSON.stringify([launch.command, ...launch.args]); + if (this.launchKey === key && this.connection && this.process && isRunning(this.process)) { + return Promise.resolve(this.connection); + } + if (this.unavailable.has(key)) { + return Promise.resolve(undefined); + } + this.stop(); + this.launchKey = key; + this.starting ??= this.start(launch, key).finally(() => this.starting = undefined); + return this.starting; + } + + private async start(launch: HostLaunch, key: string): Promise { + try { + const child = spawn(launch.command, launch.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: launch.env }); + const failed = new Promise((_, reject) => child.once('error', reject)); + child.stderr?.on('data', data => this.output.append(`${data}`)); + + const connection = createMessageConnection(new StreamMessageReader(child.stdout!), new StreamMessageWriter(child.stdin!)); + connection.onNotification('preview/restart', () => { + this.output.appendLine('Project rebuilt, restarting the preview host.'); + this.restarted.fire(); + }); + connection.onNotification('window/logMessage', (params: { message: string }) => this.output.appendLine(params.message)); + connection.listen(); + + this.process = child; + this.connection = connection; + await Promise.race([connection.sendRequest('initialize', { processId: process.pid }), failed]); + connection.sendNotification('initialized', {}); + return connection; + } catch (e) { + this.unavailable.add(key); + this.output.appendLine(`Could not start the Eto preview host using "${launch.command}": ${e}`); + this.stop(); + return undefined; + } + } + + private stop(): void { + const connection = this.connection; + const child = this.process; + this.connection = undefined; + this.process = undefined; + this.launchKey = undefined; + connection?.dispose(); + if (child && isRunning(child)) { + child.kill(); + } + } +} + +function isRunning(child: ChildProcess): boolean { + return child.exitCode === null && child.signalCode === null; +} + +function error(message: string, details?: string): RenderResult { + return { error: { message, details: details ?? message } }; +} diff --git a/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest b/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest index c1a35d9..124f30f 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest +++ b/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest @@ -3,7 +3,7 @@ xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> - + Eto.Forms Templates and Previewer Eto.Forms Support for Visual Studio. Eto.Forms is a cross platform GUI framework for desktop and mobile applications in .NET that can target Wpf, WinForms, Direct2D, MonoMac, Xamarin.Mac, Gtk2, and Gtk3 with a single codebase. https://github.com/picoe/Eto diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs index 3c0f488..a031d54 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs @@ -212,12 +212,10 @@ public int CreateEditorInstance( try { - var outputFile = GetAssemblyPath(proj); - var references = GetReferences(proj).ToList(); - //var outputDir = Path.GetDirectoryName(outputFile); + var projectKey = proj?.FullName ?? pszMkDocument; // Create the Document (editor) - var editor = new EtoPreviewPane(editorPackage, pszMkDocument, textBuffer, outputFile, references, codeEditor); + var editor = new EtoPreviewPane(editorPackage, pszMkDocument, textBuffer, projectKey, codeEditor); ppunkDocView = Marshal.GetIUnknownForObject(editor); } catch (Exception ex) @@ -259,35 +257,6 @@ public IVsProject ToVsProject(EnvDTE.Project project) return vsProject; } - public static IEnumerable GetReferences(EnvDTE.Project project) - { - Microsoft.VisualStudio.Shell.ThreadHelper.ThrowIfNotOnUIThread(); - var vsproject = project.Object as VSLangProj.VSProject; - if (vsproject == null) - yield break; - // note: you could also try casting to VsWebSite.VSWebSite - - foreach (VSLangProj.Reference reference in vsproject.References) - { - if (reference.SourceProject == null) - { - //skip framework assemblies - //if (((dynamic)reference).AutoReferenced) - // continue; - - // This is an assembly reference - if (reference.Path != null) - yield return reference.Path; - } - else - { - // This is a project reference - var path = GetAssemblyPath(reference.SourceProject); - if (path != null) - yield return path; - } - } - } internal static string GetAssemblyPath(EnvDTE.Project vsProject) { Microsoft.VisualStudio.Shell.ThreadHelper.ThrowIfNotOnUIThread(); diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs deleted file mode 100755 index c641e9a..0000000 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using Eto.Forms; -using Eto.Designer; -using Eto.Wpf.Forms; -using System.Windows; -using System.AddIn.Pipeline; -using System.AddIn.Contract; -using System.Collections.Generic; -using Eto.Wpf.Forms.Controls; -using sw = System.Windows; -using swc = System.Windows.Controls; -using System.Windows.Threading; -using Eto; -using Eto.DevExtension.VisualStudio.Windows.Editor; - -[assembly: ExportHandler(typeof(IEtoAdapterHandler), typeof(EtoAdapterHandler))] - -namespace Eto.DevExtension.VisualStudio.Windows.Editor -{ - class ControlContract : MarshalByRefObject - { - public IntPtr Handle { get; set; } - } - - class NativeControl : WpfFrameworkElement - { - public NativeControl(FrameworkElement control) - { - Control = control; - } - - public override Drawing.Color BackgroundColor - { - get { return Drawing.Colors.Transparent; } - set - { - } - } - } - - public class EtoAdapterHandler : IEtoAdapterHandler - { - public object ToContract(Control control) - { - var content = DesignPanel.GetContent(control); - var view = content.ToNative(true); - return FrameworkElementAdapters.ViewToContractAdapter(view); - } - - public Control FromContract(object contract) - { - var view = FrameworkElementAdapters.ContractToViewAdapter((INativeHandleContract)contract); - view.Focusable = false; // otherwise editor loses focus when switching back to its tab.. may be a better way around this. - return new Control(new NativeControl(view)); - } - - public void Unload() - { - Dispatcher.CurrentDispatcher?.InvokeShutdown(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } -} - diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs index 8fa8cf9..4c2bd39 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs @@ -31,6 +31,7 @@ using System.Windows.Media; using IOleServiceProvider = Microsoft.VisualStudio.OLE.Interop.IServiceProvider; using System.Collections.Generic; +using Eto.DevExtension.VisualStudio.Intellisense; namespace Eto.DevExtension.VisualStudio.Windows.Editor { @@ -48,6 +49,7 @@ public sealed class EtoPreviewPane : Microsoft.VisualStudio.Shell.WindowPane, EtoAddinPackage package; PreviewEditorView preview; PreviewEditorViewSplitter previewSplitter; + PreviewHostClient previewHost; Panel editorControl; uint dataEventsCookie; uint linesEventsCookie; @@ -100,7 +102,8 @@ void RegisterIndependentView(bool subscribe) /// our initialization functions. /// /// Our Package instance. - public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines textBuffer, string mainAssembly, IEnumerable references, CodeEditorHost codeEditor) + /// Identifies the project, so its designers share one preview host. + public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines textBuffer, string projectKey, CodeEditorHost codeEditor) : base(package) { this.editor = codeEditor; @@ -111,7 +114,11 @@ public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines tex editorControl = new Panel(); editorControl.Content = editor.wpfElement.ToEto(); - previewSplitter = new PreviewEditorViewSplitter(editorControl, mainAssembly, references, () => textBuffer?.GetText()); + var host = previewHost = PreviewHostClient.Acquire(projectKey); + host.ProjectChanged += PreviewHost_ProjectChanged; + var designHost = new RemoteDesignPanel(async request => await host.RenderAsync(request, await ProjectAssemblyPaths.GetAsync(fileName))); + + previewSplitter = new PreviewEditorViewSplitter(editorControl, designHost, () => textBuffer?.GetText()); previewSplitter.GotFocus += (sender, e) => { WpfTextView?.VisualElement?.Focus(); @@ -257,7 +264,14 @@ protected override void Dispose(bool disposing) if (disposing) { - RegisterIndependentView(false); + RegisterIndependentView(false); + + if (previewHost != null) + { + previewHost.ProjectChanged -= PreviewHost_ProjectChanged; + previewHost.Release(); + previewHost = null; + } //previewSplitter?.Dispose(); //previewSplitter = null; @@ -306,6 +320,8 @@ void ViewCode() #endregion + void PreviewHost_ProjectChanged(object sender, EventArgs e) => preview?.Update(); + void IVsTextBufferDataEvents.OnFileChanged(uint grfChange, uint dwFileAttrs) { preview.Update(); diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs new file mode 100644 index 0000000..1c0b93f --- /dev/null +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs @@ -0,0 +1,228 @@ +using Eto.Designer; +using Eto.Drawing; +using Newtonsoft.Json.Linq; +using StreamJsonRpc; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Eto.DevExtension.VisualStudio.Windows.Editor +{ + /// + /// The .NET preview host that draws designer files, shared by the open designers of one project. + /// + /// + /// Previews are drawn in a separate process so the project's own code never loads into Visual Studio. + /// + sealed class PreviewHostClient + { + const string HostDll = "Eto.DevExtension.PreviewHost.dll"; + // long enough for a first render that compiles code, short enough to recover from a hung control + static readonly TimeSpan RenderTimeout = TimeSpan.FromSeconds(30); + + static readonly Dictionary hosts = new Dictionary(StringComparer.OrdinalIgnoreCase); + static bool unavailable; + + readonly string key; + readonly SemaphoreSlim requestLock = new SemaphoreSlim(1, 1); + int references; + Process process; + JsonRpc rpc; + + /// Raised on the UI thread after the project is rebuilt, so previews can be redrawn. + public event EventHandler ProjectChanged; + + PreviewHostClient(string key) => this.key = key; + + bool IsRunning => rpc != null && process != null && !process.HasExited; + + /// Identifies the project, so its designers share one host. + public static PreviewHostClient Acquire(string key) + { + key = key ?? string.Empty; + lock (hosts) + { + if (!hosts.TryGetValue(key, out var host)) + hosts[key] = host = new PreviewHostClient(key); + host.references++; + return host; + } + } + + public void Release() + { + lock (hosts) + { + if (--references > 0) + return; + hosts.Remove(key); + } + Stop(); + } + + /// Project assembly files, the project's own first, or null to let the host find them. + public async Task RenderAsync(PreviewRenderRequest request, IList assemblies) + { + await requestLock.WaitAsync(); + try + { + // a second try covers a host that exited after a rebuild, or that serves an older build + for (var attempt = 0; attempt < 2; attempt++) + { + var server = await GetServerAsync(); + if (server == null) + return Error("The preview needs the .NET 8 Desktop Runtime (or newer) to be installed.", null); + + try + { + using (var timeout = new CancellationTokenSource(RenderTimeout)) + { + var result = await server.InvokeWithParameterObjectAsync( + "preview/render", + new + { + fileName = request.FileName, + text = request.Text, + assemblies, + width = request.Size?.Width, + height = request.Size?.Height, + scale = request.Scale + }, + timeout.Token); + + if ((bool?)result?["restartRequired"] == true) + { + Stop(); + continue; + } + return Read(result); + } + } + catch (OperationCanceledException) + { + Stop(); + return Error("The preview took too long to draw, so it was stopped.", null); + } + catch (Exception ex) when (ex is ConnectionLostException || ex is ObjectDisposedException || ex is IOException) + { + Stop(); + if (attempt > 0) + return Error("The preview host stopped unexpectedly.", ex.ToString()); + } + } + return Error("The preview host could not load the project.", null); + } + finally + { + requestLock.Release(); + } + } + + static PreviewRenderResult Read(JToken result) + { + var error = result?["error"]; + if (error != null && error.Type == JTokenType.Object) + return Error((string)error["message"], (string)error["details"]); + + var image = (string)result?["image"]; + return new PreviewRenderResult + { + Image = string.IsNullOrEmpty(image) ? null : Convert.FromBase64String(image), + Size = new Size((int?)result?["width"] ?? 0, (int?)result?["height"] ?? 0) + }; + } + + static PreviewRenderResult Error(string message, string details) => + new PreviewRenderResult { Error = new DesignError { Message = message, Details = details ?? message } }; + + async Task GetServerAsync() + { + if (IsRunning) + return rpc; + if (unavailable) + return null; + + // clean up after a host that exited, such as after a rebuild + Stop(); + + var hostPath = Path.Combine(Path.GetDirectoryName(typeof(PreviewHostClient).Assembly.Location), "preview", HostDll); + if (!File.Exists(hostPath)) + { + unavailable = true; + Debug.WriteLine($"Eto preview host not found at {hostPath}"); + return null; + } + + try + { + var start = new ProcessStartInfo("dotnet", "\"" + hostPath + "\"") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + process = Process.Start(start); + process.ErrorDataReceived += (sender, e) => Debug.WriteLine(e.Data); + process.BeginErrorReadLine(); + + rpc = new JsonRpc(new HeaderDelimitedMessageHandler(process.StandardInput.BaseStream, process.StandardOutput.BaseStream)); + rpc.AddLocalRpcMethod("preview/restart", new Action(OnRestart)); + rpc.AddLocalRpcMethod("window/logMessage", new Action((type, message) => Debug.WriteLine($"Eto preview: {message}"))); + rpc.StartListening(); + await rpc.InvokeWithParameterObjectAsync("initialize", new { processId = Process.GetCurrentProcess().Id, theme = GetTheme() }); + await rpc.NotifyWithParameterObjectAsync("initialized", new { }); + return rpc; + } + catch (Exception ex) + { + // usually no desktop runtime to run it with, so don't keep trying + unavailable = true; + Debug.WriteLine($"Could not start the Eto preview host: {ex}"); + Stop(); + return null; + } + } + + void OnRestart() + { + // the host exits right after this, and the next render starts a fresh one + Eto.Forms.Application.Instance.AsyncInvoke(() => ProjectChanged?.Invoke(this, EventArgs.Empty)); + } + + static object GetTheme() + { + var theme = Global.Theme; + return new + { + designerPanel = theme.DesignerPanel.ToHex(), + designerBackground = theme.DesignerBackground.ToHex(), + projectBackground = theme.ProjectBackground.ToHex(), + projectForeground = theme.ProjectForeground.ToHex() + }; + } + + void Stop() + { + var oldRpc = rpc; + var oldProcess = process; + rpc = null; + process = null; + try + { + oldRpc?.Dispose(); + if (oldProcess != null && !oldProcess.HasExited) + oldProcess.Kill(); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not stop the Eto preview host: {ex.Message}"); + } + oldProcess?.Dispose(); + } + } +} diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj index ae16b09..d83579a 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj +++ b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj @@ -84,8 +84,8 @@ - + @@ -184,8 +184,6 @@ - - @@ -211,11 +209,11 @@ --> - - - - - + + + + + @@ -282,6 +280,18 @@ + + + $(MSBuildProjectDirectory)\..\Eto.DevExtension.PreviewHost\Eto.DevExtension.PreviewHost.csproj + $(ArtifactsDir)Eto.DevExtension.PreviewHost\publish\$(Configuration)\ + + + + + + + + diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs b/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs index e04d3d4..87b78d0 100644 --- a/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs @@ -10,7 +10,7 @@ [assembly: AssemblyTitle("Eto.VisualStudio")] [assembly: AssemblyDescription("")] -[assembly: AssemblyVersion("2.7.5.0")] +[assembly: AssemblyVersion("2.12.0.0")] // Setting ComVisible to false makes the types in this assembly not visible @@ -21,6 +21,6 @@ [assembly: ProvideCodeBase( AssemblyName = "Eto.DevExtension.VisualStudio.Windows", - Version = "2.7.5.0", + Version = "2.12.0.0", CodeBase = "$PackageFolder$\\Eto.DevExtension.VisualStudio.Windows.dll")] diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate index 26fc4bf..3d61f5e 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate @@ -35,7 +35,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate index 6c10728..b6ad1ac 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate @@ -35,7 +35,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate index 63c1e3c..141ea1a 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate @@ -35,7 +35,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate index 23ee50f..99d3cb7 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate @@ -24,7 +24,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate index 520fe35..ee5f3c2 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate @@ -24,7 +24,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate index 6415732..bcce098 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate @@ -24,7 +24,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs b/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs index 4c7401a..01c7961 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs @@ -34,8 +34,6 @@ public static void Initialize() if (Application.Instance == null) new Eto.Forms.Application().Attach(); - - Eto.Designer.Builders.BaseCompiledInterfaceBuilder.InitializeAssembly = typeof(EtoInitializer).Assembly.FullName; } private static void ThemeWindow(System.Windows.Window w)