2 /* -----------------------------------------------------------------------
3 * <copyright file="ShellViewModel.cs" company="GRNet">
5 * Copyright 2011-2012 GRNET S.A. All rights reserved.
7 * Redistribution and use in source and binary forms, with or
8 * without modification, are permitted provided that the following
11 * 1. Redistributions of source code must retain the above
12 * copyright notice, this list of conditions and the following
15 * 2. Redistributions in binary form must reproduce the above
16 * copyright notice, this list of conditions and the following
17 * disclaimer in the documentation and/or other materials
18 * provided with the distribution.
21 * THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
22 * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
23 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
24 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
25 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
26 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
27 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
28 * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
29 * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31 * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32 * POSSIBILITY OF SUCH DAMAGE.
34 * The views and conclusions contained in the software and
35 * documentation are those of the authors and should not be
36 * interpreted as representing official policies, either expressed
37 * or implied, of GRNET S.A.
39 * -----------------------------------------------------------------------
42 using System.Collections.Concurrent;
43 using System.Diagnostics;
44 using System.Diagnostics.Contracts;
47 using System.Reflection;
48 using System.Runtime.InteropServices;
49 using System.ServiceModel;
50 using System.Threading.Tasks;
52 using System.Windows.Controls.Primitives;
54 using Hardcodet.Wpf.TaskbarNotification;
55 using Pithos.Client.WPF.Configuration;
56 using Pithos.Client.WPF.FileProperties;
57 using Pithos.Client.WPF.Preferences;
58 using Pithos.Client.WPF.Properties;
59 using Pithos.Client.WPF.SelectiveSynch;
60 using Pithos.Client.WPF.Services;
61 using Pithos.Client.WPF.Shell;
63 using Pithos.Core.Agents;
64 using Pithos.Interfaces;
66 using System.Collections.Generic;
69 using StatusService = Pithos.Client.WPF.Services.StatusService;
71 namespace Pithos.Client.WPF {
72 using System.ComponentModel.Composition;
76 /// The "shell" of the Pithos application displays the taskbar icon, menu and notifications.
77 /// The shell also hosts the status service called by shell extensions to retrieve file info
80 /// It is a strange "shell" as its main visible element is an icon instead of a window
81 /// The shell subscribes to the following events:
82 /// * Notification: Raised by components that want to notify the user. Usually displayed in a balloon
83 /// * 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
84 /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog
86 //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges?
87 [Export(typeof(IShell))]
88 public class ShellViewModel : Screen, IStatusNotification, IShell,
89 IHandle<Notification>, IHandle<SelectiveSynchChanges>, IHandle<ShowFilePropertiesEvent>
91 //The Status Checker provides the current synch state
92 //TODO: Could we remove the status checker and use events in its place?
93 private readonly IStatusChecker _statusChecker;
94 private readonly IEventAggregator _events;
96 public PithosSettings Settings { get; private set; }
99 private readonly ConcurrentDictionary<string, PithosMonitor> _monitors = new ConcurrentDictionary<string, PithosMonitor>();
101 /// Dictionary of account monitors, keyed by account
104 /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands,
105 /// retrieve account and boject info
107 // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design?
108 // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier
109 public ConcurrentDictionary<string, PithosMonitor> Monitors
111 get { return _monitors; }
116 /// The status service is used by Shell extensions to retrieve file status information
118 //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
119 private ServiceHost _statusService;
121 //Logging in the Pithos client is provided by log4net
122 private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos");
124 //Lazily initialized File Version info. This is done once and lazily to avoid blocking the UI
125 private Lazy<FileVersionInfo> _fileVersion;
127 private PollAgent _pollAgent;
130 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
133 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
135 [ImportingConstructor]
136 public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings,PollAgent pollAgent)
141 _windowManager = windowManager;
142 //CHECK: Caliburn doesn't need explicit command construction
143 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
144 _statusChecker = statusChecker;
147 _events.Subscribe(this);
149 _pollAgent = pollAgent;
152 Proxy.SetFromSettings(settings);
154 StatusMessage = "In Synch";
156 _fileVersion= new Lazy<FileVersionInfo>(() =>
158 Assembly assembly = Assembly.GetExecutingAssembly();
159 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
162 _accounts.CollectionChanged += (sender, e) =>
164 NotifyOfPropertyChange(() => OpenFolderCaption);
165 NotifyOfPropertyChange(() => HasAccounts);
169 catch (Exception exc)
171 Log.Error("Error while starting the ShellViewModel",exc);
177 protected override void OnActivate()
188 private async void StartMonitoring()
192 var accounts = Settings.Accounts.Select(MonitorAccount);
193 await TaskEx.WhenAll(accounts);
194 _statusService = StatusService.Start();
197 foreach (var account in Settings.Accounts)
199 await MonitorAccount(account);
204 catch (AggregateException exc)
208 Log.Error("Error while starting monitoring", e);
215 protected override void OnDeactivate(bool close)
217 base.OnDeactivate(close);
220 StatusService.Stop(_statusService);
221 _statusService = null;
225 public Task MonitorAccount(AccountSettings account)
227 return Task.Factory.StartNew(() =>
229 PithosMonitor monitor;
230 var accountName = account.AccountName;
232 if (_monitors.TryGetValue(accountName, out monitor))
234 //If the account is active
235 if (account.IsActive)
236 //Start the monitor. It's OK to start an already started monitor,
237 //it will just ignore the call
238 StartMonitor(monitor).Wait();
241 //If the account is inactive
242 //Stop and remove the monitor
243 RemoveMonitor(accountName);
249 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
250 monitor = new PithosMonitor
252 UserName = accountName,
253 ApiKey = account.ApiKey,
254 StatusNotification = this,
255 RootPath = account.RootPath
257 //PithosMonitor uses MEF so we need to resolve it
258 IoC.BuildUp(monitor);
260 monitor.AuthenticationUrl = account.ServerUrl;
262 _monitors[accountName] = monitor;
264 if (account.IsActive)
266 //Don't start a monitor if it doesn't have an account and ApiKey
267 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
268 String.IsNullOrWhiteSpace(monitor.ApiKey))
270 StartMonitor(monitor);
276 protected override void OnViewLoaded(object view)
279 var window = (Window)view;
280 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
281 base.OnViewLoaded(view);
285 #region Status Properties
287 private string _statusMessage;
288 public string StatusMessage
290 get { return _statusMessage; }
293 _statusMessage = value;
294 NotifyOfPropertyChange(() => StatusMessage);
298 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
299 public ObservableConcurrentCollection<AccountInfo> Accounts
301 get { return _accounts; }
304 public bool HasAccounts
306 get { return _accounts.Count > 0; }
310 public string OpenFolderCaption
314 return (_accounts.Count == 0)
315 ? "No Accounts Defined"
316 : "Open Pithos Folder";
320 private string _pauseSyncCaption="Pause Synching";
321 public string PauseSyncCaption
323 get { return _pauseSyncCaption; }
326 _pauseSyncCaption = value;
327 NotifyOfPropertyChange(() => PauseSyncCaption);
331 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
332 public ObservableConcurrentCollection<FileEntry> RecentFiles
334 get { return _recentFiles; }
338 private string _statusIcon="../Images/Pithos.ico";
339 public string StatusIcon
341 get { return _statusIcon; }
344 //TODO: Ensure all status icons use the Pithos logo
346 NotifyOfPropertyChange(() => StatusIcon);
354 public void ShowPreferences()
356 ShowPreferences(null);
359 public void ShowPreferences(string currentTab)
362 var preferences = new PreferencesViewModel(_windowManager, _events, this, Settings,currentTab);
363 _windowManager.ShowDialog(preferences);
367 public void AboutPithos()
369 var about = new AboutViewModel();
370 _windowManager.ShowWindow(about);
373 public void SendFeedback()
375 var feedBack = IoC.Get<FeedbackViewModel>();
376 _windowManager.ShowWindow(feedBack);
379 //public PithosCommand OpenPithosFolderCommand { get; private set; }
381 public void OpenPithosFolder()
383 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
386 Process.Start(account.RootPath);
389 public void OpenPithosFolder(AccountInfo account)
391 Process.Start(account.AccountPath);
396 public void GoToSite()
398 var site = Properties.Settings.Default.PithosSite;
403 public void GoToSite(AccountInfo account)
405 /*var site = String.Format("{0}/ui/?token={1}&user={2}",
406 account.SiteUri,account.Token,
408 Process.Start(account.SiteUri);
411 public void ShowFileProperties()
413 var account = Settings.Accounts.First(acc => acc.IsActive);
414 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
415 var files=dir.GetFiles();
417 var idx=r.Next(0, files.Length);
418 ShowFileProperties(files[idx].FullName);
421 public void ShowFileProperties(string filePath)
423 if (String.IsNullOrWhiteSpace(filePath))
424 throw new ArgumentNullException("filePath");
425 if (!File.Exists(filePath) && !Directory.Exists(filePath))
426 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
427 Contract.EndContractBlock();
429 var pair=(from monitor in Monitors
430 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
431 select monitor).FirstOrDefault();
432 var accountMonitor = pair.Value;
434 if (accountMonitor == null)
437 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
441 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
442 _windowManager.ShowWindow(fileProperties);
445 public void ShowContainerProperties()
447 var account = Settings.Accounts.First(acc => acc.IsActive);
448 var dir = new DirectoryInfo(account.RootPath);
449 var fullName = (from folder in dir.EnumerateDirectories()
450 where (folder.Attributes & FileAttributes.Hidden) == 0
451 select folder.FullName).First();
452 ShowContainerProperties(fullName);
455 public void ShowContainerProperties(string filePath)
457 if (String.IsNullOrWhiteSpace(filePath))
458 throw new ArgumentNullException("filePath");
459 if (!Directory.Exists(filePath))
460 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
461 Contract.EndContractBlock();
463 var pair=(from monitor in Monitors
464 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
465 select monitor).FirstOrDefault();
466 var accountMonitor = pair.Value;
467 var info = accountMonitor.GetContainerInfo(filePath);
471 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
472 _windowManager.ShowWindow(containerProperties);
475 public void SynchNow()
477 _pollAgent.SynchNow();
480 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
482 if (currentInfo==null)
483 throw new ArgumentNullException("currentInfo");
484 Contract.EndContractBlock();
486 var monitor = Monitors[currentInfo.Account];
487 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
491 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
493 if (container == null)
494 throw new ArgumentNullException("container");
495 Contract.EndContractBlock();
497 var monitor = Monitors[container.Account];
498 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
503 public void ToggleSynching()
506 foreach (var pair in Monitors)
508 var monitor = pair.Value;
509 monitor.Pause = !monitor.Pause;
510 isPaused = monitor.Pause;
513 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
514 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
515 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
518 public void ExitPithos()
520 foreach (var pair in Monitors)
522 var monitor = pair.Value;
526 ((Window)GetView()).Close();
531 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
533 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
534 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
535 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
536 }.ToDictionary(s => s.Status);
538 readonly IWindowManager _windowManager;
542 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
544 public void UpdateStatus()
546 var pithosStatus = _statusChecker.GetPithosStatus();
548 if (_iconNames.ContainsKey(pithosStatus))
550 var info = _iconNames[pithosStatus];
551 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
555 StatusMessage = String.Format("Pithos {0}\r\n{1}", _fileVersion.Value.FileVersion,info.StatusText);
558 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
563 private Task StartMonitor(PithosMonitor monitor,int retries=0)
565 return Task.Factory.StartNew(() =>
567 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
571 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
575 catch (WebException exc)
577 if (AbandonRetry(monitor, retries))
580 HttpStatusCode statusCode =HttpStatusCode.OK;
581 var response = exc.Response as HttpWebResponse;
583 statusCode = response.StatusCode;
587 case HttpStatusCode.Unauthorized:
588 var message = String.Format("API Key Expired for {0}. Starting Renewal",
590 Log.Error(message, exc);
591 var account = Settings.Accounts.Find(acc => acc.AccountName == monitor.UserName);
592 account.IsExpired = true;
593 Notify(new ExpirationNotification(account));
594 //TryAuthorize(monitor.UserName, retries).Wait();
596 case HttpStatusCode.ProxyAuthenticationRequired:
597 TryAuthenticateProxy(monitor,retries);
600 TryLater(monitor, exc, retries);
604 catch (Exception exc)
606 if (AbandonRetry(monitor, retries))
609 TryLater(monitor,exc,retries);
615 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
617 Execute.OnUIThread(() =>
619 var proxyAccount = IoC.Get<ProxyAccountViewModel>();
620 proxyAccount.Settings = this.Settings;
621 if (true != _windowManager.ShowDialog(proxyAccount))
623 StartMonitor(monitor, retries);
624 NotifyOfPropertyChange(() => Accounts);
628 private bool AbandonRetry(PithosMonitor monitor, int retries)
632 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
634 _events.Publish(new Notification
635 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
643 private static bool IsUnauthorized(WebException exc)
646 throw new ArgumentNullException("exc");
647 Contract.EndContractBlock();
649 var response = exc.Response as HttpWebResponse;
650 if (response == null)
652 return (response.StatusCode == HttpStatusCode.Unauthorized);
655 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
657 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
658 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
659 _events.Publish(new Notification
660 {Title = "Error", Message = message, Level = TraceLevel.Error});
661 Log.Error(message, exc);
665 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
667 StatusMessage = status;
669 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
672 public void NotifyChangedFile(string filePath)
674 var entry = new FileEntry {FullPath=filePath};
675 IProducerConsumerCollection<FileEntry> files=RecentFiles;
677 while (files.Count > 5)
678 files.TryTake(out popped);
682 public void NotifyAccount(AccountInfo account)
686 //TODO: What happens to an existing account whose Token has changed?
687 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
688 account.SiteUri, Uri.EscapeDataString(account.Token),
689 Uri.EscapeDataString(account.UserName));
691 if (Accounts.All(item => item.UserName != account.UserName))
692 Accounts.TryAdd(account);
696 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
698 if (conflictFiles == null)
700 if (!conflictFiles.Any())
704 //TODO: Create a more specific message. For now, just show a warning
705 NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
709 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
716 StatusMessage = message;
718 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
721 public void Notify(Notification notification)
723 _events.Publish(notification);
727 public void RemoveMonitor(string accountName)
729 if (String.IsNullOrWhiteSpace(accountName))
732 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
733 _accounts.TryRemove(accountInfo);
735 PithosMonitor monitor;
736 if (Monitors.TryRemove(accountName, out monitor))
742 public void RefreshOverlays()
744 foreach (var pair in Monitors)
746 var monitor = pair.Value;
748 var path = monitor.RootPath;
750 if (String.IsNullOrWhiteSpace(path))
753 if (!Directory.Exists(path) && !File.Exists(path))
756 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
760 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
761 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
762 pathPointer, IntPtr.Zero);
766 Marshal.FreeHGlobal(pathPointer);
771 #region Event Handlers
773 public void Handle(SelectiveSynchChanges message)
775 var accountName = message.Account.AccountName;
776 PithosMonitor monitor;
777 if (_monitors.TryGetValue(accountName, out monitor))
779 monitor.SetSelectivePaths(message.Uris,message.Added,message.Removed);
786 private bool _pollStarted = false;
788 //SMELL: Doing so much work for notifications in the shell is wrong
789 //The notifications should be moved to their own view/viewmodel pair
790 //and different templates should be used for different message types
791 //This will also allow the addition of extra functionality, eg. actions
793 public void Handle(Notification notification)
797 if (!Settings.ShowDesktopNotifications)
800 if (notification is PollNotification)
805 if (notification is CloudNotification)
810 notification.Title = "Pithos";
811 notification.Message = "Start Synchronisation";
814 if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
818 switch (notification.Level)
820 case TraceLevel.Error:
821 icon = BalloonIcon.Error;
823 case TraceLevel.Info:
824 case TraceLevel.Verbose:
825 icon = BalloonIcon.Info;
827 case TraceLevel.Warning:
828 icon = BalloonIcon.Warning;
831 icon = BalloonIcon.None;
835 if (Settings.ShowDesktopNotifications)
837 var tv = (ShellView) GetView();
838 System.Action clickAction = null;
839 if (notification is ExpirationNotification)
841 clickAction = ()=>ShowPreferences("AccountTab");
843 var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon,ClickAction=clickAction};
844 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
845 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
850 public void Handle(ShowFilePropertiesEvent message)
853 throw new ArgumentNullException("message");
854 if (String.IsNullOrWhiteSpace(message.FileName) )
855 throw new ArgumentException("message");
856 Contract.EndContractBlock();
858 var fileName = message.FileName;
859 //TODO: Display file properties for non-container folders
860 if (File.Exists(fileName))
861 //Retrieve the full name with exact casing. Pithos names are case sensitive
862 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
863 else if (Directory.Exists(fileName))
864 //Retrieve the full name with exact casing. Pithos names are case sensitive
866 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
867 if (IsContainer(path))
868 ShowContainerProperties(path);
870 ShowFileProperties(path);
874 private bool IsContainer(string path)
876 var matchingFolders = from account in _accounts
877 from rootFolder in Directory.GetDirectories(account.AccountPath)
878 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
880 return matchingFolders.Any();
883 public FileStatus GetFileStatus(string localFileName)
885 if (String.IsNullOrWhiteSpace(localFileName))
886 throw new ArgumentNullException("localFileName");
887 Contract.EndContractBlock();
889 var statusKeeper = IoC.Get<IStatusKeeper>();
890 var status=statusKeeper.GetFileStatus(localFileName);