1 /*
2  * PlumberAPI.java
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 package org.rstudio.studio.client.plumber;
16 
17 import org.rstudio.core.client.BrowseCap;
18 import org.rstudio.core.client.Size;
19 import org.rstudio.core.client.StringUtil;
20 import org.rstudio.core.client.command.CommandBinder;
21 import org.rstudio.core.client.command.Handler;
22 import org.rstudio.core.client.dom.WindowCloseMonitor;
23 import org.rstudio.core.client.dom.WindowEx;
24 import org.rstudio.studio.client.application.ApplicationInterrupt;
25 import org.rstudio.studio.client.application.Desktop;
26 import org.rstudio.studio.client.application.events.EventBus;
27 import org.rstudio.studio.client.application.events.InterruptStatusEvent;
28 import org.rstudio.studio.client.application.events.RestartStatusEvent;
29 import org.rstudio.studio.client.common.GlobalDisplay;
30 import org.rstudio.studio.client.common.dependencies.DependencyManager;
31 import org.rstudio.studio.client.common.plumber.model.PlumberServerOperations;
32 import org.rstudio.studio.client.common.satellite.SatelliteManager;
33 import org.rstudio.studio.client.common.satellite.events.WindowClosedEvent;
34 import org.rstudio.studio.client.plumber.events.LaunchPlumberAPIEvent;
35 import org.rstudio.studio.client.plumber.events.PlumberAPIStatusEvent;
36 import org.rstudio.studio.client.plumber.model.PlumberAPIParams;
37 import org.rstudio.studio.client.plumber.model.PlumberRunCmd;
38 import org.rstudio.studio.client.server.ServerError;
39 import org.rstudio.studio.client.server.ServerRequestCallback;
40 import org.rstudio.studio.client.workbench.commands.Commands;
41 import org.rstudio.studio.client.workbench.prefs.model.UserPrefs;
42 import org.rstudio.studio.client.workbench.views.console.events.ConsoleBusyEvent;
43 import org.rstudio.studio.client.workbench.views.console.events.SendToConsoleEvent;
44 import org.rstudio.studio.client.workbench.views.environment.events.DebugModeChangedEvent;
45 import org.rstudio.studio.client.workbench.views.viewer.events.ViewerClearedEvent;
46 
47 import com.google.gwt.core.client.JavaScriptObject;
48 import com.google.inject.Inject;
49 import com.google.inject.Provider;
50 import com.google.inject.Singleton;
51 
52 @Singleton
53 public class PlumberAPI implements PlumberAPIStatusEvent.Handler,
54                                    ConsoleBusyEvent.Handler,
55                                    DebugModeChangedEvent.Handler,
56                                    RestartStatusEvent.Handler,
57                                    WindowClosedEvent.Handler,
58                                    LaunchPlumberAPIEvent.Handler,
59                                    InterruptStatusEvent.Handler
60 {
61    public interface Binder
62    extends CommandBinder<Commands, PlumberAPI> {}
63 
64    @Inject
PlumberAPI(EventBus eventBus, Commands commands, Binder binder, Provider<UserPrefs> pPrefs, final SatelliteManager satelliteManager, PlumberServerOperations server, GlobalDisplay display, DependencyManager dependencyManager, ApplicationInterrupt interrupt)65    public PlumberAPI(EventBus eventBus,
66                      Commands commands,
67                      Binder binder,
68                      Provider<UserPrefs> pPrefs,
69                      final SatelliteManager satelliteManager,
70                      PlumberServerOperations server,
71                      GlobalDisplay display,
72                      DependencyManager dependencyManager,
73                      ApplicationInterrupt interrupt)
74    {
75       eventBus_ = eventBus;
76       satelliteManager_ = satelliteManager;
77       commands_ = commands;
78       pPrefs_ = pPrefs;
79       server_ = server;
80       display_ = display;
81       isBusy_ = false;
82       currentViewType_ = UserPrefs.PLUMBER_VIEWER_TYPE_NONE;
83       dependencyManager_ = dependencyManager;
84       interrupt_ = interrupt;
85 
86       eventBus_.addHandler(PlumberAPIStatusEvent.TYPE, this);
87       eventBus_.addHandler(LaunchPlumberAPIEvent.TYPE, this);
88       eventBus_.addHandler(ConsoleBusyEvent.TYPE, this);
89       eventBus_.addHandler(DebugModeChangedEvent.TYPE, this);
90       eventBus_.addHandler(RestartStatusEvent.TYPE, this);
91       eventBus_.addHandler(WindowClosedEvent.TYPE, this);
92       eventBus_.addHandler(InterruptStatusEvent.TYPE, this);
93 
94       binder.bind(commands, this);
95       exportPlumberAPIClosedCallback();
96    }
97 
98    // Event handlers ----------------------------------------------------------
99 
100    @Override
onPlumberAPIStatus(PlumberAPIStatusEvent event)101    public void onPlumberAPIStatus(PlumberAPIStatusEvent event)
102    {
103       if (StringUtil.equals(event.getParams().getState(), PlumberAPIParams.STATE_STARTED))
104       {
105          currentViewType_ = event.getParams().getViewerType();
106 
107          // open the window to view the API if needed
108          if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
109          {
110             activateWindow(event.getParams());
111          }
112          else if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_BROWSER)
113          {
114             display_.openWindow(event.getParams().getUrl());
115          }
116          params_ = event.getParams();
117 
118          // if the API was started from the same path as a pending satellite
119          // closure, don't shut down the API when the close finishes
120          if (StringUtil.equals(event.getParams().getPath(), satelliteClosePath_))
121          {
122             stopOnNextClose_ = false;
123          }
124       }
125       else if (StringUtil.equals(event.getParams().getState(), PlumberAPIParams.STATE_STOPPED))
126       {
127          params_ = null;
128       }
129    }
130 
131    @Override
onLaunchPlumberAPI(LaunchPlumberAPIEvent event)132    public void onLaunchPlumberAPI(LaunchPlumberAPIEvent event)
133    {
134       launchPlumberAPI(event.getPath());
135    }
136 
137    @Override
onConsoleBusy(ConsoleBusyEvent event)138    public void onConsoleBusy(ConsoleBusyEvent event)
139    {
140       isBusy_ = event.isBusy();
141 
142       // if the browser is up and R stops being busy, presume it's because the
143       // API has stopped
144       if (!isBusy_ && params_ != null &&
145           params_.getViewerType() == UserPrefs.PLUMBER_VIEWER_TYPE_BROWSER)
146       {
147          params_.setState(PlumberAPIParams.STATE_STOPPED);
148          eventBus_.fireEvent(new PlumberAPIStatusEvent(params_));
149       }
150    }
151 
152    @Override
onDebugModeChanged(DebugModeChangedEvent event)153    public void onDebugModeChanged(DebugModeChangedEvent event)
154    {
155       // When leaving debug mode while the Plumber API is open in a
156       // browser, automatically return to the API by activating the window.
157       if (!event.debugging() &&
158           params_ != null &&
159           currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
160       {
161          satelliteManager_.activateSatelliteWindow(PlumberAPISatellite.NAME);
162       }
163    }
164 
165    @Override
onRestartStatus(RestartStatusEvent event)166    public void onRestartStatus(RestartStatusEvent event)
167    {
168       // Close the satellite window when R restarts, since this leads to the
169       // Plumber API being terminated. Closing the window triggers a
170       // PlumberAPIStatusEvent that allows the rest of the UI a chance
171       // to react to the API's termination.
172       if (event.getStatus() == RestartStatusEvent.RESTART_INITIATED &&
173           currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
174       {
175             satelliteManager_.closeSatelliteWindow(PlumberAPISatellite.NAME);
176       }
177    }
178 
179    @Override
onWindowClosed(WindowClosedEvent event)180    public void onWindowClosed(WindowClosedEvent event)
181    {
182       // we get this event on the desktop (currently only Cocoa); it lets us
183       // know that the satellite has been shut down even in the case where the
184       // script window that ordinarily would let us know has been disconnected.
185       if (!StringUtil.equals(event.getName(), PlumberAPISatellite.NAME))
186          return;
187 
188       // stop the API if this event wasn't generated by a disconnect
189       if (params_ != null && disconnectingUrl_ == null && stopOnNextClose_)
190       {
191          params_.setState(PlumberAPIParams.STATE_STOPPING);
192          notifyPlumberAPIClosed(params_);
193       }
194    }
195 
196    @Handler
onPlumberRunInPane()197    public void onPlumberRunInPane()
198    {
199       setPlumberViewerType(UserPrefs.PLUMBER_VIEWER_TYPE_PANE);
200    }
201 
202    @Handler
onPlumberRunInViewer()203    public void onPlumberRunInViewer()
204    {
205       setPlumberViewerType(UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW);
206    }
207 
208    @Handler
onPlumberRunInBrowser()209    public void onPlumberRunInBrowser()
210    {
211       setPlumberViewerType(UserPrefs.PLUMBER_VIEWER_TYPE_BROWSER);
212    }
213 
214    @Override
onInterruptStatus(InterruptStatusEvent event)215    public void onInterruptStatus(InterruptStatusEvent event)
216    {
217       // If API is stopped via Console, ensure Satellite is closed
218       if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
219       {
220             satelliteManager_.closeSatelliteWindow(PlumberAPISatellite.NAME);
221       }
222    }
223 
launchPlumberAPI(final String filePath)224    private void launchPlumberAPI(final String filePath)
225    {
226       String fileDir = filePath.substring(0, filePath.lastIndexOf("/"));
227       if (fileDir.equals(currentAppPath()))
228       {
229          // The API being launched is the one already running; open and reload the API.
230          if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
231          {
232             satelliteManager_.dispatchCommand(commands_.reloadPlumberAPI(), PlumberAPISatellite.NAME);
233             activateWindow();
234          }
235          else if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_PANE &&
236                   commands_.viewerRefresh().isEnabled())
237          {
238             commands_.viewerRefresh().execute();
239          }
240          else if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_BROWSER)
241          {
242             eventBus_.fireEvent(new PlumberAPIStatusEvent(params_));
243          }
244          return;
245       }
246       else if (params_ != null && isBusy_)
247       {
248          // There's another API running. Interrupt it and then start this one.
249          interrupt_.interruptR(() -> launchPlumberFile(filePath));
250       }
251       else
252       {
253          // Nothing else running, start this API.
254          dependencyManager_.withRPlumber("Running Plumber API", () -> launchPlumberFile(filePath));
255       }
256    }
257 
currentAppPath()258    private String currentAppPath()
259    {
260       if (params_ != null)
261          return params_.getPath();
262       return null;
263    }
264 
notifyPlumberAPIDisconnected(JavaScriptObject params)265    private void notifyPlumberAPIDisconnected(JavaScriptObject params)
266    {
267       PlumberAPIParams apiState = params.cast();
268       if (params_ == null)
269          return;
270 
271       // remember that this URL is disconnecting (so we don't interrupt R when
272       // the window is torn down)
273       disconnectingUrl_ = apiState.getUrl();
274    }
275 
notifyPlumberAPIClosed(final JavaScriptObject params)276    private void notifyPlumberAPIClosed(final JavaScriptObject params)
277    {
278       // if we don't know that an API is running, ignore this event
279       if (params_ == null)
280          return;
281 
282       satelliteClosePath_ = params_.getPath();
283 
284       // wait for confirmation of window closure (could be a reload)
285       WindowCloseMonitor.monitorSatelliteClosure(PlumberAPISatellite.NAME, () -> {
286          // satellite closed for real; shut down the API
287          satelliteClosePath_ = null;
288          onPlumberAPIClosed(params);
289       }, () -> {
290          // satellite didn't actually close (it was a reload)
291          satelliteClosePath_ = null;
292       });
293    }
294 
onPlumberAPIClosed(JavaScriptObject params)295    private void onPlumberAPIClosed(JavaScriptObject params)
296    {
297       PlumberAPIParams apiState = params.cast();
298 
299       // this completes any pending disconnection
300       disconnectingUrl_ = null;
301 
302       // if we were asked not to stop when the window closes (i.e. when
303       // changing viewer types), bail out
304       if (!stopOnNextClose_)
305       {
306          stopOnNextClose_ = true;
307          return;
308       }
309 
310       // If the API is stopping, then the user initiated the stop by
311       // closing the API window. Interrupt R to stop the Plumber API.
312       if (StringUtil.equals(apiState.getState(), PlumberAPIParams.STATE_STOPPING))
313       {
314          if (commands_.interruptR().isEnabled())
315             commands_.interruptR().execute();
316          apiState.setState(PlumberAPIParams.STATE_STOPPED);
317       }
318       eventBus_.fireEvent(new PlumberAPIStatusEvent((PlumberAPIParams) params.cast()));
319    }
320 
exportPlumberAPIClosedCallback()321    private final native void exportPlumberAPIClosedCallback()/*-{
322       var registry = this;
323       $wnd.notifyPlumberAPIClosed = $entry(
324          function(params) {
325             registry.@org.rstudio.studio.client.plumber.PlumberAPI::notifyPlumberAPIClosed(Lcom/google/gwt/core/client/JavaScriptObject;)(params);
326          }
327       );
328       $wnd.notifyPlumberAPIDisconnected = $entry(
329          function(params) {
330             registry.@org.rstudio.studio.client.plumber.PlumberAPI::notifyPlumberAPIDisconnected(Lcom/google/gwt/core/client/JavaScriptObject;)(params);
331          }
332       );
333    }-*/;
334 
setPlumberViewerType(String viewerType)335    private void setPlumberViewerType(String viewerType)
336    {
337       UserPrefs prefs = pPrefs_.get();
338       prefs.plumberViewerType().setGlobalValue(viewerType);
339       prefs.writeUserPrefs();
340 
341       // if we have a running Plumber API and the viewer type has changed,
342       // snap the API into the new location
343       if (currentViewType_ != viewerType && params_ != null)
344       {
345          // if transitioning away from the pane or the window, close down
346          // the old instance
347          if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_PANE ||
348              currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
349          {
350             if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW)
351             {
352                stopOnNextClose_ = false;
353                satelliteManager_.closeSatelliteWindow(PlumberAPISatellite.NAME);
354             }
355             else
356             {
357                eventBus_.fireEvent(new ViewerClearedEvent(false));
358             }
359          }
360 
361          // assign new viewer type
362          currentViewType_ = viewerType;
363          params_.setViewerType(viewerType);
364 
365          if (currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_PANE ||
366              currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_WINDOW ||
367              currentViewType_ == UserPrefs.PLUMBER_VIEWER_TYPE_BROWSER)
368          {
369             eventBus_.fireEvent(new PlumberAPIStatusEvent(params_));
370          }
371       }
372    }
373 
launchPlumberFile(String file)374    private void launchPlumberFile(String file)
375    {
376       server_.getPlumberRunCmd(file,
377             new ServerRequestCallback<PlumberRunCmd>()
378             {
379                @Override
380                public void onResponseReceived(PlumberRunCmd cmd)
381                {
382                   eventBus_.fireEvent(new SendToConsoleEvent(cmd.getRunCmd(), true));
383                }
384 
385                @Override
386                public void onError(ServerError error)
387                {
388                   display_.showErrorMessage("Plumber API Launch Failed", error.getMessage());
389                }
390             });
391    }
392 
activateWindow()393    private void activateWindow()
394    {
395       activateWindow(null);
396    }
397 
activateWindow(PlumberAPIParams params)398    private void activateWindow(PlumberAPIParams params)
399    {
400       WindowEx win = satelliteManager_.getSatelliteWindowObject(PlumberAPISatellite.NAME);
401       boolean isRefresh = win != null &&
402             (params == null || (params_ != null &&
403                                 StringUtil.equals(params.getPath(), params_.getPath())));
404       boolean isChrome = !Desktop.isDesktop() && BrowseCap.isChrome();
405       if (params != null)
406          params_ = params;
407       if (win == null || (!isRefresh && !isChrome))
408       {
409          int width = 910;
410 
411          // If there's no window yet, or we're switching APIs in a browser
412          // other than Chrome, do a normal open
413          satelliteManager_.openSatellite(PlumberAPISatellite.NAME,
414                                          params_, new Size(width, 1100));
415       }
416       else if (isChrome)
417       {
418          // we have a window and we're Chrome, so force a close and reopen
419          satelliteManager_.forceReopenSatellite(PlumberAPISatellite.NAME,
420                                                 params_,
421                                                 true);
422       }
423       else
424       {
425          satelliteManager_.activateSatelliteWindow(PlumberAPISatellite.NAME);
426       }
427    }
428 
429    private final EventBus eventBus_;
430    private final SatelliteManager satelliteManager_;
431    private final DependencyManager dependencyManager_;
432    private final Commands commands_;
433    private final Provider<UserPrefs> pPrefs_;
434    private final PlumberServerOperations server_;
435    private final GlobalDisplay display_;
436    private final ApplicationInterrupt interrupt_;
437 
438    private PlumberAPIParams params_;
439    private String disconnectingUrl_;
440    private boolean isBusy_;
441    private boolean stopOnNextClose_ = true;
442    private String satelliteClosePath_ = null;
443    private String currentViewType_;
444 }
445