Retire legacy implementations and flatten managed layout
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<!-- Where the CMake `windows-client` preset puts the built DLL (see ../../CMakePresets.json
|
||||
and clients/windows/README.md). Override with an environment variable or a
|
||||
Directory.Build.props further down the tree if your build/ lives elsewhere. -->
|
||||
<VoiceCatNativeDir Condition="'$(VoiceCatNativeDir)' == ''">$(MSBuildThisFileDirectory)..\..\build\windows-client\bin</VoiceCatNativeDir>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,43 +1,21 @@
|
||||
# VoiceCat — Windows client
|
||||
# Windows client
|
||||
|
||||
The WinForms .NET 10 client uses `VoiceCat.Core` for TLS, TOFU, protocol state and encrypted UDP, and `VoiceCat.Audio` for streams, jitter, Opus and mixing. Its only native runtime component is `voicecat_media.dll`, the narrow Opus/RNNoise C shim. The old `voicecat.dll` core is no longer loaded or published.
|
||||
|
||||
## Build
|
||||
|
||||
Stage the pinned native media dependencies once, then build the solution:
|
||||
The .NET 10 WinForms client uses `VoiceCat.Core` and `VoiceCat.Audio`. Its only native runtime
|
||||
component is `voicecat_media.dll`, the narrow Opus/RNNoise shim.
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1
|
||||
./scripts/build-native.ps1
|
||||
dotnet restore clients/windows/VoiceCat.slnx --locked-mode
|
||||
dotnet build clients/windows/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
The original `VoiceCat.Interop` project remains in the repository as a migration oracle. The app references `VoiceCat.Managed`, whose compatibility facade lets the existing accessible WinForms UI keep its event-pump shape while all networking and audio state live in the idiomatic managed libraries.
|
||||
|
||||
## Publish
|
||||
|
||||
```powershell
|
||||
./clients/windows/publish-client.ps1
|
||||
```
|
||||
|
||||
This produces a self-contained `win-x64` distribution in `dotnet/artifacts/client/win-x64`. The script requires `voicecat_media.dll` and fails if the legacy `voicecat.dll` appears.
|
||||
|
||||
The noninteractive startup and real WASAPI device check is:
|
||||
Publishing produces `artifacts/client/win-x64`. The noninteractive form and WASAPI device gate
|
||||
is:
|
||||
|
||||
```powershell
|
||||
./dotnet/artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio
|
||||
./artifacts/client/win-x64/VoiceCat.App.exe --smoke-test --audio
|
||||
```
|
||||
|
||||
`--smoke-test` constructs the real main form and pumps the managed client. `--audio` additionally opens the default WASAPI capture and render endpoints, moves PCM through both for three seconds, and fails if capture produces no samples.
|
||||
|
||||
## Run manually
|
||||
|
||||
```powershell
|
||||
# Terminal 1
|
||||
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe
|
||||
|
||||
# Terminal 2
|
||||
dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj
|
||||
```
|
||||
|
||||
Manual release validation includes NVDA navigation and a sustained two-client listen test, as tracked in `docs/roadmap.md`.
|
||||
Manual release validation includes keyboard operation, NVDA navigation, curated announcements,
|
||||
and a sustained real call.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Audio;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using VoiceCat.App.Audio;
|
||||
using VoiceCat.App.Models;
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using VoiceCat.App.Models;
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using VoiceCat.App.Audio;
|
||||
using VoiceCat.App.Models;
|
||||
using VoiceCat.App.Native;
|
||||
using VoiceCat.App.Notifications;
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Windows;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ internal static partial class RawInput
|
||||
public uint ExtraInformation;
|
||||
}
|
||||
|
||||
// ── P/Invoke (source-generated via LibraryImport, matching VoiceCat.Interop) ────────────
|
||||
// Raw Input P/Invoke (source-generated via LibraryImport).
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool RegisterRawInputDevices(
|
||||
|
||||
@@ -20,7 +20,7 @@ internal static class Program
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new VoiceCat.Interop.VoiceCatClient("Smoke", "test");
|
||||
using var client = new VoiceCat.Windows.VoiceCatClient("Smoke", "test");
|
||||
using var form = new MainForm(client, 0, "Smoke", "Managed core");
|
||||
form.CreateControl();
|
||||
if (form.Controls.Count == 0 || string.IsNullOrEmpty(form.Text)) return 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VoiceCat.Managed\VoiceCat.Managed.csproj" />
|
||||
<ProjectReference Include="..\VoiceCat.Windows\VoiceCat.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.managed": {
|
||||
"voicecat.windows": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Core": "[1.0.0, )"
|
||||
@@ -60,4 +60,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.managed": {
|
||||
"voicecat.windows": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Core": "[1.0.0, )"
|
||||
@@ -74,4 +74,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using VoiceCat.Interop;
|
||||
|
||||
namespace VoiceCat.Interop.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// API-surface smoke tests for StreamFeedPcm / SetPcmSink through the P/Invoke layer.
|
||||
/// These tests need voicecat.dll but NOT a running server — they verify the C# wrapper
|
||||
/// compiles, is callable, and the error paths work at the C-ABI boundary. Full E2E relay
|
||||
/// verification is handled by tests/test_external_pcm.cpp (C++ ctest).
|
||||
/// </summary>
|
||||
public sealed class ExternalPcmTests
|
||||
{
|
||||
[Fact]
|
||||
public void StreamFeedPcm_NoActiveStream_ReturnsInvalidArg()
|
||||
{
|
||||
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
|
||||
var pcm = new short[960];
|
||||
// Stream 0 doesn't exist — core must return InvalidArg, not crash.
|
||||
var result = client.StreamFeedPcm(0, pcm, 960, channels: 1);
|
||||
Assert.Equal(VcResult.InvalidArg, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamFeedPcm_InvalidChannels_ReturnsInvalidArg()
|
||||
{
|
||||
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
|
||||
var pcm = new short[960 * 3];
|
||||
// channels=3 is not supported — trampoline rejects before touching audio state.
|
||||
var result = client.StreamFeedPcm(0, pcm, 960, channels: 3);
|
||||
Assert.Equal(VcResult.InvalidArg, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPcmSink_Zero_ReturnsOk()
|
||||
{
|
||||
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
|
||||
// IntPtr.Zero = disable — default state, must be a no-op that returns Ok.
|
||||
var result = client.SetPcmSink(IntPtr.Zero, IntPtr.Zero);
|
||||
Assert.Equal(VcResult.Ok, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPcmSink_EnableThenDisable_BothSucceed()
|
||||
{
|
||||
using var client = new VoiceCatClient("ext-pcm-test", "0.1", VcLogLevel.Off);
|
||||
|
||||
// Keep the delegate alive until after we unregister it (not just until the P/Invoke call).
|
||||
NativeMethods.VcPcmSinkCallback sink = static (_, _, _, _, _, _, _) => { };
|
||||
nint fp = Marshal.GetFunctionPointerForDelegate(sink);
|
||||
Assert.Equal(VcResult.Ok, client.SetPcmSink(fp, IntPtr.Zero));
|
||||
Assert.Equal(VcResult.Ok, client.SetPcmSink(IntPtr.Zero, IntPtr.Zero));
|
||||
GC.KeepAlive(sink);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- voicecat.dll must exist (build the `windows-client` CMake preset first — see
|
||||
clients/windows/README.md) before these tests can run; the P/Invoke smoke tests need
|
||||
a real native library to call into. -->
|
||||
<ItemGroup>
|
||||
<Content Include="$(VoiceCatNativeDir)\voicecat.dll" Condition="Exists('$(VoiceCatNativeDir)\voicecat.dll')">
|
||||
<Link>voicecat.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="VoiceCatCheckNativeDll" BeforeTargets="Build">
|
||||
<Error Condition="!Exists('$(VoiceCatNativeDir)\voicecat.dll')"
|
||||
Text="voicecat.dll not found at '$(VoiceCatNativeDir)'. Build it first: cmake --preset windows-client && cmake --build --preset windows-client (see clients/windows/README.md)." />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -1,401 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using VoiceCat.Interop;
|
||||
|
||||
namespace VoiceCat.Interop.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow
|
||||
/// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real
|
||||
/// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow
|
||||
/// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through
|
||||
/// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention
|
||||
/// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch.
|
||||
/// </summary>
|
||||
public sealed class VoiceCatClientSmokeTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly Process _server;
|
||||
private readonly ushort _port;
|
||||
|
||||
public VoiceCatClientSmokeTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
string serverExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-server.exe");
|
||||
Assert.True(File.Exists(serverExe),
|
||||
$"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " +
|
||||
"(cmake --preset dev && cmake --build --preset dev).");
|
||||
|
||||
var psi = new ProcessStartInfo(serverExe)
|
||||
{
|
||||
Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
_server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe");
|
||||
|
||||
// "[voicecat-server] <name> — TCP :<port> UDP :<port>" — see server/src/server.cpp.
|
||||
// ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a
|
||||
// server that never prints anything (crash, hang) can't hang this constructor forever
|
||||
// — the deadline check must apply to the read itself, not just the loop around it.
|
||||
ushort? port = null;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (port is null && DateTime.UtcNow < deadline)
|
||||
{
|
||||
var readTask = _server.StandardOutput.ReadLineAsync();
|
||||
var remaining = deadline - DateTime.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break;
|
||||
string? line = readTask.Result;
|
||||
if (line is null) break;
|
||||
var m = Regex.Match(line, @"TCP :(\d+)");
|
||||
if (m.Success) port = ushort.Parse(m.Groups[1].Value);
|
||||
}
|
||||
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
||||
_port = port!.Value;
|
||||
|
||||
// Provision a known admin account so we can exercise moderation wrappers end-to-end.
|
||||
string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe");
|
||||
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset.");
|
||||
var adminPsi = new ProcessStartInfo(adminExe)
|
||||
{
|
||||
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe"))
|
||||
{
|
||||
Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s.");
|
||||
Assert.Equal(0, adminProc.ExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ }
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json")))
|
||||
dir = dir.Parent;
|
||||
return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
private static bool PumpUntil(VoiceCatClient client, Func<bool> predicate, int timeoutMs)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
client.PumpEvents();
|
||||
if (predicate()) return true;
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
client.PumpEvents();
|
||||
return predicate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionString_IsNonEmpty()
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_Tofu_Auth_ListChannels_RoundTrips()
|
||||
{
|
||||
var events = new List<VoiceCatEvent>();
|
||||
using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off,
|
||||
tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt"));
|
||||
client.EventReceived += events.Add;
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
||||
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke"));
|
||||
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000),
|
||||
"did not receive VC_EVENT_SERVER_IDENTITY");
|
||||
var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity);
|
||||
Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a);
|
||||
Assert.NotNull(identityEvent.Text);
|
||||
Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators
|
||||
|
||||
// Auth must NOT complete before the identity is confirmed.
|
||||
Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800));
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
||||
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000),
|
||||
"did not receive VC_EVENT_AUTH_RESULT after confirming identity");
|
||||
var authEvent = events.First(e => e.Type == VcEventType.AuthResult);
|
||||
Assert.Equal(VcResult.Ok, authEvent.Result);
|
||||
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000),
|
||||
"did not receive VC_EVENT_CHANNEL_LIST");
|
||||
|
||||
var channels = client.ListChannels();
|
||||
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
||||
|
||||
// Permissions getter round-trip.
|
||||
var perms = client.GetPermissions();
|
||||
Assert.False(perms.IsAdmin);
|
||||
Assert.False(perms.CanKick);
|
||||
|
||||
// Moderation request wrappers queue without error. As a guest, account listing
|
||||
// is rejected by the server with a GenericResult, which proves the wrapper path works
|
||||
// end-to-end and that the event type is delivered through P/Invoke.
|
||||
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
|
||||
"did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts");
|
||||
var generic = events.First(e => e.Type == VcEventType.GenericResult);
|
||||
Assert.Equal(VcResult.PermissionDenied, generic.Result);
|
||||
|
||||
client.Disconnect();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_ChannelCrud_AccountCrud_RoundTrips()
|
||||
{
|
||||
var events = new List<VoiceCatEvent>();
|
||||
using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off,
|
||||
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt"));
|
||||
client.EventReceived += events.Add;
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
||||
Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123"));
|
||||
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
||||
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
||||
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
||||
|
||||
var perms = client.GetPermissions();
|
||||
Assert.True(perms.IsAdmin || perms.CanAdminAccounts);
|
||||
|
||||
// Channel CRUD
|
||||
Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo(
|
||||
Id: 0,
|
||||
ParentId: 0,
|
||||
Name: "CSharp Test Channel",
|
||||
Topic: "Created by C# smoke test",
|
||||
PasswordProtected: false,
|
||||
Password: null,
|
||||
MaxUsers: 42,
|
||||
SortOrder: 0,
|
||||
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
||||
"CreateChannel did not succeed");
|
||||
|
||||
var channels = client.ListChannels();
|
||||
var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel");
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("Created by C# smoke test", created.Topic);
|
||||
Assert.True(created.PasswordProtected == false);
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo(
|
||||
created.Id,
|
||||
created.ParentId,
|
||||
created.Name,
|
||||
"Updated topic",
|
||||
created.PasswordProtected,
|
||||
null,
|
||||
100,
|
||||
0,
|
||||
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
|
||||
events.Clear();
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
||||
"EditChannel did not succeed");
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id));
|
||||
events.Clear();
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
||||
"DeleteChannel did not succeed");
|
||||
|
||||
// Account CRUD
|
||||
Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw"));
|
||||
events.Clear();
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
||||
"CreateAccount did not succeed");
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000));
|
||||
var accounts = client.ListAccounts();
|
||||
Assert.Contains(accounts, a => a.Username == "csharp_smoke_user");
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123"));
|
||||
events.Clear();
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
||||
"ResetPassword did not succeed");
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user"));
|
||||
events.Clear();
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
||||
"DeleteAccount did not succeed");
|
||||
|
||||
client.Disconnect();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's
|
||||
/// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the
|
||||
/// StreamAnnounce succeeds regardless of whether the loopback device actually initializes
|
||||
/// on a headless box, so this test passes in CI while still exercising the full
|
||||
/// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke.
|
||||
/// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ScreenAudioStream_Starts_And_Stops()
|
||||
{
|
||||
var events = new List<VoiceCatEvent>();
|
||||
using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off,
|
||||
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt"));
|
||||
client.EventReceived += events.Add;
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
||||
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen"));
|
||||
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
||||
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
||||
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.JoinVoice());
|
||||
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
|
||||
// Give the async UDP binding handshake a moment to land before announcing a stream
|
||||
// (mirrors vccli's 500ms sleep after auth).
|
||||
Thread.Sleep(500);
|
||||
|
||||
var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
||||
Assert.Equal(VcResult.Ok, startResult);
|
||||
Assert.True(streamId != 0, "StreamId should be non-zero on success");
|
||||
|
||||
// The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp
|
||||
// handle_stream_announce_result), so we see our own screen-audio stream start.
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000),
|
||||
"did not receive VC_EVENT_STREAM_STARTED for screen-audio stream");
|
||||
|
||||
Assert.Equal(VcResult.Ok, client.StopStream(streamId));
|
||||
Assert.True(PumpUntil(client,
|
||||
() => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000),
|
||||
"did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream");
|
||||
|
||||
client.Disconnect();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two
|
||||
/// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then
|
||||
/// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in
|
||||
/// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++
|
||||
/// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PerStream_RecvControls_RoundTrip_Through_PInvoke()
|
||||
{
|
||||
var eventsA = new List<VoiceCatEvent>();
|
||||
var eventsB = new List<VoiceCatEvent>();
|
||||
using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off,
|
||||
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt"));
|
||||
using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off,
|
||||
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt"));
|
||||
a.EventReceived += eventsA.Add;
|
||||
b.EventReceived += eventsB.Add;
|
||||
|
||||
// Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against
|
||||
// the same server (mirrors the C++ test_m3_multistream harness, which connects A to
|
||||
// completion before starting B).
|
||||
Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port));
|
||||
Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA"));
|
||||
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
||||
Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true));
|
||||
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
||||
Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result);
|
||||
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
||||
|
||||
Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port));
|
||||
Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB"));
|
||||
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
||||
Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true));
|
||||
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
||||
Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result);
|
||||
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
||||
|
||||
// Both join Lobby (channel 1) so voice relays between them.
|
||||
Assert.Equal(VcResult.Ok, a.JoinChannel(1, null));
|
||||
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000),
|
||||
"A did not receive VC_EVENT_JOIN_RESULT");
|
||||
Assert.Equal(VcResult.Ok, b.JoinChannel(1, null));
|
||||
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
|
||||
"B did not receive VC_EVENT_JOIN_RESULT");
|
||||
|
||||
Assert.Equal(VcResult.Ok, a.JoinVoice());
|
||||
Assert.Equal(VcResult.Ok, b.JoinVoice());
|
||||
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
|
||||
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.VoiceState && e.U32a == 1), 5000));
|
||||
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
|
||||
Thread.Sleep(500);
|
||||
|
||||
// A publishes a MIC stream.
|
||||
var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic");
|
||||
Assert.Equal(VcResult.Ok, startResult);
|
||||
Assert.True(streamId != 0);
|
||||
|
||||
// B sees A's stream and can enumerate it.
|
||||
uint aUid = 0;
|
||||
Assert.True(PumpUntil(b, () =>
|
||||
{
|
||||
return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId);
|
||||
}, 5000), "B did not see A's STREAM_STARTED");
|
||||
// Resolve A's user id from B's user list.
|
||||
Assert.True(PumpUntil(b, () =>
|
||||
{
|
||||
aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0;
|
||||
return aUid != 0;
|
||||
}, 3000), "could not resolve A's user id on B");
|
||||
Assert.True(aUid != 0);
|
||||
|
||||
Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000),
|
||||
"B could not enumerate A's stream");
|
||||
var bStreams = b.ListUserStreams(aUid);
|
||||
Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic);
|
||||
|
||||
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
|
||||
var (r0, st0) = b.GetRemoteStream(aUid, streamId);
|
||||
Assert.Equal(VcResult.Ok, r0);
|
||||
Assert.NotNull(st0);
|
||||
Assert.Equal(1.0f, st0!.Gain);
|
||||
Assert.False(st0.Muted);
|
||||
Assert.False(st0.NoiseReduction);
|
||||
|
||||
// B turns A down to 0.4×, mutes, and enables NR — then reads it back.
|
||||
Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true));
|
||||
var (r1, st1) = b.GetRemoteStream(aUid, streamId);
|
||||
Assert.Equal(VcResult.Ok, r1);
|
||||
Assert.NotNull(st1);
|
||||
Assert.Equal(0.4f, st1!.Gain);
|
||||
Assert.True(st1.Muted);
|
||||
Assert.True(st1.NoiseReduction);
|
||||
|
||||
// Unknown stream id on a known user -> INVALID_ARG.
|
||||
var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF);
|
||||
Assert.Equal(VcResult.InvalidArg, rBad);
|
||||
Assert.Null(stBad);
|
||||
|
||||
a.Disconnect();
|
||||
b.Disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// Shared "walk a native array of owned-struct entries, convert to managed records, free the
|
||||
// native list" pattern — identical shape for vc_device_list/vc_channel_list/vc_user_list/
|
||||
// vc_stream_summary_list (all core-allocated, caller-freed; see voicecat.h's doc comments on
|
||||
// each). The matching vc_free_*_list call happens INSIDE each ToManaged here, immediately
|
||||
// after the conversion, so callers never need to remember to free anything themselves.
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
internal static class Marshaling
|
||||
{
|
||||
public static List<DeviceInfo> ToManaged(ref VcDeviceListNative native)
|
||||
{
|
||||
var result = new List<DeviceInfo>((int)native.Count);
|
||||
int size = Marshal.SizeOf<VcDeviceNative>();
|
||||
for (nuint i = 0; i < native.Count; i++)
|
||||
{
|
||||
var raw = Marshal.PtrToStructure<VcDeviceNative>(native.Items + (int)i * size);
|
||||
result.Add(new DeviceInfo(
|
||||
Marshal.PtrToStringUTF8(raw.Id) ?? string.Empty,
|
||||
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
|
||||
raw.IsDefault != 0));
|
||||
}
|
||||
NativeMethods.vc_free_device_list(ref native);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<ChannelInfo> ToManaged(ref VcChannelListNative native)
|
||||
{
|
||||
var result = new List<ChannelInfo>((int)native.Count);
|
||||
int size = Marshal.SizeOf<VcChannelNative>();
|
||||
for (nuint i = 0; i < native.Count; i++)
|
||||
{
|
||||
var raw = Marshal.PtrToStructure<VcChannelNative>(native.Items + (int)i * size);
|
||||
result.Add(new ChannelInfo(
|
||||
raw.Id,
|
||||
raw.ParentId,
|
||||
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
|
||||
Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty,
|
||||
raw.PasswordProtected != 0,
|
||||
raw.MaxUsers,
|
||||
raw.SortOrder,
|
||||
ToManaged(in raw.Audio)));
|
||||
}
|
||||
NativeMethods.vc_free_channel_list(ref native);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<UserInfo> ToManaged(ref VcUserListNative native)
|
||||
{
|
||||
var result = new List<UserInfo>((int)native.Count);
|
||||
int size = Marshal.SizeOf<VcUserNative>();
|
||||
for (nuint i = 0; i < native.Count; i++)
|
||||
{
|
||||
var raw = Marshal.PtrToStructure<VcUserNative>(native.Items + (int)i * size);
|
||||
result.Add(new UserInfo(
|
||||
raw.Id,
|
||||
Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty,
|
||||
raw.IsGuest != 0,
|
||||
raw.ChannelId,
|
||||
raw.SelfMicMuted != 0,
|
||||
raw.SelfDeafened != 0,
|
||||
raw.ServerMuted != 0,
|
||||
raw.ServerDeafened != 0,
|
||||
raw.VoiceSubscribed != 0));
|
||||
}
|
||||
NativeMethods.vc_free_user_list(ref native);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<StreamSummary> ToManaged(ref VcStreamSummaryListNative native)
|
||||
{
|
||||
var result = new List<StreamSummary>((int)native.Count);
|
||||
int size = Marshal.SizeOf<VcStreamSummaryNative>();
|
||||
for (nuint i = 0; i < native.Count; i++)
|
||||
{
|
||||
var raw = Marshal.PtrToStructure<VcStreamSummaryNative>(native.Items + (int)i * size);
|
||||
result.Add(new StreamSummary(
|
||||
raw.StreamId,
|
||||
raw.Kind,
|
||||
Marshal.PtrToStringUTF8(raw.Label) ?? string.Empty));
|
||||
}
|
||||
NativeMethods.vc_free_stream_summary_list(ref native);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static RemoteStreamState ToManaged(in VcRemoteStreamStateNative native) => new(
|
||||
native.Gain,
|
||||
native.Muted != 0,
|
||||
native.NoiseReduction != 0);
|
||||
|
||||
public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new(
|
||||
native.Codec,
|
||||
native.Mode != 0,
|
||||
native.SampleRate,
|
||||
native.BitrateBps,
|
||||
native.FrameMs,
|
||||
native.Application,
|
||||
native.Fec != 0,
|
||||
native.ExpectedPacketLoss,
|
||||
native.Dtx != 0,
|
||||
native.Complexity,
|
||||
native.Dred != 0);
|
||||
|
||||
public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new(
|
||||
native.CanCreateTempChannel != 0,
|
||||
native.CanKick != 0,
|
||||
native.CanBan != 0,
|
||||
native.CanMoveUsers != 0,
|
||||
native.CanAdminAccounts != 0,
|
||||
native.IsAdmin != 0);
|
||||
|
||||
public static List<AccountInfo> ToManaged(ref VcAccountListNative native)
|
||||
{
|
||||
var result = new List<AccountInfo>((int)native.Count);
|
||||
int size = Marshal.SizeOf<VcAccountNative>();
|
||||
for (nuint i = 0; i < native.Count; i++)
|
||||
{
|
||||
var raw = Marshal.PtrToStructure<VcAccountNative>(native.Items + (int)i * size);
|
||||
result.Add(new AccountInfo(
|
||||
Marshal.PtrToStringUTF8(raw.Username) ?? string.Empty,
|
||||
raw.IsAdmin != 0,
|
||||
raw.CreatedAtUnixMs,
|
||||
raw.LastLoginUnixMs));
|
||||
}
|
||||
NativeMethods.vc_free_account_list(ref native);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// [UnmanagedCallersOnly] static methods for vc_callbacks.on_event/on_level — true native
|
||||
// function pointers, not GC-tracked delegates (avoids the classic P/Invoke pitfall where a
|
||||
// delegate is collected by the GC sometime after the call that registered it returns; see
|
||||
// docs/tech-stack.md §2). vc_callbacks.user is a GCHandle-wrapped VoiceCatClient (allocated in
|
||||
// VoiceCatClient's constructor, freed in Dispose) — these methods must be static, so they
|
||||
// resolve back to the right client instance via that handle rather than closing over state.
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
internal static unsafe class NativeCallbacks
|
||||
{
|
||||
[UnmanagedCallersOnly]
|
||||
internal static void OnEvent(IntPtr userContext, VcEventNative* ev)
|
||||
{
|
||||
if (userContext == IntPtr.Zero) return;
|
||||
if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return;
|
||||
|
||||
// CRITICAL (voicecat.h's vc_event doc comment): ev->Text is owned by the core and
|
||||
// valid ONLY for the duration of this callback. Convert to a managed string NOW,
|
||||
// before returning — never store/queue the raw VcEventNative across the callback
|
||||
// boundary, or Text will be a dangling pointer by the time it's read.
|
||||
client.EnqueueEvent(VoiceCatEvent.FromNative(*ev));
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly]
|
||||
internal static void OnLevel(IntPtr userContext, uint streamId, float rms)
|
||||
{
|
||||
if (userContext == IntPtr.Zero) return;
|
||||
if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return;
|
||||
client.EnqueueLevel(streamId, rms);
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// Raw P/Invoke surface over core/include/voicecat.h, via LibraryImport (source-generated —
|
||||
// no runtime reflection marshaling stub; see docs/tech-stack.md §2). One entry per voicecat.h
|
||||
// function. `vc_client*` is represented as a raw `nint` here — VoiceCatClientHandle (a
|
||||
// SafeHandle) owns the create/destroy lifetime one level up; these declarations never see a
|
||||
// SafeHandle directly, per .NET's own SafeHandle convention.
|
||||
//
|
||||
// "voicecat" resolves to voicecat.dll via the OS's standard DLL search order (same directory
|
||||
// as the .exe first) — see clients/windows/README.md for how it gets there at build time.
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "voicecat";
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
|
||||
// NOTE: these two return `const char*` pointing at STATIC string literals the core never
|
||||
// expects the caller to free. Declaring them as `string` with StringMarshalling.Utf8
|
||||
// would be wrong: the built-in Utf8StringMarshaller's return-value convention assumes the
|
||||
// native callee allocated the string FOR this call and that the marshaller should free it
|
||||
// afterward — calling that on a static literal corrupts the heap (confirmed: it crashes
|
||||
// with STATUS_HEAP_CORRUPTION / 0xC0000374). Return the raw pointer instead and convert
|
||||
// with Marshal.PtrToStringUTF8 ourselves, without ever freeing it — see VoiceCatClient.cs.
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial nint vc_version_string();
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial nint vc_result_string(VcResult code);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial nint vc_client_create(in VcConfigNative cfg, VcCallbacksNative cb);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial void vc_client_destroy(nint c);
|
||||
|
||||
// ── Connection & auth (async; results via on_event) ────────────────────────────────────
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_connect(nint c, string host, ushort port);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_disconnect(nint c);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_authenticate_guest(nint c, string nickname);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_authenticate_user(nint c, string username, string password);
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_join_channel(nint c, uint channelId, string? password);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_leave_channel(nint c);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_join_voice(nint c);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_leave_voice(nint c);
|
||||
|
||||
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc,
|
||||
out uint outStreamId);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_stream_stop(nint c, uint streamId);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_capture_channels(nint c, uint streamId, uint channels);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_audio_restart(nint c);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_vad_threshold(nint c, float threshold);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_push_to_talk(nint c, int active);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_output_volume(nint c, float gain);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_input_gain(nint c, float gain);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_input_noise_reduction(nint c, int enable);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
||||
float gain, int muted, int noiseReduction);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_get_remote_stream(nint c, uint userId, uint streamId,
|
||||
out VcRemoteStreamStateNative outState);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId,
|
||||
uint streamId, out VcAudioConfigNative outCfg);
|
||||
|
||||
// TEST-ONLY in the core (see voicecat.h) — declared for ABI parity; the real app never
|
||||
// calls this (no microphone-bypass path in production UI).
|
||||
[LibraryImport(LibName)]
|
||||
internal static unsafe partial VcResult vc_test_inject_capture(nint c, uint streamId,
|
||||
short* pcm, nuint samples);
|
||||
|
||||
// ── External PCM feed / tap ──────────────────────────────────────────────────────────
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static unsafe partial VcResult vc_stream_feed_pcm(nint c, uint streamId,
|
||||
short* pcm, nuint samplesPerChannel, uint channels);
|
||||
|
||||
// Delegate type for the PCM sink callback — callers convert to a native function
|
||||
// pointer via Marshal.GetFunctionPointerForDelegate (for instance members) or by casting
|
||||
// a static lambda to delegate* unmanaged<> (for [UnmanagedCallersOnly] statics).
|
||||
// Keep the delegate alive for the lifetime of the sink registration.
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate void VcPcmSinkCallback(IntPtr user, uint userId, uint streamId,
|
||||
IntPtr pcm, nuint samplesPerChannel, uint channels, uint sampleRate);
|
||||
|
||||
// cb is a raw function pointer (IntPtr.Zero = disable). Use
|
||||
// Marshal.GetFunctionPointerForDelegate(sinkDelegate) to convert from VcPcmSinkCallback.
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_pcm_sink(nint c, nint cb, IntPtr user);
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────────────────
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_send_text(nint c, VcTextScope scope, uint targetId,
|
||||
string utf8);
|
||||
|
||||
// ── Device enumeration ───────────────────────────────────────────────────────────────
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_list_devices(nint c, VcDeviceKind kind,
|
||||
out VcDeviceListNative outList);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
|
||||
|
||||
// ── Channel / user / stream snapshot getters ────────────────────────────────────────────
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial void vc_free_channel_list(ref VcChannelListNative list);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_list_users(nint c, out VcUserListNative outList);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial void vc_free_user_list(ref VcUserListNative list);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_list_user_streams(nint c, uint userId,
|
||||
out VcStreamSummaryListNative outList);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
|
||||
|
||||
// ── TOFU server-identity gate ───────────────────────────────────────────────────────────
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
|
||||
nuint bufCap, out nuint outLen);
|
||||
|
||||
// ── Moderation & admin ───────────────────────────────────────────────────────────────────
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_ban_user(nint c, uint userId, string? reason,
|
||||
ulong expiresUnixMs);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_permission(nint c, uint userId,
|
||||
in VcPermissionsNative perms);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_server_mute(nint c, uint userId, int muted,
|
||||
int deafened);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_move_user(nint c, uint userId, uint channelId);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_create_channel(nint c, in VcChannelInfoNative info);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_edit_channel(nint c, in VcChannelInfoNative info);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_delete_channel(nint c, uint channelId);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_create_account(nint c, string username, string password);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_reset_password(nint c, string username,
|
||||
string newPassword);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
internal static partial VcResult vc_delete_account(nint c, string username);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_list_accounts(nint c);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_get_account_list(nint c, out VcAccountListNative outList);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial void vc_free_account_list(ref VcAccountListNative list);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_get_permissions(nint c, out VcPermissionsNative outPerms);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// Native (blittable) struct layouts mirroring voicecat.h field-for-field. These are the raw
|
||||
// P/Invoke shapes — NativeMethods.cs uses them directly; Marshaling.cs converts them to the
|
||||
// managed record types in Models.cs. const char* fields stay IntPtr here (LibraryImport's
|
||||
// StringMarshalling only auto-converts top-level string parameters/returns, not struct
|
||||
// fields) and must be hand-marshaled — see Marshaling.cs and VoiceCatClient.cs.
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcEventNative
|
||||
{
|
||||
public VcEventType Type;
|
||||
public VcConnectionState ConnectionState;
|
||||
public int Result; // vc_result
|
||||
public uint UserId;
|
||||
public uint ChannelId;
|
||||
public uint StreamId;
|
||||
public VcTextScope TextScope;
|
||||
public uint U32a;
|
||||
public IntPtr Text; // owned by core, valid ONLY for the callback's duration
|
||||
public ulong TimestampUnixMs;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcConfigNative
|
||||
{
|
||||
public IntPtr ClientName;
|
||||
public IntPtr ClientVersion;
|
||||
public VcLogLevel LogLevel;
|
||||
public IntPtr TofuStorePath;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcCallbacksNative
|
||||
{
|
||||
public IntPtr OnEvent; // delegate* unmanaged<IntPtr, VcEventNative*, void>
|
||||
public IntPtr OnLevel; // delegate* unmanaged<IntPtr, uint, float, void>
|
||||
public IntPtr User;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcStreamDescNative
|
||||
{
|
||||
public VcStreamKind Kind;
|
||||
// Device selection uses vc_set_input_device; stream start passes null.
|
||||
public IntPtr DeviceId;
|
||||
public IntPtr Label;
|
||||
// Mirrors vc_stream_desc::external_feed. When 1, the core skips its own WASAPI loopback
|
||||
// and the caller feeds PCM via StreamFeedPcm (per-app capture path on Windows).
|
||||
public int ExternalFeed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcAudioConfigNative
|
||||
{
|
||||
public uint Codec;
|
||||
public uint Mode;
|
||||
public uint SampleRate;
|
||||
public uint BitrateBps;
|
||||
public uint FrameMs;
|
||||
public uint Application;
|
||||
public int Fec;
|
||||
public uint ExpectedPacketLoss;
|
||||
public int Dtx;
|
||||
public uint Complexity;
|
||||
public int Dred;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcPermissionsNative
|
||||
{
|
||||
public int CanCreateTempChannel;
|
||||
public int CanKick;
|
||||
public int CanBan;
|
||||
public int CanMoveUsers;
|
||||
public int CanAdminAccounts;
|
||||
public int IsAdmin;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcChannelInfoNative
|
||||
{
|
||||
public uint Id;
|
||||
public uint ParentId;
|
||||
public IntPtr Name;
|
||||
public IntPtr Topic;
|
||||
public int PasswordProtected;
|
||||
public IntPtr Password;
|
||||
public uint MaxUsers;
|
||||
public uint SortOrder;
|
||||
public VcAudioConfigNative Audio;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcDeviceNative
|
||||
{
|
||||
public IntPtr Id;
|
||||
public IntPtr Name;
|
||||
public int IsDefault;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcDeviceListNative
|
||||
{
|
||||
public IntPtr Items;
|
||||
public nuint Count;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcChannelNative
|
||||
{
|
||||
public uint Id;
|
||||
public uint ParentId;
|
||||
public IntPtr Name;
|
||||
public IntPtr Topic;
|
||||
public int PasswordProtected;
|
||||
public uint MaxUsers;
|
||||
public uint SortOrder;
|
||||
public VcAudioConfigNative Audio;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcChannelListNative
|
||||
{
|
||||
public IntPtr Items;
|
||||
public nuint Count;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcUserNative
|
||||
{
|
||||
public uint Id;
|
||||
public IntPtr Nickname;
|
||||
public int IsGuest;
|
||||
public uint ChannelId;
|
||||
public int SelfMicMuted;
|
||||
public int SelfDeafened;
|
||||
public int ServerMuted;
|
||||
public int ServerDeafened;
|
||||
public int VoiceSubscribed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcUserListNative
|
||||
{
|
||||
public IntPtr Items;
|
||||
public nuint Count;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcStreamSummaryNative
|
||||
{
|
||||
public uint StreamId;
|
||||
public VcStreamKind Kind;
|
||||
public IntPtr Label;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcStreamSummaryListNative
|
||||
{
|
||||
public IntPtr Items;
|
||||
public nuint Count;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcRemoteStreamStateNative
|
||||
{
|
||||
public float Gain;
|
||||
public int Muted;
|
||||
public int NoiseReduction;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcAccountNative
|
||||
{
|
||||
public IntPtr Username;
|
||||
public int IsAdmin;
|
||||
public ulong CreatedAtUnixMs;
|
||||
public ulong LastLoginUnixMs;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct VcAccountListNative
|
||||
{
|
||||
public IntPtr Items;
|
||||
public nuint Count;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Native function pointers (NativeCallbacks.cs) and struct-array pointer walking
|
||||
(Marshaling.cs) need unsafe blocks. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<RootNamespace>VoiceCat.Interop</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup><InternalsVisibleTo Include="VoiceCat.Interop.Tests" /></ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,441 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
// The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around
|
||||
// NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates").
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
public sealed class VoiceCatClient : IDisposable
|
||||
{
|
||||
private readonly VoiceCatClientHandle _handle = new();
|
||||
private readonly GCHandle _selfHandle;
|
||||
|
||||
// Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not
|
||||
// just vc_client_create(): client_name/client_version are read later, whenever connect()
|
||||
// actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by
|
||||
// value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy
|
||||
// has returned (which synchronously joins every internal thread, so nothing can still be
|
||||
// reading these pointers by then).
|
||||
private nint _clientNamePtr;
|
||||
private nint _clientVersionPtr;
|
||||
private nint _tofuStorePathPtr;
|
||||
|
||||
private readonly Channel<VoiceCatEvent> _events =
|
||||
Channel.CreateUnbounded<VoiceCatEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
});
|
||||
|
||||
// on_level fires far more often than on_event and intermediate values are visually
|
||||
// irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, float> _latestLevels = new();
|
||||
|
||||
/// <summary>Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's
|
||||
/// doc comment) for every event, in order, never coalesced.</summary>
|
||||
public event Action<VoiceCatEvent>? EventReceived;
|
||||
|
||||
/// <summary>Raised from PumpEvents() with the latest RMS level per stream_id since the
|
||||
/// last pump.</summary>
|
||||
public event Action<uint, float>? LevelChanged;
|
||||
|
||||
public unsafe VoiceCatClient(string clientName, string clientVersion,
|
||||
VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null)
|
||||
{
|
||||
_selfHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||
|
||||
_clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName);
|
||||
_clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion);
|
||||
_tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath);
|
||||
|
||||
var cfg = new VcConfigNative
|
||||
{
|
||||
ClientName = _clientNamePtr,
|
||||
ClientVersion = _clientVersionPtr,
|
||||
LogLevel = logLevel,
|
||||
TofuStorePath = _tofuStorePathPtr,
|
||||
};
|
||||
|
||||
var cb = new VcCallbacksNative
|
||||
{
|
||||
OnEvent = (nint)(delegate* unmanaged<nint, VcEventNative*, void>)&NativeCallbacks.OnEvent,
|
||||
OnLevel = (nint)(delegate* unmanaged<nint, uint, float, void>)&NativeCallbacks.OnLevel,
|
||||
User = GCHandle.ToIntPtr(_selfHandle),
|
||||
};
|
||||
|
||||
nint native = NativeMethods.vc_client_create(in cfg, cb);
|
||||
_handle.SetHandle(native);
|
||||
if (_handle.IsInvalid)
|
||||
{
|
||||
FreeConfigStrings();
|
||||
_selfHandle.Free();
|
||||
throw new InvalidOperationException("vc_client_create failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains every event/level sample queued since the last call. Call this from a
|
||||
/// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary
|
||||
/// where the core's own event-delivery thread hands off to the UI thread; see
|
||||
/// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen
|
||||
/// over a message-only window + PostMessage.
|
||||
/// </summary>
|
||||
public void PumpEvents()
|
||||
{
|
||||
while (_events.Reader.TryRead(out var ev))
|
||||
EventReceived?.Invoke(ev);
|
||||
|
||||
if (!_latestLevels.IsEmpty)
|
||||
{
|
||||
foreach (var (streamId, rms) in _latestLevels)
|
||||
LevelChanged?.Invoke(streamId, rms);
|
||||
_latestLevels.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
internal void EnqueueEvent(VoiceCatEvent ev)
|
||||
{
|
||||
_events.Writer.TryWrite(ev);
|
||||
}
|
||||
internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms;
|
||||
|
||||
// ── Connection & auth ────────────────────────────────────────────────────────────────
|
||||
public VcResult Connect(string host, ushort port) =>
|
||||
NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port);
|
||||
|
||||
public VcResult Disconnect() =>
|
||||
NativeMethods.vc_disconnect(_handle.DangerousGetHandle());
|
||||
|
||||
public VcResult AuthenticateGuest(string nickname) =>
|
||||
NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname);
|
||||
|
||||
public VcResult AuthenticateUser(string username, string password) =>
|
||||
NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password);
|
||||
|
||||
// ── TOFU server-identity gate ───────────────────────────────────────────────────────────
|
||||
public VcResult ConfirmServerIdentity(bool accept) =>
|
||||
NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0);
|
||||
|
||||
/// <summary>The Ed25519 identity fingerprint from ServerHello, hex-formatted — display
|
||||
/// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty
|
||||
/// string if not yet available.</summary>
|
||||
public string GetServerIdentityDisplay()
|
||||
{
|
||||
nint c = _handle.DangerousGetHandle();
|
||||
NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len);
|
||||
if (len == 0) return string.Empty;
|
||||
|
||||
nint buf = Marshal.AllocHGlobal((int)len + 1);
|
||||
try
|
||||
{
|
||||
NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _);
|
||||
return Marshal.PtrToStringUTF8(buf) ?? string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
||||
/// <summary>Result arrives as a VcEventType.JoinResult event, not via this return value
|
||||
/// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment).</summary>
|
||||
public VcResult JoinChannel(uint channelId, string? password = null) =>
|
||||
NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password);
|
||||
|
||||
public VcResult LeaveChannel() =>
|
||||
NativeMethods.vc_leave_channel(_handle.DangerousGetHandle());
|
||||
|
||||
public VcResult JoinVoice() =>
|
||||
NativeMethods.vc_join_voice(_handle.DangerousGetHandle());
|
||||
|
||||
public VcResult LeaveVoice() =>
|
||||
NativeMethods.vc_leave_voice(_handle.DangerousGetHandle());
|
||||
|
||||
public List<ChannelInfo> ListChannels()
|
||||
{
|
||||
NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
public List<UserInfo> ListUsers()
|
||||
{
|
||||
NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
public List<StreamSummary> ListUserStreams(uint userId)
|
||||
{
|
||||
var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native);
|
||||
return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List<StreamSummary>();
|
||||
}
|
||||
|
||||
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
||||
public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label)
|
||||
{
|
||||
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
|
||||
try
|
||||
{
|
||||
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr };
|
||||
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
|
||||
return (r, streamId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(labelPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="StartStream"/> but sets <c>external_feed = 1</c> so the core skips its
|
||||
/// own WASAPI loopback. The caller is responsible for feeding PCM via
|
||||
/// <see cref="StreamFeedPcm"/>. Used by the Windows per-app capture path.
|
||||
/// </summary>
|
||||
public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label)
|
||||
{
|
||||
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
|
||||
try
|
||||
{
|
||||
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr, ExternalFeed = 1 };
|
||||
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
|
||||
return (r, streamId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(labelPtr);
|
||||
}
|
||||
}
|
||||
|
||||
public VcResult StopStream(uint streamId) =>
|
||||
NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId);
|
||||
|
||||
public VcResult SetInputDevice(uint streamId, string? deviceId) =>
|
||||
NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId);
|
||||
|
||||
/// <summary>Sets the mic capture channel count (1 = mono, 2 = stereo) for the given stream.
|
||||
/// Applied when the capture device next (re)starts — call before Join Voice, or pair with an
|
||||
/// audio restart to take effect live. Real stereo only reaches the wire on a stereo channel;
|
||||
/// the core folds a stereo mic to mono on a mono channel.</summary>
|
||||
public VcResult SetCaptureChannels(uint streamId, uint channels) =>
|
||||
NativeMethods.vc_set_capture_channels(_handle.DangerousGetHandle(), streamId, channels);
|
||||
|
||||
/// <summary>Uninitializes and re-initializes the capture and playback devices on a running
|
||||
/// engine, applying pending changes (e.g. capture channel count) that only take effect on a
|
||||
/// device restart. No-op if audio isn't running.</summary>
|
||||
public VcResult AudioRestart() =>
|
||||
NativeMethods.vc_audio_restart(_handle.DangerousGetHandle());
|
||||
|
||||
public VcResult SetInputMode(VcInputMode mode) =>
|
||||
NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode);
|
||||
|
||||
public VcResult SetVadThreshold(float threshold) =>
|
||||
NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold);
|
||||
|
||||
public VcResult SetPushToTalk(bool active) =>
|
||||
NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0);
|
||||
|
||||
public VcResult SetSelfMute(bool micMuted, bool deafened) =>
|
||||
NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0);
|
||||
|
||||
public VcResult SetOutputVolume(float gain) =>
|
||||
NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
||||
|
||||
/// <summary>Send-side microphone input gain, applied to captured MIC PCM before VAD/encode.
|
||||
/// 0.0 = silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). LOCAL only.</summary>
|
||||
public VcResult SetInputGain(float gain) =>
|
||||
NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
||||
|
||||
/// <summary>Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before
|
||||
/// the input gain and VAD/PTT gate, so everyone hears the cleaned signal. MIC stream only,
|
||||
/// mono only; always LOCAL — no protocol traffic. Independent of per-listener receive-side
|
||||
/// NR in <see cref="SetRemoteStream"/>.</summary>
|
||||
public VcResult SetInputNoiseReduction(bool enable) =>
|
||||
NativeMethods.vc_set_input_noise_reduction(_handle.DangerousGetHandle(), enable ? 1 : 0);
|
||||
|
||||
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
||||
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||
|
||||
public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
|
||||
{
|
||||
var r = NativeMethods.vc_get_remote_stream(_handle.DangerousGetHandle(), userId,
|
||||
streamId, out var native);
|
||||
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
||||
}
|
||||
|
||||
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
|
||||
{
|
||||
var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId,
|
||||
streamId, out var native);
|
||||
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
||||
}
|
||||
|
||||
// ── External PCM feed / tap ─────────────────────────────────────────────────────────
|
||||
public unsafe VcResult StreamFeedPcm(uint streamId, ReadOnlySpan<short> pcm,
|
||||
int samplesPerChannel, uint channels)
|
||||
{
|
||||
fixed (short* p = pcm)
|
||||
return NativeMethods.vc_stream_feed_pcm(_handle.DangerousGetHandle(),
|
||||
streamId, p, (nuint)samplesPerChannel, channels);
|
||||
}
|
||||
|
||||
// Pass Marshal.GetFunctionPointerForDelegate(cb) for a managed delegate, or
|
||||
// IntPtr.Zero to disable. Keep the delegate alive for the lifetime of the registration.
|
||||
public VcResult SetPcmSink(nint cb, IntPtr user) =>
|
||||
NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user);
|
||||
|
||||
// ── Moderation & admin ───────────────────────────────────────────────────────────────
|
||||
public VcResult KickUser(uint userId, string? reason = null) =>
|
||||
NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason);
|
||||
|
||||
public VcResult BanUser(uint userId, string? reason = null, ulong expiresUnixMs = 0) =>
|
||||
NativeMethods.vc_ban_user(_handle.DangerousGetHandle(), userId, reason, expiresUnixMs);
|
||||
|
||||
public VcResult SetPermission(uint userId, PermissionsInfo perms)
|
||||
{
|
||||
var native = new VcPermissionsNative
|
||||
{
|
||||
CanCreateTempChannel = perms.CanCreateTempChannel ? 1 : 0,
|
||||
CanKick = perms.CanKick ? 1 : 0,
|
||||
CanBan = perms.CanBan ? 1 : 0,
|
||||
CanMoveUsers = perms.CanMoveUsers ? 1 : 0,
|
||||
CanAdminAccounts = perms.CanAdminAccounts ? 1 : 0,
|
||||
IsAdmin = perms.IsAdmin ? 1 : 0,
|
||||
};
|
||||
return NativeMethods.vc_set_permission(_handle.DangerousGetHandle(), userId, in native);
|
||||
}
|
||||
|
||||
public VcResult SetServerMute(uint userId, bool muted, bool deafened) =>
|
||||
NativeMethods.vc_set_server_mute(_handle.DangerousGetHandle(), userId,
|
||||
muted ? 1 : 0, deafened ? 1 : 0);
|
||||
|
||||
public VcResult MoveUser(uint userId, uint channelId) =>
|
||||
NativeMethods.vc_move_user(_handle.DangerousGetHandle(), userId, channelId);
|
||||
|
||||
public VcResult CreateChannel(ChannelEditInfo info)
|
||||
{
|
||||
var native = ToNativeChannelInfo(info);
|
||||
try
|
||||
{
|
||||
return NativeMethods.vc_create_channel(_handle.DangerousGetHandle(), in native);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeChannelInfoStrings(native);
|
||||
}
|
||||
}
|
||||
|
||||
public VcResult EditChannel(ChannelEditInfo info)
|
||||
{
|
||||
var native = ToNativeChannelInfo(info);
|
||||
try
|
||||
{
|
||||
return NativeMethods.vc_edit_channel(_handle.DangerousGetHandle(), in native);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeChannelInfoStrings(native);
|
||||
}
|
||||
}
|
||||
|
||||
public VcResult DeleteChannel(uint channelId) =>
|
||||
NativeMethods.vc_delete_channel(_handle.DangerousGetHandle(), channelId);
|
||||
|
||||
public VcResult CreateAccount(string username, string password) =>
|
||||
NativeMethods.vc_create_account(_handle.DangerousGetHandle(), username, password);
|
||||
|
||||
public VcResult ResetPassword(string username, string newPassword) =>
|
||||
NativeMethods.vc_reset_password(_handle.DangerousGetHandle(), username, newPassword);
|
||||
|
||||
public VcResult DeleteAccount(string username) =>
|
||||
NativeMethods.vc_delete_account(_handle.DangerousGetHandle(), username);
|
||||
|
||||
public VcResult RequestAccountList() =>
|
||||
NativeMethods.vc_list_accounts(_handle.DangerousGetHandle());
|
||||
|
||||
public List<AccountInfo> ListAccounts()
|
||||
{
|
||||
NativeMethods.vc_get_account_list(_handle.DangerousGetHandle(), out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
public PermissionsInfo GetPermissions()
|
||||
{
|
||||
NativeMethods.vc_get_permissions(_handle.DangerousGetHandle(), out var native);
|
||||
return Marshaling.ToManaged(in native);
|
||||
}
|
||||
|
||||
private static VcChannelInfoNative ToNativeChannelInfo(ChannelEditInfo info)
|
||||
{
|
||||
return new VcChannelInfoNative
|
||||
{
|
||||
Id = info.Id,
|
||||
ParentId = info.ParentId,
|
||||
Name = Marshal.StringToCoTaskMemUTF8(info.Name),
|
||||
Topic = Marshal.StringToCoTaskMemUTF8(info.Topic),
|
||||
PasswordProtected = info.PasswordProtected ? 1 : 0,
|
||||
Password = string.IsNullOrEmpty(info.Password)
|
||||
? 0
|
||||
: Marshal.StringToCoTaskMemUTF8(info.Password),
|
||||
MaxUsers = info.MaxUsers,
|
||||
SortOrder = info.SortOrder,
|
||||
Audio = new VcAudioConfigNative
|
||||
{
|
||||
Codec = info.Audio.Codec,
|
||||
Mode = info.Audio.Stereo ? 1u : 0u,
|
||||
SampleRate = info.Audio.SampleRate,
|
||||
BitrateBps = info.Audio.BitrateBps,
|
||||
FrameMs = info.Audio.FrameMs,
|
||||
Application = info.Audio.Application,
|
||||
Fec = info.Audio.Fec ? 1 : 0,
|
||||
ExpectedPacketLoss = info.Audio.ExpectedPacketLoss,
|
||||
Dtx = info.Audio.Dtx ? 1 : 0,
|
||||
Complexity = info.Audio.Complexity,
|
||||
Dred = info.Audio.Dred ? 1 : 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void FreeChannelInfoStrings(VcChannelInfoNative native)
|
||||
{
|
||||
if (native.Name != 0) Marshal.FreeCoTaskMem(native.Name);
|
||||
if (native.Topic != 0) Marshal.FreeCoTaskMem(native.Topic);
|
||||
if (native.Password != 0) Marshal.FreeCoTaskMem(native.Password);
|
||||
}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────────────────
|
||||
public VcResult SendText(VcTextScope scope, uint targetId, string utf8) =>
|
||||
NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8);
|
||||
|
||||
// ── Device enumeration (works pre-connect) ──────────────────────────────────────────────
|
||||
public List<DeviceInfo> ListDevices(VcDeviceKind kind)
|
||||
{
|
||||
NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
|
||||
// NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never-
|
||||
// freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport
|
||||
// auto-marshal these as `string` (it would try to free a static literal and corrupt the
|
||||
// heap). Marshal.PtrToStringUTF8 just reads; it never frees.
|
||||
public static string VersionString =>
|
||||
Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty;
|
||||
|
||||
public static string ResultString(VcResult code) =>
|
||||
Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously
|
||||
FreeConfigStrings();
|
||||
if (_selfHandle.IsAllocated) _selfHandle.Free();
|
||||
}
|
||||
|
||||
private void FreeConfigStrings()
|
||||
{
|
||||
if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; }
|
||||
if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; }
|
||||
if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
// Standard SafeHandle pattern for vc_client* — guarantees vc_client_destroy runs even on an
|
||||
// unhandled exception or a finalizer pass, which a bare `nint` field would not.
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
internal sealed class VoiceCatClientHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public VoiceCatClientHandle() : base(ownsHandle: true) { }
|
||||
|
||||
public new void SetHandle(nint handle) => base.SetHandle(handle);
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
NativeMethods.vc_client_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Managed copy of a vc_event — safe to hold/queue past the native callback's return (unlike
|
||||
/// VcEventNative, whose Text pointer is only valid during the callback).
|
||||
/// </summary>
|
||||
public sealed record VoiceCatEvent(
|
||||
VcEventType Type,
|
||||
VcConnectionState ConnectionState,
|
||||
VcResult Result,
|
||||
uint UserId,
|
||||
uint ChannelId,
|
||||
uint StreamId,
|
||||
VcTextScope TextScope,
|
||||
uint U32a,
|
||||
string? Text,
|
||||
ulong TimestampUnixMs)
|
||||
{
|
||||
internal static VoiceCatEvent FromNative(in VcEventNative ev) => new(
|
||||
ev.Type,
|
||||
ev.ConnectionState,
|
||||
(VcResult)ev.Result,
|
||||
ev.UserId,
|
||||
ev.ChannelId,
|
||||
ev.StreamId,
|
||||
ev.TextScope,
|
||||
ev.U32a,
|
||||
ev.Text == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ev.Text),
|
||||
ev.TimestampUnixMs);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Interop;
|
||||
namespace VoiceCat.Windows;
|
||||
|
||||
public sealed partial class VoiceCatClient
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using VoiceCat.Audio;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Interop;
|
||||
namespace VoiceCat.Windows;
|
||||
|
||||
public sealed partial class VoiceCatClient
|
||||
{
|
||||
+2
-4
@@ -1,7 +1,5 @@
|
||||
// Mirrors of voicecat.h's enums. Keep these in lockstep with core/include/voicecat.h —
|
||||
// values are append-only per the C ABI's house rule, so it's safe to add new members at the
|
||||
// end here too, but never renumber/remove existing ones.
|
||||
namespace VoiceCat.Interop;
|
||||
// Windows-facing state and command values used by the WinForms application.
|
||||
namespace VoiceCat.Windows;
|
||||
|
||||
public enum VcResult
|
||||
{
|
||||
+2
-4
@@ -1,7 +1,5 @@
|
||||
// Plain managed record types — what survives past the native struct/free-list lifetime
|
||||
// (Marshaling.cs converts the native *Native structs into these and immediately frees the
|
||||
// native list). Nothing here holds an IntPtr.
|
||||
namespace VoiceCat.Interop;
|
||||
// Windows-facing records used by the WinForms application.
|
||||
namespace VoiceCat.Windows;
|
||||
|
||||
public sealed record ChannelInfo(
|
||||
uint Id,
|
||||
+1
-3
@@ -7,8 +7,6 @@
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<Compile Include="../VoiceCat.Interop/Enums.cs" Link="Enums.cs" />
|
||||
<Compile Include="../VoiceCat.Interop/Models.cs" Link="Models.cs" />
|
||||
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+1
-1
@@ -7,7 +7,7 @@ using VoiceCat.Crypto;
|
||||
using Voicecat.V1;
|
||||
using CoreClient = VoiceCat.Core.VoiceCatClient;
|
||||
|
||||
namespace VoiceCat.Interop;
|
||||
namespace VoiceCat.Windows;
|
||||
|
||||
// Preserves the shipped WinForms call/event surface while its implementation moves to
|
||||
// the async managed core. Events still reach controls only through PumpEvents on the UI thread.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace VoiceCat.Interop;
|
||||
namespace VoiceCat.Windows;
|
||||
|
||||
// Compatibility event consumed by WinForms. The managed core owns protocol and audio;
|
||||
// this assembly contains no libvoicecat bindings or native handles.
|
||||
@@ -1,12 +1,12 @@
|
||||
<Solution>
|
||||
<Folder Name="/Managed core/">
|
||||
<Project Path="../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<Project Path="../../dotnet/src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<Project Path="../../dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="../../dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Project Path="../../dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="../../dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
</Folder>
|
||||
<Project Path="VoiceCat.App/VoiceCat.App.csproj" />
|
||||
<Project Path="VoiceCat.Managed/VoiceCat.Managed.csproj" />
|
||||
<Project Path="VoiceCat.Windows/VoiceCat.Windows.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -4,12 +4,11 @@ param(
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path
|
||||
if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/dotnet/artifacts/client/$RuntimeIdentifier" }
|
||||
if ([string]::IsNullOrWhiteSpace($Output)) { $Output = "$repoRoot/artifacts/client/$RuntimeIdentifier" }
|
||||
dotnet publish "$PSScriptRoot/VoiceCat.App/VoiceCat.App.csproj" -c Release -r $RuntimeIdentifier --self-contained true `
|
||||
-p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=false `
|
||||
-p:RestorePackagesWithLockFile=true -p:RestoreLockedMode=true `
|
||||
"-p:NuGetLockFilePath=packages.publish.$RuntimeIdentifier.lock.json" -o $Output
|
||||
if ($LASTEXITCODE -ne 0) { throw "Managed Windows client publish failed." }
|
||||
if (-not (Test-Path "$Output/voicecat_media.dll")) { throw "Managed media shim was not published." }
|
||||
if (Test-Path "$Output/voicecat.dll") { throw "Legacy VoiceCat native core must not be published." }
|
||||
Write-Host "Published managed Windows client to $Output"
|
||||
|
||||
Reference in New Issue
Block a user