Updated wizard and AccountInfo to include the server's URL. Added account validation...
[pithos-ms-client] / trunk / Pithos.Client.WPF / Shell / ShellViewModel.cs
1 using System.Collections.Concurrent;
2 using System.ComponentModel;
3 using System.ComponentModel.Composition;
4 using System.Diagnostics;
5 using System.Diagnostics.Contracts;
6 using System.IO;
7 using System.Net;
8 using System.Reflection;
9 using System.Runtime.InteropServices;
10 using System.ServiceModel;
11 using System.ServiceModel.Description;
12 using System.Threading.Tasks;
13 using System.Windows;
14 using Caliburn.Micro;
15 using Hardcodet.Wpf.TaskbarNotification;
16 using Pithos.Client.WPF.Configuration;
17 using Pithos.Client.WPF.FileProperties;
18 using Pithos.Client.WPF.Properties;
19 using Pithos.Client.WPF.SelectiveSynch;
20 using Pithos.Client.WPF.Services;
21 using Pithos.Client.WPF.Shell;
22 using Pithos.Core;
23 using Pithos.Interfaces;
24 using System;
25 using System.Collections.Generic;
26 using System.Linq;
27 using System.Text;
28 using Pithos.Network;
29 using StatusService = Pithos.Client.WPF.Services.StatusService;
30
31 namespace Pithos.Client.WPF {
32     using System.ComponentModel.Composition;
33
34     
35         ///<summary>
36         /// The "shell" of the Pithos application displays the taskbar  icon, menu and notifications.
37         /// The shell also hosts the status service called by shell extensions to retrieve file info
38         ///</summary>
39         ///<remarks>
40         /// It is a strange "shell" as its main visible element is an icon instead of a window
41         /// The shell subscribes to the following events:
42         /// * Notification:  Raised by components that want to notify the user. Usually displayed in a balloon
43         /// * 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
44         /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog
45         ///</remarks>           
46         //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges?
47     [Export(typeof(IShell))]
48     public class ShellViewModel : Screen, IStatusNotification, IShell,
49         IHandle<Notification>, IHandle<SelectiveSynchChanges>, IHandle<ShowFilePropertiesEvent>
50     {
51                 //The Status Checker provides the current synch state
52                 //TODO: Could we remove the status checker and use events in its place?
53         private IStatusChecker _statusChecker;
54         private IEventAggregator _events;
55
56         public PithosSettings Settings { get; private set; }        
57
58                 
59         private Dictionary<string, PithosMonitor> _monitors = new Dictionary<string, PithosMonitor>();
60                 ///<summary>
61                 /// Dictionary of account monitors, keyed by account
62                 ///</summary>
63                 ///<remarks>
64                 /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands,
65                 /// retrieve account and boject info            
66                 ///</remarks>
67                 // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design?
68                 // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier
69         public Dictionary<string, PithosMonitor> Monitors
70         {
71             get { return _monitors; }
72         }
73
74
75                 ///<summary>
76                 /// The status service is used by Shell extensions to retrieve file status information
77                 ///</summary>
78                 //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
79         private ServiceHost _statusService { get; set; }
80
81                 //Logging in the Pithos client is provided by log4net
82         private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos");
83
84                 ///<summary>
85                 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
86                 ///</summary>
87                 ///<remarks>
88                 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
89                 ///</remarks>
90         [ImportingConstructor]          
91         public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings)
92         {
93             try
94             {
95
96                 _windowManager = windowManager;
97                                 //CHECK: Caliburn doesn't need explicit command construction
98                 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
99                 _statusChecker = statusChecker;
100                                 //The event subst
101                 _events = events;
102                 _events.Subscribe(this);
103
104                 Settings = settings;
105
106                 StatusMessage = "In Synch";
107
108                 _accounts.CollectionChanged += (sender, e) =>
109                                                    {
110                                                        NotifyOfPropertyChange(() => OpenFolderCaption);
111                                                        NotifyOfPropertyChange(() => HasAccounts);
112                                                    };
113
114             }
115             catch (Exception exc)
116             {
117                 Log.Error("Error while starting the ShellViewModel",exc);
118                 throw;
119             }
120         }
121
122
123         protected override void OnActivate()
124         {
125             base.OnActivate();
126
127             StartMonitoring();                    
128         }
129
130
131         private async Task StartMonitoring()
132         {
133             try
134             {
135                 if (Settings.Accounts == null)
136                 {
137                     Settings.Accounts=new AccountsCollection();
138                     Settings.Save();
139                     return;
140                 }
141                   
142                 foreach (var account in Settings.Accounts)
143                 {
144                     await MonitorAccount(account);
145                 }
146                 _statusService = StatusService.Start();
147             }
148             catch (AggregateException exc)
149             {
150                 exc.Handle(e =>
151                 {
152                     Log.Error("Error while starting monitoring", e);
153                     return true;
154                 });
155                 throw;
156             }
157         }
158
159         protected override void OnDeactivate(bool close)
160         {
161             base.OnDeactivate(close);
162             if (close)
163             {
164                 StatusService.Stop(_statusService);
165                 _statusService = null;
166             }
167         }
168
169         public Task MonitorAccount(AccountSettings account)
170         {
171             return Task.Factory.StartNew(() =>
172             {                                                
173                 PithosMonitor monitor = null;
174                 var accountName = account.AccountName;
175
176                 if (_monitors.TryGetValue(accountName, out monitor))
177                 {
178                     //If the account is active
179                     if (account.IsActive)
180                         //Start the monitor. It's OK to start an already started monitor,
181                         //it will just ignore the call                        
182                         StartMonitor(monitor).Wait();                        
183                     else
184                     {
185                         //If the account is inactive
186                         //Stop and remove the monitor
187                         RemoveMonitor(accountName);
188                     }
189                     return;
190                 }
191
192                 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
193                 monitor = new PithosMonitor
194                               {
195                                   UserName = accountName,
196                                   ApiKey = account.ApiKey,
197                                   UsePithos = account.UsePithos,
198                                   StatusNotification = this,
199                                   RootPath = account.RootPath
200                               };
201                 //PithosMonitor uses MEF so we need to resolve it
202                 IoC.BuildUp(monitor);
203
204                 var appSettings = Properties.Settings.Default;
205                 monitor.AuthenticationUrl = account.UsePithos
206                                                 ? account.ServerUrl.ToString()
207                                                 : appSettings.CloudfilesAuthenticationUrl;
208
209                 _monitors[accountName] = monitor;
210
211                 if (account.IsActive)
212                 {
213                     //Don't start a monitor if it doesn't have an account and ApiKey
214                     if (String.IsNullOrWhiteSpace(monitor.UserName) ||
215                         String.IsNullOrWhiteSpace(monitor.ApiKey))
216                         return;
217                     StartMonitor(monitor);
218                 }
219             });
220         }
221
222
223         protected override void OnViewLoaded(object view)
224         {
225             UpdateStatus();
226             var window = (Window)view;            
227             TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
228             base.OnViewLoaded(view);
229         }
230
231
232         #region Status Properties
233
234         private string _statusMessage;
235         public string StatusMessage
236         {
237             get { return _statusMessage; }
238             set
239             {
240                 _statusMessage = value;
241                 NotifyOfPropertyChange(() => StatusMessage);
242             }
243         }
244
245         private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
246         public ObservableConcurrentCollection<AccountInfo> Accounts
247         {
248             get { return _accounts; }
249         }
250
251             public bool HasAccounts
252             {
253             get { return _accounts.Count > 0; }
254             }
255
256
257         public string OpenFolderCaption
258         {
259             get
260             {
261                 return (_accounts.Count == 0)
262                         ? "No Accounts Defined"
263                         : "Open Pithos Folder";
264             }
265         }
266
267         private string _pauseSyncCaption="Pause Synching";
268         public string PauseSyncCaption
269         {
270             get { return _pauseSyncCaption; }
271             set
272             {
273                 _pauseSyncCaption = value;
274                 NotifyOfPropertyChange(() => PauseSyncCaption);
275             }
276         }
277
278         private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
279         public ObservableConcurrentCollection<FileEntry> RecentFiles
280         {
281             get { return _recentFiles; }
282         }
283
284
285         private string _statusIcon="../Images/Pithos.ico";
286         public string StatusIcon
287         {
288             get { return _statusIcon; }
289             set
290             {
291                 //_statusIcon = value;
292                 NotifyOfPropertyChange(() => StatusIcon);
293             }
294         }
295
296         #endregion
297
298         #region Commands
299
300         public void ShowPreferences()
301         {
302             Settings.Reload();
303             var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings);            
304             _windowManager.ShowDialog(preferences);
305             
306         }
307
308         public void AboutPithos()
309         {
310             var about = new AboutViewModel();
311             _windowManager.ShowWindow(about);
312         }
313
314         public void SendFeedback()
315         {
316             var feedBack =  IoC.Get<FeedbackViewModel>();
317             _windowManager.ShowWindow(feedBack);
318         }
319
320         //public PithosCommand OpenPithosFolderCommand { get; private set; }
321
322         public void OpenPithosFolder()
323         {
324             var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
325             if (account == null)
326                 return;
327             Process.Start(account.RootPath);
328         }
329
330         public void OpenPithosFolder(AccountInfo account)
331         {
332             Process.Start(account.AccountPath);
333         }
334
335         
336 /*
337         public void GoToSite()
338         {            
339             var site = Properties.Settings.Default.PithosSite;
340             Process.Start(site);            
341         }
342 */
343
344         public void GoToSite(AccountInfo account)
345         {
346             var site = String.Format("{0}/ui/?token={1}&user={2}",
347                 account.SiteUri,account.Token,
348                 account.UserName);
349             Process.Start(site);
350         }
351
352         public void ShowFileProperties()
353         {
354             var account = Settings.Accounts.First(acc => acc.IsActive);            
355             var dir = new DirectoryInfo(account.RootPath + @"\pithos");
356             var files=dir.GetFiles();
357             var r=new Random();
358             var idx=r.Next(0, files.Length);
359             ShowFileProperties(files[idx].FullName);            
360         }
361
362         public void ShowFileProperties(string filePath)
363         {
364             if (String.IsNullOrWhiteSpace(filePath))
365                 throw new ArgumentNullException("filePath");
366             if (!File.Exists(filePath))
367                 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
368             Contract.EndContractBlock();
369
370             var pair=(from monitor in  Monitors
371                                where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
372                                    select monitor).FirstOrDefault();
373             var account = pair.Key;
374             var accountMonitor = pair.Value;
375
376             if (accountMonitor == null)
377                 return;
378
379             var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
380
381             
382
383             var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
384             _windowManager.ShowWindow(fileProperties);
385         } 
386         
387         public void ShowContainerProperties()
388         {
389             var account = Settings.Accounts.First(acc => acc.IsActive);            
390             var dir = new DirectoryInfo(account.RootPath);
391             var fullName = (from folder in dir.EnumerateDirectories()
392                             where (folder.Attributes & FileAttributes.Hidden) == 0
393                             select folder.FullName).First();
394             ShowContainerProperties(fullName);            
395         }
396
397         public void ShowContainerProperties(string filePath)
398         {
399             if (String.IsNullOrWhiteSpace(filePath))
400                 throw new ArgumentNullException("filePath");
401             if (!Directory.Exists(filePath))
402                 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
403             Contract.EndContractBlock();
404
405             var pair=(from monitor in  Monitors
406                                where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
407                                    select monitor).FirstOrDefault();
408             var account = pair.Key;
409             var accountMonitor = pair.Value;            
410             var info = accountMonitor.GetContainerInfo(filePath);
411
412             
413
414             var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
415             _windowManager.ShowWindow(containerProperties);
416         }
417
418         public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
419         {
420             if (currentInfo==null)
421                 throw new ArgumentNullException("currentInfo");
422             Contract.EndContractBlock();
423
424             var monitor = Monitors[currentInfo.Account];
425             var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
426             return newInfo;
427         }
428
429         public ContainerInfo RefreshContainerInfo(ContainerInfo container)
430         {
431             if (container == null)
432                 throw new ArgumentNullException("container");
433             Contract.EndContractBlock();
434
435             var monitor = Monitors[container.Account];
436             var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
437             return newInfo;
438         }
439
440
441         public void ToggleSynching()
442         {
443             bool isPaused=false;
444             foreach (var pair in Monitors)
445             {
446                 var monitor = pair.Value;
447                 monitor.Pause = !monitor.Pause;
448                 isPaused = monitor.Pause;
449             }
450
451             PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
452             var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
453             StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
454         }
455
456         public void ExitPithos()
457         {
458             foreach (var pair in Monitors)
459             {
460                 var monitor = pair.Value;
461                 monitor.Stop();
462             }
463
464             ((Window)GetView()).Close();
465         }
466         #endregion
467
468
469         private Dictionary<PithosStatus, StatusInfo> iconNames = new List<StatusInfo>
470             {
471                 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
472                 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
473                 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
474             }.ToDictionary(s => s.Status);
475
476         readonly IWindowManager _windowManager;
477
478
479                 ///<summary>
480                 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat                
481                 ///</summary>
482         public void UpdateStatus()
483         {
484             var pithosStatus = _statusChecker.GetPithosStatus();
485
486             if (iconNames.ContainsKey(pithosStatus))
487             {
488                 var info = iconNames[pithosStatus];
489                 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
490
491                 Assembly assembly = Assembly.GetExecutingAssembly();                               
492                 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
493
494
495                 StatusMessage = String.Format("Pithos {0}\r\n{1}", fileVersion.FileVersion,info.StatusText);
496             }
497             
498             _events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
499         }
500
501
502        
503         private Task StartMonitor(PithosMonitor monitor,int retries=0)
504         {
505             return Task.Factory.StartNew(() =>
506             {
507                 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
508                 {
509                     try
510                     {
511                         Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
512
513                         monitor.Start();
514                     }
515                     catch (WebException exc)
516                     {
517                         if (AbandonRetry(monitor, retries))
518                             return;
519
520                         if (IsUnauthorized(exc))
521                         {
522                             var message = String.Format("API Key Expired for {0}. Starting Renewal",monitor.UserName);                            
523                             Log.Error(message,exc);
524                             TryAuthorize(monitor,retries).Wait();
525                         }
526                         else
527                         {
528                             TryLater(monitor, exc,retries);
529                         }
530                     }
531                     catch (Exception exc)
532                     {
533                         if (AbandonRetry(monitor, retries)) 
534                             return;
535
536                         TryLater(monitor,exc,retries);
537                     }
538                 }
539             });
540         }
541
542         private bool AbandonRetry(PithosMonitor monitor, int retries)
543         {
544             if (retries > 1)
545             {
546                 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
547                                             monitor.UserName);
548                 _events.Publish(new Notification
549                                     {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
550                 return true;
551             }
552             return false;
553         }
554
555
556         private async Task TryAuthorize(PithosMonitor monitor,int retries)
557         {
558             _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 });
559
560             try
561             {
562
563                 var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl);
564
565                 var account = Settings.Accounts.FirstOrDefault(act => act.AccountName == credentials.UserName);
566                 account.ApiKey = credentials.Password;
567                 monitor.ApiKey = credentials.Password;
568                 Settings.Save();
569                 await TaskEx.Delay(10000);
570                 StartMonitor(monitor, retries + 1);
571                 NotifyOfPropertyChange(()=>Accounts);
572             }
573             catch (AggregateException exc)
574             {
575                 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
576                 Log.Error(message, exc.InnerException);
577                 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
578                 return;
579             }
580             catch (Exception exc)
581             {
582                 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
583                 Log.Error(message, exc);
584                 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
585                 return;
586                 
587             }
588
589         }
590
591         private static bool IsUnauthorized(WebException exc)
592         {
593             if (exc==null)
594                 throw new ArgumentNullException("exc");
595             Contract.EndContractBlock();
596
597             var response = exc.Response as HttpWebResponse;
598             if (response == null)
599                 return false;
600             return (response.StatusCode == HttpStatusCode.Unauthorized);
601         }
602
603         private void TryLater(PithosMonitor monitor, Exception exc,int retries)
604         {
605             var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
606             Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
607             _events.Publish(new Notification
608                                 {Title = "Error", Message = message, Level = TraceLevel.Error});
609             Log.Error(message, exc);
610         }
611
612
613         public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
614         {
615             this.StatusMessage = status;
616             
617             _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
618         }
619
620         public void NotifyChangedFile(string filePath)
621         {
622             var entry = new FileEntry {FullPath=filePath};
623             IProducerConsumerCollection<FileEntry> files=this.RecentFiles;
624             FileEntry popped;
625             while (files.Count > 5)
626                 files.TryTake(out popped);
627             files.TryAdd(entry);
628         }
629
630         public void NotifyAccount(AccountInfo account)
631         {
632             if (account== null)
633                 return;
634
635             account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
636                 account.SiteUri, account.Token,
637                 account.UserName);
638
639             IProducerConsumerCollection<AccountInfo> accounts = Accounts;
640             for (var i = 0; i < _accounts.Count; i++)
641             {
642                 AccountInfo item;
643                 if (accounts.TryTake(out item))
644                 {
645                     if (item.UserName!=account.UserName)
646                     {
647                         accounts.TryAdd(item);
648                     }
649                 }
650             }
651
652             accounts.TryAdd(account);
653         }
654
655
656         public void RemoveMonitor(string accountName)
657         {
658             if (String.IsNullOrWhiteSpace(accountName))
659                 return;
660
661             var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
662             _accounts.TryRemove(accountInfo);
663
664             PithosMonitor monitor;
665             if (Monitors.TryGetValue(accountName, out monitor))
666             {
667                 Monitors.Remove(accountName);
668                 monitor.Stop();
669             }
670         }
671
672         public void RefreshOverlays()
673         {
674             foreach (var pair in Monitors)
675             {
676                 var monitor = pair.Value;
677
678                 var path = monitor.RootPath;
679
680                 if (String.IsNullOrWhiteSpace(path))
681                     continue;
682
683                 if (!Directory.Exists(path) && !File.Exists(path))
684                     continue;
685
686                 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
687
688                 try
689                 {
690                     NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
691                                                  HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
692                                                  pathPointer, IntPtr.Zero);
693                 }
694                 finally
695                 {
696                     Marshal.FreeHGlobal(pathPointer);
697                 }
698             }
699         }
700
701         #region Event Handlers
702         
703         public void Handle(SelectiveSynchChanges message)
704         {
705             var accountName = message.Account.AccountName;
706             PithosMonitor monitor;
707             if (_monitors.TryGetValue(accountName, out monitor))
708             {
709                 monitor.AddSelectivePaths(message.Added);
710                 monitor.RemoveSelectivePaths(message.Removed);
711
712             }
713             
714         }
715
716
717         public void Handle(Notification notification)
718         {
719             if (!Settings.ShowDesktopNotifications)
720                 return;
721             BalloonIcon icon = BalloonIcon.None;
722             switch (notification.Level)
723             {
724                 case TraceLevel.Error:
725                     icon = BalloonIcon.Error;
726                     break;
727                 case TraceLevel.Info:
728                 case TraceLevel.Verbose:
729                     icon = BalloonIcon.Info;
730                     break;
731                 case TraceLevel.Warning:
732                     icon = BalloonIcon.Warning;
733                     break;
734                 default:
735                     icon = BalloonIcon.None;
736                     break;
737             }
738
739             if (Settings.ShowDesktopNotifications)
740             {
741                 var tv = (ShellView) this.GetView();
742                 tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
743             }
744         }
745         #endregion
746
747         public void Handle(ShowFilePropertiesEvent message)
748         {
749             if (message == null)
750                 throw new ArgumentNullException("message");
751             if (String.IsNullOrWhiteSpace(message.FileName) )
752                 throw new ArgumentException("message");
753             Contract.EndContractBlock();
754
755             var fileName = message.FileName;
756
757             if (File.Exists(fileName))
758                 this.ShowFileProperties(fileName);
759             else if (Directory.Exists(fileName))
760                 this.ShowContainerProperties(fileName);
761         }
762     }
763 }