'use strict'; /* global angular */ /* global $ */ /* global async */ /* global ERROR */ /* global RSTATES */ /* global moment */ angular.module('Application').controller('AppStoreController', ['$scope', '$translate', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $translate, $location, $timeout, $routeParams, Client) { Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); }); $scope.HOST_PORT_MIN = 1; $scope.HOST_PORT_MAX = 65535; $scope.ready = false; $scope.apps = []; $scope.popularApps = []; $scope.config = Client.getConfig(); $scope.user = Client.getUserInfo(); $scope.users = []; $scope.groups = []; $scope.domains = []; $scope.category = ''; $scope.cachedCategory = ''; // used to cache the selected category while searching $scope.searchString = ''; $scope.validSubscription = false; $scope.subscription = {}; $scope.memory = null; // { memory, swap } $scope.showView = function (view) { $('#appInstallModal').off('hidden.bs.modal'); // wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already $('.modal').on('hidden.bs.modal', function () { $scope.appInstall.reset(); $('.modal').off('hidden.bs.modal'); $location.path(view).search({}); }); $('.modal').modal('hide'); }; // If new categories added make sure the translation below exists $scope.categories = [ { id: 'analytics', icon: 'fa fa-chart-line', label: 'Analytics'}, { id: 'automation', icon: 'fa fa-robot', label: 'Automation'}, { id: 'blog', icon: 'fa fa-font', label: 'Blog'}, { id: 'chat', icon: 'fa fa-comments', label: 'Chat'}, { id: 'crm', icon: 'fab fa-connectdevelop', label: 'CRM'}, { id: 'document', icon: 'fa fa-file-word', label: 'Documents'}, { id: 'email', icon: 'fa fa-envelope', label: 'Email'}, { id: 'federated', icon: 'fa fa-project-diagram', label: 'Federated'}, { id: 'finance', icon: 'fa fa-hand-holding-usd', label: 'Finance'}, { id: 'forum', icon: 'fa fa-users', label: 'Forum'}, { id: 'fun', icon: 'fa fa-gamepad', label: 'Fun'}, { id: 'gallery', icon: 'fa fa-images', label: 'Gallery'}, { id: 'game', icon: 'fa fa-gamepad', label: 'Games'}, { id: 'git', icon: 'fa fa-code-branch', label: 'Code Hosting'}, { id: 'hosting', icon: 'fa fa-server', label: 'Web Hosting'}, { id: 'learning', icon: 'fas fa-graduation-cap', label: 'Learning'}, { id: 'media', icon: 'fas fa-photo-video', label: 'Media'}, { id: 'no-code', icon: 'fas fa-code', label: 'No-code'}, { id: 'notes', icon: 'fa fa-sticky-note', label: 'Notes'}, { id: 'project', icon: 'fas fa-project-diagram', label: 'Project Management'}, { id: 'sync', icon: 'fa fa-sync-alt', label: 'File Sync'}, { id: 'voip', icon: 'fa fa-headset', label: 'VoIP'}, { id: 'vpn', icon: 'fa fa-user-secret', label: 'VPN'}, { id: 'wiki', icon: 'fab fa-wikipedia-w', label: 'Wiki'}, ]; // Translation IDs are generated as "appstore.category." $translate($scope.categories.map(function (c) { return 'appstore.category.' + c.id; })).then(function (tr) { Object.keys(tr).forEach(function (key) { if (key === tr[key]) return; // missing translation use default label var category = $scope.categories.find(function (c) { return key.endsWith(c.id); }); if (category) category.label = tr[key]; }); }); $scope.categoryButtonLabel = function (category) { var categoryLabel = $translate.instant('appstore.categoryLabel'); if (category === '') return $translate.instant('appstore.category.all'); if (category === 'new') return $translate.instant('appstore.category.newApps'); var tmp = $scope.categories.find(function (c) { return c.id === category; }); if (tmp) return tmp.label; return categoryLabel; }; $scope.isProxyApp = function (app) { if (!app) return false; return app.id === 'io.cloudron.builtin.appproxy'; }; $scope.userManagementFilterOptions = [ { id: '', icon: '', label: $translate.instant('appstore.ssofilter.all') }, { id: 'sso', icon: 'fas fa-user', label: $translate.instant('apps.auth.sso') }, { id: 'nosso', icon: 'far fa-user', label: $translate.instant('apps.auth.nosso') }, { id: 'email', icon: 'fas fa-envelope', label: $translate.instant('apps.auth.email') }, ]; $scope.userManagementFilterOption = $scope.userManagementFilterOptions[0]; $scope.userManagementFilterOptionIsActive = function (option) { return option.id === $scope.userManagementFilterOption.id; }; $scope.applyUserMangamentFilter = function (option) { $scope.userManagementFilterOption = option; }; $scope.appInstall = { busy: false, state: 'appInfo', error: {}, app: {}, needsOverwrite: false, subdomain: '', domain: null, // object and not the string secondaryDomains: {}, ports: {}, portsEnabled: {}, mediaLinks: [], keyFile: null, keyFileName: '', accessRestrictionOption: '', accessRestriction: { users: [], groups: [] }, customAuth: false, optionalSso: false, subscriptionErrorMesssage: '', upstreamUri: '', isAccessRestrictionValid: function () { var tmp = $scope.appInstall.accessRestriction; return !!(tmp.users.length || tmp.groups.length); }, reset: function () { $scope.appInstall.app = {}; $scope.appInstall.error = {}; $scope.appInstall.needsOverwrite = false; $scope.appInstall.subdomain = ''; $scope.appInstall.domain = null; $scope.appInstall.secondaryDomains = {}; $scope.appInstall.ports = {}; $scope.appInstall.state = 'appInfo'; $scope.appInstall.mediaLinks = []; $scope.appInstall.keyFile = null; $scope.appInstall.keyFileName = ''; $scope.appInstall.accessRestrictionOption = ''; $scope.appInstall.accessRestriction = { users: [], groups: [] }; $scope.appInstall.optionalSso = false; $scope.appInstall.customAuth = false; $scope.appInstall.subscriptionErrorMesssage = ''; $scope.appInstall.upstreamUri = ''; $('#collapseInstallForm').collapse('hide'); $('#collapseResourceConstraint').collapse('hide'); $('#collapseSubscriptionRequired').collapse('hide'); $('#collapseMediaLinksCarousel').collapse('show'); if ($scope.appInstallForm) { $scope.appInstallForm.$setPristine(); $scope.appInstallForm.$setUntouched(); } }, showForm: function (force) { var app = $scope.appInstall.app; var DEFAULT_MEMORY_LIMIT = 1024 * 1024 * 256; var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM var used = Client.getInstalledApps().reduce(function (prev, cur) { if (cur.runState === RSTATES.STOPPED) return prev; return prev + (cur.memoryLimit || cur.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT); }, 0); var totalMemory = $scope.memory.memory * 2; var available = (totalMemory || 0) - used; var enoughResourcesAvailable = (available - needed) >= 0; if (enoughResourcesAvailable || force) { $scope.appInstall.state = 'installForm'; $('#collapseMediaLinksCarousel').collapse('hide'); $('#collapseResourceConstraint').collapse('hide'); $('#collapseInstallForm').collapse('show'); $('#appInstallLocationInput').focus(); } else { $scope.appInstall.state = 'resourceConstraint'; $('#collapseMediaLinksCarousel').collapse('hide'); $('#collapseResourceConstraint').collapse('show'); } }, show: function (app) { // this is an appstore app object! $scope.appInstall.reset(); // make a copy to work with in case the app object gets updated while polling angular.copy(app, $scope.appInstall.app); $scope.appInstall.mediaLinks = $scope.appInstall.app.manifest.mediaLinks || []; $scope.appInstall.domain = $scope.domains.find(function (d) { return $scope.config.adminDomain === d.domain; }); // pre-select the adminDomain $scope.appInstall.secondaryDomains = {}; var httpPorts = $scope.appInstall.app.manifest.httpPorts || {}; for (var env2 in httpPorts) { $scope.appInstall.secondaryDomains[env2] = { subdomain: httpPorts[env2].defaultValue || '', domain: $scope.appInstall.domain }; } $scope.appInstall.portInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information $scope.appInstall.ports = {}; // This holds the env:port pair $scope.appInstall.portsEnabled = {}; // This holds the enabled/disabled flag var manifest = app.manifest; $scope.appInstall.optionalSso = !!manifest.optionalSso; $scope.appInstall.customAuth = !(manifest.addons['ldap'] || manifest.addons['oidc'] || manifest.addons['proxyAuth']); $scope.appInstall.accessRestrictionOption = $scope.groups.length ? '' : 'any'; // make the user select an ACL conciously if groups are used $scope.appInstall.accessRestriction = { users: [], groups: [] }; // set default ports var allPorts = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); for (var env in allPorts) { $scope.appInstall.ports[env] = allPorts[env].defaultValue || 0; $scope.appInstall.portsEnabled[env] = true; } $('#appInstallModal').modal('show'); }, submit: function () { $scope.appInstall.busy = true; $scope.appInstall.error.other = null; $scope.appInstall.error.location = null; $scope.appInstall.error.port = null; var secondaryDomains = {}; for (var env2 in $scope.appInstall.secondaryDomains) { secondaryDomains[env2] = { subdomain: $scope.appInstall.secondaryDomains[env2].subdomain, domain: $scope.appInstall.secondaryDomains[env2].domain.domain }; } // only use enabled ports from ports var finalPorts = {}; for (var env in $scope.appInstall.ports) { if ($scope.appInstall.portsEnabled[env]) { finalPorts[env] = $scope.appInstall.ports[env]; } } var finalAccessRestriction = null; if ($scope.appInstall.accessRestrictionOption === 'groups') { finalAccessRestriction = { users: [], groups: [] }; finalAccessRestriction.users = $scope.appInstall.accessRestriction.users.map(function (u) { return u.id; }); finalAccessRestriction.groups = $scope.appInstall.accessRestriction.groups.map(function (g) { return g.id; }); } var data = { overwriteDns: $scope.appInstall.needsOverwrite, subdomain: $scope.appInstall.subdomain || '', domain: $scope.appInstall.domain.domain, secondaryDomains: secondaryDomains, ports: finalPorts, accessRestriction: finalAccessRestriction, sso: !$scope.appInstall.optionalSso ? undefined : ($scope.appInstall.accessRestrictionOption !== 'nosso'), }; if ($scope.appInstall.upstreamUri) { data.upstreamUri = $scope.appInstall.upstreamUri; data.upstreamUri = data.upstreamUri.replace(/\/$/, ''); } var domains = []; domains.push({ subdomain: data.subdomain, domain: data.domain, type: 'primary' }); var canInstall = true; async.eachSeries(domains, function (domain, callback) { if (data.overwriteDns) return callback(); Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) { if (error) return callback(error); var message; if (result.error) { if (result.error.reason === ERROR.ACCESS_DENIED) { message = 'DNS credentials for ' + domain.domain + ' are invalid. Update it in Domains & Certs view'; if (domain.type === 'primary') { $scope.appInstall.error.location = message; } else { $scope.appInstall.error.secondaryDomain = message; } } else { if (domain.type === 'primary') { $scope.appInstall.error.location = result.error.message; } else { $scope.appInstall.error.secondaryDomain = message; } } canInstall = false; } else if (result.needsOverwrite) { message = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron'; if (data.type === 'primary') { $scope.appInstall.error.location = message; } else { $scope.appInstall.error.secondaryDomain = message; } $scope.appInstall.needsOverwrite = true; canInstall = false; } callback(); }); }, function (error) { if (error) { $scope.appInstall.busy = false; return Client.error(error); } if (!canInstall) { $scope.appInstall.busy = false; $scope.appInstallForm.location.$setPristine(); $('#appInstallLocationInput').focus(); return; } Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, data, function (error, newAppId) { if (error) { var errorMessage = error.message.toLowerCase(); if (error.statusCode === 402) { $scope.appInstall.state = 'subscriptionRequired'; $scope.appInstall.subscriptionErrorMesssage = error.message; $('#collapseMediaLinksCarousel').collapse('hide'); $('#collapseResourceConstraint').collapse('hide'); $('#collapseInstallForm').collapse('hide'); $('#collapseSubscriptionRequired').collapse('show'); } else if (error.statusCode === 409) { if (errorMessage.indexOf('port') !== -1) { $scope.appInstall.error.port = error.message; } else if (errorMessage.indexOf('location') !== -1) { if (errorMessage.indexOf('primary') !== -1) { $scope.appInstall.error.location = error.message; $scope.appInstallForm.location.$setPristine(); $('#appInstallLocationInput').focus(); } else { $scope.appInstall.error.secondaryDomain = error.message; } } else { $scope.appInstall.error.other = error.message; } } else if (error.statusCode === 400) { $scope.appInstall.error.other = error.message; } else { $scope.appInstall.error.other = error.message; } $scope.appInstall.busy = false; return; } $scope.appInstall.busy = false; // stash new app id for later $scope.appInstall.app.id = newAppId; // we track the postinstall confirmation for the current user's browser // TODO later we might want to have a notification db to track the state across admins and browsers if ($scope.appInstall.app.manifest.postInstallMessage) { localStorage['confirmPostInstall_' + $scope.appInstall.app.id] = true; } $scope.showView('/apps'); }); }); } }; $scope.appNotFound = { appId: '', version: '' }; $scope.appstoreLogin = { busy: false, error: {}, email: '', password: '', totpToken: '', setupType: 'login', termsAccepted: false, setupToken: '', submit: function () { $scope.appstoreLogin.error = {}; $scope.appstoreLogin.busy = true; var func = $scope.appstoreLogin.setupToken ? Client.registerCloudronWithSetupToken.bind(null, $scope.appstoreLogin.setupToken) : Client.registerCloudron.bind(null, $scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.setupType === 'register'); func(function (error) { if (error) { $scope.appstoreLogin.busy = false; if (error.statusCode === 409) { $scope.appstoreLogin.error.email = 'An account with this email already exists'; $scope.appstoreLogin.password = ''; $scope.appstoreSignupForm.email.$setPristine(); $scope.appstoreSignupForm.password.$setPristine(); $('#inputAppstoreLoginEmail').focus(); } else if (error.statusCode === 412) { if (error.message.indexOf('TOTP token missing') !== -1) { $scope.appstoreLogin.error.totpToken = 'A 2FA token is required'; setTimeout(function () { $('#inputAppstoreLoginTotpToken').focus(); }, 0); } else if (error.message.indexOf('TOTP token invalid') !== -1) { $scope.appstoreLogin.error.totpToken = 'Wrong 2FA token'; $scope.appstoreLogin.totpToken = ''; setTimeout(function () { $('#inputAppstoreLoginTotpToken').focus(); }, 0); } else { $scope.appstoreLogin.error.loginPassword = 'Wrong email or password'; $scope.appstoreLogin.password = ''; $('#inputAppstoreLoginPassword').focus(); $scope.appstoreLoginForm.password.$setPristine(); } } else if (error.statusCode === 424) { if (error.message === 'wrong user') { $scope.appstoreLogin.error.generic = 'Wrong cloudron.io account'; $scope.appstoreLogin.email = ''; $scope.appstoreLogin.password = ''; $scope.appstoreLoginForm.email.$setPristine(); $scope.appstoreLoginForm.password.$setPristine(); $scope.appstoreSignupForm.email.$setPristine(); $scope.appstoreSignupForm.password.$setPristine(); $('#inputAppstoreLoginEmail').focus(); } else { console.error(error); $scope.appstoreLogin.error.generic = error.message; } } else if (error.statusCode === 402) { $scope.appstoreLogin.error.setupToken = 'Invalid or expired setup token'; $scope.appstoreLogin.setupToken = ''; $scope.appstoreSetupTokenForm.setupToken.$setPristine(); $('#inputAppstoreSetupToken').focus(); } else { console.error(error); $scope.appstoreLogin.error.generic = error.message || 'Please retry later'; } return; } // do a full re-init of the view now that we have a subscription init(); }); } }; // TODO does not support testing apps in search $scope.search = function () { if (!$scope.searchString) return $scope.showCategory($scope.cachedCategory); $scope.category = ''; Client.getAppstoreAppsFast(function (error, apps) { if (error) return $timeout($scope.search, 1000); var token = $scope.searchString.toUpperCase(); $scope.popularApps = []; $scope.apps = apps.filter(function (app) { // on searches we give highe priority if title or tagline matches app.priority = 0; if (app.manifest.title.toUpperCase().indexOf(token) !== -1) { app.priority = 2; return true; } if (app.manifest.tagline.toUpperCase().indexOf(token) !== -1) { app.priority = 1; return true; } if (app.manifest.id.toUpperCase().indexOf(token) !== -1) return true; if (app.manifest.description.toUpperCase().indexOf(token) !== -1) return true; if (app.manifest.tags.join().toUpperCase().indexOf(token) !== -1) return true; return false; }); }); }; function filterForNewApps(apps) { var minApps = apps.length < 12 ? apps.length : 12; // prevent endless loop var tmp = []; var i = 0; do { var offset = moment().subtract(i++, 'days'); tmp = apps.filter(function (app) { return moment(app.publishedAt).isAfter(offset); }); } while(tmp.length < minApps); return tmp; } function filterForRecentlyUpdatedApps(apps) { var minApps = apps.length < 12 ? apps.length : 12; // prevent endless loop var tmp = []; var i = 0; do { var offset = moment().subtract(i++, 'days'); tmp = apps.filter(function (app) { return moment(app.creationDate).isAfter(offset); }); // creationDate here is from appstore's appversions table } while(tmp.length < minApps); return tmp; } $scope.showCategory = function (category) { $scope.category = category; $scope.cachedCategory = $scope.category; Client.getAppstoreAppsFast(function (error, apps) { if (error) return $timeout($scope.showCategory.bind(null, category), 1000); if (!$scope.category) { $scope.apps = apps.slice(0).filter(function (app) { return !app.featured; }).sort(function (a1, a2) { return a1.manifest.title.localeCompare(a2.manifest.title); }); $scope.popularApps = apps.slice(0).filter(function (app) { return app.featured; }).sort(function (a1, a2) { return a2.ranking - a1.ranking; }); } else if ($scope.category === 'new') { $scope.apps = filterForNewApps(apps); } else if ($scope.category === 'recent') { $scope.apps = filterForRecentlyUpdatedApps(apps); } else { $scope.apps = apps.filter(function (app) { return app.manifest.tags.some(function (tag) { return $scope.category.toUpperCase() === tag.toUpperCase(); }); // reverse sort; }).sort(function (a1, a2) { return a2.ranking - a1.ranking; }); } // ensure we scroll to top document.getElementById('ng-view').scrollTop = 0; }); }; $scope.openSubscriptionSetup = function () { Client.getSubscription(function (error, subscription) { if (error) return console.error('Unable to get subscription.', error); Client.openSubscriptionSetup(subscription); }); }; $scope.showAppNotFound = function (appId, version) { $scope.appNotFound.appId = appId; $scope.appNotFound.version = version || 'latest'; $('#appNotFoundModal').modal('show'); }; $scope.gotoApp = function (app) { $location.path('/appstore/' + app.manifest.id, false).search({ version : app.manifest.version }); }; $scope.openAppProxy = function () { $location.path('/appstore/io.cloudron.builtin.appproxy', false).search({}); }; $scope.applinksAdd = { error: {}, busy: false, upstreamUri: '', label: '', tags: '', accessRestrictionOption: 'any', accessRestriction: { users: [], groups: [] }, isAccessRestrictionValid: function () { return !!($scope.applinksAdd.accessRestriction.users.length || $scope.applinksAdd.accessRestriction.groups.length); }, show: function () { $scope.applinksAdd.error = {}; $scope.applinksAdd.busy = false; $scope.applinksAdd.upstreamUri = ''; $scope.applinksAdd.label = ''; $scope.applinksAdd.tags = ''; $scope.applinksAddForm.$setUntouched(); $scope.applinksAddForm.$setPristine(); $('#applinksAddModal').modal('show'); return false; // prevent propagation and default }, submit: function () { if (!$scope.applinksAdd.upstreamUri) return; $scope.applinksAdd.busy = true; $scope.applinksAdd.error = {}; var accessRestriction = null; if ($scope.applinksAdd.accessRestrictionOption === 'groups') { accessRestriction = { users: [], groups: [] }; accessRestriction.users = $scope.applinksAdd.accessRestriction.users.map(function (u) { return u.id; }); accessRestriction.groups = $scope.applinksAdd.accessRestriction.groups.map(function (g) { return g.id; }); } var data = { upstreamUri: $scope.applinksAdd.upstreamUri, label: $scope.applinksAdd.label, accessRestriction: accessRestriction, tags: $scope.applinksAdd.tags.split(' ').map(function (t) { return t.trim(); }).filter(function (t) { return !!t; }) }; Client.addApplink(data, function (error) { $scope.applinksAdd.busy = false; if (error && error.statusCode === 400 && error.message.includes('upstreamUri')) { $scope.applinksAdd.error.upstreamUri = error.message; $scope.applinksAddForm.$setUntouched(); $scope.applinksAddForm.$setPristine(); return; } if (error) return console.error('Failed to add applink', error); $scope.showView('/apps'); }); } }; function hashChangeListener() { // event listener is called from DOM not angular, need to use $apply $scope.$apply(function () { var appId = $location.path().slice('/appstore/'.length); var version = $location.search().version; if (appId) { Client.getAppstoreAppByIdAndVersion(appId, version || 'latest', function (error, result) { if (error) { $scope.showAppNotFound(appId, version); console.error(error); return; } $scope.appInstall.show(result); }); } else { $scope.appInstall.reset(); } }); } function fetchUsers() { Client.getAllUsers(function (error, users) { if (error) { console.error(error); return $timeout(fetchUsers, 5000); } $scope.users = users; }); } function fetchGroups() { Client.getGroups(function (error, groups) { if (error) { console.error(error); return $timeout(fetchGroups, 5000); } $scope.groups = groups; }); } function fetchMemory() { Client.memory(function (error, memory) { if (error) { console.error(error); return $timeout(fetchMemory, 5000); } $scope.memory = memory; }); } function getSubscription(callback) { var validSubscription = false; Client.getSubscription(function (error, subscription) { if (error) { if (error.statusCode === 412) { // not registered yet validSubscription = false; } else if (error.statusCode === 402) { // invalid token, license error validSubscription = false; } else { // 424/external error? return callback(error); } } else { validSubscription = true; $scope.subscription = subscription; } // clear busy state when a login/signup was performed $scope.appstoreLogin.busy = false; // also update the root controller status if ($scope.$parent) $scope.$parent.updateSubscriptionStatus(); callback(null, validSubscription); }); } function init() { Client.getAppstoreAppsFast(function (error) { if (error && error.statusCode === 402) { $scope.validSubscription = false; $scope.ready = true; return; } else if (error) { console.error('Failed to get apps. Will retry.', error); $timeout(init, 1000); return; } $scope.showCategory(''); getSubscription(function (error, validSubscription) { if (error) console.error('Failed to get subscription.', error); // autofocus login if (!validSubscription) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000); $scope.validSubscription = validSubscription; $scope.ready = true; // refresh everything in background Client.getAppstoreApps(function (error) { if (error) console.error('Failed to fetch apps.', error); }); Client.refreshConfig(); // refresh domain, user, group limit etc fetchUsers(); fetchGroups(); fetchMemory(); // domains is required since we populate the dropdown with domains[0] Client.getDomains(function (error, result) { if (error) return console.error('Error getting domains.', error); $scope.domains = result; // show install app dialog immediately if an app id was passed in the query // hashChangeListener calls $apply, so make sure we don't double digest here setTimeout(hashChangeListener, 1); setTimeout(function () { $('#appstoreSearch').focus(); }, 1); }); }); }); } Client.onReady(init); $('#appInstallModal').on('hidden.bs.modal', function () { // clear the appid and version in the search bar when dialog is cancelled $scope.$apply(function () { $location.path('/appstore', false).search({ }); // 'false' means do not reload }); }); window.addEventListener('hashchange', hashChangeListener); $scope.$on('$destroy', function handler() { window.removeEventListener('hashchange', hashChangeListener); }); // setup all the dialog focus handling ['appInstallModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find('[autofocus]:first').focus(); }); }); $('.modal-backdrop').remove(); }]);