Skip to content

Commit

Permalink
feat: add IPC server and CLI (#350)
Browse files Browse the repository at this point in the history
  • Loading branch information
lars-berger authored Aug 6, 2023
1 parent 15ca061 commit 444ea5c
Show file tree
Hide file tree
Showing 56 changed files with 1,242 additions and 481 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ indent_style = space
end_of_line = crlf

# Code files
[*.{cs,yaml,xaml,json,csproj,sln}]
[*.{cs,yaml,xaml,json,csproj}]
indent_size = 2
insert_final_newline = true
charset = utf-8
Expand Down
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/GlazeWM.Bootstrapper/bin/Debug/net7-windows10.0.17763.0/GlazeWM.dll",
"program": "${workspaceFolder}/GlazeWM.App/bin/Debug/net7-windows10.0.17763.0/GlazeWM.dll",
"args": [],
"cwd": "${workspaceFolder}/GlazeWM.Bootstrapper",
"cwd": "${workspaceFolder}/GlazeWM.App",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
Expand Down
10 changes: 5 additions & 5 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"type": "process",
"args": [
"build",
"${workspaceFolder}/GlazeWM.Bootstrapper/GlazeWM.Bootstrapper.csproj",
"${workspaceFolder}/GlazeWM.App/GlazeWM.App.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
Expand All @@ -19,7 +19,7 @@
"type": "process",
"args": [
"publish",
"${workspaceFolder}/GlazeWM.Bootstrapper/GlazeWM.Bootstrapper.csproj",
"${workspaceFolder}/GlazeWM.App/GlazeWM.App.csproj",
"--configuration=Release",
"--runtime=win-x64",
"--self-contained",
Expand All @@ -36,7 +36,7 @@
"type": "process",
"args": [
"publish",
"${workspaceFolder}/GlazeWM.Bootstrapper/GlazeWM.Bootstrapper.csproj",
"${workspaceFolder}/GlazeWM.App/GlazeWM.App.csproj",
"--configuration=Release",
"--runtime=win-x86",
"--self-contained",
Expand All @@ -55,7 +55,7 @@
"watch",
"run",
"--project",
"${workspaceFolder}/GlazeWM.Bootstrapper/GlazeWM.Bootstrapper.csproj"
"${workspaceFolder}/GlazeWM.App/GlazeWM.App.csproj"
],
"problemMatcher": "$msCompile"
},
Expand All @@ -65,7 +65,7 @@
"type": "process",
"args": [
"publish",
"${workspaceFolder}/GlazeWM.Bootstrapper/GlazeWM.Bootstrapper.csproj",
"${workspaceFolder}/GlazeWM.App/GlazeWM.App.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
Expand Down
83 changes: 83 additions & 0 deletions GlazeWM.App.Cli/CliStartup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Reactive.Linq;
using System.Text.Json;
using GlazeWM.Infrastructure.Common;
using GlazeWM.Infrastructure.Utils;

namespace GlazeWM.App.Cli
{
public sealed class CliStartup
{
public static async Task<ExitCode> Run(
string[] args,
int ipcServerPort,
bool isSubscribeMessage)
{
var client = new WebsocketClient(ipcServerPort);

try
{
var isConnected = client.Connect();

if (!isConnected)
throw new Exception("Unable to connect to IPC server.");

client.ReceiveAsync();

var message = string.Join(" ", args);
var sendSuccess = client.SendTextAsync(message);

if (!sendSuccess)
throw new Exception("Failed to send message to IPC server.");

var serverMessages = GetMessagesObservable(client);

// Wait for server to respond with a message.
var firstMessage = await serverMessages
.Timeout(TimeSpan.FromSeconds(5))
.FirstAsync();

// Exit on first message received when not subscribing to an event.
if (!isSubscribeMessage)
{
Console.WriteLine(firstMessage);
client.Disconnect();
return ExitCode.Success;
}

// Special handling is needed for event subscriptions.
serverMessages.Subscribe(
onNext: Console.WriteLine,
onError: Console.Error.WriteLine
);

var _ = Console.ReadLine();

client.Disconnect();
return ExitCode.Success;
}
catch (Exception exception)
{
Console.Error.WriteLine(exception.Message);
client.Disconnect();
return ExitCode.Error;
}
}

/// <summary>
/// Get `IObservable` of parsed server messages.
/// </summary>
private static IObservable<string> GetMessagesObservable(WebsocketClient client)
{
return client.Messages.Select(message =>
{
var parsedMessage = JsonDocument.Parse(message).RootElement;
var error = parsedMessage.GetProperty("error").GetString();
if (error is not null)
throw new Exception(error);
return parsedMessage.GetProperty("data").ToString();
});
}
}
}
12 changes: 12 additions & 0 deletions GlazeWM.App.Cli/GlazeWM.App.Cli.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7-windows10.0.17763</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\GlazeWM.Domain\GlazeWM.Domain.csproj" />
<ProjectReference Include="..\GlazeWM.Infrastructure\GlazeWM.Infrastructure.csproj" />
</ItemGroup>
</Project>
15 changes: 15 additions & 0 deletions GlazeWM.App.IpcServer/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;

namespace GlazeWM.App.IpcServer
{
public static class DependencyInjection
{
public static IServiceCollection AddIpcServerServices(this IServiceCollection services)
{
services.AddSingleton<IpcMessageHandler>();
services.AddSingleton<IpcServerManager>();

return services;
}
}
}
12 changes: 12 additions & 0 deletions GlazeWM.App.IpcServer/GlazeWM.App.IpcServer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7-windows10.0.17763.0</TargetFramework>
<DebugType>embedded</DebugType>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\GlazeWM.Domain\GlazeWM.Domain.csproj" />
<ProjectReference Include="..\GlazeWM.Infrastructure\GlazeWM.Infrastructure.csproj" />
</ItemGroup>
</Project>
182 changes: 182 additions & 0 deletions GlazeWM.App.IpcServer/IpcMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using CommandLine;
using GlazeWM.App.IpcServer.Messages;
using GlazeWM.App.IpcServer.Server;
using GlazeWM.Domain.Containers;
using GlazeWM.Domain.Monitors;
using GlazeWM.Domain.UserConfigs;
using GlazeWM.Domain.Windows;
using GlazeWM.Domain.Workspaces;
using GlazeWM.Infrastructure.Bussing;
using GlazeWM.Infrastructure.Serialization;
using GlazeWM.Infrastructure.Utils;
using Microsoft.Extensions.Logging;

namespace GlazeWM.App.IpcServer
{
public sealed class IpcMessageHandler
{
private readonly Bus _bus;
private readonly CommandParsingService _commandParsingService;
private readonly ContainerService _containerService;
private readonly ILogger<IpcMessageHandler> _logger;
private readonly MonitorService _monitorService;
private readonly WorkspaceService _workspaceService;
private readonly WindowService _windowService;

private readonly JsonSerializerOptions _serializeOptions =
JsonParser.OptionsFactory((options) =>
{
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.Converters.Add(new JsonContainerConverter());
});

/// <summary>
/// Dictionary of event names and session IDs subscribed to that event.
/// </summary>
internal Dictionary<string, List<Guid>> SubscribedSessions = new();

/// <summary>
/// Matches words separated by spaces when not surrounded by double quotes.
/// Example: "a \"b c\" d" -> ["a", "\"b c\"", "d"]
/// </summary>
private static readonly Regex _messagePartsRegex = new("(\".*?\"|\\S+)");

public IpcMessageHandler(
Bus bus,
CommandParsingService commandParsingService,
ContainerService containerService,
ILogger<IpcMessageHandler> logger,
MonitorService monitorService,
WorkspaceService workspaceService,
WindowService windowService)
{
_bus = bus;
_commandParsingService = commandParsingService;
_containerService = containerService;
_logger = logger;
_monitorService = monitorService;
_workspaceService = workspaceService;
_windowService = windowService;
}

internal string GetResponseMessage(ClientMessage message)
{
var (sessionId, messageString) = message;

_logger.LogDebug(
"IPC message from session {Session}: {Message}.",
sessionId,
messageString
);

try
{
var messageParts = _messagePartsRegex.Matches(messageString)
.Select(match => match.Value)
.Where(match => match is not null);

var parsedArgs = Parser.Default.ParseArguments<
InvokeCommandMessage,
SubscribeMessage,
GetMonitorsMessage,
GetWorkspacesMessage,
GetWindowsMessage
>(messageParts);

object? data = parsedArgs.Value switch
{
InvokeCommandMessage commandMsg => HandleInvokeCommandMessage(commandMsg),
SubscribeMessage subscribeMsg => HandleSubscribeMessage(subscribeMsg, sessionId),
GetMonitorsMessage => _monitorService.GetMonitors(),
GetWorkspacesMessage => _workspaceService.GetActiveWorkspaces(),
GetWindowsMessage => _windowService.GetWindows(),
_ => throw new Exception($"Invalid message '{messageString}'")
};

return ToResponseMessage(
success: true,
data: data,
clientMessage: messageString
);
}
catch (Exception exception)
{
return ToResponseMessage<bool?>(
success: false,
data: null,
clientMessage: messageString,
error: exception.Message
);
}
}

private bool? HandleInvokeCommandMessage(InvokeCommandMessage message)
{
var contextContainer =
_containerService.GetContainerById(message.ContextContainerId) ??
_containerService.FocusedContainer;

var commandString = CommandParsingService.FormatCommand(message.Command);

var command = _commandParsingService.ParseCommand(
commandString,
contextContainer
);

_bus.Invoke((dynamic)command);
return null;
}

private bool? HandleSubscribeMessage(SubscribeMessage message, Guid sessionId)
{
foreach (var eventName in message.Events.Split(','))
{
if (SubscribedSessions.ContainsKey(eventName))
{
var sessionIds = SubscribedSessions.GetValueOrThrow(eventName);
sessionIds.Add(sessionId);
continue;
}

SubscribedSessions.Add(eventName, new() { sessionId });
}

return null;
}

private string ToResponseMessage<T>(
bool success,
T? data,
string clientMessage,
string? error = null)
{
var responseMessage = new ServerMessage<T>(
Success: success,
MessageType: ServerMessageType.ClientResponse,
Data: data,
Error: error,
ClientMessage: clientMessage
);

return JsonParser.ToString((dynamic)responseMessage, _serializeOptions);
}

internal string ToEventMessage(Event @event)
{
var eventMessage = new ServerMessage<Event>(
Success: true,
MessageType: ServerMessageType.SubscribedEvent,
Data: @event,
Error: null,
ClientMessage: null
);

return JsonParser.ToString((dynamic)eventMessage, _serializeOptions);
}
}
}
Loading

0 comments on commit 444ea5c

Please sign in to comment.