diff --git a/.idea/gradle.xml b/.idea/gradle.xml index ce1c62c7..776aeac3 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -8,6 +8,7 @@ diff --git a/.run/Generate Protocol.run.xml b/.run/Generate Protocol.run.xml new file mode 100644 index 00000000..9e540dbe --- /dev/null +++ b/.run/Generate Protocol.run.xml @@ -0,0 +1,21 @@ + + + + + + + true + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 0a9edc42..f3f23794 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,11 +9,15 @@ plugins { alias(libs.plugins.gradleIntelliJPlugin) // Gradle IntelliJ Plugin alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin + alias(libs.plugins.rdgen) } group = properties("pluginGroup").get() version = properties("pluginVersion").get() +val rdLibDirectory: () -> File = { file("${tasks.setupDependencies.get().idea.get().classes}/lib/rd") } +extra["rdLibDirectory"] = rdLibDirectory + // Configure project's dependencies repositories { mavenCentral() @@ -63,22 +67,64 @@ tasks { gradleVersion = properties("gradleVersion").get() } + configure { + val modelDir = projectDir.resolve("protocol/src/main/kotlin/model/sessionHost") + val pluginSourcePath = projectDir.resolve("src") + val ktOutput = pluginSourcePath.resolve("main/kotlin/com/github/rafaelldi/aspireplugin/generated") + val csOutput = pluginSourcePath.resolve("dotnet/aspire-session-host/Generated") + + verbose = true + classpath({ + rdLibDirectory().resolve("rider-model.jar").canonicalPath + }) + sources(modelDir) + hashFolder = "$rootDir/build/rdgen/rider" + packages = "model.sessionHost" + + generator { + language = "kotlin" + transform = "asis" + root = "model.sessionHost.AspireSessionHostRoot" + directory = ktOutput.canonicalPath + } + + generator { + language = "csharp" + transform = "reversed" + root = "model.sessionHost.AspireSessionHostRoot" + directory = csOutput.canonicalPath + } + } + val dotnetBuildConfiguration = properties("dotnetBuildConfiguration").get() val compileDotNet by registering { doLast { exec { executable("dotnet") - args("build", "-c", dotnetBuildConfiguration, "aspire-plugin.sln") + args("build", "-c", dotnetBuildConfiguration, "/clp:ErrorsOnly", "aspire-plugin.sln") + } + } + } + val publishSessionHost by registering { + dependsOn(compileDotNet) + doLast { + exec { + executable("dotnet") + args( + "publish", + "src/dotnet/aspire-session-host/aspire-session-host.csproj", + "--configuration", dotnetBuildConfiguration + ) } } } buildPlugin { - dependsOn(compileDotNet) + dependsOn(publishSessionHost) } prepareSandbox { - dependsOn(compileDotNet) + dependsOn(publishSessionHost) val outputFolder = file("$projectDir/src/dotnet/aspire-plugin/bin/$dotnetBuildConfiguration") val dllFiles = listOf( @@ -96,6 +142,10 @@ tasks { if (!file.exists()) throw RuntimeException("File \"$file\" does not exist") } } + + from("$projectDir/src/dotnet/aspire-session-host/bin/$dotnetBuildConfiguration/publish") { + into("${rootProject.name}/aspire-session-host") + } } runPluginVerifier { @@ -114,7 +164,7 @@ tasks { val start = "" val end = "" - with (it.lines()) { + with(it.lines()) { if (!containsAll(listOf(start, end))) { throw GradleException("Plugin description section not found in README.md:\n$start ... $end") } @@ -157,6 +207,7 @@ tasks { // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel - channels = properties("pluginVersion").map { listOf(it.split('-').getOrElse(1) { "default" }.split('.').first()) } + channels = + properties("pluginVersion").map { listOf(it.split('-').getOrElse(1) { "default" }.split('.').first()) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d93a9bcb..362b608c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,8 @@ kotlin = "1.9.21" changelog = "2.2.0" gradleIntelliJPlugin = "1.16.0" qodana = "0.1.13" +rdgen = "2023.3.2" +# https://search.maven.org/artifact/com.jetbrains.rd/rd-gen [libraries] annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" } @@ -16,3 +18,4 @@ changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } gradleIntelliJPlugin = { id = "org.jetbrains.intellij", version.ref = "gradleIntelliJPlugin" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } +rdgen = { id = "com.jetbrains.rdgen", version.ref = "rdgen" } diff --git a/protocol/build.gradle.kts b/protocol/build.gradle.kts new file mode 100644 index 00000000..e2b061ed --- /dev/null +++ b/protocol/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("org.jetbrains.kotlin.jvm") +} + +val rdLibDirectory: () -> File by rootProject.extra + +repositories { + maven { setUrl("https://cache-redirector.jetbrains.com/maven-central") } + flatDir { + dir(rdLibDirectory()) + } +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib") + implementation(group = "", name = "rd-gen") + implementation(group = "", name = "rider-model") +} diff --git a/protocol/src/main/kotlin/model/sessionHost/AspireSessionHostModel.kt b/protocol/src/main/kotlin/model/sessionHost/AspireSessionHostModel.kt new file mode 100644 index 00000000..0491b9e7 --- /dev/null +++ b/protocol/src/main/kotlin/model/sessionHost/AspireSessionHostModel.kt @@ -0,0 +1,32 @@ +package model.sessionHost + +import com.jetbrains.rd.generator.nova.* +import com.jetbrains.rd.generator.nova.PredefinedType.* +import com.jetbrains.rd.generator.nova.csharp.CSharp50Generator +import com.jetbrains.rd.generator.nova.kotlin.Kotlin11Generator + +object AspireSessionHostRoot : Root() { + init { + setting(Kotlin11Generator.Namespace, "com.github.rafaelldi.aspireplugin.generated") + setting(CSharp50Generator.Namespace, "AspireSessionHost.Generated") + } +} + +@Suppress("unused") +object AspireSessionHostModel : Ext(AspireSessionHostRoot) { + private val EnvironmentVariableModel = structdef { + field("key", string) + field("value", string) + } + + private val SessionModel = structdef { + field("projectPath", string) + field("debug", bool) + field("envs", array(EnvironmentVariableModel).nullable) + field("args", array(string).nullable) + } + + init { + map("sessions", string, SessionModel) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e92a4e14..5bb1f559 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,23 @@ +pluginManagement { + repositories { + maven { setUrl("https://cache-redirector.jetbrains.com/plugins.gradle.org") } + } + resolutionStrategy { + eachPlugin { + when(requested.id.name) { + "rdgen" -> { + useModule("com.jetbrains.rd:rd-gen:${requested.version}") + } + } + } + } +} + plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.7.0" } + rootProject.name = "aspire-plugin" + +include(":protocol") diff --git a/src/dotnet/aspire-session-host/Connection.cs b/src/dotnet/aspire-session-host/Connection.cs new file mode 100644 index 00000000..dbcd29bf --- /dev/null +++ b/src/dotnet/aspire-session-host/Connection.cs @@ -0,0 +1,79 @@ +using AspireSessionHost.Generated; +using JetBrains.Collections.Viewable; +using JetBrains.Lifetimes; +using JetBrains.Rd; +using JetBrains.Rd.Impl; + +namespace AspireSessionHost; + +internal class Connection : IDisposable +{ + private readonly LifetimeDefinition _lifetimeDef = new(); + private readonly Lifetime _lifetime; + private readonly IScheduler _scheduler; + private readonly IProtocol _protocol; + private readonly Task _model; + + internal Connection(int port) + { + _lifetime = _lifetimeDef.Lifetime; + _scheduler = SingleThreadScheduler.RunOnSeparateThread(_lifetime, "AspireSessionHost Protocol Connection"); + var wire = new SocketWire.Client(_lifetime, _scheduler, port); + _protocol = new Protocol( + "AspireSessionHost Protocol", + new Serializers(), + new Identities(IdKind.Client), + _scheduler, + wire, + _lifetime + ); + _model = InitializeModelAsync(); + } + + private Task InitializeModelAsync() + { + var tcs = new TaskCompletionSource(); + _scheduler.Queue(() => + { + try + { + tcs.SetResult(new AspireSessionHostModel(_lifetime, _protocol)); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + return tcs.Task; + } + + internal async Task DoWithModel(Func action) + { + var model = await _model; + var tcs = new TaskCompletionSource(); + _scheduler.Queue(() => + { + try + { + tcs.SetResult(action(model)); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + return await tcs.Task; + } + + internal Task DoWithModel(Action action) => + DoWithModel(model => + { + action(model); + return 0; + }); + + public void Dispose() + { + _lifetimeDef.Terminate(); + } +} \ No newline at end of file diff --git a/src/dotnet/aspire-session-host/Generated/AspireSessionHostModel.Generated.cs b/src/dotnet/aspire-session-host/Generated/AspireSessionHostModel.Generated.cs new file mode 100644 index 00000000..fbaa28ce --- /dev/null +++ b/src/dotnet/aspire-session-host/Generated/AspireSessionHostModel.Generated.cs @@ -0,0 +1,316 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a RdGen v1.12. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using JetBrains.Annotations; + +using JetBrains.Core; +using JetBrains.Diagnostics; +using JetBrains.Collections; +using JetBrains.Collections.Viewable; +using JetBrains.Lifetimes; +using JetBrains.Serialization; +using JetBrains.Rd; +using JetBrains.Rd.Base; +using JetBrains.Rd.Impl; +using JetBrains.Rd.Tasks; +using JetBrains.Rd.Util; +using JetBrains.Rd.Text; + + +// ReSharper disable RedundantEmptyObjectCreationArgumentList +// ReSharper disable InconsistentNaming +// ReSharper disable RedundantOverflowCheckingContext + + +namespace AspireSessionHost.Generated +{ + + + /// + ///

Generated from: AspireSessionHostModel.kt:16

+ ///
+ public class AspireSessionHostModel : RdExtBase + { + //fields + //public fields + [NotNull] public IViewableMap Sessions => _Sessions; + + //private fields + [NotNull] private readonly RdMap _Sessions; + + //primary constructor + private AspireSessionHostModel( + [NotNull] RdMap sessions + ) + { + if (sessions == null) throw new ArgumentNullException("sessions"); + + _Sessions = sessions; + _Sessions.OptimizeNested = true; + BindableChildren.Add(new KeyValuePair("sessions", _Sessions)); + } + //secondary constructor + private AspireSessionHostModel ( + ) : this ( + new RdMap(JetBrains.Rd.Impl.Serializers.ReadString, JetBrains.Rd.Impl.Serializers.WriteString, SessionModel.Read, SessionModel.Write) + ) {} + //deconstruct trait + //statics + + + + protected override long SerializationHash => 4358981829313666140L; + + protected override Action Register => RegisterDeclaredTypesSerializers; + public static void RegisterDeclaredTypesSerializers(ISerializers serializers) + { + + serializers.RegisterToplevelOnce(typeof(AspireSessionHostRoot), AspireSessionHostRoot.RegisterDeclaredTypesSerializers); + } + + public AspireSessionHostModel(Lifetime lifetime, IProtocol protocol) : this() + { + Identify(protocol.Identities, RdId.Root.Mix("AspireSessionHostModel")); + this.BindTopLevel(lifetime, protocol, "AspireSessionHostModel"); + } + + //constants + + //custom body + //methods + //equals trait + //hash code trait + //pretty print + public override void Print(PrettyPrinter printer) + { + printer.Println("AspireSessionHostModel ("); + using (printer.IndentCookie()) { + printer.Print("sessions = "); _Sessions.PrintEx(printer); printer.Println(); + } + printer.Print(")"); + } + //toString + public override string ToString() + { + var printer = new SingleLinePrettyPrinter(); + Print(printer); + return printer.ToString(); + } + } + + + /// + ///

Generated from: AspireSessionHostModel.kt:17

+ ///
+ public sealed class EnvironmentVariableModel : IPrintable, IEquatable + { + //fields + //public fields + [NotNull] public string Key {get; private set;} + [NotNull] public string Value {get; private set;} + + //private fields + //primary constructor + public EnvironmentVariableModel( + [NotNull] string key, + [NotNull] string value + ) + { + if (key == null) throw new ArgumentNullException("key"); + if (value == null) throw new ArgumentNullException("value"); + + Key = key; + Value = value; + } + //secondary constructor + //deconstruct trait + public void Deconstruct([NotNull] out string key, [NotNull] out string value) + { + key = Key; + value = Value; + } + //statics + + public static CtxReadDelegate Read = (ctx, reader) => + { + var key = reader.ReadString(); + var value = reader.ReadString(); + var _result = new EnvironmentVariableModel(key, value); + return _result; + }; + + public static CtxWriteDelegate Write = (ctx, writer, value) => + { + writer.Write(value.Key); + writer.Write(value.Value); + }; + + //constants + + //custom body + //methods + //equals trait + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((EnvironmentVariableModel) obj); + } + public bool Equals(EnvironmentVariableModel other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Key == other.Key && Value == other.Value; + } + //hash code trait + public override int GetHashCode() + { + unchecked { + var hash = 0; + hash = hash * 31 + Key.GetHashCode(); + hash = hash * 31 + Value.GetHashCode(); + return hash; + } + } + //pretty print + public void Print(PrettyPrinter printer) + { + printer.Println("EnvironmentVariableModel ("); + using (printer.IndentCookie()) { + printer.Print("key = "); Key.PrintEx(printer); printer.Println(); + printer.Print("value = "); Value.PrintEx(printer); printer.Println(); + } + printer.Print(")"); + } + //toString + public override string ToString() + { + var printer = new SingleLinePrettyPrinter(); + Print(printer); + return printer.ToString(); + } + } + + + /// + ///

Generated from: AspireSessionHostModel.kt:22

+ ///
+ public sealed class SessionModel : IPrintable, IEquatable + { + //fields + //public fields + [NotNull] public string ProjectPath {get; private set;} + public bool Debug {get; private set;} + [CanBeNull] public EnvironmentVariableModel[] Envs {get; private set;} + [CanBeNull] public string[] Args {get; private set;} + + //private fields + //primary constructor + public SessionModel( + [NotNull] string projectPath, + bool debug, + [CanBeNull] EnvironmentVariableModel[] envs, + [CanBeNull] string[] args + ) + { + if (projectPath == null) throw new ArgumentNullException("projectPath"); + + ProjectPath = projectPath; + Debug = debug; + Envs = envs; + Args = args; + } + //secondary constructor + //deconstruct trait + public void Deconstruct([NotNull] out string projectPath, out bool debug, [CanBeNull] out EnvironmentVariableModel[] envs, [CanBeNull] out string[] args) + { + projectPath = ProjectPath; + debug = Debug; + envs = Envs; + args = Args; + } + //statics + + public static CtxReadDelegate Read = (ctx, reader) => + { + var projectPath = reader.ReadString(); + var debug = reader.ReadBool(); + var envs = ReadEnvironmentVariableModelArrayNullable(ctx, reader); + var args = ReadStringArrayNullable(ctx, reader); + var _result = new SessionModel(projectPath, debug, envs, args); + return _result; + }; + public static CtxReadDelegate ReadEnvironmentVariableModelArrayNullable = EnvironmentVariableModel.Read.Array().NullableClass(); + public static CtxReadDelegate ReadStringArrayNullable = JetBrains.Rd.Impl.Serializers.ReadString.Array().NullableClass(); + + public static CtxWriteDelegate Write = (ctx, writer, value) => + { + writer.Write(value.ProjectPath); + writer.Write(value.Debug); + WriteEnvironmentVariableModelArrayNullable(ctx, writer, value.Envs); + WriteStringArrayNullable(ctx, writer, value.Args); + }; + public static CtxWriteDelegate WriteEnvironmentVariableModelArrayNullable = EnvironmentVariableModel.Write.Array().NullableClass(); + public static CtxWriteDelegate WriteStringArrayNullable = JetBrains.Rd.Impl.Serializers.WriteString.Array().NullableClass(); + + //constants + + //custom body + //methods + //equals trait + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((SessionModel) obj); + } + public bool Equals(SessionModel other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return ProjectPath == other.ProjectPath && Debug == other.Debug && Equals(Envs, other.Envs) && Equals(Args, other.Args); + } + //hash code trait + public override int GetHashCode() + { + unchecked { + var hash = 0; + hash = hash * 31 + ProjectPath.GetHashCode(); + hash = hash * 31 + Debug.GetHashCode(); + hash = hash * 31 + (Envs != null ? Envs.ContentHashCode() : 0); + hash = hash * 31 + (Args != null ? Args.ContentHashCode() : 0); + return hash; + } + } + //pretty print + public void Print(PrettyPrinter printer) + { + printer.Println("SessionModel ("); + using (printer.IndentCookie()) { + printer.Print("projectPath = "); ProjectPath.PrintEx(printer); printer.Println(); + printer.Print("debug = "); Debug.PrintEx(printer); printer.Println(); + printer.Print("envs = "); Envs.PrintEx(printer); printer.Println(); + printer.Print("args = "); Args.PrintEx(printer); printer.Println(); + } + printer.Print(")"); + } + //toString + public override string ToString() + { + var printer = new SingleLinePrettyPrinter(); + Print(printer); + return printer.ToString(); + } + } +} diff --git a/src/dotnet/aspire-session-host/Generated/AspireSessionHostRoot.Generated.cs b/src/dotnet/aspire-session-host/Generated/AspireSessionHostRoot.Generated.cs new file mode 100644 index 00000000..033fef76 --- /dev/null +++ b/src/dotnet/aspire-session-host/Generated/AspireSessionHostRoot.Generated.cs @@ -0,0 +1,94 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a RdGen v1.12. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using JetBrains.Annotations; + +using JetBrains.Core; +using JetBrains.Diagnostics; +using JetBrains.Collections; +using JetBrains.Collections.Viewable; +using JetBrains.Lifetimes; +using JetBrains.Serialization; +using JetBrains.Rd; +using JetBrains.Rd.Base; +using JetBrains.Rd.Impl; +using JetBrains.Rd.Tasks; +using JetBrains.Rd.Util; +using JetBrains.Rd.Text; + + +// ReSharper disable RedundantEmptyObjectCreationArgumentList +// ReSharper disable InconsistentNaming +// ReSharper disable RedundantOverflowCheckingContext + + +namespace AspireSessionHost.Generated +{ + + + /// + ///

Generated from: AspireSessionHostModel.kt:8

+ ///
+ public class AspireSessionHostRoot : RdExtBase + { + //fields + //public fields + + //private fields + //primary constructor + private AspireSessionHostRoot( + ) + { + } + //secondary constructor + //deconstruct trait + //statics + + + + protected override long SerializationHash => -8489210090490017120L; + + protected override Action Register => RegisterDeclaredTypesSerializers; + public static void RegisterDeclaredTypesSerializers(ISerializers serializers) + { + + serializers.RegisterToplevelOnce(typeof(AspireSessionHostRoot), AspireSessionHostRoot.RegisterDeclaredTypesSerializers); + serializers.RegisterToplevelOnce(typeof(AspireSessionHostModel), AspireSessionHostModel.RegisterDeclaredTypesSerializers); + } + + public AspireSessionHostRoot(Lifetime lifetime, IProtocol protocol) : this() + { + Identify(protocol.Identities, RdId.Root.Mix("AspireSessionHostRoot")); + this.BindTopLevel(lifetime, protocol, "AspireSessionHostRoot"); + } + + //constants + + //custom body + //methods + //equals trait + //hash code trait + //pretty print + public override void Print(PrettyPrinter printer) + { + printer.Println("AspireSessionHostRoot ("); + printer.Print(")"); + } + //toString + public override string ToString() + { + var printer = new SingleLinePrettyPrinter(); + Print(printer); + return printer.ToString(); + } + } +} diff --git a/src/dotnet/aspire-session-host/Models.cs b/src/dotnet/aspire-session-host/Models.cs new file mode 100644 index 00000000..2f803fe5 --- /dev/null +++ b/src/dotnet/aspire-session-host/Models.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; + +namespace AspireSessionHost; + +[UsedImplicitly] +internal record Session( + string ProjectPath, + bool Debug, + EnvironmentVariable[]? Env, + string[]? Args +); + +[UsedImplicitly] +internal record EnvironmentVariable(string Name, string Value); \ No newline at end of file diff --git a/src/dotnet/aspire-session-host/ParentProcessWatchdog.cs b/src/dotnet/aspire-session-host/ParentProcessWatchdog.cs new file mode 100644 index 00000000..95895207 --- /dev/null +++ b/src/dotnet/aspire-session-host/ParentProcessWatchdog.cs @@ -0,0 +1,14 @@ +using JetBrains.Diagnostics; + +namespace AspireSessionHost; + +internal static class ParentProcessWatchdog +{ + private const string RiderParentProcessPid = "RIDER_PARENT_PROCESS_PID"; + + internal static bool IsAvailable => + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(RiderParentProcessPid)); + + internal static void StartNew() => + ProcessWatchdog.StartWatchdogForPidEnvironmentVariable(RiderParentProcessPid); +} \ No newline at end of file diff --git a/src/dotnet/aspire-session-host/Program.cs b/src/dotnet/aspire-session-host/Program.cs index f8a772ec..5528e87f 100644 --- a/src/dotnet/aspire-session-host/Program.cs +++ b/src/dotnet/aspire-session-host/Program.cs @@ -1,7 +1,15 @@ -using System.Net.WebSockets; +using System.Globalization; using System.Text.Json; +using AspireSessionHost; + +var port = Environment.GetEnvironmentVariable("RIDER_RD_PORT"); +if (port == null) throw new ApplicationException("Unable to find RIDER_RD_PORT variable"); + +var connection = new Connection(int.Parse(port, CultureInfo.InvariantCulture)); var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(connection); +builder.Services.AddScoped(); builder.Services.ConfigureHttpJsonOptions(it => { it.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; @@ -11,55 +19,6 @@ app.UseWebSockets(); -app.MapPut("/run_session", (Session session) => -{ - app.Logger.LogInformation("Session request {session}", session); - return TypedResults.Created(); -}); - -app.MapDelete("/run_session/{sessionId:guid}", (Guid sessionId) => -{ - return TypedResults.Ok(); -}); - -app.MapGet("/run_session/notify", async context => -{ - if (context.WebSockets.IsWebSocketRequest) - { - using var ws = await context.WebSockets.AcceptWebSocketAsync(); - await Receive(ws); - } - else - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - } -}); +app.MapSessionEndpoints(); app.Run(); - -static async Task Receive(WebSocket webSocket) -{ - var buffer = new byte[1024 * 4]; - var receiveResult = await webSocket.ReceiveAsync( - new ArraySegment(buffer), CancellationToken.None); - - while (!receiveResult.CloseStatus.HasValue) - { - receiveResult = await webSocket.ReceiveAsync( - new ArraySegment(buffer), CancellationToken.None); - } - - await webSocket.CloseAsync( - receiveResult.CloseStatus.Value, - receiveResult.CloseStatusDescription, - CancellationToken.None); -} - -record Session( - string ProjectPath, - bool Debug, - EnvironmentVariable[] Env, - string[] Args -); - -record EnvironmentVariable(string Name, string Value); \ No newline at end of file diff --git a/src/dotnet/aspire-session-host/SessionEndpoints.cs b/src/dotnet/aspire-session-host/SessionEndpoints.cs new file mode 100644 index 00000000..01aa7733 --- /dev/null +++ b/src/dotnet/aspire-session-host/SessionEndpoints.cs @@ -0,0 +1,57 @@ +using System.Net.WebSockets; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace AspireSessionHost; + +internal static class SessionEndpoints +{ + internal static void MapSessionEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/run_session"); + + group.MapPut("/", async (Session session, SessionService service) => + { + var id = await service.Create(session); + return TypedResults.Created($"/run_session/{id}", session); + }); + + group.MapDelete( + "/{sessionId:guid}", + async Task> (Guid sessionId, SessionService service) => + { + var isSuccessful = await service.Delete(sessionId); + return isSuccessful ? TypedResults.Ok() : TypedResults.NoContent(); + }); + + group.MapGet("/notify", async context => + { + if (context.WebSockets.IsWebSocketRequest) + { + using var ws = await context.WebSockets.AcceptWebSocketAsync(); + await Receive(ws); + } + else + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + } + }); + } + + private static async Task Receive(WebSocket webSocket) + { + var buffer = new byte[1024 * 4]; + var receiveResult = await webSocket.ReceiveAsync( + new ArraySegment(buffer), CancellationToken.None); + + while (!receiveResult.CloseStatus.HasValue) + { + receiveResult = await webSocket.ReceiveAsync( + new ArraySegment(buffer), CancellationToken.None); + } + + await webSocket.CloseAsync( + receiveResult.CloseStatus.Value, + receiveResult.CloseStatusDescription, + CancellationToken.None); + } +} \ No newline at end of file diff --git a/src/dotnet/aspire-session-host/SessionService.cs b/src/dotnet/aspire-session-host/SessionService.cs new file mode 100644 index 00000000..bab3a18f --- /dev/null +++ b/src/dotnet/aspire-session-host/SessionService.cs @@ -0,0 +1,28 @@ +using AspireSessionHost.Generated; + +namespace AspireSessionHost; + +internal class SessionService(Connection connection) +{ + internal async Task Create(Session session) + { + var sessionModel = new SessionModel( + session.ProjectPath, + session.Debug, + session.Env?.Select(it => new EnvironmentVariableModel(it.Name, it.Value)).ToArray(), + session.Args + ); + var id = Guid.NewGuid(); + + await connection.DoWithModel(model => model.Sessions.Add(id.ToString(), sessionModel)); + + return id; + } + + internal async Task Delete(Guid id) + { + var isSuccessful = await connection.DoWithModel(model => model.Sessions.Remove(id.ToString())); + + return isSuccessful; + } +} \ No newline at end of file diff --git a/src/dotnet/aspire-session-host/aspire-session-host.csproj b/src/dotnet/aspire-session-host/aspire-session-host.csproj index 5762947c..94db6481 100644 --- a/src/dotnet/aspire-session-host/aspire-session-host.csproj +++ b/src/dotnet/aspire-session-host/aspire-session-host.csproj @@ -8,6 +8,7 @@ + diff --git a/src/main/kotlin/com/github/rafaelldi/aspireplugin/generated/AspireSessionHostModel.Generated.kt b/src/main/kotlin/com/github/rafaelldi/aspireplugin/generated/AspireSessionHostModel.Generated.kt new file mode 100644 index 00000000..31511998 --- /dev/null +++ b/src/main/kotlin/com/github/rafaelldi/aspireplugin/generated/AspireSessionHostModel.Generated.kt @@ -0,0 +1,236 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE","EXPERIMENTAL_UNSIGNED_LITERALS","PackageDirectoryMismatch","UnusedImport","unused","LocalVariableName","CanBeVal","PropertyName","EnumEntryName","ClassName","ObjectPropertyName","UnnecessaryVariable","SpellCheckingInspection") +package com.github.rafaelldi.aspireplugin.generated + +import com.jetbrains.rd.framework.* +import com.jetbrains.rd.framework.base.* +import com.jetbrains.rd.framework.impl.* + +import com.jetbrains.rd.util.lifetime.* +import com.jetbrains.rd.util.reactive.* +import com.jetbrains.rd.util.string.* +import com.jetbrains.rd.util.* +import kotlin.time.Duration +import kotlin.reflect.KClass +import kotlin.jvm.JvmStatic + + + +/** + * #### Generated from [AspireSessionHostModel.kt:16] + */ +class AspireSessionHostModel private constructor( + private val _sessions: RdMap +) : RdExtBase() { + //companion + + companion object : ISerializersOwner { + + override fun registerSerializersCore(serializers: ISerializers) { + serializers.register(EnvironmentVariableModel) + serializers.register(SessionModel) + } + + + @JvmStatic + @JvmName("internalCreateModel") + @Deprecated("Use create instead", ReplaceWith("create(lifetime, protocol)")) + internal fun createModel(lifetime: Lifetime, protocol: IProtocol): AspireSessionHostModel { + @Suppress("DEPRECATION") + return create(lifetime, protocol) + } + + @JvmStatic + @Deprecated("Use protocol.aspireSessionHostModel or revise the extension scope instead", ReplaceWith("protocol.aspireSessionHostModel")) + fun create(lifetime: Lifetime, protocol: IProtocol): AspireSessionHostModel { + AspireSessionHostRoot.register(protocol.serializers) + + return AspireSessionHostModel() + } + + + const val serializationHash = 4358981829313666140L + + } + override val serializersOwner: ISerializersOwner get() = AspireSessionHostModel + override val serializationHash: Long get() = AspireSessionHostModel.serializationHash + + //fields + val sessions: IMutableViewableMap get() = _sessions + //methods + //initializer + init { + _sessions.optimizeNested = true + } + + init { + bindableChildren.add("sessions" to _sessions) + } + + //secondary constructor + private constructor( + ) : this( + RdMap(FrameworkMarshallers.String, SessionModel) + ) + + //equals trait + //hash code trait + //pretty print + override fun print(printer: PrettyPrinter) { + printer.println("AspireSessionHostModel (") + printer.indent { + print("sessions = "); _sessions.print(printer); println() + } + printer.print(")") + } + //deepClone + override fun deepClone(): AspireSessionHostModel { + return AspireSessionHostModel( + _sessions.deepClonePolymorphic() + ) + } + //contexts + //threading + override val extThreading: ExtThreadingKind get() = ExtThreadingKind.Default +} +val IProtocol.aspireSessionHostModel get() = getOrCreateExtension(AspireSessionHostModel::class) { @Suppress("DEPRECATION") AspireSessionHostModel.create(lifetime, this) } + + + +/** + * #### Generated from [AspireSessionHostModel.kt:17] + */ +data class EnvironmentVariableModel ( + val key: String, + val value: String +) : IPrintable { + //companion + + companion object : IMarshaller { + override val _type: KClass = EnvironmentVariableModel::class + + @Suppress("UNCHECKED_CAST") + override fun read(ctx: SerializationCtx, buffer: AbstractBuffer): EnvironmentVariableModel { + val key = buffer.readString() + val value = buffer.readString() + return EnvironmentVariableModel(key, value) + } + + override fun write(ctx: SerializationCtx, buffer: AbstractBuffer, value: EnvironmentVariableModel) { + buffer.writeString(value.key) + buffer.writeString(value.value) + } + + + } + //fields + //methods + //initializer + //secondary constructor + //equals trait + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other::class != this::class) return false + + other as EnvironmentVariableModel + + if (key != other.key) return false + if (value != other.value) return false + + return true + } + //hash code trait + override fun hashCode(): Int { + var __r = 0 + __r = __r*31 + key.hashCode() + __r = __r*31 + value.hashCode() + return __r + } + //pretty print + override fun print(printer: PrettyPrinter) { + printer.println("EnvironmentVariableModel (") + printer.indent { + print("key = "); key.print(printer); println() + print("value = "); value.print(printer); println() + } + printer.print(")") + } + //deepClone + //contexts + //threading +} + + +/** + * #### Generated from [AspireSessionHostModel.kt:22] + */ +data class SessionModel ( + val projectPath: String, + val debug: Boolean, + val envs: Array?, + val args: Array? +) : IPrintable { + //companion + + companion object : IMarshaller { + override val _type: KClass = SessionModel::class + + @Suppress("UNCHECKED_CAST") + override fun read(ctx: SerializationCtx, buffer: AbstractBuffer): SessionModel { + val projectPath = buffer.readString() + val debug = buffer.readBool() + val envs = buffer.readNullable { buffer.readArray {EnvironmentVariableModel.read(ctx, buffer)} } + val args = buffer.readNullable { buffer.readArray {buffer.readString()} } + return SessionModel(projectPath, debug, envs, args) + } + + override fun write(ctx: SerializationCtx, buffer: AbstractBuffer, value: SessionModel) { + buffer.writeString(value.projectPath) + buffer.writeBool(value.debug) + buffer.writeNullable(value.envs) { buffer.writeArray(it) { EnvironmentVariableModel.write(ctx, buffer, it) } } + buffer.writeNullable(value.args) { buffer.writeArray(it) { buffer.writeString(it) } } + } + + + } + //fields + //methods + //initializer + //secondary constructor + //equals trait + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other::class != this::class) return false + + other as SessionModel + + if (projectPath != other.projectPath) return false + if (debug != other.debug) return false + if (envs != other.envs) return false + if (args != other.args) return false + + return true + } + //hash code trait + override fun hashCode(): Int { + var __r = 0 + __r = __r*31 + projectPath.hashCode() + __r = __r*31 + debug.hashCode() + __r = __r*31 + if (envs != null) envs.contentDeepHashCode() else 0 + __r = __r*31 + if (args != null) args.contentDeepHashCode() else 0 + return __r + } + //pretty print + override fun print(printer: PrettyPrinter) { + printer.println("SessionModel (") + printer.indent { + print("projectPath = "); projectPath.print(printer); println() + print("debug = "); debug.print(printer); println() + print("envs = "); envs.print(printer); println() + print("args = "); args.print(printer); println() + } + printer.print(")") + } + //deepClone + //contexts + //threading +} diff --git a/src/main/kotlin/com/github/rafaelldi/aspireplugin/generated/AspireSessionHostRoot.Generated.kt b/src/main/kotlin/com/github/rafaelldi/aspireplugin/generated/AspireSessionHostRoot.Generated.kt new file mode 100644 index 00000000..f1959089 --- /dev/null +++ b/src/main/kotlin/com/github/rafaelldi/aspireplugin/generated/AspireSessionHostRoot.Generated.kt @@ -0,0 +1,61 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE","EXPERIMENTAL_UNSIGNED_LITERALS","PackageDirectoryMismatch","UnusedImport","unused","LocalVariableName","CanBeVal","PropertyName","EnumEntryName","ClassName","ObjectPropertyName","UnnecessaryVariable","SpellCheckingInspection") +package com.github.rafaelldi.aspireplugin.generated + +import com.jetbrains.rd.framework.* +import com.jetbrains.rd.framework.base.* +import com.jetbrains.rd.framework.impl.* + +import com.jetbrains.rd.util.lifetime.* +import com.jetbrains.rd.util.reactive.* +import com.jetbrains.rd.util.string.* +import com.jetbrains.rd.util.* +import kotlin.time.Duration +import kotlin.reflect.KClass +import kotlin.jvm.JvmStatic + + + +/** + * #### Generated from [AspireSessionHostModel.kt:8] + */ +class AspireSessionHostRoot private constructor( +) : RdExtBase() { + //companion + + companion object : ISerializersOwner { + + override fun registerSerializersCore(serializers: ISerializers) { + AspireSessionHostRoot.register(serializers) + AspireSessionHostModel.register(serializers) + } + + + + + + const val serializationHash = -8489210090490017120L + + } + override val serializersOwner: ISerializersOwner get() = AspireSessionHostRoot + override val serializationHash: Long get() = AspireSessionHostRoot.serializationHash + + //fields + //methods + //initializer + //secondary constructor + //equals trait + //hash code trait + //pretty print + override fun print(printer: PrettyPrinter) { + printer.println("AspireSessionHostRoot (") + printer.print(")") + } + //deepClone + override fun deepClone(): AspireSessionHostRoot { + return AspireSessionHostRoot( + ) + } + //contexts + //threading + override val extThreading: ExtThreadingKind get() = ExtThreadingKind.Default +} diff --git a/src/main/kotlin/com/github/rafaelldi/aspireplugin/run/AspireHostExecutorFactory.kt b/src/main/kotlin/com/github/rafaelldi/aspireplugin/run/AspireHostExecutorFactory.kt index 945cea93..9c9b65bd 100644 --- a/src/main/kotlin/com/github/rafaelldi/aspireplugin/run/AspireHostExecutorFactory.kt +++ b/src/main/kotlin/com/github/rafaelldi/aspireplugin/run/AspireHostExecutorFactory.kt @@ -1,5 +1,6 @@ package com.github.rafaelldi.aspireplugin.run +import com.github.rafaelldi.aspireplugin.sessionHost.AspireSessionHost import com.intellij.execution.CantRunException import com.intellij.execution.configurations.RunProfileState import com.intellij.execution.executors.DefaultDebugExecutor @@ -13,9 +14,10 @@ import com.jetbrains.rider.model.runnableProjectsModel import com.jetbrains.rider.projectView.solution import com.jetbrains.rider.run.configurations.AsyncExecutorFactory import com.jetbrains.rider.run.configurations.project.DotNetProjectConfigurationParameters -import com.jetbrains.rider.run.configurations.project.getRunOptions import com.jetbrains.rider.runtime.DotNetExecutable import com.jetbrains.rider.runtime.RiderDotNetActiveRuntimeHost +import com.jetbrains.rider.util.NetUtils +import java.util.* class AspireHostExecutorFactory( private val project: Project, @@ -36,8 +38,14 @@ class AspireHostExecutorFactory( AspireHostConfigurationType.isTypeApplicable(it.kind) && it.projectFilePath == parameters.projectFilePath } ?: throw CantRunException(DotNetProjectConfigurationParameters.PROJECT_NOT_SPECIFIED) + val aspNetPort = NetUtils.findFreePort(67800) + val sessionHost = AspireSessionHost.getInstance() + sessionHost.start(project, aspNetPort, lifetime) + val executable = getDotNetExecutable( - runnableProject + runnableProject, + aspNetPort, + UUID.randomUUID().toString() ) return when (executorId) { @@ -48,7 +56,9 @@ class AspireHostExecutorFactory( } private fun getDotNetExecutable( - runnableProject: RunnableProject + runnableProject: RunnableProject, + port: Int, + token: String ): DotNetExecutable { val projectOutput = runnableProject.projectOutputs.firstOrNull() ?: throw CantRunException("Unable to find project output") @@ -61,8 +71,8 @@ class AspireHostExecutorFactory( false, false, mapOf( - "DEBUG_SESSION_PORT" to "localhost:5000", - "DEBUG_SESSION_TOKEN" to "123" + "DEBUG_SESSION_PORT" to "localhost:$port", + "DEBUG_SESSION_TOKEN" to token ), true, parameters.startBrowserAction, diff --git a/src/main/kotlin/com/github/rafaelldi/aspireplugin/sessionHost/AspireSessionHost.kt b/src/main/kotlin/com/github/rafaelldi/aspireplugin/sessionHost/AspireSessionHost.kt new file mode 100644 index 00000000..2176c37d --- /dev/null +++ b/src/main/kotlin/com/github/rafaelldi/aspireplugin/sessionHost/AspireSessionHost.kt @@ -0,0 +1,103 @@ +package com.github.rafaelldi.aspireplugin.sessionHost + +import com.github.rafaelldi.aspireplugin.generated.AspireSessionHostModel +import com.github.rafaelldi.aspireplugin.generated.aspireSessionHostModel +import com.intellij.execution.CantRunException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.project.Project +import com.intellij.openapi.rd.createNestedDisposable +import com.intellij.openapi.rd.util.withUiContext +import com.jetbrains.rd.framework.* +import com.jetbrains.rd.util.lifetime.Lifetime +import com.jetbrains.rdclient.protocol.RdDispatcher +import com.jetbrains.rider.runtime.RiderDotNetActiveRuntimeHost +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import kotlin.io.path.div + +@Service +class AspireSessionHost : Disposable.Default { + companion object { + fun getInstance(): AspireSessionHost = service() + + private val LOG = logger() + + private const val RIDER_RD_PORT = "RIDER_RD_PORT" + } + + private val pluginId = PluginId.getId("com.github.rafaelldi.aspireplugin") + + private val hostAssemblyPath: Path = run { + val plugin = PluginManagerCore.getPlugin(pluginId) ?: error("Plugin $pluginId could not be found.") + val basePath = plugin.pluginPath ?: error("Could not detect path of plugin $plugin on disk.") + basePath / "aspire-session-host" / "aspire-session-host.dll" + } + + suspend fun start(project: Project, aspNetPort: Int, lifetime: Lifetime) { + val dotnet = RiderDotNetActiveRuntimeHost.getInstance(project).dotNetCoreRuntime.value + ?: throw CantRunException("Cannot find active .NET runtime.") + + val processLifetime = lifetime.createNested() + + val protocol = startProtocol(processLifetime) + subscribe(protocol.aspireSessionHostModel, processLifetime, project) + + val commandLine = GeneralCommandLine() + .withExePath(dotnet.cliExePath) + .withCharset(StandardCharsets.UTF_8) + .withParameters(hostAssemblyPath.toString()) + .withEnvironment( + mapOf( + "ASPNETCORE_URLS" to "http://localhost:$aspNetPort/", + RIDER_RD_PORT to "${protocol.wire.serverPort}" + ) + ) + val processHandler = KillableColoredProcessHandler.Silent(commandLine) + processLifetime.onTermination { + if (!processHandler.isProcessTerminating && !processHandler.isProcessTerminated) { + processHandler.killProcess() + } + } + processHandler.addProcessListener(object : ProcessListener { + override fun processTerminated(event: ProcessEvent) { + processLifetime.executeIfAlive { + processLifetime.terminate(true) + } + } + }, processLifetime.createNestedDisposable()) + processHandler.startNotify() + } + + private suspend fun startProtocol(lifetime: Lifetime) = withUiContext { + val dispatcher = RdDispatcher(lifetime) + val wire = SocketWire.Server(lifetime, dispatcher, null) + val protocol = Protocol( + "AspireSessionHost::protocol", + Serializers(), + Identities(IdKind.Server), + dispatcher, + wire, + lifetime + ) + return@withUiContext protocol + } + + private suspend fun subscribe( + model: AspireSessionHostModel, + lifetime: Lifetime, + project: Project + ) = withUiContext { + model.sessions.view(lifetime) { lt, id, session -> + LOG.trace("New session added $id, $session") + } + } +} \ No newline at end of file