using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.Contracts; using System.IO; using System.Net; using System.Reflection; using System.Runtime.InteropServices; using System.ServiceModel; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls.Primitives; using Caliburn.Micro; using Hardcodet.Wpf.TaskbarNotification; using Pithos.Client.WPF.Configuration; using Pithos.Client.WPF.FileProperties; using Pithos.Client.WPF.Preferences; using Pithos.Client.WPF.SelectiveSynch; using Pithos.Client.WPF.Services; using Pithos.Client.WPF.Shell; using Pithos.Core; using Pithos.Core.Agents; using Pithos.Interfaces; using System; using System.Collections.Generic; using System.Linq; using Pithos.Network; using StatusService = Pithos.Client.WPF.Services.StatusService; namespace Pithos.Client.WPF { using System.ComponentModel.Composition; /// /// The "shell" of the Pithos application displays the taskbar icon, menu and notifications. /// The shell also hosts the status service called by shell extensions to retrieve file info /// /// /// It is a strange "shell" as its main visible element is an icon instead of a window /// The shell subscribes to the following events: /// * Notification: Raised by components that want to notify the user. Usually displayed in a balloon /// * SelectiveSynchChanges: Notifies that the user made changes to the selective synch folders for an account. Raised by the Selective Synch dialog. Located here because the monitors are here /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog /// //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges? [Export(typeof(IShell))] public class ShellViewModel : Screen, IStatusNotification, IShell, IHandle, IHandle, IHandle { //The Status Checker provides the current synch state //TODO: Could we remove the status checker and use events in its place? private readonly IStatusChecker _statusChecker; private readonly IEventAggregator _events; public PithosSettings Settings { get; private set; } private readonly ConcurrentDictionary _monitors = new ConcurrentDictionary(); /// /// Dictionary of account monitors, keyed by account /// /// /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands, /// retrieve account and boject info /// // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design? // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier public ConcurrentDictionary Monitors { get { return _monitors; } } /// /// The status service is used by Shell extensions to retrieve file status information /// //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell private ServiceHost _statusService; //Logging in the Pithos client is provided by log4net private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos"); //Lazily initialized File Version info. This is done once and lazily to avoid blocking the UI private Lazy _fileVersion; /// /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings /// /// /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry) /// [ImportingConstructor] public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings) { try { _windowManager = windowManager; //CHECK: Caliburn doesn't need explicit command construction //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder); _statusChecker = statusChecker; //The event subst _events = events; _events.Subscribe(this); Settings = settings; StatusMessage = "In Synch"; _fileVersion= new Lazy(() => { Assembly assembly = Assembly.GetExecutingAssembly(); var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location); return fileVersion; }); _accounts.CollectionChanged += (sender, e) => { NotifyOfPropertyChange(() => OpenFolderCaption); NotifyOfPropertyChange(() => HasAccounts); }; } catch (Exception exc) { Log.Error("Error while starting the ShellViewModel",exc); throw; } } protected override void OnActivate() { base.OnActivate(); StartMonitoring(); } private async void StartMonitoring() { try { var accounts = Settings.Accounts.Select(MonitorAccount); await TaskEx.WhenAll(accounts); _statusService = StatusService.Start(); /* foreach (var account in Settings.Accounts) { await MonitorAccount(account); } */ } catch (AggregateException exc) { exc.Handle(e => { Log.Error("Error while starting monitoring", e); return true; }); throw; } } protected override void OnDeactivate(bool close) { base.OnDeactivate(close); if (close) { StatusService.Stop(_statusService); _statusService = null; } } public Task MonitorAccount(AccountSettings account) { return Task.Factory.StartNew(() => { PithosMonitor monitor; var accountName = account.AccountName; if (_monitors.TryGetValue(accountName, out monitor)) { //If the account is active if (account.IsActive) //Start the monitor. It's OK to start an already started monitor, //it will just ignore the call StartMonitor(monitor).Wait(); else { //If the account is inactive //Stop and remove the monitor RemoveMonitor(accountName); } return; } //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors monitor = new PithosMonitor { UserName = accountName, ApiKey = account.ApiKey, StatusNotification = this, RootPath = account.RootPath }; //PithosMonitor uses MEF so we need to resolve it IoC.BuildUp(monitor); monitor.AuthenticationUrl = account.ServerUrl; _monitors[accountName] = monitor; if (account.IsActive) { //Don't start a monitor if it doesn't have an account and ApiKey if (String.IsNullOrWhiteSpace(monitor.UserName) || String.IsNullOrWhiteSpace(monitor.ApiKey)) return; StartMonitor(monitor); } }); } protected override void OnViewLoaded(object view) { UpdateStatus(); var window = (Window)view; TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide)); base.OnViewLoaded(view); } #region Status Properties private string _statusMessage; public string StatusMessage { get { return _statusMessage; } set { _statusMessage = value; NotifyOfPropertyChange(() => StatusMessage); } } private readonly ObservableConcurrentCollection _accounts = new ObservableConcurrentCollection(); public ObservableConcurrentCollection Accounts { get { return _accounts; } } public bool HasAccounts { get { return _accounts.Count > 0; } } public string OpenFolderCaption { get { return (_accounts.Count == 0) ? "No Accounts Defined" : "Open Pithos Folder"; } } private string _pauseSyncCaption="Pause Synching"; public string PauseSyncCaption { get { return _pauseSyncCaption; } set { _pauseSyncCaption = value; NotifyOfPropertyChange(() => PauseSyncCaption); } } private readonly ObservableConcurrentCollection _recentFiles = new ObservableConcurrentCollection(); public ObservableConcurrentCollection RecentFiles { get { return _recentFiles; } } private string _statusIcon="../Images/Pithos.ico"; public string StatusIcon { get { return _statusIcon; } set { //TODO: Ensure all status icons use the Pithos logo _statusIcon = value; NotifyOfPropertyChange(() => StatusIcon); } } #endregion #region Commands public void ShowPreferences() { Settings.Reload(); var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings); _windowManager.ShowDialog(preferences); } public void AboutPithos() { var about = new AboutViewModel(); _windowManager.ShowWindow(about); } public void SendFeedback() { var feedBack = IoC.Get(); _windowManager.ShowWindow(feedBack); } //public PithosCommand OpenPithosFolderCommand { get; private set; } public void OpenPithosFolder() { var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive); if (account == null) return; Process.Start(account.RootPath); } public void OpenPithosFolder(AccountInfo account) { Process.Start(account.AccountPath); } /* public void GoToSite() { var site = Properties.Settings.Default.PithosSite; Process.Start(site); } */ public void GoToSite(AccountInfo account) { /*var site = String.Format("{0}/ui/?token={1}&user={2}", account.SiteUri,account.Token, account.UserName);*/ Process.Start(account.SiteUri); } public void ShowFileProperties() { var account = Settings.Accounts.First(acc => acc.IsActive); var dir = new DirectoryInfo(account.RootPath + @"\pithos"); var files=dir.GetFiles(); var r=new Random(); var idx=r.Next(0, files.Length); ShowFileProperties(files[idx].FullName); } public void ShowFileProperties(string filePath) { if (String.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException("filePath"); if (!File.Exists(filePath) && !Directory.Exists(filePath)) throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath"); Contract.EndContractBlock(); var pair=(from monitor in Monitors where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase) select monitor).FirstOrDefault(); var accountMonitor = pair.Value; if (accountMonitor == null) return; var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath)); var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath); _windowManager.ShowWindow(fileProperties); } public void ShowContainerProperties() { var account = Settings.Accounts.First(acc => acc.IsActive); var dir = new DirectoryInfo(account.RootPath); var fullName = (from folder in dir.EnumerateDirectories() where (folder.Attributes & FileAttributes.Hidden) == 0 select folder.FullName).First(); ShowContainerProperties(fullName); } public void ShowContainerProperties(string filePath) { if (String.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException("filePath"); if (!Directory.Exists(filePath)) throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath"); Contract.EndContractBlock(); var pair=(from monitor in Monitors where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase) select monitor).FirstOrDefault(); var accountMonitor = pair.Value; var info = accountMonitor.GetContainerInfo(filePath); var containerProperties = new ContainerPropertiesViewModel(this, info,filePath); _windowManager.ShowWindow(containerProperties); } public void SynchNow() { var agent = IoC.Get(); agent.SynchNow(); } public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo) { if (currentInfo==null) throw new ArgumentNullException("currentInfo"); Contract.EndContractBlock(); var monitor = Monitors[currentInfo.Account]; var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name); return newInfo; } public ContainerInfo RefreshContainerInfo(ContainerInfo container) { if (container == null) throw new ArgumentNullException("container"); Contract.EndContractBlock(); var monitor = Monitors[container.Account]; var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name); return newInfo; } public void ToggleSynching() { bool isPaused=false; foreach (var pair in Monitors) { var monitor = pair.Value; monitor.Pause = !monitor.Pause; isPaused = monitor.Pause; } PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing"; var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch"; StatusIcon = String.Format(@"../Images/{0}.ico", iconKey); } public void ExitPithos() { foreach (var pair in Monitors) { var monitor = pair.Value; monitor.Stop(); } ((Window)GetView()).Close(); } #endregion private readonly Dictionary _iconNames = new List { new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"), new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"), new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused") }.ToDictionary(s => s.Status); readonly IWindowManager _windowManager; /// /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat /// public void UpdateStatus() { var pithosStatus = _statusChecker.GetPithosStatus(); if (_iconNames.ContainsKey(pithosStatus)) { var info = _iconNames[pithosStatus]; StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName); StatusMessage = String.Format("Pithos {0}\r\n{1}", _fileVersion.Value.FileVersion,info.StatusText); } //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info}); } private Task StartMonitor(PithosMonitor monitor,int retries=0) { return Task.Factory.StartNew(() => { using (log4net.ThreadContext.Stacks["Monitor"].Push("Start")) { try { Log.InfoFormat("Start Monitoring {0}", monitor.UserName); monitor.Start(); } catch (WebException exc) { if (AbandonRetry(monitor, retries)) return; HttpStatusCode statusCode =HttpStatusCode.OK; var response = exc.Response as HttpWebResponse; if(response!=null) statusCode = response.StatusCode; switch (statusCode) { case HttpStatusCode.Unauthorized: var message = String.Format("API Key Expired for {0}. Starting Renewal", monitor.UserName); Log.Error(message, exc); TryAuthorize(monitor, retries).Wait(); break; case HttpStatusCode.ProxyAuthenticationRequired: TryAuthenticateProxy(monitor,retries); break; default: TryLater(monitor, exc, retries); break; } } catch (Exception exc) { if (AbandonRetry(monitor, retries)) return; TryLater(monitor,exc,retries); } } }); } private void TryAuthenticateProxy(PithosMonitor monitor,int retries) { Execute.OnUIThread(() => { var proxyAccount = new ProxyAccountViewModel(this.Settings); if (true == _windowManager.ShowDialog(proxyAccount)) { StartMonitor(monitor, retries); NotifyOfPropertyChange(() => Accounts); } }); } private bool AbandonRetry(PithosMonitor monitor, int retries) { if (retries > 1) { var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry", monitor.UserName); _events.Publish(new Notification {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error}); return true; } return false; } private async Task TryAuthorize(PithosMonitor monitor,int retries) { _events.Publish(new Notification { Title = "Authorization failed", Message = "Your API Key has probably expired. You will be directed to a page where you can renew it", Level = TraceLevel.Error }); try { var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl); var account = Settings.Accounts.First(act => act.AccountName == credentials.UserName); account.ApiKey = credentials.Password; monitor.ApiKey = credentials.Password; Settings.Save(); await TaskEx.Delay(10000); StartMonitor(monitor, retries + 1); NotifyOfPropertyChange(()=>Accounts); } catch (AggregateException exc) { string message = String.Format("API Key retrieval for {0} failed", monitor.UserName); Log.Error(message, exc.InnerException); _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error }); } catch (Exception exc) { string message = String.Format("API Key retrieval for {0} failed", monitor.UserName); Log.Error(message, exc); _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error }); } } private static bool IsUnauthorized(WebException exc) { if (exc==null) throw new ArgumentNullException("exc"); Contract.EndContractBlock(); var response = exc.Response as HttpWebResponse; if (response == null) return false; return (response.StatusCode == HttpStatusCode.Unauthorized); } private void TryLater(PithosMonitor monitor, Exception exc,int retries) { var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds"); Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1)); _events.Publish(new Notification {Title = "Error", Message = message, Level = TraceLevel.Error}); Log.Error(message, exc); } public void NotifyChange(string status, TraceLevel level=TraceLevel.Info) { StatusMessage = status; _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level }); } public void NotifyChangedFile(string filePath) { var entry = new FileEntry {FullPath=filePath}; IProducerConsumerCollection files=RecentFiles; FileEntry popped; while (files.Count > 5) files.TryTake(out popped); files.TryAdd(entry); } public void NotifyAccount(AccountInfo account) { if (account== null) return; //TODO: What happens to an existing account whose Token has changed? account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}", account.SiteUri, Uri.EscapeDataString(account.Token), Uri.EscapeDataString(account.UserName)); if (Accounts.All(item => item.UserName != account.UserName)) Accounts.TryAdd(account); } public void NotifyConflicts(IEnumerable conflictFiles, string message) { if (conflictFiles == null) return; if (!conflictFiles.Any()) return; UpdateStatus(); //TODO: Create a more specific message. For now, just show a warning NotifyForFiles(conflictFiles,message,TraceLevel.Warning); } public void NotifyForFiles(IEnumerable files, string message,TraceLevel level=TraceLevel.Info) { if (files == null) return; if (!files.Any()) return; StatusMessage = message; _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level}); } public void Notify(Notification notification) { _events.Publish(notification); } public void RemoveMonitor(string accountName) { if (String.IsNullOrWhiteSpace(accountName)) return; var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName); _accounts.TryRemove(accountInfo); PithosMonitor monitor; if (Monitors.TryRemove(accountName, out monitor)) { monitor.Stop(); } } public void RefreshOverlays() { foreach (var pair in Monitors) { var monitor = pair.Value; var path = monitor.RootPath; if (String.IsNullOrWhiteSpace(path)) continue; if (!Directory.Exists(path) && !File.Exists(path)) continue; IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path); try { NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM, HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT, pathPointer, IntPtr.Zero); } finally { Marshal.FreeHGlobal(pathPointer); } } } #region Event Handlers public void Handle(SelectiveSynchChanges message) { var accountName = message.Account.AccountName; PithosMonitor monitor; if (_monitors.TryGetValue(accountName, out monitor)) { monitor.AddSelectivePaths(message.Added); monitor.RemoveSelectivePaths(message.Removed); } } private bool _pollStarted = false; //SMELL: Doing so much work for notifications in the shell is wrong //The notifications should be moved to their own view/viewmodel pair //and different templates should be used for different message types //This will also allow the addition of extra functionality, eg. actions // public void Handle(Notification notification) { UpdateStatus(); if (!Settings.ShowDesktopNotifications) return; if (notification is PollNotification) { _pollStarted = true; return; } if (notification is CloudNotification) { if (!_pollStarted) return; _pollStarted= false; notification.Title = "Pithos"; notification.Message = "Start Synchronisation"; } if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title)) return; BalloonIcon icon; switch (notification.Level) { case TraceLevel.Error: icon = BalloonIcon.Error; break; case TraceLevel.Info: case TraceLevel.Verbose: icon = BalloonIcon.Info; break; case TraceLevel.Warning: icon = BalloonIcon.Warning; break; default: icon = BalloonIcon.None; break; } if (Settings.ShowDesktopNotifications) { var tv = (ShellView) GetView(); var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon}; tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000); // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon); } } #endregion public void Handle(ShowFilePropertiesEvent message) { if (message == null) throw new ArgumentNullException("message"); if (String.IsNullOrWhiteSpace(message.FileName) ) throw new ArgumentException("message"); Contract.EndContractBlock(); var fileName = message.FileName; //TODO: Display file properties for non-container folders if (File.Exists(fileName)) //Retrieve the full name with exact casing. Pithos names are case sensitive ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName)); else if (Directory.Exists(fileName)) //Retrieve the full name with exact casing. Pithos names are case sensitive { var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName); if (IsContainer(path)) ShowContainerProperties(path); else ShowFileProperties(path); } } private bool IsContainer(string path) { var matchingFolders = from account in _accounts from rootFolder in Directory.GetDirectories(account.AccountPath) where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase) select rootFolder; return matchingFolders.Any(); } public FileStatus GetFileStatus(string localFileName) { if (String.IsNullOrWhiteSpace(localFileName)) throw new ArgumentNullException("localFileName"); Contract.EndContractBlock(); var statusKeeper = IoC.Get(); var status=statusKeeper.GetFileStatus(localFileName); return status; } } }