diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index ee4a2952b..a63dd2284 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -1,431 +1,318 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Windows; -using System.Windows.Input; -using System.Windows.Interop; -using System.Windows.Media; -using EarTrumpet.DataModel.WindowsAudio; -using EarTrumpet.Diagnosis; -using EarTrumpet.Extensibility; -using EarTrumpet.Extensibility.Hosting; -using EarTrumpet.Extensions; -using EarTrumpet.Interop.Helpers; -using EarTrumpet.UI.Helpers; -using EarTrumpet.UI.ViewModels; -using EarTrumpet.UI.Views; -using Microsoft.Win32; -using Windows.Win32; - -namespace EarTrumpet; - -public sealed partial class App : IDisposable -{ - public static bool IsShuttingDown - { - get; private set; - } - public static bool HasIdentity - { - get; private set; - } - public static bool HasDevIdentity - { - get; private set; - } - public static string PackageName - { - get; private set; - } - public static Version PackageVersion - { - get; private set; - } - public static TimeSpan Duration => s_appTimer.Elapsed; - - public FlyoutWindow FlyoutWindow - { - get; private set; - } - public DeviceCollectionViewModel CollectionViewModel - { - get; private set; - } - - private static readonly Stopwatch s_appTimer = Stopwatch.StartNew(); - private FlyoutViewModel _flyoutViewModel; - - private ShellNotifyIcon _trayIcon; - private WindowHolder _mixerWindow; - private WindowHolder _settingsWindow; - private ErrorReporter _errorReporter; - - public static AppSettings Settings - { - get; private set; - } - - private void OnAppStartup(object sender, StartupEventArgs e) - { - RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; - - Exit += (_, __) => IsShuttingDown = true; - HasIdentity = PackageHelper.CheckHasIdentity(); - HasDevIdentity = PackageHelper.HasDevIdentity(); - PackageVersion = PackageHelper.GetVersion(HasIdentity); - PackageName = PackageHelper.GetFamilyName(HasIdentity); - - Settings = new AppSettings(); - _errorReporter = new ErrorReporter(Settings); - - if (SingleInstanceAppMutex.TakeExclusivity()) - { - Exit += (_, __) => SingleInstanceAppMutex.ReleaseExclusivity(); - - try - { - NotifyOnMissingStartupPolicies(); - ContinueStartup(); - } - catch (Exception ex) when (IsCriticalFontLoadFailure(ex)) - { - ErrorReporter.LogWarning(ex); - OnCriticalFontLoadFailure(); - } - } - else - { - Shutdown(); - } - } - - private void ContinueStartup() - { - ((UI.Themes.Manager)Resources["ThemeManager"]).Load(); - - var deviceManager = WindowsAudioFactory.Create(AudioDeviceKind.Playback); - deviceManager.Loaded += (_, __) => CompleteStartup(); - CollectionViewModel = new DeviceCollectionViewModel(deviceManager, Settings); - - _trayIcon = new ShellNotifyIcon(new TaskbarIconSource(CollectionViewModel, Settings), Settings.TrayIconIdentity); - Exit += (_, __) => _trayIcon.IsVisible = false; - CollectionViewModel.TrayPropertyChanged += () => UpdateTrayTooltip(); - - _flyoutViewModel = new FlyoutViewModel(CollectionViewModel, () => _trayIcon.SetFocus(), Settings); - FlyoutWindow = new FlyoutWindow(_flyoutViewModel); - // Initialize the FlyoutWindow last because its Show/Hide cycle will pump messages, causing UI frames - // to be executed, breaking the assumption that startup is complete. - FlyoutWindow.Initialize(); - - // listen for user session change - // When user come back after user switch, do some workaround for issue of losing audio sessions - SystemEvents.SessionSwitch += SystemEvents_SessionSwitch; - Exit += (_, __) => SystemEvents.SessionSwitch -= SystemEvents_SessionSwitch; - } - - private void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e) - { - Trace.WriteLine($"Detected User Session Switch: {e.Reason}"); - if (e.Reason == SessionSwitchReason.ConsoleConnect) - { - var devManager = WindowsAudioFactory.Create(AudioDeviceKind.Playback); - devManager.RefreshAllDevices(); - } - } - - private void CompleteStartup() - { - AddonManager.Load(shouldLoadInternalAddons: HasDevIdentity); - Exit += (_, __) => AddonManager.Shutdown(); -#if DEBUG - DebugHelpers.Add(); -#endif - _mixerWindow = new WindowHolder(CreateMixerExperience); - _settingsWindow = new WindowHolder(CreateSettingsExperience); - - Settings.FlyoutHotkeyTyped += () => _flyoutViewModel.OpenFlyout(InputType.Keyboard); - Settings.MixerHotkeyTyped += () => _mixerWindow.OpenOrClose(); - Settings.SettingsHotkeyTyped += () => _settingsWindow.OpenOrBringToFront(); - Settings.AbsoluteVolumeUpHotkeyTyped += AbsoluteVolumeIncrement; - Settings.AbsoluteVolumeDownHotkeyTyped += AbsoluteVolumeDecrement; - Settings.RegisterHotkeys(); - Settings.UseLogarithmicVolumeChanged += (_, __) => UpdateTrayTooltip(); - - _trayIcon.PrimaryInvoke += (_, type) => _flyoutViewModel.OpenFlyout(type); - _trayIcon.SecondaryInvoke += (_, args) => _trayIcon.ShowContextMenu(GetTrayContextMenuItems(), args.Point); - _trayIcon.TertiaryInvoke += (_, __) => CollectionViewModel.Default?.ToggleMute.Execute(null); - _trayIcon.Scrolled += TrayIconScrolled; - _trayIcon.SetTooltip(CollectionViewModel.GetTrayToolTip()); - _trayIcon.IsVisible = true; - - DisplayFirstRunExperience(); - ShowFullMixerWindowIfConfigured(); - } - - private void ShowFullMixerWindowIfConfigured() - { - if (Settings.ShowFullMixerWindowOnStartup) - { - _mixerWindow.OpenOrBringToFront(); - } - } - - private void UpdateTrayTooltip() - { - _trayIcon.SetTooltip(CollectionViewModel.GetTrayToolTip()); - - var hWndTray = WindowsTaskbar.GetTrayToolbarWindowHwnd(); - unsafe - { - var hWndTooltip = PInvoke.SendMessage(new HWND(hWndTray.ToPointer()), PInvoke.TB_GETTOOLTIPS, default, default); - PInvoke.SendMessage(new HWND(hWndTooltip.Value.ToPointer()), PInvoke.TTM_POPUP, default, default); - } - } - - private void TrayIconScrolled(object _, int wheelDelta) - { - if (Settings.UseScrollWheelInTray && (!Settings.UseGlobalMouseWheelHook || _flyoutViewModel.State == FlyoutViewState.Hidden)) - { - CollectionViewModel.Default?.IncrementVolume( - Math.Sign(wheelDelta) * (Settings.UseLogarithmicVolume ? 0.2f : 2.0f)); - } - } - - private static void DisplayFirstRunExperience() - { - if (!Settings.HasShownFirstRun -#if DEBUG - || Keyboard.IsKeyDown(Key.LeftCtrl) -#endif - ) - { - Trace.WriteLine($"App DisplayFirstRunExperience Showing welcome dialog"); - Settings.HasShownFirstRun = true; - - var dialog = new DialogWindow { DataContext = new WelcomeViewModel(Settings) }; - dialog.Show(); - dialog.RaiseWindow(); - } - } - - private static bool IsCriticalFontLoadFailure(Exception ex) - { - return ex.StackTrace.Contains("MS.Internal.Text.TextInterface.FontFamily.GetFirstMatchingFont") || - ex.StackTrace.Contains("MS.Internal.Text.Line.Format"); - } - - private static void OnCriticalFontLoadFailure() - { - Trace.WriteLine($"App OnCriticalFontLoadFailure"); - - new Thread(() => - { - if (MessageBox.Show( - EarTrumpet.Properties.Resources.CriticalFailureFontLookupHelpText, - EarTrumpet.Properties.Resources.CriticalFailureDialogHeaderText, - MessageBoxButton.OKCancel, - MessageBoxImage.Error, - MessageBoxResult.OK) == MessageBoxResult.OK) - { - Trace.WriteLine($"App OnCriticalFontLoadFailure OK"); - ProcessHelper.StartNoThrow("https://eartrumpet.app/jmp/fixfonts"); - } - Environment.Exit(0); - }).Start(); - - // Stop execution because callbacks to the UI thread will likely cause another cascading font error. - new AutoResetEvent(false).WaitOne(); - } - - private static bool IsAnyStartupPolicyMissing() - { - Trace.WriteLine($"App IsAnyStartupPolicyMissing"); - - try - { - var registryPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"; - using var key = Registry.LocalMachine.OpenSubKey(registryPath); - if (key == null) - { - return true; - } - - var dwords = new[] { - "EnableFullTrustStartupTasks", - "EnableUwpStartupTasks", - "SupportFullTrustStartupTasks", - "SupportUwpStartupTasks" - }; - - foreach (var dword in dwords) - { - // Warning: RegistryKey.GetValue returns int for DWORDs - - var value = key.GetValue(dword); - if (value == null || value.GetType() != typeof(int)) - { - Trace.WriteLine($"Missing or invalid: {dword}"); - return true; - } - } - } - catch (Exception ex) - { - Trace.WriteLine($"Exception: {ex}"); - } - - return false; - } - - private static void NotifyOnMissingStartupPolicies() - { - if (!IsAnyStartupPolicyMissing()) - { - return; - } - - new Thread(() => - { - if (MessageBox.Show( - EarTrumpet.Properties.Resources.MissingPoliciesHelpText, - EarTrumpet.Properties.Resources.MissingPoliciesDialogHeaderText, - MessageBoxButton.OKCancel, - MessageBoxImage.Warning, - MessageBoxResult.OK) == MessageBoxResult.OK) - { - Trace.WriteLine($"App NotifyOnMissingStartupPolicies OK"); - ProcessHelper.StartNoThrow("https://eartrumpet.app/jmp/fixstartup"); - } - }).Start(); - } - - private List GetTrayContextMenuItems() - { - var ret = new List(CollectionViewModel.AllDevices.OrderBy(x => x.DisplayName).Select(dev => new ContextMenuItem - { - DisplayName = dev.DisplayName, - IsChecked = dev.Id == CollectionViewModel.Default?.Id, - Command = new RelayCommand(() => dev.MakeDefaultDevice()), - })); - - if (ret.Count == 0) - { - ret.Add(new ContextMenuItem - { - DisplayName = EarTrumpet.Properties.Resources.ContextMenuNoDevices, - IsEnabled = false, - }); - } - - ret.AddRange( - [ - new ContextMenuSeparator(), - new ContextMenuItem - { - DisplayName = EarTrumpet.Properties.Resources.WindowsLegacyMenuText, - Children = - [ - new() { DisplayName = EarTrumpet.Properties.Resources.LegacyVolumeMixerText, Command = new RelayCommand(LegacyControlPanelHelper.StartLegacyAudioMixer) }, - new() { DisplayName = EarTrumpet.Properties.Resources.PlaybackDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("playback")) }, - new() { DisplayName = EarTrumpet.Properties.Resources.RecordingDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("recording")) }, - new() { DisplayName = EarTrumpet.Properties.Resources.SoundsControlPanelText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("sounds")) }, - new() { DisplayName = EarTrumpet.Properties.Resources.OpenSoundSettingsText, Command = new RelayCommand(() => SettingsPageHelper.Open("sound")) }, - new() { - DisplayName = Environment.OSVersion.IsAtLeast(OSVersions.Windows11) ? - EarTrumpet.Properties.Resources.OpenAppsVolume_Windows11_Text - : EarTrumpet.Properties.Resources.OpenAppsVolume_Windows10_Text, Command = new RelayCommand(() => SettingsPageHelper.Open("apps-volume")) }, - ], - }, - new ContextMenuSeparator(), - ]); - - var addonItems = AddonManager.Host.TrayContextMenuItems?.OrderBy(x => x.NotificationAreaContextMenuItems.FirstOrDefault()?.DisplayName).SelectMany(ext => ext.NotificationAreaContextMenuItems); - if (addonItems != null && addonItems.Any()) - { - ret.AddRange(addonItems); - ret.Add(new ContextMenuSeparator()); - } - - ret.AddRange( - [ - new() { DisplayName = EarTrumpet.Properties.Resources.FullWindowTitleText, Command = new RelayCommand(_mixerWindow.OpenOrBringToFront) }, - new() { DisplayName = EarTrumpet.Properties.Resources.SettingsWindowText, Command = new RelayCommand(_settingsWindow.OpenOrBringToFront) }, - new() { DisplayName = EarTrumpet.Properties.Resources.ContextMenuExitTitle, Command = new RelayCommand(Shutdown) }, - ]); - return ret; - } - - private Window CreateSettingsExperience() - { - var defaultCategory = new SettingsCategoryViewModel( - EarTrumpet.Properties.Resources.SettingsCategoryTitle, - "\xE71D", - EarTrumpet.Properties.Resources.SettingsDescriptionText, - null, - [ - new EarTrumpetShortcutsPageViewModel(Settings), - new EarTrumpetMouseSettingsPageViewModel(Settings), - new EarTrumpetCommunitySettingsPageViewModel(Settings), - new EarTrumpetLegacySettingsPageViewModel(Settings), - new EarTrumpetAboutPageViewModel(_errorReporter.DisplayDiagnosticData, Settings) - ]); - - var allCategories = new List - { - defaultCategory - }; - - if (AddonManager.Host.SettingsItems != null) - { - allCategories.AddRange(AddonManager.Host.SettingsItems.Select(CreateAddonSettingsPage)); - } - - var viewModel = new SettingsViewModel(EarTrumpet.Properties.Resources.SettingsWindowText, allCategories); - return new SettingsWindow { DataContext = viewModel }; - } - - private static SettingsCategoryViewModel CreateAddonSettingsPage(IEarTrumpetAddonSettingsPage addonSettingsPage) - { - var addon = (EarTrumpetAddon)addonSettingsPage; - var category = addonSettingsPage.GetSettingsCategory(); - - if (!addon.IsInternal()) - { - category.Pages.Add(new AddonAboutPageViewModel(addon)); - } - return category; - } - - private Window CreateMixerExperience() => new FullWindow { DataContext = new FullWindowViewModel(CollectionViewModel) }; - - public void Dispose() - { - _errorReporter.Dispose(); - _trayIcon.Dispose(); - } - - private void AbsoluteVolumeIncrement() - { - foreach (var device in CollectionViewModel.AllDevices.Where(d => !d.IsMuted || d.IsAbsMuted)) - { - device.IsAbsMuted = false; - device.IncrementVolume(2); - } - } - - private void AbsoluteVolumeDecrement() - { - foreach (var device in CollectionViewModel.AllDevices.Where(d => !d.IsMuted)) - { - var wasMuted = device.IsMuted; - device.Volume -= 2; - - if (!wasMuted == (device.Volume <= 0)) - { - device.IsAbsMuted = true; - } - } - } -} +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using EarTrumpet.DataModel.Audio; +using EarTrumpet.DataModel.WindowsAudio; +using EarTrumpet.Diagnosis; +using EarTrumpet.Extensibility; +using EarTrumpet.Extensibility.Hosting; +using EarTrumpet.Extensions; +using EarTrumpet.Interop; +using EarTrumpet.Interop.Helpers; +using EarTrumpet.UI.Helpers; +using EarTrumpet.UI.ViewModels; +using EarTrumpet.UI.Views; + + +namespace EarTrumpet +{ + public partial class App + { + private EarTrumpet.CLIServices.CliRoutingService _cliRoutingService; //added by dayeggpi + public static bool IsShuttingDown { get; private set; } + public static bool HasIdentity { get; private set; } + public static bool HasDevIdentity { get; private set; } + public static string PackageName { get; private set; } + public static Version PackageVersion { get; private set; } + public static TimeSpan Duration => s_appTimer.Elapsed; + + public FlyoutWindow FlyoutWindow { get; private set; } + public DeviceCollectionViewModel CollectionViewModel { get; private set; } + + private static readonly Stopwatch s_appTimer = Stopwatch.StartNew(); + private FlyoutViewModel _flyoutViewModel; + + private ShellNotifyIcon _trayIcon; + private WindowHolder _mixerWindow; + private WindowHolder _settingsWindow; + private ErrorReporter _errorReporter; + + public static AppSettings Settings { get; private set; } + + private void OnAppStartup(object sender, StartupEventArgs e) + { + RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; + + Exit += (_, __) => IsShuttingDown = true; + HasIdentity = PackageHelper.CheckHasIdentity(); + HasDevIdentity = PackageHelper.HasDevIdentity(); + PackageVersion = PackageHelper.GetVersion(HasIdentity); + PackageName = PackageHelper.GetFamilyName(HasIdentity); + + Settings = new AppSettings(); + _errorReporter = new ErrorReporter(Settings); + + //added by dayeggpi + _cliRoutingService = new EarTrumpet.CLIServices.CliRoutingService(this); + if (_cliRoutingService.TryHandleCliArgs(e.Args)) + { + return; + } + //END + + if (SingleInstanceAppMutex.TakeExclusivity()) + { + Exit += (_, __) => SingleInstanceAppMutex.ReleaseExclusivity(); + + try + { + ContinueStartup(); + } + catch (Exception ex) when (IsCriticalFontLoadFailure(ex)) + { + ErrorReporter.LogWarning(ex); + OnCriticalFontLoadFailure(); + } + } + else + { + Shutdown(); + } + } + + private void ContinueStartup() + { + ((UI.Themes.Manager)Resources["ThemeManager"]).Load(); + + var deviceManager = WindowsAudioFactory.Create(AudioDeviceKind.Playback); + deviceManager.Loaded += (_, __) => CompleteStartup(); + CollectionViewModel = new DeviceCollectionViewModel(deviceManager, Settings); + + _trayIcon = new ShellNotifyIcon(new TaskbarIconSource(CollectionViewModel, Settings)); + Exit += (_, __) => _trayIcon.IsVisible = false; + CollectionViewModel.TrayPropertyChanged += () => _trayIcon.SetTooltip(CollectionViewModel.GetTrayToolTip()); + + _flyoutViewModel = new FlyoutViewModel(CollectionViewModel, () => _trayIcon.SetFocus(), Settings); + FlyoutWindow = new FlyoutWindow(_flyoutViewModel); + // Initialize the FlyoutWindow last because its Show/Hide cycle will pump messages, causing UI frames + // to be executed, breaking the assumption that startup is complete. + FlyoutWindow.Initialize(); + } + + private void CompleteStartup() + { + AddonManager.Load(shouldLoadInternalAddons: HasDevIdentity); + Exit += (_, __) => AddonManager.Shutdown(); +#if DEBUG + DebugHelpers.Add(); +#endif + _mixerWindow = new WindowHolder(CreateMixerExperience); + _settingsWindow = new WindowHolder(CreateSettingsExperience); + + Settings.FlyoutHotkeyTyped += () => _flyoutViewModel.OpenFlyout(InputType.Keyboard); + Settings.MixerHotkeyTyped += () => _mixerWindow.OpenOrClose(); + Settings.SettingsHotkeyTyped += () => _settingsWindow.OpenOrBringToFront(); + Settings.AbsoluteVolumeUpHotkeyTyped += AbsoluteVolumeIncrement; + Settings.AbsoluteVolumeDownHotkeyTyped += AbsoluteVolumeDecrement; + Settings.RegisterHotkeys(); + + _trayIcon.PrimaryInvoke += (_, type) => _flyoutViewModel.OpenFlyout(type); + _trayIcon.SecondaryInvoke += (_, args) => _trayIcon.ShowContextMenu(GetTrayContextMenuItems(), args.Point); + _trayIcon.TertiaryInvoke += (_, __) => CollectionViewModel.Default?.ToggleMute.Execute(null); + _trayIcon.Scrolled += trayIconScrolled; + _trayIcon.SetTooltip(CollectionViewModel.GetTrayToolTip()); + _trayIcon.IsVisible = true; + + DisplayFirstRunExperience(); + } + + private void trayIconScrolled(object _, int wheelDelta) + { + if (Settings.UseScrollWheelInTray && (!Settings.UseGlobalMouseWheelHook || _flyoutViewModel.State == FlyoutViewState.Hidden)) + { + var hWndTray = WindowsTaskbar.GetTrayToolbarWindowHwnd(); + var hWndTooltip = User32.SendMessage(hWndTray, User32.TB_GETTOOLTIPS, IntPtr.Zero, IntPtr.Zero); + User32.SendMessage(hWndTooltip, User32.TTM_POPUP, IntPtr.Zero, IntPtr.Zero); + + CollectionViewModel.Default?.IncrementVolume(Math.Sign(wheelDelta) * 2); + } + } + + private void DisplayFirstRunExperience() + { + if (!Settings.HasShownFirstRun +#if DEBUG + || Keyboard.IsKeyDown(Key.LeftCtrl) +#endif + ) + { + Trace.WriteLine($"App DisplayFirstRunExperience Showing welcome dialog"); + Settings.HasShownFirstRun = true; + + var dialog = new DialogWindow { DataContext = new WelcomeViewModel(Settings) }; + dialog.Show(); + dialog.RaiseWindow(); + } + } + + private bool IsCriticalFontLoadFailure(Exception ex) + { + return ex.StackTrace.Contains("MS.Internal.Text.TextInterface.FontFamily.GetFirstMatchingFont") || + ex.StackTrace.Contains("MS.Internal.Text.Line.Format"); + } + + private void OnCriticalFontLoadFailure() + { + Trace.WriteLine($"App OnCriticalFontLoadFailure"); + + new Thread(() => + { + if (MessageBox.Show( + EarTrumpet.Properties.Resources.CriticalFailureFontLookupHelpText, + EarTrumpet.Properties.Resources.CriticalFailureDialogHeaderText, + MessageBoxButton.OKCancel, + MessageBoxImage.Error, + MessageBoxResult.OK) == MessageBoxResult.OK) + { + Trace.WriteLine($"App OnCriticalFontLoadFailure OK"); + ProcessHelper.StartNoThrow("https://eartrumpet.app/jmp/fixfonts"); + } + Environment.Exit(0); + }).Start(); + + // Stop execution because callbacks to the UI thread will likely cause another cascading font error. + new AutoResetEvent(false).WaitOne(); + } + + private IEnumerable GetTrayContextMenuItems() + { + var ret = new List(CollectionViewModel.AllDevices.OrderBy(x => x.DisplayName).Select(dev => new ContextMenuItem + { + DisplayName = dev.DisplayName, + IsChecked = dev.Id == CollectionViewModel.Default?.Id, + Command = new RelayCommand(() => dev.MakeDefaultDevice()), + })); + + if (!ret.Any()) + { + ret.Add(new ContextMenuItem + { + DisplayName = EarTrumpet.Properties.Resources.ContextMenuNoDevices, + IsEnabled = false, + }); + } + + ret.AddRange(new List + { + new ContextMenuSeparator(), + new ContextMenuItem + { + DisplayName = EarTrumpet.Properties.Resources.WindowsLegacyMenuText, + Children = new List + { + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.LegacyVolumeMixerText, Command = new RelayCommand(LegacyControlPanelHelper.StartLegacyAudioMixer) }, + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.PlaybackDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("playback")) }, + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.RecordingDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("recording")) }, + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.SoundsControlPanelText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("sounds")) }, + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.OpenSoundSettingsText, Command = new RelayCommand(() => SettingsPageHelper.Open("sound")) }, + new ContextMenuItem { + DisplayName = Environment.OSVersion.IsAtLeast(OSVersions.Windows11) ? + EarTrumpet.Properties.Resources.OpenAppsVolume_Windows11_Text + : EarTrumpet.Properties.Resources.OpenAppsVolume_Windows10_Text, Command = new RelayCommand(() => SettingsPageHelper.Open("apps-volume")) }, + }, + }, + new ContextMenuSeparator(), + }); + + var addonItems = AddonManager.Host.TrayContextMenuItems?.OrderBy(x => x.NotificationAreaContextMenuItems.FirstOrDefault()?.DisplayName).SelectMany(ext => ext.NotificationAreaContextMenuItems); + if (addonItems != null && addonItems.Any()) + { + ret.AddRange(addonItems); + ret.Add(new ContextMenuSeparator()); + } + + ret.AddRange(new List + { + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.FullWindowTitleText, Command = new RelayCommand(_mixerWindow.OpenOrBringToFront) }, + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.SettingsWindowText, Command = new RelayCommand(_settingsWindow.OpenOrBringToFront) }, + new ContextMenuItem { DisplayName = EarTrumpet.Properties.Resources.ContextMenuExitTitle, Command = new RelayCommand(Shutdown) }, + }); + return ret; + } + + private Window CreateSettingsExperience() + { + var defaultCategory = new SettingsCategoryViewModel( + EarTrumpet.Properties.Resources.SettingsCategoryTitle, + "\xE71D", + EarTrumpet.Properties.Resources.SettingsDescriptionText, + null, + new SettingsPageViewModel[] + { + new EarTrumpetShortcutsPageViewModel(Settings), + new EarTrumpetMouseSettingsPageViewModel(Settings), + new EarTrumpetCommunitySettingsPageViewModel(Settings), + new EarTrumpetLegacySettingsPageViewModel(Settings), + new EarTrumpetAboutPageViewModel(() => _errorReporter.DisplayDiagnosticData(), Settings) + }); + + var allCategories = new List(); + allCategories.Add(defaultCategory); + + if (AddonManager.Host.SettingsItems != null) + { + allCategories.AddRange(AddonManager.Host.SettingsItems.Select(a => CreateAddonSettingsPage(a))); + } + + var viewModel = new SettingsViewModel(EarTrumpet.Properties.Resources.SettingsWindowText, allCategories); + return new SettingsWindow { DataContext = viewModel }; + } + + private SettingsCategoryViewModel CreateAddonSettingsPage(IEarTrumpetAddonSettingsPage addonSettingsPage) + { + var addon = (EarTrumpetAddon)addonSettingsPage; + var category = addonSettingsPage.GetSettingsCategory(); + + if (!addon.IsInternal()) + { + category.Pages.Add(new AddonAboutPageViewModel(addon)); + } + return category; + } + + private Window CreateMixerExperience() => new FullWindow { DataContext = new FullWindowViewModel(CollectionViewModel) }; + + private void AbsoluteVolumeIncrement() + { + foreach (var device in CollectionViewModel.AllDevices.Where(d => !d.IsMuted || d.IsAbsMuted)) + { + // in any case this device is not abs muted anymore + device.IsAbsMuted = false; + device.IncrementVolume(2); + } + } + + private void AbsoluteVolumeDecrement() + { + foreach (var device in CollectionViewModel.AllDevices.Where(d => !d.IsMuted)) + { + // if device is not muted but will be muted by + bool wasMuted = device.IsMuted; + // device.IncrementVolume(-2); + device.Volume -= 2; + // if device is muted by this absolute down + // .IsMuted is not already updated + if (!wasMuted == (device.Volume <= 0)) + { + device.IsAbsMuted = true; + } + } + } + } +} diff --git a/EarTrumpet/CliRoutingService.cs b/EarTrumpet/CliRoutingService.cs new file mode 100644 index 000000000..86502399f --- /dev/null +++ b/EarTrumpet/CliRoutingService.cs @@ -0,0 +1,769 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using EarTrumpet.DataModel.Audio; +using EarTrumpet.DataModel.WindowsAudio; +using System.Windows.Threading; +using EarTrumpet.Interop.Helpers; + + +namespace EarTrumpet.CLIServices +{ + public class CliRoutingService + { + + private readonly App _app; + + private readonly List _managers = new List(); + private IAudioDeviceManager _singleManager; + private string _targetExeName; + private string _targetDeviceName; + + public CliRoutingService(App app) + { + _app = app; + } + + public bool TryHandleCliArgs(string[] args) + { + if (args == null || args.Length == 0) + { + return false; + } + + var a0 = NormalizeArg(args[0]); + + // --help / -h / /? / help + if (IsHelpArg(a0)) + { + PrintUsageAndExit(0); + return true; + } + + // --list-devices + if (string.Equals(a0, "list-devices", StringComparison.OrdinalIgnoreCase)) + { + StartCliListDevices(); + return true; + } + + // --list-apps + if (string.Equals(a0, "list-apps", StringComparison.OrdinalIgnoreCase)) + { + StartCliListApps(); + return true; + } + + // --set "app.exe" "Device Display Name" + if (string.Equals(a0, "set", StringComparison.OrdinalIgnoreCase) || + string.Equals(args[0], "--set", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length < 3) + { + TryAttachConsole(); + Console.Error.WriteLine("Usage: --set \"[app.exe]\" \"[Playback audio device name]\""); + HardExit(1); + return true; + } + + _targetExeName = args[1]; + _targetDeviceName = args[2]; + + StartCliSetRoute(); + return true; + } + + PrintUsageAndExit(0); + HardExit(0); + return false; + } + + private void StartCliSetRoute() + { + try + { + _singleManager = WindowsAudioFactory.CreateNonSharedDeviceManager(AudioDeviceKind.Playback); + _singleManager.Loaded += OnSetRouteManagerLoaded; + } + catch (Exception ex) + { + Trace.WriteLine($"CLI: Failed to start - {ex}"); + HardExit(10); + } + } + + private void OnSetRouteManagerLoaded(object sender, EventArgs e) + { + try { _singleManager.Loaded -= OnSetRouteManagerLoaded; } catch { } + + int exitCode = 0; + try + { + exitCode = RouteProcessesToDevice(); + } + catch (Exception ex) + { + Trace.WriteLine($"CLI: Unexpected failure: {ex}"); + exitCode = 20; + } + + HardExit(exitCode); + } + + private void StartCliListDevices() + { + try + { + foreach (AudioDeviceKind kind in Enum.GetValues(typeof(AudioDeviceKind))) + { + try + { + var mgr = WindowsAudioFactory.CreateNonSharedDeviceManager(kind); + mgr.Loaded += OnListDevicesManagerLoaded; + _managers.Add(mgr); + } + catch (Exception ex) + { + Trace.WriteLine($"CLI: Failed to create manager for kind {kind}: {ex}"); + } + } + + if (_managers.Count == 0) + { + TryAttachConsole(); + Console.Error.WriteLine("Failed to create audio device managers."); + HardExit(2); + } + } + catch (Exception ex) + { + TryAttachConsole(); + Console.Error.WriteLine("Failed to start device listing."); + Console.Error.WriteLine(ex.ToString()); + HardExit(2); + } + } + + + private int _loadedManagersForListDevices = 0; + private void OnListDevicesManagerLoaded(object sender, EventArgs e) + { + _loadedManagersForListDevices++; + if (_loadedManagersForListDevices >= _managers.Count) + { + int exitCode = 0; + try + { + var lines = new List(); + foreach (var mgr in _managers) + { + var def = mgr.Default; + foreach (var d in mgr.Devices.OrderBy(x => x.DisplayName)) + { + var isDefault = ReferenceEquals(d, def) ? " (Default)" : ""; + lines.Add($"[{mgr.Kind}] {d.DisplayName}{isDefault}"); + } + } + + TryAttachConsole(); + foreach (var line in lines) Console.WriteLine(line); + HardExit(exitCode); + } + catch (Exception ex) + { + TryAttachConsole(); + Console.Error.WriteLine("Failed to list devices."); + Console.Error.WriteLine(ex.ToString()); + HardExit(2); + } + } + } + private void StartCliListApps() + { + try + { + _singleManager = WindowsAudioFactory.CreateNonSharedDeviceManager(AudioDeviceKind.Playback); + _singleManager.Loaded += OnListAppsManagerLoaded; + } + catch (Exception ex) + { + TryAttachConsole(); + Console.Error.WriteLine("Failed to start app listing."); + Console.Error.WriteLine(ex.ToString()); + HardExit(2); + } + } + + private void OnListAppsManagerLoaded(object sender, EventArgs e) + { + + try { _singleManager.Loaded -= OnListAppsManagerLoaded; } catch { } + + try + { + var exes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var dev in _singleManager.Devices) + { + foreach (var sess in dev.Groups) + { + var exe = GetExeNameFromSession(sess); + if (!string.IsNullOrWhiteSpace(exe)) + { + exes.Add(exe); + } + } + } + + var list = exes.OrderBy(x => x).ToList(); + + TryAttachConsole(); + foreach (var x in list) Console.WriteLine(x); + HardExit(0); + } + catch (Exception ex) + { + TryAttachConsole(); + Console.Error.WriteLine("Failed to list apps."); + Console.Error.WriteLine(ex.ToString()); + HardExit(2); + } + } + + // 0 = success (routed at least one PID) + // 1 = invalid exe name + // 2 = no process found + // 3 = device not found + // 4 = nothing routed (processes found but all failed) + private int RouteProcessesToDevice() + { + var exeNoExt = Path.GetFileNameWithoutExtension(_targetExeName); + if (string.IsNullOrWhiteSpace(exeNoExt)) + { + return 1; + } + + var processes = Process.GetProcessesByName(exeNoExt); + if (processes == null || processes.Length == 0) + { + return 2; + } + + var targetDevice = FindTargetDevice(); + if (targetDevice == null) + { + return 3; + } + + var mgr = (IAudioDeviceManagerWindowsAudio)_singleManager; + int success = 0; + + foreach (var p in processes) + { + try + { + mgr.SetDefaultEndPoint(targetDevice.Id, p.Id); + success++; + } + catch (Exception ex) + { + Trace.WriteLine($"CLI: Failed to route PID {p?.Id}: {ex}"); + } + } + + return success > 0 ? 0 : 4; + } + + private IAudioDevice FindTargetDevice() + { + var devices = _singleManager.Devices; + return devices.FirstOrDefault(d => string.Equals(d.DisplayName, _targetDeviceName, StringComparison.OrdinalIgnoreCase)) ?? + devices.FirstOrDefault(d => (d.DisplayName ?? "").IndexOf(_targetDeviceName, StringComparison.OrdinalIgnoreCase) >= 0); + } + + private void PrintUsageAndExit(int code) + { + TryAttachConsole(); + var exe = AppDomain.CurrentDomain.FriendlyName; + Console.WriteLine("EarTrumpet CLI"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine($" {exe} --help Show this help"); + Console.WriteLine($" {exe} --list-devices List audio devices"); + Console.WriteLine($" {exe} --list-apps List audio applications (exe names)"); + Console.WriteLine($" {exe} --set \"[app.exe]\" \"[Playback audio device name]\" Route app to playback device"); + Console.WriteLine(); + Console.WriteLine("Notes:"); + Console.WriteLine(" '-', '--', and '/' prefixes are supported (e.g., -h, --help, /help)."); + HardExit(code); + } + + private static bool IsHelpArg(string arg) + { + var a = NormalizeArg(arg); + return a == "h" || a == "help" || a == "?"; + } + + private static string NormalizeArg(string arg) + { + if (string.IsNullOrWhiteSpace(arg)) return string.Empty; + var trimmed = arg.Trim().TrimStart('-', '/'); + return trimmed.ToLowerInvariant(); + } + + private static string GetExeNameFromSession(object sess) + { + var exe = GetProp(sess, "ExeName"); + if (!string.IsNullOrWhiteSpace(exe)) return Path.GetFileName(exe); + + var appId = GetProp(sess, "AppId"); + if (!string.IsNullOrWhiteSpace(appId)) return Path.GetFileName(appId); + + var display = GetProp(sess, "DisplayName"); + if (!string.IsNullOrWhiteSpace(display)) return Path.GetFileName(display); + + var isSystem = GetProp(sess, "IsSystemSoundsSession") ?? false; + if (isSystem) return "System Sounds"; + + return null; + } + + private static T GetProp(object obj, string prop) + { + try + { + var pi = obj.GetType().GetProperty(prop); + if (pi == null) return default; + var val = pi.GetValue(obj); + if (val == null) return default; + if (typeof(T).IsAssignableFrom(val.GetType())) return (T)val; + return (T)Convert.ChangeType(val, typeof(T)); + } + catch + { + return default; + } + } + + private void HardExit(int code) + { + Environment.Exit(code); + } + + private static bool _consoleAttached = false; + + private static void TryAttachConsole() + { + if (_consoleAttached) return; + + try + { + if (!AttachConsole(ATTACH_PARENT_PROCESS)) + { + } + } + catch { } + finally + { + _consoleAttached = true; + } + } + + private const int ATTACH_PARENT_PROCESS = -1; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int dwProcessId); + } + + + internal static class DeviceReader + { + + + public static IEnumerable FromEarTrumpet() + { + var tcs = new TaskCompletionSource>(); + + var thread = new Thread(() => + { + var dispatcher = Dispatcher.CurrentDispatcher; + + try + { + var earAsm = typeof(IAudioDeviceManager).Assembly; + + var mgrType = earAsm + .GetTypes() + .FirstOrDefault(t => + t.IsClass && + !t.IsAbstract && + typeof(IAudioDeviceManager).IsAssignableFrom(t) && + t.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(c => + { + var ps = c.GetParameters(); + return ps.Length == 1 && ps[0].ParameterType.IsEnum; + })); + + if (mgrType == null) + throw new TypeLoadException("Could not locate a concrete IAudioDeviceManager with a single enum constructor in the EarTrumpet assembly."); + + var ctor = mgrType + .GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .First(c => + { + var ps = c.GetParameters(); + return ps.Length == 1 && ps[0].ParameterType.IsEnum; + }); + + var enumType = ctor.GetParameters()[0].ParameterType; + + var enumValues = Enum.GetValues(enumType).Cast().ToList(); + + var managers = enumValues + .Select(val => (IAudioDeviceManager)ctor.Invoke(new object[] { val })) + .ToList(); + + int loadedCount = 0; + var frame = new DispatcherFrame(); + + EventHandler onLoaded = (s, e) => + { + loadedCount++; + if (loadedCount >= managers.Count) + { + var lines = new List(); + foreach (var mgr in managers) + { + lines.AddRange(RenderManagerDevices(mgr)); + } + tcs.TrySetResult(lines); + frame.Continue = false; + } + }; + + foreach (var mgr in managers) + { + mgr.Loaded += onLoaded; + } + + Dispatcher.PushFrame(frame); + + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + + return tcs.Task.GetAwaiter().GetResult(); + } + + private static IEnumerable RenderManagerDevices(IAudioDeviceManager mgr) + { + var kind = mgr.Kind; + var def = mgr.Default; + + return mgr.Devices + .OrderBy(d => d.DisplayName) + .Select(d => + { + var isDefault = ReferenceEquals(d, def) ? " (Default)" : ""; + return $"[{kind}] {d.DisplayName}{isDefault}"; + }) + .ToList(); + } + + public static IEnumerable ListRunningApps() + { + var tcs = new TaskCompletionSource>(); + + var thread = new Thread(() => + { + var dispatcher = Dispatcher.CurrentDispatcher; + + try + { + var earAsm = typeof(IAudioDeviceManager).Assembly; + + var mgrType = earAsm + .GetTypes() + .FirstOrDefault(t => + t.IsClass && + !t.IsAbstract && + typeof(IAudioDeviceManager).IsAssignableFrom(t) && + t.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(c => + { + var ps = c.GetParameters(); + return ps.Length == 1 && ps[0].ParameterType.IsEnum; + })); + + if (mgrType == null) + throw new TypeLoadException("Could not locate a concrete IAudioDeviceManager with a single enum constructor."); + + var ctor = mgrType + .GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .First(c => + { + var ps = c.GetParameters(); + return ps.Length == 1 && ps[0].ParameterType.IsEnum; + }); + + var enumType = ctor.GetParameters()[0].ParameterType; + var enumValues = Enum.GetValues(enumType).Cast().ToList(); + + var managers = enumValues + .Select(val => (IAudioDeviceManager)ctor.Invoke(new object[] { val })) + .ToList(); + + int loadedCount = 0; + var frame = new DispatcherFrame(); + + EventHandler onLoaded = (s, e) => + { + loadedCount++; + if (loadedCount >= managers.Count) + { + var exes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var mgr in managers) + { + var isPlayback = string.Equals(mgr.Kind, "Playback", StringComparison.OrdinalIgnoreCase); + if (!isPlayback) continue; + + foreach (var device in mgr.Devices) + { + foreach (var sess in device.Groups) + { + var exe = GetExeNameFromSession(sess); + if (!string.IsNullOrWhiteSpace(exe)) + { + exes.Add(exe); + } + } + } + } + + var list = exes.OrderBy(x => x).ToList(); + tcs.TrySetResult(list); + frame.Continue = false; + } + }; + + foreach (var mgr in managers) + { + mgr.Loaded += onLoaded; + } + + Dispatcher.PushFrame(frame); + + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + + return tcs.Task.GetAwaiter().GetResult(); + } + + private static string TryGetProcessPath(int pid) + { + try + { + using (var p = Process.GetProcessById(pid)) + { + return p?.MainModule?.FileName ?? string.Empty; + } + } + catch + { + return string.Empty; + } + } + + private static string GetExeNameFromSession(object sess) + { + var exe = GetProp(sess, "ExeName"); + if (!string.IsNullOrWhiteSpace(exe)) return System.IO.Path.GetFileName(exe); + + var appId = GetProp(sess, "AppId"); + if (!string.IsNullOrWhiteSpace(appId)) return System.IO.Path.GetFileName(appId); + + var display = GetProp(sess, "DisplayName"); + if (!string.IsNullOrWhiteSpace(display)) return System.IO.Path.GetFileName(display); + + var isSystem = GetProp(sess, "IsSystemSoundsSession") ?? false; + if (isSystem) return "System Sounds"; + + return null; + } + + private static string ExtractFileName(string s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + s = s.Trim(); + + var sepIdx = Math.Max(s.LastIndexOf('\\'), s.LastIndexOf('/')); + if (sepIdx >= 0 && sepIdx + 1 < s.Length) + s = s.Substring(sepIdx + 1); + + var bang = s.IndexOf('!'); + if (bang > 0) s = s.Substring(0, bang); + + return s.Length > 0 ? s : null; + } + + private static IAudioDevice FindDeviceByDisplayName(IAudioDeviceManager mgr, string name, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(name)) + { + error = "Device name is empty."; + return null; + } + + var devices = mgr.Devices.ToList(); + var exact = devices.FirstOrDefault(d => string.Equals(d.DisplayName, name, StringComparison.OrdinalIgnoreCase)); + if (exact != null) return exact; + + var contains = devices.Where(d => d.DisplayName.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); + if (contains.Count == 1) return contains[0]; + + if (contains.Count == 0) + { + error = $"Device \"{name}\" not found. Available playback devices:\n - " + + string.Join("\n - ", devices.Select(d => d.DisplayName)); + } + else + { + error = $"Device name \"{name}\" is ambiguous. Candidates:\n - " + + string.Join("\n - ", contains.Select(d => d.DisplayName)); + } + return null; + } + + private static bool MatchesExe(string exeName, string exeBase) + { + if (string.IsNullOrWhiteSpace(exeName) || string.IsNullOrWhiteSpace(exeBase)) return false; + var baseName = NormalizeExeBase(exeName); + return string.Equals(baseName, exeBase, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeExeBase(string exe) + { + if (string.IsNullOrWhiteSpace(exe)) return null; + exe = System.IO.Path.GetFileName(exe.Trim()); + if (exe.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + exe = exe.Substring(0, exe.Length - 4); + return exe; + } + + private static T GetProp(object obj, string prop) + { + var pi = obj.GetType().GetProperty(prop, BindingFlags.Public | BindingFlags.Instance); + if (pi == null) return default; + var val = pi.GetValue(obj); + if (val == null) return default; + if (typeof(T).IsAssignableFrom(val.GetType())) return (T)val; + try { return (T)Convert.ChangeType(val, typeof(T)); } catch { return default; } + } + + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + private const uint KEYEVENTF_KEYUP = 0x0002; + + private static void ForceAudioSessionRestart(HashSet pids, Action V) + { + foreach (var pid in pids) + { + try + { + var sessions = GetAudioSessionsForProcess(pid); + foreach (var session in sessions) + { + var volume = session.GetMasterVolume(); + var muted = session.GetMute(); + + session.SetMute(!muted, Guid.Empty); + Thread.Sleep(50); + session.SetMute(muted, Guid.Empty); + + V($"Toggled mute for pid {pid} to force stream restart"); + } + } + catch { } + } + } + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo); + + private const byte VK_MEDIA_PLAY_PAUSE = 0xB3; + private const uint KEYEVENTF_EXTENDEDKEY = 0x0001; + + private static IEnumerable GetAudioSessionsForProcess(int pid) + { + return new List(); + } + + private static string GetSessionDeviceIdForPid(int pid) + { + try + { + Type mgrType = null; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + mgrType = asm.GetType("EarTrumpet.DataModel.WindowsAudio.Internal.AudioDeviceManager", throwOnError: false); + if (mgrType != null) break; + } + if (mgrType == null) return string.Empty; + + var create = mgrType.GetMethod("Create", BindingFlags.Public | BindingFlags.Static); + var mgr = create?.Invoke(null, null); + if (mgr == null) return string.Empty; + + var sessionsProp = mgrType.GetProperty("Sessions", BindingFlags.Public | BindingFlags.Instance); + var sessions = sessionsProp?.GetValue(mgr) as System.Collections.IEnumerable; + if (sessions == null) return string.Empty; + + foreach (var s in sessions) + { + var spid = GetProp(s, "ProcessId"); + if (spid == pid) + { + return GetProp(s, "DeviceId") ?? string.Empty; + } + } + } + catch { } + return string.Empty; + } + + } + +} \ No newline at end of file