ViewerViewModel.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. using Stylet;
  2. using SyncTrayzor.Syncthing;
  3. using SyncTrayzor.Utils;
  4. using System;
  5. using System.Globalization;
  6. using CefSharp;
  7. using CefSharp.Wpf;
  8. using SyncTrayzor.Services.Config;
  9. using System.Threading;
  10. using SyncTrayzor.Services;
  11. using SyncTrayzor.Properties;
  12. using Microsoft.WindowsAPICodePack.Dialogs;
  13. using CefSharp.Handler;
  14. namespace SyncTrayzor.Pages
  15. {
  16. public class ViewerViewModel : Screen, IResourceRequestHandlerFactory, ILifeSpanHandler, IContextMenuHandler, IDisposable
  17. {
  18. private readonly IWindowManager windowManager;
  19. private readonly ISyncthingManager syncthingManager;
  20. private readonly IProcessStartProvider processStartProvider;
  21. private readonly IConfigurationProvider configurationProvider;
  22. private readonly IApplicationPathsProvider pathsProvider;
  23. private readonly CustomResourceRequestHandler customResourceRequestHandler;
  24. private readonly object cultureLock = new object(); // This can be read from many threads
  25. private CultureInfo culture;
  26. private double zoomLevel;
  27. public string Location
  28. {
  29. get => this.WebBrowser?.Address;
  30. private set
  31. {
  32. if (this.WebBrowser != null)
  33. this.WebBrowser.Address = value;
  34. }
  35. }
  36. private SyncthingState syncthingState { get; set; }
  37. public bool ShowSyncthingStarting => this.syncthingState == SyncthingState.Starting;
  38. public bool ShowSyncthingStopped => this.syncthingState == SyncthingState.Stopped;
  39. public ChromiumWebBrowser WebBrowser { get; set; }
  40. private JavascriptCallbackObject callback;
  41. public ViewerViewModel(
  42. IWindowManager windowManager,
  43. ISyncthingManager syncthingManager,
  44. IConfigurationProvider configurationProvider,
  45. IProcessStartProvider processStartProvider,
  46. IApplicationPathsProvider pathsProvider)
  47. {
  48. this.windowManager = windowManager;
  49. this.syncthingManager = syncthingManager;
  50. this.processStartProvider = processStartProvider;
  51. this.configurationProvider = configurationProvider;
  52. this.pathsProvider = pathsProvider;
  53. var configuration = this.configurationProvider.Load();
  54. this.zoomLevel = configuration.SyncthingWebBrowserZoomLevel;
  55. this.syncthingManager.StateChanged += this.SyncthingStateChanged;
  56. this.customResourceRequestHandler = new CustomResourceRequestHandler(this);
  57. this.callback = new JavascriptCallbackObject(this);
  58. this.SetCulture(configuration);
  59. configurationProvider.ConfigurationChanged += this.ConfigurationChanged;
  60. }
  61. private void SyncthingStateChanged(object sender, SyncthingStateChangedEventArgs e)
  62. {
  63. this.syncthingState = e.NewState;
  64. this.RefreshBrowser();
  65. }
  66. private void ConfigurationChanged(object sender, ConfigurationChangedEventArgs e)
  67. {
  68. this.SetCulture(e.NewConfiguration);
  69. }
  70. private void SetCulture(Configuration configuration)
  71. {
  72. lock (this.cultureLock)
  73. {
  74. this.culture = configuration.UseComputerCulture ? Thread.CurrentThread.CurrentUICulture : null;
  75. }
  76. }
  77. protected override void OnInitialActivate()
  78. {
  79. if (!Cef.IsInitialized)
  80. {
  81. var configuration = this.configurationProvider.Load();
  82. var settings = new CefSettings()
  83. {
  84. RemoteDebuggingPort = AppSettings.Instance.CefRemoteDebuggingPort,
  85. // We really only want to set the LocalStorage path, but we don't have that level of control....
  86. CachePath = this.pathsProvider.CefCachePath,
  87. IgnoreCertificateErrors = true,
  88. LogSeverity = LogSeverity.Disable,
  89. };
  90. // System proxy settings (which also specify a proxy for localhost) shouldn't affect us
  91. settings.CefCommandLineArgs.Add("no-proxy-server", "1");
  92. settings.CefCommandLineArgs.Add("disable-cache", "1");
  93. settings.CefCommandLineArgs.Add("disable-extensions", "1");
  94. if (configuration.DisableHardwareRendering)
  95. {
  96. settings.CefCommandLineArgs.Add("disable-gpu", "1");
  97. settings.CefCommandLineArgs.Add("disable-gpu-vsync", "1");
  98. settings.CefCommandLineArgs.Add("disable-application-cache", "1");
  99. }
  100. Cef.Initialize(settings);
  101. }
  102. var webBrowser = new ChromiumWebBrowser();
  103. this.InitializeBrowser(webBrowser);
  104. this.WebBrowser = webBrowser;
  105. this.RefreshBrowser();
  106. }
  107. private void InitializeBrowser(ChromiumWebBrowser webBrowser)
  108. {
  109. webBrowser.RequestHandler = new CustomRequestHandler();
  110. webBrowser.ResourceRequestHandlerFactory = this;
  111. webBrowser.LifeSpanHandler = this;
  112. webBrowser.MenuHandler = this;
  113. webBrowser.JavascriptObjectRepository.Settings.LegacyBindingEnabled = true;
  114. webBrowser.JavascriptObjectRepository.Register("callbackObject", this.callback, isAsync: true);
  115. // So. Fun story. From https://github.com/cefsharp/CefSharp/issues/738#issuecomment-91099199, we need to set the zoom level
  116. // in the FrameLoadStart event. However, the IWpfWebBrowser's ZoomLevel is a DependencyProperty, and it wraps
  117. // the SetZoomLevel method on the unmanaged browser (which is exposed directly by ChromiumWebBrowser, but not by IWpfWebBrowser).
  118. // Now, FrameLoadState and FrameLoadEnd are called on a background thread, and since ZoomLevel is a DP, it can only be changed
  119. // from the UI thread (it's "helpful" and does a dispatcher check for us). But, if we dispatch back to the UI thread to call
  120. // ZoomLevel = xxx, then CEF seems to hit threading issues, and can sometimes render things entirely badly (massive icons, no
  121. // localization, bad spacing, no JavaScript at all, etc).
  122. // So, in this case, we need to call SetZoomLevel directly, as we can do that from the thread on which FrameLoadStart is called,
  123. // and everything's happy.
  124. // However, this means that the DP value isn't updated... Which means we can't use the DP at all. We have to call SetZoomLevel
  125. // *everywhere*, and that means keeping a local field zoomLevel to track the current zoom level. Such is life
  126. webBrowser.FrameLoadStart += (o, e) => webBrowser.SetZoomLevel(this.zoomLevel);
  127. webBrowser.FrameLoadEnd += (o, e) =>
  128. {
  129. if (e.Frame.IsMain && e.Url != "about:blank")
  130. {
  131. // I tried to do this using Syncthing's events, but it's very painful - the DOM is updated some time
  132. // after the event is fired. It's a lot easier to just watch for changes on the DOM.
  133. var addOpenFolderButton =
  134. @"var syncTrayzorAddOpenFolderButton = function(elem) {" +
  135. @" var $buttonContainer = elem.find('.panel-footer .pull-right');" +
  136. @" $buttonContainer.find('.panel-footer .synctrayzor-add-folder-button').remove();" +
  137. @" $buttonContainer.prepend(" +
  138. @" '<button class=""btn btn-sm btn-default synctrayzor-add-folder-button"" onclick=""callbackObject.openFolder(angular.element(this).scope().folder.id)"">" +
  139. @" <span class=""fa fa-folder-open""></span>" +
  140. @" <span style=""margin-left: 3px"">" + Resources.ViewerView_OpenFolder + @"</span>" +
  141. @" </button>');" +
  142. @"};" +
  143. @"new MutationObserver(function(mutations, observer) {" +
  144. @" for (var i = 0; i < mutations.length; i++) {" +
  145. @" for (var j = 0; j < mutations[i].addedNodes.length; j++) {" +
  146. @" syncTrayzorAddOpenFolderButton($(mutations[i].addedNodes[j]));" +
  147. @" }" +
  148. @" }" +
  149. @"}).observe(document.getElementById('folders'), {" +
  150. @" childList: true" +
  151. @"});" +
  152. @"syncTrayzorAddOpenFolderButton($('#folders'));" +
  153. @"";
  154. webBrowser.ExecuteScriptAsync(addOpenFolderButton);
  155. var addFolderBrowse =
  156. @"$('#folderPath').wrap($('<div/>').css('display', 'flex'));" +
  157. @"$('#folderPath').after(" +
  158. @" $('<button>').attr('id', 'folderPathBrowseButton')" +
  159. @" .addClass('btn btn-sm btn-default')" +
  160. @" .html('" + Resources.ViewerView_BrowseToFolder + @"')" +
  161. @" .css({'flex-grow': 1, 'margin': '0 0 0 5px'})" +
  162. @" .on('click', function() { callbackObject.browseFolderPath() })" +
  163. @");" +
  164. @"$('#folderPath').removeAttr('list');" +
  165. @"$('#directory-list').remove();" +
  166. @"$('#editFolder').on('shown.bs.modal', function() {" +
  167. @" if ($('#folderPath').is('[readonly]')) {" +
  168. @" $('#folderPathBrowseButton').attr('disabled', 'disabled');" +
  169. @" }" +
  170. @" else {" +
  171. @" $('#folderPathBrowseButton').removeAttr('disabled');" +
  172. @" }" +
  173. @"});";
  174. webBrowser.ExecuteScriptAsync(addFolderBrowse);
  175. }
  176. };
  177. // Chinese IME workaround, copied from
  178. // https://github.com/cefsharp/CefSharp/commit/c7c90581da7ed3dda80fd8304a856462d133d9a7
  179. webBrowser.PreviewTextInput += (o, e) =>
  180. {
  181. var host = webBrowser.GetBrowser().GetHost();
  182. var keyEvent = new KeyEvent();
  183. foreach (var character in e.Text)
  184. {
  185. keyEvent.WindowsKeyCode = character;
  186. keyEvent.Type = KeyEventType.Char;
  187. host.SendKeyEvent(keyEvent);
  188. }
  189. e.Handled = true;
  190. };
  191. }
  192. public void RefreshBrowserNukeCache()
  193. {
  194. if (this.Location == this.GetSyncthingAddress().ToString())
  195. {
  196. this.WebBrowser?.Reload(ignoreCache: true);
  197. }
  198. else if (this.syncthingManager.State == SyncthingState.Running)
  199. {
  200. this.Location = this.GetSyncthingAddress().ToString();
  201. }
  202. }
  203. public void RefreshBrowser()
  204. {
  205. this.Location = "about:blank";
  206. if (this.syncthingManager.State == SyncthingState.Running)
  207. {
  208. this.Location = this.GetSyncthingAddress().ToString();
  209. }
  210. }
  211. public void ZoomIn()
  212. {
  213. this.ZoomTo(this.zoomLevel + 0.2);
  214. }
  215. public void ZoomOut()
  216. {
  217. this.ZoomTo(this.zoomLevel - 0.2);
  218. }
  219. public void ZoomReset()
  220. {
  221. this.ZoomTo(0.0);
  222. }
  223. private void ZoomTo(double zoomLevel)
  224. {
  225. if (this.WebBrowser == null || this.syncthingState != SyncthingState.Running)
  226. return;
  227. this.zoomLevel = zoomLevel;
  228. this.WebBrowser.SetZoomLevel(zoomLevel);
  229. this.configurationProvider.AtomicLoadAndSave(c => c.SyncthingWebBrowserZoomLevel = zoomLevel);
  230. }
  231. private void OpenFolder(string folderId)
  232. {
  233. if (!this.syncthingManager.Folders.TryFetchById(folderId, out var folder))
  234. return;
  235. this.processStartProvider.ShowFolderInExplorer(folder.Path);
  236. }
  237. private void BrowseFolderPath()
  238. {
  239. Execute.OnUIThread(() =>
  240. {
  241. var dialog = new CommonOpenFileDialog()
  242. {
  243. IsFolderPicker = true,
  244. };
  245. var result = dialog.ShowDialog();
  246. if (result == CommonFileDialogResult.Ok)
  247. {
  248. var script =
  249. @"$('#folderPath').val('" + dialog.FileName.Replace("\\", "\\\\").Replace("'", "\\'") + "');" +
  250. @"$('#folderPath').change();";
  251. this.WebBrowser.ExecuteScriptAsync(script);
  252. }
  253. });
  254. }
  255. protected override void OnClose()
  256. {
  257. this.WebBrowser?.Dispose();
  258. this.WebBrowser = null;
  259. // This is such a dirty, horrible, hacky thing to do...
  260. // So it turns out that doesn't like being shut down, then re-initialized, see http://www.magpcss.org/ceforum/viewtopic.php?f=6&t=10807&start=10
  261. // and others. However, if we wait a little while (presumably for the WebBrowser to die and all open connections to the subprocess
  262. // to close), then kill it in a very dirty way (by killing the process rather than calling Cef.Shutdown), it springs back to life
  263. // when Cef.Initialize is called again.
  264. // I'm not 100% it's not leaking something somewhere, but it seems to work, and saves 50MB of idle memory usage
  265. // However, I'm not comfortable enough with this to enable it permanently yet
  266. //await Task.Delay(5000);
  267. //CefSharpHelper.TerminateCefSharpProcess();
  268. }
  269. public async void Start()
  270. {
  271. await this.syncthingManager.StartWithErrorDialogAsync(this.windowManager);
  272. }
  273. private Uri GetSyncthingAddress()
  274. {
  275. // SyncthingManager will always request over HTTPS, whether Syncthing enforces this or not.
  276. // However in an attempt to avoid #201 we'll use HTTP if available, and if not Syncthing will redirect us.
  277. var uriBuilder = new UriBuilder(this.syncthingManager.Address.NormalizeZeroHost())
  278. {
  279. Scheme = "http"
  280. };
  281. return uriBuilder.Uri;
  282. }
  283. bool IResourceRequestHandlerFactory.HasHandlers => true;
  284. IResourceRequestHandler IResourceRequestHandlerFactory.GetResourceRequestHandler(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, bool isNavigation, bool isDownload, string requestInitiator, ref bool disableDefaultHandling)
  285. {
  286. return this.customResourceRequestHandler;
  287. }
  288. private CefReturnValue OnBeforeResourceLoad(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback)
  289. {
  290. var uri = new Uri(request.Url);
  291. // We can get http requests just after changing Syncthing's address: after we've navigated to about:blank but before navigating to
  292. // the new address (Which we do when Syncthing hits the 'running' State).
  293. // Therefore only open external browsers if Syncthing is actually running
  294. if (this.syncthingManager.State == SyncthingState.Running && (uri.Scheme == "http" || uri.Scheme == "https") && uri.Host != this.GetSyncthingAddress().Host)
  295. {
  296. this.processStartProvider.StartDetached(request.Url);
  297. return CefReturnValue.Cancel;
  298. }
  299. // See https://github.com/canton7/SyncTrayzor/issues/13
  300. // and https://github.com/cefsharp/CefSharp/issues/534#issuecomment-60694502
  301. var headers = request.Headers;
  302. headers["X-API-Key"] = this.syncthingManager.ApiKey;
  303. // I don't know why it adds these, even when we explicitly disable caching.
  304. headers.Remove("Cache-Control");
  305. headers.Remove("If-None-Match");
  306. headers.Remove("If-Modified-Since");
  307. lock (this.cultureLock)
  308. {
  309. if (this.culture != null)
  310. headers["Accept-Language"] = $"{this.culture.Name};q=0.8,en;q=0.6";
  311. }
  312. request.Headers = headers;
  313. return CefReturnValue.Continue;
  314. }
  315. void ILifeSpanHandler.OnBeforeClose(IWebBrowser browserControl, IBrowser browser)
  316. {
  317. }
  318. bool ILifeSpanHandler.OnBeforePopup(IWebBrowser browserControl, IBrowser browser, IFrame frame, string targetUrl, string targetFrameName, WindowOpenDisposition targetDisposition, bool userGesture, IPopupFeatures popupFeatures, IWindowInfo windowInfo, IBrowserSettings browserSettings, ref bool noJavascriptAccess, out IWebBrowser newBrowser)
  319. {
  320. this.processStartProvider.StartDetached(targetUrl);
  321. newBrowser = null;
  322. return true;
  323. }
  324. void ILifeSpanHandler.OnAfterCreated(IWebBrowser browserControl, IBrowser browser)
  325. {
  326. }
  327. bool ILifeSpanHandler.DoClose(IWebBrowser browserControl, IBrowser browser)
  328. {
  329. return false;
  330. }
  331. void IContextMenuHandler.OnBeforeContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model)
  332. {
  333. // Clear the default menu, just leaving our custom one
  334. model.Clear();
  335. }
  336. bool IContextMenuHandler.OnContextMenuCommand(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, CefMenuCommand commandId, CefEventFlags eventFlags)
  337. {
  338. return false;
  339. }
  340. void IContextMenuHandler.OnContextMenuDismissed(IWebBrowser browserControl, IBrowser browser, IFrame frame)
  341. {
  342. }
  343. bool IContextMenuHandler.RunContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model, IRunContextMenuCallback callback)
  344. {
  345. return false;
  346. }
  347. public void Dispose()
  348. {
  349. this.syncthingManager.StateChanged -= this.SyncthingStateChanged;
  350. this.configurationProvider.ConfigurationChanged -= this.ConfigurationChanged;
  351. }
  352. private class CustomRequestHandler : RequestHandler
  353. {
  354. protected override bool OnCertificateError(IWebBrowser chromiumWebBrowser, IBrowser browser, CefErrorCode errorCode, string requestUrl, ISslInfo sslInfo, IRequestCallback callback)
  355. {
  356. // We shouldn't hit this because IgnoreCertificateErrors is true, but we do
  357. callback.Continue(true);
  358. return true;
  359. }
  360. }
  361. private class CustomResourceRequestHandler : ResourceRequestHandler
  362. {
  363. private readonly ViewerViewModel parent;
  364. public CustomResourceRequestHandler(ViewerViewModel parent)
  365. {
  366. this.parent = parent;
  367. }
  368. protected override CefReturnValue OnBeforeResourceLoad(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback)
  369. {
  370. return this.parent.OnBeforeResourceLoad(chromiumWebBrowser, browser, frame, request, callback);
  371. }
  372. }
  373. private class JavascriptCallbackObject
  374. {
  375. private readonly ViewerViewModel parent;
  376. public JavascriptCallbackObject(ViewerViewModel parent)
  377. {
  378. this.parent = parent;
  379. }
  380. public void OpenFolder(string folderId)
  381. {
  382. this.parent.OpenFolder(folderId);
  383. }
  384. public void BrowseFolderPath()
  385. {
  386. this.parent.BrowseFolderPath();
  387. }
  388. }
  389. }
  390. }