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");
83 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
86 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
88 [ImportingConstructor]
89 public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings)
94 _windowManager = windowManager;
95 //CHECK: Caliburn doesn't need explicit command construction
96 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
97 _statusChecker = statusChecker;
100 _events.Subscribe(this);
104 StatusMessage = "In Synch";
106 _accounts.CollectionChanged += (sender, e) =>
108 NotifyOfPropertyChange(() => OpenFolderCaption);
109 NotifyOfPropertyChange(() => HasAccounts);
113 catch (Exception exc)
115 Log.Error("Error while starting the ShellViewModel",exc);
121 protected override void OnActivate()
129 private async void StartMonitoring()
133 var accounts = Settings.Accounts.Select(MonitorAccount);
134 await TaskEx.WhenAll(accounts);
135 _statusService = StatusService.Start();
138 foreach (var account in Settings.Accounts)
140 await MonitorAccount(account);
145 catch (AggregateException exc)
149 Log.Error("Error while starting monitoring", e);
156 protected override void OnDeactivate(bool close)
158 base.OnDeactivate(close);
161 StatusService.Stop(_statusService);
162 _statusService = null;
166 public Task MonitorAccount(AccountSettings account)
168 return Task.Factory.StartNew(() =>
170 PithosMonitor monitor;
171 var accountName = account.AccountName;
173 if (_monitors.TryGetValue(accountName, out monitor))
175 //If the account is active
176 if (account.IsActive)
177 //Start the monitor. It's OK to start an already started monitor,
178 //it will just ignore the call
179 StartMonitor(monitor).Wait();
182 //If the account is inactive
183 //Stop and remove the monitor
184 RemoveMonitor(accountName);
189 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
190 monitor = new PithosMonitor
192 UserName = accountName,
193 ApiKey = account.ApiKey,
194 StatusNotification = this,
195 RootPath = account.RootPath
197 //PithosMonitor uses MEF so we need to resolve it
198 IoC.BuildUp(monitor);
200 monitor.AuthenticationUrl = account.ServerUrl;
202 _monitors[accountName] = monitor;
204 if (account.IsActive)
206 //Don't start a monitor if it doesn't have an account and ApiKey
207 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
208 String.IsNullOrWhiteSpace(monitor.ApiKey))
210 StartMonitor(monitor);
216 protected override void OnViewLoaded(object view)
219 var window = (Window)view;
220 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
221 base.OnViewLoaded(view);
225 #region Status Properties
227 private string _statusMessage;
228 public string StatusMessage
230 get { return _statusMessage; }
233 _statusMessage = value;
234 NotifyOfPropertyChange(() => StatusMessage);
238 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
239 public ObservableConcurrentCollection<AccountInfo> Accounts
241 get { return _accounts; }
244 public bool HasAccounts
246 get { return _accounts.Count > 0; }
250 public string OpenFolderCaption
254 return (_accounts.Count == 0)
255 ? "No Accounts Defined"
256 : "Open Pithos Folder";
260 private string _pauseSyncCaption="Pause Synching";
261 public string PauseSyncCaption
263 get { return _pauseSyncCaption; }
266 _pauseSyncCaption = value;
267 NotifyOfPropertyChange(() => PauseSyncCaption);
271 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
272 public ObservableConcurrentCollection<FileEntry> RecentFiles
274 get { return _recentFiles; }
278 private string _statusIcon="../Images/Pithos.ico";
279 public string StatusIcon
281 get { return _statusIcon; }
284 //TODO: Ensure all status icons use the Pithos logo
286 NotifyOfPropertyChange(() => StatusIcon);
294 public void ShowPreferences()
297 var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings);
298 _windowManager.ShowDialog(preferences);
302 public void AboutPithos()
304 var about = new AboutViewModel();
305 _windowManager.ShowWindow(about);
308 public void SendFeedback()
310 var feedBack = IoC.Get<FeedbackViewModel>();
311 _windowManager.ShowWindow(feedBack);
314 //public PithosCommand OpenPithosFolderCommand { get; private set; }
316 public void OpenPithosFolder()
318 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
321 Process.Start(account.RootPath);
324 public void OpenPithosFolder(AccountInfo account)
326 Process.Start(account.AccountPath);
331 public void GoToSite()
333 var site = Properties.Settings.Default.PithosSite;
338 public void GoToSite(AccountInfo account)
340 /*var site = String.Format("{0}/ui/?token={1}&user={2}",
341 account.SiteUri,account.Token,
343 Process.Start(account.SiteUri);
346 public void ShowFileProperties()
348 var account = Settings.Accounts.First(acc => acc.IsActive);
349 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
350 var files=dir.GetFiles();
352 var idx=r.Next(0, files.Length);
353 ShowFileProperties(files[idx].FullName);
356 public void ShowFileProperties(string filePath)
358 if (String.IsNullOrWhiteSpace(filePath))
359 throw new ArgumentNullException("filePath");
360 if (!File.Exists(filePath) && !Directory.Exists(filePath))
361 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
362 Contract.EndContractBlock();
364 var pair=(from monitor in Monitors
365 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
366 select monitor).FirstOrDefault();
367 var accountMonitor = pair.Value;
369 if (accountMonitor == null)
372 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
376 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
377 _windowManager.ShowWindow(fileProperties);
380 public void ShowContainerProperties()
382 var account = Settings.Accounts.First(acc => acc.IsActive);
383 var dir = new DirectoryInfo(account.RootPath);
384 var fullName = (from folder in dir.EnumerateDirectories()
385 where (folder.Attributes & FileAttributes.Hidden) == 0
386 select folder.FullName).First();
387 ShowContainerProperties(fullName);
390 public void ShowContainerProperties(string filePath)
392 if (String.IsNullOrWhiteSpace(filePath))
393 throw new ArgumentNullException("filePath");
394 if (!Directory.Exists(filePath))
395 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
396 Contract.EndContractBlock();
398 var pair=(from monitor in Monitors
399 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
400 select monitor).FirstOrDefault();
401 var accountMonitor = pair.Value;
402 var info = accountMonitor.GetContainerInfo(filePath);
406 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
407 _windowManager.ShowWindow(containerProperties);
410 public void SynchNow()
412 var agent = IoC.Get<NetworkAgent>();
416 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
418 if (currentInfo==null)
419 throw new ArgumentNullException("currentInfo");
420 Contract.EndContractBlock();
422 var monitor = Monitors[currentInfo.Account];
423 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
427 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
429 if (container == null)
430 throw new ArgumentNullException("container");
431 Contract.EndContractBlock();
433 var monitor = Monitors[container.Account];
434 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
439 public void ToggleSynching()
442 foreach (var pair in Monitors)
444 var monitor = pair.Value;
445 monitor.Pause = !monitor.Pause;
446 isPaused = monitor.Pause;
449 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
450 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
451 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
454 public void ExitPithos()
456 foreach (var pair in Monitors)
458 var monitor = pair.Value;
462 ((Window)GetView()).Close();
467 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
469 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
470 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
471 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
472 }.ToDictionary(s => s.Status);
474 readonly IWindowManager _windowManager;
478 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
480 public void UpdateStatus()
482 var pithosStatus = _statusChecker.GetPithosStatus();
484 if (_iconNames.ContainsKey(pithosStatus))
486 var info = _iconNames[pithosStatus];
487 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
489 Assembly assembly = Assembly.GetExecutingAssembly();
490 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
493 StatusMessage = String.Format("Pithos {0}\r\n{1}", fileVersion.FileVersion,info.StatusText);
496 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
501 private Task StartMonitor(PithosMonitor monitor,int retries=0)
503 return Task.Factory.StartNew(() =>
505 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
509 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
513 catch (WebException exc)
515 if (AbandonRetry(monitor, retries))
518 HttpStatusCode statusCode =HttpStatusCode.OK;
519 var response = exc.Response as HttpWebResponse;
521 statusCode = response.StatusCode;
525 case HttpStatusCode.Unauthorized:
526 var message = String.Format("API Key Expired for {0}. Starting Renewal",
528 Log.Error(message, exc);
529 TryAuthorize(monitor, retries).Wait();
531 case HttpStatusCode.ProxyAuthenticationRequired:
532 TryAuthenticateProxy(monitor,retries);
535 TryLater(monitor, exc, retries);
539 catch (Exception exc)
541 if (AbandonRetry(monitor, retries))
544 TryLater(monitor,exc,retries);
550 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
552 Execute.OnUIThread(() =>
554 var proxyAccount = new ProxyAccountViewModel(this.Settings);
555 if (true == _windowManager.ShowDialog(proxyAccount))
558 StartMonitor(monitor, retries);
559 NotifyOfPropertyChange(() => Accounts);
564 private bool AbandonRetry(PithosMonitor monitor, int retries)
568 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
570 _events.Publish(new Notification
571 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
578 private async Task TryAuthorize(PithosMonitor monitor,int retries)
580 _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 });
585 var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl);
587 var account = Settings.Accounts.First(act => act.AccountName == credentials.UserName);
588 account.ApiKey = credentials.Password;
589 monitor.ApiKey = credentials.Password;
591 await TaskEx.Delay(10000);
592 StartMonitor(monitor, retries + 1);
593 NotifyOfPropertyChange(()=>Accounts);
595 catch (AggregateException exc)
597 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
598 Log.Error(message, exc.InnerException);
599 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
601 catch (Exception exc)
603 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
604 Log.Error(message, exc);
605 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
610 private static bool IsUnauthorized(WebException exc)
613 throw new ArgumentNullException("exc");
614 Contract.EndContractBlock();
616 var response = exc.Response as HttpWebResponse;
617 if (response == null)
619 return (response.StatusCode == HttpStatusCode.Unauthorized);
622 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
624 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
625 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
626 _events.Publish(new Notification
627 {Title = "Error", Message = message, Level = TraceLevel.Error});
628 Log.Error(message, exc);
632 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
634 StatusMessage = status;
636 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
639 public void NotifyChangedFile(string filePath)
641 var entry = new FileEntry {FullPath=filePath};
642 IProducerConsumerCollection<FileEntry> files=RecentFiles;
644 while (files.Count > 5)
645 files.TryTake(out popped);
649 public void NotifyAccount(AccountInfo account)
653 //TODO: What happens to an existing account whose Token has changed?
654 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
655 account.SiteUri, Uri.EscapeDataString(account.Token),
656 Uri.EscapeDataString(account.UserName));
658 if (Accounts.All(item => item.UserName != account.UserName))
659 Accounts.TryAdd(account);
663 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
665 if (conflictFiles == null)
667 if (!conflictFiles.Any())
671 //TODO: Create a more specific message. For now, just show a warning
672 NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
676 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
683 StatusMessage = message;
685 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
688 public void Notify(Notification notification)
690 _events.Publish(notification);
694 public void RemoveMonitor(string accountName)
696 if (String.IsNullOrWhiteSpace(accountName))
699 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
700 _accounts.TryRemove(accountInfo);
702 PithosMonitor monitor;
703 if (Monitors.TryRemove(accountName, out monitor))
709 public void RefreshOverlays()
711 foreach (var pair in Monitors)
713 var monitor = pair.Value;
715 var path = monitor.RootPath;
717 if (String.IsNullOrWhiteSpace(path))
720 if (!Directory.Exists(path) && !File.Exists(path))
723 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
727 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
728 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
729 pathPointer, IntPtr.Zero);
733 Marshal.FreeHGlobal(pathPointer);
738 #region Event Handlers
740 public void Handle(SelectiveSynchChanges message)
742 var accountName = message.Account.AccountName;
743 PithosMonitor monitor;
744 if (_monitors.TryGetValue(accountName, out monitor))
746 monitor.AddSelectivePaths(message.Added);
747 monitor.RemoveSelectivePaths(message.Removed);
754 private bool _pollStarted = false;
756 //SMELL: Doing so much work for notifications in the shell is wrong
757 //The notifications should be moved to their own view/viewmodel pair
758 //and different templates should be used for different message types
759 //This will also allow the addition of extra functionality, eg. actions
761 public void Handle(Notification notification)
765 if (!Settings.ShowDesktopNotifications)
768 if (notification is PollNotification)
773 if (notification is CloudNotification)
778 notification.Title = "Pithos";
779 notification.Message = "Start Synchronisation";
783 switch (notification.Level)
785 case TraceLevel.Error:
786 icon = BalloonIcon.Error;
788 case TraceLevel.Info:
789 case TraceLevel.Verbose:
790 icon = BalloonIcon.Info;
792 case TraceLevel.Warning:
793 icon = BalloonIcon.Warning;
796 icon = BalloonIcon.None;
800 if (Settings.ShowDesktopNotifications)
802 var tv = (ShellView) GetView();
803 var balloon=new PithosBalloon{BalloonText=notification.Message};
804 tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
805 // tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
810 public void Handle(ShowFilePropertiesEvent message)
813 throw new ArgumentNullException("message");
814 if (String.IsNullOrWhiteSpace(message.FileName) )
815 throw new ArgumentException("message");
816 Contract.EndContractBlock();
818 var fileName = message.FileName;
819 //TODO: Display file properties for non-container folders
820 if (File.Exists(fileName))
821 //Retrieve the full name with exact casing. Pithos names are case sensitive
822 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
823 else if (Directory.Exists(fileName))
824 //Retrieve the full name with exact casing. Pithos names are case sensitive
826 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
827 if (IsContainer(path))
828 ShowContainerProperties(path);
830 ShowFileProperties(path);
834 private bool IsContainer(string path)
836 var matchingFolders = from account in _accounts
837 from rootFolder in Directory.GetDirectories(account.AccountPath)
838 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
840 return matchingFolders.Any();
843 public FileStatus GetFileStatus(string localFileName)
845 if (String.IsNullOrWhiteSpace(localFileName))
846 throw new ArgumentNullException("localFileName");
847 Contract.EndContractBlock();
849 var statusKeeper = IoC.Get<IStatusKeeper>();
850 var status=statusKeeper.GetFileStatus(localFileName);