Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add IPC server and CLI #350

Merged
merged 70 commits into from
Aug 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
b1127cc
feat: initial IPC implementation from Zinvoke
lars-berger Jul 19, 2023
cd064a9
feat: significantly refactor ipc impl; multicast bus events to all se…
lars-berger Jul 19, 2023
a90ae2b
fix: syntax and linting fixes
lars-berger Jul 19, 2023
402c57e
feat(infra): rename and change yaml/json parsers to be static
lars-berger Jul 19, 2023
c1cb30c
chore(infra): install "CommandLineParser"
lars-berger Jul 23, 2023
b6da391
chore(ipc): set correct target framework in project file
lars-berger Jul 23, 2023
0c451be
feat(boot): add debug setup for command line parsing
lars-berger Jul 23, 2023
17cb37f
feat: create 2 new projects (WM & CLI)
lars-berger Jul 23, 2023
5a1a489
fix: qualify references to `Application.Run()`
lars-berger Jul 23, 2023
1bef693
refactor: rename `GlazeWM.Bootstrapper` -> `GlazeWM.Application`
lars-berger Jul 23, 2023
2ab009f
feat(wm): create `WindowManager` for startup logic for window manager
lars-berger Jul 24, 2023
535aeea
feat(cli): create `Cli` for startup logic for cli
lars-berger Jul 24, 2023
c5d2b3e
feat(infra): create `ExitCode` enum
lars-berger Jul 24, 2023
2abf7fa
feat(ipc): create classes for ipc message types
lars-berger Jul 24, 2023
289afcf
feat(ipc): create class for `OutgoingIpcMessage`
lars-berger Jul 24, 2023
c15cfff
feat(app): start wm/cli depending on args passed to `Main`
lars-berger Jul 24, 2023
4b1b364
refactor: rename `Interprocess` proj -> `Application.IpcServer`
lars-berger Jul 24, 2023
1aae1bd
refactor(ipc): rename `WebsocketMessage` -> `IncomingIpcMessage`
lars-berger Jul 24, 2023
307803a
refactor(ipc): file and class renames
lars-berger Jul 24, 2023
0f7f0a2
chore: fixes to project files
lars-berger Jul 26, 2023
9ba24e4
feat(domain): create `WmStartupOptions`
lars-berger Jul 26, 2023
8edda62
feat(ipc): partially implement handler for incoming ipc messages
lars-berger Jul 26, 2023
25c6e3e
fix: various fixes
lars-berger Jul 26, 2023
cfe0d6a
feat(infra): create `ThreadUtils`
lars-berger Jul 27, 2023
3096253
feat(infra): create `CasingUtil` for converting pascal to snake case
lars-berger Jul 29, 2023
5a17136
feat(infra): add `FriendlyName` to `Event`
lars-berger Jul 29, 2023
dd0110d
feat(ipc): add handlers for all message types
lars-berger Jul 29, 2023
c656513
feat(ipc): stringify instances of `OutgoingIpcMessage`
lars-berger Jul 29, 2023
11d72fa
refactor(ipc): renaming ipc-related files
lars-berger Jul 29, 2023
7800283
feat(infra,cli): add example websocket client
lars-berger Jul 29, 2023
1132a0b
feat(infra,ipc): add `DictionaryExtensions`
lars-berger Jul 29, 2023
01b289d
fix DI errors
lars-berger Jul 29, 2023
527dfb3
feat: add unimplemented `JsonContainerConverterFactory`
lars-berger Jul 31, 2023
4016157
feat(ipc): correctly send text message to session
lars-berger Jul 31, 2023
d0ae39c
feat: correctly parse cli args
lars-berger Jul 31, 2023
3b15cc0
refactor: move ipc message interfaces to `Domain/Common`
lars-berger Jul 31, 2023
3f6431d
feat: downcast to `IEnumerable<Container>` to avoid exception when se…
lars-berger Aug 1, 2023
3088973
feat: pass down ipc server port from `Program`
lars-berger Aug 1, 2023
8ca014e
chore(app): set output type to exe
lars-berger Aug 1, 2023
59f9275
feat(cli,infra): successfully connect via websocket client and send m…
lars-berger Aug 1, 2023
216d613
refactor(cli): simplify client connection
lars-berger Aug 1, 2023
0e930ce
feat(cli): improve error handling
lars-berger Aug 1, 2023
944f33a
feat(cli): add special handling for subscribe message
lars-berger Aug 1, 2023
7097cc4
feat(cli): json parse `data` property; output `error` to stderr
lars-berger Aug 2, 2023
334acfc
refactor(cli): simplify json parsing and error handling in cli
lars-berger Aug 2, 2023
e27c20d
feat(ipc): change properties on `ServerMessage` record
lars-berger Aug 2, 2023
59d370c
feat(domain): add `Type` to `Container`
lars-berger Aug 2, 2023
9a4878d
refactor(domain): simplify `JsonContainerConverter`
lars-berger Aug 2, 2023
4868794
various fixes
lars-berger Aug 2, 2023
3a1813f
fix: use `ReplaySubject` to avoid error when subscribing too late
lars-berger Aug 2, 2023
85529e5
style: fix file encodings
lars-berger Aug 2, 2023
99115fd
feat(infra): container converter should use casing from options
lars-berger Aug 3, 2023
8618829
feat(ipc): correctly split messages on spaces when not surrounded by …
lars-berger Aug 3, 2023
15ebbc4
feat(ipc,domain): format command string prior to invoking
lars-berger Aug 3, 2023
a1a8629
refactor: move IPC message classes under `IpcServer` proj
lars-berger Aug 3, 2023
0c6d95c
feat(ipc): progress on formatting client response message
lars-berger Aug 3, 2023
d00eba6
refactor(ipc): simplify how response message is stringified
lars-berger Aug 3, 2023
9b52f13
refactor: rename project directories
lars-berger Aug 3, 2023
2897954
fix: correct namespace naming conflict
lars-berger Aug 3, 2023
0d76fb7
chore: fix target frameworks after .net7 update
lars-berger Aug 6, 2023
4b527fa
linting fixes
lars-berger Aug 6, 2023
1fc2b51
refactor(ipc): remove unused `GetContainersMessage`
lars-berger Aug 6, 2023
a4470cd
fix: invalid xaml in `ComponentPortal` after merge
lars-berger Aug 6, 2023
281335c
refactor: remove unused `IpcServerPort` from general config
lars-berger Aug 6, 2023
6a2dec7
chore: exclude .sln files from `indent_size` rule
lars-berger Aug 6, 2023
bee8d38
fix(domain): correctly trim single/double quotes when formatting comm…
lars-berger Aug 6, 2023
6b2a9e5
feat(ipc,domain): change casing of ipc messages to camel-case
lars-berger Aug 6, 2023
bd1ec28
feat: serialize enums to lowercase
lars-berger Aug 6, 2023
2472d5f
feat(domain): change format of container IDs
lars-berger Aug 6, 2023
6dd9a53
chore: remove "dotnet.defaultSolution" setting
lars-berger Aug 6, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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