Skip to content

Commit

Permalink
✨ Add images to the service using memory mapped files.
Browse files Browse the repository at this point in the history
May need to address that global namespace thing. I don't know how this can work without it otherwise (but it works for the pipes somehow?)
  • Loading branch information
hexawyz committed Jan 18, 2025
1 parent 8216603 commit e66649c
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 24 deletions.
2 changes: 1 addition & 1 deletion src/Exo/Core/Exo.Core/Configuration/ImageNameSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public sealed class ImageNameSerializer : INameSerializer<string>
{
private static readonly SearchValues<char> AllowedCharacters = SearchValues.Create("+-0123456789=ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");

public static bool IsNameValid(ReadOnlySpan<char> name) => !name.ContainsAnyExcept(AllowedCharacters);
public static bool IsNameValid(ReadOnlySpan<char> name) => name.Length > 0 && !name.ContainsAnyExcept(AllowedCharacters);

public static ImageNameSerializer Instance = new();

Expand Down
1 change: 1 addition & 0 deletions src/Exo/Service/Exo.Service.Core/Exo.Service.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Exo.Service</RootNamespace>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
Expand Down
15 changes: 9 additions & 6 deletions src/Exo/Service/Exo.Service.Core/ImageStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using Exo.Configuration;
using Exo.Images;
Expand All @@ -10,6 +11,7 @@ namespace Exo.Service;
internal sealed class ImageStorageService
{
[TypeId(0x1D185C1A, 0x4903, 0x4D4A, 0x91, 0x20, 0x69, 0x4A, 0xE5, 0x2C, 0x07, 0x7A)]
[method: JsonConstructor]
private readonly struct ImageMetadata(UInt128 id, ushort width, ushort height, ImageFormat format, bool isAnimated)
{
public UInt128 Id { get; } = id;
Expand All @@ -19,6 +21,8 @@ private readonly struct ImageMetadata(UInt128 id, ushort width, ushort height, I
public bool IsAnimated { get; } = isAnimated;
}

private static string GetFileName(string imageCacheDirectory, UInt128 imageId) => Path.Combine(imageCacheDirectory, imageId.ToString("x32", CultureInfo.InvariantCulture));

public static async Task<ImageStorageService> CreateAsync(IConfigurationContainer<string> imagesConfigurationContainer, string imageCacheDirectory, CancellationToken cancellationToken)
{
if (!Path.IsPathRooted(imageCacheDirectory)) throw new ArgumentException("Images directory path must be rooted.");
Expand All @@ -35,10 +39,11 @@ public static async Task<ImageStorageService> CreateAsync(IConfigurationContaine
var result = await imagesConfigurationContainer.ReadValueAsync<ImageMetadata>(imageName, cancellationToken).ConfigureAwait(false);
if (result.Found)
{
if (!File.Exists(Path.Combine(imageCacheDirectory, imageName + ".dat")))
if (!File.Exists(GetFileName(imageCacheDirectory, result.Value.Id)))
{
// TODO: Log warning about missing image being removed from the collection.
await imagesConfigurationContainer.DeleteValueAsync<ImageMetadata>(imageName).ConfigureAwait(false);
continue;
}
imageCollection.Add(imageName, result.Value);
}
Expand All @@ -61,8 +66,6 @@ private ImageStorageService(IConfigurationContainer<string> imagesConfigurationC
_lock = new();
}

private string GetFileName(UInt128 imageId) => Path.Combine(_imageCacheDirectory, imageId.ToString("X32", CultureInfo.InvariantCulture));

public async IAsyncEnumerable<ImageChangeNotification> WatchChangesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
var channel = Watcher.CreateSingleWriterChannel<ImageChangeNotification>();
Expand All @@ -74,7 +77,7 @@ public async IAsyncEnumerable<ImageChangeNotification> WatchChangesAsync([Enumer
int i = 0;
foreach (var (name, metadata) in _imageCollection)
{
images[i++] = new(metadata.Id, name, GetFileName(metadata.Id), metadata.Width, metadata.Height, metadata.Format, metadata.IsAnimated);
images[i++] = new(metadata.Id, name, GetFileName(_imageCacheDirectory, metadata.Id), metadata.Width, metadata.Height, metadata.Format, metadata.IsAnimated);
}
ArrayExtensions.InterlockedAdd(ref _changeListeners, channel);
}
Expand Down Expand Up @@ -146,7 +149,7 @@ public async ValueTask AddImageAsync(string imageName, Memory<byte> data, Cancel
isAnimated
);

string fileName = GetFileName(metadata.Id);
string fileName = GetFileName(_imageCacheDirectory, metadata.Id);
if (File.Exists(fileName)) throw new InvalidOperationException("An image with the same data already exists.");

await _imagesConfigurationContainer.WriteValueAsync(imageName, metadata, cancellationToken).ConfigureAwait(false);
Expand All @@ -167,7 +170,7 @@ public async ValueTask RemoveImageAsync(string imageName, CancellationToken canc
{
if (_imageCollection.TryGetValue(imageName, out var metadata))
{
string fileName = GetFileName(metadata.Id);
string fileName = GetFileName(_imageCacheDirectory, metadata.Id);
File.Delete(fileName);
_imageCollection.Remove(imageName);

Expand Down
45 changes: 45 additions & 0 deletions src/Exo/Service/Exo.Service.Core/MemoryMappedFileMemoryManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Buffers;
using System.IO.MemoryMappedFiles;
using Microsoft.Win32.SafeHandles;

namespace Exo.Service;

internal sealed unsafe class MemoryMappedFileMemoryManager : MemoryManager<byte>
{
private readonly SafeMemoryMappedViewHandle _viewHandle;
private readonly nint _offset;
private readonly int _length;
private MemoryMappedViewAccessor? _viewAccessor;

public MemoryMappedFileMemoryManager(MemoryMappedFile memoryMappedFile, nint offset, int length, MemoryMappedFileAccess access)
{
ArgumentNullException.ThrowIfNull(memoryMappedFile);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
ArgumentOutOfRangeException.ThrowIfNegative(length);
_viewAccessor = memoryMappedFile.CreateViewAccessor((long)offset, length, access);
_viewHandle = _viewAccessor.SafeMemoryMappedViewHandle;
_offset = offset;
_length = length;
bool success = false;
_viewHandle.DangerousAddRef(ref success);
}

protected override void Dispose(bool disposing)
{
if (Interlocked.Exchange(ref _viewAccessor, null) is { } accessor)
{
accessor.Dispose();
}
}

public override Span<byte> GetSpan() => new((byte*)_viewHandle.DangerousGetHandle(), _length);

public override MemoryHandle Pin(int elementIndex = 0)
{
byte* pointer = null;
_viewHandle.AcquirePointer(ref pointer);
return new MemoryHandle(pointer, pinnable: this);
}

public override void Unpin() => _viewHandle.ReleasePointer();
}
7 changes: 6 additions & 1 deletion src/Exo/Service/Exo.Service.Grpc/GrpcImageService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO.MemoryMappedFiles;
using System.Runtime.CompilerServices;
using Exo.Contracts.Ui.Settings;

Expand All @@ -23,7 +24,11 @@ internal sealed class GrpcImageService : IImageService

public async ValueTask AddImageAsync(ImageRegistrationRequest request, CancellationToken cancellationToken)
{
await _imageStorageService.AddImageAsync(request.ImageName, request.Data, cancellationToken).ConfigureAwait(false);
using (var memoryMappedFile = MemoryMappedFile.CreateOrOpen(request.SharedMemoryName, (long)request.SharedMemoryLength, MemoryMappedFileAccess.Read))
using (var memoryManager = new MemoryMappedFileMemoryManager(memoryMappedFile, 0, (int)request.SharedMemoryLength, MemoryMappedFileAccess.Read))
{
await _imageStorageService.AddImageAsync(request.ImageName, memoryManager.Memory, cancellationToken).ConfigureAwait(false);
}
}

public async ValueTask RemoveImageAsync(ImageReference request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Runtime.Serialization;
using System.Runtime.Serialization;

namespace Exo.Contracts.Ui.Settings;

Expand All @@ -8,5 +8,7 @@ public sealed class ImageRegistrationRequest
[DataMember(Order = 1)]
public required string ImageName { get; init; }
[DataMember(Order = 2)]
public required byte[] Data { get; init; }
public required string SharedMemoryName { get; init; }
[DataMember(Order = 3)]
public required ulong SharedMemoryLength { get; init; }
}
1 change: 1 addition & 0 deletions src/Exo/Ui/Exo.Settings.Ui/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<lconverters:SecondsToStringConverter x:Key="SecondsToStringConverter" />
<lconverters:GridLengthConverter x:Key="GridLengthConverter" />
<lconverters:ByteArrayToBitmapImageConverter x:Key="ByteArrayToBitmapImageConverter" />
<lconverters:SharedMemoryToBitmapImageConverter x:Key="SharedMemoryToBitmapImageConverter" />
<lconverters:FileNameToBitmapImageConverter x:Key="FileNameToBitmapImageConverter" />

<local:RgbLightingDefaultPalette x:Key="RgbLightingDefaultPalette" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Exo.Settings.Ui.ViewModels;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media.Imaging;

namespace Exo.Settings.Ui.Converters;

internal sealed class SharedMemoryToBitmapImageConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, string language)
{
if (value is not SharedMemory data) return null;

var bitmapImage = new BitmapImage();
LoadImage(bitmapImage, data);
return bitmapImage;
}

private async void LoadImage(BitmapImage bitmapImage, SharedMemory sharedMemory)
{
try
{
using (var sharedMemoryStream = sharedMemory.CreateReadStream())
using (var randomAccessStream = sharedMemoryStream.AsRandomAccessStream())
{
await bitmapImage.SetSourceAsync(randomAccessStream);
}
}
catch
{
}
}

public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
}
4 changes: 2 additions & 2 deletions src/Exo/Ui/Exo.Settings.Ui/ImagesPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
<ColumnDefinition Width="*" MaxWidth="200" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image Margin="{ThemeResource RowContentMargin}" Source="{Binding LoadedImageData, Converter={StaticResource ByteArrayToBitmapImageConverter}}" />
<Image Margin="{ThemeResource RowContentMargin}" Source="{Binding LoadedImageData, Converter={StaticResource SharedMemoryToBitmapImageConverter}}" />
<StackPanel Grid.Column="1" Orientation="Vertical" Margin="{ThemeResource RowContentMargin}">
<TextBox Text="{Binding LoadedImageName}" Header="Name" Width="250" IsEnabled="{Binding LoadedImageData, Converter={StaticResource NullabilityToBooleanConverter}}" />
<TextBox Text="{Binding LoadedImageName, Mode=TwoWay}" Header="Name" Width="250" IsEnabled="{Binding LoadedImageData, Converter={StaticResource NullabilityToBooleanConverter}}" />
<Button Margin="{ThemeResource RowLabelMargin}" Click="OnOpenButtonClick" Width="120">Open</Button>
<Button Margin="{ThemeResource RowLabelMargin}" Command="{Binding AddImageCommand}" Width="120" Style="{ThemeResource AccentButtonStyle}">Add</Button>
</StackPanel>
Expand Down
17 changes: 14 additions & 3 deletions src/Exo/Ui/Exo.Settings.Ui/ImagesPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,22 @@ private async void OnOpenButtonClick(object sender, Microsoft.UI.Xaml.RoutedEven
InitializeWithWindow.Initialize(fileOpenPicker, WindowNative.GetWindowHandle(App.Current.MainWindow));
var file = await fileOpenPicker.PickSingleFileAsync();
if (file is null) return;
byte[]? data = null;
SharedMemory? data;
using (var stream = await file.OpenStreamForReadAsync())
{
data = new byte[stream.Length];
await stream.ReadExactlyAsync(data);
long length = stream.Length;
if (length <= 0)
{
data = null;
}
else
{
data = SharedMemory.Create("Exo_Image_", (ulong)length);
using (var viewStream = data.CreateWriteStream())
{
await stream.CopyToAsync(viewStream);
}
}
}

if (data is not null)
Expand Down
45 changes: 36 additions & 9 deletions src/Exo/Ui/Exo.Settings.Ui/ViewModels/ImagesViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Buffers;
using System.Collections.ObjectModel;
using System.IO.MemoryMappedFiles;
using System.Windows.Input;
using Exo.Contracts.Ui.Settings;
using Exo.Settings.Ui.Services;
Expand Down Expand Up @@ -36,12 +38,16 @@ public async void Execute(object? parameter)
}
}

private static readonly SearchValues<char> NameAllowedCharacters = SearchValues.Create("+-0123456789=ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");

public static bool IsNameValid(ReadOnlySpan<char> name) => name.Length > 0 && !name.ContainsAnyExcept(NameAllowedCharacters);

private readonly ObservableCollection<ImageViewModel> _images;
private readonly ReadOnlyObservableCollection<ImageViewModel> _readOnlyImages;
private readonly Commands.AddImageCommand _addImageCommand;

private string? _loadedImageName;
private byte[]? _loadedImageData;
private SharedMemory? _loadedImageData;

private readonly SettingsServiceConnectionManager _connectionManager;
private IImageService? _imageService;
Expand Down Expand Up @@ -114,20 +120,39 @@ private async Task WatchImagesAsync(IImageService imageService, CancellationToke
public string? LoadedImageName
{
get => _loadedImageName;
private set => SetValue(ref _loadedImageName, value, ChangedProperty.LoadedImageName);
set
{
// Image names will be case insensitive, however, we want to consider all casing changes here.
if (!string.Equals(_loadedImageName, value, StringComparison.Ordinal))
{
bool couldAddImage = CanAddImage;
_loadedImageName = value;
NotifyPropertyChanged(ChangedProperty.LoadedImageName);
if (couldAddImage != CanAddImage) _addImageCommand.NotifyCanExecuteChanged();
}
}
}

public byte[]? LoadedImageData
public SharedMemory? LoadedImageData
{
get => _loadedImageData;
private set => SetValue(ref _loadedImageData, value, ChangedProperty.LoadedImageData);
private set
{
if (value != _loadedImageData)
{
bool couldAddImage = CanAddImage;
_loadedImageData?.Dispose();
_loadedImageData = value;
NotifyPropertyChanged(ChangedProperty.LoadedImageData);
if (couldAddImage != CanAddImage) _addImageCommand.NotifyCanExecuteChanged();
}
}
}

public void SetImage(string name, byte[] data)
public void SetImage(string name, SharedMemory data)
{
LoadedImageName = name;
LoadedImageData = data;
_addImageCommand.NotifyCanExecuteChanged();
}

public void ClearImage()
Expand All @@ -136,17 +161,19 @@ public void ClearImage()
LoadedImageData = null;
}

private bool CanAddImage => _loadedImageName is { Length: > 0 } && _loadedImageData is not null;
private bool CanAddImage => _loadedImageName is not null && IsNameValid(_loadedImageName) && _loadedImageData is not null;

private async Task AddImageAsync(CancellationToken cancellationToken)
{
if (_imageService is null || _loadedImageName is null || _loadedImageData is null) return;
if (_imageService is null || _loadedImageName is null || !IsNameValid(_loadedImageName) || _loadedImageData is null) return;

await _imageService.AddImageAsync(new() { ImageName = _loadedImageName, Data = _loadedImageData }, cancellationToken);
await _imageService.AddImageAsync(new() { ImageName = _loadedImageName, SharedMemoryName = _loadedImageData.Name, SharedMemoryLength = _loadedImageData.Length }, cancellationToken);
_loadedImageName = null;
_loadedImageData.Dispose();
_loadedImageData = null;
NotifyPropertyChanged(ChangedProperty.LoadedImageName);
NotifyPropertyChanged(ChangedProperty.LoadedImageData);
_addImageCommand.NotifyCanExecuteChanged();
}
}

Expand Down
45 changes: 45 additions & 0 deletions src/Exo/Ui/Exo.Settings.Ui/ViewModels/SharedMemory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.IO.MemoryMappedFiles;
using System.Security.Cryptography;

namespace Exo.Settings.Ui.ViewModels;

internal sealed class SharedMemory : IDisposable
{
public static SharedMemory Create(string prefix, ulong length)
{
ArgumentNullException.ThrowIfNull(prefix);
ArgumentOutOfRangeException.ThrowIfGreaterThan(length, (ulong)long.MaxValue);

string name = string.Create
(
0 + 32 + prefix.Length,
prefix,
static (span, prefix) =>
{
//@"Global\".CopyTo(span[..7]);
prefix.CopyTo(span[0..]);
RandomNumberGenerator.GetHexString(span[(0 + prefix.Length)..], true);
}
);
return new(name, MemoryMappedFile.CreateNew(name, (long)length, MemoryMappedFileAccess.ReadWrite), length);
}

private readonly string _name;
private readonly MemoryMappedFile _file;
private readonly ulong _length;

private SharedMemory(string name, MemoryMappedFile file, ulong length)
{
_name = name;
_file = file;
_length = length;
}

public void Dispose() => _file.Dispose();

public string Name => _name;
public ulong Length => _length;

public Stream CreateReadStream() => _file.CreateViewStream(0, (long)_length, MemoryMappedFileAccess.Read);
public Stream CreateWriteStream() => _file.CreateViewStream(0, (long)_length, MemoryMappedFileAccess.Write);
}

0 comments on commit e66649c

Please sign in to comment.