merge m-c to fx-team; a=merge

This commit is contained in:
Tim Taubert 2014-08-03 05:28:57 +02:00
commit 67466f4ff1
77 changed files with 3196 additions and 285 deletions

View File

@ -310,10 +310,16 @@ function onSearchSubmit(aEvent)
document.dispatchEvent(event);
}
aEvent.preventDefault();
gSearchSuggestionController.addInputValueToFormHistory();
if (aEvent) {
aEvent.preventDefault();
}
}
let gSearchSuggestionController;
function setupSearchEngine()
{
// The "autofocus" attribute doesn't focus the form element
@ -341,6 +347,12 @@ function setupSearchEngine()
searchText.placeholder = searchEngineName;
}
if (!gSearchSuggestionController) {
gSearchSuggestionController =
new SearchSuggestionUIController(searchText, searchText.parentNode,
onSearchSubmit);
}
gSearchSuggestionController.engineName = searchEngineName;
}
/**

View File

@ -24,10 +24,14 @@
<link rel="icon" type="image/png" id="favicon"
href="chrome://branding/content/icon32.png"/>
<link rel="stylesheet" type="text/css" media="all"
href="chrome://browser/content/searchSuggestionUI.css"/>
<link rel="stylesheet" type="text/css" media="all" defer="defer"
href="chrome://browser/content/abouthome/aboutHome.css"/>
<script type="text/javascript;version=1.8"
src="chrome://browser/content/abouthome/aboutHome.js"/>
<script type="text/javascript;version=1.8"
src="chrome://browser/content/searchSuggestionUI.js"/>
</head>
<body dir="&locale.dir;">

View File

@ -216,9 +216,37 @@ let AboutHomeListener = {
AboutHomeListener.init(this);
// An event listener for custom "WebChannelMessageToChrome" events on pages
addEventListener("WebChannelMessageToChrome", function (e) {
// if target is window then we want the document principal, otherwise fallback to target itself.
let principal = e.target.nodePrincipal ? e.target.nodePrincipal : e.target.document.nodePrincipal;
if (e.detail) {
sendAsyncMessage("WebChannelMessageToChrome", e.detail, null, principal);
} else {
Cu.reportError("WebChannel message failed. No message detail.");
}
}, true, true);
// Add message listener for "WebChannelMessageToContent" messages from chrome scripts
addMessageListener("WebChannelMessageToContent", function (e) {
if (e.data) {
content.dispatchEvent(new content.CustomEvent("WebChannelMessageToContent", {
detail: Cu.cloneInto({
id: e.data.id,
message: e.data.message,
}, content),
}));
} else {
Cu.reportError("WebChannel message failed. No message data.");
}
});
let ContentSearchMediator = {
whitelist: new Set([
"about:home",
"about:newtab",
]),
@ -247,7 +275,7 @@ let ContentSearchMediator = {
},
get _contentWhitelisted() {
return this.whitelist.has(content.document.documentURI.toLowerCase());
return this.whitelist.has(content.document.documentURI);
},
_sendMsg: function (type, data=null) {
@ -258,12 +286,14 @@ let ContentSearchMediator = {
},
_fireEvent: function (type, data=null) {
content.dispatchEvent(new content.CustomEvent("ContentSearchService", {
let event = Cu.cloneInto({
detail: {
type: type,
data: data,
},
}));
}, content);
content.dispatchEvent(new content.CustomEvent("ContentSearchService",
event));
},
};
ContentSearchMediator.init(this);

View File

@ -392,3 +392,8 @@ input[type=button] {
.newtab-search-panel-engine[selected] {
background: url("chrome://global/skin/menu/shared-menu-check.png") center left 4px no-repeat transparent;
}
.searchSuggestionTable {
font: message-box;
font-size: 16px;
}

View File

@ -5,6 +5,7 @@
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<?xml-stylesheet href="chrome://browser/content/searchSuggestionUI.css" type="text/css"?>
<?xml-stylesheet href="chrome://browser/content/newtab/newTab.css" type="text/css"?>
<?xml-stylesheet href="chrome://browser/skin/newtab/newTab.css" type="text/css"?>
@ -97,4 +98,6 @@
<xul:script type="text/javascript;version=1.8"
src="chrome://browser/content/newtab/newTab.js"/>
<xul:script type="text/javascript;version=1.8"
src="chrome://browser/content/searchSuggestionUI.js"/>
</xul:window>

View File

@ -30,7 +30,9 @@ let gSearch = {
},
search: function (event) {
event.preventDefault();
if (event) {
event.preventDefault();
}
let searchStr = this._nodes.text.value;
if (this.currentEngineName && searchStr.length) {
this._send("Search", {
@ -39,6 +41,7 @@ let gSearch = {
whence: "newtab",
});
}
this._suggestionController.addInputValueToFormHistory();
},
manageEngines: function () {
@ -47,7 +50,10 @@ let gSearch = {
},
handleEvent: function (event) {
this["on" + event.detail.type](event.detail.data);
let methodName = "on" + event.detail.type;
if (this.hasOwnProperty(methodName)) {
this[methodName](event.detail.data);
}
},
onState: function (data) {
@ -183,5 +189,14 @@ let gSearch = {
this._nodes.logo.hidden = true;
this._nodes.text.placeholder = engine.name;
}
// Set up the suggestion controller.
if (!this._suggestionController) {
let parent = document.getElementById("newtab-scrollbox");
this._suggestionController =
new SearchSuggestionUIController(this._nodes.text, parent,
() => this.search());
}
this._suggestionController.engineName = engine.name;
},
};

View File

@ -0,0 +1,48 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
.searchSuggestionTable {
background-color: hsla(0,0%,100%,.99);
border: 1px solid;
border-color: hsla(210,54%,20%,.15) hsla(210,54%,20%,.17) hsla(210,54%,20%,.2);
border-spacing: 0;
border-top: 0;
box-shadow: 0 1px 0 hsla(210,65%,9%,.02) inset,
0 0 2px hsla(210,65%,9%,.1) inset,
0 1px 0 hsla(0,0%,100%,.2);
overflow: hidden;
padding: 0;
position: absolute;
text-align: start;
z-index: 1001;
}
.searchSuggestionRow {
cursor: default;
margin: 0;
max-width: inherit;
padding: 0;
}
.searchSuggestionRow.formHistory {
color: hsl(210,100%,40%);
}
.searchSuggestionRow.selected {
background-color: hsl(210,100%,40%);
color: hsl(0,0%,100%);
}
.searchSuggestionEntry {
margin: 0;
max-width: inherit;
overflow: hidden;
padding: 6px 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchSuggestionEntry > span.typed {
font-weight: bold;
}

View File

@ -0,0 +1,379 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
this.SearchSuggestionUIController = (function () {
const MAX_DISPLAYED_SUGGESTIONS = 6;
const SUGGESTION_ID_PREFIX = "searchSuggestion";
const CSS_URI = "chrome://browser/content/searchSuggestionUI.css";
const HTML_NS = "http://www.w3.org/1999/xhtml";
/**
* Creates a new object that manages search suggestions and their UI for a text
* box.
*
* The UI consists of an html:table that's inserted into the DOM after the given
* text box and styled so that it appears as a dropdown below the text box.
*
* @param inputElement
* Search suggestions will be based on the text in this text box.
* Assumed to be an html:input. xul:textbox is untested but might work.
* @param tableParent
* The suggestion table is appended as a child to this element. Since
* the table is absolutely positioned and its top and left values are set
* to be relative to the top and left of the page, either the parent and
* all its ancestors should not be positioned elements (i.e., their
* positions should be "static"), or the parent's position should be the
* top left of the page.
* @param onClick
* A function that's called when a search suggestion is clicked. Ideally
* we could call submit() on inputElement's ancestor form, but that
* doesn't trigger submit listeners.
* @param idPrefix
* The IDs of elements created by the object will be prefixed with this
* string.
*/
function SearchSuggestionUIController(inputElement, tableParent, onClick=null,
idPrefix="") {
this.input = inputElement;
this.onClick = onClick;
this._idPrefix = idPrefix;
let tableID = idPrefix + "searchSuggestionTable";
this.input.autocomplete = "off";
this.input.setAttribute("aria-autocomplete", "true");
this.input.setAttribute("aria-controls", tableID);
tableParent.appendChild(this._makeTable(tableID));
this.input.addEventListener("keypress", this);
this.input.addEventListener("input", this);
this.input.addEventListener("focus", this);
this.input.addEventListener("blur", this);
window.addEventListener("ContentSearchService", this);
this._stickyInputValue = "";
this._hideSuggestions();
}
SearchSuggestionUIController.prototype = {
// The timeout (ms) of the remote suggestions. Corresponds to
// SearchSuggestionController.remoteTimeout. Uses
// SearchSuggestionController's default timeout if falsey.
remoteTimeout: undefined,
get engineName() {
return this._engineName;
},
set engineName(val) {
this._engineName = val;
if (val && document.activeElement == this.input) {
this._speculativeConnect();
}
},
get selectedIndex() {
for (let i = 0; i < this._table.children.length; i++) {
let row = this._table.children[i];
if (row.classList.contains("selected")) {
return i;
}
}
return -1;
},
set selectedIndex(idx) {
// Update the table's rows, and the input when there is a selection.
this._table.removeAttribute("aria-activedescendant");
for (let i = 0; i < this._table.children.length; i++) {
let row = this._table.children[i];
if (i == idx) {
row.classList.add("selected");
row.firstChild.setAttribute("aria-selected", "true");
this._table.setAttribute("aria-activedescendant", row.firstChild.id);
this.input.value = this.suggestionAtIndex(i);
}
else {
row.classList.remove("selected");
row.firstChild.setAttribute("aria-selected", "false");
}
}
// Update the input when there is no selection.
if (idx < 0) {
this.input.value = this._stickyInputValue;
}
},
get numSuggestions() {
return this._table.children.length;
},
suggestionAtIndex: function (idx) {
let row = this._table.children[idx];
return row ? row.textContent : null;
},
deleteSuggestionAtIndex: function (idx) {
// Only form history suggestions can be deleted.
if (this.isFormHistorySuggestionAtIndex(idx)) {
let suggestionStr = this.suggestionAtIndex(idx);
this._sendMsg("RemoveFormHistoryEntry", suggestionStr);
this._table.children[idx].remove();
this.selectedIndex = -1;
}
},
isFormHistorySuggestionAtIndex: function (idx) {
let row = this._table.children[idx];
return row && row.classList.contains("formHistory");
},
addInputValueToFormHistory: function () {
this._sendMsg("AddFormHistoryEntry", this.input.value);
},
handleEvent: function (event) {
this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
},
_onInput: function () {
if (this.input.value) {
this._getSuggestions();
}
else {
this._stickyInputValue = "";
this._hideSuggestions();
}
this.selectedIndex = -1;
},
_onKeypress: function (event) {
let selectedIndexDelta = 0;
switch (event.keyCode) {
case event.DOM_VK_UP:
if (this.numSuggestions) {
selectedIndexDelta = -1;
}
break;
case event.DOM_VK_DOWN:
if (this.numSuggestions) {
selectedIndexDelta = 1;
}
else {
this._getSuggestions();
}
break;
case event.DOM_VK_RIGHT:
// Allow normal caret movement until the caret is at the end of the input.
if (this.input.selectionStart != this.input.selectionEnd ||
this.input.selectionEnd != this.input.value.length) {
return;
}
// else, fall through
case event.DOM_VK_RETURN:
if (this.selectedIndex >= 0) {
this.input.value = this.suggestionAtIndex(this.selectedIndex);
}
this._stickyInputValue = this.input.value;
this._hideSuggestions();
break;
case event.DOM_VK_DELETE:
if (this.selectedIndex >= 0) {
this.deleteSuggestionAtIndex(this.selectedIndex);
}
break;
default:
return;
}
if (selectedIndexDelta) {
// Update the selection.
let newSelectedIndex = this.selectedIndex + selectedIndexDelta;
if (newSelectedIndex < -1) {
newSelectedIndex = this.numSuggestions - 1;
}
else if (this.numSuggestions <= newSelectedIndex) {
newSelectedIndex = -1;
}
this.selectedIndex = newSelectedIndex;
// Prevent the input's caret from moving.
event.preventDefault();
}
},
_onFocus: function () {
this._speculativeConnect();
},
_onBlur: function () {
this._hideSuggestions();
},
_onMousemove: function (event) {
// It's important to listen for mousemove, not mouseover or mouseenter. The
// latter two are triggered when the user is typing and the mouse happens to
// be over the suggestions popup.
this.selectedIndex = this._indexOfTableRowOrDescendent(event.target);
},
_onMousedown: function (event) {
let idx = this._indexOfTableRowOrDescendent(event.target);
let suggestion = this.suggestionAtIndex(idx);
this._stickyInputValue = suggestion;
this.input.value = suggestion;
this._hideSuggestions();
if (this.onClick) {
this.onClick.call(null);
}
},
_onContentSearchService: function (event) {
let methodName = "_onMsg" + event.detail.type;
if (methodName in this) {
this[methodName](event.detail.data);
}
},
_onMsgSuggestions: function (suggestions) {
// Ignore the suggestions if their search string or engine doesn't match
// ours. Due to the async nature of message passing, this can easily happen
// when the user types quickly.
if (this._stickyInputValue != suggestions.searchString ||
this.engineName != suggestions.engineName) {
return;
}
// Empty the table.
while (this._table.firstElementChild) {
this._table.firstElementChild.remove();
}
// Position and size the table.
let { left, bottom } = this.input.getBoundingClientRect();
this._table.style.left = (left + window.scrollX) + "px";
this._table.style.top = (bottom + window.scrollY) + "px";
this._table.style.minWidth = this.input.offsetWidth + "px";
this._table.style.maxWidth = (window.innerWidth - left - 40) + "px";
// Add the suggestions to the table.
let searchWords =
new Set(suggestions.searchString.trim().toLowerCase().split(/\s+/));
for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) {
let type, idx;
if (i < suggestions.formHistory.length) {
[type, idx] = ["formHistory", i];
}
else {
let j = i - suggestions.formHistory.length;
if (j < suggestions.remote.length) {
[type, idx] = ["remote", j];
}
else {
break;
}
}
this._table.appendChild(this._makeTableRow(type, suggestions[type][idx],
i, searchWords));
}
this._table.hidden = false;
this.input.setAttribute("aria-expanded", "true");
},
_speculativeConnect: function () {
if (this.engineName) {
this._sendMsg("SpeculativeConnect", this.engineName);
}
},
_makeTableRow: function (type, suggestionStr, currentRow, searchWords) {
let row = document.createElementNS(HTML_NS, "tr");
row.classList.add("searchSuggestionRow");
row.classList.add(type);
row.setAttribute("role", "presentation");
row.addEventListener("mousemove", this);
row.addEventListener("mousedown", this);
let entry = document.createElementNS(HTML_NS, "td");
entry.classList.add("searchSuggestionEntry");
entry.setAttribute("role", "option");
entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow;
entry.setAttribute("aria-selected", "false");
let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/);
for (let i = 0; i < suggestionWords.length; i++) {
let word = suggestionWords[i];
let wordSpan = document.createElementNS(HTML_NS, "span");
if (searchWords.has(word)) {
wordSpan.classList.add("typed");
}
wordSpan.textContent = word;
entry.appendChild(wordSpan);
if (i < suggestionWords.length - 1) {
entry.appendChild(document.createTextNode(" "));
}
}
row.appendChild(entry);
return row;
},
_getSuggestions: function () {
this._stickyInputValue = this.input.value;
if (this.engineName) {
this._sendMsg("GetSuggestions", {
engineName: this.engineName,
searchString: this.input.value,
remoteTimeout: this.remoteTimeout,
});
}
},
_hideSuggestions: function () {
this.input.setAttribute("aria-expanded", "false");
this._table.hidden = true;
while (this._table.firstElementChild) {
this._table.firstElementChild.remove();
}
this.selectedIndex = -1;
},
_indexOfTableRowOrDescendent: function (row) {
while (row && row.localName != "tr") {
row = row.parentNode;
}
if (!row) {
throw new Error("Element is not a row");
}
return row.rowIndex;
},
_makeTable: function (id) {
this._table = document.createElementNS(HTML_NS, "table");
this._table.id = id;
this._table.hidden = true;
this._table.dir = "auto";
this._table.classList.add("searchSuggestionTable");
this._table.setAttribute("role", "listbox");
return this._table;
},
_sendMsg: function (type, data=null) {
dispatchEvent(new CustomEvent("ContentSearchClient", {
detail: {
type: type,
data: data,
},
}));
},
};
return SearchSuggestionUIController;
})();

View File

@ -1913,6 +1913,7 @@
<body>
<![CDATA[
if (aTab.closing ||
aTab._pendingPermitUnload ||
this._windowIsClosing)
return false;
@ -1920,10 +1921,17 @@
if (!aTabWillBeMoved) {
let ds = browser.docShell;
if (ds &&
ds.contentViewer &&
!ds.contentViewer.permitUnload()) {
return false;
if (ds && ds.contentViewer) {
// We need to block while calling permitUnload() because it
// processes the event queue and may lead to another removeTab()
// call before permitUnload() even returned.
aTab._pendingPermitUnload = true;
let permitUnload = ds.contentViewer.permitUnload();
delete aTab._pendingPermitUnload;
if (!permitUnload) {
return false;
}
}
}

View File

@ -11,9 +11,11 @@ support-files =
browser_bug678392-1.html
browser_bug678392-2.html
browser_bug970746.xhtml
browser_fxa_oauth.html
browser_registerProtocolHandler_notification.html
browser_star_hsts.sjs
browser_tab_dragdrop2_frame1.xul
browser_web_channel.html
bug564387.html
bug564387_video1.ogv
bug564387_video1.ogv^headers^
@ -64,6 +66,8 @@ support-files =
page_style_sample.html
print_postdata.sjs
redirect_bug623155.sjs
searchSuggestionEngine.sjs
searchSuggestionEngine.xml
test-mixedcontent-securityerrors.html
test_bug435035.html
test_bug462673.html
@ -295,6 +299,7 @@ skip-if = true # browser_drag.js is disabled, as it needs to be updated for the
[browser_findbarClose.js]
skip-if = e10s # Bug ?????? - test directly manipulates content (tries to grab an iframe directly from content)
[browser_fullscreen-window-open.js]
[browser_fxa_oauth.js]
skip-if = buildapp == 'mulet' || e10s || os == "linux" # Bug 933103 - mochitest's EventUtils.synthesizeMouse functions not e10s friendly. Linux: Intermittent failures - bug 941575.
[browser_gestureSupport.js]
skip-if = e10s # Bug 863514 - no gesture support.
@ -371,6 +376,10 @@ skip-if = buildapp == 'mulet' || e10s # e10s: Bug 933103 - mochitest's EventUtil
[browser_save_video.js]
skip-if = buildapp == 'mulet' || e10s # Bug ?????? - test directly manipulates content (event.target)
[browser_scope.js]
[browser_searchSuggestionUI.js]
support-files =
searchSuggestionUI.html
searchSuggestionUI.js
[browser_selectTabAtIndex.js]
skip-if = e10s # Bug ?????? - no idea! "Accel+9 selects expected tab - Got 0, expected 9"
[browser_star_hsts.js]
@ -428,6 +437,7 @@ skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
[browser_visibleTabs_contextMenu.js]
skip-if = e10s # Bug 921905 - pinTab/unpinTab fail in e10s
[browser_visibleTabs_tabPreview.js]
[browser_web_channel.js]
skip-if = (os == "win" && !debug) || e10s # Bug 1007418 / Bug 698371 - thumbnail captures need e10s love (tabPreviews_capture fails with Argument 1 of CanvasRenderingContext2D.drawWindow does not implement interface Window.)
[browser_windowopen_reflows.js]
skip-if = buildapp == 'mulet'

View File

@ -97,7 +97,9 @@ let gTests = [
setup: function () { },
run: function () {
// Skip this test on Linux.
if (navigator.platform.indexOf("Linux") == 0) { return; }
if (navigator.platform.indexOf("Linux") == 0) {
return Promise.resolve();
}
try {
let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
@ -372,6 +374,53 @@ let gTests = [
}
},
{
// See browser_searchSuggestionUI.js for comprehensive content search
// suggestion UI tests.
desc: "Search suggestion smoke test",
setup: function() {},
run: function()
{
return Task.spawn(function* () {
// Add a test engine that provides suggestions and switch to it.
let engine = yield promiseNewEngine("searchSuggestionEngine.xml");
let promise = promiseBrowserAttributes(gBrowser.selectedTab);
Services.search.currentEngine = engine;
yield promise;
// Avoid intermittent failures.
gBrowser.contentWindow.wrappedJSObject.gSearchSuggestionController.remoteTimeout = 5000;
// Type an X in the search input.
let input = gBrowser.contentDocument.getElementById("searchText");
input.focus();
EventUtils.synthesizeKey("x", {});
// Wait for the search suggestions to become visible.
let table =
gBrowser.contentDocument.getElementById("searchSuggestionTable");
let deferred = Promise.defer();
let observer = new MutationObserver(() => {
if (input.getAttribute("aria-expanded") == "true") {
observer.disconnect();
ok(!table.hidden, "Search suggestion table unhidden");
deferred.resolve();
}
});
observer.observe(input, {
attributes: true,
attributeFilter: ["aria-expanded"],
});
yield deferred.promise;
// Empty the search input, causing the suggestions to be hidden.
EventUtils.synthesizeKey("a", { accelKey: true });
EventUtils.synthesizeKey("VK_DELETE", {});
ok(table.hidden, "Search suggestion table hidden");
});
}
},
];
function test()
@ -547,3 +596,21 @@ function waitForLoad(cb) {
cb();
}, true);
}
function promiseNewEngine(basename) {
info("Waiting for engine to be added: " + basename);
let addDeferred = Promise.defer();
let url = getRootDirectory(gTestPath) + basename;
Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
onSuccess: function (engine) {
info("Search engine added: " + basename);
registerCleanupFunction(() => Services.search.removeEngine(engine));
addDeferred.resolve(engine);
},
onError: function (errCode) {
ok(false, "addEngine failed with error code " + errCode);
addDeferred.reject();
},
});
return addDeferred.promise;
}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>fxa_oauth_test</title>
</head>
<body>
<script>
window.onload = function(){
var event = new window.CustomEvent("WebChannelMessageToChrome", {
detail: {
id: "oauth_client_id",
message: {
command: "oauth_complete",
data: {
state: "state",
code: "code1",
closeWindow: "signin",
},
},
},
});
window.dispatchEvent(event);
};
</script>
</body>
</html>

View File

@ -0,0 +1,75 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthClient",
"resource://gre/modules/FxAccountsOAuthClient.jsm");
const HTTP_PATH = "http://example.com";
const HTTP_ENDPOINT = "/browser/browser/base/content/test/general/browser_fxa_oauth.html";
let gTests = [
{
desc: "FxA OAuth - should open a new tab, complete OAuth flow",
run: function* () {
return new Promise(function(resolve, reject) {
let tabOpened = false;
let properUrl = "http://example.com/browser/browser/base/content/test/general/browser_fxa_oauth.html?" +
"webChannelId=oauth_client_id&scope=&client_id=client_id&action=signin&state=state";
waitForTab(function (tab) {
Assert.ok("Tab successfully opened");
Assert.equal(gBrowser.currentURI.spec, properUrl);
tabOpened = true;
});
let client = new FxAccountsOAuthClient({
parameters: {
state: "state",
client_id: "client_id",
oauth_uri: HTTP_PATH,
content_uri: HTTP_PATH,
},
authorizationEndpoint: HTTP_ENDPOINT
});
client.onComplete = function(tokenData) {
Assert.ok(tabOpened);
Assert.equal(tokenData.code, "code1");
Assert.equal(tokenData.state, "state");
resolve();
};
client.launchWebFlow();
});
}
}
]; // gTests
function waitForTab(aCallback) {
let container = gBrowser.tabContainer;
container.addEventListener("TabOpen", function tabOpener(event) {
container.removeEventListener("TabOpen", tabOpener, false);
gBrowser.addEventListener("load", function listener() {
gBrowser.removeEventListener("load", listener, true);
let tab = event.target;
aCallback(tab);
}, true);
}, false);
}
function test() {
waitForExplicitFinish();
Task.spawn(function () {
for (let test of gTests) {
info("Running: " + test.desc);
yield test.run();
}
}).then(finish, ex => {
Assert.ok(false, "Unexpected Exception: " + ex);
finish();
});
}

View File

@ -0,0 +1,305 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const TEST_PAGE_BASENAME = "searchSuggestionUI.html";
const TEST_CONTENT_SCRIPT_BASENAME = "searchSuggestionUI.js";
const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
const TEST_MSG = "SearchSuggestionUIControllerTest";
add_task(function* emptyInput() {
yield setUp();
let state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
state = yield msg("key", "VK_BACK_SPACE");
checkState(state, "", [], -1);
yield msg("reset");
});
add_task(function* blur() {
yield setUp();
let state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
state = yield msg("blur");
checkState(state, "x", [], -1);
yield msg("reset");
});
add_task(function* arrowKeys() {
yield setUp();
let state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
// Cycle down the suggestions starting from no selection.
state = yield msg("key", "VK_DOWN");
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
state = yield msg("key", "VK_DOWN");
checkState(state, "xbar", ["xfoo", "xbar"], 1);
state = yield msg("key", "VK_DOWN");
checkState(state, "x", ["xfoo", "xbar"], -1);
// Cycle up starting from no selection.
state = yield msg("key", "VK_UP");
checkState(state, "xbar", ["xfoo", "xbar"], 1);
state = yield msg("key", "VK_UP");
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
state = yield msg("key", "VK_UP");
checkState(state, "x", ["xfoo", "xbar"], -1);
yield msg("reset");
});
// The right arrow and return key function the same.
function rightArrowOrReturn(keyName) {
return function* rightArrowOrReturnTest() {
yield setUp();
let state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
state = yield msg("key", "VK_DOWN");
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
// This should make the xfoo suggestion sticky. To make sure it sticks,
// trigger suggestions again and cycle through them by pressing Down until
// nothing is selected again.
state = yield msg("key", keyName);
checkState(state, "xfoo", [], -1);
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
state = yield msg("key", "VK_DOWN");
checkState(state, "xfoofoo", ["xfoofoo", "xfoobar"], 0);
state = yield msg("key", "VK_DOWN");
checkState(state, "xfoobar", ["xfoofoo", "xfoobar"], 1);
state = yield msg("key", "VK_DOWN");
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
yield msg("reset");
};
}
add_task(rightArrowOrReturn("VK_RIGHT"));
add_task(rightArrowOrReturn("VK_RETURN"));
add_task(function* mouse() {
yield setUp();
let state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
// Mouse over the first suggestion.
state = yield msg("mousemove", 0);
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
// Mouse over the second suggestion.
state = yield msg("mousemove", 1);
checkState(state, "xbar", ["xfoo", "xbar"], 1);
// Click the second suggestion. This should make it sticky. To make sure it
// sticks, trigger suggestions again and cycle through them by pressing Down
// until nothing is selected again.
state = yield msg("mousedown", 1);
checkState(state, "xbar", [], -1);
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
state = yield msg("key", "VK_DOWN");
checkState(state, "xbarfoo", ["xbarfoo", "xbarbar"], 0);
state = yield msg("key", "VK_DOWN");
checkState(state, "xbarbar", ["xbarfoo", "xbarbar"], 1);
state = yield msg("key", "VK_DOWN");
checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
yield msg("reset");
});
add_task(function* formHistory() {
yield setUp();
// Type an X and add it to form history.
let state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
yield msg("addInputValueToFormHistory");
// Wait for Satchel to say it's been added to form history.
let deferred = Promise.defer();
Services.obs.addObserver(function onAdd(subj, topic, data) {
if (data == "formhistory-add") {
executeSoon(() => deferred.resolve());
}
}, "satchel-storage-changed", false);
yield deferred.promise;
// Reset the input.
state = yield msg("reset");
checkState(state, "", [], -1);
// Type an X again. The form history entry should appear.
state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
-1);
// Select the form history entry and delete it.
state = yield msg("key", "VK_DOWN");
checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
0);
state = yield msg("key", "VK_DELETE");
checkState(state, "x", ["xfoo", "xbar"], -1);
// Wait for Satchel.
deferred = Promise.defer();
Services.obs.addObserver(function onAdd(subj, topic, data) {
if (data == "formhistory-remove") {
executeSoon(() => deferred.resolve());
}
}, "satchel-storage-changed", false);
yield deferred.promise;
// Reset the input.
state = yield msg("reset");
checkState(state, "", [], -1);
// Type an X again. The form history entry should still be gone.
state = yield msg("key", { key: "x", waitForSuggestions: true });
checkState(state, "x", ["xfoo", "xbar"], -1);
yield msg("reset");
});
let gDidInitialSetUp = false;
function setUp() {
return Task.spawn(function* () {
if (!gDidInitialSetUp) {
yield promiseNewEngine(TEST_ENGINE_BASENAME);
yield promiseTab();
gDidInitialSetUp = true;
}
yield msg("focus");
});
}
function msg(type, data=null) {
gMsgMan.sendAsyncMessage(TEST_MSG, {
type: type,
data: data,
});
let deferred = Promise.defer();
gMsgMan.addMessageListener(TEST_MSG, function onMsg(msg) {
gMsgMan.removeMessageListener(TEST_MSG, onMsg);
deferred.resolve(msg.data);
});
return deferred.promise;
}
function checkState(actualState, expectedInputVal, expectedSuggestions,
expectedSelectedIdx) {
expectedSuggestions = expectedSuggestions.map(sugg => {
return typeof(sugg) == "object" ? sugg : {
str: sugg,
type: "remote",
};
});
let expectedState = {
selectedIndex: expectedSelectedIdx,
numSuggestions: expectedSuggestions.length,
suggestionAtIndex: expectedSuggestions.map(s => s.str),
isFormHistorySuggestionAtIndex:
expectedSuggestions.map(s => s.type == "formHistory"),
tableHidden: expectedSuggestions.length == 0,
tableChildrenLength: expectedSuggestions.length,
tableChildren: expectedSuggestions.map((s, i) => {
let expectedClasses = new Set([s.type]);
if (i == expectedSelectedIdx) {
expectedClasses.add("selected");
}
return {
textContent: s.str,
classes: expectedClasses,
};
}),
inputValue: expectedInputVal,
ariaExpanded: expectedSuggestions.length == 0 ? "false" : "true",
};
SimpleTest.isDeeply(actualState, expectedState, "State");
}
var gMsgMan;
function promiseTab() {
let deferred = Promise.defer();
let tab = gBrowser.addTab();
registerCleanupFunction(() => gBrowser.removeTab(tab));
gBrowser.selectedTab = tab;
let pageURL = getRootDirectory(gTestPath) + TEST_PAGE_BASENAME;
tab.linkedBrowser.addEventListener("load", function onLoad(event) {
tab.linkedBrowser.removeEventListener("load", onLoad, true);
gMsgMan = tab.linkedBrowser.messageManager;
gMsgMan.sendAsyncMessage("ContentSearch", {
type: "AddToWhitelist",
data: [pageURL],
});
promiseMsg("ContentSearch", "AddToWhitelistAck", gMsgMan).then(() => {
let jsURL = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
gMsgMan.loadFrameScript(jsURL, false);
deferred.resolve();
});
}, true, true);
openUILinkIn(pageURL, "current");
return deferred.promise;
}
function promiseMsg(name, type, msgMan) {
let deferred = Promise.defer();
info("Waiting for " + name + " message " + type + "...");
msgMan.addMessageListener(name, function onMsg(msg) {
info("Received " + name + " message " + msg.data.type + "\n");
if (msg.data.type == type) {
msgMan.removeMessageListener(name, onMsg);
deferred.resolve(msg);
}
});
return deferred.promise;
}
function promiseNewEngine(basename) {
info("Waiting for engine to be added: " + basename);
let addDeferred = Promise.defer();
let url = getRootDirectory(gTestPath) + basename;
Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
onSuccess: function (engine) {
info("Search engine added: " + basename);
registerCleanupFunction(() => Services.search.removeEngine(engine));
addDeferred.resolve(engine);
},
onError: function (errCode) {
ok(false, "addEngine failed with error code " + errCode);
addDeferred.reject();
},
});
return addDeferred.promise;
}

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>web_channel_test</title>
</head>
<body>
<script>
window.onload = function() {
var testName = window.location.search.replace(/^\?/, "");
switch(testName) {
case "generic":
test_generic();
break;
case "twoway":
test_twoWay();
break;
case "multichannel":
test_multichannel();
break;
}
};
function test_generic() {
var event = new window.CustomEvent("WebChannelMessageToChrome", {
detail: {
id: "generic",
message: {
something: {
nested: "hello",
},
}
}
});
window.dispatchEvent(event);
}
function test_twoWay() {
var firstMessage = new window.CustomEvent("WebChannelMessageToChrome", {
detail: {
id: "twoway",
message: {
command: "one",
},
}
});
window.addEventListener("WebChannelMessageToContent", function(e) {
var secondMessage = new window.CustomEvent("WebChannelMessageToChrome", {
detail: {
id: "twoway",
message: {
command: "two",
detail: e.detail.message,
},
},
});
if (!e.detail.message.error) {
window.dispatchEvent(secondMessage);
}
}, true);
window.dispatchEvent(firstMessage);
}
function test_multichannel() {
var event1 = new window.CustomEvent("WebChannelMessageToChrome", {
detail: {
id: "wrongchannel",
message: {},
}
});
var event2 = new window.CustomEvent("WebChannelMessageToChrome", {
detail: {
id: "multichannel",
message: {},
}
});
window.dispatchEvent(event1);
window.dispatchEvent(event2);
}
</script>
</body>
</html>

View File

@ -0,0 +1,91 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
"resource://gre/modules/WebChannel.jsm");
const HTTP_PATH = "http://example.com";
const HTTP_ENDPOINT = "/browser/browser/base/content/test/general/browser_web_channel.html";
let gTests = [
{
desc: "WebChannel generic message",
run: function* () {
return new Promise(function(resolve, reject) {
let tab;
let channel = new WebChannel("generic", Services.io.newURI(HTTP_PATH, null, null));
channel.listen(function (id, message, target) {
is(id, "generic");
is(message.something.nested, "hello");
channel.stopListening();
gBrowser.removeTab(tab);
resolve();
});
tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?generic");
});
}
},
{
desc: "WebChannel two way communication",
run: function* () {
return new Promise(function(resolve, reject) {
let tab;
let channel = new WebChannel("twoway", Services.io.newURI(HTTP_PATH, null, null));
channel.listen(function (id, message, sender) {
is(id, "twoway");
ok(message.command);
if (message.command === "one") {
channel.send({ data: { nested: true } }, sender);
}
if (message.command === "two") {
is(message.detail.data.nested, true);
channel.stopListening();
gBrowser.removeTab(tab);
resolve();
}
});
tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?twoway");
});
}
},
{
desc: "WebChannel multichannel",
run: function* () {
return new Promise(function(resolve, reject) {
let tab;
let channel = new WebChannel("multichannel", Services.io.newURI(HTTP_PATH, null, null));
channel.listen(function (id, message, sender) {
is(id, "multichannel");
gBrowser.removeTab(tab);
resolve();
});
tab = gBrowser.addTab(HTTP_PATH + HTTP_ENDPOINT + "?multichannel");
});
}
}
]; // gTests
function test() {
waitForExplicitFinish();
Task.spawn(function () {
for (let test of gTests) {
info("Running: " + test.desc);
yield test.run();
}
}).then(finish, ex => {
ok(false, "Unexpected Exception: " + ex);
finish();
});
}

View File

@ -0,0 +1,9 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
function handleRequest(req, resp) {
let suffixes = ["foo", "bar"];
let data = [req.queryString, suffixes.map(s => req.queryString + s)];
resp.setHeader("Content-Type", "application/json", false);
resp.write(JSON.stringify(data));
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Any copyright is dedicated to the Public Domain.
- http://creativecommons.org/publicdomain/zero/1.0/ -->
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/base/content/test/general/searchSuggestionEngine.sjs?{searchTerms}"/>
<Url type="text/html" method="GET" template="http://browser-searchSuggestionEngine.com/searchSuggestionEngine" rel="searchform"/>
</SearchPlugin>

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<!-- Any copyright is dedicated to the Public Domain.
- http://creativecommons.org/publicdomain/zero/1.0/ -->
<html>
<head>
<meta charset="utf-8">
<script type="application/javascript;version=1.8"
src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js">
</script>
<script type="application/javascript;version=1.8"
src="chrome://browser/content/searchSuggestionUI.js">
</script>
</head>
<body>
<input>
</body>
</html>

View File

@ -0,0 +1,138 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
(function () {
const TEST_MSG = "SearchSuggestionUIControllerTest";
const ENGINE_NAME = "browser_searchSuggestionEngine searchSuggestionEngine.xml";
let input = content.document.querySelector("input");
let gController =
new content.SearchSuggestionUIController(input, input.parentNode);
gController.engineName = ENGINE_NAME;
gController.remoteTimeout = 5000;
addMessageListener(TEST_MSG, msg => {
messageHandlers[msg.data.type](msg.data.data);
});
let messageHandlers = {
key: function (arg) {
let keyName = typeof(arg) == "string" ? arg : arg.key;
content.synthesizeKey(keyName, {});
let wait = arg.waitForSuggestions ? waitForSuggestions : cb => cb();
wait(ack);
},
focus: function () {
gController.input.focus();
ack();
},
blur: function () {
gController.input.blur();
ack();
},
mousemove: function (suggestionIdx) {
// Copied from widget/tests/test_panel_mouse_coords.xul and
// browser/base/content/test/newtab/head.js
let row = gController._table.children[suggestionIdx];
let rect = row.getBoundingClientRect();
let left = content.mozInnerScreenX + rect.left;
let x = left + rect.width / 2;
let y = content.mozInnerScreenY + rect.top + rect.height / 2;
let utils = content.SpecialPowers.getDOMWindowUtils(content);
let scale = utils.screenPixelsPerCSSPixel;
let widgetToolkit = content.SpecialPowers.
Cc["@mozilla.org/xre/app-info;1"].
getService(content.SpecialPowers.Ci.nsIXULRuntime).
widgetToolkit;
let nativeMsg = widgetToolkit == "cocoa" ? 5 : // NSMouseMoved
widgetToolkit == "windows" ? 1 : // MOUSEEVENTF_MOVE
3; // GDK_MOTION_NOTIFY
row.addEventListener("mousemove", function onMove() {
row.removeEventListener("mousemove", onMove);
ack();
});
utils.sendNativeMouseEvent(x * scale, y * scale, nativeMsg, 0, null);
},
mousedown: function (suggestionIdx) {
gController.onClick = () => {
gController.onClick = null;
ack();
};
let row = gController._table.children[suggestionIdx];
content.sendMouseEvent({ type: "mousedown" }, row);
},
addInputValueToFormHistory: function () {
gController.addInputValueToFormHistory();
ack();
},
reset: function () {
// Reset both the input and suggestions by select all + delete.
gController.input.focus();
content.synthesizeKey("a", { accelKey: true });
content.synthesizeKey("VK_DELETE", {});
ack();
},
};
function ack() {
sendAsyncMessage(TEST_MSG, currentState());
}
function waitForSuggestions(cb) {
let observer = new content.MutationObserver(() => {
if (gController.input.getAttribute("aria-expanded") == "true") {
observer.disconnect();
cb();
}
});
observer.observe(gController.input, {
attributes: true,
attributeFilter: ["aria-expanded"],
});
}
function currentState() {
let state = {
selectedIndex: gController.selectedIndex,
numSuggestions: gController.numSuggestions,
suggestionAtIndex: [],
isFormHistorySuggestionAtIndex: [],
tableHidden: gController._table.hidden,
tableChildrenLength: gController._table.children.length,
tableChildren: [],
inputValue: gController.input.value,
ariaExpanded: gController.input.getAttribute("aria-expanded"),
};
for (let i = 0; i < gController.numSuggestions; i++) {
state.suggestionAtIndex.push(gController.suggestionAtIndex(i));
state.isFormHistorySuggestionAtIndex.push(
gController.isFormHistorySuggestionAtIndex(i));
}
for (let child of gController._table.children) {
state.tableChildren.push({
textContent: child.textContent,
classes: new Set(child.className.split(/\s+/)),
});
}
return state;
}
})();

View File

@ -34,6 +34,8 @@ support-files =
searchEngine1xLogo.xml
searchEngine2xLogo.xml
searchEngine1x2xLogo.xml
../general/searchSuggestionEngine.xml
../general/searchSuggestionEngine.sjs
[browser_newtab_sponsored_icon_click.js]
[browser_newtab_tabsync.js]
[browser_newtab_undo.js]

View File

@ -8,6 +8,7 @@ const ENGINE_NO_LOGO = "searchEngineNoLogo.xml";
const ENGINE_1X_LOGO = "searchEngine1xLogo.xml";
const ENGINE_2X_LOGO = "searchEngine2xLogo.xml";
const ENGINE_1X_2X_LOGO = "searchEngine1x2xLogo.xml";
const ENGINE_SUGGESTIONS = "searchSuggestionEngine.xml";
const SERVICE_EVENT_NAME = "ContentSearchService";
@ -141,6 +142,50 @@ function runTests() {
promiseClick(manageBox),
]).then(TestRunner.next);
// Add the engine that provides search suggestions and switch to it.
let suggestionEngine = null;
yield promiseNewSearchEngine(ENGINE_SUGGESTIONS, 0).then(engine => {
suggestionEngine = engine;
TestRunner.next();
});
Services.search.currentEngine = suggestionEngine;
yield promiseSearchEvents(["CurrentEngine"]).then(TestRunner.next);
yield checkCurrentEngine(ENGINE_SUGGESTIONS, false, false);
// Avoid intermittent failures.
gSearch()._suggestionController.remoteTimeout = 5000;
// Type an X in the search input. This is only a smoke test. See
// browser_searchSuggestionUI.js for comprehensive content search suggestion
// UI tests.
let input = $("text");
input.focus();
EventUtils.synthesizeKey("x", {});
let suggestionsPromise = promiseSearchEvents(["Suggestions"]);
// Wait for the search suggestions to become visible and for the Suggestions
// message.
let table = getContentDocument().getElementById("searchSuggestionTable");
info("Waiting for suggestions table to open");
let observer = new MutationObserver(() => {
if (input.getAttribute("aria-expanded") == "true") {
observer.disconnect();
ok(!table.hidden, "Search suggestion table unhidden");
TestRunner.next();
}
});
observer.observe(input, {
attributes: true,
attributeFilter: ["aria-expanded"],
});
yield undefined;
yield suggestionsPromise.then(TestRunner.next);
// Empty the search input, causing the suggestions to be hidden.
EventUtils.synthesizeKey("a", { accelKey: true });
EventUtils.synthesizeKey("VK_DELETE", {});
ok(table.hidden, "Search suggestion table hidden");
// Done. Revert the current engine and remove the new engines.
Services.search.currentEngine = oldCurrentEngine;
yield promiseSearchEvents(["CurrentEngine"]).then(TestRunner.next);
@ -264,6 +309,11 @@ function checkCurrentEngine(basename, has1xLogo, has2xLogo) {
ok(/^url\("blob:/.test(logo.style.backgroundImage), "Logo URI"); //"
}
if (logo.hidden) {
executeSoon(TestRunner.next);
return;
}
// "selected" attributes of engines in the panel
let panel = searchPanel();
promisePanelShown(panel).then(() => {
@ -283,7 +333,7 @@ function checkCurrentEngine(basename, has1xLogo, has2xLogo) {
}
TestRunner.next();
});
panel.openPopup(logoImg());
panel.openPopup(logo);
}
function promisePanelShown(panel) {

View File

@ -118,6 +118,8 @@ browser.jar:
* content/browser/sanitize.xul (content/sanitize.xul)
* content/browser/sanitizeDialog.js (content/sanitizeDialog.js)
content/browser/sanitizeDialog.css (content/sanitizeDialog.css)
content/browser/searchSuggestionUI.js (content/searchSuggestionUI.js)
content/browser/searchSuggestionUI.css (content/searchSuggestionUI.css)
content/browser/tabbrowser.css (content/tabbrowser.css)
* content/browser/tabbrowser.xml (content/tabbrowser.xml)
* content/browser/urlbarBindings.xml (content/urlbarBindings.xml)

View File

@ -19,7 +19,7 @@ add_task(function() {
let historyButton = document.getElementById("history-panelmenu");
let historySubview = document.getElementById("PanelUI-history");
let subviewShownPromise = subviewShown(historySubview);
EventUtils.synthesizeMouseAtCenter(historyButton, {});
historyButton.click();
yield subviewShownPromise;
let tabsFromOtherComputers = document.getElementById("sync-tabs-menuitem2");
@ -34,7 +34,7 @@ add_task(function() {
yield PanelUI.show({type: "command"});
subviewShownPromise = subviewShown(historySubview);
EventUtils.synthesizeMouseAtCenter(historyButton, {});
historyButton.click();
yield subviewShownPromise;
is(tabsFromOtherComputers.hidden, false, "The Tabs From Other Computers menuitem should be shown when sync is enabled.");

View File

@ -31,6 +31,7 @@
<script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
<script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
<script type="text/javascript" src="loop/shared/js/utils.js"></script>
<script type="text/javascript" src="loop/shared/js/models.js"></script>
<script type="text/javascript" src="loop/shared/js/router.js"></script>
<script type="text/javascript" src="loop/shared/js/views.js"></script>

View File

@ -48,26 +48,6 @@ loop.conversation = (function(OT, mozL10n) {
}
},
/**
* Used for adding different styles to the panel
* @returns {String} Corresponds to the client platform
* */
_getTargetPlatform: function() {
var platform="unknown_platform";
if (navigator.platform.indexOf("Win") !== -1) {
platform = "windows";
}
if (navigator.platform.indexOf("Mac") !== -1) {
platform = "mac";
}
if (navigator.platform.indexOf("Linux") !== -1) {
platform = "linux";
}
return platform;
},
_handleAccept: function() {
this.props.model.trigger("accept");
},
@ -97,7 +77,8 @@ loop.conversation = (function(OT, mozL10n) {
var btnClassAccept = "btn btn-success btn-accept";
var btnClassBlock = "btn btn-error btn-block";
var btnClassDecline = "btn btn-error btn-decline";
var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
var conversationPanelClass = "incoming-call " +
loop.shared.utils.getTargetPlatform();
var cx = React.addons.classSet;
var declineDropdownMenuClasses = cx({
"native-dropdown-menu": true,

View File

@ -48,26 +48,6 @@ loop.conversation = (function(OT, mozL10n) {
}
},
/**
* Used for adding different styles to the panel
* @returns {String} Corresponds to the client platform
* */
_getTargetPlatform: function() {
var platform="unknown_platform";
if (navigator.platform.indexOf("Win") !== -1) {
platform = "windows";
}
if (navigator.platform.indexOf("Mac") !== -1) {
platform = "mac";
}
if (navigator.platform.indexOf("Linux") !== -1) {
platform = "linux";
}
return platform;
},
_handleAccept: function() {
this.props.model.trigger("accept");
},
@ -97,7 +77,8 @@ loop.conversation = (function(OT, mozL10n) {
var btnClassAccept = "btn btn-success btn-accept";
var btnClassBlock = "btn btn-error btn-block";
var btnClassDecline = "btn btn-error btn-decline";
var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
var conversationPanelClass = "incoming-call " +
loop.shared.utils.getTargetPlatform();
var cx = React.addons.classSet;
var declineDropdownMenuClasses = cx({
"native-dropdown-menu": true,

View File

@ -77,22 +77,22 @@ loop.panel = (function(_, mozL10n) {
__("display_name_available_status");
return (
React.DOM.div( {className:"footer component-spacer"},
React.DOM.div( {className:"do-not-disturb"},
React.DOM.p( {className:"dnd-status", onClick:this.showDropdownMenu},
React.DOM.span(null, availabilityText),
React.DOM.i( {className:availabilityStatus})
),
React.DOM.ul( {className:availabilityDropdown,
onMouseLeave:this.hideDropdownMenu},
React.DOM.li( {onClick:this.changeAvailability("available"),
className:"dnd-menu-item dnd-make-available"},
React.DOM.i( {className:"status status-available"}),
React.DOM.div({className: "footer component-spacer"},
React.DOM.div({className: "do-not-disturb"},
React.DOM.p({className: "dnd-status", onClick: this.showDropdownMenu},
React.DOM.span(null, availabilityText),
React.DOM.i({className: availabilityStatus})
),
React.DOM.ul({className: availabilityDropdown,
onMouseLeave: this.hideDropdownMenu},
React.DOM.li({onClick: this.changeAvailability("available"),
className: "dnd-menu-item dnd-make-available"},
React.DOM.i({className: "status status-available"}),
React.DOM.span(null, __("display_name_available_status"))
),
React.DOM.li( {onClick:this.changeAvailability("do-not-disturb"),
className:"dnd-menu-item dnd-make-unavailable"},
React.DOM.i( {className:"status status-dnd"}),
),
React.DOM.li({onClick: this.changeAvailability("do-not-disturb"),
className: "dnd-menu-item dnd-make-unavailable"},
React.DOM.i({className: "status status-dnd"}),
React.DOM.span(null, __("display_name_dnd_status"))
)
)
@ -115,10 +115,10 @@ loop.panel = (function(_, mozL10n) {
if (this.state.seenToS == "unseen") {
navigator.mozLoop.setLoopCharPref('seenToS', 'seen');
return React.DOM.p( {className:"terms-service",
dangerouslySetInnerHTML:{__html: tosHTML}});
return React.DOM.p({className: "terms-service",
dangerouslySetInnerHTML: {__html: tosHTML}});
} else {
return React.DOM.div(null );
return React.DOM.div(null);
}
}
});
@ -130,11 +130,11 @@ loop.panel = (function(_, mozL10n) {
render: function() {
return (
React.DOM.div( {className:"component-spacer share generate-url"},
React.DOM.div( {className:"description"},
React.DOM.p( {className:"description-content"}, this.props.summary)
),
React.DOM.div( {className:"action"},
React.DOM.div({className: "component-spacer share generate-url"},
React.DOM.div({className: "description"},
React.DOM.p({className: "description-content"}, this.props.summary)
),
React.DOM.div({className: "action"},
this.props.children
)
)
@ -201,10 +201,10 @@ loop.panel = (function(_, mozL10n) {
// from the react lib.
var cx = React.addons.classSet;
return (
PanelLayout( {summary:__("share_link_header_text")},
React.DOM.div( {className:"invite"},
React.DOM.input( {type:"url", value:this.state.callUrl, readOnly:"true",
className:cx({'pending': this.state.pending})} )
PanelLayout({summary: __("share_link_header_text")},
React.DOM.div({className: "invite"},
React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true",
className: cx({'pending': this.state.pending})})
)
)
);
@ -223,10 +223,10 @@ loop.panel = (function(_, mozL10n) {
render: function() {
return (
React.DOM.div(null,
CallUrlResult( {client:this.props.client,
notifier:this.props.notifier} ),
ToSView(null ),
AvailabilityDropdown(null )
CallUrlResult({client: this.props.client,
notifier: this.props.notifier}),
ToSView(null),
AvailabilityDropdown(null)
)
);
}
@ -293,8 +293,8 @@ loop.panel = (function(_, mozL10n) {
var client = new loop.Client({
baseServerUrl: navigator.mozLoop.serverUrl
});
this.loadReactComponent(PanelView( {client:client,
notifier:this._notifier} ));
this.loadReactComponent(PanelView({client: client,
notifier: this._notifier}));
}
});

View File

@ -96,7 +96,20 @@ h1, h2, h3 {
}
.btn-large {
padding: .4em 1.6em;
/* Dimensions from spec
* https://people.mozilla.org/~dhenein/labs/loop-link-spec/#call-start */
padding: .5em;
font-size: 18px;
height: auto;
}
/*
* Left / Right padding elements
* used to center components
* */
.flex-padding-1 {
display: flex;
flex: 1;
}
.btn-info {
@ -209,6 +222,8 @@ h1, h2, h3 {
.button-group {
display: flex;
width: 100%;
align-content: space-between;
justify-content: center;
}
.button-group .btn {
@ -271,6 +286,26 @@ h1, h2, h3 {
opacity: 0;
}
.btn-large .icon {
display: inline-block;
width: 20px;
height: 20px;
background-size: 20px;
background-repeat: no-repeat;
vertical-align: top;
margin-left: 10px;
}
.icon-video {
background-image: url("../img/video-inverse-14x14.png");
}
@media (min-resolution: 2dppx) {
.icon-video {
background-image: url("../img/video-inverse-14x14@2x.png");
}
}
/*
* Platform specific styles
* The UI should match the user OS

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* global loop:true */
var loop = loop || {};
loop.shared = loop.shared || {};
loop.shared.utils = (function() {
"use strict";
/**
* Used for adding different styles to the panel
* @returns {String} Corresponds to the client platform
* */
function getTargetPlatform() {
var platform="unknown_platform";
if (navigator.platform.indexOf("Win") !== -1) {
platform = "windows";
}
if (navigator.platform.indexOf("Mac") !== -1) {
platform = "mac";
}
if (navigator.platform.indexOf("Linux") !== -1) {
platform = "linux";
}
return platform;
}
return {
getTargetPlatform: getTargetPlatform
};
})();

View File

@ -42,6 +42,7 @@ browser.jar:
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/router.js (content/shared/js/router.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
# Shared libs
content/browser/loop/shared/libs/react-0.10.0.js (content/shared/libs/react-0.10.0.js)

View File

@ -2,10 +2,125 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
html,
body,
#main {
/* Required for `justify-content: space-between` to divide space equally
* based on the full height of the page */
height: 100%;
}
body {
width: 100%;
/* prevent the video convsersation elements to occupy the whole available
width hence the height while keeping aspect ratio */
max-width: 730px;
margin: 0 auto;
background: #fbfbfb;
color: #666;
text-align: center;
font-family: Open Sans,sans-serif;
}
header {
border-radius: 4px;
background: #fff;
padding: 1rem 5rem;
border: 1px solid #E7E7E7;
box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03);
}
/*
* Top/Bottom spacing
**/
header {
margin-top: 2rem;
}
.footer {
margin-bottom: 2rem;
}
.container {
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.container-box {
display: flex;
flex-direction: column;
align-content: center;
width: 100%;
}
.footer,
.footer a,
.terms-service,
.terms-service a {
font-size: .6rem;
font-weight: 400;
color: #adadad;
}
.terms-service a {
text-decoration: none;
}
.terms-service a:hover {
text-decoration: underline;
}
.terms-service a {
color: #777;
}
.footer-external-links a {
padding: .2rem .7rem;
margin: 0 .5rem;
text-decoration: none;
}
.footer-external-links a:hover {
color: #111;
}
.footer-logo {
width: 100px;
margin: 0 auto;
height: 30px;
background-size: contain;
background-image: url("../../content/shared/img/mozilla-logo.png");
background-repeat: no-repeat;
}
.call-url {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.loop-logo {
width: 100px;
height: 100px;
margin: 1rem auto;
background-image: url("../../content/shared/img/firefox-logo.png");
background-size: cover;
background-repeat: no-repeat;
}
.large-font {
font-size: 1.8rem;
}
.light-weight-font {
font-weight: lighter;
}
.light-color-font {
opacity: .4;
font-weight: normal;
}

View File

@ -9,15 +9,9 @@
<link rel="stylesheet" type="text/css" href="shared/css/common.css">
<link rel="stylesheet" type="text/css" href="shared/css/conversation.css">
<link rel="stylesheet" type="text/css" href="css/webapp.css">
<link rel="prefetch" type="application/l10n" href="l10n/data.ini">
<link type="application/l10n" href="l10n/data.ini">
</head>
<body onload="loop.webapp.init();">
<header>
<h1>Loop</h1>
</header>
<div id="messages"></div>
<body>
<div id="main"></div>
@ -39,6 +33,7 @@
<!-- app scripts -->
<script type="text/javascript" src="config.js"></script>
<script type="text/javascript" src="shared/js/utils.js"></script>
<script type="text/javascript" src="shared/js/models.js"></script>
<script type="text/javascript" src="shared/js/views.js"></script>
<script type="text/javascript" src="shared/js/router.js"></script>
@ -46,11 +41,10 @@
<script type="text/javascript" src="js/webapp.js"></script>
<script>
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
window.addEventListener('localized', function() {
document.documentElement.lang = document.webL10n.getLanguage();
document.documentElement.dir = document.webL10n.getDirection();
}, false);
// Wait for all the localization notes to load
window.addEventListener('localized', function() {
loop.webapp.init();
}, false);
</script>
</body>
</html>

View File

@ -75,6 +75,27 @@ loop.StandaloneClient = (function($) {
cb(err);
},
/**
* Makes a request for url creation date for standalone UI
*
* @param {String} loopToken The loopToken representing the call
* @param {Function} cb Callback(err, callUrlInfo)
*
**/
requestCallUrlInfo: function(loopToken, cb) {
if (!loopToken) {
throw new Error("Missing required parameter loopToken");
}
if (!cb) {
throw new Error("Missing required callback function");
}
$.get(this.settings.baseServerUrl + "/calls/" + loopToken)
.done(function(callUrlInfo) {
cb(null, callUrlInfo);
}).fail(this._failureHandler.bind(this, cb));
},
/**
* Posts a call request to the server for a call represented by the
* loopToken. Will return the session data for the call.

View File

@ -82,23 +82,56 @@ loop.webapp = (function($, _, OT, webL10n) {
}
});
var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
render: function() {
var cx = React.addons.classSet;
var conversationUrl = location.href;
var urlCreationDateClasses = cx({
"light-color-font": true,
"call-url-date": true, /* Used as a handler in the tests */
/*hidden until date is available*/
"hide": !this.props.urlCreationDateString.length
});
var callUrlCreationDateString = __("call_url_creation_date_label", {
"call_url_creation_date": this.props.urlCreationDateString
});
return (
/* jshint ignore:start */
React.DOM.header({className: "container-box"},
React.DOM.h1({className: "light-weight-font"},
React.DOM.strong(null, __("brandShortname")), " ", __("clientShortname")
),
React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}),
React.DOM.h3({className: "call-url"},
conversationUrl
),
React.DOM.h4({className: urlCreationDateClasses},
callUrlCreationDateString
)
)
/* jshint ignore:end */
);
}
});
var ConversationFooter = React.createClass({displayName: 'ConversationFooter',
render: function() {
return (
React.DOM.div({className: "footer container-box"},
React.DOM.div({title: "Mozilla Logo", className: "footer-logo"})
)
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
*/
var ConversationFormView = sharedViews.BaseView.extend({
template: _.template([
'<form>',
' <p>',
' <button class="btn btn-success" data-l10n-id="start_call"></button>',
' </p>',
'</form>'
].join("")),
events: {
"submit": "initiate"
},
var ConversationFormView = React.createClass({displayName: 'ConversationFormView',
/**
* Constructor.
*
@ -106,55 +139,115 @@ loop.webapp = (function($, _, OT, webL10n) {
* - {loop.shared.model.ConversationModel} model Conversation model.
* - {loop.shared.views.NotificationListView} notifier Notifier component.
*
* @param {Object} options Options object.
*/
initialize: function(options) {
options = options || {};
if (!options.model) {
throw new Error("missing required model");
}
this.model = options.model;
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false
};
},
if (!options.notifier) {
throw new Error("missing required notifier");
}
this.notifier = options.notifier;
propTypes: {
model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifier: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
},
this.listenTo(this.model, "session:error", this._onSessionError);
componentDidMount: function() {
this.props.model.listenTo(this.props.model, "session:error",
this._onSessionError);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
// XXX DOM element does not exist before React view gets instantiated
// We should turn the notifier into a react component
this.props.notifier.$el = $("#messages");
},
_onSessionError: function(error) {
console.error(error);
this.notifier.errorL10n("unable_retrieve_call_info");
},
/**
* Disables this form to prevent multiple submissions.
*
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=991126
*/
disableForm: function() {
this.$("button").attr("disabled", "disabled");
this.props.notifier.errorL10n("unable_retrieve_call_info");
},
/**
* Initiates the call.
*
* @param {SubmitEvent} event
*/
initiate: function(event) {
event.preventDefault();
this.model.initiate({
_initiate: function() {
this.props.model.initiate({
client: new loop.StandaloneClient({
baseServerUrl: baseServerUrl
}),
outgoing: true,
// For now, we assume both audio and video as there is no
// other option to select.
callType: "audio-video"
callType: "audio-video",
loopServer: loop.config.serverUrl
});
this.disableForm();
this.setState({disableCallButton: true});
},
_setConversationTimestamp: function(err, callUrlInfo) {
if (err) {
this.props.notifier.errorL10n("unable_retrieve_call_info");
} else {
var date = (new Date(callUrlInfo.urlCreationDate * 1000));
var options = {year: "numeric", month: "long", day: "numeric"};
var timestamp = date.toLocaleDateString(navigator.language, options);
this.setState({urlCreationDateString: timestamp});
}
},
render: function() {
var tos_link_name = __("terms_of_use_link_text");
var privacy_notice_name = __("privacy_notice_link_text");
var tosHTML = __("legal_text_and_links", {
"terms_of_use_url": "<a target=_blank href='" +
"https://accounts.firefox.com/legal/terms'>" + tos_link_name + "</a>",
"privacy_notice_url": "<a target=_blank href='" +
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
});
var callButtonClasses = "btn btn-success btn-large " +
loop.shared.utils.getTargetPlatform();
return (
/* jshint ignore:start */
React.DOM.div({className: "container"},
React.DOM.div({className: "container-box"},
ConversationHeader({
urlCreationDateString: this.state.urlCreationDateString}),
React.DOM.p({className: "large-font light-weight-font"},
__("initiate_call_button_label")
),
React.DOM.div({id: "messages"}),
React.DOM.div({className: "button-group"},
React.DOM.div({className: "flex-padding-1"}),
React.DOM.button({ref: "submitButton", onClick: this._initiate,
className: callButtonClasses,
disabled: this.state.disableCallButton},
__("initiate_call_button"),
React.DOM.i({className: "icon icon-video"})
),
React.DOM.div({className: "flex-padding-1"})
),
React.DOM.p({className: "terms-service",
dangerouslySetInnerHTML: {__html: tosHTML}})
),
ConversationFooter(null)
)
/* jshint ignore:end */
);
}
});
@ -250,9 +343,12 @@ loop.webapp = (function($, _, OT, webL10n) {
this._conversation.endSession();
}
this._conversation.set("loopToken", loopToken);
this.loadView(new ConversationFormView({
this.loadReactComponent(ConversationFormView({
model: this._conversation,
notifier: this._notifier
notifier: this._notifier,
client: new loop.StandaloneClient({
baseServerUrl: loop.config.serverUrl
})
}));
},
@ -308,6 +404,9 @@ loop.webapp = (function($, _, OT, webL10n) {
} else if (!OT.checkSystemRequirements()) {
router.navigate("unsupportedBrowser", {trigger: true});
}
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
document.documentElement.lang = document.webL10n.getLanguage();
document.documentElement.dir = document.webL10n.getDirection();
}
return {

View File

@ -82,23 +82,56 @@ loop.webapp = (function($, _, OT, webL10n) {
}
});
var ConversationHeader = React.createClass({
render: function() {
var cx = React.addons.classSet;
var conversationUrl = location.href;
var urlCreationDateClasses = cx({
"light-color-font": true,
"call-url-date": true, /* Used as a handler in the tests */
/*hidden until date is available*/
"hide": !this.props.urlCreationDateString.length
});
var callUrlCreationDateString = __("call_url_creation_date_label", {
"call_url_creation_date": this.props.urlCreationDateString
});
return (
/* jshint ignore:start */
<header className="container-box">
<h1 className="light-weight-font">
<strong>{__("brandShortname")}</strong> {__("clientShortname")}
</h1>
<div className="loop-logo" title="Firefox WebRTC! logo"></div>
<h3 className="call-url">
{conversationUrl}
</h3>
<h4 className={urlCreationDateClasses} >
{callUrlCreationDateString}
</h4>
</header>
/* jshint ignore:end */
);
}
});
var ConversationFooter = React.createClass({
render: function() {
return (
<div className="footer container-box">
<div title="Mozilla Logo" className="footer-logo"></div>
</div>
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
*/
var ConversationFormView = sharedViews.BaseView.extend({
template: _.template([
'<form>',
' <p>',
' <button class="btn btn-success" data-l10n-id="start_call"></button>',
' </p>',
'</form>'
].join("")),
events: {
"submit": "initiate"
},
var ConversationFormView = React.createClass({
/**
* Constructor.
*
@ -106,55 +139,115 @@ loop.webapp = (function($, _, OT, webL10n) {
* - {loop.shared.model.ConversationModel} model Conversation model.
* - {loop.shared.views.NotificationListView} notifier Notifier component.
*
* @param {Object} options Options object.
*/
initialize: function(options) {
options = options || {};
if (!options.model) {
throw new Error("missing required model");
}
this.model = options.model;
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false
};
},
if (!options.notifier) {
throw new Error("missing required notifier");
}
this.notifier = options.notifier;
propTypes: {
model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
.isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifier: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
},
this.listenTo(this.model, "session:error", this._onSessionError);
componentDidMount: function() {
this.props.model.listenTo(this.props.model, "session:error",
this._onSessionError);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
// XXX DOM element does not exist before React view gets instantiated
// We should turn the notifier into a react component
this.props.notifier.$el = $("#messages");
},
_onSessionError: function(error) {
console.error(error);
this.notifier.errorL10n("unable_retrieve_call_info");
},
/**
* Disables this form to prevent multiple submissions.
*
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=991126
*/
disableForm: function() {
this.$("button").attr("disabled", "disabled");
this.props.notifier.errorL10n("unable_retrieve_call_info");
},
/**
* Initiates the call.
*
* @param {SubmitEvent} event
*/
initiate: function(event) {
event.preventDefault();
this.model.initiate({
_initiate: function() {
this.props.model.initiate({
client: new loop.StandaloneClient({
baseServerUrl: baseServerUrl
}),
outgoing: true,
// For now, we assume both audio and video as there is no
// other option to select.
callType: "audio-video"
callType: "audio-video",
loopServer: loop.config.serverUrl
});
this.disableForm();
this.setState({disableCallButton: true});
},
_setConversationTimestamp: function(err, callUrlInfo) {
if (err) {
this.props.notifier.errorL10n("unable_retrieve_call_info");
} else {
var date = (new Date(callUrlInfo.urlCreationDate * 1000));
var options = {year: "numeric", month: "long", day: "numeric"};
var timestamp = date.toLocaleDateString(navigator.language, options);
this.setState({urlCreationDateString: timestamp});
}
},
render: function() {
var tos_link_name = __("terms_of_use_link_text");
var privacy_notice_name = __("privacy_notice_link_text");
var tosHTML = __("legal_text_and_links", {
"terms_of_use_url": "<a target=_blank href='" +
"https://accounts.firefox.com/legal/terms'>" + tos_link_name + "</a>",
"privacy_notice_url": "<a target=_blank href='" +
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
});
var callButtonClasses = "btn btn-success btn-large " +
loop.shared.utils.getTargetPlatform();
return (
/* jshint ignore:start */
<div className="container">
<div className="container-box">
<ConversationHeader
urlCreationDateString={this.state.urlCreationDateString} />
<p className="large-font light-weight-font">
{__("initiate_call_button_label")}
</p>
<div id="messages"></div>
<div className="button-group">
<div className="flex-padding-1"></div>
<button ref="submitButton" onClick={this._initiate}
className={callButtonClasses}
disabled={this.state.disableCallButton}>
{__("initiate_call_button")}
<i className="icon icon-video"></i>
</button>
<div className="flex-padding-1"></div>
</div>
<p className="terms-service"
dangerouslySetInnerHTML={{__html: tosHTML}}></p>
</div>
<ConversationFooter />
</div>
/* jshint ignore:end */
);
}
});
@ -250,9 +343,12 @@ loop.webapp = (function($, _, OT, webL10n) {
this._conversation.endSession();
}
this._conversation.set("loopToken", loopToken);
this.loadView(new ConversationFormView({
this.loadReactComponent(ConversationFormView({
model: this._conversation,
notifier: this._notifier
notifier: this._notifier,
client: new loop.StandaloneClient({
baseServerUrl: loop.config.serverUrl
})
}));
},
@ -308,6 +404,9 @@ loop.webapp = (function($, _, OT, webL10n) {
} else if (!OT.checkSystemRequirements()) {
router.navigate("unsupportedBrowser", {trigger: true});
}
// Set the 'lang' and 'dir' attributes to <html> when the page is translated
document.documentElement.lang = document.webL10n.getLanguage();
document.documentElement.dir = document.webL10n.getDirection();
}
return {

View File

@ -23,6 +23,18 @@ call_url_unavailable_notification_heading=Oops!
call_url_unavailable_notification_message=This URL is unavailable.
promote_firefox_hello_heading=Download Firefox to make free audio and video calls!
get_firefox_button=Get Firefox
call_url_unavailable_notification=This URL is unavailable.
initiate_call_button_label=Click Call to start a video chat
initiate_call_button=Call
## LOCALIZATION NOTE (legal_text_and_links): In this item, don't translate the
## part between {{..}}
legal_text_and_links=By using this product you agree to the {{terms_of_use_url}} and {{privacy_notice_url}}
terms_of_use_link_text=Terms of use
privacy_notice_link_text=Privacy notice
brandShortname=Firefox
clientShortname=WebRTC!
## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
call_url_creation_date_label=(from {{call_url_creation_date}})
[fr]
call_has_ended=L'appel est terminé.

View File

@ -32,6 +32,7 @@
</script>
<!-- App scripts -->
<script src="../../content/shared/js/utils.js"></script>
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/router.js"></script>
<script src="../../content/shared/js/views.js"></script>

View File

@ -31,6 +31,7 @@
mocha.setup('bdd');
</script>
<!-- App scripts -->
<script src="../../content/shared/js/utils.js"></script>
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/views.js"></script>
<script src="../../content/shared/js/router.js"></script>

View File

@ -40,6 +40,65 @@ describe("loop.StandaloneClient", function() {
});
});
describe("#requestCallUrlInfo", function() {
var client, fakeServerErrorDescription;
beforeEach(function() {
client = new loop.StandaloneClient(
{baseServerUrl: "http://fake.api"}
);
});
describe("should make the requests to the server", function() {
it("should throw if loopToken is missing", function() {
expect(client.requestCallUrlInfo).to
.throw(/Missing required parameter loopToken/);
});
it("should make a GET request for the call url creation date", function() {
client.requestCallUrlInfo("fakeCallUrlToken", function() {});
expect(requests).to.have.length.of(1);
expect(requests[0].url)
.to.eql("http://fake.api/calls/fakeCallUrlToken");
expect(requests[0].method).to.eql("GET");
});
it("should call the callback with (null, serverResponse)", function() {
var successCallback = sandbox.spy(function() {});
var serverResponse = {
calleeFriendlyName: "Andrei",
urlCreationDate: 0
};
client.requestCallUrlInfo("fakeCallUrlToken", successCallback);
requests[0].respond(200, {"Content-Type": "application/json"},
JSON.stringify(serverResponse));
sinon.assert.calledWithExactly(successCallback,
null,
serverResponse);
});
it("should log the error if the requests fails", function() {
sinon.stub(console, "error");
var serverResponse = {error: true};
var error = JSON.stringify(serverResponse);
client.requestCallUrlInfo("fakeCallUrlToken", sandbox.stub());
requests[0].respond(404, {"Content-Type": "application/json"},
error);
sinon.assert.calledOnce(console.error);
sinon.assert.calledWithExactly(console.error, "Server error",
"HTTP 404 Not Found", serverResponse);
});
})
});
describe("requestCallInfo", function() {
var client, fakeServerErrorDescription;

View File

@ -76,13 +76,13 @@ describe("loop.webapp", function() {
sdk: {},
pendingCallTimeout: 1000
});
sandbox.stub(loop.webapp.WebappRouter.prototype, "loadReactComponent");
router = new loop.webapp.WebappRouter({
helper: {},
conversation: conversation,
notifier: notifier
});
sandbox.stub(router, "loadView");
sandbox.stub(router, "loadReactComponent");
sandbox.stub(router, "navigate");
});
@ -165,9 +165,12 @@ describe("loop.webapp", function() {
it("should load the ConversationFormView", function() {
router.initiate("fakeToken");
sinon.assert.calledOnce(router.loadView);
sinon.assert.calledWith(router.loadView,
sinon.match.instanceOf(loop.webapp.ConversationFormView));
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWithExactly(router.loadReactComponent,
sinon.match(function(value) {
return React.addons.TestUtils.isComponentOfType(
value, loop.webapp.ConversationFormView);
}));
});
// https://bugzilla.mozilla.org/show_bug.cgi?id=991118
@ -295,47 +298,68 @@ describe("loop.webapp", function() {
});
describe("#initiate", function() {
var conversation, initiate, view, fakeSubmitEvent;
var conversation, initiate, view, fakeSubmitEvent, requestCallUrlInfo;
beforeEach(function() {
conversation = new sharedModels.ConversationModel({}, {
sdk: {},
pendingCallTimeout: 1000
});
view = new loop.webapp.ConversationFormView({
model: conversation,
notifier: notifier
});
fakeSubmitEvent = {preventDefault: sinon.spy()};
initiate = sinon.stub(conversation, "initiate");
var standaloneClientStub = {
requestCallUrlInfo: function(token, cb) {
cb(null, {urlCreationDate: 0});
},
settings: {baseServerUrl: loop.webapp.baseServerUrl}
}
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.ConversationFormView({
model: conversation,
notifier: notifier,
client: standaloneClientStub
})
);
});
it("should start the conversation establishment process", function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector("button");
React.addons.TestUtils.Simulate.click(button);
view.initiate(fakeSubmitEvent);
sinon.assert.calledOnce(fakeSubmitEvent.preventDefault);
sinon.assert.calledOnce(initiate);
sinon.assert.calledWith(initiate, sinon.match(function (value) {
return !!value.outgoing &&
(value.client instanceof loop.StandaloneClient) &&
value.client.settings.baseServerUrl === loop.webapp.baseServerUrl;
}, "{client: <properly constructed client>, outgoing: true}"));
(value.client.settings.baseServerUrl === loop.webapp.baseServerUrl)
}, "outgoing: true && correct baseServerUrl"));
});
it("should disable current form once session is initiated", function() {
sandbox.stub(view, "disableForm");
conversation.set("loopToken", "fake");
view.initiate(fakeSubmitEvent);
var button = view.getDOMNode().querySelector("button");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(view.disableForm);
expect(button.disabled).to.eql(true);
});
it("should set state.urlCreationDateString to a locale date string",
function() {
// wrap in a jquery object because text is broken up
// into several span elements
var date = new Date(0);
var options = {year: "numeric", month: "long", day: "numeric"};
var timestamp = date.toLocaleDateString(navigator.language, options);
expect(view.state.urlCreationDateString).to.eql(timestamp);
});
});
describe("Events", function() {
var conversation, view;
var conversation, view, StandaloneClient, requestCallUrlInfo;
beforeEach(function() {
conversation = new sharedModels.ConversationModel({
@ -344,10 +368,30 @@ describe("loop.webapp", function() {
sdk: {},
pendingCallTimeout: 1000
});
view = new loop.webapp.ConversationFormView({
model: conversation,
notifier: notifier
});
sandbox.spy(conversation, "listenTo");
requestCallUrlInfo = sandbox.stub();
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.ConversationFormView({
model: conversation,
notifier: notifier,
client: {requestCallUrlInfo: requestCallUrlInfo}
})
);
});
it("should call requestCallUrlInfo", function() {
sinon.assert.calledOnce(requestCallUrlInfo);
sinon.assert.calledWithExactly(requestCallUrlInfo,
sinon.match.string,
sinon.match.func);
});
it("should listen for session:error events", function() {
sinon.assert.calledOnce(conversation.listenTo);
sinon.assert.calledWithExactly(conversation.listenTo, conversation,
"session:error", sinon.match.func);
});
it("should trigger a notication when a session:error model event is " +

View File

@ -262,18 +262,15 @@ SessionStartup.prototype = {
* Get the session state as a jsval
*/
get state() {
this._ensureInitialized();
return this._initialState;
},
/**
* Determines whether there is a pending session restore. Should only be
* called after initialization has completed.
* @throws Error if initialization is not complete yet.
* @returns bool
*/
doRestore: function sss_doRestore() {
this._ensureInitialized();
return this._willRestore();
},
@ -324,7 +321,6 @@ SessionStartup.prototype = {
* Get the type of pending session store, if any.
*/
get sessionType() {
this._ensureInitialized();
return this._sessionType;
},
@ -332,19 +328,9 @@ SessionStartup.prototype = {
* Get whether the previous session crashed.
*/
get previousSessionCrashed() {
this._ensureInitialized();
return this._previousSessionCrashed;
},
// Ensure that initialization is complete. If initialization is not complete
// yet, something is attempting to use the old synchronous initialization,
// throw an error.
_ensureInitialized: function sss__ensureInitialized() {
if (!this._initialized) {
throw new Error("Session Store is not initialized.");
}
},
/* ........ QueryInterface .............. */
QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver,
Ci.nsISupportsWeakReference,

View File

@ -11,6 +11,9 @@ function ifWebGLSupported() {
yield getPrograms(front, 2);
// Wait a frame to ensure rendering
yield front.waitForFrame();
let pixel = yield front.getPixel({ selector: "#canvas1", position: { x: 0, y: 0 }});
is(pixel.r, 255, "correct `r` value for first canvas.")
is(pixel.g, 255, "correct `g` value for first canvas.")

View File

@ -13,6 +13,14 @@ const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
"resource://gre/modules/FormHistory.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
"resource://gre/modules/SearchSuggestionController.jsm");
const INBOUND_MESSAGE = "ContentSearch";
const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
@ -26,30 +34,45 @@ const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
*
* Inbound messages have the following types:
*
* AddFormHistoryEntry
* Adds an entry to the search form history.
* data: the entry, a string
* GetSuggestions
* Retrieves an array of search suggestions given a search string.
* data: { engineName, searchString, [remoteTimeout] }
* GetState
* Retrieves the current search engine state.
* data: null
* Retrieves the current search engine state.
* data: null
* ManageEngines
* Opens the search engine management window.
* data: null
* Opens the search engine management window.
* data: null
* RemoveFormHistoryEntry
* Removes an entry from the search form history.
* data: the entry, a string
* Search
* Performs a search.
* data: an object { engineName, searchString, whence }
* Performs a search.
* data: { engineName, searchString, whence }
* SetCurrentEngine
* Sets the current engine.
* data: the name of the engine
* Sets the current engine.
* data: the name of the engine
* SpeculativeConnect
* Speculatively connects to an engine.
* data: the name of the engine
*
* Outbound messages have the following types:
*
* CurrentEngine
* Sent when the current engine changes.
* Broadcast when the current engine changes.
* data: see _currentEngineObj
* CurrentState
* Sent when the current search state changes.
* Broadcast when the current search state changes.
* data: see _currentStateObj
* State
* Sent in reply to GetState.
* data: see _currentStateObj
* Suggestions
* Sent in reply to GetSuggestions.
* data: see _onMessageGetSuggestions
*/
this.ContentSearch = {
@ -60,6 +83,10 @@ this.ContentSearch = {
_eventQueue: [],
_currentEvent: null,
// This is used to handle search suggestions. It maps xul:browsers to objects
// { controller, previousFormHistoryResult }. See _onMessageGetSuggestions.
_suggestionMap: new WeakMap(),
init: function () {
Cc["@mozilla.org/globalmessagemanager;1"].
getService(Ci.nsIMessageListenerManager).
@ -72,10 +99,15 @@ this.ContentSearch = {
// the event queue. If the message's source docshell changes browsers in
// the meantime, then we need to update msg.target. event.detail will be
// the docshell's new parent <xul:browser> element.
msg.handleEvent = function (event) {
this.target.removeEventListener("SwapDocShells", this, true);
this.target = event.detail;
this.target.addEventListener("SwapDocShells", this, true);
msg.handleEvent = event => {
let browserData = this._suggestionMap.get(msg.target);
if (browserData) {
this._suggestionMap.delete(msg.target);
this._suggestionMap.set(event.detail, browserData);
}
msg.target.removeEventListener("SwapDocShells", msg, true);
msg.target = event.detail;
msg.target.addEventListener("SwapDocShells", msg, true);
};
msg.target.addEventListener("SwapDocShells", msg, true);
@ -106,6 +138,9 @@ this.ContentSearch = {
try {
yield this["_on" + this._currentEvent.type](this._currentEvent.data);
}
catch (err) {
Cu.reportError(err);
}
finally {
this._currentEvent = null;
this._processEventQueue();
@ -128,17 +163,11 @@ this.ContentSearch = {
},
_onMessageSearch: function (msg, data) {
let expectedDataProps = [
this._ensureDataHasProperties(data, [
"engineName",
"searchString",
"whence",
];
for (let prop of expectedDataProps) {
if (!(prop in data)) {
Cu.reportError("Message data missing required property: " + prop);
return Promise.resolve();
}
}
]);
let browserWin = msg.target.ownerDocument.defaultView;
let engine = Services.search.getEngineByName(data.engineName);
browserWin.BrowserSearch.recordSearchInHealthReport(engine, data.whence);
@ -158,6 +187,92 @@ this.ContentSearch = {
return Promise.resolve();
},
_onMessageGetSuggestions: Task.async(function* (msg, data) {
this._ensureDataHasProperties(data, [
"engineName",
"searchString",
]);
let engine = Services.search.getEngineByName(data.engineName);
if (!engine) {
throw new Error("Unknown engine name: " + data.engineName);
}
let browserData = this._suggestionDataForBrowser(msg.target, true);
let { controller } = browserData;
let ok = SearchSuggestionController.engineOffersSuggestions(engine);
controller.maxLocalResults = ok ? 2 : 6;
controller.maxRemoteResults = ok ? 6 : 0;
controller.remoteTimeout = data.remoteTimeout || undefined;
let priv = PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow);
// fetch() rejects its promise if there's a pending request, but since we
// process our event queue serially, there's never a pending request.
let suggestions = yield controller.fetch(data.searchString, priv, engine);
// Keep the form history result so RemoveFormHistoryEntry can remove entries
// from it. Keeping only one result isn't foolproof because the client may
// try to remove an entry from one set of suggestions after it has requested
// more but before it's received them. In that case, the entry may not
// appear in the new suggestions. But that should happen rarely.
browserData.previousFormHistoryResult = suggestions.formHistoryResult;
this._reply(msg, "Suggestions", {
engineName: data.engineName,
searchString: suggestions.term,
formHistory: suggestions.local,
remote: suggestions.remote,
});
}),
_onMessageAddFormHistoryEntry: function (msg, entry) {
// There are some tests that use about:home and newtab that trigger a search
// and then immediately close the tab. In those cases, the browser may have
// been destroyed by the time we receive this message, and as a result
// contentWindow is undefined.
if (!msg.target.contentWindow ||
PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow)) {
return Promise.resolve();
}
let browserData = this._suggestionDataForBrowser(msg.target, true);
FormHistory.update({
op: "bump",
fieldname: browserData.controller.formHistoryParam,
value: entry,
}, {
handleCompletion: () => {},
handleError: err => {
Cu.reportError("Error adding form history entry: " + err);
},
});
return Promise.resolve();
},
_onMessageRemoveFormHistoryEntry: function (msg, entry) {
let browserData = this._suggestionDataForBrowser(msg.target);
if (browserData && browserData.previousFormHistoryResult) {
let { previousFormHistoryResult } = browserData;
for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
if (previousFormHistoryResult.getValueAt(i) == entry) {
previousFormHistoryResult.removeValueAt(i, true);
break;
}
}
}
return Promise.resolve();
},
_onMessageSpeculativeConnect: function (msg, engineName) {
let engine = Services.search.getEngineByName(engineName);
if (!engine) {
throw new Error("Unknown engine name: " + engineName);
}
if (msg.target.contentWindow) {
engine.speculativeConnect({
window: msg.target.contentWindow,
});
}
},
_onObserve: Task.async(function* (data) {
if (data == "engine-current") {
let engine = yield this._currentEngineObj();
@ -171,6 +286,20 @@ this.ContentSearch = {
}
}),
_suggestionDataForBrowser: function (browser, create=false) {
let data = this._suggestionMap.get(browser);
if (!data && create) {
// Since one SearchSuggestionController instance is meant to be used per
// autocomplete widget, this means that we assume each xul:browser has at
// most one such widget.
data = {
controller: new SearchSuggestionController(),
};
this._suggestionMap.set(browser, data);
}
return data;
},
_reply: function (msg, type, data) {
// We reply asyncly to messages, and by the time we reply the browser we're
// responding to may have been destroyed. messageManager is null then.
@ -241,6 +370,14 @@ this.ContentSearch = {
return deferred.promise;
},
_ensureDataHasProperties: function (data, requiredProperties) {
for (let prop of requiredProperties) {
if (!(prop in data)) {
throw new Error("Message data missing required property: " + prop);
}
}
},
_initService: function () {
if (!this._initServicePromise) {
let deferred = Promise.defer();

View File

@ -9,6 +9,8 @@ support-files =
support-files =
contentSearch.js
contentSearchBadImage.xml
contentSearchSuggestions.sjs
contentSearchSuggestions.xml
[browser_NetworkPrioritizer.js]
skip-if = e10s # Bug 666804 - Support NetworkPrioritizer in e10s
[browser_SignInToWebsite.js]

View File

@ -171,6 +171,92 @@ add_task(function* badImage() {
yield waitForTestMsg("CurrentState");
});
add_task(function* GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() {
yield addTab();
// Add the test engine that provides suggestions.
let vals = yield waitForNewEngine("contentSearchSuggestions.xml", 0);
let engine = vals[0];
let searchStr = "browser_ContentSearch.js-suggestions-";
// Add a form history suggestion and wait for Satchel to notify about it.
gMsgMan.sendAsyncMessage(TEST_MSG, {
type: "AddFormHistoryEntry",
data: searchStr + "form",
});
let deferred = Promise.defer();
Services.obs.addObserver(function onAdd(subj, topic, data) {
if (data == "formhistory-add") {
executeSoon(() => deferred.resolve());
}
}, "satchel-storage-changed", false);
yield deferred.promise;
// Send GetSuggestions using the test engine. Its suggestions should appear
// in the remote suggestions in the Suggestions response below.
gMsgMan.sendAsyncMessage(TEST_MSG, {
type: "GetSuggestions",
data: {
engineName: engine.name,
searchString: searchStr,
remoteTimeout: 5000,
},
});
// Check the Suggestions response.
let msg = yield waitForTestMsg("Suggestions");
checkMsg(msg, {
type: "Suggestions",
data: {
engineName: engine.name,
searchString: searchStr,
formHistory: [searchStr + "form"],
remote: [searchStr + "foo", searchStr + "bar"],
},
});
// Delete the form history suggestion and wait for Satchel to notify about it.
gMsgMan.sendAsyncMessage(TEST_MSG, {
type: "RemoveFormHistoryEntry",
data: searchStr + "form",
});
deferred = Promise.defer();
Services.obs.addObserver(function onRemove(subj, topic, data) {
if (data == "formhistory-remove") {
executeSoon(() => deferred.resolve());
}
}, "satchel-storage-changed", false);
yield deferred.promise;
// Send GetSuggestions again.
gMsgMan.sendAsyncMessage(TEST_MSG, {
type: "GetSuggestions",
data: {
engineName: engine.name,
searchString: searchStr,
remoteTimeout: 5000,
},
});
// The formHistory suggestions in the Suggestions response should be empty.
msg = yield waitForTestMsg("Suggestions");
checkMsg(msg, {
type: "Suggestions",
data: {
engineName: engine.name,
searchString: searchStr,
formHistory: [],
remote: [searchStr + "foo", searchStr + "bar"],
},
});
// Finally, clean up by removing the test engine.
Services.search.removeEngine(engine);
yield waitForTestMsg("CurrentState");
});
function checkMsg(actualMsg, expectedMsgData) {
SimpleTest.isDeeply(actualMsg.data, expectedMsgData, "Checking message");
}
@ -226,7 +312,7 @@ function addTab() {
let tab = gBrowser.addTab();
gBrowser.selectedTab = tab;
tab.linkedBrowser.addEventListener("load", function load() {
tab.removeEventListener("load", load, true);
tab.linkedBrowser.removeEventListener("load", load, true);
let url = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
gMsgMan = tab.linkedBrowser.messageManager;
gMsgMan.sendAsyncMessage(CONTENT_SEARCH_MSG, {

View File

@ -0,0 +1,9 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
function handleRequest(req, resp) {
let suffixes = ["foo", "bar"];
let data = [req.queryString, suffixes.map(s => req.queryString + s)];
resp.setHeader("Content-Type", "application/json", false);
resp.write(JSON.stringify(data));
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName>
<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/contentSearchSuggestions.sjs?{searchTerms}"/>
<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/>
</SearchPlugin>

View File

@ -1,4 +1,5 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
@ -32,7 +33,7 @@ using dom::ConstrainLongRange;
NS_IMPL_ISUPPORTS(MediaEngineTabVideoSource, nsIDOMEventListener, nsITimerCallback)
MediaEngineTabVideoSource::MediaEngineTabVideoSource()
: mMonitor("MediaEngineTabVideoSource")
: mMonitor("MediaEngineTabVideoSource"), mTabSource(nullptr)
{
}
@ -42,7 +43,9 @@ MediaEngineTabVideoSource::StartRunnable::Run()
mVideoSource->Draw();
mVideoSource->mTimer = do_CreateInstance(NS_TIMER_CONTRACTID);
mVideoSource->mTimer->InitWithCallback(mVideoSource, mVideoSource->mTimePerFrame, nsITimer:: TYPE_REPEATING_SLACK);
mVideoSource->mTabSource->NotifyStreamStart(mVideoSource->mWindow);
if (mVideoSource->mTabSource) {
mVideoSource->mTabSource->NotifyStreamStart(mVideoSource->mWindow);
}
return NS_OK;
}
@ -55,7 +58,9 @@ MediaEngineTabVideoSource::StopRunnable::Run()
mVideoSource->mTimer->Cancel();
mVideoSource->mTimer = nullptr;
}
mVideoSource->mTabSource->NotifyStreamStop(mVideoSource->mWindow);
if (mVideoSource->mTabSource) {
mVideoSource->mTabSource->NotifyStreamStop(mVideoSource->mWindow);
}
return NS_OK;
}
@ -76,18 +81,25 @@ nsresult
MediaEngineTabVideoSource::InitRunnable::Run()
{
mVideoSource->mData = (unsigned char*)malloc(mVideoSource->mBufW * mVideoSource->mBufH * 4);
if (mVideoSource->mWindowId != -1) {
nsCOMPtr<nsPIDOMWindow> window = nsGlobalWindow::GetOuterWindowWithId(mVideoSource->mWindowId);
if (window) {
mVideoSource->mWindow = window;
}
}
if (!mVideoSource->mWindow) {
nsresult rv;
mVideoSource->mTabSource = do_GetService(NS_TABSOURCESERVICE_CONTRACTID, &rv);
NS_ENSURE_SUCCESS(rv, rv);
nsresult rv;
mVideoSource->mTabSource = do_GetService(NS_TABSOURCESERVICE_CONTRACTID, &rv);
NS_ENSURE_SUCCESS(rv, rv);
nsCOMPtr<nsIDOMWindow> win;
rv = mVideoSource->mTabSource->GetTabToStream(getter_AddRefs(win));
NS_ENSURE_SUCCESS(rv, rv);
if (!win)
return NS_OK;
nsCOMPtr<nsIDOMWindow> win;
rv = mVideoSource->mTabSource->GetTabToStream(getter_AddRefs(win));
NS_ENSURE_SUCCESS(rv, rv);
if (!win)
return NS_OK;
mVideoSource->mWindow = win;
mVideoSource->mWindow = win;
}
nsCOMPtr<nsIRunnable> start(new StartRunnable(mVideoSource));
start->Run();
return NS_OK;
@ -113,13 +125,26 @@ MediaEngineTabVideoSource::Allocate(const VideoTrackConstraintsN& aConstraints,
ConstrainLongRange cWidth(aConstraints.mRequired.mWidth);
ConstrainLongRange cHeight(aConstraints.mRequired.mHeight);
mWindowId = aConstraints.mBrowserWindow.WasPassed() ? aConstraints.mBrowserWindow.Value() : -1;
bool haveScrollWithPage = aConstraints.mScrollWithPage.WasPassed();
mScrollWithPage = haveScrollWithPage ? aConstraints.mScrollWithPage.Value() : true;
if (aConstraints.mAdvanced.WasPassed()) {
const auto& advanced = aConstraints.mAdvanced.Value();
for (uint32_t i = 0; i < advanced.Length(); i++) {
if (cWidth.mMax >= advanced[i].mWidth.mMin && cWidth.mMin <= advanced[i].mWidth.mMax &&
cHeight.mMax >= advanced[i].mHeight.mMin && cHeight.mMin <= advanced[i].mHeight.mMax) {
cWidth.mMin = std::max(cWidth.mMin, advanced[i].mWidth.mMin);
cHeight.mMin = std::max(cHeight.mMin, advanced[i].mHeight.mMin);
cHeight.mMax >= advanced[i].mHeight.mMin && cHeight.mMin <= advanced[i].mHeight.mMax) {
cWidth.mMin = std::max(cWidth.mMin, advanced[i].mWidth.mMin);
cHeight.mMin = std::max(cHeight.mMin, advanced[i].mHeight.mMin);
}
if (mWindowId == -1 && advanced[i].mBrowserWindow.WasPassed()) {
mWindowId = advanced[i].mBrowserWindow.Value();
}
if (!haveScrollWithPage && advanced[i].mScrollWithPage.WasPassed()) {
mScrollWithPage = advanced[i].mScrollWithPage.Value();
haveScrollWithPage = true;
}
}
}
@ -140,7 +165,6 @@ MediaEngineTabVideoSource::Allocate(const VideoTrackConstraintsN& aConstraints,
}
mTimePerFrame = aPrefs.mFPS ? 1000 / aPrefs.mFPS : aPrefs.mFPS;
return NS_OK;
}
@ -227,6 +251,13 @@ MediaEngineTabVideoSource::Draw() {
rect->GetWidth(&width);
rect->GetHeight(&height);
if (mScrollWithPage) {
nsPoint point;
utils->GetScrollXY(false, &point.x, &point.y);
left += point.x;
top += point.y;
}
if (width == 0 || height == 0) {
return;
}

View File

@ -63,6 +63,8 @@ protected:
private:
int mBufW;
int mBufH;
int64_t mWindowId;
bool mScrollWithPage;
int mTimePerFrame;
ScopedFreePtr<unsigned char> mData;
nsCOMPtr<nsIDOMWindow> mWindow;

View File

@ -100,7 +100,12 @@ struct VideoTrackConstraintsN :
Triage(Kind::Width).mWidth = mWidth;
Triage(Kind::Height).mHeight = mHeight;
Triage(Kind::FrameRate).mFrameRate = mFrameRate;
if (mBrowserWindow.WasPassed()) {
Triage(Kind::BrowserWindow).mBrowserWindow.Construct(mBrowserWindow.Value());
}
if (mScrollWithPage.WasPassed()) {
Triage(Kind::ScrollWithPage).mScrollWithPage.Construct(mScrollWithPage.Value());
}
// treat MediaSource special because it's always required
mRequired.mMediaSource = mMediaSource;
}

View File

@ -1530,6 +1530,23 @@ MediaManager::GetUserMedia(bool aPrivileged,
}
#endif
if (c.mVideo.IsMediaTrackConstraints() && !aPrivileged) {
auto& tc = c.mVideo.GetAsMediaTrackConstraints();
// only allow privileged content to set the window id
if (tc.mBrowserWindow.WasPassed()) {
tc.mBrowserWindow.Construct(-1);
}
if (tc.mAdvanced.WasPassed()) {
uint32_t length = tc.mAdvanced.Value().Length();
for (uint32_t i = 0; i < length; i++) {
if (tc.mAdvanced.Value()[i].mBrowserWindow.WasPassed()) {
tc.mAdvanced.Value()[i].mBrowserWindow.Construct(-1);
}
}
}
}
// Pass callbacks and MediaStreamListener along to GetUserMediaRunnable.
nsRefPtr<GetUserMediaRunnable> runnable;
if (c.mFake) {
@ -1548,12 +1565,16 @@ MediaManager::GetUserMedia(bool aPrivileged,
auto& tc = c.mVideo.GetAsMediaTrackConstraints();
// deny screensharing request if support is disabled
if (tc.mMediaSource != dom::MediaSourceEnum::Camera) {
if (!Preferences::GetBool("media.getusermedia.screensharing.enabled", false)) {
if (tc.mMediaSource == dom::MediaSourceEnum::Browser) {
if (!Preferences::GetBool("media.getusermedia.browser.enabled", false)) {
return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
}
} else if (!Preferences::GetBool("media.getusermedia.screensharing.enabled", false)) {
return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
}
/* Deny screensharing if the requesting document is not from a host
on the whitelist. */
if (!HostHasPermission(*docURI)) {
if (!aPrivileged && !HostHasPermission(*docURI)) {
return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
}
}

View File

@ -13,7 +13,9 @@ enum SupportedVideoConstraints {
"width",
"height",
"frameRate",
"mediaSource"
"mediaSource",
"browserWindow",
"scrollWithPage"
};
enum SupportedAudioConstraints {
@ -27,6 +29,8 @@ dictionary MediaTrackConstraintSet {
ConstrainDoubleRange frameRate;
ConstrainVideoFacingMode facingMode;
ConstrainMediaSource mediaSource = "camera";
long long browserWindow;
boolean scrollWithPage;
};
// TODO: Bug 995352 can't nest unions

View File

@ -21,8 +21,9 @@ import java.util.Locale;
public final class GeckoLoader {
private static final String LOGTAG = "GeckoLoader";
// This matches AppConstants, but we're built earlier.
// These match AppConstants, but we're built earlier.
private static final String ANDROID_PACKAGE_NAME = "@ANDROID_PACKAGE_NAME@";
private static final String MOZ_APP_ABI = "@MOZ_APP_ABI@";
private static volatile Intent sIntent;
private static File sCacheFile;
@ -264,6 +265,8 @@ public final class GeckoLoader {
final StringBuilder message = new StringBuilder("LOAD ");
message.append(lib);
// These might differ. If so, we know why the library won't load!
message.append(": ABI: " + MOZ_APP_ABI + ", " + android.os.Build.CPU_ABI);
message.append(": Data: " + context.getApplicationInfo().dataDir);
try {
final boolean appLibExists = new File("/data/app-lib/" + ANDROID_PACKAGE_NAME + "/lib" + lib + ".so").exists();
@ -336,9 +339,17 @@ public final class GeckoLoader {
// Attempt 2: use nativeLibraryDir, which should also work.
final String libDir = context.getApplicationInfo().nativeLibraryDir;
if (attemptLoad(libDir + "/lib" + lib + ".so")) {
// Success!
return null;
final String libPath = libDir + "/lib" + lib + ".so";
// Does it even exist?
if (new File(libPath).exists()) {
if (attemptLoad(libPath)) {
// Success!
return null;
}
Log.wtf(LOGTAG, "Library exists but couldn't load!");
} else {
Log.wtf(LOGTAG, "Library doesn't exist when it should.");
}
// We failed. Return the original cause.

View File

@ -79,8 +79,12 @@ MOZ_NATIVE_DEVICES=
# Mark as WebGL conformant
MOZ_WEBGL_CONFORMANT=1
# Don't enable the Search Activity.
# MOZ_ANDROID_SEARCH_ACTIVITY=1
# Enable the Search Activity in nightly.
if test "$NIGHTLY_BUILD"; then
MOZ_ANDROID_SEARCH_ACTIVITY=1
else
MOZ_ANDROID_SEARCH_ACTIVITY=
fi
# Don't enable the Mozilla Location Service stumbler.
# MOZ_ANDROID_MLS_STUMBLER=1

View File

@ -192,6 +192,8 @@ let TabMirror = function(deviceId, window) {
let constraints = {
video: {
mediaSource: "browser",
browserWindow: windowId,
scrollWithPage: true,
advanced: [
{ width: { min: videoWidth, max: videoWidth },
height: { min: videoHeight, max: videoHeight }

View File

@ -18,6 +18,7 @@ import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ProgressBar;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract;
@ -100,6 +101,7 @@ public class PostSearchFragment extends Fragment {
TelemetryContract.Method.CONTENT, "search-result");
view.stopLoading();
Intent i = new Intent(Intent.ACTION_VIEW);
i.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.BROWSER_INTENT_CLASS_NAME);
i.setData(Uri.parse(url));
startActivity(i);
}

View File

@ -13,6 +13,7 @@ import android.support.v4.app.LoaderManager;
import android.support.v4.content.AsyncTaskLoader;
import android.support.v4.content.Loader;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
@ -136,8 +137,12 @@ public class SearchFragment extends Fragment implements AcceptsJumpTaps {
@Override
public void onSubmit(String text) {
transitionToWaiting();
searchListener.onSearch(text);
// Don't submit an empty query.
final String trimmedQuery = text.trim();
if (!TextUtils.isEmpty(trimmedQuery)) {
transitionToWaiting();
searchListener.onSearch(trimmedQuery);
}
}
});

View File

@ -1,5 +1,6 @@
<activity
android:name="org.mozilla.search.MainActivity"
android:icon="@drawable/search_launcher"
android:label="@string/search_app_name"
android:theme="@style/AppTheme"
android:screenOrientation="portrait">
@ -37,6 +38,7 @@
<activity
android:name="org.mozilla.search.SearchPreferenceActivity"
android:logo="@drawable/search_launcher"
android:label="@string/search_pref_title"
android:parentActivityName="org.mozilla.search.MainActivity"
android:theme="@style/SettingsTheme" >

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -311,6 +311,7 @@ pref("media.navigator.video.h264.max_br", 0);
pref("media.navigator.video.h264.max_mbps", 0);
pref("media.peerconnection.video.h264_enabled", false);
pref("media.getusermedia.aec", 1);
pref("media.getusermedia.browser.enabled", true);
#endif
pref("media.peerconnection.video.min_bitrate", 200);
pref("media.peerconnection.video.start_bitrate", 300);

View File

@ -0,0 +1,204 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Firefox Accounts OAuth browser login helper.
* Uses the WebChannel component to receive OAuth messages and complete login flows.
*/
this.EXPORTED_SYMBOLS = ["FxAccountsOAuthClient"];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
"resource://gre/modules/WebChannel.jsm");
Cu.importGlobalProperties(["URL"]);
/**
* Create a new FxAccountsOAuthClient for browser some service.
*
* @param {Object} options Options
* @param {Object} options.parameters
* Opaque alphanumeric token to be included in verification links
* @param {String} options.parameters.client_id
* OAuth id returned from client registration
* @param {String} options.parameters.state
* A value that will be returned to the client as-is upon redirection
* @param {String} options.parameters.oauth_uri
* The FxA OAuth server uri
* @param {String} options.parameters.content_uri
* The FxA Content server uri
* @param {String} [options.parameters.scope]
* Optional. A colon-separated list of scopes that the user has authorized
* @param {String} [options.parameters.action]
* Optional. If provided, should be either signup or signin.
* @param [authorizationEndpoint] {String}
* Optional authorization endpoint for the OAuth server
* @constructor
*/
this.FxAccountsOAuthClient = function(options) {
this._validateOptions(options);
this.parameters = options.parameters;
this._configureChannel();
let authorizationEndpoint = options.authorizationEndpoint || "/authorization";
try {
this._fxaOAuthStartUrl = new URL(this.parameters.oauth_uri + authorizationEndpoint + "?");
} catch (e) {
throw new Error("Invalid OAuth Url");
}
let params = this._fxaOAuthStartUrl.searchParams;
params.append("client_id", this.parameters.client_id);
params.append("state", this.parameters.state);
params.append("scope", this.parameters.scope || "");
params.append("action", this.parameters.action || "signin");
params.append("webChannelId", this._webChannelId);
};
this.FxAccountsOAuthClient.prototype = {
/**
* Function that gets called once the OAuth flow is successfully complete.
*/
onComplete: null,
/**
* Configuration object that stores all OAuth parameters.
*/
parameters: null,
/**
* WebChannel that is used to communicate with content page.
*/
_channel: null,
/**
* Boolean to indicate if this client has completed an OAuth flow.
*/
_complete: false,
/**
* The url that opens the Firefox Accounts OAuth flow.
*/
_fxaOAuthStartUrl: null,
/**
* WebChannel id.
*/
_webChannelId: null,
/**
* WebChannel origin, used to validate origin of messages.
*/
_webChannelOrigin: null,
/**
* Opens a tab at "this._fxaOAuthStartUrl".
* Registers a WebChannel listener and sets up a callback if needed.
*/
launchWebFlow: function () {
if (!this._channelCallback) {
this._registerChannel();
}
if (this._complete) {
throw new Error("This client already completed the OAuth flow");
} else {
let opener = Services.wm.getMostRecentWindow("navigator:browser").gBrowser;
opener.selectedTab = opener.addTab(this._fxaOAuthStartUrl.href);
}
},
/**
* Release all resources that are in use.
*/
tearDown: function() {
this.onComplete = null;
this._complete = true;
this._channel.stopListening();
},
/**
* Configures WebChannel id and origin
*
* @private
*/
_configureChannel: function() {
this._webChannelId = "oauth_" + this.parameters.client_id;
// if this.parameters.content_uri is present but not a valid URI, then this will throw an error.
try {
this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri, null, null);
} catch (e) {
throw e;
}
},
/**
* Create a new channel with the WebChannelBroker, setup a callback listener
* @private
*/
_registerChannel: function() {
/**
* Processes messages that are called back from the FxAccountsChannel
*
* @param webChannelId {String}
* Command webChannelId
* @param message {Object}
* Command message
* @param target {EventTarget}
* Channel message event target
* @private
*/
let listener = function (webChannelId, message, target) {
if (message) {
let command = message.command;
let data = message.data;
switch (command) {
case "oauth_complete":
// validate the state parameter and call onComplete
if (this.onComplete && data.code && this.parameters.state === data.state) {
log.debug("OAuth flow completed.");
this.onComplete({
code: data.code,
state: data.state
});
// onComplete will be called for this client only once
// calling onComplete again will result in a failure of the OAuth flow
this.tearDown();
}
// if the message asked to close the tab
if (data.closeWindow && target && target.contentWindow) {
target.contentWindow.close();
}
break;
}
}
};
this._channelCallback = listener.bind(this);
this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
this._channel.listen(this._channelCallback);
log.debug("Channel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
},
/**
* Validates the required FxA OAuth parameters
*
* @param options {Object}
* OAuth client options
* @private
*/
_validateOptions: function (options) {
if (!options || !options.parameters) {
throw new Error("Missing 'parameters' configuration option");
}
["oauth_uri", "client_id", "content_uri", "state"].forEach(option => {
if (!options.parameters[option]) {
throw new Error("Missing 'parameters." + option + "' parameter");
}
});
},
};

View File

@ -12,7 +12,8 @@ EXTRA_JS_MODULES += [
'Credentials.jsm',
'FxAccounts.jsm',
'FxAccountsClient.jsm',
'FxAccountsCommon.js'
'FxAccountsCommon.js',
'FxAccountsOAuthClient.jsm',
]
# For now, we will only be using the FxA manager in B2G.

View File

@ -0,0 +1,46 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
function run_test() {
validationHelper(undefined,
"Error: Missing 'parameters' configuration option");
validationHelper({},
"Error: Missing 'parameters' configuration option");
validationHelper({ parameters: {} },
"Error: Missing 'parameters.oauth_uri' parameter");
validationHelper({ parameters: {
oauth_uri: "http://oauth.test/v1"
}},
"Error: Missing 'parameters.client_id' parameter");
validationHelper({ parameters: {
oauth_uri: "http://oauth.test/v1",
client_id: "client_id"
}},
"Error: Missing 'parameters.content_uri' parameter");
validationHelper({ parameters: {
oauth_uri: "http://oauth.test/v1",
client_id: "client_id",
content_uri: "http://content.test"
}},
"Error: Missing 'parameters.state' parameter");
run_next_test();
}
function validationHelper(params, expected) {
try {
new FxAccountsOAuthClient(params);
} catch (e) {
return do_check_eq(e.toString(), expected);
}
throw new Error("Validation helper error");
}

View File

@ -8,3 +8,4 @@ tail =
[test_manager.js]
run-if = appname == 'b2g'
reason = FxAccountsManager is only available for B2G for now
[test_oauth_client.js]

View File

@ -46,6 +46,16 @@ this.SearchSuggestionController.prototype = {
*/
maxRemoteResults: 10,
/**
* The maximum time (ms) to wait before giving up on a remote suggestions.
*/
remoteTimeout: REMOTE_TIMEOUT,
/**
* The additional parameter used when searching form history.
*/
formHistoryParam: DEFAULT_FORM_HISTORY_PARAM,
// Private properties
/**
* The last form history result used to improve the performance of subsequent searches.
@ -168,7 +178,7 @@ this.SearchSuggestionController.prototype = {
this._remoteResultTimer = Cc["@mozilla.org/timer;1"].
createInstance(Ci.nsITimer);
this._remoteResultTimer.initWithCallback(this._onRemoteTimeout.bind(this),
REMOTE_TIMEOUT,
this.remoteTimeout || REMOTE_TIMEOUT,
Ci.nsITimer.TYPE_ONE_SHOT);
}
@ -199,7 +209,8 @@ this.SearchSuggestionController.prototype = {
let formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"].
createInstance(Ci.nsIAutoCompleteSearch);
formHistory.startSearch(searchTerm, DEFAULT_FORM_HISTORY_PARAM, this._formHistoryResult,
formHistory.startSearch(searchTerm, this.formHistoryParam || DEFAULT_FORM_HISTORY_PARAM,
this._formHistoryResult,
acSearchObserver);
return deferredFormHistory;
},
@ -347,3 +358,13 @@ this.SearchSuggestionController.prototype = {
this._searchString = null;
},
};
/**
* Determines whether the given engine offers search suggestions.
*
* @param {nsISearchEngine} engine - The search engine
* @return {boolean} True if the engine offers suggestions and false otherwise.
*/
this.SearchSuggestionController.engineOffersSuggestions = function(engine) {
return engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON);
};

View File

@ -0,0 +1,249 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* WebChannel is an abstraction that uses the Message Manager and Custom Events
* to create a two-way communication channel between chrome and content code.
*/
this.EXPORTED_SYMBOLS = ["WebChannel", "WebChannelBroker"];
const ERRNO_UNKNOWN_ERROR = 999;
const ERROR_UNKNOWN = "UNKNOWN_ERROR";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
/**
* WebChannelBroker is a global object that helps manage WebChannel objects.
* This object handles channel registration, origin validation and message multiplexing.
*/
let WebChannelBroker = Object.create({
/**
* Register a new channel that callbacks messages
* based on proper origin and channel name
*
* @param channel {WebChannel}
*/
registerChannel: function (channel) {
if (!this._channelMap.has(channel)) {
this._channelMap.set(channel);
} else {
Cu.reportError("Failed to register the channel. Channel already exists.");
}
// attach the global message listener if needed
if (!this._messageListenerAttached) {
this._messageListenerAttached = true;
this._manager.addMessageListener("WebChannelMessageToChrome", this._listener.bind(this));
}
},
/**
* Unregister a channel
*
* @param channelToRemove {WebChannel}
* WebChannel to remove from the channel map
*
* Removes the specified channel from the channel map
*/
unregisterChannel: function (channelToRemove) {
if (!this._channelMap.delete(channelToRemove)) {
Cu.reportError("Failed to unregister the channel. Channel not found.");
}
},
/**
* @param event {Event}
* Message Manager event
* @private
*/
_listener: function (event) {
let data = event.data;
let sender = event.target;
if (data && data.id) {
if (!event.principal) {
this._sendErrorEventToContent(data.id, sender, "Message principal missing");
} else {
let validChannelFound = false;
data.message = data.message || {};
for (var channel of this._channelMap.keys()) {
if (channel.id === data.id &&
channel.origin.prePath === event.principal.origin) {
validChannelFound = true;
channel.deliver(data, sender);
}
}
// if no valid origins send an event that there is no such valid channel
if (!validChannelFound) {
this._sendErrorEventToContent(data.id, sender, "No Such Channel");
}
}
} else {
Cu.reportError("WebChannel channel id missing");
}
},
/**
* The global message manager operates on every <browser>
*/
_manager: Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager),
/**
* Boolean used to detect if the global message manager event is already attached
*/
_messageListenerAttached: false,
/**
* Object to store pairs of message origins and callback functions
*/
_channelMap: new Map(),
/**
*
* @param id {String}
* The WebChannel id to include in the message
* @param sender {EventTarget}
* EventTarget with a "messageManager" that will send be used to send the message
* @param [errorMsg] {String}
* Error message
* @private
*/
_sendErrorEventToContent: function (id, sender, errorMsg) {
errorMsg = errorMsg || "Web Channel Broker error";
if (sender.messageManager) {
sender.messageManager.sendAsyncMessage("WebChannelMessageToContent", {
id: id,
error: errorMsg,
}, sender);
}
Cu.reportError(id.toString() + " error message. " + errorMsg);
},
});
/**
* Creates a new WebChannel that listens and sends messages over some channel id
*
* @param id {String}
* WebChannel id
* @param origin {nsIURI}
* Valid origin that should be part of requests for this channel
* @constructor
*/
this.WebChannel = function(id, origin) {
if (!id || !origin) {
throw new Error("WebChannel id and origin are required.");
}
this.id = id;
this.origin = origin;
};
this.WebChannel.prototype = {
/**
* WebChannel id
*/
id: null,
/**
* WebChannel origin
*/
origin: null,
/**
* WebChannelBroker that manages WebChannels
*/
_broker: WebChannelBroker,
/**
* Callback that will be called with the contents of an incoming message
*/
_deliverCallback: null,
/**
* Registers the callback for messages on this channel
* Registers the channel itself with the WebChannelBroker
*
* @param callback {Function}
* Callback that will be called when there is a message
* @param {String} id
* The WebChannel id that was used for this message
* @param {Object} message
* The message itself
* @param {EventTarget} sender
* The source of the message
*/
listen: function (callback) {
if (this._deliverCallback) {
throw new Error("Failed to listen. Listener already attached.");
} else if (!callback) {
throw new Error("Failed to listen. Callback argument missing.");
} else {
this._deliverCallback = callback;
this._broker.registerChannel(this);
}
},
/**
* Resets the callback for messages on this channel
* Removes the channel from the WebChannelBroker
*/
stopListening: function () {
this._broker.unregisterChannel(this);
this._deliverCallback = null;
},
/**
* Sends messages over the WebChannel id using the "WebChannelMessageToContent" event
*
* @param message {Object}
* The message object that will be sent
* @param target {browser}
* The <browser> object that has a "messageManager" that sends messages
*
*/
send: function (message, target) {
if (message && target && target.messageManager) {
target.messageManager.sendAsyncMessage("WebChannelMessageToContent", {
id: this.id,
message: message
});
} else if (!message) {
Cu.reportError("Failed to send a WebChannel message. Message not set.");
} else {
Cu.reportError("Failed to send a WebChannel message. Target invalid.");
}
},
/**
* Deliver WebChannel messages to the set "_channelCallback"
*
* @param data {Object}
* Message data
* @param sender {browser}
* Message sender
*/
deliver: function(data, sender) {
if (this._deliverCallback) {
try {
this._deliverCallback(data.id, data.message, sender);
} catch (ex) {
this.send({
errno: ERRNO_UNKNOWN_ERROR,
error: ex.message ? ex.message : ERROR_UNKNOWN
}, sender);
Cu.reportError("Failed to execute callback:" + ex);
}
} else {
Cu.reportError("No callback set for this channel.");
}
}
};

View File

@ -55,6 +55,7 @@ EXTRA_JS_MODULES += [
'Task.jsm',
'TelemetryTimestamps.jsm',
'Timer.jsm',
'WebChannel.jsm',
'ZipUtils.jsm',
]

View File

@ -0,0 +1,104 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const Cu = Components.utils;
Cu.import("resource://services-common/async.js");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/WebChannel.jsm");
const ERROR_ID_ORIGIN_REQUIRED = "WebChannel id and origin are required.";
const VALID_WEB_CHANNEL_ID = "id";
const URL_STRING = "http://example.com";
const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null);
let MockWebChannelBroker = {
_channelMap: new Map(),
registerChannel: function(channel) {
if (!this._channelMap.has(channel)) {
this._channelMap.set(channel);
}
},
unregisterChannel: function (channelToRemove) {
this._channelMap.delete(channelToRemove)
}
};
function run_test() {
run_next_test();
}
/**
* Web channel tests
*/
/**
* Test channel listening
*/
add_test(function test_web_channel_listen() {
let channel = new WebChannel(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN, {
broker: MockWebChannelBroker
});
let cb = Async.makeSpinningCallback();
let delivered = 0;
do_check_eq(channel.id, VALID_WEB_CHANNEL_ID);
do_check_eq(channel.origin.spec, VALID_WEB_CHANNEL_ORIGIN.spec);
do_check_eq(channel._deliverCallback, null);
channel.listen(function(id, message, target) {
do_check_eq(id, VALID_WEB_CHANNEL_ID);
do_check_true(message);
do_check_true(message.command);
do_check_true(target.sender);
delivered++;
// 2 messages should be delivered
if (delivered === 2) {
channel.stopListening();
do_check_eq(channel._deliverCallback, null);
cb();
run_next_test();
}
});
// send two messages
channel.deliver({
id: VALID_WEB_CHANNEL_ID,
message: {
command: "one"
}
}, { sender: true });
channel.deliver({
id: VALID_WEB_CHANNEL_ID,
message: {
command: "two"
}
}, { sender: true });
cb.wait();
});
/**
* Test constructor
*/
add_test(function test_web_channel_constructor() {
do_check_eq(constructorTester(), ERROR_ID_ORIGIN_REQUIRED);
do_check_eq(constructorTester(undefined), ERROR_ID_ORIGIN_REQUIRED);
do_check_eq(constructorTester(undefined, VALID_WEB_CHANNEL_ORIGIN), ERROR_ID_ORIGIN_REQUIRED);
do_check_eq(constructorTester(VALID_WEB_CHANNEL_ID, undefined), ERROR_ID_ORIGIN_REQUIRED);
do_check_false(constructorTester(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN));
run_next_test();
});
function constructorTester(id, origin) {
try {
new WebChannel(id, origin);
} catch (e) {
return e.message;
}
return false;
}

View File

@ -0,0 +1,85 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const Cu = Components.utils;
Cu.import("resource://services-common/async.js");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/WebChannel.jsm");
const VALID_WEB_CHANNEL_ID = "id";
const URL_STRING = "http://example.com";
const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null);
function run_test() {
run_next_test();
}
/**
* Test WebChannelBroker channel map
*/
add_test(function test_web_channel_broker_channel_map() {
let channel = new Object();
let channel2 = new Object();
do_check_eq(WebChannelBroker._channelMap.size, 0);
do_check_false(WebChannelBroker._messageListenerAttached);
// make sure _channelMap works correctly
WebChannelBroker.registerChannel(channel);
do_check_eq(WebChannelBroker._channelMap.size, 1);
do_check_true(WebChannelBroker._messageListenerAttached);
WebChannelBroker.registerChannel(channel2);
do_check_eq(WebChannelBroker._channelMap.size, 2);
WebChannelBroker.unregisterChannel(channel);
do_check_eq(WebChannelBroker._channelMap.size, 1);
// make sure the correct channel is unregistered
do_check_false(WebChannelBroker._channelMap.has(channel));
do_check_true(WebChannelBroker._channelMap.has(channel2));
WebChannelBroker.unregisterChannel(channel2);
do_check_eq(WebChannelBroker._channelMap.size, 0);
run_next_test();
});
/**
* Test WebChannelBroker _listener test
*/
add_test(function test_web_channel_broker_listener() {
let cb = Async.makeSpinningCallback();
var channel = new Object({
id: VALID_WEB_CHANNEL_ID,
origin: VALID_WEB_CHANNEL_ORIGIN,
deliver: function(data, sender) {
do_check_eq(data.id, VALID_WEB_CHANNEL_ID);
do_check_eq(data.message.command, "hello");
WebChannelBroker.unregisterChannel(channel);
cb();
run_next_test();
}
});
WebChannelBroker.registerChannel(channel);
var mockEvent = {
data: {
id: VALID_WEB_CHANNEL_ID,
message: {
command: "hello"
}
},
principal: {
origin: URL_STRING
}
};
WebChannelBroker._listener(mockEvent);
cb.wait();
});

View File

@ -28,4 +28,6 @@ support-files =
[test_task.js]
[test_TelemetryTimestamps.js]
[test_timer.js]
[test_web_channel.js]
[test_web_channel_broker.js]
[test_ZipUtils.js]