Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 180 additions & 72 deletions .github/workflows/build.yml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersion>11.3.9</AvaloniaVersion>
<AvaloniaVersion>11.3.13</AvaloniaVersion>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
Expand All @@ -29,7 +29,7 @@
<PackageVersion Include="DiscordRichPresence" Version="1.6.1.70" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="9.0.120" />
<PackageVersion Include="OpusSharp" Version="1.6.3" />
<PackageVersion Include="OpusSharp" Version="1.6.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
Expand Down
3 changes: 3 additions & 0 deletions VoiceCraft.Client/VoiceCraft.Client.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using Avalonia.Android;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.ApplicationModel;
using SoundFlow.Abstracts;
using SoundFlow.Backends.MiniAudio;
using VoiceCraft.Client.Android.Audio;
using VoiceCraft.Client.Services;
using VoiceCraft.Core;
Expand Down Expand Up @@ -52,6 +54,7 @@ protected override void OnCreate(Bundle? app)
if (audioManager == null)
throw new Exception($"Could not find {AudioService}. Cannot initialize audio service.");

App.ServiceCollection.AddSingleton<AudioEngine, MiniAudioEngine>();
App.ServiceCollection.AddSingleton<StorageService>(nativeStorage);
App.ServiceCollection.AddSingleton<HotKeyService, NativeHotKeyService>();
App.ServiceCollection.AddSingleton<IBackgroundService>(x =>
Expand Down
3 changes: 3 additions & 0 deletions VoiceCraft.Client/VoiceCraft.Client.Linux/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Diagnostics;
using Avalonia;
using Microsoft.Extensions.DependencyInjection;
using SoundFlow.Abstracts;
using SoundFlow.Backends.MiniAudio;
using VoiceCraft.Client.Linux.Audio;
using VoiceCraft.Client.Linux.Permissions;
using VoiceCraft.Client.Services;
Expand All @@ -25,6 +27,7 @@ public static void Main(string[] args)
AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
try
{
App.ServiceCollection.AddSingleton<AudioEngine, MiniAudioEngine>();
App.ServiceCollection.AddSingleton<HotKeyService, NativeHotKeyService>();
App.ServiceCollection.AddSingleton<StorageService>(nativeStorage);
App.ServiceCollection.AddSingleton<IBackgroundService>(x =>
Expand Down
30 changes: 30 additions & 0 deletions VoiceCraft.Client/VoiceCraft.Client.MacOS/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>VoiceCraft</string>
<key>CFBundleName</key>
<string>VoiceCraft</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIdentifier</key>
<string>com.avionblock.voicecraft.client.macos</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.games</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSMicrophoneUsageDescription</key>
<string>VoiceCraft needs microphone access for voice chat.</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
</dict>
</plist>
106 changes: 106 additions & 0 deletions VoiceCraft.Client/VoiceCraft.Client.MacOS/NativeBackgroundService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using VoiceCraft.Client.Services;

namespace VoiceCraft.Client.MacOS;

public class NativeBackgroundService(Func<Type, object> backgroundFactory) : IBackgroundService
{
private static ConcurrentDictionary<Type, BackgroundTask> Services { get; } = new();
private Func<Type, object> BackgroundFactory { get; } = backgroundFactory;

public Task StartServiceAsync<T>(Action<T, Action<string>, Action<string>> startAction) where T : notnull
{
var backgroundType = typeof(T);
if (Services.ContainsKey(backgroundType))
throw new InvalidOperationException();

if (BackgroundFactory.Invoke(backgroundType) is not T instance)
throw new Exception($"Background task of type {backgroundType} is not of type {backgroundType}");

var backgroundTask = new BackgroundTask(instance);
backgroundTask.OnCompleted += BackgroundTaskOnCompleted;
try
{
Services.TryAdd(backgroundType, backgroundTask);
backgroundTask.Start(() => startAction.Invoke(instance, _ => { }, _ => { }));
}
catch
{
Services.TryRemove(backgroundType, out _);
backgroundTask.OnCompleted -= BackgroundTaskOnCompleted;
backgroundTask.Dispose();
throw;
}

return Task.CompletedTask;
}

public T? GetService<T>() where T : notnull
{
if (Services.TryGetValue(typeof(T), out var service) && service.TaskInstance is T taskInstance)
return taskInstance;
return default;
}

public void Dispose()
{
foreach (var service in Services.Values)
{
service.Dispose();
}
GC.SuppressFinalize(this);
}

private static void BackgroundTaskOnCompleted(BackgroundTask task)
{
task.OnCompleted -= BackgroundTaskOnCompleted;
Services.TryRemove(task.TaskInstance.GetType(), out _);
}

private class BackgroundTask(object taskInstance) : IDisposable
{
public event Action<BackgroundTask>? OnCompleted;
public Task? RunningTask { get; private set; }
public object TaskInstance { get; } = taskInstance;

public void Start(Action startAction)
{
RunningTask = Task.Run(() =>
{
try
{
startAction.Invoke();
}
finally
{
Dispose();
OnCompleted?.Invoke(this);
}
});

var sw = new SpinWait();
while (RunningTask.Status < TaskStatus.Running)
{
sw.SpinOnce();
}
}

public void Dispose()
{
try
{
if (TaskInstance is IDisposable disposable)
disposable.Dispose();
}
catch
{
//Do Nothing
}

GC.SuppressFinalize(this);
}
}
}
73 changes: 73 additions & 0 deletions VoiceCraft.Client/VoiceCraft.Client.MacOS/NativeHotKeyService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Text;
using SharpHook;
using SharpHook.Data;
using VoiceCraft.Client.Services;

namespace VoiceCraft.Client.MacOS;

public class NativeHotKeyService : HotKeyService
{
private readonly EventLoopGlobalHook _hook;
private readonly List<KeyCode> _pressedKeys = [];
private readonly StringBuilder _stringBuilder = new();

public NativeHotKeyService(IEnumerable<HotKeyAction> registeredHotKeyActions) : base(registeredHotKeyActions)
{
_hook = new EventLoopGlobalHook();
_hook.KeyPressed += OnKeyPressed;
_hook.KeyReleased += OnKeyReleased;
}

public override void Initialize()
{
_ = _hook.RunAsync();
}

public override void Dispose()
{
_hook.KeyPressed -= OnKeyPressed;
try
{
_hook.Dispose();
}
catch (Exception ex)
{
LogService.Log(ex);
}

GC.SuppressFinalize(this);
}

private void OnKeyPressed(object? sender, KeyboardHookEventArgs e)
{
if (_pressedKeys.Contains(e.Data.KeyCode)) return;
_pressedKeys.Add(e.Data.KeyCode);
_stringBuilder.Clear();

for (var i = 0; i < _pressedKeys.Count; i++)
{
var cleanKey = _pressedKeys[i].ToString().Replace("Vc", "");
_stringBuilder.Append($"{cleanKey}{(i < _pressedKeys.Count - 1 ? "\0" : "")}");
}

if (HotKeyActions.TryGetValue(_stringBuilder.ToString(), out var action)) action.Press();
}

private void OnKeyReleased(object? sender, KeyboardHookEventArgs e)
{
if (!_pressedKeys.Contains(e.Data.KeyCode)) return;
_stringBuilder.Clear();

for (var i = 0; i < _pressedKeys.Count; i++)
{
var cleanKey = _pressedKeys[i].ToString().Replace("Vc", "");
_stringBuilder.Append($"{cleanKey}{(i < _pressedKeys.Count - 1 ? "\0" : "")}");
}

_pressedKeys.Remove(e.Data.KeyCode);

if (HotKeyActions.TryGetValue(_stringBuilder.ToString(), out var action)) action.Release();
}
}
47 changes: 47 additions & 0 deletions VoiceCraft.Client/VoiceCraft.Client.MacOS/NativeStorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Threading.Tasks;
using VoiceCraft.Client.Services;
using VoiceCraft.Core;

namespace VoiceCraft.Client.MacOS;

public class NativeStorageService : StorageService
{
private static readonly string ApplicationDirectory =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
Constants.ApplicationDirectory);

public override bool Exists(string directory)
{
return File.Exists(Path.Combine(ApplicationDirectory, directory));
}

public override byte[] Load(string directory)
{
return File.ReadAllBytes(Path.Combine(ApplicationDirectory, directory));
}

public override void Save(string directory, byte[] data)
{
CreateDirectoryIfNotExists();
File.WriteAllBytes(Path.Combine(ApplicationDirectory, directory), data);
}

public override async Task<byte[]> LoadAsync(string directory)
{
return await File.ReadAllBytesAsync(Path.Combine(ApplicationDirectory, directory));
}

public override async Task SaveAsync(string directory, byte[] data)
{
CreateDirectoryIfNotExists();
await File.WriteAllBytesAsync(Path.Combine(ApplicationDirectory, directory), data);
}

private static void CreateDirectoryIfNotExists()
{
if (!Directory.Exists(ApplicationDirectory))
Directory.CreateDirectory(ApplicationDirectory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Threading.Tasks;
using AVFoundation;
using Microsoft.Maui.ApplicationModel;

namespace VoiceCraft.Client.MacOS.Permissions;

public class Microphone : Microsoft.Maui.ApplicationModel.Permissions.Microphone
{
public override void EnsureDeclared()
{
}

public override Task<PermissionStatus> CheckStatusAsync()
{
var status = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Audio);
return Task.FromResult(status switch
{
AVAuthorizationStatus.Authorized => PermissionStatus.Granted,
AVAuthorizationStatus.Denied => PermissionStatus.Denied,
AVAuthorizationStatus.Restricted => PermissionStatus.Restricted,
_ => PermissionStatus.Unknown
});
}

public override async Task<PermissionStatus> RequestAsync()
{
var status = await CheckStatusAsync();
if (status != PermissionStatus.Unknown)
return status;

var tcs = new TaskCompletionSource<bool>();
AVCaptureDevice.RequestAccessForMediaType(AVAuthorizationMediaType.Audio, granted => tcs.TrySetResult(granted));
var granted = await tcs.Task;
return granted ? PermissionStatus.Granted : PermissionStatus.Denied;
}

public override bool ShouldShowRationale()
{
return false;
}
}
Loading
Loading