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 _accounts.TryRemove(accountInfo);
742 _pollAgent.RemoveAccount(accountInfo);
743 PithosMonitor monitor;
744 if (Monitors.TryRemove(accountName, out monitor))
747 //TODO: Also remove any pending actions for this account
748 //from the network queue
752 public void RefreshOverlays()
754 foreach (var pair in Monitors)
756 var monitor = pair.Value;
758 var path = monitor.RootPath;
760 if (String.IsNullOrWhiteSpace(path))
763 if (!Directory.Exists(path) && !File.Exists(path))
766 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
770 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
771 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
772 pathPointer, IntPtr.Zero);
776 Marshal.FreeHGlobal(pathPointer);
781 #region Event Handlers
783 public void Handle(SelectiveSynchChanges message)
785 var accountName = message.Account.AccountName;
786 PithosMonitor monitor;
787 if (_monitors.TryGetValue(accountName, out monitor))
789 monitor.SetSelectivePaths(message.Uris,message.Added,message.Removed);
796 private bool _pollStarted;
798 //SMELL: Doing so much work for notifications in the shell is wrong
799 //The notifications should be moved to their own view/viewmodel pair
800 //and different templates should be used for different message types
801 //This will also allow the addition of extra functionality, eg. actions
803 public void Handle(Notification notification)
807 if (!Settings.ShowDesktopNotifications)
810 if (notification is PollNotification)
815 if (notification is CloudNotification)
820 notification.Title = "Pithos";
821 notification.Message = "Start Synchronisation";
824 if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
828 switch (notification.Level)
830 case TraceLevel.Error:
831 icon = BalloonIcon.Error;
833 case TraceLevel.Info:
834 case TraceLevel.Verbose:
835 icon = BalloonIcon.Info;
837 case TraceLevel.Warning:
838 icon = BalloonIcon.Warning;
841 icon = BalloonIcon.None;
845 if (Settings.ShowDesktopNotifications)
847 var tv = (ShellView) GetView();
848 System.Action clickAction = null;
849 if (notification is ExpirationNotification)
851 clickAction = ()=>ShowPreferences("AccountTab");
853 var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon,ClickAction=clickAction};
854 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
855 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
860 public void Handle(ShowFilePropertiesEvent message)
863 throw new ArgumentNullException("message");
864 if (String.IsNullOrWhiteSpace(message.FileName) )
865 throw new ArgumentException("message");
866 Contract.EndContractBlock();
868 var fileName = message.FileName;
869 //TODO: Display file properties for non-container folders
870 if (File.Exists(fileName))
871 //Retrieve the full name with exact casing. Pithos names are case sensitive
872 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
873 else if (Directory.Exists(fileName))
874 //Retrieve the full name with exact casing. Pithos names are case sensitive
876 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
877 if (IsContainer(path))
878 ShowContainerProperties(path);
880 ShowFileProperties(path);
884 private bool IsContainer(string path)
886 var matchingFolders = from account in _accounts
887 from rootFolder in Directory.GetDirectories(account.AccountPath)
888 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
890 return matchingFolders.Any();
893 public FileStatus GetFileStatus(string localFileName)
895 if (String.IsNullOrWhiteSpace(localFileName))
896 throw new ArgumentNullException("localFileName");
897 Contract.EndContractBlock();
899 var statusKeeper = IoC.Get<IStatusKeeper>();
900 var status=statusKeeper.GetFileStatus(localFileName);