mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-10-08 19:04:45 +00:00
Bug 1234558 - Use icons from app manifest. r=marcosc, r=sebastian
This commit is contained in:
parent
a25a4562c0
commit
0a37fafed0
@ -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
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
|
85
dom/manifest/ManifestIcons.jsm
Normal file
85
dom/manifest/ManifestIcons.jsm
Normal file
@ -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',
|
||||
|
BIN
dom/manifest/test/blue-150.png
Normal file
BIN
dom/manifest/test/blue-150.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 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]
|
56
dom/manifest/test/browser_ManifestIcons_browserFetchIcon.js
Normal file
56
dom/manifest/test/browser_ManifestIcons_browserFetchIcon.js
Normal file
@ -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');
|
||||
});
|
||||
});
|
BIN
dom/manifest/test/icon.png
Normal file
BIN
dom/manifest/test/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.0 KiB |
BIN
dom/manifest/test/red-50.png
Normal file
BIN
dom/manifest/test/red-50.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 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;
|
||||
|
Loading…
Reference in New Issue
Block a user