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.SelectiveSynch;
59 using Pithos.Client.WPF.Services;
60 using Pithos.Client.WPF.Shell;
62 using Pithos.Core.Agents;
63 using Pithos.Interfaces;
65 using System.Collections.Generic;
68 using StatusService = Pithos.Client.WPF.Services.StatusService;
70 namespace Pithos.Client.WPF {
71 using System.ComponentModel.Composition;
75 /// The "shell" of the Pithos application displays the taskbar icon, menu and notifications.
76 /// The shell also hosts the status service called by shell extensions to retrieve file info
79 /// It is a strange "shell" as its main visible element is an icon instead of a window
80 /// The shell subscribes to the following events:
81 /// * Notification: Raised by components that want to notify the user. Usually displayed in a balloon
82 /// * 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
83 /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog
85 //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges?
86 [Export(typeof(IShell))]
87 public class ShellViewModel : Screen, IStatusNotification, IShell,
88 IHandle<Notification>, IHandle<SelectiveSynchChanges>, IHandle<ShowFilePropertiesEvent>
90 //The Status Checker provides the current synch state
91 //TODO: Could we remove the status checker and use events in its place?
92 private readonly IStatusChecker _statusChecker;
93 private readonly IEventAggregator _events;
95 public PithosSettings Settings { get; private set; }
98 private readonly ConcurrentDictionary<string, PithosMonitor> _monitors = new ConcurrentDictionary<string, PithosMonitor>();
100 /// Dictionary of account monitors, keyed by account
103 /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands,
104 /// retrieve account and boject info
106 // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design?
107 // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier
108 public ConcurrentDictionary<string, PithosMonitor> Monitors
110 get { return _monitors; }
115 /// The status service is used by Shell extensions to retrieve file status information
117 //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
118 private ServiceHost _statusService;
120 //Logging in the Pithos client is provided by log4net
121 private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos");
123 //Lazily initialized File Version info. This is done once and lazily to avoid blocking the UI
124 private Lazy<FileVersionInfo> _fileVersion;
127 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
130 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
132 [ImportingConstructor]
133 public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings)
138 _windowManager = windowManager;
139 //CHECK: Caliburn doesn't need explicit command construction
140 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
141 _statusChecker = statusChecker;
144 _events.Subscribe(this);
148 Proxy.SetFromSettings(settings);
150 StatusMessage = "In Synch";
152 _fileVersion= new Lazy<FileVersionInfo>(() =>
154 Assembly assembly = Assembly.GetExecutingAssembly();
155 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
158 _accounts.CollectionChanged += (sender, e) =>
160 NotifyOfPropertyChange(() => OpenFolderCaption);
161 NotifyOfPropertyChange(() => HasAccounts);
165 catch (Exception exc)
167 Log.Error("Error while starting the ShellViewModel",exc);
173 protected override void OnActivate()
184 private async void StartMonitoring()
188 var accounts = Settings.Accounts.Select(MonitorAccount);
189 await TaskEx.WhenAll(accounts);
190 _statusService = StatusService.Start();
193 foreach (var account in Settings.Accounts)
195 await MonitorAccount(account);
200 catch (AggregateException exc)
204 Log.Error("Error while starting monitoring", e);
211 protected override void OnDeactivate(bool close)
213 base.OnDeactivate(close);
216 StatusService.Stop(_statusService);
217 _statusService = null;
221 public Task MonitorAccount(AccountSettings account)
223 return Task.Factory.StartNew(() =>
225 PithosMonitor monitor;
226 var accountName = account.AccountName;
228 if (_monitors.TryGetValue(accountName, out monitor))
230 //If the account is active
231 if (account.IsActive)
232 //Start the monitor. It's OK to start an already started monitor,
233 //it will just ignore the call
234 StartMonitor(monitor).Wait();
237 //If the account is inactive
238 //Stop and remove the monitor
239 RemoveMonitor(accountName);
245 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
246 monitor = new PithosMonitor
248 UserName = accountName,
249 ApiKey = account.ApiKey,
250 StatusNotification = this,
251 RootPath = account.RootPath
253 //PithosMonitor uses MEF so we need to resolve it
254 IoC.BuildUp(monitor);
256 monitor.AuthenticationUrl = account.ServerUrl;
258 _monitors[accountName] = monitor;
260 if (account.IsActive)
262 //Don't start a monitor if it doesn't have an account and ApiKey
263 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
264 String.IsNullOrWhiteSpace(monitor.ApiKey))
266 StartMonitor(monitor);
272 protected override void OnViewLoaded(object view)
275 var window = (Window)view;
276 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
277 base.OnViewLoaded(view);
281 #region Status Properties
283 private string _statusMessage;
284 public string StatusMessage
286 get { return _statusMessage; }
289 _statusMessage = value;
290 NotifyOfPropertyChange(() => StatusMessage);
294 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
295 public ObservableConcurrentCollection<AccountInfo> Accounts
297 get { return _accounts; }
300 public bool HasAccounts
302 get { return _accounts.Count > 0; }
306 public string OpenFolderCaption
310 return (_accounts.Count == 0)
311 ? "No Accounts Defined"
312 : "Open Pithos Folder";
316 private string _pauseSyncCaption="Pause Synching";
317 public string PauseSyncCaption
319 get { return _pauseSyncCaption; }
322 _pauseSyncCaption = value;
323 NotifyOfPropertyChange(() => PauseSyncCaption);
327 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
328 public ObservableConcurrentCollection<FileEntry> RecentFiles
330 get { return _recentFiles; }
334 private string _statusIcon="../Images/Pithos.ico";
335 public string StatusIcon
337 get { return _statusIcon; }
340 //TODO: Ensure all status icons use the Pithos logo
342 NotifyOfPropertyChange(() => StatusIcon);
350 public void ShowPreferences()
353 var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings);
354 _windowManager.ShowDialog(preferences);
358 public void AboutPithos()
360 var about = new AboutViewModel();
361 _windowManager.ShowWindow(about);
364 public void SendFeedback()
366 var feedBack = IoC.Get<FeedbackViewModel>();
367 _windowManager.ShowWindow(feedBack);
370 //public PithosCommand OpenPithosFolderCommand { get; private set; }
372 public void OpenPithosFolder()
374 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
377 Process.Start(account.RootPath);
380 public void OpenPithosFolder(AccountInfo account)
382 Process.Start(account.AccountPath);
387 public void GoToSite()
389 var site = Properties.Settings.Default.PithosSite;
394 public void GoToSite(AccountInfo account)
396 /*var site = String.Format("{0}/ui/?token={1}&user={2}",
397 account.SiteUri,account.Token,
399 Process.Start(account.SiteUri);
402 public void ShowFileProperties()
404 var account = Settings.Accounts.First(acc => acc.IsActive);
405 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
406 var files=dir.GetFiles();
408 var idx=r.Next(0, files.Length);
409 ShowFileProperties(files[idx].FullName);
412 public void ShowFileProperties(string filePath)
414 if (String.IsNullOrWhiteSpace(filePath))
415 throw new ArgumentNullException("filePath");
416 if (!File.Exists(filePath) && !Directory.Exists(filePath))
417 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
418 Contract.EndContractBlock();
420 var pair=(from monitor in Monitors
421 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
422 select monitor).FirstOrDefault();
423 var accountMonitor = pair.Value;
425 if (accountMonitor == null)
428 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
432 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
433 _windowManager.ShowWindow(fileProperties);
436 public void ShowContainerProperties()
438 var account = Settings.Accounts.First(acc => acc.IsActive);
439 var dir = new DirectoryInfo(account.RootPath);
440 var fullName = (from folder in dir.EnumerateDirectories()
441 where (folder.Attributes & FileAttributes.Hidden) == 0
442 select folder.FullName).First();
443 ShowContainerProperties(fullName);
446 public void ShowContainerProperties(string filePath)
448 if (String.IsNullOrWhiteSpace(filePath))
449 throw new ArgumentNullException("filePath");
450 if (!Directory.Exists(filePath))
451 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
452 Contract.EndContractBlock();
454 var pair=(from monitor in Monitors
455 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
456 select monitor).FirstOrDefault();
457 var accountMonitor = pair.Value;
458 var info = accountMonitor.GetContainerInfo(filePath);
462 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
463 _windowManager.ShowWindow(containerProperties);
466 public void SynchNow()
468 var agent = IoC.Get<PollAgent>();
472 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
474 if (currentInfo==null)
475 throw new ArgumentNullException("currentInfo");
476 Contract.EndContractBlock();
478 var monitor = Monitors[currentInfo.Account];
479 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
483 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
485 if (container == null)
486 throw new ArgumentNullException("container");
487 Contract.EndContractBlock();
489 var monitor = Monitors[container.Account];
490 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
495 public void ToggleSynching()
498 foreach (var pair in Monitors)
500 var monitor = pair.Value;
501 monitor.Pause = !monitor.Pause;
502 isPaused = monitor.Pause;
505 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
506 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
507 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
510 public void ExitPithos()
512 foreach (var pair in Monitors)
514 var monitor = pair.Value;
518 ((Window)GetView()).Close();
523 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
525 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
526 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
527 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
528 }.ToDictionary(s => s.Status);
530 readonly IWindowManager _windowManager;
534 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
536 public void UpdateStatus()
538 var pithosStatus = _statusChecker.GetPithosStatus();
540 if (_iconNames.ContainsKey(pithosStatus))
542 var info = _iconNames[pithosStatus];
543 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
547 StatusMessage = String.Format("Pithos {0}\r\n{1}", _fileVersion.Value.FileVersion,info.StatusText);
550 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
555 private Task StartMonitor(PithosMonitor monitor,int retries=0)
557 return Task.Factory.StartNew(() =>
559 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
563 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
567 catch (WebException exc)
569 if (AbandonRetry(monitor, retries))
572 HttpStatusCode statusCode =HttpStatusCode.OK;
573 var response = exc.Response as HttpWebResponse;
575 statusCode = response.StatusCode;
579 case HttpStatusCode.Unauthorized:
580 var message = String.Format("API Key Expired for {0}. Starting Renewal",
582 Log.Error(message, exc);
583 TryAuthorize(monitor.UserName, retries).Wait();
585 case HttpStatusCode.ProxyAuthenticationRequired:
586 TryAuthenticateProxy(monitor,retries);
589 TryLater(monitor, exc, retries);
593 catch (Exception exc)
595 if (AbandonRetry(monitor, retries))
598 TryLater(monitor,exc,retries);
604 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
606 Execute.OnUIThread(() =>
608 var proxyAccount = IoC.Get<ProxyAccountViewModel>();
609 proxyAccount.Settings = this.Settings;
610 if (true != _windowManager.ShowDialog(proxyAccount))
612 StartMonitor(monitor, retries);
613 NotifyOfPropertyChange(() => Accounts);
617 private bool AbandonRetry(PithosMonitor monitor, int retries)
621 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
623 _events.Publish(new Notification
624 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
631 public async Task TryAuthorize(string userName, int retries)
633 _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 });
638 var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl);
640 var account = Settings.Accounts.First(act => act.AccountName == credentials.UserName);
641 //The server may return credentials for a different account
642 var monitor = _monitors[account.AccountName];
643 account.ApiKey = credentials.Password;
644 monitor.ApiKey = credentials.Password;
646 await TaskEx.Delay(10000);
647 StartMonitor(monitor, retries + 1);
648 NotifyOfPropertyChange(()=>Accounts);
650 catch (AggregateException exc)
652 string message = String.Format("API Key retrieval for {0} failed", userName);
653 Log.Error(message, exc.InnerException);
654 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
656 catch (Exception exc)
658 string message = String.Format("API Key retrieval for {0} failed", userName);
659 Log.Error(message, exc);
660 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
665 private static bool IsUnauthorized(WebException exc)
668 throw new ArgumentNullException("exc");
669 Contract.EndContractBlock();
671 var response = exc.Response as HttpWebResponse;
672 if (response == null)
674 return (response.StatusCode == HttpStatusCode.Unauthorized);
677 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
679 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
680 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
681 _events.Publish(new Notification
682 {Title = "Error", Message = message, Level = TraceLevel.Error});
683 Log.Error(message, exc);
687 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
689 StatusMessage = status;
691 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
694 public void NotifyChangedFile(string filePath)
696 var entry = new FileEntry {FullPath=filePath};
697 IProducerConsumerCollection<FileEntry> files=RecentFiles;
699 while (files.Count > 5)
700 files.TryTake(out popped);
704 public void NotifyAccount(AccountInfo account)
708 //TODO: What happens to an existing account whose Token has changed?
709 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
710 account.SiteUri, Uri.EscapeDataString(account.Token),
711 Uri.EscapeDataString(account.UserName));
713 if (Accounts.All(item => item.UserName != account.UserName))
714 Accounts.TryAdd(account);
718 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
720 if (conflictFiles == null)
722 if (!conflictFiles.Any())
726 //TODO: Create a more specific message. For now, just show a warning
727 NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
731 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
738 StatusMessage = message;
740 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
743 public void Notify(Notification notification)
745 _events.Publish(notification);
749 public void RemoveMonitor(string accountName)
751 if (String.IsNullOrWhiteSpace(accountName))
754 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
755 _accounts.TryRemove(accountInfo);
757 PithosMonitor monitor;
758 if (Monitors.TryRemove(accountName, out monitor))
764 public void RefreshOverlays()
766 foreach (var pair in Monitors)
768 var monitor = pair.Value;
770 var path = monitor.RootPath;
772 if (String.IsNullOrWhiteSpace(path))
775 if (!Directory.Exists(path) && !File.Exists(path))
778 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
782 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
783 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
784 pathPointer, IntPtr.Zero);
788 Marshal.FreeHGlobal(pathPointer);
793 #region Event Handlers
795 public void Handle(SelectiveSynchChanges message)
797 var accountName = message.Account.AccountName;
798 PithosMonitor monitor;
799 if (_monitors.TryGetValue(accountName, out monitor))
801 monitor.SetSelectivePaths(message.Uris,message.Added,message.Removed);
808 private bool _pollStarted = false;
810 //SMELL: Doing so much work for notifications in the shell is wrong
811 //The notifications should be moved to their own view/viewmodel pair
812 //and different templates should be used for different message types
813 //This will also allow the addition of extra functionality, eg. actions
815 public void Handle(Notification notification)
819 if (!Settings.ShowDesktopNotifications)
822 if (notification is PollNotification)
827 if (notification is CloudNotification)
832 notification.Title = "Pithos";
833 notification.Message = "Start Synchronisation";
836 if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
840 switch (notification.Level)
842 case TraceLevel.Error:
843 icon = BalloonIcon.Error;
845 case TraceLevel.Info:
846 case TraceLevel.Verbose:
847 icon = BalloonIcon.Info;
849 case TraceLevel.Warning:
850 icon = BalloonIcon.Warning;
853 icon = BalloonIcon.None;
857 if (Settings.ShowDesktopNotifications)
859 var tv = (ShellView) GetView();
860 var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon};
861 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
862 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
867 public void Handle(ShowFilePropertiesEvent message)
870 throw new ArgumentNullException("message");
871 if (String.IsNullOrWhiteSpace(message.FileName) )
872 throw new ArgumentException("message");
873 Contract.EndContractBlock();
875 var fileName = message.FileName;
876 //TODO: Display file properties for non-container folders
877 if (File.Exists(fileName))
878 //Retrieve the full name with exact casing. Pithos names are case sensitive
879 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
880 else if (Directory.Exists(fileName))
881 //Retrieve the full name with exact casing. Pithos names are case sensitive
883 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
884 if (IsContainer(path))
885 ShowContainerProperties(path);
887 ShowFileProperties(path);
891 private bool IsContainer(string path)
893 var matchingFolders = from account in _accounts
894 from rootFolder in Directory.GetDirectories(account.AccountPath)
895 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
897 return matchingFolders.Any();
900 public FileStatus GetFileStatus(string localFileName)
902 if (String.IsNullOrWhiteSpace(localFileName))
903 throw new ArgumentNullException("localFileName");
904 Contract.EndContractBlock();
906 var statusKeeper = IoC.Get<IStatusKeeper>();
907 var status=statusKeeper.GetFileStatus(localFileName);