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  import java.util.Optional;
78  
79  import javax.inject.Inject;
80  import javax.inject.Named;
81  
82  import org.apache.commons.lang3.StringUtils;
83  import org.slf4j.Logger;
84  import org.slf4j.LoggerFactory;
85  
86  import com.google.inject.Key;
87  
88  /**
89   * Implementation of the {@link info.magnolia.ui.api.app.AppController}.
90   *
91   * The App controller that manages the lifecycle of running apps and raises callbacks to the app.
92   * It provides methods to start, stop and focus already running {@link info.magnolia.ui.api.app.App}s.
93   * Registers handlers to the following location change events triggered by the {@link LocationController}:
94   * <ul>
95   * <li>{@link LocationChangedEvent}</li>
96   * <li>{@link LocationChangeRequestedEvent}</li>
97   * </ul>
98   *
99   * @see LocationController
100  * @see info.magnolia.ui.api.app.AppContext
101  * @see info.magnolia.ui.api.app.App
102  */
103 @AdmincentralScoped
104 public class AppControllerImpl implements AppController, LocationChangedEvent.Handler, LocationChangeRequestedEvent.Handler, CloseAppEvent.Handler {
105 
106     private static final Logger log = LoggerFactory.getLogger(AppControllerImpl.class);
107 
108     private final ComponentProvider componentProvider;
109     private final AppDescriptorRegistry appDescriptorRegistry;
110     private final LocationController locationController;
111     private final EventBus eventBus;
112     private final Map<String, AppInstanceController> runningApps = new HashMap<>();
113     private final LinkedList<AppInstanceController> appHistory = new LinkedList<>();
114     private final MessagesManager messagesManager;
115     private final SimpleTranslator i18n;
116     private final User user;
117 
118     private Map<String, AppInstanceController> appInstances = new HashMap<>();
119     private Viewport viewport;
120     private AppInstanceController currentAppInstanceController;
121 
122     @Inject
123     public AppControllerImpl(ComponentProvider componentProvider, AppDescriptorRegistry appDescriptorRegistry, LocationController locationController, @Named(AdmincentralEventBus.NAME) EventBus admincentralEventBus, MessagesManager messagesManager, SimpleTranslator i18n, Context context) {
124         this.componentProvider = componentProvider;
125         this.appDescriptorRegistry = appDescriptorRegistry;
126         this.locationController = locationController;
127         this.eventBus = admincentralEventBus;
128         this.messagesManager = messagesManager;
129         this.i18n = i18n;
130         this.user = context.getUser();
131 
132         admincentralEventBus.addHandler(LocationChangedEvent.class, this);
133         admincentralEventBus.addHandler(LocationChangeRequestedEvent.class, this);
134         admincentralEventBus.addHandler(CloseAppEvent.class, this);
135     }
136 
137     /**
138      * @deprecated since 6.0 - use {@link AppControllerImpl(ComponentProvider, AppDescriptorRegistry, LocationController, EventBus, MessagesManager, SimpleTranslator, Context)} instead.
139      */
140     @Deprecated
141     public AppControllerImpl(ComponentProvider componentProvider, AppDescriptorRegistry appDescriptorRegistry, LocationController locationController, @Named(AdmincentralEventBus.NAME) EventBus admincentralEventBus, MessagesManager messagesManager, SimpleTranslator i18n) {
142         this(componentProvider, appDescriptorRegistry, locationController, admincentralEventBus, messagesManager, i18n, Components.getComponent(Context.class));
143     }
144 
145     @Override
146     public void setViewport(Viewport viewport) {
147         this.viewport = viewport;
148     }
149 
150     @Override
151     public void onCloseApp(CloseAppEvent event) {
152         stopCurrentApp();
153     }
154 
155     /**
156      * This method is called to create an instance of an app independent from the {@link LocationController} and the {@link AppController} handling.
157      * It will not open in the {@link info.magnolia.ui.api.view.Viewport} and will not register itself to the running apps.
158      * 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.
159      *
160      * @param appName of the {@link info.magnolia.ui.api.app.App} to instantiate.
161      */
162     private App getAppWithoutStarting(String appName) {
163         AppInstanceController appInstanceController = createNewAppInstance(appName);
164         ComponentProvider appComponentProvider = createAppComponentProvider(appInstanceController);
165         App app = appComponentProvider.newInstance(appInstanceController.getAppDescriptor().getAppClass());
166 
167         appInstanceController.setApp(app);
168         return app;
169     }
170 
171     /**
172      * This method can be called to launch an {@link App} and then delegate it to the {@link LocationController}.
173      * It should have the same effect as calling the {@link LocationController} directly.
174      *
175      * @param appName of the {@link App} to start.
176      * @param location holds information about the subApp to use and the parameters.
177      */
178     public App startIfNotAlreadyRunningThenFocus(String appName, Location location) {
179         AppInstanceController appInstanceController = getAppInstance(appName);
180         appInstanceController = doStartIfNotAlreadyRunning(appInstanceController, location);
181         doFocus(appInstanceController);
182         return appInstanceController.getApp();
183     }
184 
185     @Override
186     public void stopApp(String appName) {
187         final AppInstanceController appInstanceController = runningApps.get(appName);
188         if (appInstanceController != null) {
189             doStop(appInstanceController);
190         }
191     }
192 
193     @Override
194     public void stopCurrentApp() {
195         if (currentAppInstanceController.isHeaderApp()) {
196             stopApp(currentAppInstanceController.getName());
197         } else {
198             final AppInstanceController appInstanceController = appHistory.peekFirst();
199             if (appInstanceController != null) {
200                 doStop(appInstanceController);
201             }
202         }
203     }
204 
205     @Override
206     public boolean isAppStarted(String appName) {
207         return runningApps.containsKey(appName);
208     }
209 
210     @Override
211     public void focusCurrentApp() {
212         if (currentAppInstanceController != null) {
213             doFocus(currentAppInstanceController);
214         }
215     }
216 
217     @Override
218     public App getCurrentApp() {
219         return currentAppInstanceController == null ? null : currentAppInstanceController.getApp();
220     }
221 
222     /**
223      * 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.
224      *
225      * @see info.magnolia.ui.api.location.LocationController#getWhere()
226      */
227     @Override
228     public Location getCurrentAppLocation() {
229         return currentAppInstanceController == null ? null : currentAppInstanceController.getCurrentLocation();
230     }
231 
232     /**
233      * Returns the current location of a running app instance or null, if it is not running. The App does not have to be focused.
234      */
235     @Override
236     public Location getAppLocation(String appName) {
237         AppInstanceController appInstanceController = runningApps.get(appName);
238         return appInstanceController == null ? null : appInstanceController.getCurrentLocation();
239     }
240 
241     /**
242      * Delegates the starting of an {@link App} to the {@link info.magnolia.ui.api.app.AppContext}. In
243      * case the app is already started, it will update its location.
244      */
245     private AppInstanceController doStartIfNotAlreadyRunning(AppInstanceController appInstanceController, Location location) {
246         if (isAppStarted(appInstanceController.getAppDescriptor().getName())) {
247             appInstanceController.onLocationUpdate(location);
248             sendEvent(AppLifecycleEventType.FOCUSED, appInstanceController.getAppDescriptor());
249             return appInstanceController;
250         }
251 
252         runningApps.put(appInstanceController.getAppDescriptor().getName(), appInstanceController);
253         appInstanceController.start(location);
254         sendEvent(AppLifecycleEventType.STARTED, appInstanceController.getAppDescriptor());
255         return appInstanceController;
256     }
257 
258     /**
259      * Focuses an already running {@link App} by passing it to the
260      * {@link LocationController}.
261      */
262     private void doFocus(AppInstanceController appInstanceController) {
263         locationController.goTo(appInstanceController.getCurrentLocation());
264         // focus event is further sent if re-focusing an already open app
265         // see #doStartIfNotAlreadyRunning
266     }
267 
268     private void doStop(AppInstanceController appInstanceController) {
269         final Location currentLocation = locationController.getWhere();
270 
271         sendEvent(AppLifecycleEventType.STOPPED, appInstanceController.getAppDescriptor());
272         appInstanceController.stop();
273         while (appHistory.remove(appInstanceController)) {
274             ;
275         }
276 
277         final String appName = appInstanceController.getAppDescriptor().getName();
278         runningApps.remove(appName);
279         appInstances.remove(appName);
280 
281         if (currentAppInstanceController == appInstanceController) {
282             currentAppInstanceController = null;
283             viewport.setView(null);
284         }
285 
286         if (!appHistory.isEmpty()) {
287             doFocus(appHistory.peekFirst());
288         } else if (!AdmincentralFlavour.get().isM5()) {
289             // last app was stopped?
290             locationController.goTo(new DefaultLocation("home"));
291         }
292 
293         if (AdmincentralFlavour.get().isM5() &&
294                 StringUtils.equalsIgnoreCase(appName, currentLocation.getAppName())) {
295             // Only 'fallback' to app launcher we are closing the currently visible app
296             locationController.goTo(new DefaultLocation(Location.LOCATION_TYPE_SHELL_APP, "applauncher"));
297         }
298     }
299 
300     private void sendEvent(AppLifecycleEventType appEventType, AppDescriptor appDescriptor) {
301         eventBus.fireEvent(new AppLifecycleEvent(appDescriptor, appEventType));
302     }
303 
304     /**
305      * Takes care of {@link LocationChangedEvent}s by:
306      * <ul>
307      * <li>Obtaining the {@link AppDescriptor} associated with the {@link Location}.</li>
308      * <li>Creating a new {@link info.magnolia.ui.api.app.AppContext} if not running, otherwise obtain it from the running apps.</li>
309      * <li>Updating the {@Link Location} and redirecting in case of missing subAppId.</li>
310      * <li>Starting the App.</li>
311      * <li>Adding the {@link info.magnolia.ui.api.app.AppContext} to the appHistory.</li>
312      * <li>Setting the viewport and updating the current running app.</li>
313      * </ul>
314      */
315     @Override
316     public void onLocationChanged(LocationChangedEvent event) {
317         Location newLocation = event.getNewLocation();
318 
319         // Whenever app-locations come with empty params and app is already running, attempt to restore them
320         // This may happen from client side history states (doesn't remember app parameters), or from loose app-targeted DefaultLocations
321         String actualParam = newLocation.getParameter();
322         if (StringUtils.isEmpty(actualParam) && newLocation instanceof DefaultLocation) {
323             Location lastAppLocation = getAppLocation(newLocation.getAppName());
324             if (lastAppLocation != null) {
325                 ((DefaultLocation) newLocation).setParameter(lastAppLocation.getParameter());
326             }
327         }
328 
329         if (locationIsEmpty(newLocation)) {
330             return;
331         }
332 
333         if (!newLocation.getAppType().equals(Location.LOCATION_TYPE_APP)) {
334             return;
335         }
336 
337         if (newLocation.equals(getCurrentAppLocation())) {
338             return;
339         }
340 
341         if (!isAllowedToUser(newLocation)) {
342             return;
343         }
344 
345         AppDescriptor nextApp = getAppForLocation(newLocation);
346         if (nextApp == null) {
347             return;
348         }
349 
350         AppInstanceController nextAppContext = getAppInstance(nextApp.getName());
351 
352         // update location
353         Location updateLocation = updateLocation(nextAppContext, newLocation);
354         if (!updateLocation.equals(newLocation)) {
355             locationController.goTo(updateLocation);
356             return;
357         }
358 
359         if (currentAppInstanceController != nextAppContext) {
360             if (!nextAppContext.isHeaderApp()) {
361                 appHistory.addFirst(nextAppContext);
362             }
363             currentAppInstanceController = nextAppContext;
364         }
365 
366         nextAppContext = doStartIfNotAlreadyRunning(nextAppContext, newLocation);
367 
368         try {
369             viewport.setView(nextAppContext.getApp().getView());
370         } catch (Exception e) {
371             log.error("App {} failed to start: {}", nextApp.getName(), e.getMessage(), e);
372             App failedApp = componentProvider.newInstance(FailedAppStub.class, nextAppContext, e);
373             failedApp.start(newLocation);
374             viewport.setView(failedApp.getView());
375         }
376     }
377 
378     /**
379      * Updates the {@link Location} in case of missing subAppId:
380      * <ul>
381      * <li>If the app is running, it will fetch the current Location associated with the App.</li>
382      * <li>Will fetch the configured default subAppId otherwise.</li>
383      * </ul>
384      */
385     private Location updateLocation(AppInstanceController appInstanceController, Location location) {
386         String appType = location.getAppType();
387         String appName = location.getAppName();
388         String subAppId = location.getSubAppId();
389         String params = location.getParameter();
390 
391         if (StringUtils.isBlank(subAppId)) {
392 
393             if (isAppStarted(appName)) {
394                 AppInstanceController runningAppContext = runningApps.get(appName);
395                 subAppId = runningAppContext.getCurrentLocation().getSubAppId();
396             } else if (StringUtils.isBlank(subAppId)) {
397                 Location defaultLocation = appInstanceController.getDefaultLocation();
398                 if (defaultLocation != null) {
399                     subAppId = defaultLocation.getSubAppId();
400                 } else {
401                     log.warn("No default location could be found for the '{}' app, please check subapp configuration.", appName);
402                 }
403 
404             }
405         }
406 
407         return new DefaultLocation(appType, appName, subAppId, params);
408     }
409 
410     private AppInstanceController getAppInstance(String appName) {
411         return appInstances.computeIfAbsent(appName, this::createNewAppInstance);
412     }
413 
414     private AppInstanceController createNewAppInstance(String appName) {
415         AppDescriptor descriptor = getAppDescriptor(appName);
416         if (descriptor == null) {
417             sendAppDescriptorReadErrorNotification(appName);
418             return null;
419         }
420 
421         AppInstanceController appInstanceController = componentProvider.newInstance(AppInstanceController.class, descriptor);
422         createAppComponentProvider(appInstanceController);
423 
424         return appInstanceController;
425     }
426 
427     @Override
428     public void onLocationChangeRequested(LocationChangeRequestedEvent event) {
429         if (currentAppInstanceController != null) {
430             final String message = currentAppInstanceController.mayStop();
431             if (message != null) {
432                 event.setWarning(message);
433             }
434         }
435     }
436 
437     @Override
438     public void openChooseDialog(String appName, UiContext uiContext, String selectedId, ChooseDialogCallback callback) {
439         openChooseDialog(appName, uiContext, null, selectedId, callback);
440     }
441 
442     @Override
443     public void openChooseDialog(String appName, UiContext uiContext, String targetTreeRootPath, String selectedId, ChooseDialogCallback callback) {
444         App targetApp = getAppWithoutStarting(appName);
445         if (targetApp != null) {
446             final ChooseDialogCallback composedCallback = ChooseDialogCallback.composeWith(targetApp::stop, callback);
447             if (StringUtils.isNotBlank(targetTreeRootPath)) {
448                 targetApp.openChooseDialog(uiContext, targetTreeRootPath, selectedId, composedCallback);
449             } else {
450                 targetApp.openChooseDialog(uiContext, selectedId, callback);
451             }
452         }
453     }
454 
455     @Override
456     public Optional<AppContext> getCurrentAppContext() {
457         return Optional.ofNullable(this.currentAppInstanceController);
458     }
459 
460     @Override
461     public Map<String, AppContext> getRunningApps() {
462         return (Map) this.appInstances;
463     }
464 
465     private AppDescriptor getAppForLocation(Location location) {
466         return getAppDescriptor(location.getAppName());
467     }
468 
469     private AppDescriptor getAppDescriptor(String name) throws RuntimeException {
470         final DefinitionProvider<AppDescriptor> definitionProvider;
471         try {
472             definitionProvider = appDescriptorRegistry.getProvider(name);
473         } catch (Registry.NoSuchDefinitionException | IllegalStateException e) {
474             sendAppDescriptorReadErrorNotification(name);
475             throw new RuntimeException(e);
476         }
477         return definitionProvider.get();
478     }
479 
480     private void sendAppDescriptorReadErrorNotification(String name) {
481         Message errorMessage = new Message();
482         errorMessage.setType(MessageType.ERROR);
483         errorMessage.setSubject(i18n.translate("ui-framework.app.appdescriptorReadError.subject"));
484         errorMessage.setMessage(String.format(i18n.translate("ui-framework.app.appdescriptorReadError.message"), name));
485         messagesManager.sendLocalMessage(errorMessage);
486     }
487 
488     private ComponentProvider createAppComponentProvider(AppInstanceController appInstanceController) {
489         final UiContextReference uiContextReference = UiContextReference.ofApp(appInstanceController);
490         final UiContextBoundComponentProvidertml#UiContextBoundComponentProvider">UiContextBoundComponentProvider appComponentProvider = new UiContextBoundComponentProvider(uiContextReference);
491         appInstanceController.setAppComponentProvider(appComponentProvider);
492 
493         final Map<Key, Object> initialScopedInstances = new HashMap<>();
494         initialScopedInstances.put(Key.get(AppContext.class), appInstanceController);
495         initialScopedInstances.put(Key.get(AppDescriptor.class), appInstanceController.getAppDescriptor());
496 
497         SessionStore.access().createBeanStore(uiContextReference, initialScopedInstances);
498 
499         return appComponentProvider;
500     }
501 
502     private boolean locationIsEmpty(Location location) {
503         return Objects.equals(Location.NOWHERE, location) || (StringUtils.equals(Location.NOWHERE.getAppType(), location.getAppType())
504                 && StringUtils.equals(Location.NOWHERE.getAppName(), location.getAppName())
505                 && StringUtils.equals(Location.NOWHERE.getSubAppId(), location.getSubAppId())
506                 && StringUtils.equals(Location.NOWHERE.getParameter(), location.getParameter()));
507     }
508 
509     private boolean isAllowedToUser(final Location location) {
510         if (Location.LOCATION_TYPE_SHELL_APP.equals(location.getAppType())) {
511             return true;
512         }
513         final AppDescriptor appDescriptor = getAppDescriptor(location.getAppName());
514         final AccessDefinition permissions = appDescriptor.getPermissions();
515 
516         return appDescriptor.isEnabled() && (permissions == null || permissions.hasAccess(user));
517     }
518 }