Bug 1234558 - Use icons from app manifest. r=marcosc, r=sebastian

This commit is contained in:
Dale Harvey 2017-01-17 18:24:53 +00:00
Родитель a25a4562c0
Коммит 0a37fafed0
14 изменённых файлов: 290 добавлений и 4 удалений

Просмотреть файл

@ -18,6 +18,7 @@ const {
} = Components;
Cu.import("resource://gre/modules/ManifestObtainer.jsm");
Cu.import("resource://gre/modules/ManifestFinder.jsm");
Cu.import("resource://gre/modules/ManifestIcons.jsm");
Cu.import("resource://gre/modules/Task.jsm");
const MessageHandler = {
@ -34,6 +35,10 @@ const MessageHandler = {
"DOM:Manifest:FireAppInstalledEvent",
this.fireAppInstalledEvent.bind(this)
);
addMessageListener(
"DOM:WebManifest:fetchIcon",
this.fetchIcon.bind(this)
);
},
/**
@ -76,7 +81,24 @@ const MessageHandler = {
content.dispatchEvent(ev);
}
sendAsyncMessage("DOM:Manifest:FireAppInstalledEvent", response);
}
},
/**
* Given a manifest and an expected icon size, ask ManifestIcons
* to fetch the appropriate icon and send along result
*/
fetchIcon: Task.async(function* ({data: {id, manifest, iconSize}}) {
const response = makeMsgResponse(id);
try {
response.result =
yield ManifestIcons.contentFetchIcon(content, manifest, iconSize);
response.success = true;
} catch (err) {
response.result = serializeError(err);
}
sendAsyncMessage("DOM:WebManifest:fetchIcon", response);
}),
};
/**
* Utility function to Serializes an JS Error, so it can be transferred over

42
dom/manifest/Manifest.jsm Normal file
Просмотреть файл

@ -0,0 +1,42 @@
/*
* Manifest.jsm is the top level api for managing installed web applications
* https://www.w3.org/TR/appmanifest/
*
* It is used to trigger the installation of a web application via .install()
* and to access the manifest data (including icons).
*
* TODO:
* - Persist installed manifest data to disk and keep track of which
* origins have installed applications
* - Trigger appropriate app installed events
*/
"use strict";
const Cu = Components.utils;
Cu.import("resource://gre/modules/Task.jsm");
const { ManifestObtainer } =
Cu.import('resource://gre/modules/ManifestObtainer.jsm', {});
const { ManifestIcons } =
Cu.import('resource://gre/modules/ManifestIcons.jsm', {});
function Manifest(browser) {
this.browser = browser;
this.data = null;
}
Manifest.prototype.install = Task.async(function* () {
this.data = yield ManifestObtainer.browserObtainManifest(this.browser);
});
Manifest.prototype.icon = Task.async(function* (expectedSize) {
return yield ManifestIcons.browserFetchIcon(this.browser, this.data, expectedSize);
});
Manifest.prototype.name = function () {
return this.data.short_name || this.data.short_url;
}
this.EXPORTED_SYMBOLS = ["Manifest"]; // jshint ignore:line

Просмотреть файл

@ -0,0 +1,85 @@
"use strict";
const {
utils: Cu,
classes: Cc,
interfaces: Ci
} = Components;
Cu.import("resource://gre/modules/PromiseMessage.jsm");
this.ManifestIcons = {
async browserFetchIcon(aBrowser, manifest, iconSize) {
const msgKey = "DOM:WebManifest:fetchIcon";
const mm = aBrowser.messageManager;
const {data: {success, result}} =
await PromiseMessage.send(mm, msgKey, {manifest, iconSize});
if (!success) {
throw result;
}
return result;
},
async contentFetchIcon(aWindow, manifest, iconSize) {
return await getIcon(aWindow, toIconArray(manifest.icons), iconSize);
}
};
function parseIconSize(size) {
if (size === "any" || size === "") {
// We want icons without size specified to sorted
// as the largest available icons
return Number.MAX_SAFE_INTEGER;
}
// 100x100 will parse as 100
return parseInt(size, 10);
}
// Create an array of icons sorted by their size
function toIconArray(icons) {
const iconBySize = [];
icons.forEach(icon => {
const sizes = ("sizes" in icon) ? icon.sizes : "";
sizes.split(" ").forEach(size => {
iconBySize.push({src: icon.src, size: parseIconSize(size)});
});
});
return iconBySize.sort((a, b) => a.size - b.size);
}
async function getIcon(aWindow, icons, expectedSize) {
if (!icons.length) {
throw new Error("Could not find valid icon");
}
// We start trying the smallest icon that is larger than the requested
// size and go up to the largest icon if they fail, if all those fail
// go back down to the smallest
let index = icons.findIndex(icon => icon.size >= expectedSize);
if (index === -1) {
index = icons.length - 1;
}
return fetchIcon(aWindow, icons[index].src).catch(err => {
// Remove all icons with the failed source, the same source
// may have been used for multiple sizes
icons = icons.filter(x => x.src === icons[index].src);
return getIcon(aWindow, icons, expectedSize);
});
}
function fetchIcon(aWindow, src) {
const manifestURL = new aWindow.URL(src);
const request = new aWindow.Request(manifestURL, {mode: "cors"});
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_MANIFEST);
return aWindow.fetch(request)
.then(response => response.blob())
.then(blob => new Promise((resolve, reject) => {
var reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
}));
}
this.EXPORTED_SYMBOLS = ["ManifestIcons"]; // jshint ignore:line

Просмотреть файл

@ -6,7 +6,9 @@
EXTRA_JS_MODULES += [
'ImageObjectProcessor.jsm',
'Manifest.jsm',
'ManifestFinder.jsm',
'ManifestIcons.jsm',
'ManifestObtainer.jsm',
'ManifestProcessor.jsm',
'ValueExtractor.jsm',

Двоичные данные
dom/manifest/test/blue-150.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 534 B

Просмотреть файл

@ -4,6 +4,9 @@ support-files =
file_testserver.sjs
manifestLoader.html
resource.sjs
red-50.png
blue-150.png
[browser_ManifestFinder_browserHasManifestLink.js]
[browser_ManifestIcons_browserFetchIcon.js]
[browser_ManifestObtainer_obtain.js]
[browser_fire_appinstalled_event.js]

Просмотреть файл

@ -0,0 +1,56 @@
//Used by JSHint:
/*global Cu, BrowserTestUtils, ok, add_task, gBrowser */
"use strict";
const { ManifestIcons } = Cu.import("resource://gre/modules/ManifestIcons.jsm", {});
const { ManifestObtainer } = Cu.import("resource://gre/modules/ManifestObtainer.jsm", {});
const defaultURL = new URL("http://example.org/browser/dom/manifest/test/resource.sjs");
defaultURL.searchParams.set("Content-Type", "application/manifest+json");
const manifest = JSON.stringify({
icons: [{
sizes: "50x50",
src: "red-50.png?Content-type=image/png"
}, {
sizes: "150x150",
src: "blue-150.png?Content-type=image/png"
}]
});
function makeTestURL(manifest) {
const url = new URL(defaultURL);
const body = `<link rel="manifest" href='${defaultURL}&body=${manifest}'>`;
url.searchParams.set("Content-Type", "text/html; charset=utf-8");
url.searchParams.set("body", encodeURIComponent(body));
return url.href;
}
function getIconColor(icon) {
return new Promise((resolve, reject) => {
const canvas = content.document.createElement('canvas');
const ctx = canvas.getContext("2d");
const image = new content.Image();
image.onload = function() {
ctx.drawImage(image, 0, 0);
resolve(ctx.getImageData(1, 1, 1, 1).data);
};
image.onerror = function() {
reject(new Error("could not create image"));
};
image.src = icon;
});
}
add_task(function*() {
const tabOptions = {gBrowser, url: makeTestURL(manifest)};
yield BrowserTestUtils.withNewTab(tabOptions, function*(browser) {
const manifest = yield ManifestObtainer.browserObtainManifest(browser);
let icon = yield ManifestIcons.browserFetchIcon(browser, manifest, 25);
let color = yield ContentTask.spawn(browser, icon, getIconColor);
is(color[0], 255, 'Fetched red icon');
icon = yield ManifestIcons.browserFetchIcon(browser, manifest, 500);
color = yield ContentTask.spawn(browser, icon, getIconColor);
is(color[2], 255, 'Fetched blue icon');
});
});

Двоичные данные
dom/manifest/test/icon.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 8.0 KiB

Двоичные данные
dom/manifest/test/red-50.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 141 B

Просмотреть файл

@ -37,6 +37,7 @@ import org.mozilla.gecko.distribution.PartnerBrowserCustomizationsClient;
import org.mozilla.gecko.dlc.DownloadContentService;
import org.mozilla.gecko.icons.IconsHelper;
import org.mozilla.gecko.icons.decoders.IconDirectoryEntry;
import org.mozilla.gecko.icons.decoders.FaviconDecoder;
import org.mozilla.gecko.feeds.ContentNotificationsDelegate;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.firstrun.FirstrunAnimationContainer;
@ -752,6 +753,7 @@ public class BrowserApp extends GeckoApp
"Sanitize:ClearSyncedTabs",
"Telemetry:Gather",
"Download:AndroidDownloadManager",
"Website:AppInstalled",
"Website:Metadata",
null);
@ -1376,7 +1378,6 @@ public class BrowserApp extends GeckoApp
@Override
public void run() {
GeckoAppShell.createShortcut(title, url);
}
});
@ -1465,6 +1466,7 @@ public class BrowserApp extends GeckoApp
"Sanitize:ClearSyncedTabs",
"Telemetry:Gather",
"Download:AndroidDownloadManager",
"Website:AppInstalled",
"Website:Metadata",
null);
@ -1968,6 +1970,15 @@ public class BrowserApp extends GeckoApp
ContextUtils.isPackageInstalled(getContext(), "org.torproject.android") ? 1 : 0);
break;
case "Website:AppInstalled":
final String name = message.getString("name");
final String startUrl = message.getString("start_url");
final Bitmap icon = FaviconDecoder
.decodeDataURI(getContext(), message.getString("icon"))
.getBestBitmap(GeckoAppShell.getPreferredIconSize());
createShortcut(name, startUrl, icon);
break;
case "Updater:Launch":
/**
* Launch UI that lets the user update Firefox.

Просмотреть файл

@ -1947,6 +1947,18 @@ public abstract class GeckoApp
@Override
public void createShortcut(final String title, final String url) {
final Tab selectedTab = Tabs.getInstance().getSelectedTab();
if (selectedTab.hasManifest()) {
// If a page has associated manifest, lets install it
final GeckoBundle message = new GeckoBundle();
message.putInt("iconSize", GeckoAppShell.getPreferredIconSize());
EventDispatcher.getInstance().dispatch("Browser:LoadManifest", message);
return;
}
// Otherwise we try to pick best icon from favicons etc
Icons.with(this)
.pageUrl(url)
.skipNetwork()
@ -1956,12 +1968,12 @@ public abstract class GeckoApp
.execute(new IconCallback() {
@Override
public void onIconResponse(IconResponse response) {
doCreateShortcut(title, url, response.getBitmap());
createShortcut(title, url, response.getBitmap());
}
});
}
private void doCreateShortcut(final String aTitle, final String aURI, final Bitmap aIcon) {
public void createShortcut(final String aTitle, final String aURI, final Bitmap aIcon) {
// The intent to be launched by the shortcut.
Intent shortcutIntent = new Intent();
shortcutIntent.setAction(GeckoApp.ACTION_HOMESCREEN_SHORTCUT);

Просмотреть файл

@ -60,6 +60,7 @@ public class Tab {
private Future<IconResponse> mRunningIconRequest;
private boolean mHasFeeds;
private boolean mHasManifest;
private boolean mHasOpenSearch;
private final SiteIdentity mSiteIdentity;
private SiteLogins mSiteLogins;
@ -300,6 +301,10 @@ public class Tab {
return mHasFeeds;
}
public boolean hasManifest() {
return mHasManifest;
}
public boolean hasOpenSearch() {
return mHasOpenSearch;
}
@ -477,6 +482,10 @@ public class Tab {
mHasFeeds = hasFeeds;
}
public void setHasManifest(boolean hasManifest) {
mHasManifest = hasManifest;
}
public void setHasOpenSearch(boolean hasOpenSearch) {
mHasOpenSearch = hasOpenSearch;
}
@ -638,6 +647,7 @@ public class Tab {
mBaseDomain = message.optString("baseDomain");
setHasFeeds(false);
setHasManifest(false);
setHasOpenSearch(false);
mSiteIdentity.reset();
setSiteLogins(null);

Просмотреть файл

@ -125,6 +125,7 @@ public class Tabs implements BundleEventListener, GeckoEventListener {
"Link:Touchicon",
"Link:Feed",
"Link:OpenSearch",
"Link:Manifest",
"DesktopMode:Changed",
"Tab:StreamStart",
"Tab:StreamStop",
@ -584,6 +585,9 @@ public class Tabs implements BundleEventListener, GeckoEventListener {
} else if (event.equals("Link:Feed")) {
tab.setHasFeeds(true);
notifyListeners(tab, TabEvents.LINK_FEED);
} else if (event.equals("Link:Manifest")) {
tab.setHasManifest(true);
} else if (event.equals("Link:OpenSearch")) {
boolean visible = message.getBoolean("visible");
tab.setHasOpenSearch(visible);

Просмотреть файл

@ -21,6 +21,9 @@ if (AppConstants.ACCESSIBILITY) {
"resource://gre/modules/accessibility/AccessFu.jsm");
}
XPCOMUtils.defineLazyModuleGetter(this, "Manifest",
"resource://gre/modules/Manifest.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "SpatialNavigation",
"resource://gre/modules/SpatialNavigation.jsm");
@ -393,6 +396,8 @@ var BrowserApp = {
Services.obs.addObserver(this, "Fonts:Reload", false);
Services.obs.addObserver(this, "Vibration:Request", false);
GlobalEventDispatcher.registerListener(this, "Browser:LoadManifest");
Messaging.addListener(this.getHistory.bind(this), "Session:GetHistory");
window.addEventListener("fullscreen", function() {
@ -492,6 +497,9 @@ var BrowserApp = {
let mm = window.getGroupMessageManager("browsers");
mm.loadFrameScript("chrome://browser/content/content.js", true);
// Listen to manifest messages
mm.loadFrameScript("chrome://global/content/manifestMessages.js", true);
// We can't delay registering WebChannel listeners: if the first page is
// about:accounts, which can happen when starting the Firefox Account flow
// from the first run experience, or via the Firefox Account Status
@ -1598,6 +1606,26 @@ var BrowserApp = {
Services.prefs.setComplexValue(pref, Ci.nsIPrefLocalizedString, pls);
},
onEvent: function (event, data, callback) {
switch (event) {
case "Browser:LoadManifest":
const manifest = new Manifest(BrowserApp.selectedBrowser);
manifest.install().then(() => {
return manifest.icon(data.iconSize);
}).then(icon => {
GlobalEventDispatcher.sendRequest({
type: "Website:AppInstalled",
icon: icon,
name: manifest.name(),
start_url: manifest.data.start_url
});
}).catch(err => {
Cu.reportError("Failed to install " + data.src);
});
break;
}
},
observe: function(aSubject, aTopic, aData) {
let browser = this.selectedBrowser;
@ -3780,6 +3808,13 @@ Tab.prototype = {
}
},
makeManifestMessage: function() {
return {
type: "Link:Manifest",
tabID: this.id
};
},
sendOpenSearchMessage: function(eventTarget) {
let type = eventTarget.type && eventTarget.type.toLowerCase();
// Replace all starting or trailing spaces or spaces before "*;" globally w/ "".
@ -3988,6 +4023,10 @@ Tab.prototype = {
jsonMessage = this.makeFeedMessage(target, type);
} else if (list.indexOf("[search]") != -1 && aEvent.type == "DOMLinkAdded") {
this.sendOpenSearchMessage(target);
} else if (list.indexOf("[manifest]") != -1 &&
aEvent.type == "DOMLinkAdded" &&
Services.prefs.getBoolPref("manifest.install.enabled")) {
jsonMessage = this.makeManifestMessage(target);
}
if (!jsonMessage)
return;