/*
* SatelliteManager.java
*
* Copyright (C) 2009-17 by RStudio, Inc.
*
* Unless you have received this program directly from RStudio pursuant
* to the terms of a commercial license agreement with RStudio, then
* this program is licensed to you under the terms of version 3 of the
* GNU Affero General Public License. This program is distributed WITHOUT
* ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
* MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
* AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
*
*/
package org.rstudio.studio.client.common.satellite;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map.Entry;
import com.google.inject.Provider;
import org.rstudio.core.client.BrowseCap;
import org.rstudio.core.client.Debug;
import org.rstudio.core.client.Point;
import org.rstudio.core.client.Size;
import org.rstudio.core.client.StringUtil;
import org.rstudio.core.client.command.AppCommand;
import org.rstudio.core.client.dom.DomUtils;
import org.rstudio.core.client.dom.WindowEx;
import org.rstudio.core.client.js.JsObject;
import org.rstudio.core.client.layout.ScreenUtils;
import org.rstudio.studio.client.RStudioGinjector;
import org.rstudio.studio.client.application.ApplicationUncaughtExceptionHandler;
import org.rstudio.studio.client.application.Desktop;
import org.rstudio.studio.client.application.events.CrossWindowEvent;
import org.rstudio.studio.client.application.events.EventBus;
import org.rstudio.studio.client.application.events.ThemeChangedEvent;
import org.rstudio.studio.client.common.GlobalDisplay.NewWindowOptions;
import org.rstudio.studio.client.common.satellite.events.AllSatellitesClosingEvent;
import org.rstudio.studio.client.common.satellite.events.SatelliteClosedEvent;
import org.rstudio.studio.client.common.satellite.events.WindowClosedEvent;
import org.rstudio.studio.client.common.satellite.events.WindowOpenedEvent;
import org.rstudio.studio.client.workbench.model.Session;
import org.rstudio.studio.client.workbench.model.SessionInfo;
import org.rstudio.studio.client.workbench.prefs.model.UIPrefs;
import org.rstudio.studio.client.workbench.views.source.SourceWindowManager;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.dom.client.Document;
import com.google.gwt.event.logical.shared.CloseEvent;
import com.google.gwt.event.logical.shared.CloseHandler;
import com.google.gwt.user.client.Window;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@Singleton
public class SatelliteManager implements CloseHandler<Window>
{
@Inject
public SatelliteManager(
Session session,
EventBus events,
Provider<SourceWindowManager> pSourceWindowManager,
Provider<ApplicationUncaughtExceptionHandler> pUncaughtExceptionHandler,
Provider<UIPrefs> pUIPrefs)
{
session_ = session;
events_ = events;
pUIPrefs_ = pUIPrefs;
pSourceWindowManager_ = pSourceWindowManager;
pUncaughtExceptionHandler_ = pUncaughtExceptionHandler;
initializeCommonCallbacks();
}
// the main window should call this method during startup to set itself
// up to manage and communicate with the satellite windows
public void initialize()
{
// export the registration hook used by satellites
exportSatelliteRegistrationCallback();
// handle onClosed to automatically close all satellites
Window.addCloseHandler(this);
}
public void openSatellite(String name,
JavaScriptObject params,
Size preferredSize)
{
openSatellite(name, params, preferredSize, true);
}
public void openSatellite(String name,
JavaScriptObject params,
Size preferredSize,
boolean activate)
{
openSatellite(name, params, preferredSize, true, null, activate);
}
public void openSatellite(String name,
JavaScriptObject params,
Size preferredSize,
Point position,
boolean activate)
{
openSatellite(name, params, preferredSize, true, position, activate);
}
public void openSatellite(String name,
JavaScriptObject params,
Size preferredSize,
boolean adjustSize,
Point position)
{
openSatellite(name, params, preferredSize, adjustSize, position, true);
}
// open a satellite window (optionally activate existing)
public void openSatellite(String name,
JavaScriptObject params,
Size preferredSize,
boolean adjustSize,
Point position,
boolean activate)
{
// satellites can't launch other satellites -- this is because the
// delegating/forwarding of remote server calls and events doesn't
// cascade correctly -- it wouldn't be totally out of the question
// to make this work but we'd rather not have this complexity
// if we don't need to.
if (isCurrentWindowSatellite())
{
Debug.log("Satellite windows can't launch other satellites");
assert false;
return;
}
// check for a re-activation of an existing window
for (ActiveSatellite satellite : satellites_)
{
if (satellite.getName().equals(name))
{
WindowEx window = satellite.getWindow();
if (!window.isClosed())
{
// for web mode bring the window to the front, notify
// it that it has been reactivated, then exit.
if (!Desktop.isDesktop())
{
// don't do this for chrome (since it doesn't allow
// window.focus). for chrome we'll just fall through
// and openSatelliteWindow will be called and the
// window will be reloaded)
if (!BrowseCap.isChrome() || !activate)
{
if (activate)
window.focus();
callNotifyReactivated(window, params);
return;
}
else
{
// for Chrome, let the window know it's about to be
// closed and reopened
callNotifyPendingReactivate(window);
}
}
// desktop mode: activate and return
else
{
if (activate)
{
Desktop.getFrame().activateSatelliteWindow(
SatelliteUtils.getSatelliteWindowName(satellite.getName()));
}
callNotifyReactivated(window, params);
return;
}
}
}
}
// Start buffering events sent to this satellite. That way, we won't miss
// anything while the satellite is being loaded/reactivated
if (!pendingEventsBySatelliteName_.containsKey(name))
{
pendingEventsBySatelliteName_.put(name,
new ArrayList<JavaScriptObject>());
}
// record satellite params for subsequent setting (this value is read
// by the satellite within the call to registerAsSatellite)
if (params != null)
satelliteParams_.put(name, params);
// set size and position, if desired
Size windowSize = adjustSize ?
ScreenUtils.getAdjustedWindowSize(preferredSize) :
preferredSize;
NewWindowOptions options = new NewWindowOptions();
options.setFocus(activate);
if (position != null)
options.setPosition(position);
// open the satellite - it will call us back on registerAsSatellite
// at which time we'll call setSessionInfo, setParams, etc.
RStudioGinjector.INSTANCE.getGlobalDisplay().openSatelliteWindow(
name,
windowSize.width,
windowSize.height,
options);
}
// Forcefully reopen a satellite window. This refreshes the window and
// pushes it to the front in Chrome. It should be used as a last resort;
// if responding to a UI event, use openSatellite instead, since Chrome
// permits window.open to reactivate windows in that context.
public void forceReopenSatellite(final String name,
final JavaScriptObject params,
boolean activate)
{
Size preferredSize = null;
Point preferredPos = null;
for (ActiveSatellite satellite : satellites_)
{
if (satellite.getName().equals(name) &&
!satellite.getWindow().isClosed())
{
// save the window's geometry so we can restore it after the window
// is destroyed
final WindowEx win = satellite.getWindow();
Document doc = win.getDocument();
preferredSize = new Size(doc.getClientWidth(), doc.getClientHeight());
preferredPos = new Point(win.getLeft(), win.getTop());
callNotifyPendingReactivate(win);
satellite.close();
break;
}
}
// didn't find an open window to reopen
if (preferredSize == null)
return;
// open a new window with the same geometry as the one we just destroyed,
// but with the newly supplied set of parameters
final Size windowSize = preferredSize;
final Point windowPos = preferredPos;
openSatellite(name, params, windowSize, false, windowPos, activate);
}
public boolean satelliteWindowExists(String name)
{
return getSatelliteWindowObject(name) != null;
}
public WindowEx getSatelliteWindowObject(String name)
{
for (ActiveSatellite satellite : satellites_)
if (satellite.getName().equals(name) &&
!satellite.getWindow().isClosed())
return satellite.getWindow();
return null;
}
public void activateSatelliteWindow(String name)
{
if (Desktop.isDesktop())
{
Desktop.getFrame().activateSatelliteWindow(
SatelliteUtils.getSatelliteWindowName(name));
}
else
{
for (ActiveSatellite satellite : satellites_)
{
if (satellite.getName().equals(name) &&
!satellite.getWindow().isClosed())
{
satellite.getWindow().focus();
break;
}
}
}
}
public boolean getSatellitesOpen()
{
return satellites_.size() >= 1;
}
// close all satellite windows
public void closeAllSatellites()
{
// let anyone interested know that we're tearing things down; this allows
// listeners to distinguish between user-initiated window closure and
// the closes we initiate on shutdown/restart/quit/etc.
events_.fireEvent(new AllSatellitesClosingEvent());
for (ActiveSatellite satellite : satellites_)
{
satellite.close();
}
satellites_.clear();
pendingEventsBySatelliteName_.clear();
}
public String getWindowAtPoint(int x, int y)
{
// check to see if the point is in any of our satellites
for (ActiveSatellite satellite : satellites_)
{
if (satellite.getWindow() == null || satellite.getWindow().isClosed())
continue;
if (isPointWithinSatellite(satellite.getWindow(), x, y))
return satellite.getName();
}
// check to see if the point is inside our own window
if (DomUtils.elementFromPoint(x - WindowEx.get().getScreenX(),
y - WindowEx.get().getScreenY()) != null)
{
return "";
}
return null;
}
// close one satellite window
public void closeSatelliteWindow(String name)
{
for (ActiveSatellite satellite : satellites_)
{
if (satellite.getName().equals(name) &&
!satellite.getWindow().isClosed())
{
satellite.close();
break;
}
}
}
// dispatch an event to all satellites
public void dispatchClientEvent(JavaScriptObject clientEvent)
{
// list of windows to remove (because they were closed)
ArrayList<ActiveSatellite> removeWindows = null;
// iterate over the satellites (make a copy to avoid races if
// for some reason firing an event creates or destroys a satellite)
@SuppressWarnings("unchecked")
ArrayList<ActiveSatellite> satellites =
(ArrayList<ActiveSatellite>)satellites_.clone();
for (ActiveSatellite satellite : satellites)
{
try
{
// If we're buffering events for this satellite, then don't dispatch
// them
if (pendingEventsBySatelliteName_.containsKey(satellite.getName()))
continue;
WindowEx satelliteWnd = satellite.getWindow();
if (satelliteWnd.isClosed())
{
if (removeWindows == null)
removeWindows = new ArrayList<ActiveSatellite>();
removeWindows.add(satellite);
}
else
{
callDispatchEvent(satelliteWnd, clientEvent);
}
}
catch(Throwable e)
{
}
}
for (Entry<String, ArrayList<JavaScriptObject>> entry :
pendingEventsBySatelliteName_.entrySet())
{
entry.getValue().add(clientEvent);
}
// remove windows if necessary
if (removeWindows != null)
{
for (ActiveSatellite satellite : removeWindows)
{
satellites_.remove(satellite);
}
}
}
// dispatch a command to the named satellite window, or to the main window
// if no target is specified
public void dispatchCommand(AppCommand command, String target)
{
if (StringUtil.isNullOrEmpty(target))
{
callDispatchCommandMain(command.getId());
}
else
{
for (ActiveSatellite satellite: satellites_)
{
if (satellite.getName() == target)
{
callDispatchCommandSatellite(
satellite.getWindow(), command.getId());
}
}
}
}
// dispatch a cross-window event to all satellites
public void dispatchCrossWindowEvent(CrossWindowEvent<?> event)
{
for (ActiveSatellite satellite: satellites_)
{
events_.fireEventToSatellite(event, satellite.getWindow());
}
}
// close all satellites when we are closed
@Override
public void onClose(CloseEvent<Window> event)
{
closeAllSatellites();
}
// call notifyPendingReactivate on a satellite
public native static void callNotifyPendingReactivate(JavaScriptObject satellite) /*-{
satellite.notifyPendingReactivate();
}-*/;
// call notifyPendingClosure on a satellite
public native static void callNotifyPendingClosure(JavaScriptObject satellite) /*-{
satellite.notifyPendingClosure();
}-*/;
// called by satellites to connect themselves with the main window
private void registerAsSatellite(final String name, JavaScriptObject wnd)
{
// get the satellite and add it to our list. in some cases (such as
// the Ctrl+R reload of an existing satellite window) we actually
// already have a reference to this satellite in our list so in that
// case we make sure not to add a duplicate
WindowEx satelliteWnd = wnd.<WindowEx>cast();
ActiveSatellite satellite = new ActiveSatellite(name, satelliteWnd);
if (!satellites_.contains(satellite))
satellites_.add(satellite);
// augment the current session info with an up-to-date set of source
// documents
SessionInfo sessionInfo = session_.getSessionInfo();
sessionInfo.setSourceDocuments(
pSourceWindowManager_.get().getSourceDocs());
// clone the session info so the satellites aren't reading/writing the
// same copy as the main window; pass the cloned copy along
JsObject sessionInfoJs = session_.getSessionInfo().cast();
callSetSessionInfo(satelliteWnd, sessionInfoJs.clone());
// call setParams
JavaScriptObject params = satelliteParams_.get(name);
if (params != null)
callSetParams(satelliteWnd, params);
// set themes
events_.fireEventToSatellite(new ThemeChangedEvent(
pUIPrefs_.get().getFlatTheme().getGlobalValue()),
satelliteWnd);
}
// called to register child windows (not necessarily full-fledged
// satellites). only used in desktop mode, since in server mode we have the
// child window object as a return value from window.open.
private void registerDesktopChildWindow (String name, JavaScriptObject window)
{
events_.fireEvent(new WindowOpenedEvent(name, (WindowEx) window.cast()));
}
private void unregisterDesktopChildWindow (String name)
{
if (SatelliteUtils.windowNameIsSatellite(name))
name = SatelliteUtils.getWindowNameFromSatelliteName(name);
events_.fireEvent(new WindowClosedEvent(name));
events_.fireEvent(new SatelliteClosedEvent(name));
// remove this satellite from the list of active satellites; ordinarily
// we'd rely on the window object's isClosed() method, but it's possible
// in some cases for the window to enter a zombie state in which it exists
// and reports as open even after being closed, apparently due to
// exceptions occurring during teardown (see case 4436).
for (ActiveSatellite satellite: satellites_)
{
if (satellite.getName() == name)
{
satellites_.remove(satellite);
}
}
}
private void flushPendingEvents(String name)
{
ArrayList<JavaScriptObject> events =
pendingEventsBySatelliteName_.remove(name);
if (events == null || events.size() == 0)
return;
for (ActiveSatellite satellite :
new ArrayList<ActiveSatellite>(satellites_))
{
if (satellite.getName().equals(name)
&& !satellite.getWindow().isClosed())
{
for (JavaScriptObject evt : events)
{
try
{
callDispatchEvent(satellite.getWindow(), evt);
}
catch (Exception e)
{
pUncaughtExceptionHandler_.get().onUncaughtException(e);
}
}
}
}
}
// export the global function required for satellites to register
private native void exportSatelliteRegistrationCallback() /*-{
var manager = this;
$wnd.$RStudio = {};
$wnd.registerAsRStudioSatellite = $entry(
function(name, satelliteWnd) {
manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::registerAsSatellite(Ljava/lang/String;Lcom/google/gwt/core/client/JavaScriptObject;)(name, satelliteWnd);
}
);
$wnd.flushPendingEvents = $entry(
function(name) {
manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::flushPendingEvents(Ljava/lang/String;)(name);
}
);
$wnd.registerDesktopChildWindow = $entry(
function(name, wnd) {
manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::registerDesktopChildWindow(Ljava/lang/String;Lcom/google/gwt/core/client/JavaScriptObject;)(name, wnd);
}
);
$wnd.unregisterDesktopChildWindow = $entry(
function(name, wnd) {
manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::unregisterDesktopChildWindow(Ljava/lang/String;)(name);
}
);
$wnd.notifyRStudioSatelliteClosed = $entry(
function(name) {
manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::notifyRStudioSatelliteClosed(Ljava/lang/String;)(name);
}
);
}-*/;
private final native void initializeCommonCallbacks() /*-{
var manager = this;
$wnd.dispatchRStudioCommandExternal = $entry(
function(commandId) {
manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::dispatchCommandExternal(Ljava/lang/String;)(commandId);
}
);
$wnd.getWindowAtPoint = $entry(
function(x, y) {
return manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::getWindowAtPoint(II)(x,y);
}
);
}-*/;
private void dispatchCommandExternal(String commandId)
{
AppCommand cmd = RStudioGinjector.INSTANCE.getCommands()
.getCommandById(commandId);
if (cmd != null)
cmd.execute();
}
private void notifyRStudioSatelliteClosed(String name)
{
events_.fireEvent(new SatelliteClosedEvent(name));
}
// call setSessionInfo on a satellite
private native void callSetSessionInfo(JavaScriptObject satellite,
JavaScriptObject sessionInfo) /*-{
satellite.setRStudioSatelliteSessionInfo(sessionInfo);
}-*/;
// call setParams on a satellite
private native void callSetParams(JavaScriptObject satellite,
JavaScriptObject params) /*-{
satellite.setRStudioSatelliteParams(params);
}-*/;
// call notifyReactivated on a satellite
private native void callNotifyReactivated(JavaScriptObject satellite,
JavaScriptObject params) /*-{
satellite.notifyRStudioSatelliteReactivated(params);
}-*/;
// dispatch event to a satellite
private native void callDispatchEvent(JavaScriptObject satellite,
JavaScriptObject clientEvent) /*-{
satellite.dispatchEventToRStudioSatellite(clientEvent);
}-*/;
// dispatch command to a satellite
private native void callDispatchCommandSatellite(JavaScriptObject satellite,
String commandId) /*-{
satellite.dispatchRStudioCommandExternal(commandId);
}-*/;
private native boolean isPointWithinSatellite(JavaScriptObject satellite,
int x, int y) /*-{
return satellite.isPointWithinSatellite(x, y);
}-*/;
private native void callDispatchCommandMain(String commandId) /*-{
$wnd.opener.dispatchRStudioCommandExternal(commandId);
}-*/;
// check whether the current window is a satellite (note this method
// is also implemented in the Satellite class -- we don't want this class
// to depend on Satellite so we duplicate the definition)
private native boolean isCurrentWindowSatellite() /*-{
return !!$wnd.isRStudioSatellite;
}-*/;
// alert callback (used for testing html preview sandbox)
//private void showAlert(String message)
//{
// RStudioGinjector.INSTANCE.getGlobalDisplay().showErrorMessage("Alert",
// message);
//}
//private native void exportSatelliteAlertCallback() /*-{
// var manager = this;
// $wnd.rstudioSatelliteAlert = $entry(
// function(message) {
// manager.@org.rstudio.studio.client.common.satellite.SatelliteManager::showAlert(Ljava/lang/String;)(message);
// }
// );
//}-*/;
private final Session session_;
private final EventBus events_;
private final Provider<SourceWindowManager> pSourceWindowManager_;
private final Provider<ApplicationUncaughtExceptionHandler> pUncaughtExceptionHandler_;
private final ArrayList<ActiveSatellite> satellites_ =
new ArrayList<ActiveSatellite>();
private final HashMap<String,JavaScriptObject> satelliteParams_ =
new HashMap<String,JavaScriptObject>();
private final HashMap<String, ArrayList<JavaScriptObject>>
pendingEventsBySatelliteName_ = new HashMap<String, ArrayList<JavaScriptObject>>();
private class ActiveSatellite
{
public ActiveSatellite(String name, WindowEx window)
{
name_ = name;
window_ = window;
}
public String getName()
{
return name_;
}
public WindowEx getWindow()
{
return window_;
}
public void close()
{
try
{
callNotifyPendingClosure(getWindow());
getWindow().close();
}
catch(Throwable e)
{
}
}
@Override
public boolean equals(Object other)
{
if (other == null)
return false;
ActiveSatellite otherSatellite = (ActiveSatellite)other;
return getName().equals(otherSatellite.getName()) &&
getWindow().equals(otherSatellite.getWindow());
}
private final String name_;
private final WindowEx window_;
}
private final Provider<UIPrefs> pUIPrefs_;
}