diff --git a/.github/workflows/crowdin_download.yml b/.github/workflows/crowdin_download.yml index 4bc6f39b..b684f3bf 100644 --- a/.github/workflows/crowdin_download.yml +++ b/.github/workflows/crowdin_download.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v2 - name: crowdin action - uses: crowdin/github-action@1.3.3 + uses: crowdin/github-action@v2 with: crowdin_branch_name: morphic-windows diff --git a/LICENSE.txt b/LICENSE.txt index 33862201..8a775bee 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright 2020-2024 Raising the Floor - US, Inc. +Copyright 2020-2025 Raising the Floor - US, Inc. The R&D leading to these results received funding from the: * Rehabilitation Services Administration, US Dept. of Education under diff --git a/Morphic.Client/App.xaml.cs b/Morphic.Client/App.xaml.cs index 2e3efdb7..8243c39f 100644 --- a/Morphic.Client/App.xaml.cs +++ b/Morphic.Client/App.xaml.cs @@ -1,4 +1,4 @@ -// Copyright 2020-2022 Raising the Floor - US, Inc. +// Copyright 2020-2025 Raising the Floor - US, Inc. // // Licensed under the New BSD license. You may not use this file except in // compliance with this License. @@ -200,6 +200,7 @@ public class EnabledFeature } public class MorphicBarConfigSection { + public string? defaultLocation { get; set; } public string? visibilityAfterLogin { get; set; } public List? extraItems { get; set; } } @@ -222,6 +223,7 @@ private struct CommonConfigurationContents public bool CustomMorphicBarsIsEnabled; public bool ResetSettingsIsEnabled; public bool SignInIsEnabled; + public ConfigurableFeatures.MorphicBarDefaultLocationOption MorphicBarDefaultLocation; public ConfigurableFeatures.MorphicBarVisibilityAfterLoginOption? MorphicBarVisibilityAfterLogin; public List ExtraMorphicBarItems; public string? TelemetrySiteId; @@ -258,7 +260,8 @@ private async Task GetCommonConfigurationAsync() // allow users to sign in to Morphic accounts result.SignInIsEnabled = true; // - // morphic bar (visibility and extra items) + // morphic bar (default location, visibility and extra items) + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomTrailing; result.MorphicBarVisibilityAfterLogin = null; result.ExtraMorphicBarItems = new List(); @@ -425,6 +428,44 @@ private async Task GetCommonConfigurationAsync() result.SignInIsEnabled = deserializedJson.features.signIn.enabled.Value; } + // capture the desired default location of the MorphicBar + if (deserializedJson.morphicBar?.defaultLocation is not null) + { + switch (deserializedJson.morphicBar?.defaultLocation) + { + case "topLeft": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.TopLeft; + break; + case "topRight": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.TopRight; + break; + case "bottomLeft": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomLeft; + break; + case "bottomRight": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomRight; + break; + // + case "topLeading": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.TopLeading; + break; + case "topTrailing": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.TopTrailing; + break; + case "bottomLeading": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomLeading; + break; + case "bottomTrailing": + result.MorphicBarDefaultLocation = ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomTrailing; + break; + default: + // sorry, we don't understand this visibility setting + // NOTE: consider refusing to start up (for security reasons) if the configuration file cannot be read + Logger?.LogError("Unknown morphicBar.defaultLocation setting: " + deserializedJson.morphicBar?.visibilityAfterLogin); + return result; + } + } + // capture the desired after-login (autorun) visibility of the MorphicBar switch (deserializedJson.morphicBar?.visibilityAfterLogin) { @@ -798,6 +839,16 @@ void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptio try { + Version? applicationVersion = Assembly.GetExecutingAssembly().GetName().Version; + if (applicationVersion is not null) + { + this.Logger.LogError("version: " + applicationVersion.Major.ToString() + "." + applicationVersion.Minor.ToString() + "." + applicationVersion.Build.ToString() + "." + applicationVersion.Revision.ToString()); + } + else + { + this.Logger.LogError("version: null"); + } + // this.Logger.LogError("handled uncaught exception: {msg}", ex.Message); this.Logger.LogError(ex.StackTrace); } @@ -928,6 +979,7 @@ protected override async void OnStartup(StartupEventArgs e) resetSettingsIsEnabled: commonConfiguration.ResetSettingsIsEnabled, signInIsEnabled: commonConfiguration.SignInIsEnabled, telemetryIsEnabled: telemetryIsEnabled, + morphicBarDefaultLocation: commonConfiguration.MorphicBarDefaultLocation, morphicBarvisibilityAfterLogin: commonConfiguration.MorphicBarVisibilityAfterLogin, morphicBarExtraItems: commonConfiguration.ExtraMorphicBarItems, telemetrySiteId: commonConfiguration.TelemetrySiteId @@ -936,9 +988,10 @@ protected override async void OnStartup(StartupEventArgs e) // before initializing any user interface, initialize our localization culture var currentUICulture = System.Threading.Thread.CurrentThread.CurrentUICulture; var iso639LanguageCode = Morphic.Localization.LocalizationManager.GetIso639LanguageCode(currentUICulture); + var cultureName = Morphic.Localization.LocalizationManager.GetCultureName(currentUICulture); // // NOTE: if the current culture is not supported (or if it's the same as the base culture), fail silently and use the base settings - _ = Morphic.Localization.LocalizationManager.SetUICulture(App.Current.Resources, iso639LanguageCode); + _ = Morphic.Localization.LocalizationManager.SetUICulture(App.Current.Resources, iso639LanguageCode, cultureName); // determine if Morphic (i.e. the taskbar icon, the MorphicBar, etc.) should be shown bool morphicShouldBeHidden = false; @@ -959,7 +1012,7 @@ protected override async void OnStartup(StartupEventArgs e) this.MorphicMainMenu = new(); // // NOTE: if the current culture is not supported (or if it's the same as the base culture), fail silently and use the base settings - _ = Morphic.Localization.LocalizationManager.SetUICulture(this.MorphicMainMenu.Resources, iso639LanguageCode); + _ = Morphic.Localization.LocalizationManager.SetUICulture(this.MorphicMainMenu.Resources, iso639LanguageCode, cultureName); // initialize our taskbar icon (button); this will not show the button this.InitTaskbarIconWithoutShowing(); @@ -1078,7 +1131,7 @@ private void InitTaskbarIconWithoutShowing() Icon = morphicIcon, Text = "Morphic", TrayIconLocation = Controls.HybridTrayIcon.TrayIconLocationOption.NextToNotificationTray, - Visible = false, + Visible = false, // NOTE: default state; should not be necessary }; this.HybridTrayIcon = hybridTrayIcon; diff --git a/Morphic.Client/Assets/bar-icons/morphic-logo.xaml b/Morphic.Client/Assets/bar-icons/morphic-logo.xaml index d5ab7628..d70440ea 100644 --- a/Morphic.Client/Assets/bar-icons/morphic-logo.xaml +++ b/Morphic.Client/Assets/bar-icons/morphic-logo.xaml @@ -1,18 +1,23 @@  - - - + + + + - + - - - - + + + + + + + + diff --git a/Morphic.Client/AtUseCounter/AtUseCounterEngine.cs b/Morphic.Client/AtUseCounter/AtUseCounterEngine.cs index 67ba2f2e..4648dc2c 100644 --- a/Morphic.Client/AtUseCounter/AtUseCounterEngine.cs +++ b/Morphic.Client/AtUseCounter/AtUseCounterEngine.cs @@ -1,4 +1,4 @@ -// Copyright 2020-2023 Raising the Floor - US, Inc. +// Copyright 2020-2025 Raising the Floor - US, Inc. // // Licensed under the New BSD license. You may not use this file except in // compliance with this License. @@ -67,6 +67,8 @@ internal class AtUseCounterEngine private static Morphic.Core.MorphicSequentialTaskScheduler _sequentialTaskScheduler; private static TaskFactory _sequentialTaskFactory; + private static Morphic.WindowsNative.Process.ProcessWatcher _processWatcher; + static public async Task ConfigureAndStartAtUseCounterAsync(string mqttServerHostname, string appName, string appKey, Utils.TelemetryUtils.TelemetryIdComponents telemetryIds) { // start a stopwatch; we'll use this to help understand when rapid sequences of changes are really just one change (e.g. mouse cursor size changes) @@ -173,11 +175,13 @@ static public async Task ConfigureAndStartAtUseCounterAsync(string mqttServerHos } // triggered when watched processes are started/stopped - var processWatcher = new Morphic.WindowsNative.Process.ProcessWatcher(); - processWatcher.ProcessNamesWatchFilter = new(new string[] { "Magnify.exe", "Magnify", "ScreenClippingHost.exe", "ScreenClippingHost", "SystemSettings.exe", "SystemSettings" }); + var processWatcher = Morphic.WindowsNative.Process.ProcessWatcher.CreateNew(); + processWatcher.ProcessNamesWatchFilter = new(new string[] { "Magnify.exe", "Magnify", "ScreenClippingHost.exe", "ScreenClippingHost", "SystemSettings.exe", "SystemSettings", "VoiceAccess.exe", "VoiceAccess" }); processWatcher.ProcessStarted += AtUseCounterEngine.ProcessWatcher_ProcessStarted; processWatcher.ProcessStopped += AtUseCounterEngine.ProcessWatcher_ProcessStopped; - processWatcher.Start(new TimeSpan(0, 0, 1)); + _processWatcher = processWatcher; + // + Morphic.WindowsNative.Process.ProcessWatcher.Start(new TimeSpan(0, 0, 1)); } // NOTE: this function waits up to two seconds for the telemetry client to close @@ -665,37 +669,44 @@ static void CursorsSubKey_RegistryKeyChangedEvent(Morphic.WindowsNative.Registry static void ProcessWatcher_ProcessStarted(object? sender, Morphic.WindowsNative.Process.ProcessWatcher.ProcessUpdatedEventArgs e) { - if (SHOW_EVENT_HANDLER_CALLS == true) - { - Debug.WriteLine("ProcessWatcher_ProcessStarted"); - } - - if (e.ProcessName.Contains("Magnify", StringComparison.InvariantCultureIgnoreCase) == true) - { - Debug.WriteLine("CHANGED | Magnifier shown"); - - // submit telemetry event - _telemetryClient?.EnqueueEvent("magnifierShow", null); - } - else if (e.ProcessName.Contains("ScreenClippingHost", StringComparison.InvariantCultureIgnoreCase) == true) - { - Debug.WriteLine("CHANGED | Screen clipping activated"); - - // submit telemetry event - _telemetryClient?.EnqueueEvent("screenSnip", null); - } - else if (e.ProcessName.Contains("SystemSettings", StringComparison.InvariantCultureIgnoreCase) == true) - { - Debug.WriteLine("CHANGED | System Settings app started"); - - // submit telemetry event - _telemetryClient?.EnqueueEvent("systemSettings", null); - } - else - { - Debug.Assert(false, "invalid code path; we are not watching any other process names"); - Debug.WriteLine("CHANGED | Process started: " + e.ProcessName); - } + if (SHOW_EVENT_HANDLER_CALLS == true) + { + Debug.WriteLine("ProcessWatcher_ProcessStarted"); + } + + if (e.ProcessName.Contains("Magnify", StringComparison.InvariantCultureIgnoreCase) == true) + { + Debug.WriteLine("CHANGED | Magnifier shown"); + + // submit telemetry event + _telemetryClient?.EnqueueEvent("magnifierShow", null); + } + else if (e.ProcessName.Contains("ScreenClippingHost", StringComparison.InvariantCultureIgnoreCase) == true) + { + Debug.WriteLine("CHANGED | Screen clipping activated"); + + // submit telemetry event + _telemetryClient?.EnqueueEvent("screenSnip", null); + } + else if (e.ProcessName.Contains("SystemSettings", StringComparison.InvariantCultureIgnoreCase) == true) + { + Debug.WriteLine("CHANGED | System Settings app started"); + + // submit telemetry event + _telemetryClient?.EnqueueEvent("systemSettings", null); + } + else if (e.ProcessName.Contains("VoiceAccess", StringComparison.InvariantCultureIgnoreCase) == true) + { + Debug.WriteLine("CHANGED | Voice Access started"); + + // submit telemetry event + _telemetryClient?.EnqueueEvent("voiceControlOn", null); + } + else + { + Debug.Assert(false, "invalid code path; we are not watching any other process names"); + Debug.WriteLine("CHANGED | Process started: " + e.ProcessName); + } } static void ProcessWatcher_ProcessStopped(object? sender, Morphic.WindowsNative.Process.ProcessWatcher.ProcessUpdatedEventArgs e) @@ -720,6 +731,13 @@ static void ProcessWatcher_ProcessStopped(object? sender, Morphic.WindowsNative. { Debug.WriteLine("CHANGED | System Settings app closed"); } + else if (e.ProcessName.Contains("VoiceAccess", StringComparison.InvariantCultureIgnoreCase) == true) + { + Debug.WriteLine("CHANGED | Voice Access stopped"); + + // submit telemetry event + _telemetryClient?.EnqueueEvent("voiceControlOff", null); + } else { Debug.Assert(false, "invalid code path; we are not watching any other process names"); diff --git a/Morphic.Client/Bar/BarManager.cs b/Morphic.Client/Bar/BarManager.cs index 09db53e3..de164212 100644 --- a/Morphic.Client/Bar/BarManager.cs +++ b/Morphic.Client/Bar/BarManager.cs @@ -273,47 +273,10 @@ public async Task LoadSessionBarAsync(MorphicSession session, string communityId UserCommunity? community = null; UserBar? userBar = null; - //if (session.Communities.Length == 0) - //{ - // MessageBox.Show("You are not part of a Morphic community yet.", "Morphic"); - //} - //else if (session.Communities.Length == 1) - //{ - // community = session.Communities.First(); - //} - //else - //{ - // The user is a member of multiple communities. - - //// See if any membership has changed - //bool changed = session.Communities.Length != lastCommunities.Length - // || !session.Communities.Select(c => c.Id).OrderBy(id => id) - // .SequenceEqual(lastCommunities.OrderBy(id => id)); - - if (/*!changed &&*/ communityId is not null) - { - community = session.Communities.FirstOrDefault(c => c.Id == communityId); - } - - //if (community is null) - //{ - // this.Logger.LogInformation("Showing community picker"); - - // // Load the bars while the picker is shown - // Dictionary> bars = - // session.Communities.ToDictionary(c => c.Id, c => session.GetBar(c.Id)); - - // // Show the picker - // CommunityPickerWindow picker = new CommunityPickerWindow(session.Communities); - // bool gotCommunity = picker.ShowDialog() == true; - // community = gotCommunity ? picker.SelectedCommunity : null; - - // if (community is not null) - // { - // userBar = await bars[community.Id]; - // } - //} - //} + if (/*!changed &&*/ communityId is not null) + { + community = session.Communities.FirstOrDefault(c => c.Id == communityId); + } if (community is not null) { diff --git a/Morphic.Client/Bar/Data/Actions/ApplicationProcessUtils.cs b/Morphic.Client/Bar/Data/Actions/ApplicationProcessUtils.cs new file mode 100644 index 00000000..d437d530 --- /dev/null +++ b/Morphic.Client/Bar/Data/Actions/ApplicationProcessUtils.cs @@ -0,0 +1,116 @@ +// Copyright 2025 Raising the Floor - US, Inc. +// +// Licensed under the New BSD license. You may not use this file except in +// compliance with this License. +// +// You may obtain a copy of the License at +// https://github.com/raisingthefloor/morphic-windows/blob/master/LICENSE.txt +// +// The R&D leading to these results received funding from the: +// * Rehabilitation Services Administration, US Dept. of Education under +// grant H421A150006 (APCP) +// * National Institute on Disability, Independent Living, and +// Rehabilitation Research (NIDILRR) +// * Administration for Independent Living & Dept. of Education under grants +// H133E080022 (RERC-IT) and H133E130028/90RE5003-01-00 (UIITA-RERC) +// * European Union's Seventh Framework Programme (FP7/2007-2013) grant +// agreement nos. 289016 (Cloud4all) and 610510 (Prosperity4All) +// * William and Flora Hewlett Foundation +// * Ontario Ministry of Research and Innovation +// * Canadian Foundation for Innovation +// * Adobe Foundation +// * Consumer Electronics Association Foundation + +using Morphic.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Morphic.Client.Bar.Data.Actions; + +public class ApplicationProcessUtils +{ + public interface IStartApplicationError + { + public record CannotFindExecutable : IStartApplicationError; + public record NotStarted : IStartApplicationError; + public record Win32Exception(System.ComponentModel.Win32Exception Exception) : IStartApplicationError; + } + public static MorphicResult StartApplication(string pathToApplication) + { + if (pathToApplication is null || System.IO.File.Exists(pathToApplication!) == false) + { + return MorphicResult.ErrorResult(new IStartApplicationError.CannotFindExecutable()); + } + + var startProcessResult = Morphic.WindowsNative.Process.Process.StartProcess(pathToApplication); + if (startProcessResult.IsError == true) + { + switch (startProcessResult.Error!) + { + case Morphic.WindowsNative.Process.Process.IStartProcessError.NotStarted: + return MorphicResult.ErrorResult(new IStartApplicationError.NotStarted()); + case Morphic.WindowsNative.Process.Process.IStartProcessError.Win32Exception(var exception): + return MorphicResult.ErrorResult(new IStartApplicationError.Win32Exception(exception)); + default: + throw new MorphicUnhandledErrorException(); + } + } + + return MorphicResult.OkResult(); + } + + public interface IStopApplicationError + { + public record NotStarted : IStopApplicationError; + public record Win32Exception(System.ComponentModel.Win32Exception Exception) : IStopApplicationError; + } + public static async Task> StopApplicationAsync(string processName, TimeSpan timeout) + { + // NOTE: we are stopping the process based on its name, so technically this could refer to a _different_ same-process-name app than our target + // [it is possible to check the path of the process, but that would require admin permissions; to keep things simple, we have opted to react simply to the task name itself.] + + // get process ID for application + var processId = Morphic.WindowsNative.Process.Process.GetProcessIdOrNullByProcessName(processName); + if (processId is null) + { + return MorphicResult.ErrorResult(new IStopApplicationError.NotStarted()); + } + + // try closing all the windows for the process + var closeResult = Morphic.WindowsNative.Process.Process.CloseAllWindowsForProcess(processId!.Value); + if (closeResult.IsError == true) + { + switch (closeResult.Error!) + { + case Morphic.WindowsNative.Process.Process.ICloseAllWindowsError.NotRunning: + return MorphicResult.ErrorResult(new IStopApplicationError.NotStarted()); + case Morphic.WindowsNative.Process.Process.ICloseAllWindowsError.PartialFailure: + System.Diagnostics.Debug.WriteLine("Could not close all windows associated with application's process"); + break; + default: + throw new MorphicUnhandledErrorException(); + } + } + + // finally, wait for the process to exit + var stopProcessResult = await Morphic.WindowsNative.Process.Process.StopProcessWithIdAsync(processId!.Value, timeout, true); + if (stopProcessResult.IsError == true) + { + switch (stopProcessResult.Error!) + { + case Morphic.WindowsNative.Process.Process.IStopProcessError.NotRunning: + // this is fine (as the process may have terminated when the windows were closed; proceed + break; + case Morphic.WindowsNative.Process.Process.IStopProcessError.Win32Exception(var exception): + return MorphicResult.ErrorResult(new IStopApplicationError.Win32Exception(exception)); + default: + throw new MorphicUnhandledErrorException(); + } + } + + return MorphicResult.OkResult(); + } +} diff --git a/Morphic.Client/Bar/Data/Actions/BarAction.cs b/Morphic.Client/Bar/Data/Actions/BarAction.cs index 059f5c86..3cd0f388 100644 --- a/Morphic.Client/Bar/Data/Actions/BarAction.cs +++ b/Morphic.Client/Bar/Data/Actions/BarAction.cs @@ -93,8 +93,21 @@ private async Task SendTelemetryForBarAction(string? source = null, bool? toggle case "log-off": await App.Current.Telemetry_RecordEventAsync("signOut"); break; + case "voice-control": + // NOTE: These are also repeated below in the "+3" section + switch (source) + { + case "on": + await App.Current.Telemetry_RecordEventAsync("voiceControlOn"); + break; + case "off": + await App.Current.Telemetry_RecordEventAsync("voiceControlOff"); + break; + } + break; case "volume": { + // NOTE: These are also repeated below in the "+3" section if (source == "up") { await App.Current.Telemetry_RecordEventAsync("volumeUp"); @@ -236,6 +249,14 @@ private async Task SendTelemetryForBarAction(string? source = null, bool? toggle case "ejectallusb": await App.Current.Telemetry_RecordEventAsync("ejectUsbDrives"); break; + case "voicecontrolon": + // NOTE: this is captured here because telemetry for voiceControlOff on the +3 bar is not captured otherwise + await App.Current.Telemetry_RecordEventAsync("voiceControlOn"); + break; + case "voicecontroloff": + // NOTE: this is captured here because telemetry for voiceControlOff on the +3 bar is not captured otherwise + await App.Current.Telemetry_RecordEventAsync("voiceControlOff"); + break; case "volumeDown": // NOTE: this is captured here because telemetry for volumeDown on the +3 bar is not captured by SendTelemetryForBarAction await App.Current.Telemetry_RecordEventAsync("volumeDown"); @@ -347,7 +368,7 @@ protected async override Task> InvokeAsy { await App.Current.Telemetry_RecordEventAsync(this.TelemetryEventName!); } - } + } } } diff --git a/Morphic.Client/Bar/Data/Actions/Functions.cs b/Morphic.Client/Bar/Data/Actions/Functions.cs index e1bb0cbd..727c980c 100644 --- a/Morphic.Client/Bar/Data/Actions/Functions.cs +++ b/Morphic.Client/Bar/Data/Actions/Functions.cs @@ -2,9 +2,6 @@ namespace Morphic.Client.Bar.Data.Actions { using Microsoft.Extensions.Logging; using Morphic.Core; - using Morphic.WindowsNative.Speech; - using Settings.SettingsHandlers; - using Settings.SolutionsRegistry; using System; using System.Collections.Generic; using System.Diagnostics; @@ -13,7 +10,6 @@ namespace Morphic.Client.Bar.Data.Actions using System.Threading; using System.Threading.Tasks; using System.Windows; - using System.Windows.Automation.Text; using UI; [HasInternalFunctions] @@ -276,7 +272,7 @@ public static async Task> ReadAloudAsync // activate the target window (i.e. topmost/last-active window, rather than the MorphicBar); we will then capture the current selection in that window // NOTE: ideally we would activate the last window as part of our atomic operation, but we really have no control over whether or not another application // or the user changes the activated window (and our internal code is also not set up to block us from moving activation/focus temporarily). - await SelectionReader.Default.ActivateLastActiveWindow(); + await Morphic.WindowsNative.Speech.SelectionReader.Default.ActivateLastActiveWindowAsync(); // as a primary strategy, try using the built-in Windows functionality for capturing the current selection via UI automation // NOTE: this does not work with some apps (such as Internet Explorer...but also others) @@ -286,7 +282,7 @@ public static async Task> ReadAloudAsync await s_captureTextSemaphore.WaitAsync(); try { - var getSelectedTextResult = Morphic.WindowsNative.UIAutomation.UIAutomationClient.GetSelectedText(); + var getSelectedTextResult = Morphic.WindowsNative.UIAutomation.UIAutomationSelectedTextScripts.GetSelectedText(); if (getSelectedTextResult.IsSuccess == true) { selectedText = getSelectedTextResult.Value; @@ -313,16 +309,16 @@ public static async Task> ReadAloudAsync else { // NOTE: we only log errors here, rather than returning an error condition to our caller; we don't return immediately here because we have a backup strategy (i.e. ctrl+c) which we employ if this strategy fails - switch (getSelectedTextResult.Error!.Value) + switch (getSelectedTextResult.Error!) { - case WindowsNative.UIAutomation.UIAutomationClient.CaptureSelectedTextError.Values.ComInterfaceInstantiationFailed: + case WindowsNative.UIAutomation.UIAutomationSelectedTextScripts.ICaptureSelectedTextError.ComInterfaceInstantiationFailed: App.Current.Logger.LogDebug("ReadAloud: Capture selected text via UI automation failed (com interface could not be instantiated)"); break; - case WindowsNative.UIAutomation.UIAutomationClient.CaptureSelectedTextError.Values.TextRangeIsNull: + case WindowsNative.UIAutomation.UIAutomationSelectedTextScripts.ICaptureSelectedTextError.TextRangeIsNull: App.Current.Logger.LogDebug("ReadAloud: Capture selected text via UI automation returned a null text range; this is an unexpected error condition"); break; - case WindowsNative.UIAutomation.UIAutomationClient.CaptureSelectedTextError.Values.Win32Error: - App.Current.Logger.LogDebug("ReadAloud: Capture selected text via UI automation resulted in win32 error code: " + getSelectedTextResult.Error!.Win32ErrorCode.ToString()); + case WindowsNative.UIAutomation.UIAutomationSelectedTextScripts.ICaptureSelectedTextError.Win32Error(var win32ErrorCode): + App.Current.Logger.LogDebug("ReadAloud: Capture selected text via UI automation resulted in win32 error code: " + win32ErrorCode.ToString()); break; default: throw new MorphicUnhandledErrorException(); @@ -836,7 +832,7 @@ private static async Task> SendKeysAsync(FunctionArgs args) { - await SelectionReader.Default.ActivateLastActiveWindow(); + await Morphic.WindowsNative.Speech.SelectionReader.Default.ActivateLastActiveWindowAsync(); System.Windows.Forms.SendKeys.SendWait(args["keys"]); return MorphicResult.OkResult(); } @@ -1215,22 +1211,22 @@ internal async static Task> SetDarkModeS public static async Task> DarkModeAsync(FunctionArgs args) { // if we have a "value" property, this is a multi-segmented button and we should use "value" instead of "state" - bool on; + bool newState; if (args.Arguments.Keys.Contains("value")) { - on = (args["value"] == "on"); + newState = (args["value"] == "on"); } else if (args.Arguments.Keys.Contains("state")) { - on = (args["state"] == "on"); + newState = (args["state"] == "on"); } else { System.Diagnostics.Debug.Assert(false, "Function 'darkMode' did not receive a new state"); - on = false; + newState = false; } - var setDarkModeStateResult = await Functions.SetDarkModeStateAsync(on); + var setDarkModeStateResult = await Functions.SetDarkModeStateAsync(newState); if (setDarkModeStateResult.IsError == true) { return MorphicResult.ErrorResult(); @@ -1241,6 +1237,131 @@ public static async Task> DarkModeAsync( // + private const string VOICE_ACCESS_PROCESS_NAME = "VoiceAccess"; + + internal async static Task> SetVoiceAccessStateAsync(bool state) + { + var osVersion = Morphic.WindowsNative.OsVersion.OsVersion.GetWindowsVersion(); + if (osVersion is null) + { + // error + return MorphicResult.ErrorResult(); + } + else + { + // Windows 10 v1903+ + + var pathToVoiceAccess = Functions.GetPathToVoiceAccess(); + if (pathToVoiceAccess is null) + { + Debug.WriteLine("ERROR: could not find VoiceAccess.exe"); + return MorphicResult.ErrorResult(); + } + + if (state == true) + { + var startResult = ApplicationProcessUtils.StartApplication(pathToVoiceAccess); + if (startResult.IsError == true) + { + switch (startResult.Error!) + { + case ApplicationProcessUtils.IStartApplicationError.CannotFindExecutable: + Debug.WriteLine("Could not start Pointing Magnifier.\n\nCannot find application's executable file."); + return MorphicResult.ErrorResult(); + case ApplicationProcessUtils.IStartApplicationError.NotStarted: + Debug.WriteLine("Could not start Pointing Magnifier.\n\nApplication was not started."); + return MorphicResult.ErrorResult(); + case ApplicationProcessUtils.IStartApplicationError.Win32Exception(var exception): + Debug.WriteLine("Could not start Pointing Magnifier.\n\nWin32 error code: " + exception.NativeErrorCode.ToString()); + return MorphicResult.ErrorResult(); + default: + throw new MorphicUnhandledErrorException(); + } + } + } + else + { + var stopResult = await ApplicationProcessUtils.StopApplicationAsync(VOICE_ACCESS_PROCESS_NAME, new TimeSpan(0, 0, 0, 1)); + if (stopResult.IsError == true) + { + switch (stopResult.Error!) + { + case ApplicationProcessUtils.IStopApplicationError.NotStarted: + // if the pointing magnifier was already stopped, proceed + Debug.WriteLine("DEBUG: could not stop Pointing Magnifier: PROCESS WAS NOT RUNNING"); + return MorphicResult.ErrorResult(); + case ApplicationProcessUtils.IStopApplicationError.Win32Exception(var exception): + Debug.WriteLine("Could not close pointing magnifier.\n\nWin32 error code: " + exception.NativeErrorCode.ToString()); + return MorphicResult.ErrorResult(); + default: + throw new MorphicUnhandledErrorException(); + } + } + } + } + + return MorphicResult.OkResult(); + } + + [InternalFunction("voiceAccess")] + public static async Task> VoiceAccessAsync(FunctionArgs args) + { + // if we have a "value" property, this is a multi-segmented button and we should use "value" instead of "state" + bool newState; + if (args.Arguments.Keys.Contains("value")) + { + newState = (args["value"] == "on"); + } + else if (args.Arguments.Keys.Contains("state")) + { + newState = (args["state"] == "on"); + } + else + { + System.Diagnostics.Debug.Assert(false, "Function 'voiceAccess' did not receive a new state"); + newState = false; + } + + var setVoiceAccessStateResult = await Functions.SetVoiceAccessStateAsync(newState); + if (setVoiceAccessStateResult.IsError == true) + { + return MorphicResult.ErrorResult(); + } + + return MorphicResult.OkResult(); + } + + [InternalFunction("voiceAccessOn")] + public static async Task> VoiceAccessOnAsync(FunctionArgs args) + { + args.Arguments.Add("value", "on"); + return await VoiceAccessAsync(args); + } + + [InternalFunction("voiceAccessOff")] + public static async Task> VoiceAccessOffAsync(FunctionArgs args) + { + args.Arguments.Add("value", "off"); + return await VoiceAccessAsync(args); + } + + private static string? GetPathToVoiceAccess() + { + var windowsSystemFolder = Environment.GetFolderPath(Environment.SpecialFolder.System); + var pathToVoiceAccess = System.IO.Path.Combine(windowsSystemFolder, "VoiceAccess.exe"); + + if (System.IO.File.Exists(pathToVoiceAccess) == true) + { + return pathToVoiceAccess; + } + else + { + return null; + } + } + + // + const string WORD_RUNNING_MESSAGE = "You need to exit Word in order to use the Word Simplify buttons.\n\n(1) Quit Word.\n(2) Use the Word Simplify buttons to add or remove the simplified ribbon(s) you want.\n(3) Re-launch Word."; private static bool IsSafeToModifyRibbonFile_WarnUser() diff --git a/Morphic.Client/Bar/Data/BarData.cs b/Morphic.Client/Bar/Data/BarData.cs index 7a80b031..f290bd8b 100644 --- a/Morphic.Client/Bar/Data/BarData.cs +++ b/Morphic.Client/Bar/Data/BarData.cs @@ -285,6 +285,44 @@ private bool IsSecondaryItem(BarItem item) extraBarItemShouldBeAdded = true; } break; + case "voice": + { + // NOTE: in the future, this control may become a 2-button 'voice' section; if not, we may want to alias this as both "voice" and "voicecontrol"s + extraBarItem.Text = extraItemData.label ?? "{{QuickStrip_VoiceControl_Title}}"; + // + var turnOnVoiceAccessAction = new Morphic.Client.Bar.Data.Actions.InternalAction(); + turnOnVoiceAccessAction.TelemetryEventName = "morphicBarExtraItem"; + turnOnVoiceAccessAction.FunctionName = "voiceAccessOn"; + var onButton = new BarMultiButton.ButtonInfo + { + Text = "{{QuickStrip_VoiceControl_On_Title}}", + Action = turnOnVoiceAccessAction, + TelemetryCategory = "morphicBarExtraItem", + Tooltip = "{{QuickStrip_VoiceControl_On_HelpTitle}}", + Value = "voicecontrolon" + }; + // + var turnOffVoiceAccessAction = new Morphic.Client.Bar.Data.Actions.InternalAction(); + turnOffVoiceAccessAction.TelemetryEventName = "morphicBarExtraItem"; + turnOffVoiceAccessAction.FunctionName = "voiceAccessOff"; + var offButton = new BarMultiButton.ButtonInfo + { + Text = "{{QuickStrip_VoiceControl_Off_Title}}", + Action = turnOffVoiceAccessAction, + TelemetryCategory = "morphicBarExtraItem", + Tooltip = "{{QuickStrip_VoiceControl_Off_HelpTitle}}", + Value = "voicecontroloff" + }; + // + ((BarMultiButton)extraBarItem).Buttons = new Dictionary + { + { "on", onButton }, + { "off", offButton } + }; + // + extraBarItemShouldBeAdded = true; + } + break; case "volume": { extraBarItem.Text = extraItemData.label ?? "{{QuickStrip_Volume_Title}}"; @@ -419,11 +457,12 @@ private bool IsSecondaryItem(BarItem item) } // add a spacer entry + // TODO: we need to make this button invisible to mouse input (so that dragging in this position will still drag the MorphicBar) BarButton spacerBarItem = new BarButton(defaultBar); spacerBarItem.ToolTipHeader = ""; spacerBarItem.ToolTip = ""; spacerBarItem.Text = ""; - spacerBarItem.ColorValue = "#FFFFFF"; + spacerBarItem.ColorValue = "#00FFFFFF"; // defaultBar?.AllItems.Add(spacerBarItem); } diff --git a/Morphic.Client/Bar/UI/PrimaryBarWindow.cs b/Morphic.Client/Bar/UI/PrimaryBarWindow.cs index 5d28766c..15c18c08 100644 --- a/Morphic.Client/Bar/UI/PrimaryBarWindow.cs +++ b/Morphic.Client/Bar/UI/PrimaryBarWindow.cs @@ -2,9 +2,11 @@ namespace Morphic.Client.Bar.UI { using AppBarWindow; using Data; + using Morphic.Client.Config; using Morphic.WindowsNative; using Morphic.WindowsNative.Speech; using System; + using System.Diagnostics; using System.Linq; using System.Windows; using System.Windows.Interop; @@ -136,9 +138,51 @@ protected override void SetInitialPosition() Point pos = this.Bar.Position.Primary.GetPosition(workArea, size); this.Left = pos.X; this.Top = pos.Y; + + // OBSERVATION: the default corner position needs "edge docking" options and we should make sure none of those are selected _before_ we enter this outer 'if' block + // OBSERVATION: we should only set the "default" location if the user doesn't already have a last manually-moved corner/edge set for location + if (_cornerPosition is null) + { + switch (ConfigurableFeatures.MorphicBarDefaultLocation) + { + case ConfigurableFeatures.MorphicBarDefaultLocationOption.TopLeft: + _cornerPosition = CornerPosition.TopLeft; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.TopRight: + _cornerPosition = CornerPosition.TopRight; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomLeft: + _cornerPosition = CornerPosition.BottomLeft; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomRight: + _cornerPosition = CornerPosition.BottomRight; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.TopLeading: + // NOTE: RTL awareness is not yet implemented in Morphic (i.e. planned for v2.0) + _cornerPosition = CornerPosition.TopLeft; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.TopTrailing: + // NOTE: RTL awareness is not yet implemented in Morphic (i.e. planned for v2.0) + _cornerPosition = CornerPosition.TopRight; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomLeading: + // NOTE: RTL awareness is not yet implemented in Morphic (i.e. planned for v2.0) + _cornerPosition = CornerPosition.BottomLeft; + break; + case ConfigurableFeatures.MorphicBarDefaultLocationOption.BottomTrailing: + // NOTE: RTL awareness is not yet implemented in Morphic (i.e. planned for v2.0) + _cornerPosition = CornerPosition.BottomRight; + break; + default: + Debug.Assert(false, "ERROR: ConfigurableFeatures.MorphicBarDefaultLocationOption is invalid for non-docked MorphicBars"); + break; + } + } } else { + // TODO: add options: dockTrailing, dockLeading, dockLeft, dockRight, dockTop, and dockBottom + this.AppBar.ApplyAppBar(this.Bar.Position.DockEdge); } diff --git a/Morphic.Client/Config/ConfigurableFeatures.cs b/Morphic.Client/Config/ConfigurableFeatures.cs index ec059cbd..ba0caa54 100644 --- a/Morphic.Client/Config/ConfigurableFeatures.cs +++ b/Morphic.Client/Config/ConfigurableFeatures.cs @@ -20,6 +20,19 @@ public enum MorphicBarVisibilityAfterLoginOption Hide // always hide the MorphicBar after login } + public enum MorphicBarDefaultLocationOption + { + TopLeft, + TopRight, + BottomLeft, + BottomRight, + // + TopLeading, + TopTrailing, + BottomLeading, + BottomTrailing, + } + public static bool AtOnDemandIsEnabled = true; // public static bool AtUseCounterIsEnabled = false; @@ -42,6 +55,7 @@ public enum MorphicBarVisibilityAfterLoginOption public static bool TelemetryIsEnabled = true; // // NOTE: this setting has no effect if Autorun is disabled + public static MorphicBarDefaultLocationOption MorphicBarDefaultLocation = MorphicBarDefaultLocationOption.BottomTrailing; public static MorphicBarVisibilityAfterLoginOption? MorphicBarVisibilityAfterLogin = MorphicBarVisibilityAfterLoginOption.Restore; public static List MorphicBarExtraItems = new List(); // @@ -58,6 +72,7 @@ public static void SetFeatures( bool resetSettingsIsEnabled, bool signInIsEnabled, bool telemetryIsEnabled, + MorphicBarDefaultLocationOption morphicBarDefaultLocation, MorphicBarVisibilityAfterLoginOption? morphicBarvisibilityAfterLogin, List morphicBarExtraItems, string? telemetrySiteId @@ -80,6 +95,7 @@ public static void SetFeatures( ConfigurableFeatures.ResetSettingsIsEnabled = resetSettingsIsEnabled; ConfigurableFeatures.SignInIsEnabled = signInIsEnabled; ConfigurableFeatures.TelemetryIsEnabled = telemetryIsEnabled; + ConfigurableFeatures.MorphicBarDefaultLocation = morphicBarDefaultLocation; ConfigurableFeatures.MorphicBarVisibilityAfterLogin = morphicBarvisibilityAfterLogin; ConfigurableFeatures.MorphicBarExtraItems = morphicBarExtraItems; ConfigurableFeatures.TelemetrySiteId = telemetrySiteId; diff --git a/Morphic.Client/DefaultConfig/presets.json5 b/Morphic.Client/DefaultConfig/presets.json5 index a706ac08..05540677 100644 --- a/Morphic.Client/DefaultConfig/presets.json5 +++ b/Morphic.Client/DefaultConfig/presets.json5 @@ -341,7 +341,35 @@ } } } - } + }, + "voice-control": { + // Toggles voice control (aka Windows Voice Access). + kind: "internal", + widget: "multi", + configuration: { + function: "voiceAccess", + args: { + value: "{button}" + }, + label: "{{QuickStrip_VoiceControl_Title}}", + menu: { + setting: "easeofaccess-speechrecognition" + }, + telemetryCategory: "voiceControl", + buttons: { + on: { + label: "{{QuickStrip_VoiceControl_On_Title}}", + value: "on", + tooltip: "{{QuickStrip_VoiceControl_On_HelpTitle}}" + }, + off: { + label: "{{QuickStrip_VoiceControl_Off_Title}}", + value: "off", + tooltip: "{{QuickStrip_VoiceControl_Off_HelpTitle}}" + } + } + } + }, }, defaults: { "calendar": { diff --git a/Morphic.Client/Dialogs/CommunityPickerWindow.xaml b/Morphic.Client/Dialogs/CommunityPickerWindow.xaml deleted file mode 100644 index dfdae05b..00000000 --- a/Morphic.Client/Dialogs/CommunityPickerWindow.xaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - -