diff --git a/VRCFaceTracking.Core/Contracts/Services/ILibManager.cs b/VRCFaceTracking.Core/Contracts/Services/ILibManager.cs index 4d49e2e7..858bdb64 100644 --- a/VRCFaceTracking.Core/Contracts/Services/ILibManager.cs +++ b/VRCFaceTracking.Core/Contracts/Services/ILibManager.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using VRCFaceTracking.Core.Models; namespace VRCFaceTracking.Core.Contracts.Services; @@ -7,4 +8,10 @@ public interface ILibManager public ObservableCollection LoadedModulesMetadata { get; set; } public void Initialize(); void TeardownAllAndResetAsync(); + + /// + /// The module enabled states that were actually applied at the last initialize. + /// Used to detect state changes that still require a restart to take effect. + /// + IReadOnlyDictionary AppliedModuleStates { get; } } \ No newline at end of file diff --git a/VRCFaceTracking.Core/Library/UnifiedLibManager.cs b/VRCFaceTracking.Core/Library/UnifiedLibManager.cs index 87417bfe..f97eb9d0 100644 --- a/VRCFaceTracking.Core/Library/UnifiedLibManager.cs +++ b/VRCFaceTracking.Core/Library/UnifiedLibManager.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using VRCFaceTracking.Core.Contracts.Services; +using VRCFaceTracking.Core.Models; using VRCFaceTracking.Core.Sandboxing; using VRCFaceTracking.Core.Sandboxing.IPC; @@ -15,6 +16,7 @@ public class UnifiedLibManager : ILibManager private readonly ILogger _logger; private readonly ILogger _moduleLogger; private readonly ILoggerFactory _loggerFactory; + private readonly ILocalSettingsService _settingsService; #endregion #region Observables @@ -36,6 +38,8 @@ public class UnifiedLibManager : ILibManager private string _sandboxProcessPath { get; set; } private List AvailableSandboxModules = new (); + private Dictionary _moduleSettingsByPath = new (); + public IReadOnlyDictionary AppliedModuleStates { get; private set; } = new Dictionary(); #endregion #region Thread @@ -43,13 +47,14 @@ public class UnifiedLibManager : ILibManager private static VrcftSandboxServer _sandboxServer; #endregion - public UnifiedLibManager(ILoggerFactory factory, IDispatcherService dispatcherService, IModuleDataService moduleDataService) + public UnifiedLibManager(ILoggerFactory factory, IDispatcherService dispatcherService, IModuleDataService moduleDataService, ILocalSettingsService settingsService) { _loggerFactory = factory; _logger = factory.CreateLogger(); _moduleLogger = factory.CreateLogger("\0VRCFT\0"); _dispatcherService = dispatcherService; _moduleDataService = moduleDataService; + _settingsService = settingsService; LoadedModulesMetadata = new ObservableCollection(); _sandboxProcessPath = Path.GetFullPath(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "VRCFaceTracking.ModuleProcess.exe" : "VRCFaceTracking.ModuleProcess"); @@ -157,11 +162,16 @@ public void Initialize() AvailableSandboxModules[moduleIndex].SupportsEyeTracking = AvailableSandboxModules[moduleIndex].SupportsEyeTracking && replySupportedPacket.eyeAvailable; AvailableSandboxModules[moduleIndex].SupportsExpressionTracking = AvailableSandboxModules[moduleIndex].SupportsExpressionTracking && replySupportedPacket.expressionAvailable; + // Respect the user's per-module state. A module may only claim a slot it has been allowed to use. + _moduleSettingsByPath.TryGetValue(AvailableSandboxModules[moduleIndex].SandboxModulePath, out var moduleState); + var allowEye = moduleState is ModuleEnabledState.Enabled or ModuleEnabledState.EyesOnly; + var allowExpression = moduleState is ModuleEnabledState.Enabled or ModuleEnabledState.FaceOnly; + // Now tell it to initialise EventInitPacket eventInitPacket = new EventInitPacket() { - expressionAvailable = ExpressionStatus == ModuleState.Uninitialized, - eyeAvailable = EyeStatus == ModuleState.Uninitialized, + expressionAvailable = ExpressionStatus == ModuleState.Uninitialized && allowExpression, + eyeAvailable = EyeStatus == ModuleState.Uninitialized && allowEye, }; _logger.LogInformation("Got supported for module {module}. Expr: {} Eye: {}...", AvailableSandboxModules[moduleIndex].ModuleClassName, @@ -296,7 +306,30 @@ public void Initialize() // Find all modules var modules = _moduleDataService.GetInstalledModules().Concat(_moduleDataService.GetLegacyModules()); - var modulePaths = modules.Select(m => m.AssemblyLoadPath); + + // Load the per-module state settings. They are applied at startup, so changing a module's state only takes effect after a restart. + var allSettings = _settingsService.ReadSettingAsync(Utils.ModuleStateSettingsKey, new Dictionary()).GetAwaiter().GetResult(); + var modulesToLoad = new List(); + var settingsByPath = new Dictionary(); + var appliedStates = new Dictionary(); + foreach ( var m in modules ) + { + var state = allSettings.TryGetValue(m.ModuleKey, out var s) ? s : ModuleEnabledState.Enabled; + settingsByPath[m.AssemblyLoadPath] = state; + appliedStates[m.ModuleKey] = state; + + // Skip modules that have been disabled by the user as a whole + if ( state == ModuleEnabledState.Disabled ) + { + continue; + } + + modulesToLoad.Add(m); + } + + _moduleSettingsByPath = settingsByPath; + AppliedModuleStates = appliedStates; + var modulePaths = modulesToLoad.Select(m => m.AssemblyLoadPath); // Load all modules AvailableSandboxModules.Clear(); diff --git a/VRCFaceTracking.Core/Models/InstallableTrackingModule.cs b/VRCFaceTracking.Core/Models/InstallableTrackingModule.cs index e3b5b28a..94e4296f 100644 --- a/VRCFaceTracking.Core/Models/InstallableTrackingModule.cs +++ b/VRCFaceTracking.Core/Models/InstallableTrackingModule.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using System.ComponentModel; +using Newtonsoft.Json; namespace VRCFaceTracking.Core.Models; @@ -10,8 +11,10 @@ public enum InstallState AwaitingRestart } -public class InstallableTrackingModule : TrackingModuleMetadata +public class InstallableTrackingModule : TrackingModuleMetadata, INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + public InstallState InstallationState { get; set; @@ -22,4 +25,75 @@ public string AssemblyLoadPath { get; set; } + + private ModuleEnabledState _state = ModuleEnabledState.Enabled; + + /// + /// The enabled state of this module. Changes take effect after VRCFT is restarted. + /// The value is persisted separately and does not get written to module.json. + /// + [JsonIgnore] + public ModuleEnabledState State + { + get => _state; + set + { + if ( _state == value ) + { + return; + } + + _state = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(State))); + } + } + + private string _stateBadgeText = string.Empty; + + /// + /// Localized display text for the module's enabled state shown in the module list. + /// Shows "(old → new)" when the state has been changed and a restart is pending. + /// + [JsonIgnore] + public string StateBadgeText + { + get => _stateBadgeText; + set + { + if ( _stateBadgeText == value ) + { + return; + } + + _stateBadgeText = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StateBadgeText))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasStateBadge))); + } + } + + /// + /// Whether the module list should show a state badge (false for not-installed modules). + /// + [JsonIgnore] + public bool HasStateBadge => !string.IsNullOrEmpty(_stateBadgeText); + + /// + /// Updates the list badge text. The state-to-string conversion is delegated to + /// so this Core model stays free of UI dependencies. + /// Shows "(old → new)" when the state has been changed and a restart is pending. + /// + public void UpdateStateBadge(ModuleEnabledState? applied, Func localize) + { + var current = localize(State); + StateBadgeText = applied.HasValue && applied.Value != State + ? $"({localize(applied.Value)} → {current})" + : $"({current})"; + } + + /// + /// A stable identifier used to persist the enabled/disabled state of a module. + /// Registry modules are keyed by their module id, legacy modules by their assembly path. + /// + [JsonIgnore] + public string ModuleKey => ModuleId != Guid.Empty ? ModuleId.ToString() : AssemblyLoadPath; } \ No newline at end of file diff --git a/VRCFaceTracking.Core/Models/ModuleEnabledState.cs b/VRCFaceTracking.Core/Models/ModuleEnabledState.cs new file mode 100644 index 00000000..6b54720c --- /dev/null +++ b/VRCFaceTracking.Core/Models/ModuleEnabledState.cs @@ -0,0 +1,28 @@ +namespace VRCFaceTracking.Core.Models; + +/// +/// The enabled state of a module, controlling which tracking slots it may claim. +/// Persisted as its numeric value. +/// +public enum ModuleEnabledState : byte +{ + /// + /// Module is loaded and may claim both the eye and facial tracking slots. + /// + Enabled = 0, + + /// + /// Module is not loaded at all. + /// + Disabled = 1, + + /// + /// Module is loaded but may only claim the eye tracking slot. + /// + EyesOnly = 2, + + /// + /// Module is loaded but may only claim the facial / expression tracking slot. + /// + FaceOnly = 3 +} \ No newline at end of file diff --git a/VRCFaceTracking.Core/Utils.cs b/VRCFaceTracking.Core/Utils.cs index c091bd25..52070577 100644 --- a/VRCFaceTracking.Core/Utils.cs +++ b/VRCFaceTracking.Core/Utils.cs @@ -42,6 +42,11 @@ public static class Utils public static readonly string UserAccessibleDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "VRCFaceTracking"); public static readonly string PersistentDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VRCFaceTracking"); public static readonly string CustomLibsDirectory = Path.Combine(PersistentDataDirectory, "CustomLibs"); + + /// + /// Local settings key for the persisted per-module enabled-state dictionary. + /// + public const string ModuleStateSettingsKey = "ModuleEnabledState"; public static int GetRandomFreePort() { diff --git a/VRCFaceTracking/Services/ModuleDataService.cs b/VRCFaceTracking/Services/ModuleDataService.cs index 4b9d6972..861e1584 100644 --- a/VRCFaceTracking/Services/ModuleDataService.cs +++ b/VRCFaceTracking/Services/ModuleDataService.cs @@ -198,5 +198,5 @@ public IEnumerable GetInstalledModules() } return installedModules; - } + } } \ No newline at end of file diff --git a/VRCFaceTracking/Strings/en-US/Resources.resw b/VRCFaceTracking/Strings/en-US/Resources.resw index 1f93581c..78ea5292 100644 --- a/VRCFaceTracking/Strings/en-US/Resources.resw +++ b/VRCFaceTracking/Strings/en-US/Resources.resw @@ -299,6 +299,33 @@ Usage Instructions + + Enabled + + + Disabled + + + Eyes Only + + + Face Only + + + Enabled + + + Disabled + + + Eyes Only + + + Face Only + + + Changes take effect after restarting VRCFT + Version diff --git a/VRCFaceTracking/Strings/es-ES/Resources.resw b/VRCFaceTracking/Strings/es-ES/Resources.resw index 7995c4c6..4b0530ae 100644 --- a/VRCFaceTracking/Strings/es-ES/Resources.resw +++ b/VRCFaceTracking/Strings/es-ES/Resources.resw @@ -284,4 +284,31 @@ Configuración de Seguimiento + + Habilitado + + + Deshabilitado + + + Solo ojos + + + Solo cara + + + Habilitado + + + Deshabilitado + + + Solo ojos + + + Solo cara + + + Los cambios se aplicarán al reiniciar VRCFT + \ No newline at end of file diff --git a/VRCFaceTracking/Strings/ja-JP/Resources.resw b/VRCFaceTracking/Strings/ja-JP/Resources.resw index 3f9231d3..ed11a087 100644 --- a/VRCFaceTracking/Strings/ja-JP/Resources.resw +++ b/VRCFaceTracking/Strings/ja-JP/Resources.resw @@ -326,4 +326,31 @@ SteamVRの起動時に自動で起動します + + 有効 + + + 無効 + + + 目のみ + + + 顔のみ + + + 有効 + + + 無効 + + + 目のみ + + + 顔のみ + + + 変更はVRCFTの再起動後に有効になります + \ No newline at end of file diff --git a/VRCFaceTracking/Strings/ko-KR/Resources.resw b/VRCFaceTracking/Strings/ko-KR/Resources.resw index 6bbe9a55..927c3ea3 100644 --- a/VRCFaceTracking/Strings/ko-KR/Resources.resw +++ b/VRCFaceTracking/Strings/ko-KR/Resources.resw @@ -395,4 +395,31 @@ 추적 설정 + + 활성화됨 + + + 비활성화됨 + + + 눈만 + + + 얼굴만 + + + 활성화됨 + + + 비활성화됨 + + + 눈만 + + + 얼굴만 + + + 변경 사항은 VRCFT를 다시 시작한 후에 적용됩니다 + \ No newline at end of file diff --git a/VRCFaceTracking/Strings/pl-PL/Resources.resw b/VRCFaceTracking/Strings/pl-PL/Resources.resw index c2e2b1c9..a894b494 100644 --- a/VRCFaceTracking/Strings/pl-PL/Resources.resw +++ b/VRCFaceTracking/Strings/pl-PL/Resources.resw @@ -390,4 +390,31 @@ Po prostu pooglądaj sobie swój język + + Włączony + + + Wyłączony + + + Tylko oczy + + + Tylko twarz + + + Włączony + + + Wyłączony + + + Tylko oczy + + + Tylko twarz + + + Zmiany zostaną zastosowane po ponownym uruchomieniu VRCFT + \ No newline at end of file diff --git a/VRCFaceTracking/Strings/zh-CN/Resources.resw b/VRCFaceTracking/Strings/zh-CN/Resources.resw index 125928cc..931edb8a 100644 --- a/VRCFaceTracking/Strings/zh-CN/Resources.resw +++ b/VRCFaceTracking/Strings/zh-CN/Resources.resw @@ -272,6 +272,33 @@ 使用说明 + + 启用 + + + 禁用 + + + 仅眼追 + + + 仅面捕 + + + 已启用 + + + 已禁用 + + + 仅眼追 + + + 仅面捕 + + + 更改将在重启 VRCFT 后生效 + 版本号 diff --git a/VRCFaceTracking/ViewModels/ModuleRegistryViewModel.cs b/VRCFaceTracking/ViewModels/ModuleRegistryViewModel.cs index 8efebde6..816ba536 100644 --- a/VRCFaceTracking/ViewModels/ModuleRegistryViewModel.cs +++ b/VRCFaceTracking/ViewModels/ModuleRegistryViewModel.cs @@ -5,19 +5,24 @@ using VRCFaceTracking.Contracts.ViewModels; using VRCFaceTracking.Core.Contracts.Services; using VRCFaceTracking.Core.Models; +using VRCFaceTracking.Helpers; namespace VRCFaceTracking.ViewModels; public partial class ModuleRegistryViewModel : ObservableRecipient, INavigationAware { private readonly IModuleDataService _moduleDataService; + private readonly ILibManager _libManager; + private readonly ILocalSettingsService _settingsService; [ObservableProperty] private InstallableTrackingModule? _selected; public ObservableCollection ModuleInfos { get; } = new(); - public ModuleRegistryViewModel(IModuleDataService moduleDataService) + public ModuleRegistryViewModel(IModuleDataService moduleDataService, ILibManager libManager, ILocalSettingsService settingsService) { _moduleDataService = moduleDataService; + _libManager = libManager; + _settingsService = settingsService; } public async void OnNavigatedTo(object parameter) @@ -30,10 +35,20 @@ public async void OnNavigatedTo(object parameter) // If any of the IDs match a remote module and the other data contained within does not match, // then we need to set the local module install state to outdated. If everything matches then we need to set the install state to installed. var installedModules = _moduleDataService.GetInstalledModules().Concat(_moduleDataService.GetLegacyModules()); + var moduleSettings = await _settingsService.ReadSettingAsync(VRCFaceTracking.Core.Utils.ModuleStateSettingsKey, new Dictionary()); + var appliedStates = _libManager.AppliedModuleStates; var localModules = new List(); // dw about it foreach (var installedModule in installedModules) { installedModule.InstallationState = InstallState.Installed; + if (moduleSettings.TryGetValue(installedModule.ModuleKey, out var state)) + { + installedModule.State = state; + } + + var applied = appliedStates.TryGetValue(installedModule.ModuleKey, out var appliedState) ? appliedState : (ModuleEnabledState?)null; + installedModule.UpdateStateBadge(applied, s => $"ModuleStateText_{s}".GetLocalized()); + var remoteModule = data.FirstOrDefault(x => x.ModuleId == installedModule.ModuleId); if (remoteModule == null) // If this module is completely missing from the remote list, then we need to add it to the list. { @@ -44,6 +59,8 @@ public async void OnNavigatedTo(object parameter) { // This module is installed and in the remote list, so we need to update the remote module's install state. remoteModule.InstallationState = remoteModule.Version != installedModule.Version ? InstallState.Outdated : InstallState.Installed; + remoteModule.State = installedModule.State; + remoteModule.StateBadgeText = installedModule.StateBadgeText; } } diff --git a/VRCFaceTracking/Views/ModuleRegistryDetailControl.xaml b/VRCFaceTracking/Views/ModuleRegistryDetailControl.xaml index 3fddce80..a64ff410 100644 --- a/VRCFaceTracking/Views/ModuleRegistryDetailControl.xaml +++ b/VRCFaceTracking/Views/ModuleRegistryDetailControl.xaml @@ -20,10 +20,29 @@ Style="{ThemeResource TitleTextBlockStyle}" /> -