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)
237 //The Api Key may have changed throuth the Preferences dialog
238 monitor.ApiKey = account.ApiKey;
239 Debug.Assert(monitor.StatusNotification == this,"An existing monitor should already have a StatusNotification service object");
240 monitor.RootPath = account.RootPath;
241 //Start the monitor. It's OK to start an already started monitor,
242 //it will just ignore the call
243 StartMonitor(monitor).Wait();
247 //If the account is inactive
248 //Stop and remove the monitor
249 RemoveMonitor(accountName);
255 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
256 monitor = new PithosMonitor
258 UserName = accountName,
259 ApiKey = account.ApiKey,
260 StatusNotification = this,
261 RootPath = account.RootPath
263 //PithosMonitor uses MEF so we need to resolve it
264 IoC.BuildUp(monitor);
266 monitor.AuthenticationUrl = account.ServerUrl;
268 _monitors[accountName] = monitor;
270 if (account.IsActive)
272 //Don't start a monitor if it doesn't have an account and ApiKey
273 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
274 String.IsNullOrWhiteSpace(monitor.ApiKey))
276 StartMonitor(monitor);
282 protected override void OnViewLoaded(object view)
285 var window = (Window)view;
286 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
287 base.OnViewLoaded(view);
291 #region Status Properties
293 private string _statusMessage;
294 public string StatusMessage
296 get { return _statusMessage; }
299 _statusMessage = value;
300 NotifyOfPropertyChange(() => StatusMessage);
304 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
305 public ObservableConcurrentCollection<AccountInfo> Accounts
307 get { return _accounts; }
310 public bool HasAccounts
312 get { return _accounts.Count > 0; }
316 public string OpenFolderCaption
320 return (_accounts.Count == 0)
321 ? "No Accounts Defined"
322 : "Open Pithos Folder";
326 private string _pauseSyncCaption="Pause Synching";
327 public string PauseSyncCaption
329 get { return _pauseSyncCaption; }
332 _pauseSyncCaption = value;
333 NotifyOfPropertyChange(() => PauseSyncCaption);
337 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
338 public ObservableConcurrentCollection<FileEntry> RecentFiles
340 get { return _recentFiles; }
344 private string _statusIcon="../Images/Pithos.ico";
345 public string StatusIcon
347 get { return _statusIcon; }
350 //TODO: Ensure all status icons use the Pithos logo
352 NotifyOfPropertyChange(() => StatusIcon);
360 public void ShowPreferences()
362 ShowPreferences(null);
365 public void ShowPreferences(string currentTab)
368 var preferences = new PreferencesViewModel(_windowManager, _events, this, Settings,currentTab);
369 _windowManager.ShowDialog(preferences);
373 public void AboutPithos()
375 var about = new AboutViewModel();
376 _windowManager.ShowWindow(about);
379 public void SendFeedback()
381 var feedBack = IoC.Get<FeedbackViewModel>();
382 _windowManager.ShowWindow(feedBack);
385 //public PithosCommand OpenPithosFolderCommand { get; private set; }
387 public void OpenPithosFolder()
389 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
392 Process.Start(account.RootPath);
395 public void OpenPithosFolder(AccountInfo account)
397 Process.Start(account.AccountPath);
402 public void GoToSite()
404 var site = Properties.Settings.Default.PithosSite;
409 public void GoToSite(AccountInfo account)
411 /*var site = String.Format("{0}/ui/?token={1}&user={2}",
412 account.SiteUri,account.Token,
414 Process.Start(account.SiteUri);
417 public void ShowFileProperties()
419 var account = Settings.Accounts.First(acc => acc.IsActive);
420 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
421 var files=dir.GetFiles();
423 var idx=r.Next(0, files.Length);
424 ShowFileProperties(files[idx].FullName);
427 public void ShowFileProperties(string filePath)
429 if (String.IsNullOrWhiteSpace(filePath))
430 throw new ArgumentNullException("filePath");
431 if (!File.Exists(filePath) && !Directory.Exists(filePath))
432 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
433 Contract.EndContractBlock();
435 var pair=(from monitor in Monitors
436 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
437 select monitor).FirstOrDefault();
438 var accountMonitor = pair.Value;
440 if (accountMonitor == null)
443 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
447 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
448 _windowManager.ShowWindow(fileProperties);
451 public void ShowContainerProperties()
453 var account = Settings.Accounts.First(acc => acc.IsActive);
454 var dir = new DirectoryInfo(account.RootPath);
455 var fullName = (from folder in dir.EnumerateDirectories()
456 where (folder.Attributes & FileAttributes.Hidden) == 0
457 select folder.FullName).First();
458 ShowContainerProperties(fullName);
461 public void ShowContainerProperties(string filePath)
463 if (String.IsNullOrWhiteSpace(filePath))
464 throw new ArgumentNullException("filePath");
465 if (!Directory.Exists(filePath))
466 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
467 Contract.EndContractBlock();
469 var pair=(from monitor in Monitors
470 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
471 select monitor).FirstOrDefault();
472 var accountMonitor = pair.Value;
473 var info = accountMonitor.GetContainerInfo(filePath);
477 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
478 _windowManager.ShowWindow(containerProperties);
481 public void SynchNow()
483 _pollAgent.SynchNow();
486 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
488 if (currentInfo==null)
489 throw new ArgumentNullException("currentInfo");
490 Contract.EndContractBlock();
492 var monitor = Monitors[currentInfo.Account];
493 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
497 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
499 if (container == null)
500 throw new ArgumentNullException("container");
501 Contract.EndContractBlock();
503 var monitor = Monitors[container.Account];
504 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
509 public void ToggleSynching()
512 foreach (var pair in Monitors)
514 var monitor = pair.Value;
515 monitor.Pause = !monitor.Pause;
516 isPaused = monitor.Pause;
519 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
520 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
521 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
524 public void ExitPithos()
526 foreach (var pair in Monitors)
528 var monitor = pair.Value;
532 ((Window)GetView()).Close();
537 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
539 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
540 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
541 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
542 }.ToDictionary(s => s.Status);
544 readonly IWindowManager _windowManager;
548 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
550 public void UpdateStatus()
552 var pithosStatus = _statusChecker.GetPithosStatus();
554 if (_iconNames.ContainsKey(pithosStatus))
556 var info = _iconNames[pithosStatus];
557 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
561 StatusMessage = String.Format("Pithos {0}\r\n{1}", _fileVersion.Value.FileVersion,info.StatusText);
564 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
569 private Task StartMonitor(PithosMonitor monitor,int retries=0)
571 return Task.Factory.StartNew(() =>
573 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
577 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
581 catch (WebException exc)
583 if (AbandonRetry(monitor, retries))
586 HttpStatusCode statusCode =HttpStatusCode.OK;
587 var response = exc.Response as HttpWebResponse;
589 statusCode = response.StatusCode;
593 case HttpStatusCode.Unauthorized:
594 var message = String.Format("API Key Expired for {0}. Starting Renewal",
596 Log.Error(message, exc);
597 var account = Settings.Accounts.Find(acc => acc.AccountName == monitor.UserName);
598 account.IsExpired = true;
599 Notify(new ExpirationNotification(account));
600 //TryAuthorize(monitor.UserName, retries).Wait();
602 case HttpStatusCode.ProxyAuthenticationRequired:
603 TryAuthenticateProxy(monitor,retries);
606 TryLater(monitor, exc, retries);
610 catch (Exception exc)
612 if (AbandonRetry(monitor, retries))
615 TryLater(monitor,exc,retries);
621 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
623 Execute.OnUIThread(() =>
625 var proxyAccount = IoC.Get<ProxyAccountViewModel>();
626 proxyAccount.Settings = this.Settings;
627 if (true != _windowManager.ShowDialog(proxyAccount))
629 StartMonitor(monitor, retries);
630 NotifyOfPropertyChange(() => Accounts);
634 private bool AbandonRetry(PithosMonitor monitor, int retries)
638 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
640 _events.Publish(new Notification
641 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
649 private static bool IsUnauthorized(WebException exc)
652 throw new ArgumentNullException("exc");
653 Contract.EndContractBlock();
655 var response = exc.Response as HttpWebResponse;
656 if (response == null)
658 return (response.StatusCode == HttpStatusCode.Unauthorized);
661 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
663 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
664 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
665 _events.Publish(new Notification
666 {Title = "Error", Message = message, Level = TraceLevel.Error});
667 Log.Error(message, exc);
671 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
673 StatusMessage = status;
675 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
678 public void NotifyChangedFile(string filePath)
680 var entry = new FileEntry {FullPath=filePath};
681 IProducerConsumerCollection<FileEntry> files=RecentFiles;
683 while (files.Count > 5)
684 files.TryTake(out popped);
688 public void NotifyAccount(AccountInfo account)
692 //TODO: What happens to an existing account whose Token has changed?
693 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
694 account.SiteUri, Uri.EscapeDataString(account.Token),
695 Uri.EscapeDataString(account.UserName));
697 if (Accounts.All(item => item.UserName != account.UserName))
698 Accounts.TryAdd(account);
702 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
704 if (conflictFiles == null)
706 if (!conflictFiles.Any())
710 //TODO: Create a more specific message. For now, just show a warning
711 NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
715 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
722 StatusMessage = message;
724 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
727 public void Notify(Notification notification)
729 _events.Publish(notification);
733 public void RemoveMonitor(string accountName)
735 if (String.IsNullOrWhiteSpace(accountName))
738 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
739 _accounts.TryRemove(accountInfo);
741 PithosMonitor monitor;
742 if (Monitors.TryRemove(accountName, out monitor))
748 public void RefreshOverlays()
750 foreach (var pair in Monitors)
752 var monitor = pair.Value;
754 var path = monitor.RootPath;
756 if (String.IsNullOrWhiteSpace(path))
759 if (!Directory.Exists(path) && !File.Exists(path))
762 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
766 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
767 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
768 pathPointer, IntPtr.Zero);
772 Marshal.FreeHGlobal(pathPointer);
777 #region Event Handlers
779 public void Handle(SelectiveSynchChanges message)
781 var accountName = message.Account.AccountName;
782 PithosMonitor monitor;
783 if (_monitors.TryGetValue(accountName, out monitor))
785 monitor.SetSelectivePaths(message.Uris,message.Added,message.Removed);
792 private bool _pollStarted = false;
794 //SMELL: Doing so much work for notifications in the shell is wrong
795 //The notifications should be moved to their own view/viewmodel pair
796 //and different templates should be used for different message types
797 //This will also allow the addition of extra functionality, eg. actions
799 public void Handle(Notification notification)
803 if (!Settings.ShowDesktopNotifications)
806 if (notification is PollNotification)
811 if (notification is CloudNotification)
816 notification.Title = "Pithos";
817 notification.Message = "Start Synchronisation";
820 if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
824 switch (notification.Level)
826 case TraceLevel.Error:
827 icon = BalloonIcon.Error;
829 case TraceLevel.Info:
830 case TraceLevel.Verbose:
831 icon = BalloonIcon.Info;
833 case TraceLevel.Warning:
834 icon = BalloonIcon.Warning;
837 icon = BalloonIcon.None;
841 if (Settings.ShowDesktopNotifications)
843 var tv = (ShellView) GetView();
844 System.Action clickAction = null;
845 if (notification is ExpirationNotification)
847 clickAction = ()=>ShowPreferences("AccountTab");
849 var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon,ClickAction=clickAction};
850 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
851 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
856 public void Handle(ShowFilePropertiesEvent message)
859 throw new ArgumentNullException("message");
860 if (String.IsNullOrWhiteSpace(message.FileName) )
861 throw new ArgumentException("message");
862 Contract.EndContractBlock();
864 var fileName = message.FileName;
865 //TODO: Display file properties for non-container folders
866 if (File.Exists(fileName))
867 //Retrieve the full name with exact casing. Pithos names are case sensitive
868 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
869 else if (Directory.Exists(fileName))
870 //Retrieve the full name with exact casing. Pithos names are case sensitive
872 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
873 if (IsContainer(path))
874 ShowContainerProperties(path);
876 ShowFileProperties(path);
880 private bool IsContainer(string path)
882 var matchingFolders = from account in _accounts
883 from rootFolder in Directory.GetDirectories(account.AccountPath)
884 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
886 return matchingFolders.Any();
889 public FileStatus GetFileStatus(string localFileName)
891 if (String.IsNullOrWhiteSpace(localFileName))
892 throw new ArgumentNullException("localFileName");
893 Contract.EndContractBlock();
895 var statusKeeper = IoC.Get<IStatusKeeper>();
896 var status=statusKeeper.GetFileStatus(localFileName);