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 readonly Lazy<FileVersionInfo> _fileVersion;
126 private readonly PollAgent _pollAgent;
129 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
132 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
134 [ImportingConstructor]
135 public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings,PollAgent pollAgent)
140 _windowManager = windowManager;
141 //CHECK: Caliburn doesn't need explicit command construction
142 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
143 _statusChecker = statusChecker;
146 _events.Subscribe(this);
148 _pollAgent = pollAgent;
151 Proxy.SetFromSettings(settings);
153 StatusMessage = "In Synch";
155 _fileVersion= new Lazy<FileVersionInfo>(() =>
157 Assembly assembly = Assembly.GetExecutingAssembly();
158 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
161 _accounts.CollectionChanged += (sender, e) =>
163 NotifyOfPropertyChange(() => OpenFolderCaption);
164 NotifyOfPropertyChange(() => HasAccounts);
168 catch (Exception exc)
170 Log.Error("Error while starting the ShellViewModel",exc);
176 protected override void OnActivate()
187 private async void StartMonitoring()
191 var accounts = Settings.Accounts.Select(MonitorAccount);
192 await TaskEx.WhenAll(accounts);
193 _statusService = StatusService.Start();
196 foreach (var account in Settings.Accounts)
198 await MonitorAccount(account);
203 catch (AggregateException exc)
207 Log.Error("Error while starting monitoring", e);
214 protected override void OnDeactivate(bool close)
216 base.OnDeactivate(close);
219 StatusService.Stop(_statusService);
220 _statusService = null;
224 public Task MonitorAccount(AccountSettings account)
226 return Task.Factory.StartNew(() =>
228 PithosMonitor monitor;
229 var accountName = account.AccountName;
231 if (_monitors.TryGetValue(accountName, out monitor))
233 //If the account is active
234 if (account.IsActive)
236 //The Api Key may have changed throuth the Preferences dialog
237 monitor.ApiKey = account.ApiKey;
238 Debug.Assert(monitor.StatusNotification == this,"An existing monitor should already have a StatusNotification service object");
239 monitor.RootPath = account.RootPath;
240 //Start the monitor. It's OK to start an already started monitor,
241 //it will just ignore the call
242 StartMonitor(monitor).Wait();
246 //If the account is inactive
247 //Stop and remove the monitor
248 RemoveMonitor(accountName);
254 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
255 monitor = new PithosMonitor
257 UserName = accountName,
258 ApiKey = account.ApiKey,
259 StatusNotification = this,
260 RootPath = account.RootPath
262 //PithosMonitor uses MEF so we need to resolve it
263 IoC.BuildUp(monitor);
265 monitor.AuthenticationUrl = account.ServerUrl;
267 _monitors[accountName] = monitor;
269 if (account.IsActive)
271 //Don't start a monitor if it doesn't have an account and ApiKey
272 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
273 String.IsNullOrWhiteSpace(monitor.ApiKey))
275 StartMonitor(monitor);
281 protected override void OnViewLoaded(object view)
284 var window = (Window)view;
285 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
286 base.OnViewLoaded(view);
290 #region Status Properties
292 private string _statusMessage;
293 public string StatusMessage
295 get { return _statusMessage; }
298 _statusMessage = value;
299 NotifyOfPropertyChange(() => StatusMessage);
303 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
304 public ObservableConcurrentCollection<AccountInfo> Accounts
306 get { return _accounts; }
309 public bool HasAccounts
311 get { return _accounts.Count > 0; }
315 public string OpenFolderCaption
319 return (_accounts.Count == 0)
320 ? "No Accounts Defined"
321 : "Open Pithos Folder";
325 private string _pauseSyncCaption="Pause Synching";
326 public string PauseSyncCaption
328 get { return _pauseSyncCaption; }
331 _pauseSyncCaption = value;
332 NotifyOfPropertyChange(() => PauseSyncCaption);
336 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
337 public ObservableConcurrentCollection<FileEntry> RecentFiles
339 get { return _recentFiles; }
343 private string _statusIcon="../Images/Pithos.ico";
344 public string StatusIcon
346 get { return _statusIcon; }
349 //TODO: Ensure all status icons use the Pithos logo
351 NotifyOfPropertyChange(() => StatusIcon);
359 public void ShowPreferences()
361 ShowPreferences(null);
364 public void ShowPreferences(string currentTab)
367 var preferences = new PreferencesViewModel(_windowManager, _events, this, Settings,currentTab);
368 _windowManager.ShowDialog(preferences);
372 public void AboutPithos()
374 var about = new AboutViewModel();
375 _windowManager.ShowWindow(about);
378 public void SendFeedback()
380 var feedBack = IoC.Get<FeedbackViewModel>();
381 _windowManager.ShowWindow(feedBack);
384 //public PithosCommand OpenPithosFolderCommand { get; private set; }
386 public void OpenPithosFolder()
388 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
391 Process.Start(account.RootPath);
394 public void OpenPithosFolder(AccountInfo account)
396 Process.Start(account.AccountPath);
401 public void GoToSite()
403 var site = Properties.Settings.Default.PithosSite;
408 public void GoToSite(AccountInfo account)
410 Process.Start(account.SiteUri);
414 /// Open an explorer window to the target path's directory
415 /// and select the file
417 /// <param name="entry"></param>
418 public void GoToFile(FileEntry entry)
420 var fullPath = entry.FullPath;
421 if (!File.Exists(fullPath) && !Directory.Exists(fullPath))
423 Process.Start("explorer.exe","/select, " + fullPath);
426 public void ShowFileProperties()
428 var account = Settings.Accounts.First(acc => acc.IsActive);
429 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
430 var files=dir.GetFiles();
432 var idx=r.Next(0, files.Length);
433 ShowFileProperties(files[idx].FullName);
436 public void ShowFileProperties(string filePath)
438 if (String.IsNullOrWhiteSpace(filePath))
439 throw new ArgumentNullException("filePath");
440 if (!File.Exists(filePath) && !Directory.Exists(filePath))
441 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
442 Contract.EndContractBlock();
444 var pair=(from monitor in Monitors
445 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
446 select monitor).FirstOrDefault();
447 var accountMonitor = pair.Value;
449 if (accountMonitor == null)
452 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
456 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
457 _windowManager.ShowWindow(fileProperties);
460 public void ShowContainerProperties()
462 var account = Settings.Accounts.First(acc => acc.IsActive);
463 var dir = new DirectoryInfo(account.RootPath);
464 var fullName = (from folder in dir.EnumerateDirectories()
465 where (folder.Attributes & FileAttributes.Hidden) == 0
466 select folder.FullName).First();
467 ShowContainerProperties(fullName);
470 public void ShowContainerProperties(string filePath)
472 if (String.IsNullOrWhiteSpace(filePath))
473 throw new ArgumentNullException("filePath");
474 if (!Directory.Exists(filePath))
475 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
476 Contract.EndContractBlock();
478 var pair=(from monitor in Monitors
479 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
480 select monitor).FirstOrDefault();
481 var accountMonitor = pair.Value;
482 var info = accountMonitor.GetContainerInfo(filePath);
486 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
487 _windowManager.ShowWindow(containerProperties);
490 public void SynchNow()
492 _pollAgent.SynchNow();
495 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
497 if (currentInfo==null)
498 throw new ArgumentNullException("currentInfo");
499 Contract.EndContractBlock();
501 var monitor = Monitors[currentInfo.Account];
502 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
506 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
508 if (container == null)
509 throw new ArgumentNullException("container");
510 Contract.EndContractBlock();
512 var monitor = Monitors[container.Account];
513 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
518 public void ToggleSynching()
521 foreach (var pair in Monitors)
523 var monitor = pair.Value;
524 monitor.Pause = !monitor.Pause;
525 isPaused = monitor.Pause;
528 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
529 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
530 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
533 public void ExitPithos()
535 foreach (var pair in Monitors)
537 var monitor = pair.Value;
541 ((Window)GetView()).Close();
546 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
548 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
549 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
550 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
551 }.ToDictionary(s => s.Status);
553 readonly IWindowManager _windowManager;
557 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
559 public void UpdateStatus()
561 var pithosStatus = _statusChecker.GetPithosStatus();
563 if (_iconNames.ContainsKey(pithosStatus))
565 var info = _iconNames[pithosStatus];
566 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
570 StatusMessage = String.Format("Pithos {0}\r\n{1}", _fileVersion.Value.FileVersion,info.StatusText);
573 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
578 private Task StartMonitor(PithosMonitor monitor,int retries=0)
580 return Task.Factory.StartNew(() =>
582 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
586 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
590 catch (WebException exc)
592 if (AbandonRetry(monitor, retries))
595 HttpStatusCode statusCode =HttpStatusCode.OK;
596 var response = exc.Response as HttpWebResponse;
598 statusCode = response.StatusCode;
602 case HttpStatusCode.Unauthorized:
603 var message = String.Format("API Key Expired for {0}. Starting Renewal",
605 Log.Error(message, exc);
606 var account = Settings.Accounts.Find(acc => acc.AccountName == monitor.UserName);
607 account.IsExpired = true;
608 Notify(new ExpirationNotification(account));
609 //TryAuthorize(monitor.UserName, retries).Wait();
611 case HttpStatusCode.ProxyAuthenticationRequired:
612 TryAuthenticateProxy(monitor,retries);
615 TryLater(monitor, exc, retries);
619 catch (Exception exc)
621 if (AbandonRetry(monitor, retries))
624 TryLater(monitor,exc,retries);
630 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
632 Execute.OnUIThread(() =>
634 var proxyAccount = IoC.Get<ProxyAccountViewModel>();
635 proxyAccount.Settings = Settings;
636 if (true != _windowManager.ShowDialog(proxyAccount))
638 StartMonitor(monitor, retries);
639 NotifyOfPropertyChange(() => Accounts);
643 private bool AbandonRetry(PithosMonitor monitor, int retries)
647 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
649 _events.Publish(new Notification
650 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
657 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
659 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
660 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
661 _events.Publish(new Notification
662 {Title = "Error", Message = message, Level = TraceLevel.Error});
663 Log.Error(message, exc);
667 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
669 StatusMessage = status;
671 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
674 public void NotifyChangedFile(string filePath)
676 if (RecentFiles.Any(e => e.FullPath == filePath))
679 IProducerConsumerCollection<FileEntry> files=RecentFiles;
681 while (files.Count > 5)
682 files.TryTake(out popped);
683 var entry = new FileEntry { FullPath = filePath };
687 public void NotifyAccount(AccountInfo account)
691 //TODO: What happens to an existing account whose Token has changed?
692 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
693 account.SiteUri, Uri.EscapeDataString(account.Token),
694 Uri.EscapeDataString(account.UserName));
696 if (Accounts.All(item => item.UserName != account.UserName))
697 Accounts.TryAdd(account);
701 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
703 if (conflictFiles == null)
705 //Convert to list to avoid multiple iterations
706 var files = conflictFiles.ToList();
711 //TODO: Create a more specific message. For now, just show a warning
712 NotifyForFiles(files,message,TraceLevel.Warning);
716 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
723 StatusMessage = message;
725 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
728 public void Notify(Notification notification)
730 _events.Publish(notification);
734 public void RemoveMonitor(string accountName)
736 if (String.IsNullOrWhiteSpace(accountName))
739 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
740 if (accountInfo != null)
742 _accounts.TryRemove(accountInfo);
743 _pollAgent.RemoveAccount(accountInfo);
746 PithosMonitor monitor;
747 if (Monitors.TryRemove(accountName, out monitor))
750 //TODO: Also remove any pending actions for this account
751 //from the network queue
755 public void RefreshOverlays()
757 foreach (var pair in Monitors)
759 var monitor = pair.Value;
761 var path = monitor.RootPath;
763 if (String.IsNullOrWhiteSpace(path))
766 if (!Directory.Exists(path) && !File.Exists(path))
769 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
773 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
774 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
775 pathPointer, IntPtr.Zero);
779 Marshal.FreeHGlobal(pathPointer);
784 #region Event Handlers
786 public void Handle(SelectiveSynchChanges message)
788 var accountName = message.Account.AccountName;
789 PithosMonitor monitor;
790 if (_monitors.TryGetValue(accountName, out monitor))
792 monitor.SetSelectivePaths(message.Uris,message.Added,message.Removed);
799 private bool _pollStarted;
801 //SMELL: Doing so much work for notifications in the shell is wrong
802 //The notifications should be moved to their own view/viewmodel pair
803 //and different templates should be used for different message types
804 //This will also allow the addition of extra functionality, eg. actions
806 public void Handle(Notification notification)
810 if (!Settings.ShowDesktopNotifications)
813 if (notification is PollNotification)
818 if (notification is CloudNotification)
823 notification.Title = "Pithos";
824 notification.Message = "Start Synchronisation";
827 if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
831 switch (notification.Level)
833 case TraceLevel.Error:
834 icon = BalloonIcon.Error;
836 case TraceLevel.Info:
837 case TraceLevel.Verbose:
838 icon = BalloonIcon.Info;
840 case TraceLevel.Warning:
841 icon = BalloonIcon.Warning;
844 icon = BalloonIcon.None;
848 if (Settings.ShowDesktopNotifications)
850 var tv = (ShellView) GetView();
851 System.Action clickAction = null;
852 if (notification is ExpirationNotification)
854 clickAction = ()=>ShowPreferences("AccountTab");
856 var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon,ClickAction=clickAction};
857 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
858 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
863 public void Handle(ShowFilePropertiesEvent message)
866 throw new ArgumentNullException("message");
867 if (String.IsNullOrWhiteSpace(message.FileName) )
868 throw new ArgumentException("message");
869 Contract.EndContractBlock();
871 var fileName = message.FileName;
872 //TODO: Display file properties for non-container folders
873 if (File.Exists(fileName))
874 //Retrieve the full name with exact casing. Pithos names are case sensitive
875 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
876 else if (Directory.Exists(fileName))
877 //Retrieve the full name with exact casing. Pithos names are case sensitive
879 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
880 if (IsContainer(path))
881 ShowContainerProperties(path);
883 ShowFileProperties(path);
887 private bool IsContainer(string path)
889 var matchingFolders = from account in _accounts
890 from rootFolder in Directory.GetDirectories(account.AccountPath)
891 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
893 return matchingFolders.Any();
896 public FileStatus GetFileStatus(string localFileName)
898 if (String.IsNullOrWhiteSpace(localFileName))
899 throw new ArgumentNullException("localFileName");
900 Contract.EndContractBlock();
902 var statusKeeper = IoC.Get<IStatusKeeper>();
903 var status=statusKeeper.GetFileStatus(localFileName);