View Javadoc
1   /**
2    * This file Copyright (c) 2012-2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.ui.framework.app;
35  
36  import info.magnolia.cms.security.User;
37  import info.magnolia.cms.security.operations.AccessDefinition;
38  import info.magnolia.config.registry.DefinitionProvider;
39  import info.magnolia.config.registry.Registry;
40  import info.magnolia.context.Context;
41  import info.magnolia.event.EventBus;
42  import info.magnolia.i18nsystem.SimpleTranslator;
43  import info.magnolia.objectfactory.ComponentProvider;
44  import info.magnolia.objectfactory.Components;
45  import info.magnolia.ui.api.app.App;
46  import info.magnolia.ui.api.app.AppContext;
47  import info.magnolia.ui.api.app.AppController;
48  import info.magnolia.ui.api.app.AppDescriptor;
49  import info.magnolia.ui.api.app.AppInstanceController;
50  import info.magnolia.ui.api.app.AppLifecycleEvent;
51  import info.magnolia.ui.api.app.AppLifecycleEventType;
52  import info.magnolia.ui.api.app.ChooseDialogCallback;
53  import info.magnolia.ui.api.app.registry.AppDescriptorRegistry;
54  import info.magnolia.ui.api.context.UiContext;
55  import info.magnolia.ui.api.event.AdmincentralEventBus;
56  import info.magnolia.ui.api.ioc.AdmincentralScoped;
57  import info.magnolia.ui.api.location.DefaultLocation;
58  import info.magnolia.ui.api.location.Location;
59  import info.magnolia.ui.api.location.LocationChangeRequestedEvent;
60  import info.magnolia.ui.api.location.LocationChangedEvent;
61  import info.magnolia.ui.api.location.LocationController;
62  import info.magnolia.ui.api.message.Message;
63  import info.magnolia.ui.api.message.MessageType;
64  import info.magnolia.ui.api.shell.CloseAppEvent;
65  import info.magnolia.ui.api.view.Viewport;
66  import info.magnolia.ui.framework.app.stub.FailedAppStub;
67  import info.magnolia.ui.framework.ioc.AdmincentralFlavour;
68  import info.magnolia.ui.framework.ioc.SessionStore;
69  import info.magnolia.ui.framework.ioc.UiContextBoundComponentProvider;
70  import info.magnolia.ui.framework.ioc.UiContextReference;
71  import info.magnolia.ui.framework.message.MessagesManager;
72  
73  import java.util.HashMap;
74  import java.util.LinkedList;
75  import java.util.Map;
76  import java.util.Objects;
77  
78  import javax.inject.Inject;
79  import javax.inject.Named;
80  
81  import org.apache.commons.lang3.StringUtils;
82  import org.slf4j.Logger;
83  import org.slf4j.LoggerFactory;
84  
85  import com.google.inject.Key;
86  
87  /**
88   * Implementation of the {@link info.magnolia.ui.api.app.AppController}.
89   *
90   * The App controller that manages the lifecycle of running apps and raises callbacks to the app.
91   * It provides methods to start, stop and focus already running {@link info.magnolia.ui.api.app.App}s.
92   * Registers handlers to the following location change events triggered by the {@link LocationController}:
93   * <ul>
94   * <li>{@link LocationChangedEvent}</li>
95   * <li>{@link LocationChangeRequestedEvent}</li>
96   * </ul>
97   *
98   * @see LocationController
99   * @see info.magnolia.ui.api.app.AppContext
100  * @see info.magnolia.ui.api.app.App
101  */
102 @AdmincentralScoped
103 public class AppControllerImpl implements AppController, LocationChangedEvent.Handler, LocationChangeRequestedEvent.Handler, CloseAppEvent.Handler {
104 
105     private static final Logger log = LoggerFactory.getLogger(AppControllerImpl.class);
106 
107     private final ComponentProvider componentProvider;
108     private final AppDescriptorRegistry appDescriptorRegistry;
109     private final LocationController locationController;
110     private final EventBus eventBus;
111     private final Map<String, AppInstanceController> runningApps = new HashMap<>();
112     private final LinkedList<AppInstanceController> appHistory = new LinkedList<>();
113     private final MessagesManager messagesManager;
114     private final SimpleTranslator i18n;
115     private final User user;
116 
117     private Map<String, AppInstanceController> appInstances = new HashMap<>();
118     private Viewport viewport;
119     private AppInstanceController currentAppInstanceController;
120 
121     @Inject
122     public AppControllerImpl(ComponentProvider componentProvider, AppDescriptorRegistry appDescriptorRegistry, LocationController locationController, @Named(AdmincentralEventBus.NAME) EventBus admincentralEventBus, MessagesManager messagesManager, SimpleTranslator i18n, Context context) {
123         this.componentProvider = componentProvider;
124         this.appDescriptorRegistry = appDescriptorRegistry;
125         this.locationController = locationController;
126         this.eventBus = admincentralEventBus;
127         this.messagesManager = messagesManager;
128         this.i18n = i18n;
129         this.user = context.getUser();
130 
131         admincentralEventBus.addHandler(LocationChangedEvent.class, this);
132         admincentralEventBus.addHandler(LocationChangeRequestedEvent.class, this);
133         admincentralEventBus.addHandler(CloseAppEvent.class, this);
134     }
135 
136     /**
137      * @deprecated since 6.0 - use {@link AppControllerImpl(ComponentProvider, AppDescriptorRegistry, LocationController, EventBus, MessagesManager, SimpleTranslator, Context)} instead.
138      */
139     @Deprecated
140     public AppControllerImpl(ComponentProvider componentProvider, AppDescriptorRegistry appDescriptorRegistry, LocationController locationController, @Named(AdmincentralEventBus.NAME) EventBus admincentralEventBus, MessagesManager messagesManager, SimpleTranslator i18n) {
141         this(componentProvider, appDescriptorRegistry, locationController, admincentralEventBus, messagesManager, i18n, Components.getComponent(Context.class));
142     }
143 
144     @Override
145     public void setViewport(Viewport viewport) {
146         this.viewport = viewport;
147     }
148 
149     @Override
150     public void onCloseApp(CloseAppEvent event) {
151         stopCurrentApp();
152     }
153 
154     /**
155      * This method is called to create an instance of an app independent from the {@link LocationController} and the {@link AppController} handling.
156      * It will not open in the {@link info.magnolia.ui.api.view.Viewport} and will not register itself to the running apps.
157      * This is e.g. used to pass the {@link info.magnolia.ui.api.app.App} into a dialog and obtain app-specific information from outside the app.
158      *
159      * @param appName of the {@link info.magnolia.ui.api.app.App} to instantiate.
160      */
161     private App getAppWithoutStarting(String appName) {
162         AppInstanceController appInstanceController = createNewAppInstance(appName);
163         ComponentProvider appComponentProvider = createAppComponentProvider(appInstanceController);
164         App app = appComponentProvider.newInstance(appInstanceController.getAppDescriptor().getAppClass());
165 
166         appInstanceController.setApp(app);
167         return app;
168     }
169 
170     /**
171      * This method can be called to launch an {@link App} and then delegate it to the {@link LocationController}.
172      * It should have the same effect as calling the {@link LocationController} directly.
173      *
174      * @param appName of the {@link App} to start.
175      * @param location holds information about the subApp to use and the parameters.
176      */
177     public App startIfNotAlreadyRunningThenFocus(String appName, Location location) {
178         AppInstanceController appInstanceController = getAppInstance(appName);
179         appInstanceController = doStartIfNotAlreadyRunning(appInstanceController, location);
180         doFocus(appInstanceController);
181         return appInstanceController.getApp();
182     }
183 
184     @Override
185     public void stopApp(String appName) {
186         final AppInstanceController appInstanceController = runningApps.get(appName);
187         if (appInstanceController != null) {
188             doStop(appInstanceController);
189         }
190     }
191 
192     @Override
193     public void stopCurrentApp() {
194         if (currentAppInstanceController.isHeaderApp()) {
195             stopApp(currentAppInstanceController.getName());
196         } else {
197             final AppInstanceController appInstanceController = appHistory.peekFirst();
198             if (appInstanceController != null) {
199                 doStop(appInstanceController);
200             }
201         }
202     }
203 
204     @Override
205     public boolean isAppStarted(String appName) {
206         return runningApps.containsKey(appName);
207     }
208 
209     @Override
210     public void focusCurrentApp() {
211         if (currentAppInstanceController != null) {
212             doFocus(currentAppInstanceController);
213         }
214     }
215 
216     @Override
217     public App getCurrentApp() {
218         return currentAppInstanceController == null ? null : currentAppInstanceController.getApp();
219     }
220 
221     /**
222      * Returns the current location of the focused app. This can differ from the actual location of the admin central, e.g. when a shell app is open.
223      *
224      * @see info.magnolia.ui.api.location.LocationController#getWhere()
225      */
226     @Override
227     public Location getCurrentAppLocation() {
228         return currentAppInstanceController == null ? null : currentAppInstanceController.getCurrentLocation();
229     }
230 
231     /**
232      * Returns the current location of a running app instance or null, if it is not running. The App does not have to be focused.
233      */
234     @Override
235     public Location getAppLocation(String appName) {
236         AppInstanceController appInstanceController = runningApps.get(appName);
237         return appInstanceController == null ? null : appInstanceController.getCurrentLocation();
238     }
239 
240     /**
241      * Delegates the starting of an {@link App} to the {@link info.magnolia.ui.api.app.AppContext}. In
242      * case the app is already started, it will update its location.
243      */
244     private AppInstanceController doStartIfNotAlreadyRunning(AppInstanceController appInstanceController, Location location) {
245         if (isAppStarted(appInstanceController.getAppDescriptor().getName())) {
246             appInstanceController.onLocationUpdate(location);
247             sendEvent(AppLifecycleEventType.FOCUSED, appInstanceController.getAppDescriptor());
248             return appInstanceController;
249         }
250 
251         runningApps.put(appInstanceController.getAppDescriptor().getName(), appInstanceController);
252         appInstanceController.start(location);
253         sendEvent(AppLifecycleEventType.STARTED, appInstanceController.getAppDescriptor());
254         return appInstanceController;
255     }
256 
257     /**
258      * Focuses an already running {@link App} by passing it to the
259      * {@link LocationController}.
260      */
261     private void doFocus(AppInstanceController appInstanceController) {
262         locationController.goTo(appInstanceController.getCurrentLocation());
263         // focus event is further sent if re-focusing an already open app
264         // see #doStartIfNotAlreadyRunning
265     }
266 
267     private void doStop(AppInstanceController appInstanceController) {
268         final Location currentLocation = locationController.getWhere();
269 
270         sendEvent(AppLifecycleEventType.STOPPED, appInstanceController.getAppDescriptor());
271         appInstanceController.stop();
272         while (appHistory.remove(appInstanceController)) {
273             ;
274         }
275 
276         final String appName = appInstanceController.getAppDescriptor().getName();
277         runningApps.remove(appName);
278         appInstances.remove(appName);
279 
280         if (currentAppInstanceController == appInstanceController) {
281             currentAppInstanceController = null;
282             viewport.setView(null);
283         }
284 
285         if (!appHistory.isEmpty()) {
286             doFocus(appHistory.peekFirst());
287         } else if (!AdmincentralFlavour.get().isM5()) {
288             // last app was stopped?
289             locationController.goTo(new DefaultLocation("home"));
290         }
291 
292         if (AdmincentralFlavour.get().isM5() &&
293                 StringUtils.equalsIgnoreCase(appName, currentLocation.getAppName())) {
294             // Only 'fallback' to app launcher we are closing the currently visible app
295             locationController.goTo(new DefaultLocation(Location.LOCATION_TYPE_SHELL_APP, "applauncher"));
296         }
297     }
298 
299     private void sendEvent(AppLifecycleEventType appEventType, AppDescriptor appDescriptor) {
300         eventBus.fireEvent(new AppLifecycleEvent(appDescriptor, appEventType));
301     }
302 
303     /**
304      * Takes care of {@link LocationChangedEvent}s by:
305      * <ul>
306      * <li>Obtaining the {@link AppDescriptor} associated with the {@link Location}.</li>
307      * <li>Creating a new {@link info.magnolia.ui.api.app.AppContext} if not running, otherwise obtain it from the running apps.</li>
308      * <li>Updating the {@Link Location} and redirecting in case of missing subAppId.</li>
309      * <li>Starting the App.</li>
310      * <li>Adding the {@link info.magnolia.ui.api.app.AppContext} to the appHistory.</li>
311      * <li>Setting the viewport and updating the current running app.</li>
312      * </ul>
313      */
314     @Override
315     public void onLocationChanged(LocationChangedEvent event) {
316         Location newLocation = event.getNewLocation();
317 
318         // Whenever app-locations come with empty params and app is already running, attempt to restore them
319         // This may happen from client side history states (doesn't remember app parameters), or from loose app-targeted DefaultLocations
320         String actualParam = newLocation.getParameter();
321         if (StringUtils.isEmpty(actualParam) && newLocation instanceof DefaultLocation) {
322             Location lastAppLocation = getAppLocation(newLocation.getAppName());
323             if (lastAppLocation != null) {
324                 ((DefaultLocation) newLocation).setParameter(lastAppLocation.getParameter());
325             }
326         }
327 
328         if (locationIsEmpty(newLocation)) {
329             return;
330         }
331 
332         if (!newLocation.getAppType().equals(Location.LOCATION_TYPE_APP)) {
333             return;
334         }
335 
336         if (newLocation.equals(getCurrentAppLocation())) {
337             return;
338         }
339 
340         if (!isAllowedToUser(newLocation)) {
341             return;
342         }
343 
344         AppDescriptor nextApp = getAppForLocation(newLocation);
345         if (nextApp == null) {
346             return;
347         }
348 
349         AppInstanceController nextAppContext = getAppInstance(nextApp.getName());
350 
351         // update location
352         Location updateLocation = updateLocation(nextAppContext, newLocation);
353         if (!updateLocation.equals(newLocation)) {
354             locationController.goTo(updateLocation);
355             return;
356         }
357 
358         if (currentAppInstanceController != nextAppContext) {
359             if (!nextAppContext.isHeaderApp()) {
360                 appHistory.addFirst(nextAppContext);
361             }
362             currentAppInstanceController = nextAppContext;
363         }
364 
365         nextAppContext = doStartIfNotAlreadyRunning(nextAppContext, newLocation);
366 
367         try {
368             viewport.setView(nextAppContext.getApp().getView());
369         } catch (Exception e) {
370             log.error("App {} failed to start: {}", nextApp.getName(), e.getMessage(), e);
371             App failedApp = componentProvider.newInstance(FailedAppStub.class, nextAppContext, e);
372             failedApp.start(newLocation);
373             viewport.setView(failedApp.getView());
374         }
375     }
376 
377     /**
378      * Updates the {@link Location} in case of missing subAppId:
379      * <ul>
380      * <li>If the app is running, it will fetch the current Location associated with the App.</li>
381      * <li>Will fetch the configured default subAppId otherwise.</li>
382      * </ul>
383      */
384     private Location updateLocation(AppInstanceController appInstanceController, Location location) {
385         String appType = location.getAppType();
386         String appName = location.getAppName();
387         String subAppId = location.getSubAppId();
388         String params = location.getParameter();
389 
390         if (StringUtils.isBlank(subAppId)) {
391 
392             if (isAppStarted(appName)) {
393                 AppInstanceController runningAppContext = runningApps.get(appName);
394                 subAppId = runningAppContext.getCurrentLocation().getSubAppId();
395             } else if (StringUtils.isBlank(subAppId)) {
396                 Location defaultLocation = appInstanceController.getDefaultLocation();
397                 if (defaultLocation != null) {
398                     subAppId = defaultLocation.getSubAppId();
399                 } else {
400                     log.warn("No default location could be found for the '{}' app, please check subapp configuration.", appName);
401                 }
402 
403             }
404         }
405 
406         return new DefaultLocation(appType, appName, subAppId, params);
407     }
408 
409     private AppInstanceController getAppInstance(String appName) {
410         return appInstances.computeIfAbsent(appName, this::createNewAppInstance);
411     }
412 
413     private AppInstanceController createNewAppInstance(String appName) {
414         AppDescriptor descriptor = getAppDescriptor(appName);
415         if (descriptor == null) {
416             sendAppDescriptorReadErrorNotification(appName);
417             return null;
418         }
419 
420         AppInstanceController appInstanceController = componentProvider.newInstance(AppInstanceController.class, descriptor);
421         createAppComponentProvider(appInstanceController);
422 
423         return appInstanceController;
424     }
425 
426     @Override
427     public void onLocationChangeRequested(LocationChangeRequestedEvent event) {
428         if (currentAppInstanceController != null) {
429             final String message = currentAppInstanceController.mayStop();
430             if (message != null) {
431                 event.setWarning(message);
432             }
433         }
434     }
435 
436     @Override
437     public void openChooseDialog(String appName, UiContext uiContext, String selectedId, ChooseDialogCallback callback) {
438         openChooseDialog(appName, uiContext, null, selectedId, callback);
439     }
440 
441     @Override
442     public void openChooseDialog(String appName, UiContext uiContext, String targetTreeRootPath, String selectedId, ChooseDialogCallback callback) {
443         App targetApp = getAppWithoutStarting(appName);
444         if (targetApp != null) {
445             final ChooseDialogCallback composedCallback = ChooseDialogCallback.composeWith(targetApp::stop, callback);
446             if (StringUtils.isNotBlank(targetTreeRootPath)) {
447                 targetApp.openChooseDialog(uiContext, targetTreeRootPath, selectedId, composedCallback);
448             } else {
449                 targetApp.openChooseDialog(uiContext, selectedId, callback);
450             }
451         }
452     }
453 
454     private AppDescriptor getAppForLocation(Location location) {
455         return getAppDescriptor(location.getAppName());
456     }
457 
458     private AppDescriptor getAppDescriptor(String name) throws RuntimeException {
459         final DefinitionProvider<AppDescriptor> definitionProvider;
460         try {
461             definitionProvider = appDescriptorRegistry.getProvider(name);
462         } catch (Registry.NoSuchDefinitionException | IllegalStateException e) {
463             sendAppDescriptorReadErrorNotification(name);
464             throw new RuntimeException(e);
465         }
466         return definitionProvider.get();
467     }
468 
469     private void sendAppDescriptorReadErrorNotification(String name) {
470         Message errorMessage = new Message();
471         errorMessage.setType(MessageType.ERROR);
472         errorMessage.setSubject(i18n.translate("ui-framework.app.appdescriptorReadError.subject"));
473         errorMessage.setMessage(String.format(i18n.translate("ui-framework.app.appdescriptorReadError.message"), name));
474         messagesManager.sendLocalMessage(errorMessage);
475     }
476 
477     private ComponentProvider createAppComponentProvider(AppInstanceController appInstanceController) {
478         final UiContextReference uiContextReference = UiContextReference.ofApp(appInstanceController);
479         final UiContextBoundComponentProvidertml#UiContextBoundComponentProvider">UiContextBoundComponentProvider appComponentProvider = new UiContextBoundComponentProvider(uiContextReference);
480         appInstanceController.setAppComponentProvider(appComponentProvider);
481 
482         final Map<Key, Object> initialScopedInstances = new HashMap<>();
483         initialScopedInstances.put(Key.get(AppContext.class), appInstanceController);
484         initialScopedInstances.put(Key.get(AppDescriptor.class), appInstanceController.getAppDescriptor());
485 
486         SessionStore.access().createBeanStore(uiContextReference, initialScopedInstances);
487 
488         return appComponentProvider;
489     }
490 
491     private boolean locationIsEmpty(Location location) {
492         return Objects.equals(Location.NOWHERE, location) || (StringUtils.equals(Location.NOWHERE.getAppType(), location.getAppType())
493                 && StringUtils.equals(Location.NOWHERE.getAppName(), location.getAppName())
494                 && StringUtils.equals(Location.NOWHERE.getSubAppId(), location.getSubAppId())
495                 && StringUtils.equals(Location.NOWHERE.getParameter(), location.getParameter()));
496     }
497 
498     private boolean isAllowedToUser(final Location location) {
499         if (Location.LOCATION_TYPE_SHELL_APP.equals(location.getAppType())) {
500             return true;
501         }
502         final AppDescriptor appDescriptor = getAppDescriptor(location.getAppName());
503         final AccessDefinition permissions = appDescriptor.getPermissions();
504 
505         return appDescriptor.isEnabled() && (permissions == null || permissions.hasAccess(user));
506     }
507 }