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