1 using System.Collections.Concurrent;
2 using System.Diagnostics;
3 using System.Diagnostics.Contracts;
6 using System.Reflection;
7 using System.Runtime.InteropServices;
8 using System.ServiceModel;
9 using System.Threading.Tasks;
11 using System.Windows.Controls.Primitives;
13 using Hardcodet.Wpf.TaskbarNotification;
14 using Pithos.Client.WPF.Configuration;
15 using Pithos.Client.WPF.FileProperties;
16 using Pithos.Client.WPF.Preferences;
17 using Pithos.Client.WPF.SelectiveSynch;
18 using Pithos.Client.WPF.Services;
19 using Pithos.Client.WPF.Shell;
21 using Pithos.Core.Agents;
22 using Pithos.Interfaces;
24 using System.Collections.Generic;
27 using StatusService = Pithos.Client.WPF.Services.StatusService;
29 namespace Pithos.Client.WPF {
30 using System.ComponentModel.Composition;
34 /// The "shell" of the Pithos application displays the taskbar icon, menu and notifications.
35 /// The shell also hosts the status service called by shell extensions to retrieve file info
38 /// It is a strange "shell" as its main visible element is an icon instead of a window
39 /// The shell subscribes to the following events:
40 /// * Notification: Raised by components that want to notify the user. Usually displayed in a balloon
41 /// * 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
42 /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog
44 //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges?
45 [Export(typeof(IShell))]
46 public class ShellViewModel : Screen, IStatusNotification, IShell,
47 IHandle<Notification>, IHandle<SelectiveSynchChanges>, IHandle<ShowFilePropertiesEvent>
49 //The Status Checker provides the current synch state
50 //TODO: Could we remove the status checker and use events in its place?
51 private readonly IStatusChecker _statusChecker;
52 private readonly IEventAggregator _events;
54 public PithosSettings Settings { get; private set; }
57 private readonly ConcurrentDictionary<string, PithosMonitor> _monitors = new ConcurrentDictionary<string, PithosMonitor>();
59 /// Dictionary of account monitors, keyed by account
62 /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands,
63 /// retrieve account and boject info
65 // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design?
66 // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier
67 public ConcurrentDictionary<string, PithosMonitor> Monitors
69 get { return _monitors; }
74 /// The status service is used by Shell extensions to retrieve file status information
76 //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
77 private ServiceHost _statusService;
79 //Logging in the Pithos client is provided by log4net
80 private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos");
82 //Lazily initialized File Version info. This is done once and lazily to avoid blocking the UI
83 private Lazy<FileVersionInfo> _fileVersion;
86 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
89 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
91 [ImportingConstructor]
92 public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings)
97 _windowManager = windowManager;
98 //CHECK: Caliburn doesn't need explicit command construction
99 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
100 _statusChecker = statusChecker;
103 _events.Subscribe(this);
107 Proxy.SetFromSettings(settings);
109 StatusMessage = "In Synch";
111 _fileVersion= new Lazy<FileVersionInfo>(() =>
113 Assembly assembly = Assembly.GetExecutingAssembly();
114 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
117 _accounts.CollectionChanged += (sender, e) =>
119 NotifyOfPropertyChange(() => OpenFolderCaption);
120 NotifyOfPropertyChange(() => HasAccounts);
124 catch (Exception exc)
126 Log.Error("Error while starting the ShellViewModel",exc);
132 protected override void OnActivate()
143 private async void StartMonitoring()
147 var accounts = Settings.Accounts.Select(MonitorAccount);
148 await TaskEx.WhenAll(accounts);
149 _statusService = StatusService.Start();
152 foreach (var account in Settings.Accounts)
154 await MonitorAccount(account);
159 catch (AggregateException exc)
163 Log.Error("Error while starting monitoring", e);
170 protected override void OnDeactivate(bool close)
172 base.OnDeactivate(close);
175 StatusService.Stop(_statusService);
176 _statusService = null;
180 public Task MonitorAccount(AccountSettings account)
182 return Task.Factory.StartNew(() =>
184 PithosMonitor monitor;
185 var accountName = account.AccountName;
187 if (_monitors.TryGetValue(accountName, out monitor))
189 //If the account is active
190 if (account.IsActive)
191 //Start the monitor. It's OK to start an already started monitor,
192 //it will just ignore the call
193 StartMonitor(monitor).Wait();
196 //If the account is inactive
197 //Stop and remove the monitor
198 RemoveMonitor(accountName);
204 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
205 monitor = new PithosMonitor
207 UserName = accountName,
208 ApiKey = account.ApiKey,
209 StatusNotification = this,
210 RootPath = account.RootPath
212 //PithosMonitor uses MEF so we need to resolve it
213 IoC.BuildUp(monitor);
215 monitor.AuthenticationUrl = account.ServerUrl;
217 _monitors[accountName] = monitor;
219 if (account.IsActive)
221 //Don't start a monitor if it doesn't have an account and ApiKey
222 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
223 String.IsNullOrWhiteSpace(monitor.ApiKey))
225 StartMonitor(monitor);
231 protected override void OnViewLoaded(object view)
234 var window = (Window)view;
235 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
236 base.OnViewLoaded(view);
240 #region Status Properties
242 private string _statusMessage;
243 public string StatusMessage
245 get { return _statusMessage; }
248 _statusMessage = value;
249 NotifyOfPropertyChange(() => StatusMessage);
253 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
254 public ObservableConcurrentCollection<AccountInfo> Accounts
256 get { return _accounts; }
259 public bool HasAccounts
261 get { return _accounts.Count > 0; }
265 public string OpenFolderCaption
269 return (_accounts.Count == 0)
270 ? "No Accounts Defined"
271 : "Open Pithos Folder";
275 private string _pauseSyncCaption="Pause Synching";
276 public string PauseSyncCaption
278 get { return _pauseSyncCaption; }
281 _pauseSyncCaption = value;
282 NotifyOfPropertyChange(() => PauseSyncCaption);
286 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
287 public ObservableConcurrentCollection<FileEntry> RecentFiles
289 get { return _recentFiles; }
293 private string _statusIcon="../Images/Pithos.ico";
294 public string StatusIcon
296 get { return _statusIcon; }
299 //TODO: Ensure all status icons use the Pithos logo
301 NotifyOfPropertyChange(() => StatusIcon);
309 public void ShowPreferences()
312 var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings);
313 _windowManager.ShowDialog(preferences);
317 public void AboutPithos()
319 var about = new AboutViewModel();
320 _windowManager.ShowWindow(about);
323 public void SendFeedback()
325 var feedBack = IoC.Get<FeedbackViewModel>();
326 _windowManager.ShowWindow(feedBack);
329 //public PithosCommand OpenPithosFolderCommand { get; private set; }
331 public void OpenPithosFolder()
333 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
336 Process.Start(account.RootPath);
339 public void OpenPithosFolder(AccountInfo account)
341 Process.Start(account.AccountPath);
346 public void GoToSite()
348 var site = Properties.Settings.Default.PithosSite;
353 public void GoToSite(AccountInfo account)
355 /*var site = String.Format("{0}/ui/?token={1}&user={2}",
356 account.SiteUri,account.Token,
358 Process.Start(account.SiteUri);
361 public void ShowFileProperties()
363 var account = Settings.Accounts.First(acc => acc.IsActive);
364 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
365 var files=dir.GetFiles();
367 var idx=r.Next(0, files.Length);
368 ShowFileProperties(files[idx].FullName);
371 public void ShowFileProperties(string filePath)
373 if (String.IsNullOrWhiteSpace(filePath))
374 throw new ArgumentNullException("filePath");
375 if (!File.Exists(filePath) && !Directory.Exists(filePath))
376 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
377 Contract.EndContractBlock();
379 var pair=(from monitor in Monitors
380 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
381 select monitor).FirstOrDefault();
382 var accountMonitor = pair.Value;
384 if (accountMonitor == null)
387 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
391 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
392 _windowManager.ShowWindow(fileProperties);
395 public void ShowContainerProperties()
397 var account = Settings.Accounts.First(acc => acc.IsActive);
398 var dir = new DirectoryInfo(account.RootPath);
399 var fullName = (from folder in dir.EnumerateDirectories()
400 where (folder.Attributes & FileAttributes.Hidden) == 0
401 select folder.FullName).First();
402 ShowContainerProperties(fullName);
405 public void ShowContainerProperties(string filePath)
407 if (String.IsNullOrWhiteSpace(filePath))
408 throw new ArgumentNullException("filePath");
409 if (!Directory.Exists(filePath))
410 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
411 Contract.EndContractBlock();
413 var pair=(from monitor in Monitors
414 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
415 select monitor).FirstOrDefault();
416 var accountMonitor = pair.Value;
417 var info = accountMonitor.GetContainerInfo(filePath);
421 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
422 _windowManager.ShowWindow(containerProperties);
425 public void SynchNow()
427 var agent = IoC.Get<NetworkAgent>();
431 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
433 if (currentInfo==null)
434 throw new ArgumentNullException("currentInfo");
435 Contract.EndContractBlock();
437 var monitor = Monitors[currentInfo.Account];
438 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
442 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
444 if (container == null)
445 throw new ArgumentNullException("container");
446 Contract.EndContractBlock();
448 var monitor = Monitors[container.Account];
449 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
454 public void ToggleSynching()
457 foreach (var pair in Monitors)
459 var monitor = pair.Value;
460 monitor.Pause = !monitor.Pause;
461 isPaused = monitor.Pause;
464 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
465 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
466 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
469 public void ExitPithos()
471 foreach (var pair in Monitors)
473 var monitor = pair.Value;
477 ((Window)GetView()).Close();
482 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
484 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
485 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
486 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
487 }.ToDictionary(s => s.Status);
489 readonly IWindowManager _windowManager;
493 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
495 public void UpdateStatus()
497 var pithosStatus = _statusChecker.GetPithosStatus();
499 if (_iconNames.ContainsKey(pithosStatus))
501 var info = _iconNames[pithosStatus];
502 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
506 StatusMessage = String.Format("Pithos {0}\r\n{1}", _fileVersion.Value.FileVersion,info.StatusText);
509 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
514 private Task StartMonitor(PithosMonitor monitor,int retries=0)
516 return Task.Factory.StartNew(() =>
518 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
522 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
526 catch (WebException exc)
528 if (AbandonRetry(monitor, retries))
531 HttpStatusCode statusCode =HttpStatusCode.OK;
532 var response = exc.Response as HttpWebResponse;
534 statusCode = response.StatusCode;
538 case HttpStatusCode.Unauthorized:
539 var message = String.Format("API Key Expired for {0}. Starting Renewal",
541 Log.Error(message, exc);
542 TryAuthorize(monitor, retries).Wait();
544 case HttpStatusCode.ProxyAuthenticationRequired:
545 TryAuthenticateProxy(monitor,retries);
548 TryLater(monitor, exc, retries);
552 catch (Exception exc)
554 if (AbandonRetry(monitor, retries))
557 TryLater(monitor,exc,retries);
563 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
565 Execute.OnUIThread(() =>
567 var proxyAccount = IoC.Get<ProxyAccountViewModel>();
568 proxyAccount.Settings = this.Settings;
569 if (true != _windowManager.ShowDialog(proxyAccount))
571 StartMonitor(monitor, retries);
572 NotifyOfPropertyChange(() => Accounts);
576 private bool AbandonRetry(PithosMonitor monitor, int retries)
580 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
582 _events.Publish(new Notification
583 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
590 private async Task TryAuthorize(PithosMonitor monitor,int retries)
592 _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 });
597 var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl);
599 var account = Settings.Accounts.First(act => act.AccountName == credentials.UserName);
600 account.ApiKey = credentials.Password;
601 monitor.ApiKey = credentials.Password;
603 await TaskEx.Delay(10000);
604 StartMonitor(monitor, retries + 1);
605 NotifyOfPropertyChange(()=>Accounts);
607 catch (AggregateException exc)
609 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
610 Log.Error(message, exc.InnerException);
611 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
613 catch (Exception exc)
615 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
616 Log.Error(message, exc);
617 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
622 private static bool IsUnauthorized(WebException exc)
625 throw new ArgumentNullException("exc");
626 Contract.EndContractBlock();
628 var response = exc.Response as HttpWebResponse;
629 if (response == null)
631 return (response.StatusCode == HttpStatusCode.Unauthorized);
634 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
636 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
637 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
638 _events.Publish(new Notification
639 {Title = "Error", Message = message, Level = TraceLevel.Error});
640 Log.Error(message, exc);
644 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
646 StatusMessage = status;
648 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
651 public void NotifyChangedFile(string filePath)
653 var entry = new FileEntry {FullPath=filePath};
654 IProducerConsumerCollection<FileEntry> files=RecentFiles;
656 while (files.Count > 5)
657 files.TryTake(out popped);
661 public void NotifyAccount(AccountInfo account)
665 //TODO: What happens to an existing account whose Token has changed?
666 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
667 account.SiteUri, Uri.EscapeDataString(account.Token),
668 Uri.EscapeDataString(account.UserName));
670 if (Accounts.All(item => item.UserName != account.UserName))
671 Accounts.TryAdd(account);
675 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
677 if (conflictFiles == null)
679 if (!conflictFiles.Any())
683 //TODO: Create a more specific message. For now, just show a warning
684 NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
688 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
695 StatusMessage = message;
697 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
700 public void Notify(Notification notification)
702 _events.Publish(notification);
706 public void RemoveMonitor(string accountName)
708 if (String.IsNullOrWhiteSpace(accountName))
711 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
712 _accounts.TryRemove(accountInfo);
714 PithosMonitor monitor;
715 if (Monitors.TryRemove(accountName, out monitor))
721 public void RefreshOverlays()
723 foreach (var pair in Monitors)
725 var monitor = pair.Value;
727 var path = monitor.RootPath;
729 if (String.IsNullOrWhiteSpace(path))
732 if (!Directory.Exists(path) && !File.Exists(path))
735 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
739 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
740 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
741 pathPointer, IntPtr.Zero);
745 Marshal.FreeHGlobal(pathPointer);
750 #region Event Handlers
752 public void Handle(SelectiveSynchChanges message)
754 var accountName = message.Account.AccountName;
755 PithosMonitor monitor;
756 if (_monitors.TryGetValue(accountName, out monitor))
758 monitor.AddSelectivePaths(message.Added);
759 monitor.RemoveSelectivePaths(message.Removed);
766 private bool _pollStarted = false;
768 //SMELL: Doing so much work for notifications in the shell is wrong
769 //The notifications should be moved to their own view/viewmodel pair
770 //and different templates should be used for different message types
771 //This will also allow the addition of extra functionality, eg. actions
773 public void Handle(Notification notification)
777 if (!Settings.ShowDesktopNotifications)
780 if (notification is PollNotification)
785 if (notification is CloudNotification)
790 notification.Title = "Pithos";
791 notification.Message = "Start Synchronisation";
794 if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
798 switch (notification.Level)
800 case TraceLevel.Error:
801 icon = BalloonIcon.Error;
803 case TraceLevel.Info:
804 case TraceLevel.Verbose:
805 icon = BalloonIcon.Info;
807 case TraceLevel.Warning:
808 icon = BalloonIcon.Warning;
811 icon = BalloonIcon.None;
815 if (Settings.ShowDesktopNotifications)
817 var tv = (ShellView) GetView();
818 var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon};
819 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
820 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
825 public void Handle(ShowFilePropertiesEvent message)
828 throw new ArgumentNullException("message");
829 if (String.IsNullOrWhiteSpace(message.FileName) )
830 throw new ArgumentException("message");
831 Contract.EndContractBlock();
833 var fileName = message.FileName;
834 //TODO: Display file properties for non-container folders
835 if (File.Exists(fileName))
836 //Retrieve the full name with exact casing. Pithos names are case sensitive
837 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
838 else if (Directory.Exists(fileName))
839 //Retrieve the full name with exact casing. Pithos names are case sensitive
841 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
842 if (IsContainer(path))
843 ShowContainerProperties(path);
845 ShowFileProperties(path);
849 private bool IsContainer(string path)
851 var matchingFolders = from account in _accounts
852 from rootFolder in Directory.GetDirectories(account.AccountPath)
853 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
855 return matchingFolders.Any();
858 public FileStatus GetFileStatus(string localFileName)
860 if (String.IsNullOrWhiteSpace(localFileName))
861 throw new ArgumentNullException("localFileName");
862 Contract.EndContractBlock();
864 var statusKeeper = IoC.Get<IStatusKeeper>();
865 var status=statusKeeper.GetFileStatus(localFileName);