Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 8e6ec03

Browse files
authoredMar 11, 2025··
chore: add MutagenSdk project (#48)
Contains a gRPC client for mutagen's synchronization API. All required .proto files are vendored using a new script `Update-Proto.ps1`, which finds all required files by scanning `import` directives. The vendored files are modified to add `csharp_namespace` and a MIT license header from mutagen. Example usage: ```cs using var client = new MutagenClient(@"C:\Users\dean\.mutagen"); var res = await client.Synchronization.ListAsync(new ListRequest { Selection = new Selection { All = true, }, }); foreach (var state in res.SessionStates) Console.WriteLine(state); ``` Closes coder/internal#378
1 parent 7fc6398 commit 8e6ec03

30 files changed

+2012
-0
lines changed
 

‎.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MutagenSdk/Proto/**/*.proto linguist-generated=true

‎Coder.Desktop.sln

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.DebugClient", "Vpn.Debu
2323
EndProject
2424
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer", "Installer\Installer.csproj", "{39F5B55A-09D8-477D-A3FA-ADAC29C52605}"
2525
EndProject
26+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MutagenSdk", "MutagenSdk\MutagenSdk.csproj", "{E2477ADC-03DA-490D-9369-79A4CC4A58D2}"
27+
EndProject
2628
Global
2729
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2830
Debug|Any CPU = Debug|Any CPU
@@ -203,6 +205,22 @@ Global
203205
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x64.Build.0 = Release|Any CPU
204206
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x86.ActiveCfg = Release|Any CPU
205207
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x86.Build.0 = Release|Any CPU
208+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
209+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
210+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|ARM64.ActiveCfg = Debug|Any CPU
211+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|ARM64.Build.0 = Debug|Any CPU
212+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x64.ActiveCfg = Debug|Any CPU
213+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x64.Build.0 = Debug|Any CPU
214+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x86.ActiveCfg = Debug|Any CPU
215+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x86.Build.0 = Debug|Any CPU
216+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
217+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|Any CPU.Build.0 = Release|Any CPU
218+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|ARM64.ActiveCfg = Release|Any CPU
219+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|ARM64.Build.0 = Release|Any CPU
220+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x64.ActiveCfg = Release|Any CPU
221+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x64.Build.0 = Release|Any CPU
222+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x86.ActiveCfg = Release|Any CPU
223+
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x86.Build.0 = Release|Any CPU
206224
EndGlobalSection
207225
GlobalSection(SolutionProperties) = preSolution
208226
HideSolutionNode = FALSE

‎MutagenSdk/MutagenClient.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization;
2+
using Grpc.Core;
3+
using Grpc.Net.Client;
4+
5+
namespace Coder.Desktop.MutagenSdk;
6+
7+
public class MutagenClient : IDisposable
8+
{
9+
private readonly GrpcChannel _channel;
10+
11+
public readonly Synchronization.SynchronizationClient Synchronization;
12+
13+
public MutagenClient(string dataDir)
14+
{
15+
// Check for the lock file first, since it should exist if it's running.
16+
var daemonLockFile = Path.Combine(dataDir, "daemon", "daemon.lock");
17+
if (!File.Exists(daemonLockFile))
18+
throw new FileNotFoundException(
19+
"Mutagen daemon lock file not found, did the mutagen daemon start successfully?", daemonLockFile);
20+
21+
// Read the IPC named pipe address from the sock file.
22+
var daemonSockFile = Path.Combine(dataDir, "daemon", "daemon.sock");
23+
if (!File.Exists(daemonSockFile))
24+
throw new FileNotFoundException(
25+
"Mutagen daemon socket file not found, did the mutagen daemon start successfully?", daemonSockFile);
26+
var daemonSockAddress = File.ReadAllText(daemonSockFile).Trim();
27+
if (string.IsNullOrWhiteSpace(daemonSockAddress))
28+
throw new InvalidOperationException(
29+
"Mutagen daemon socket address is empty, did the mutagen daemon start successfully?");
30+
31+
const string namedPipePrefix = @"\\.\pipe\";
32+
if (!daemonSockAddress.StartsWith(namedPipePrefix))
33+
throw new InvalidOperationException("Mutagen daemon socket address is not a named pipe address");
34+
var pipeName = daemonSockAddress[namedPipePrefix.Length..];
35+
36+
var connectionFactory = new NamedPipesConnectionFactory(pipeName);
37+
var socketsHttpHandler = new SocketsHttpHandler
38+
{
39+
ConnectCallback = connectionFactory.ConnectAsync,
40+
};
41+
42+
_channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
43+
{
44+
Credentials = ChannelCredentials.Insecure,
45+
HttpHandler = socketsHttpHandler,
46+
});
47+
Synchronization = new Synchronization.SynchronizationClient(_channel);
48+
}
49+
50+
public void Dispose()
51+
{
52+
_channel.Dispose();
53+
GC.SuppressFinalize(this);
54+
}
55+
}

‎MutagenSdk/MutagenSdk.csproj

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<AssemblyName>Coder.Desktop.MutagenSdk</AssemblyName>
5+
<RootNamespace>Coder.Desktop.MutagenSdk</RootNamespace>
6+
<TargetFramework>net8.0</TargetFramework>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<Nullable>enable</Nullable>
9+
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<Protobuf Include="Proto\**\*.proto" ProtoRoot="Proto" GrpcServices="Client" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
18+
<PackageReference Include="Grpc.Net.Client" Version="2.67.0" />
19+
<PackageReference Include="Grpc.Tools" Version="2.69.0">
20+
<PrivateAssets>all</PrivateAssets>
21+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
22+
</PackageReference>
23+
</ItemGroup>
24+
25+
</Project>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.IO.Pipes;
2+
using System.Security.Principal;
3+
4+
namespace Coder.Desktop.MutagenSdk;
5+
6+
public class NamedPipesConnectionFactory
7+
{
8+
private readonly string _pipeName;
9+
10+
public NamedPipesConnectionFactory(string pipeName)
11+
{
12+
_pipeName = pipeName;
13+
}
14+
15+
public async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext _,
16+
CancellationToken cancellationToken = default)
17+
{
18+
var client = new NamedPipeClientStream(
19+
".",
20+
_pipeName,
21+
PipeDirection.InOut,
22+
PipeOptions.WriteThrough | PipeOptions.Asynchronous,
23+
TokenImpersonationLevel.Anonymous);
24+
25+
try
26+
{
27+
await client.ConnectAsync(cancellationToken);
28+
return client;
29+
}
30+
catch
31+
{
32+
await client.DisposeAsync();
33+
throw;
34+
}
35+
}
36+
}

‎MutagenSdk/Proto/filesystem/behavior/probe_mode.proto

Lines changed: 51 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎MutagenSdk/Proto/selection/selection.proto

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 170 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 176 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 111 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 102 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 161 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎MutagenSdk/Proto/url/url.proto

Lines changed: 92 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎MutagenSdk/Update-Proto.ps1

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Usage: Update-Proto.ps1 -mutagenTag <version>
2+
param (
3+
[Parameter(Mandatory = $true)]
4+
[string] $mutagenTag
5+
)
6+
7+
$ErrorActionPreference = "Stop"
8+
9+
$repo = "mutagen-io/mutagen"
10+
$protoPrefix = "pkg"
11+
$entryFile = "service\synchronization\synchronization.proto"
12+
13+
$outputNamespace = "Coder.Desktop.MutagenSdk.Proto"
14+
$outputDir = "MutagenSdk\Proto"
15+
16+
$cloneDir = Join-Path $env:TEMP "coder-desktop-mutagen-proto"
17+
if (Test-Path $cloneDir) {
18+
Write-Host "Found existing mutagen repo at $cloneDir, checking out $mutagenTag..."
19+
# Checkout tag and clean
20+
Push-Location $cloneDir
21+
try {
22+
& git.exe clean -fdx
23+
if ($LASTEXITCODE -ne 0) { throw "Failed to clean $mutagenTag" }
24+
# If we're already on the tag, we don't need to fetch or checkout.
25+
if ((& git.exe name-rev --name-only HEAD) -eq "tags/$mutagenTag") {
26+
Write-Host "Already on $mutagenTag"
27+
}
28+
else {
29+
& git.exe fetch --all
30+
if ($LASTEXITCODE -ne 0) { throw "Failed to fetch all tags" }
31+
& git.exe checkout $mutagenTag
32+
if ($LASTEXITCODE -ne 0) { throw "Failed to checkout $mutagenTag" }
33+
}
34+
}
35+
finally {
36+
Pop-Location
37+
}
38+
}
39+
else {
40+
New-Item -ItemType Directory -Path $cloneDir -Force
41+
42+
Write-Host "Cloning mutagen repo to $cloneDir..."
43+
& git.exe clone `
44+
--depth 1 `
45+
--branch $mutagenTag `
46+
"https://github.com/$repo.git" `
47+
$cloneDir
48+
}
49+
50+
# Read and format the license header for the copied files.
51+
$licenseContent = Get-Content (Join-Path $cloneDir "LICENSE")
52+
# Find the index where MIT License starts so we don't include the preamble.
53+
$mitStartIndex = $licenseContent.IndexOf("MIT License")
54+
$licenseHeader = ($licenseContent[$mitStartIndex..($licenseContent.Length - 1)] | ForEach-Object { (" * " + $_).TrimEnd() }) -join "`n"
55+
56+
$entryFilePath = Join-Path $cloneDir (Join-Path $protoPrefix $entryFile)
57+
if (-not (Test-Path $entryFilePath)) {
58+
throw "Failed to find $entryFilePath in mutagen repo"
59+
}
60+
61+
# Map of src (in the mutagen repo) to dst (within the $outputDir).
62+
$filesToCopy = @{
63+
$entryFilePath = $entryFile
64+
}
65+
66+
function Add-ImportedFiles([string] $path) {
67+
$content = Get-Content $path
68+
foreach ($line in $content) {
69+
if ($line -match '^import "(.+)"') {
70+
$importPath = $matches[1]
71+
72+
# If the import path starts with google, it doesn't exist in the
73+
# mutagen repo, so we need to skip it.
74+
if ($importPath -match '^google/') {
75+
Write-Host "Skipping $importPath"
76+
continue
77+
}
78+
79+
# Mutagen generates from within the pkg directory, so we need to add
80+
# the prefix.
81+
$filePath = Join-Path $cloneDir (Join-Path $protoPrefix $importPath)
82+
if (-not $filesToCopy.ContainsKey($filePath)) {
83+
Write-Host "Adding $filePath $importPath"
84+
$filesToCopy[$filePath] = $importPath
85+
Add-ImportedFiles $filePath
86+
}
87+
}
88+
}
89+
}
90+
91+
Add-ImportedFiles $entryFilePath
92+
93+
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
94+
Push-Location $repoRoot
95+
if (Test-Path $outputDir) {
96+
Remove-Item -Recurse -Force $outputDir
97+
}
98+
New-Item -ItemType Directory -Path $outputDir -Force
99+
100+
try {
101+
foreach ($filePath in $filesToCopy.Keys) {
102+
$protoPath = $filesToCopy[$filePath]
103+
$dstPath = Join-Path $outputDir $protoPath
104+
$destDir = Split-Path -Path $dstPath -Parent
105+
if (-not (Test-Path $destDir)) {
106+
New-Item -ItemType Directory -Path $destDir -Force
107+
}
108+
Copy-Item -Force $filePath $dstPath
109+
110+
# Determine the license header.
111+
$fileHeader = "/*`n" +
112+
" * This file was taken from`n" +
113+
" * https://github.com/$repo/tree/$mutagenTag/$protoPrefix/$protoPath`n" +
114+
" *`n" +
115+
$licenseHeader +
116+
"`n */`n`n"
117+
118+
# Determine the csharp_namespace for the file.
119+
# Remove the filename and capitalize the first letter of each component
120+
# of the path, then join with dots.
121+
$protoDir = Split-Path -Path $protoPath -Parent
122+
$csharpNamespaceSuffix = ($protoDir -split '[/\\]' | ForEach-Object { $_.Substring(0, 1).ToUpper() + $_.Substring(1) }) -join '.'
123+
$csharpNamespace = "$outputNamespace"
124+
if ($csharpNamespaceSuffix) {
125+
$csharpNamespace += ".$csharpNamespaceSuffix"
126+
}
127+
128+
# Add the csharp_namespace declaration.
129+
$content = Get-Content $dstPath -Raw
130+
$content = $fileHeader + $content
131+
$content = $content -replace '(?m)^(package .*?;)', "`$1`noption csharp_namespace = `"$csharpNamespace`";"
132+
133+
# Replace all LF with CRLF to avoid spurious diffs in git.
134+
$content = $content -replace "(?<!`r)`n", "`r`n"
135+
Set-Content -Path $dstPath -Value $content -Encoding UTF8
136+
}
137+
}
138+
finally {
139+
Pop-Location
140+
}

‎MutagenSdk/packages.lock.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"version": 1,
3+
"dependencies": {
4+
"net8.0": {
5+
"Google.Protobuf": {
6+
"type": "Direct",
7+
"requested": "[3.29.3, )",
8+
"resolved": "3.29.3",
9+
"contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw=="
10+
},
11+
"Grpc.Net.Client": {
12+
"type": "Direct",
13+
"requested": "[2.67.0, )",
14+
"resolved": "2.67.0",
15+
"contentHash": "ofTjJQfegWkVlk5R4k/LlwpcucpsBzntygd4iAeuKd/eLMkmBWoXN+xcjYJ5IibAahRpIJU461jABZvT6E9dwA==",
16+
"dependencies": {
17+
"Grpc.Net.Common": "2.67.0",
18+
"Microsoft.Extensions.Logging.Abstractions": "6.0.0"
19+
}
20+
},
21+
"Grpc.Tools": {
22+
"type": "Direct",
23+
"requested": "[2.69.0, )",
24+
"resolved": "2.69.0",
25+
"contentHash": "W5hW4R1h19FCzKb8ToqIJMI5YxnQqGmREEpV8E5XkfCtLPIK5MSHztwQ8gZUfG8qu9fg5MhItjzyPRqQBjnrbA=="
26+
},
27+
"Grpc.Core.Api": {
28+
"type": "Transitive",
29+
"resolved": "2.67.0",
30+
"contentHash": "cL1/2f8kc8lsAGNdfCU25deedXVehhLA6GXKLLN4hAWx16XN7BmjYn3gFU+FBpir5yJynvDTHEypr3Tl0j7x/Q=="
31+
},
32+
"Grpc.Net.Common": {
33+
"type": "Transitive",
34+
"resolved": "2.67.0",
35+
"contentHash": "gazn1cD2Eol0/W5ZJRV4PYbNrxJ9oMs8pGYux5S9E4MymClvl7aqYSmpqgmWAUWvziRqK9K+yt3cjCMfQ3x/5A==",
36+
"dependencies": {
37+
"Grpc.Core.Api": "2.67.0"
38+
}
39+
},
40+
"Microsoft.Extensions.Logging.Abstractions": {
41+
"type": "Transitive",
42+
"resolved": "6.0.0",
43+
"contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA=="
44+
}
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)
Please sign in to comment.