sources/Google.Solutions.IapDesktop/Windows/MainForm.cs (576 lines of code) (raw):

// // Copyright 2020 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using Google.Solutions.Apis.Auth; using Google.Solutions.Common.Linq; using Google.Solutions.Common.Util; using Google.Solutions.IapDesktop.Application.Data; using Google.Solutions.IapDesktop.Application.Diagnostics; using Google.Solutions.IapDesktop.Application.Host; using Google.Solutions.IapDesktop.Application.Profile; using Google.Solutions.IapDesktop.Application.Profile.Settings; using Google.Solutions.IapDesktop.Application.Theme; using Google.Solutions.IapDesktop.Application.ToolWindows.ProjectExplorer; using Google.Solutions.IapDesktop.Application.ToolWindows.Update; using Google.Solutions.IapDesktop.Application.Windows; using Google.Solutions.IapDesktop.Application.Windows.Auth; using Google.Solutions.IapDesktop.Application.Windows.Dialog; using Google.Solutions.IapDesktop.Application.Windows.Options; using Google.Solutions.IapDesktop.Application.Windows.ProjectExplorer; using Google.Solutions.IapDesktop.Core.ObjectModel; using Google.Solutions.Mvvm.Binding; using Google.Solutions.Mvvm.Binding.Commands; using Google.Solutions.Mvvm.Controls; using Google.Solutions.Mvvm.Drawing; using Google.Solutions.Mvvm.Shell; using Google.Solutions.Platform.Interop; using Google.Solutions.Platform.Net; using Google.Solutions.Settings.Collection; using System; using System.ComponentModel; using System.Diagnostics; using System.Drawing; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using WeifenLuo.WinFormsUI.Docking; #pragma warning disable IDE1006 // Naming Styles #pragma warning disable CA1031 // Do not catch general exception types namespace Google.Solutions.IapDesktop.Windows { public partial class MainForm : Form, IJobHost, IMainWindow { // // Calculate minimum size so that it's a quarter of a 1080p screen, // leaving some room for the taskbar (typically <= 50px). // private static readonly Size MinimumWindowSize = new Size( 1920 / 2, (1080 - 50) / 2); private readonly MainFormViewModel viewModel; private readonly IMainWindowTheme windowTheme; private readonly IDialogTheme dialogTheme; private readonly IRepository<IApplicationSettings> applicationSettings; private readonly IServiceProvider serviceProvider; private readonly IBindingContext bindingContext; private readonly ContextSource<IMainWindow> viewMenuContextSource; private readonly ContextSource<ToolWindowViewBase> windowMenuContextSource; private readonly CommandContainer<IMainWindow> viewMenuCommands; private readonly CommandContainer<ToolWindowViewBase> windowMenuCommands; public bool ShowWhatsNew { get; set; } = false; public IapRdpUrl? StartupUrl { get; set; } public ICommandContainer<IMainWindow> ViewMenu => this.viewMenuCommands; public ICommandContainer<ToolWindowViewBase> WindowMenu => this.windowMenuCommands; public MainForm(IServiceProvider serviceProvider) { this.serviceProvider = serviceProvider; this.windowTheme = this.serviceProvider.GetService<IMainWindowTheme>(); this.dialogTheme = this.serviceProvider.GetService<IDialogTheme>(); this.applicationSettings = this.serviceProvider.GetService<IRepository<IApplicationSettings>>(); this.bindingContext = serviceProvider.GetService<IBindingContext>(); // // Restore window settings. // var windowSettings = this.applicationSettings.GetSettings(); if (windowSettings.IsMainWindowMaximized.Value) { this.WindowState = FormWindowState.Maximized; InitializeComponent(); } else if (windowSettings.MainWindowHeight.Value != 0 && windowSettings.MainWindowWidth.Value != 0) { InitializeComponent(); this.Size = new Size( windowSettings.MainWindowWidth.Value, windowSettings.MainWindowHeight.Value); } else { InitializeComponent(); } SuspendLayout(); this.windowTheme.ApplyTo(this); this.MinimumSize = MinimumWindowSize; // Set fixed size for the left/right panels (in pixels). this.dockPanel.DockLeftPortion = this.dockPanel.DockRightPortion = LogicalToDeviceUnits(300); // // View menu. // this.viewMenuContextSource = new ContextSource<IMainWindow>() { Context = this // Pseudo-context, never changes }; this.viewMenuCommands = new CommandContainer<IMainWindow>( ToolStripItemDisplayStyle.ImageAndText, this.viewMenuContextSource, this.bindingContext); this.viewMenuCommands.BindTo( this.viewToolStripMenuItem, this.bindingContext); // // Window menu. // this.windowMenuContextSource = new ContextSource<ToolWindowViewBase>(); this.windowToolStripMenuItem.DropDownOpening += (sender, args) => { this.windowMenuContextSource.Context = this.dockPanel.ActiveContent as ToolWindowViewBase; }; this.dockPanel.ActiveContentChanged += (sender, args) => { // // NB. It's possible that ActiveContent is null although there // is an active document. Most commonly, this happens when // focus is released from an RDP window by using a keyboard // shortcut. // this.windowMenuContextSource.Context = (this.dockPanel.ActiveContent ?? this.dockPanel.ActiveDocumentPane?.ActiveContent) as ToolWindowViewBase; }; this.windowMenuCommands = new CommandContainer<ToolWindowViewBase>( ToolStripItemDisplayStyle.ImageAndText, this.windowMenuContextSource, this.bindingContext); this.windowMenuCommands.BindTo( this.windowToolStripMenuItem, this.bindingContext); // // Bind controls. // this.viewModel = new MainFormViewModel( this, this.serviceProvider.GetService<IInstall>(), this.serviceProvider.GetService<UserProfile>(), this.serviceProvider.GetService<IAuthorization>()); this.BindProperty( c => c.Text, this.viewModel, m => m.WindowTitle, this.bindingContext); // // Status bar. // this.statusStrip.BindReadonlyProperty( c => c.Active, this.viewModel, m => m.IsLoggingEnabled, this.bindingContext); this.toolStripStatus.BindProperty( c => c.Text, this.viewModel, m => m.StatusText, this.bindingContext); this.backgroundJobLabel.BindProperty( c => c.Visible, this.viewModel, m => m.IsBackgroundJobStatusVisible, this.bindingContext); this.cancelBackgroundJobsButton.BindProperty( c => c.Visible, this.viewModel, m => m.IsBackgroundJobStatusVisible, this.bindingContext); this.backgroundJobLabel.BindProperty( c => c.Text, this.viewModel, m => m.BackgroundJobStatus, this.bindingContext); this.profileStateButton.BindReadonlyProperty( c => c.Text, this.viewModel, m => m.ProfileStateCaption, this.bindingContext); // // Profile chooser. // var dynamicProfileMenuItemTag = new object(); this.profileStateButton.DropDownOpening += (sender, args) => { // // Re-populate list of profile menu items. // // Mark dynamic menu items with a tag so that we don't // accidentally remove any static menu items. // this.profileStateButton .DropDownItems .RemoveAll(item => item.Tag == dynamicProfileMenuItemTag) .AddRange(this.viewModel .AlternativeProfileNames .Select(name => new ToolStripMenuItem(name) { Name = name, Tag = dynamicProfileMenuItemTag }) .ToArray()); }; this.profileStateButton.DropDownItemClicked += (sender, args) => { if (args.ClickedItem.Tag == dynamicProfileMenuItemTag) { this.viewModel.LaunchInstanceWithProfile(args.ClickedItem.Name); } }; // // Logging. // this.enableloggingToolStripMenuItem.BindProperty( c => c.Checked, this.viewModel, m => m.IsLoggingEnabled, this.bindingContext); // // Bind menu commands. // this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "&Close", window => window != null && window.IsDockable ? CommandState.Enabled : CommandState.Disabled, window => window.CloseSafely())); this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "&Float", window => window != null && !window.IsFloat && window.IsDockStateValid(DockState.Float) ? CommandState.Enabled : CommandState.Disabled, window => window.IsFloat = true)); this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "&Auto hide", window => window != null && window.IsDocked && !window.IsAutoHide ? CommandState.Enabled : CommandState.Disabled, window => { window.IsAutoHide = true; OnDockLayoutChanged(); }) { ShortcutKeys = Keys.Control | Keys.Alt | Keys.H }); var dockCommand = this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "Dock", _ => CommandState.Enabled, context => { })); dockCommand.AddCommand(CreateDockCommand( "&Left", DockState.DockLeft, Keys.Control | Keys.Alt | Keys.Left)); dockCommand.AddCommand(CreateDockCommand( "&Right", DockState.DockRight, Keys.Control | Keys.Alt | Keys.Right)); dockCommand.AddCommand(CreateDockCommand( "&Top", DockState.DockTop, Keys.Control | Keys.Alt | Keys.Up)); dockCommand.AddCommand(CreateDockCommand( "&Bottom", DockState.DockBottom, Keys.Control | Keys.Alt | Keys.Down)); this.WindowMenu.AddSeparator(); CommandState showTabCommand(ToolWindowViewBase window) => window != null && window.DockState == DockState.Document && window.Pane.Contents.Count > 1 ? CommandState.Enabled : CommandState.Disabled; this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "&Next tab", showTabCommand, window => SwitchTab(window, 1)) { ShortcutKeys = Keys.Control | Keys.Alt | Keys.PageDown }); this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "&Previous tab", showTabCommand, window => SwitchTab(window, -1)) { ShortcutKeys = Keys.Control | Keys.Alt | Keys.PageUp }); this.WindowMenu.AddCommand( new ContextCommand<ToolWindowViewBase>( "Capture/release &focus", _ => this.dockPanel.ActiveDocumentPane != null && this.dockPanel.ActiveDocumentPane.Contents.EnsureNotNull().Any() ? CommandState.Enabled : CommandState.Disabled, window => (this.dockPanel.ActiveDocumentPane?.ActiveContent as DocumentWindow)?.SwitchToDocument()) { ShortcutKeys = Keys.Control | Keys.Alt | Keys.Home }); ResumeLayout(); } //--------------------------------------------------------------------- // Window events. //--------------------------------------------------------------------- private void MainForm_FormClosing(object sender, FormClosingEventArgs _) { var settings = this.applicationSettings.GetSettings(); // // Check for updates. // var checkForUpdates = new CheckForUpdateCommand<IMainWindow>( this, this.serviceProvider.GetService<IInstall>(), this.serviceProvider.GetService<IUpdatePolicy>(), this.serviceProvider.GetService<IReleaseFeed>(), this.serviceProvider.GetService<ITaskDialog>(), this.serviceProvider.GetService<IBrowser>()); if (checkForUpdates.IsAutomatedCheckDue( DateTime.FromBinary(settings.LastUpdateCheck.Value))) { try { using (var cts = new CancellationTokenSource()) { // // Check for updates. This check must be performed synchronously, // otherwise this method returns and the application exits. // In order not to block everything for too long in case of a network // problem, use a timeout. // cts.CancelAfter(TimeSpan.FromSeconds(5)); // // Prompt for survey unless the user has opted out. // checkForUpdates.EnableSurveys = settings.IsSurveyEnabled.Value; if (Version.TryParse( settings.LastSurveyVersion.Value, out var lastSurveyVersion)) { checkForUpdates.LastSurveyVersion = lastSurveyVersion; } checkForUpdates.Execute(cts.Token); settings.LastUpdateCheck.Value = DateTime.UtcNow.ToBinary(); settings.IsSurveyEnabled.Value = checkForUpdates.EnableSurveys; settings.LastSurveyVersion.Value = checkForUpdates.LastSurveyVersion?.ToString(); } } catch (Exception e) { // Ignore in Release builds. Debug.Fail(e.FullMessage()); } } // // Save window state. // settings.IsMainWindowMaximized.Value = this.WindowState == FormWindowState.Maximized; settings.MainWindowHeight.Value = this.Size.Height; settings.MainWindowWidth.Value = this.Size.Width; this.applicationSettings.SetSettings(settings); } private void MainForm_Shown(object sender, EventArgs __) { var profile = this.serviceProvider.GetService<UserProfile>(); if (!profile.IsDefault) { // // Add taskbar badge to help distinguish this profile // from other profiles. // // NB. This can only be done after the window has been shown, // so this code must not be moved to the constructor. // using (var badge = BadgeIcon.ForTextInitial(profile.Name)) using (var taskbar = ComReference.For((ITaskbarList3)new TaskbarList())) { taskbar.Object.HrInit(); taskbar.Object.SetOverlayIcon( this.Handle, badge.Handle, string.Empty); } } if (this.StartupUrl != null) { // // Dispatch URL. // ConnectToUrl(this.StartupUrl); } else { // // No URL provided, just show project explorer then. // this.serviceProvider .GetService<IToolWindowHost>() .GetToolWindow<ProjectExplorerView, ProjectExplorerViewModel>() .Show(); } if (this.ShowWhatsNew) { // // Show the "What's new" window (in addition to the project explorer). // var window = this.serviceProvider .GetService<IToolWindowHost>() .GetToolWindow<ReleaseNotesView, ReleaseNotesViewModel>(); window.ViewModel.ShowAllReleases = false; window.Show(); } } private void SwitchTab(ToolWindowViewBase reference, int delta) { // // Find a sibling tab and activate it. Make sure // to not run out of bounds. // var pane = this.dockPanel.ActiveDocumentPane; var windowIndex = pane.Contents.IndexOf(reference); var tabCount = pane.Contents.Count; if (pane.Contents[(tabCount + windowIndex + delta) % tabCount] is DocumentWindow sibling) { sibling.SwitchToDocument(); } } private ContextCommand<ToolWindowViewBase> CreateDockCommand( string caption, DockState dockState, Keys shortcutKeys) { return new ContextCommand<ToolWindowViewBase>( caption, window => window != null && window.VisibleState != dockState && window.IsDockStateValid(dockState) ? CommandState.Enabled : CommandState.Disabled, window => { window.DockState = dockState; OnDockLayoutChanged(); }) { ShortcutKeys = shortcutKeys }; } private void OnDockLayoutChanged() { // // The DockPanel has a quirk where re-docking a // window doesn't cause the document to re-paint, // even if it changed positions. // To fix this, force the active document pane // to relayout. // this.dockPanel.ActiveDocumentPane?.PerformLayout(); // // Force context refresh. This is necessary // of the command is triggered by a shortcut, // bypassing the menu-open event (which normally // updates the context). // this.windowMenuCommands.ForceRefresh(); } private async Task ConnectToUrlAsync(IapRdpUrl url) { var command = this.serviceProvider.GetService<UrlCommands>().LaunchRdpUrl; if (command.QueryState(url) == CommandState.Enabled) { try { await command .ExecuteAsync(url) .ConfigureAwait(true); } catch (Exception e) when (e.IsCancellation()) { // The user cancelled, nervemind. } catch (Exception e) { this.serviceProvider .GetService<IExceptionDialog>() .Show( this, $"Connecting to the VM instance {url.Instance.Name} failed", e); } } } internal void ConnectToUrl(IapRdpUrl url) { _ = ConnectToUrlAsync(url).ContinueWith(_ => { }); } private void dockPanel_ActiveContentChanged(object sender, EventArgs e) { if (this.dockPanel.ActiveContent is ToolWindowViewBase toolWindow && toolWindow.DockState == DockState.Document) { // // Focus switched to a document (we're not interested in // any other windows). // this.viewModel.SwitchToDocument(toolWindow.Text); } else if ( this.dockPanel.ActiveContent == null || !this.dockPanel.Documents.EnsureNotNull().Any()) { // // All documents closed. // this.viewModel.SwitchToDocument(null); } } //--------------------------------------------------------------------- // IMainForm. //--------------------------------------------------------------------- public IWin32Window Window => this; public DockPanel MainPanel => this.dockPanel; public ICommandContainer<TContext> AddMenu<TContext>( string caption, int? index, Func<TContext?> queryCurrentContextFunc) where TContext : class { var menu = new ToolStripMenuItem(caption); if (index.HasValue) { this.mainMenu.Items.Insert( Math.Min(index.Value, this.mainMenu.Items.Count), menu); } else { this.mainMenu.Items.Add(menu); } var container = new CommandContainer<TContext>( ToolStripItemDisplayStyle.ImageAndText, new CallbackSource<TContext>(queryCurrentContextFunc), this.bindingContext); container.BindTo(menu, this.bindingContext); menu.DropDownOpening += (sender, args) => { // // Force refresh since we can't know if the context // has changed or not. // container.ForceRefresh(); }; return container; } public void Minimize() { this.WindowState = FormWindowState.Minimized; } public bool IsWindowThread() { return !this.InvokeRequired; } //--------------------------------------------------------------------- // Main menu events. //--------------------------------------------------------------------- private void projectExplorerToolStripMenuItem_Click(object sender, EventArgs _) { this.serviceProvider .GetService<IToolWindowHost>() .GetToolWindow<ProjectExplorerView, ProjectExplorerViewModel>() .Show(); } private void enableloggingToolStripMenuItem_Click(object sender, EventArgs _) { try { // Toggle logging state. this.viewModel.IsLoggingEnabled = !this.viewModel.IsLoggingEnabled; } catch (Exception e) { this.serviceProvider .GetService<IExceptionDialog>() .Show(this, "Configuring logging failed", e); } } private void optionsToolStripMenuItem_Click(object sender, EventArgs _) { try { OptionsDialog.Show(this, (IServiceCategoryProvider)this.serviceProvider); } catch (TaskCanceledException) { // Ignore. } catch (Exception e) { this.serviceProvider .GetService<IExceptionDialog>() .Show(this, "Opening Options window failed", e); } } private void accessStateButton_Click(object sender, EventArgs e) { var button = (ToolStripItem)sender; var screenPosition = new Rectangle( this.statusStrip.PointToScreen(button.Bounds.Location), button.Size); this.serviceProvider .GetService<WindowActivator<AccessInfoFlyoutView, AccessInfoViewModel, IMainWindowTheme>>() .CreateWindow() .Form .Show(this, screenPosition, ContentAlignment.TopLeft); } private void addProfileToolStripMenuItem_Click(object sender, EventArgs _) { try { using (var dialog = this.serviceProvider .GetService<WindowActivator<NewProfileView, NewProfileViewModel, IDialogTheme>>() .CreateDialog()) { if (dialog.ShowDialog(this) == DialogResult.OK) { using (var profile = this.serviceProvider .GetService<IInstall>() .CreateProfile(dialog.ViewModel.ProfileName)) { this.viewModel.LaunchInstanceWithProfile(profile.Name); } } } } catch (Exception e) { this.serviceProvider .GetService<IExceptionDialog>() .Show(this, "New profile", e); } } //--------------------------------------------------------------------- // IJobHost. //--------------------------------------------------------------------- public ISynchronizeInvoke Invoker => this; public IJobUserFeedback ShowFeedback( JobDescription jobDescription, CancellationTokenSource cancellationSource) { Debug.Assert(!this.Invoker.InvokeRequired, "ShowForegroundFeedback must be called on UI thread"); switch (jobDescription.Feedback) { case JobUserFeedbackType.ForegroundFeedback: // // Show WaitDialog, blocking all user intraction. // var waitDialog = new WaitDialog(this, jobDescription.StatusMessage, cancellationSource); this.dialogTheme.ApplyTo(waitDialog); return waitDialog; default: return this.viewModel.CreateBackgroundJob(jobDescription, cancellationSource); } } public void Reauthorize() { using (var dialog = this.serviceProvider .GetService<WindowActivator<AuthorizeView, AuthorizeViewModel, IDialogTheme>>() .CreateDialog()) { dialog.ViewModel.UseExistingAuthorization( this.serviceProvider.GetService<IAuthorization>()); dialog.ShowDialog(this); } } private void cancelBackgroundJobsButton_Click(object sender, EventArgs e) => this.viewModel.CancelBackgroundJobs(); //--------------------------------------------------------------------- // Helper classes. //--------------------------------------------------------------------- private class CallbackSource<TContext> : IContextSource<TContext> where TContext : class { private readonly Func<TContext?> queryCurrentContextFunc; public CallbackSource(Func<TContext?> queryCurrentContextFunc) { this.queryCurrentContextFunc = queryCurrentContextFunc; } public TContext? Context => this.queryCurrentContextFunc(); } } }