VulpesOS/gaia/shared/js/smart-screen/card_manager.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

815 lines
27 KiB
JavaScript
Executable file

/* global evt, SharedUtils, Promise, PipedPromise, Application, CardStore,
Deck, Folder, AsyncSemaphore, AppBookmark */
(function(exports) {
'use strict';
var CardManager = function() {
};
CardManager.STATES = Object.freeze({
'READY': 'READY',
'SYNCING': 'SYNCING'
});
CardManager.prototype = evt({
HIDDEN_ROLES: ['system', 'homescreen', 'addon', 'langpack'],
TABLE_NAME: 'home_cards',
// Only two modes available: readonly and readwrite (default)
// 'readwrite' mode is for Smart-Home app only
// This '_mode' variable only affects CardStore.
_mode: 'readwrite',
_manifestURLOfCardStore: undefined,
_cardStore: undefined,
_cardList: [],
installedApps: {},
_asyncSemaphore: undefined,
_isHiddenApp: function cm_isHiddenApp(role) {
if (!role) {
return false;
}
return (this.HIDDEN_ROLES.indexOf(role) !== -1);
},
_isCardListLoaded: function cm_isCardListLoaded() {
return this._cardList && this._cardList.length > 0;
},
_serializeCard: function cm_serializeCard(card) {
return card && card.serialize();
},
_deserializeCardEntry: function cm_deserializeCardEntry(cardEntry) {
var cardInstance;
switch (cardEntry.type) {
case 'Application':
cardInstance = Application.deserialize(cardEntry, this.installedApps);
break;
case 'Deck':
cardInstance = Deck.deserialize(cardEntry, this.installedApps);
break;
case 'AppBookmark':
cardInstance = AppBookmark.deserialize(cardEntry);
break;
case 'Folder':
cardInstance = Folder.deserialize(cardEntry);
// Save the folder into card store whenever we receives
// folder-changed event
cardInstance.on('folder-changed', this._onFolderChange.bind(this));
break;
}
return cardInstance;
},
writeFolderInCardStore: function cm_writeFolderInCardStore(folder) {
var that = this;
return new Promise(function(resolve, reject) {
if (folder instanceof Folder) {
that._asyncSemaphore.v();
var cardEntriesInFolder =
folder.getCardList().map(that._serializeCard.bind(that));
that._cardStore.saveData(folder.folderId,
cardEntriesInFolder).then(function() {
folder.state = Folder.STATES.NORMAL;
}).then(function() {
that._asyncSemaphore.p();
resolve();
}, reject);
} else {
reject();
}
});
},
// XXX: Call this function when you need to write cardList back to datastore
// Because when we write cardList to datastore, we need to seperate
// first level cards and cards under folder into sperated records in
// datastore. A better way whould pull out logic related to cardList
// into a standalone module. We will do this later.
writeCardlistInCardStore: function cm_writeCardlistInCardStore(options) {
var that = this;
var emptyFolderIndices = [];
return new Promise(function(resolve, reject) {
var saveDataPromises = [];
var newCardList;
that._asyncSemaphore.v();
// The cards inside of folder are not saved nested in cardList
// but we explicit save them under key of folderId.
// Here we save content of each folder one by one
newCardList = that._cardList.filter(function(card, index) {
if (card instanceof Folder) {
if (card.getCardList().length > 0) {
saveDataPromises.push(that.writeFolderInCardStore(card));
} else {
emptyFolderIndices.push(index);
return false;
}
}
return true;
});
if (options && options.cleanEmptyFolder) {
that._cardList = newCardList;
}
Promise.all(saveDataPromises).then(function() {
resolve();
});
}).then(function() {
var cardEntries =
that._cardList.map(that._serializeCard.bind(that));
return that._cardStore.saveData('cardList', cardEntries);
}).then(function() {
that._asyncSemaphore.p();
if (options && options.cleanEmptyFolder) {
that.fire('card-removed', emptyFolderIndices);
}
}).catch(function() {
that._asyncSemaphore.p();
});
},
_loadDefaultCardList: function cm_loadDefaultCardList() {
var that = this;
return this._getPipedPromise('_loadDefaultCardList',
function(resolve, reject) {
var defaultCardListFile = 'shared/resources/default-cards.json';
that._asyncSemaphore.v();
that._loadFile({
url: defaultCardListFile,
responseType: 'json'
}).then(function onFulfill(config) {
that._cardList =
config.card_list.map(function(cardEntry) {
var card = that._deserializeCardEntry(cardEntry);
if (card instanceof Folder && card.isDeserializing()) {
card.loadCardsInFolder({
from: 'config',
cardEntry: cardEntry,
deserializer: that._deserializeCardEntry.bind(that)
});
}
return card;
});
// write cardList into data store for the first time
that.writeCardlistInCardStore().then(resolve, reject);
}).then(function() {
that._asyncSemaphore.p();
}).catch(function onReject(error) {
var reason ='request ' + defaultCardListFile +
' got reject ' + error;
that._asyncSemaphore.p();
reject(reason);
});
});
},
_onCardStoreChange: function cm_onCardStoreChange(evt) {
var that = this;
if (evt.operation === 'updated') {
that._asyncSemaphore.v();
// When we receives 'cardlist-changed' in readonly mode, it means
// Smart-Home app has change cardList. We'd better re-fetch cardList
// as a whole.
if (this._mode === 'readonly') {
this._cardList = [];
}
this._reloadCardList().then(function() {
that._asyncSemaphore.p();
that.fire('cardlist-changed');
});
}
},
_onFolderChange: function cm_onFolderChange(folder) {
if (folder.isDetached()) {
this.writeCardlistInCardStore();
} else {
this.writeFolderInCardStore(folder);
if (folder.isEmpty()) {
this.writeCardlistInCardStore();
}
}
},
_initCardStoreIfNeeded: function cm_initCardStore() {
if (!this._cardStore) {
this._cardStore = new CardStore(
this.TABLE_NAME, this._mode, this._manifestURLOfCardStore);
this._cardStore.on('change', this._onCardStoreChange.bind(this));
}
},
// XXX: DO NOT call this directly except for _reloadCardList.
// Because it is not protected by AsyncSemaphore. We should change it as
// returning a `Promise`
_loadCardListFromCardStore:
function cm_loadCardListFromCardStore(cardListFromCardStore) {
var that = this;
cardListFromCardStore.forEach(function(cardEntry) {
var found = that.findCardFromCardList({cardEntry: cardEntry});
if (!found) {
var card = that._deserializeCardEntry(cardEntry);
// The cards inside of folder are not saved nested in
// datastore 'cardList'. But we explicit save them under key
// of folderId. So we need to retrieve them by their folderId
// and put them back to folders where they belong.
if (card instanceof Folder && card.isDeserializing()) {
card.loadCardsInFolder({
from: 'datastore',
datastore: that._cardStore,
deserializer: that._deserializeCardEntry.bind(that)
});
}
that._cardList.push(card);
}
});
},
_reloadCardList: function cm_loadCardList() {
var that = this;
return this._getPipedPromise('_reloadCardList',
function(resolve, reject) {
that._asyncSemaphore.v();
that._initCardStoreIfNeeded();
resolve(that._cardStore.getData('cardList'));
}).then(function(cardListFromCardStore) {
if (cardListFromCardStore) {
// XXX: Change _loadCardListFromCardStore as returning a `Promise`
that._loadCardListFromCardStore(cardListFromCardStore);
} else {
// no cardList in datastore, load default from config file
return Promise.resolve(that._loadDefaultCardList());
}
}).then(function() {
that._asyncSemaphore.p();
}).catch(function(reason) {
console.warn('Unable to reload cardList due to ' + reason);
that._asyncSemaphore.p();
});
},
_loadFile: function cm_loadIcon(request) {
// _loadFile could accept reentrance, so it should return
// new Promise on each invokation
return new Promise(function(resolve, reject) {
var url = request.url;
var responseType = request.responseType || 'text';
if (typeof url === 'string') {
try {
var xhr = new XMLHttpRequest({mozAnon: true, mozSystem: true});
xhr.open('GET', url, true);
xhr.responseType = responseType;
xhr.onload = function onload(evt) {
if (xhr.status !== 0 && xhr.status !== 200) {
reject(xhr.statusText);
} else {
resolve(xhr.response);
}
};
xhr.ontimeout = xhr.onerror = function onErrorOrTimeout() {
reject();
};
xhr.send();
} catch (e) {
reject(e.message);
}
} else {
reject('invalid request');
}
});
},
_bestMatchingIcon:
function cm_bestMatchingIcon(app, manifest, preferredSize) {
var max = 0;
var closestSize = 0;
preferredSize = preferredSize || Number.MAX_VALUE;
for (var size in manifest.icons) {
size = parseInt(size, 10);
if (size > max) {
max = size;
}
if (!closestSize && size >= preferredSize) {
closestSize = size;
}
}
if (!closestSize) {
closestSize = max;
}
var url = manifest.icons[closestSize];
if (!url) {
return;
}
if (url.indexOf('data:') === 0 ||
url.indexOf('app://') === 0 ||
url.indexOf('http://') === 0 ||
url.indexOf('https://') === 0) {
return url;
}
if (url.charAt(0) != '/') {
console.warn('`' + manifest.name + '` app icon is invalid. ' +
'Manifest `icons` attribute should contain URLs -or- ' +
'absolute paths from the origin field.');
return '';
}
if (app.origin.slice(-1) === '/') {
return app.origin.slice(0, -1) + url;
}
return [app.origin + url, closestSize];
},
_onAppInstall: function cm_onAppInstall(evt) {
var app = evt.application;
var manifest = app.manifest || app.updateManifest;
if (!app.launch || !manifest || !manifest.icons ||
this._isHiddenApp(manifest.role)) {
return;
}
var message =
this.installedApps[app.manifestURL] ? 'update' : 'install';
this.installedApps[app.manifestURL] = app;
this.fire(message, this.getAppEntries(app.manifestURL));
},
_onAppUninstall: function cm_onAppUninstall(evt) {
var app = evt.application;
// XXX: Delete uninstalled app cards if any. The deletion should be
// performed only once, so we let only the owner of data store to do it.
// Notice the application object will be destroyed after uninstall event
// is done, and we won't be able to read any data inside card.nativeApp
// anymore (thus cause failure to FindCardFromCardList and etc.).
// So we let CardManager do it consciously to prevent zombie app cards.
// A possible way to improve it is to decouple AppMgmt and its app objects
// completely with CardManager and leave them inside applications.js.
if (this._cardStore.isOwner) {
var card = this.findCardFromCardList({
manifestURL: app.manifestURL
});
card && this.removeCard(card);
}
if (this.installedApps[app.manifestURL]) {
this.fire('uninstall', this.getAppEntries(app.manifestURL));
delete this.installedApps[app.manifestURL];
}
},
insertNewFolder: function cm_insertFolder(name, index) {
var newFolder = new Folder({
name: name,
state: Folder.STATES.DETACHED
});
this._asyncSemaphore.wait(function() {
if (typeof index !== 'number') {
index = this._cardList.length;
}
this._cardList.splice(index, 0, newFolder);
// Notice that we are not saving card list yet
// Because newFolder is empty, it is meaningless to save it
// But we need to hook `folder-changed` event handler in case
// we need to save it when its content changed
newFolder.on('folder-changed', this._onFolderChange.bind(this));
this.fire('card-inserted', newFolder, index);
}, this);
return newFolder;
},
insertCard: function cm_insertCard(options) {
var that = this;
this._asyncSemaphore.wait(function() {
var newCard;
var position;
if (options.cardEntry) {
newCard = this._deserializeCardEntry(options.cardEntry);
} else {
newCard = options.card;
}
// prevent same card from being inserted twice
if (newCard && newCard.nativeApp) {
var sameCardExisting = that.findCardFromCardList({
manifestURL: newCard.nativeApp.manifestURL,
launchURL: newCard.launchURL
});
if (sameCardExisting) {
return;
}
}
if (typeof options.index === 'number') {
position = options.index;
} else if (!newCard.group) {
position = this._cardList.length;
} else {
// If the given card belongs to a deck (has type), we assume the deck
// spans a group with all its bookmarks following deck icon itself,
// and the given card should be put at the end of the group.
position = -1;
for(var idx = 0; idx < this._cardList.length; idx++) {
var card = this._cardList[idx];
if(position === -1 && !(card instanceof Deck)) {
// Only Decks are admitted as the start of the group.
continue;
} else if (position !== -1 && card.group !== newCard.group) {
// We've exceeded the end of the group.
break;
} else if (card.group === newCard.group) {
// We're still inside the group.
position = idx;
}
}
position += 1;
// No corresponding deck found; insert at bottom.
if (position === 0) {
position = this._cardList.length;
}
}
this._cardList.splice(position, 0, newCard);
this.writeCardlistInCardStore().then(function() {
that.fire('card-inserted', newCard, position, options.overFolder);
});
}, this);
},
removeCard: function cm_removeCard(item) {
this._asyncSemaphore.wait(function() {
var that = this;
var index =
(typeof item === 'number') ? item : this._cardList.indexOf(item);
if (index >= 0) {
this._cardList.splice(index, 1);
this.writeCardlistInCardStore().then(function() {
that.fire('card-removed', [index]);
});
} else {
// the card is not in _cardList, then it could probably be in folder
this._cardList.forEach(function(card) {
if (card instanceof Folder) {
// don't bother to fire card-removed event because the folder
// itself will fire this event
card.removeCard(item);
}
});
}
}, this);
},
updateCard: function cm_updateCard(item, index) {
var that = this;
this._asyncSemaphore.wait(function() {
if (typeof index === 'undefined') {
index = this._cardList.findIndex(function(elem) {
return elem.cardId === item.cardId;
});
}
if (index >= 0) {
// Most of the time, 'item' is directly reference to card in _cardList
// and by the time we reach here, the card ('item') is already
// updated. So don't bother to update it again.
if (this._cardList[index] !== item) {
this._cardList[index] = item;
}
this.writeCardlistInCardStore().then(function() {
that.fire('card-updated', that._cardList[index], index);
});
}
}, this);
},
// XXX: Note that if you put card index as parameters for item1 and item2
// Please make sure they are "index of _cardList"
swapCard: function cm_switchCard(item1, item2) {
this._asyncSemaphore.wait(function() {
var that = this;
var idx1, idx2;
idx1 = (typeof item1 === 'number') ?
idx1 = item1 :
this._cardList.indexOf(item1);
idx2 = (typeof item2 === 'number') ?
idx2 = item2 :
this._cardList.indexOf(item2);
var tmp = this._cardList[idx1];
this._cardList[idx1] = this._cardList[idx2];
this._cardList[idx2] = tmp;
this.writeCardlistInCardStore().then(function() {
that.fire('card-swapped',
that._cardList[idx1], that._cardList[idx2], idx1, idx2);
});
}, this);
},
init: function cm_init(mode) {
var that = this;
var appMgmt = navigator.mozApps.mgmt;
this._asyncSemaphore = new AsyncSemaphore();
// protect from async access to cardList
this._asyncSemaphore.v();
this._mode = mode || 'readwrite';
// If we are running in readonly mode, we need to tell card store what
// manifestURL of the datastore we are going to use, because we are not
// using card store of current app.
if (this._mode === 'readonly') {
this._manifestURLOfCardStore =
'app://smart-home.gaiamobile.org/manifest.webapp';
}
return this._getPipedPromise('init', function(resolve, reject) {
var request = appMgmt.getAll();
request.onsuccess = function onsuccess(event) {
event.target.result.forEach(function eachApp(app) {
var manifest = app.manifest;
if (!app.launch || !manifest || !manifest.icons ||
that._isHiddenApp(manifest.role)) {
return;
}
that.installedApps[app.manifestURL] = app;
});
resolve(that._reloadCardList());
};
request.onerror = function() {
reject();
that._asyncSemaphore.p();
};
appMgmt.addEventListener('install', that);
appMgmt.addEventListener('uninstall', that);
}).then(function() {
that._asyncSemaphore.p();
});
},
uninit: function cm_uninit() {
var appMgmt = navigator.mozApps.mgmt;
appMgmt.removeEventListener('install', this);
appMgmt.removeEventListener('uninstall', this);
this._cardList = [];
this._cardStore.off('change');
this._cardStore = undefined;
this.installedApps = {};
},
getAppEntries: function cm_getAppEntries(manifestURL) {
if (!manifestURL || !this.installedApps[manifestURL]) {
return [];
}
var manifest = this.installedApps[manifestURL].manifest ||
this.installedApps[manifestURL].updateManifest;
var entryPoints = manifest.entry_points;
var entries = [];
var removable = this.installedApps[manifestURL].removable;
if (!entryPoints || manifest.type !== 'certified') {
entries.push({
manifestURL: manifestURL,
entryPoint: '',
name: manifest.name,
removable: removable,
type: 'Application'
});
} else {
for (var entryPoint in entryPoints) {
if (entryPoints[entryPoint].icons) {
entries.push({
manifestURL: manifestURL,
entryPoint: entryPoint,
name: entryPoints[entryPoint].name,
removable: removable,
type: 'Application'
});
}
}
}
return entries;
},
getEntryManifest: function cm_getEntryManifest(manifestURL, entryPoint) {
if (!manifestURL || !this.installedApps[manifestURL]) {
return null;
}
var manifest = this.installedApps[manifestURL].manifest ||
this.installedApps[manifestURL].updateManifest;
if (entryPoint) {
var entry_manifest = manifest.entry_points[entryPoint];
return entry_manifest || null;
}
return manifest;
},
getIconBlob: function cm_getIconBlob(options) {
var manifestURL = options.manifestURL;
var entryPoint = options.entryPoint;
var preferredSize = options.preferredSize;
var that = this;
return new Promise(function(resolve, reject) {
var entry_manifest = that.getEntryManifest(manifestURL, entryPoint);
if (!entry_manifest) {
reject('No manifest');
}
var iconData = that._bestMatchingIcon(
that.installedApps[manifestURL], entry_manifest, preferredSize);
if (!iconData) {
reject('No url');
return;
}
that._loadFile({
url: iconData[0],
responseType: 'blob'
}).then(function onFulfill(blob) {
resolve([blob, iconData[1]]);
}, function onReject(error) {
reject('Error on loading blob of ' + manifestURL);
});
});
},
_getLocalizedName: function cm_getLocalizedName(manifestURL, lang) {
if (!manifestURL || !lang) {
return;
}
var manifest = this.getEntryManifest(manifestURL);
var locales = manifest.locales;
var localizedName = locales && locales[lang] &&
(locales[lang].short_name || locales[lang].name);
return localizedName || manifest.short_name || manifest.name;
},
// Resolve to one of the following forms:
// 1. {raw: 'localized name'}
// 2. {id: 'l10nid'}
// 3. undefined
resolveCardName: function cm_resolveCardName(card, lang) {
var name;
if (!card || !lang) {
return name;
}
// Priority is:
// 1. user given name
// 2. l10nId if any
// 3. localized application/deck name if it is an application/deck
if (card.name && card.name.raw) {
name = {
raw: card.name.raw
};
} else if (card.name && card.name.id) {
name = card.name;
} else if (card.nativeApp &&
(card instanceof Application || card instanceof Deck)) {
name = {
raw: this._getLocalizedName(card.nativeApp.manifestURL, lang)
};
}
return name;
},
getCardList: function cm_getCardList() {
var that = this;
return this._getPipedPromise('getCardList', function(resolve, reject) {
that._asyncSemaphore.wait(function() {
resolve(that._reloadCardList());
}, that);
}).then(function() {
return Promise.resolve(that._cardList);
});
},
getFilteredCardList: function cm_getFilteredCardList(filterName) {
return this.getCardList().then(function(allCards) {
var filteredCards = [];
if (filterName === 'all') {
filteredCards = allCards;
} else if (filterName) {
allCards.forEach(function(card) {
if (card.group === filterName) {
filteredCards.push(card);
}
});
}
return Promise.resolve(filteredCards);
});
},
// TODO: need to be protected by semaphore
// TODO: some comparison are based on card type. We may move these part to
// card class themselves.
// There are three types of query:
// 1. query by cardId
// 2. query by manifestURL and optionally launchURL
// 3. query by bookmark url
// 4. query by cardEntry (i.e. serialized card)
findCardFromCardList: function cm_findCardFromCardList(query) {
var found;
this._cardList.some(function(card) {
if (card instanceof Folder) {
// XXX: findCard() shares almost the same logic as
// findCardFromCardList. We should consolidate them. See
// http://bugzil.la/1156726
found = card.findCard(query);
if (found) {
return true;
}
}
if (card.cardId === query.cardId) {
found = card;
return true;
} else if (query.manifestURL && card.nativeApp &&
card.nativeApp.manifestURL === query.manifestURL) {
// if we specify launchURL in query, then we must compare
// launchURL first
if (query.launchURL) {
if (card.launchURL === query.launchURL) {
found = card;
return true;
}
} else {
found = card;
return true;
}
} else if (query.url) {
if (query.url === card.url) {
found = card;
return true;
}
} else if (query.cardEntry) {
// XXX: this could be bad at performance because we serialize card
// in every loop. We might need improvement on this query.
if (JSON.stringify(card.serialize()) ===
JSON.stringify(query.cardEntry)) {
found = card;
return true;
}
}
});
return found;
},
findContainingFolder: function cm_findContainingFolder(query) {
var folder;
this._cardList.some(function(card) {
if (card instanceof Folder) {
if (card.findCard(query)) {
folder = card;
}
}
});
return folder;
},
isPinned: function cm_isPinned(options) {
var that = this;
return this._getPipedPromise('isPinned', function(resolve, reject) {
that._asyncSemaphore.wait(function() {
resolve(that._reloadCardList());
}, that);
}).then(function() {
return Promise.resolve(!!that.findCardFromCardList(options));
});
},
handleEvent: function cr_handleEvent(evt) {
switch(evt.type) {
case 'install':
this._onAppInstall(evt);
break;
case 'uninstall':
this._onAppUninstall(evt);
break;
}
}
});
SharedUtils.addMixin(CardManager, PipedPromise);
exports.CardManager = CardManager;
}(window));