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;
12 using Hardcodet.Wpf.TaskbarNotification;
13 using Pithos.Client.WPF.Configuration;
14 using Pithos.Client.WPF.FileProperties;
15 using Pithos.Client.WPF.Preferences;
16 using Pithos.Client.WPF.SelectiveSynch;
17 using Pithos.Client.WPF.Services;
18 using Pithos.Client.WPF.Shell;
20 using Pithos.Core.Agents;
21 using Pithos.Interfaces;
23 using System.Collections.Generic;
26 using StatusService = Pithos.Client.WPF.Services.StatusService;
28 namespace Pithos.Client.WPF {
29 using System.ComponentModel.Composition;
33 /// The "shell" of the Pithos application displays the taskbar icon, menu and notifications.
34 /// The shell also hosts the status service called by shell extensions to retrieve file info
37 /// It is a strange "shell" as its main visible element is an icon instead of a window
38 /// The shell subscribes to the following events:
39 /// * Notification: Raised by components that want to notify the user. Usually displayed in a balloon
40 /// * 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
41 /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog
43 //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges?
44 [Export(typeof(IShell))]
45 public class ShellViewModel : Screen, IStatusNotification, IShell,
46 IHandle<Notification>, IHandle<SelectiveSynchChanges>, IHandle<ShowFilePropertiesEvent>
48 //The Status Checker provides the current synch state
49 //TODO: Could we remove the status checker and use events in its place?
50 private readonly IStatusChecker _statusChecker;
51 private readonly IEventAggregator _events;
53 public PithosSettings Settings { get; private set; }
56 private readonly ConcurrentDictionary<string, PithosMonitor> _monitors = new ConcurrentDictionary<string, PithosMonitor>();
58 /// Dictionary of account monitors, keyed by account
61 /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands,
62 /// retrieve account and boject info
64 // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design?
65 // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier
66 public ConcurrentDictionary<string, PithosMonitor> Monitors
68 get { return _monitors; }
73 /// The status service is used by Shell extensions to retrieve file status information
75 //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
76 private ServiceHost _statusService;
78 //Logging in the Pithos client is provided by log4net
79 private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos");
82 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
85 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
87 [ImportingConstructor]
88 public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings)
93 _windowManager = windowManager;
94 //CHECK: Caliburn doesn't need explicit command construction
95 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
96 _statusChecker = statusChecker;
99 _events.Subscribe(this);
103 StatusMessage = "In Synch";
105 _accounts.CollectionChanged += (sender, e) =>
107 NotifyOfPropertyChange(() => OpenFolderCaption);
108 NotifyOfPropertyChange(() => HasAccounts);
112 catch (Exception exc)
114 Log.Error("Error while starting the ShellViewModel",exc);
120 protected override void OnActivate()
128 private async void StartMonitoring()
132 var accounts = Settings.Accounts.Select(MonitorAccount);
133 await TaskEx.WhenAll(accounts);
134 _statusService = StatusService.Start();
137 foreach (var account in Settings.Accounts)
139 await MonitorAccount(account);
144 catch (AggregateException exc)
148 Log.Error("Error while starting monitoring", e);
155 protected override void OnDeactivate(bool close)
157 base.OnDeactivate(close);
160 StatusService.Stop(_statusService);
161 _statusService = null;
165 public Task MonitorAccount(AccountSettings account)
167 return Task.Factory.StartNew(() =>
169 PithosMonitor monitor;
170 var accountName = account.AccountName;
172 if (_monitors.TryGetValue(accountName, out monitor))
174 //If the account is active
175 if (account.IsActive)
176 //Start the monitor. It's OK to start an already started monitor,
177 //it will just ignore the call
178 StartMonitor(monitor).Wait();
181 //If the account is inactive
182 //Stop and remove the monitor
183 RemoveMonitor(accountName);
188 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
189 monitor = new PithosMonitor
191 UserName = accountName,
192 ApiKey = account.ApiKey,
193 StatusNotification = this,
194 RootPath = account.RootPath
196 //PithosMonitor uses MEF so we need to resolve it
197 IoC.BuildUp(monitor);
199 monitor.AuthenticationUrl = account.ServerUrl;
201 _monitors[accountName] = monitor;
203 if (account.IsActive)
205 //Don't start a monitor if it doesn't have an account and ApiKey
206 if (String.IsNullOrWhiteSpace(monitor.UserName) ||
207 String.IsNullOrWhiteSpace(monitor.ApiKey))
209 StartMonitor(monitor);
215 protected override void OnViewLoaded(object view)
218 var window = (Window)view;
219 TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
220 base.OnViewLoaded(view);
224 #region Status Properties
226 private string _statusMessage;
227 public string StatusMessage
229 get { return _statusMessage; }
232 _statusMessage = value;
233 NotifyOfPropertyChange(() => StatusMessage);
237 private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
238 public ObservableConcurrentCollection<AccountInfo> Accounts
240 get { return _accounts; }
243 public bool HasAccounts
245 get { return _accounts.Count > 0; }
249 public string OpenFolderCaption
253 return (_accounts.Count == 0)
254 ? "No Accounts Defined"
255 : "Open Pithos Folder";
259 private string _pauseSyncCaption="Pause Synching";
260 public string PauseSyncCaption
262 get { return _pauseSyncCaption; }
265 _pauseSyncCaption = value;
266 NotifyOfPropertyChange(() => PauseSyncCaption);
270 private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
271 public ObservableConcurrentCollection<FileEntry> RecentFiles
273 get { return _recentFiles; }
277 private string _statusIcon="../Images/Pithos.ico";
278 public string StatusIcon
280 get { return _statusIcon; }
283 //TODO: Ensure all status icons use the Pithos logo
285 NotifyOfPropertyChange(() => StatusIcon);
293 public void ShowPreferences()
296 var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings);
297 _windowManager.ShowDialog(preferences);
301 public void AboutPithos()
303 var about = new AboutViewModel();
304 _windowManager.ShowWindow(about);
307 public void SendFeedback()
309 var feedBack = IoC.Get<FeedbackViewModel>();
310 _windowManager.ShowWindow(feedBack);
313 //public PithosCommand OpenPithosFolderCommand { get; private set; }
315 public void OpenPithosFolder()
317 var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
320 Process.Start(account.RootPath);
323 public void OpenPithosFolder(AccountInfo account)
325 Process.Start(account.AccountPath);
330 public void GoToSite()
332 var site = Properties.Settings.Default.PithosSite;
337 public void GoToSite(AccountInfo account)
339 /*var site = String.Format("{0}/ui/?token={1}&user={2}",
340 account.SiteUri,account.Token,
342 Process.Start(account.SiteUri);
345 public void ShowFileProperties()
347 var account = Settings.Accounts.First(acc => acc.IsActive);
348 var dir = new DirectoryInfo(account.RootPath + @"\pithos");
349 var files=dir.GetFiles();
351 var idx=r.Next(0, files.Length);
352 ShowFileProperties(files[idx].FullName);
355 public void ShowFileProperties(string filePath)
357 if (String.IsNullOrWhiteSpace(filePath))
358 throw new ArgumentNullException("filePath");
359 if (!File.Exists(filePath) && !Directory.Exists(filePath))
360 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
361 Contract.EndContractBlock();
363 var pair=(from monitor in Monitors
364 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
365 select monitor).FirstOrDefault();
366 var accountMonitor = pair.Value;
368 if (accountMonitor == null)
371 var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
375 var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
376 _windowManager.ShowWindow(fileProperties);
379 public void ShowContainerProperties()
381 var account = Settings.Accounts.First(acc => acc.IsActive);
382 var dir = new DirectoryInfo(account.RootPath);
383 var fullName = (from folder in dir.EnumerateDirectories()
384 where (folder.Attributes & FileAttributes.Hidden) == 0
385 select folder.FullName).First();
386 ShowContainerProperties(fullName);
389 public void ShowContainerProperties(string filePath)
391 if (String.IsNullOrWhiteSpace(filePath))
392 throw new ArgumentNullException("filePath");
393 if (!Directory.Exists(filePath))
394 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
395 Contract.EndContractBlock();
397 var pair=(from monitor in Monitors
398 where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
399 select monitor).FirstOrDefault();
400 var accountMonitor = pair.Value;
401 var info = accountMonitor.GetContainerInfo(filePath);
405 var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
406 _windowManager.ShowWindow(containerProperties);
409 public void SynchNow()
411 var agent = IoC.Get<NetworkAgent>();
415 public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
417 if (currentInfo==null)
418 throw new ArgumentNullException("currentInfo");
419 Contract.EndContractBlock();
421 var monitor = Monitors[currentInfo.Account];
422 var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
426 public ContainerInfo RefreshContainerInfo(ContainerInfo container)
428 if (container == null)
429 throw new ArgumentNullException("container");
430 Contract.EndContractBlock();
432 var monitor = Monitors[container.Account];
433 var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
438 public void ToggleSynching()
441 foreach (var pair in Monitors)
443 var monitor = pair.Value;
444 monitor.Pause = !monitor.Pause;
445 isPaused = monitor.Pause;
448 PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
449 var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
450 StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
453 public void ExitPithos()
455 foreach (var pair in Monitors)
457 var monitor = pair.Value;
461 ((Window)GetView()).Close();
466 private readonly Dictionary<PithosStatus, StatusInfo> _iconNames = new List<StatusInfo>
468 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
469 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
470 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
471 }.ToDictionary(s => s.Status);
473 readonly IWindowManager _windowManager;
477 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat
479 public void UpdateStatus()
481 var pithosStatus = _statusChecker.GetPithosStatus();
483 if (_iconNames.ContainsKey(pithosStatus))
485 var info = _iconNames[pithosStatus];
486 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
488 Assembly assembly = Assembly.GetExecutingAssembly();
489 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
492 StatusMessage = String.Format("Pithos {0}\r\n{1}", fileVersion.FileVersion,info.StatusText);
495 //_events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
500 private Task StartMonitor(PithosMonitor monitor,int retries=0)
502 return Task.Factory.StartNew(() =>
504 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
508 Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
512 catch (WebException exc)
514 if (AbandonRetry(monitor, retries))
517 HttpStatusCode statusCode =HttpStatusCode.OK;
518 var response = exc.Response as HttpWebResponse;
520 statusCode = response.StatusCode;
524 case HttpStatusCode.Unauthorized:
525 var message = String.Format("API Key Expired for {0}. Starting Renewal",
527 Log.Error(message, exc);
528 TryAuthorize(monitor, retries).Wait();
530 case HttpStatusCode.ProxyAuthenticationRequired:
531 TryAuthenticateProxy(monitor,retries);
534 TryLater(monitor, exc, retries);
538 catch (Exception exc)
540 if (AbandonRetry(monitor, retries))
543 TryLater(monitor,exc,retries);
549 private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
551 Execute.OnUIThread(() =>
553 var proxyAccount = new ProxyAccountViewModel(this.Settings);
554 if (true == _windowManager.ShowDialog(proxyAccount))
557 StartMonitor(monitor, retries);
558 NotifyOfPropertyChange(() => Accounts);
563 private bool AbandonRetry(PithosMonitor monitor, int retries)
567 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
569 _events.Publish(new Notification
570 {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
577 private async Task TryAuthorize(PithosMonitor monitor,int retries)
579 _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 });
584 var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl);
586 var account = Settings.Accounts.First(act => act.AccountName == credentials.UserName);
587 account.ApiKey = credentials.Password;
588 monitor.ApiKey = credentials.Password;
590 await TaskEx.Delay(10000);
591 StartMonitor(monitor, retries + 1);
592 NotifyOfPropertyChange(()=>Accounts);
594 catch (AggregateException exc)
596 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
597 Log.Error(message, exc.InnerException);
598 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
600 catch (Exception exc)
602 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
603 Log.Error(message, exc);
604 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
609 private static bool IsUnauthorized(WebException exc)
612 throw new ArgumentNullException("exc");
613 Contract.EndContractBlock();
615 var response = exc.Response as HttpWebResponse;
616 if (response == null)
618 return (response.StatusCode == HttpStatusCode.Unauthorized);
621 private void TryLater(PithosMonitor monitor, Exception exc,int retries)
623 var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
624 Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
625 _events.Publish(new Notification
626 {Title = "Error", Message = message, Level = TraceLevel.Error});
627 Log.Error(message, exc);
631 public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
633 StatusMessage = status;
635 _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
638 public void NotifyChangedFile(string filePath)
640 var entry = new FileEntry {FullPath=filePath};
641 IProducerConsumerCollection<FileEntry> files=RecentFiles;
643 while (files.Count > 5)
644 files.TryTake(out popped);
648 public void NotifyAccount(AccountInfo account)
652 //TODO: What happens to an existing account whose Token has changed?
653 account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
654 account.SiteUri, Uri.EscapeDataString(account.Token),
655 Uri.EscapeDataString(account.UserName));
657 if (Accounts.All(item => item.UserName != account.UserName))
658 Accounts.TryAdd(account);
662 public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
664 if (conflictFiles == null)
666 if (!conflictFiles.Any())
670 //TODO: Create a more specific message. For now, just show a warning
671 NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
675 public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
682 StatusMessage = message;
684 _events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
687 public void Notify(Notification notification)
689 _events.Publish(notification);
693 public void RemoveMonitor(string accountName)
695 if (String.IsNullOrWhiteSpace(accountName))
698 var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
699 _accounts.TryRemove(accountInfo);
701 PithosMonitor monitor;
702 if (Monitors.TryRemove(accountName, out monitor))
708 public void RefreshOverlays()
710 foreach (var pair in Monitors)
712 var monitor = pair.Value;
714 var path = monitor.RootPath;
716 if (String.IsNullOrWhiteSpace(path))
719 if (!Directory.Exists(path) && !File.Exists(path))
722 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
726 NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
727 HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
728 pathPointer, IntPtr.Zero);
732 Marshal.FreeHGlobal(pathPointer);
737 #region Event Handlers
739 public void Handle(SelectiveSynchChanges message)
741 var accountName = message.Account.AccountName;
742 PithosMonitor monitor;
743 if (_monitors.TryGetValue(accountName, out monitor))
745 monitor.AddSelectivePaths(message.Added);
746 monitor.RemoveSelectivePaths(message.Removed);
753 private bool _pollStarted = false;
755 //SMELL: Doing so much work for notifications in the shell is wrong
756 //The notifications should be moved to their own view/viewmodel pair
757 //and different templates should be used for different message types
758 //This will also allow the addition of extra functionality, eg. actions
760 public void Handle(Notification notification)
764 if (!Settings.ShowDesktopNotifications)
767 if (notification is PollNotification)
772 if (notification is CloudNotification)
777 notification.Title = "Pithos";
778 notification.Message = "Start Synchronisation";
782 switch (notification.Level)
784 case TraceLevel.Error:
785 icon = BalloonIcon.Error;
787 case TraceLevel.Info:
788 case TraceLevel.Verbose:
789 icon = BalloonIcon.Info;
791 case TraceLevel.Warning:
792 icon = BalloonIcon.Warning;
795 icon = BalloonIcon.None;
799 if (Settings.ShowDesktopNotifications)
801 var tv = (ShellView) GetView();
802 tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
807 public void Handle(ShowFilePropertiesEvent message)
810 throw new ArgumentNullException("message");
811 if (String.IsNullOrWhiteSpace(message.FileName) )
812 throw new ArgumentException("message");
813 Contract.EndContractBlock();
815 var fileName = message.FileName;
816 //TODO: Display file properties for non-container folders
817 if (File.Exists(fileName))
818 //Retrieve the full name with exact casing. Pithos names are case sensitive
819 ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
820 else if (Directory.Exists(fileName))
821 //Retrieve the full name with exact casing. Pithos names are case sensitive
823 var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
824 if (IsContainer(path))
825 ShowContainerProperties(path);
827 ShowFileProperties(path);
831 private bool IsContainer(string path)
833 var matchingFolders = from account in _accounts
834 from rootFolder in Directory.GetDirectories(account.AccountPath)
835 where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
837 return matchingFolders.Any();
840 public FileStatus GetFileStatus(string localFileName)
842 if (String.IsNullOrWhiteSpace(localFileName))
843 throw new ArgumentNullException("localFileName");
844 Contract.EndContractBlock();
846 var statusKeeper = IoC.Get<IStatusKeeper>();
847 var status=statusKeeper.GetFileStatus(localFileName);