'use strict'; const CC = Components.Constructor; const Cc = Components.classes; const Ci = Components.interfaces; const Cu = Components.utils; const RE_MCCMNC = /^\d{3}-\d{3}$/; const PAGE_MAX_APP = 16; const DOCK_MAX_APP = 5; const MARKETPLACE_INSTALL_ORIGIN = 'https://marketplace.firefox.com'; const EXTERNAL_APP_DIR = 'external-apps'; const MARKETPLACE_SEARCH_HASH = '#marketplace-search-result'; const MARKETPLACE_SEARCH_BASEURL = 'https://marketplace.firefox.com/api/v1/' + 'apps/search/?device=firefoxos&q='; Cu.import('resource://gre/modules/Services.jsm'); Cu.import('resource://gre/modules/FileUtils.jsm'); const GAIA_DIR_PREF_NAME = 'extensions.gaia.dir'; const DEFAULT_PAGE = 2; var utils, variant; var Homescreen = { operators: {}, init: function hs_init() { var self = this; this.addPageButton = document.getElementById('add-page'); this.grid = document.getElementById('grid'); this.dock = document.getElementById('dock'); this.apps = []; this.searchResultContainer = document.getElementById('marketplace-search-container'); this.addPageButton.addEventListener('click', this.addPage.bind(this)); this.operatorsElement = document.getElementById('operators'); if (this.grid.children.length <= 1) { for (var i = 0; i < DEFAULT_PAGE; i++) { this.addPage(); } } var gaiaBuilds = document.getElementById('gaia-builds'); gaiaBuilds.addEventListener('change', function(evt) { var selected = gaiaBuilds.options[gaiaBuilds.options.selectedIndex]; self.selectBuild(selected.value); }); document.getElementById('save').addEventListener('click', this.save.bind(this)); document.getElementById('select-gaia').addEventListener('click', this.selectGaiaFolder.bind(this)); document.getElementById('options-remove').addEventListener('click', this.removeApp.bind(this)); document.getElementById('reset-gaia').addEventListener('click', resetGaiaPath); document.getElementById('marketplace-keyword').addEventListener('click', function(evt) { this.select(); }); document.getElementById('marketplace-search').addEventListener('click', function(evt) { var keyword = document.getElementById('marketplace-keyword').value; if (keyword) { self.search(keyword); } else { alert('Please specify a keyword.'); } }); function createOperator() { var div = document.createElement('div'); div.classList.add('operator'); self.createOperatorEditor(div); self.operatorsElement.appendChild(div); } document.getElementById('add-operator').addEventListener('click', createOperator); document.getElementById('certain-mcc-mnc').addEventListener('change', function(evt) { var app = self.apps[self.editingAppElement.dataset.name]; if (evt.target.checked) { app.forOperators = true; } else { app.forOperators = false; } }); window.addEventListener('hashchange', function(evt) { var oldURL = new URL(evt.oldURL); if (oldURL.hash === MARKETPLACE_SEARCH_HASH) { delete self.searchResult; } }); createOperator(); }, search: function hs_search(keyword) { var {downloadJSON} = utils; this.searchResultContainer.innerHTML = 'Loading...'; window.location.hash = MARKETPLACE_SEARCH_HASH; var self = this; var url = MARKETPLACE_SEARCH_BASEURL + encodeURI(keyword); downloadJSON(url, function(json) { if (json) { self.searchResult = json; self.showSearchResult(self.searchResult); } else { var msg = 'Marketplace search request failed'; alert(msg); throw new Error(msg); } }); }, showSearchResult: function hs_showSearchResult(json) { var result = this.searchResultContainer; while(result.firstChild) { result.removeChild(result.firstChild); } json.objects.forEach(function(obj, index) { result.appendChild(this.createSearchEntry(obj, index)); }.bind(this)); }, createSearchEntry: function hs_createSearchEntry(obj, index) { const FULL_STARS = 5; var container = document.createElement('a'); container.classList.add('marketplace-app-container'); var elements = { 'icon': 'img', 'name': 'div', 'author': 'div', 'ratings': 'div', 'review': 'div', 'type': 'div' }; for (var key in elements) { var el = document.createElement(elements[key]); el.classList.add('marketplace-app-' + key); container.appendChild(el); elements[key] = el; } elements.name.textContent = obj.name; elements.author.textContent = obj.author; elements.icon.src = obj.icons['64']; var ratings = Math.round(obj.ratings.average); elements.ratings.innerHTML = new Array(ratings + 1).join('★'); var len = elements.ratings.innerHTML.length; elements.ratings.innerHTML += new Array(FULL_STARS - len + 1).join('☆'); elements.ratings.dataset.average = obj.ratings.average; elements.review.textContent = obj.ratings.count + ' reviews'; elements.type.dataset.isPackaged = obj.is_packaged; container.dataset.index = index; container.addEventListener('click', this.addAppFromMarketplace.bind(this)); return container; }, selectBuild: function hs_selectBuild(buildname) { if (!this.gaiaConfig) { throw new Error('Homescreen.gaiaConfig doesn\'t exist'); } var c = this.gaiaConfig; var {getAppsByList} = utils; var apps = getAppsByList(this.buildConfig[buildname].content, c.GAIA_DIR, c.GAIA_DISTRIBUTION_DIR); this.setApps(apps); }, selectGaiaFolder: function hs_selectGaiaFolder() { this.reset(); let path = this.promptFolder(); if (!path) { return; } Services.prefs.setCharPref(GAIA_DIR_PREF_NAME, path); }, promptFolder: function hs_promptFolder() { let fp = Cc['@mozilla.org/filepicker;1'].createInstance(Ci.nsIFilePicker); fp.init(window, 'Select Gaia directory', Ci.nsIFilePicker.modeGetFolder); let res = fp.show(); if (res != Ci.nsIFilePicker.returnCancel) { return fp.file.path; } return null; }, reset: function hs_reset() { var availableApps = document.getElementById('available-apps'); while (availableApps.firstChild) { availableApps.removeChild(availableApps.firstChild); } var pages = document.getElementsByClassName('container'); Array.prototype.forEach.call(pages, function(page, index) { while (page.firstChild) { page.removeChild(page.firstChild); } }); }, resetBuilds: function hs_resetBuilds() { var builds = document.getElementById('gaia-builds'); Array.prototype.forEach.call(builds.children, function(option) { option.remove(); }); }, setApps: function hs_setApps(apps) { this.installedApps = {}; var availableApps = document.getElementById('available-apps'); this.apps = apps; this.reset(); for (var app in apps) { // app with role in manifest means it has specific purpose and those apps // usually doesn't show on homescreen. e.g., role of bluetooth, pdf.js & // ringtones is "system" which means they are executed by another apps but // doesn't show on homescreen, but those apps are essential for gaia . if (apps[app].manifest.role) { this.installedApps[app] = apps[app]; continue; } else { this.createAppElements(apps[app]).forEach(function(el) { availableApps.appendChild(el); }); } } }, showOptions: function hs_showOptions(evt) { var self = this; this.editingAppElement = evt.target; var operatorsCheckbox = document.getElementById('certain-mcc-mnc'); var operatorsOptions = document.getElementById('options-content'); var operatorsDisabledMsg = document.getElementById('operators-disabled-desc'); var name = this.editingAppElement.dataset.name; document.getElementById('options-title').innerHTML = evt.target.innerHTML; window.location.hash = '#options'; function updateOperators(name) { var inputs = document.querySelectorAll('.operator input[type="checkbox"]'); Array.prototype.forEach.call(inputs, function(input) { var operatorId = input.dataset.operatorId; input.checked = self.operators[operatorId].apps[name] ? true : false; }); } if (!this.apps[name]) { throw new Error('App doesn\'t exist: ' + name); } else if (this.apps[name].metadata && this.apps[name].metadata.source === 'external') { updateOperators(name); operatorsCheckbox.checked = this.apps[name].forOperators || false; operatorsOptions.classList.remove('hidden'); operatorsDisabledMsg.classList.add('hidden'); operatorsCheckbox.disabled = false; } else { operatorsDisabledMsg.classList.remove('hidden'); operatorsOptions.classList.add('hidden'); operatorsCheckbox.disabled = true; } }, updateOperators: function hs_updateOperators(operator, appName) { var prevOperatorId; if (this.editingOperator) { prevOperatorId = this.editingOperator.id; } else { prevOperatorId = operator.id; } if (this.operators[prevOperatorId] && prevOperatorId !== operator.id) { operator.apps = this.operators[prevOperatorId].apps; delete this.operators[prevOperatorId]; } this.operators[operator.id] = operator; if (appName) { operator.apps[appName] = true; } }, getOperator: function hs_getOperator(name) { var operator; this.operators.every(function(opr) { if (opr.id === name) { operator = opr; return false; } return true; }); return operator; }, convertToOperator: function hs_convertToOperator(el) { var operatorId = el.querySelector('[data-field="operatorId"]').value; var mccmnc = el.querySelector('[data-field="mccmnc"]').value; var formatError = false; var mccmncArray = mccmnc.split(',').map(function(item) { var pair = item.trim(); formatError = !RE_MCCMNC.test(pair) || formatError; return pair; }); if (formatError) { throw new Error('MCC/MNC format incorrect, both of MCC and MNC need ' + '3 digits like "310-410" (without double quotes)'); } return { 'id': operatorId, 'mcc-mnc': mccmncArray, 'apps': {} }; }, switchOperatorMode: function hs_switchOperatorMode(operatorDiv, toEdit) { function removeAllChildren(element) { while (element.firstChild) { element.removeChild(element.firstChild); } } if (toEdit) { var operatorId = operatorDiv.querySelector('input[type="checkbox"]').dataset.operatorId; var operator = this.operators[operatorId]; this.editingOperator = operator; removeAllChildren(operatorDiv); this.createOperatorEditor(operatorDiv, operator); } else { var operator = this.convertToOperator(operatorDiv); removeAllChildren(operatorDiv); this.createOperatorView(operatorDiv, operator.id); delete this.editingOperator; } }, createOperatorView: function hs_createOperatorView(div, operatorId) { var {normalizeString} = utils; var self = this; var operator = this.operators[operatorId]; var checkbox = document.createElement('input'); checkbox.dataset.operatorId = operatorId; checkbox.dataset.normalizedName = normalizeString(operatorId); checkbox.type = 'checkbox'; checkbox.id = 'checkbox-' + checkbox.dataset.normalizedName; var label = document.createElement('label'); label.setAttribute('for', checkbox.id); label.innerHTML = operatorId; var span = document.createElement('span'); span.innerHTML = ' - '; var edit = document.createElement('a'); edit.innerHTML = 'edit'; checkbox.checked = (operator && operator.apps[this.editingAppElement.dataset.name]); checkbox.addEventListener('change', function(evt) { var operator = self.operators[evt.target.dataset.operatorId]; if (evt.target.checked) { operator.apps[self.editingAppElement.dataset.name] = true; } else { delete operator.apps[self.editingAppElement.dataset.name]; } }); edit.addEventListener('click', function(evt) { self.switchOperatorMode(div, true); }); span.appendChild(edit); [checkbox, label, span].forEach(function(child) { div.appendChild(child); }); }, createOperatorEditor: function hs_createOperatorEditor(div, operator) { var self = this; function createInput(field, placeholder) { var input = document.createElement('input'); input.type = 'text'; input.placeholder = placeholder; input.dataset.field = field; return input; } var idInput = createInput('operatorId', 'Operator ID'); var mccmncInput = createInput('mccmnc', 'mcc/mnc'); var save = document.createElement('a'); save.innerHTML = 'Save'; save.addEventListener('click', function(evt) { var operatorDiv = evt.target.parentElement; try { var operator = self.convertToOperator(operatorDiv); self.updateOperators(operator, self.editingAppElement.dataset.name); self.switchOperatorMode(operatorDiv, false); } catch (e) { alert(e.message); } }); if (operator) { idInput.value = operator.id; mccmncInput.value = operator['mcc-mnc'].join(', '); } [idInput, mccmncInput, save].forEach(function(el) { div.appendChild(el); }); }, removeApp: function hs_removeApp() { if (!this.editingAppElement) { throw new Error('You didn\'t select an app to edit'); } var availableApps = document.getElementById('available-apps'); availableApps.appendChild(this.createApp(this.editingAppElement.dataset)); this.editingAppElement.remove(); delete this.editingAppElement; for (var key in this.operators) { delete this.operators[key].apps[this.editingAppElement.dataset.name]; } window.location.hash = ''; }, getMetadata: function hs_getMetadata(entry, manifest, callback) { var {normalizeString} = utils; var name = normalizeString(entry.name) var metadata = { 'name': name, 'installOrigin': MARKETPLACE_INSTALL_ORIGIN, 'manifestURL': entry.manifest_url, 'source': 'external' }; if (entry.is_packaged) { metadata.origin = 'app://' + name; var xhr = new XMLHttpRequest(); xhr.open('HEAD', manifest.package_path, false); xhr.addEventListener('load', function() { if (xhr.status !== 200 && xhr.status !== 304) { console.warn('Get header of packaged app failed, Status Code: ' + xhr.status); callback(metadata); return; } var etag = xhr.getResponseHeader('etag'); if (etag) { metadata.packageEtag = etag; } callback(metadata); }); xhr.send(); } else { var url = new URL(entry.manifest_url); metadata.origin = url.origin; callback(metadata); } }, filterProperties: function hs_filterProperties(obj, names) { var newObj = {}; for (var prop in obj) { if (names.indexOf(prop) >= 0) { newObj[prop] = obj[prop]; } } return newObj; }, processAppDownload: function hs_processAppDownload(metadata, json) { var {getFile, ensureFolderExists} = utils; var {downloadApp} = variant; var filteredMetadata = this.filterProperties(metadata, ['origin', 'manifestURL', 'installOrigin', 'etag', 'packageEtag']); var appPath = getFile(this.gaiaConfig.GAIA_DISTRIBUTION_DIR, EXTERNAL_APP_DIR, metadata.name); ensureFolderExists(appPath); downloadApp(json, filteredMetadata, appPath.path, function() { var {getApp} = utils; var app = getApp(EXTERNAL_APP_DIR, metadata.name, this.gaiaConfig.GAIA_DIR, this.gaiaConfig.GAIA_DISTRIBUTION_DIR); app.metadata = metadata; this.apps[metadata.name] = app; window.location.hash = ''; }.bind(this)); }, addAppFromMarketplace: function hs_addAppFromMarketplace(evt) { var self = this; var {normalizeString} = utils; var entry = this.searchResult.objects[evt.currentTarget.dataset.index]; var name = normalizeString(entry.name); if (this.apps[name]) { alert(name + ' app already exists.'); return; } this.searchResultContainer.innerHTML = 'Downloading...'; var xhr = new XMLHttpRequest(); xhr.open('GET', entry.manifest_url, false); xhr.addEventListener('load', function() { if (xhr.status !== 200 && xhr.status !== 304) { alert('Download Error, Status Code: ' + xhr.status); window.location.hash = ''; return; } var manifest = JSON.parse(xhr.responseText); self.getMetadata(entry, manifest, function(metadata) { var app = self.createApp({ 'name': metadata.name, 'source': 'external' }); document.getElementById('available-apps').appendChild(app); var etag = xhr.getResponseHeader('etag'); if (etag) { metadata.etag = etag; } self.processAppDownload(metadata, manifest); }); }); xhr.send(); }, addToPage: function hs_addToPage(evt) { var props = {}; Object.keys(evt.target.dataset).forEach(function(key) { props[key] = evt.target.dataset[key]; }); var selectedPage = document.querySelector('input[type=radio]:checked ~ .container'); var app = this.createApp(props, true); var max = selectedPage.parentElement.id === 'dock' ? DOCK_MAX_APP : PAGE_MAX_APP; if (selectedPage.children.length < max) { selectedPage.appendChild(app); evt.target.remove(); } else { alert('can\'t add anymore'); } }, createAppElements: function hs_createAppElements(app) { function logMissingIcon(app, entryPoint) { var name = app + (entryPoint ? ':' + entryPoint : ''); console.warn('icons for ' + name + ' doesn\'t exist'); } var elements = []; if (!app.manifest.icons && !app.manifest.entry_points) { logMissingIcon(app.name); } else if (!app.manifest.entry_points && app.manifest.icons) { elements.push(this.createApp({'name': app.name})); } else { for (var entryPoint in app.manifest.entry_points) { if (!app.manifest.entry_points[entryPoint].icons) { logMissingIcon(app.name, entryPoint); continue; } elements.push(this.createApp({ 'name': app.name, 'entryPoint': entryPoint })); } } return elements; }, createApp: function hs_createApp(props, inPage) { var div = document.createElement('div'); div.classList.add('app'); div.innerHTML = props.name; if (props.entryPoint) { div.innerHTML += ':' + props.entryPoint; } Object.keys(props).forEach(function(key) { div.dataset[key] = props[key]; }); if (!div.dataset.source) { div.dataset.source = 'builtin'; } if (!inPage) { div.addEventListener('click', this.addToPage.bind(this)); } else { div.addEventListener('click', this.showOptions.bind(this)); } return div; }, setBuildConfig: function hs_setBuildConfig(config) { this.reset(); this.buildConfig = config; var buildsElement = document.getElementById('gaia-builds'); for (var build in config) { var option = document.createElement('option'); option.setAttribute('value', build); option.innerHTML = build; if (build === 'production') { option.setAttribute('selected', 'selected'); this.selectBuild(build); } buildsElement.appendChild(option); } }, addPage: function hs_addPage() { var self = this; var lastInput = document.querySelector('#grid > div:nth-last-child(2) > input'); var lastId = lastInput ? parseInt(lastInput.id.substr('page'.length)) : 0; var page = document.createElement('div'); page.classList.add('page'); var input = document.createElement('input'); input.setAttribute('type', 'radio'); input.setAttribute('name', 'page-selection'); input.id = 'page' + (lastId + 1); var label = document.createElement('label'); label.setAttribute('for', input.id); var closeBtn = document.createElement('div'); closeBtn.classList.add('close-button'); closeBtn.innerHTML = '×'; closeBtn.addEventListener('click', function(evt) { var targetPage = evt.target.parentNode; var targetInput = targetPage.getElementsByTagName('input')[0]; if (targetInput.checked) { document.getElementById('page0').checked = true; } var apps = targetPage.querySelectorAll('.app'); var availableApps = document.getElementById('available-apps'); Array.prototype.forEach.call(apps, function(app) { var {name, entryPoint} = app.dataset; var restoredApp = self.createApp({ 'name': name, 'entryPoint': entryPoint}); availableApps.appendChild(restoredApp); }); targetPage.remove(); }); var container = document.createElement('div'); container.classList.add('container'); page.appendChild(closeBtn); page.appendChild(input); page.appendChild(label); page.appendChild(container); this.grid.insertBefore(page, this.addPageButton); }, save: function hs_save(evt) { var self = this; if (!utils) { throw new Error('utils module doesn\'t exist'); } var variant = { 'apps': {}, 'operators': [] }; var operators = JSON.parse(JSON.stringify(this.operators)); var distDir = utils.getFile(this.gaiaConfig.GAIA_DISTRIBUTION_DIR); utils.ensureFolderExists(distDir); var pages = document.querySelectorAll('div.page > div.container'); var grid = { homescreens: [], search_page: { enabled: true } }; Array.prototype.forEach.call(pages, function(page, index) { var pageArray = []; Array.prototype.forEach.call(page.children, function(el, position) { var parent; var {name, entryPoint} = el.dataset; if (!self.apps[name]) { throw new Error('App doesn\'t exist: ' + name); } if (self.apps[name].forOperators) { for (var key in self.operators) { if (self.operators[key].apps[name]) { operators[key].apps[name] = { 'id': name, 'screen': index, 'position': position }; variant.apps[name] = self.filterProperties(self.apps[name].metadata, ['origin', 'manifestURL', 'installOrigin', 'etag'] ); } } return; } var f = utils.getFile(self.apps[name].path); parent = f.parent.leafName; var appArray = [parent, name]; if (entryPoint) { appArray.push(entryPoint); } pageArray.push(appArray); self.installedApps[name] = self.apps[name]; }); if (index !== pages.length - 1) { grid.homescreens.push(pageArray); } else { // insert apps in dock to first one element of pageArray grid.homescreens.splice(0, 0, pageArray); } }); var appslistFile = distDir.clone(); appslistFile.append('apps.list'); var applistArray = []; for (var key in self.installedApps) { applistArray.push(self.installedApps[key].parent + '/' + key); } utils.writeContent(appslistFile, applistArray.join('\n')); var homescreenFile = distDir.clone(); homescreenFile.append('homescreens.json'); utils.writeContent(homescreenFile, JSON.stringify(grid, undefined, 2)); for (var key in operators) { var operator = operators[key]; var apps = []; for (var appname in operator.apps) { apps.push(operator.apps[appname]); } operator.apps = apps; variant.operators.push(operators[key]); } var variantFile = distDir.clone(); variantFile.append('variant.json'); utils.writeContent(variantFile, JSON.stringify(variant, undefined, 2)); // open directory in file manager if click save button if (evt && evt.target) { distDir.launch(); } } }; function resetGaiaPath() { Homescreen.resetBuilds(); Services.prefs.setCharPref(GAIA_DIR_PREF_NAME, ''); window.location.hash = '#gaia-path-lightbox'; } window.addEventListener('load', function() { Homescreen.init(); Gaia.addEventListener('setupFinished', function() { utils = Gaia.require('utils'); variant = Gaia.require('variant'); Homescreen.gaiaConfig = Gaia.config; var {getFile, getBuildConfig} = utils; var configs = getBuildConfig(getFile(Gaia.config.GAIA_DIR, 'build', 'config').path); Homescreen.setBuildConfig(configs); }); var observer = { observe: function(subject, topic, data) { if (data !== GAIA_DIR_PREF_NAME) { return; } var gaiaPath = Services.prefs.getCharPref(GAIA_DIR_PREF_NAME); if (!gaiaPath) { return; } try { Gaia.setup(gaiaPath); window.location.hash = ''; } catch (e) { alert(e.message); resetGaiaPath(); } } }; Services.prefs.addObserver(GAIA_DIR_PREF_NAME, observer, false); document.getElementById('launch').addEventListener('click', function(evt) { Homescreen.save(); var installedAppPaths = []; for (var key in Homescreen.installedApps) { installedAppPaths.push(Homescreen.installedApps[key].path); } var original = evt.target.textContent; evt.target.textContent = 'Building gaia... please wait.'; setTimeout(function() { if (!Services.prefs.prefHasUserValue(GAIA_DIR_PREF_NAME)) { Homescreen.selectGaiaFolder(); } Gaia.profile.remove(true); utils.ensureFolderExists(Gaia.profile); Gaia.config.GAIA_APPDIRS = installedAppPaths.join(' '); Gaia.install(); Gaia.launch(); evt.target.textContent = original; }, 100); }); if (Services.prefs.prefHasUserValue(GAIA_DIR_PREF_NAME)) { var gaiaPath = Services.prefs.getCharPref(GAIA_DIR_PREF_NAME); if (gaiaPath) { try { Gaia.setup(gaiaPath); } catch (e) { alert(e.message); resetGaiaPath(); } } else { resetGaiaPath(); } } else { resetGaiaPath(); } }); // FIXME: this is a workaround because we can't use to // apply hash tag to url if using about:gaia. if (window.location.href === 'about:gaia') { window.location = 'chrome://gaia-build/content/aboutGaia.html'; }