Source code

Revision control

Copy as Markdown

Other Tools

/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs",
});
const gInContentProcess =
Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
const FAVICON_REQUEST_TIMEOUT = 60 * 1000;
// Map from windows to arrays of data about pending favicon loads.
let gFaviconLoadDataMap = new Map();
const ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD = 10;
// copied from utilityOverlay.js
const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
let InternalFaviconLoader = {
/**
* Actually cancel the request, and clear the timeout for cancelling it.
*
* @param {object} options
* The options object containing:
* @param {object} options.uri
* The URI of the favicon to cancel.
* @param {number} options.innerWindowID
* The inner window ID of the window. Unused.
* @param {number} options.timerID
* The timer ID of the timeout to be cancelled
* @param {*} options.callback
* The request callback
* @param {string} reason
* The reason for cancelling the request.
*/
_cancelRequest({ uri, innerWindowID, timerID, callback }, reason) {
// Break cycle
let request = callback.request;
delete callback.request;
// Ensure we don't time out.
clearTimeout(timerID);
try {
request.cancel();
} catch (ex) {
console.error(
`When cancelling a request for ${uri.spec} because ${reason}, it was already canceled!`
);
}
},
/**
* Called for every inner that gets destroyed, only in the parent process.
*
* @param {number} innerID
* The innerID of the window.
*/
removeRequestsForInner(innerID) {
for (let [window, loadDataForWindow] of gFaviconLoadDataMap) {
let newLoadDataForWindow = loadDataForWindow.filter(loadData => {
let innerWasDestroyed = loadData.innerWindowID == innerID;
if (innerWasDestroyed) {
this._cancelRequest(
loadData,
"the inner window was destroyed or a new favicon was loaded for it"
);
}
// Keep the items whose inner is still alive.
return !innerWasDestroyed;
});
// Map iteration with for...of is safe against modification, so
// now just replace the old value:
gFaviconLoadDataMap.set(window, newLoadDataForWindow);
}
},
/**
* Called when a toplevel chrome window unloads. We use this to tidy up after ourselves,
* avoid leaks, and cancel any remaining requests. The last part should in theory be
* handled by the inner-window-destroyed handlers. We clean up just to be on the safe side.
*
* @param {DOMWindow} win
* The window that was unloaded.
*/
onUnload(win) {
let loadDataForWindow = gFaviconLoadDataMap.get(win);
if (loadDataForWindow) {
for (let loadData of loadDataForWindow) {
this._cancelRequest(loadData, "the chrome window went away");
}
}
gFaviconLoadDataMap.delete(win);
},
/**
* Remove a particular favicon load's loading data from our map tracking
* load data per chrome window.
*
* @param {DOMWindow} win
* the chrome window in which we should look for this load
* @param {object} filterData
* the data we should use to find this particular load to remove.
* @param {number} filterData.innerWindowID
* The inner window ID of the window.
* @param {string} filterData.uri
* The URI of the favicon to cancel.
* @param {*} filterData.callback
* The request callback
*
* @returns {object|null}
* the loadData object we removed, or null if we didn't find any.
*/
_removeLoadDataFromWindowMap(win, { innerWindowID, uri, callback }) {
let loadDataForWindow = gFaviconLoadDataMap.get(win);
if (loadDataForWindow) {
let itemIndex = loadDataForWindow.findIndex(loadData => {
return (
loadData.innerWindowID == innerWindowID &&
loadData.uri.equals(uri) &&
loadData.callback.request == callback.request
);
});
if (itemIndex != -1) {
let loadData = loadDataForWindow[itemIndex];
loadDataForWindow.splice(itemIndex, 1);
return loadData;
}
}
return null;
},
/**
* Create a function to use as a nsIFaviconDataCallback, so we can remove cancelling
* information when the request succeeds. Note that right now there are some edge-cases,
* such as about: URIs with chrome:// favicons where the success callback is not invoked.
* This is OK: we will 'cancel' the request after the timeout (or when the window goes
* away) but that will be a no-op in such cases.
*
* @param {DOMWindow} win
* The chrome window in which the request was made.
* @param {number} id
* The inner window ID of the window.
* @returns {object}
*/
_makeCompletionCallback(win, id) {
return {
onComplete(uri) {
let loadData = InternalFaviconLoader._removeLoadDataFromWindowMap(win, {
uri,
innerWindowID: id,
callback: this,
});
if (loadData) {
clearTimeout(loadData.timerID);
}
delete this.request;
},
};
},
ensureInitialized() {
if (this._initialized) {
return;
}
this._initialized = true;
Services.obs.addObserver(windowGlobal => {
this.removeRequestsForInner(windowGlobal.innerWindowId);
}, "window-global-destroyed");
},
loadFavicon(browser, principal, pageURI, uri, expiration, iconURI) {
this.ensureInitialized();
let { ownerGlobal: win, innerWindowID } = browser;
if (!gFaviconLoadDataMap.has(win)) {
gFaviconLoadDataMap.set(win, []);
let unloadHandler = event => {
let doc = event.target;
let eventWin = doc.defaultView;
if (eventWin == win) {
win.removeEventListener("unload", unloadHandler);
this.onUnload(win);
}
};
win.addEventListener("unload", unloadHandler, true);
}
// First we do the actual setAndFetch call:
let loadType = lazy.PrivateBrowsingUtils.isWindowPrivate(win)
? lazy.PlacesUtils.favicons.FAVICON_LOAD_PRIVATE
: lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE;
let callback = this._makeCompletionCallback(win, innerWindowID);
if (iconURI && iconURI.schemeIs("data")) {
expiration = lazy.PlacesUtils.toPRTime(expiration);
lazy.PlacesUtils.favicons.replaceFaviconDataFromDataURL(
uri,
iconURI.spec,
expiration,
principal
);
}
let request = lazy.PlacesUtils.favicons.setAndFetchFaviconForPage(
pageURI,
uri,
false,
loadType,
callback,
principal
);
// Now register the result so we can cancel it if/when necessary.
if (!request) {
// The favicon service can return with success but no-op (and leave request
// as null) if the icon is the same as the page (e.g. for images) or if it is
// the favicon for an error page. In this case, we do not need to do anything else.
return;
}
callback.request = request;
let loadData = { innerWindowID, uri, callback };
loadData.timerID = setTimeout(() => {
this._cancelRequest(loadData, "it timed out");
this._removeLoadDataFromWindowMap(win, loadData);
}, FAVICON_REQUEST_TIMEOUT);
let loadDataForWindow = gFaviconLoadDataMap.get(win);
loadDataForWindow.push(loadData);
},
};
/**
* Collects all information for a bookmark and performs editmethods
*/
class BookmarkState {
/**
* Construct a new BookmarkState.
*
* @param {object} options
* The constructor options.
* @param {object} options.info
* Either a result node or a node-like object representing the item to be edited.
* @param {string} [options.tags]
* Tags (if any) for the bookmark in a comma separated string. Empty tags are
* skipped
* @param {string} [options.keyword]
* Existing (if there are any) keyword for bookmark
* @param {boolean} [options.isFolder]
* If the item is a folder.
* @param {Array<{ title: string; url: nsIURI; }>} [options.children]
* The list of child URIs to bookmark within the folder.
* @param {boolean} [options.autosave]
* If changes to bookmark fields should be saved immediately after calling
* its respective "changed" method, rather than waiting for save() to be
* called.
* @param {number} [options.index]
* The insertion point index of the bookmark.
*/
constructor({
info,
tags = "",
keyword = "",
isFolder = false,
children = [],
autosave = false,
index,
}) {
this._guid = info.itemGuid;
this._postData = info.postData;
this._isTagContainer = info.isTag;
this._bulkTaggingUrls = info.uris?.map(uri => uri.spec);
this._isFolder = isFolder;
this._children = children;
this._autosave = autosave;
// Original Bookmark
this._originalState = {
title: this._isTagContainer ? info.tag : info.title,
uri: info.uri?.spec,
tags: tags
.trim()
.split(/\s*,\s*/)
.filter(tag => !!tag.length),
keyword,
parentGuid: info.parentGuid,
index,
};
// Edited bookmark
this._newState = {};
}
/**
* Save edited title for the bookmark
*
* @param {string} title
* The title of the bookmark
*/
async _titleChanged(title) {
this._newState.title = title;
await this._maybeSave();
}
/**
* Save edited location for the bookmark
*
* @param {string} location
* The location of the bookmark
*/
async _locationChanged(location) {
this._newState.uri = location;
await this._maybeSave();
}
/**
* Save edited tags for the bookmark
*
* @param {string} tags
* Comma separated list of tags
*/
async _tagsChanged(tags) {
this._newState.tags = tags;
await this._maybeSave();
}
/**
* Save edited keyword for the bookmark
*
* @param {string} keyword
* The keyword of the bookmark
*/
async _keywordChanged(keyword) {
this._newState.keyword = keyword;
await this._maybeSave();
}
/**
* Save edited parentGuid for the bookmark
*
* @param {string} parentGuid
* The parentGuid of the bookmark
*/
async _parentGuidChanged(parentGuid) {
this._newState.parentGuid = parentGuid;
await this._maybeSave();
}
/**
* Save changes if autosave is enabled.
*/
async _maybeSave() {
if (this._autosave) {
await this.save();
}
}
/**
* Create a new bookmark.
*
* @returns {string} The bookmark's GUID.
*/
async _createBookmark() {
let transactions = [
lazy.PlacesTransactions.NewBookmark({
parentGuid: this.parentGuid,
tags: this._newState.tags,
title: this._newState.title ?? this._originalState.title,
url: this._newState.uri ?? this._originalState.uri,
index: this._originalState.index,
}),
];
if (this._newState.keyword) {
transactions.push(previousResults =>
lazy.PlacesTransactions.EditKeyword({
guid: previousResults[0],
keyword: this._newState.keyword,
postData: this._postData,
})
);
}
let results = await lazy.PlacesTransactions.batch(
transactions,
"BookmarkState::createBookmark"
);
this._guid = results?.[0];
return this._guid;
}
/**
* Create a new folder.
*
* @returns {string} The folder's GUID.
*/
async _createFolder() {
this._guid = await lazy.PlacesTransactions.NewFolder({
parentGuid: this.parentGuid,
title: this._newState.title ?? this._originalState.title,
children: this._children,
index: this._originalState.index,
}).transact();
return this._guid;
}
get parentGuid() {
return this._newState.parentGuid ?? this._originalState.parentGuid;
}
/**
* Save() API function for bookmark.
*
* @returns {string} bookmark.guid
*/
async save() {
if (this._guid === lazy.PlacesUtils.bookmarks.unsavedGuid) {
return this._isFolder ? this._createFolder() : this._createBookmark();
}
if (!Object.keys(this._newState).length) {
return this._guid;
}
if (this._isTagContainer && this._newState.title) {
await lazy.PlacesTransactions.RenameTag({
oldTag: this._originalState.title,
tag: this._newState.title,
})
.transact()
.catch(console.error);
return this._guid;
}
let url = this._newState.uri || this._originalState.uri;
let transactions = [];
if (this._newState.uri) {
transactions.push(
lazy.PlacesTransactions.EditUrl({
guid: this._guid,
url,
})
);
}
for (const [key, value] of Object.entries(this._newState)) {
switch (key) {
case "title":
transactions.push(
lazy.PlacesTransactions.EditTitle({
guid: this._guid,
title: value,
})
);
break;
case "tags":
const newTags = value.filter(
tag => !this._originalState.tags.includes(tag)
);
const removedTags = this._originalState.tags.filter(
tag => !value.includes(tag)
);
if (newTags.length) {
transactions.push(
lazy.PlacesTransactions.Tag({
urls: this._bulkTaggingUrls || [url],
tags: newTags,
})
);
}
if (removedTags.length) {
transactions.push(
lazy.PlacesTransactions.Untag({
urls: this._bulkTaggingUrls || [url],
tags: removedTags,
})
);
}
break;
case "keyword":
transactions.push(
lazy.PlacesTransactions.EditKeyword({
guid: this._guid,
keyword: value,
postData: this._postData,
oldKeyword: this._originalState.keyword,
})
);
break;
case "parentGuid":
transactions.push(
lazy.PlacesTransactions.Move({
guid: this._guid,
newParentGuid: this._newState.parentGuid,
})
);
break;
}
}
if (transactions.length) {
await lazy.PlacesTransactions.batch(transactions, "BookmarkState::save");
}
this._originalState = { ...this._originalState, ...this._newState };
this._newState = {};
return this._guid;
}
}
export var PlacesUIUtils = {
BookmarkState,
_bookmarkToolbarTelemetryListening: false,
LAST_USED_FOLDERS_META_KEY: "bookmarks/lastusedfolders",
lastContextMenuTriggerNode: null,
// This allows to await for all the relevant bookmark changes to be applied
// when a bookmark dialog is closed. It is resolved to the bookmark guid,
// if a bookmark was created or modified.
lastBookmarkDialogDeferred: null,
/**
* Obfuscates a place: URL to use it in xulstore without the risk of
leaking browsing information. Uses md5 to hash the query string.
*
* @param {URL} url
* the URL for xulstore with place: key pairs.
* @returns {string} "place:[md5_hash]" hashed url
*/
obfuscateUrlForXulStore(url) {
if (!url.startsWith("place:")) {
throw new Error("Method must be used to only obfuscate place: uris!");
}
let urlNoProtocol = url.substring(url.indexOf(":") + 1);
let hashedURL = lazy.PlacesUtils.md5(urlNoProtocol);
return `place:${hashedURL}`;
},
/**
* Shows the bookmark dialog corresponding to the specified info.
*
* @param {object} aInfo
* Describes the item to be edited/added in the dialog.
* See documentation at the top of bookmarkProperties.js
* @param {DOMWindow} [aParentWindow]
* Owner window for the new dialog.
*
* @see documentation at the top of bookmarkProperties.js
* @returns {string} The guid of the item that was created or edited,
* undefined otherwise.
*/
async showBookmarkDialog(aInfo, aParentWindow = null) {
this.lastBookmarkDialogDeferred = Promise.withResolvers();
let features = "centerscreen,chrome,modal,resizable=no";
let bookmarkGuid;
if (!aParentWindow) {
aParentWindow = Services.wm.getMostRecentWindow(null);
}
if (aParentWindow.gDialogBox) {
await aParentWindow.gDialogBox.open(dialogURL, aInfo);
} else {
aParentWindow.openDialog(dialogURL, "", features, aInfo);
}
if (aInfo.bookmarkState) {
bookmarkGuid = await aInfo.bookmarkState.save();
this.lastBookmarkDialogDeferred.resolve(bookmarkGuid);
return bookmarkGuid;
}
bookmarkGuid = undefined;
this.lastBookmarkDialogDeferred.resolve(bookmarkGuid);
return bookmarkGuid;
},
/**
* Bookmarks one or more pages. If there is more than one, this will create
* the bookmarks in a new folder.
*
* @param {Array.<nsIURI>} URIList
* The list of URIs to bookmark.
* @param {Array.<string>} [hiddenRows]
* An array of rows to be hidden.
* @param {DOMWindow} [win]
* The window to use as the parent to display the bookmark dialog.
*/
async showBookmarkPagesDialog(URIList, hiddenRows = [], win = null) {
if (!URIList.length) {
return;
}
const bookmarkDialogInfo = { action: "add", hiddenRows };
if (URIList.length > 1) {
bookmarkDialogInfo.type = "folder";
bookmarkDialogInfo.URIList = URIList;
} else {
bookmarkDialogInfo.type = "bookmark";
bookmarkDialogInfo.title = URIList[0].title;
bookmarkDialogInfo.uri = URIList[0].uri;
}
await PlacesUIUtils.showBookmarkDialog(bookmarkDialogInfo, win);
},
/**
* set and fetch a favicon. Can only be used from the parent process.
*
* @param {object} browser
* The XUL browser element for which we're fetching a favicon.
* @param {Principal} principal
* The loading principal to use for the fetch.
* @param {URI} pageURI
* The page URI associated to this favicon load.
* @param {URI} uri
* The URI to fetch.
* @param {number} expiration
* An optional expiration time.
* @param {URI} iconURI
* An optional data: URI holding the icon's data.
*/
loadFavicon(
browser,
principal,
pageURI,
uri,
expiration = 0,
iconURI = null
) {
if (gInContentProcess) {
throw new Error("Can't track loads from within the child process!");
}
InternalFaviconLoader.loadFavicon(
browser,
principal,
pageURI,
uri,
expiration,
iconURI
);
},
/**
* Returns the closet ancestor places view for the given DOM node
*
* @param {DOMNode} aNode
* a DOM node
* @returns {DOMNode} the closest ancestor places view if exists, null otherwsie.
*/
getViewForNode: function PUIU_getViewForNode(aNode) {
let node = aNode;
if (Cu.isDeadWrapper(node)) {
return null;
}
if (node.localName == "panelview" && node._placesView) {
return node._placesView;
}
// The view for a <menu> of which its associated menupopup is a places
// view, is the menupopup.
if (
node.localName == "menu" &&
!node._placesNode &&
node.menupopup._placesView
) {
return node.menupopup._placesView;
}
while (Element.isInstance(node)) {
if (node._placesView) {
return node._placesView;
}
if (
node.localName == "tree" &&
node.getAttribute("is") == "places-tree"
) {
return node;
}
node = node.parentNode;
}
return null;
},
/**
* Returns the active PlacesController for a given command.
*
* @param {DOMWindow} win The window containing the affected view
* @param {string} command The command
* @returns {PlacesController} a places controller
*/
getControllerForCommand(win, command) {
// If we're building a context menu for a non-focusable view, for example
// a menupopup, we must return the view that triggered the context menu.
let popupNode = PlacesUIUtils.lastContextMenuTriggerNode;
if (popupNode) {
let isManaged = !!popupNode.closest("#managed-bookmarks");
if (isManaged) {
return this.managedBookmarksController;
}
let view = this.getViewForNode(popupNode);
if (view && view._contextMenuShown) {
return view.controllers.getControllerForCommand(command);
}
}
// When we're not building a context menu, only focusable views
// are possible. Thus, we can safely use the command dispatcher.
let controller =
win.top.document.commandDispatcher.getControllerForCommand(command);
return controller || null;
},
/**
* Update all the Places commands for the given window.
*
* @param {DOMWindow} win The window to update.
*/
updateCommands(win) {
// Get the controller for one of the places commands.
let controller = this.getControllerForCommand(win, "placesCmd_open");
for (let command of [
"placesCmd_open",
"placesCmd_open:window",
"placesCmd_open:privatewindow",
"placesCmd_open:tab",
"placesCmd_new:folder",
"placesCmd_new:bookmark",
"placesCmd_new:separator",
"placesCmd_show:info",
"placesCmd_reload",
"placesCmd_sortBy:name",
"placesCmd_cut",
"placesCmd_copy",
"placesCmd_paste",
"placesCmd_delete",
"placesCmd_showInFolder",
]) {
win.goSetCommandEnabled(
command,
controller && controller.isCommandEnabled(command)
);
}
},
/**
* Executes the given command on the currently active controller.
*
* @param {DOMWindow} win The window containing the affected view
* @param {string} command The command to execute
*/
doCommand(win, command) {
let controller = this.getControllerForCommand(win, command);
if (controller && controller.isCommandEnabled(command)) {
controller.doCommand(command);
}
},
/**
* By calling this before visiting an URL, the visit will be associated to a
* TRANSITION_TYPED transition (if there is no a referrer).
* This is used when visiting pages from the history menu, history sidebar,
* url bar, url autocomplete results, and history searches from the places
* organizer. If this is not called visits will be marked as
* TRANSITION_LINK.
*
* @param {string} aURL
* The URL to mark as typed.
*/
markPageAsTyped: function PUIU_markPageAsTyped(aURL) {
lazy.PlacesUtils.history.markPageAsTyped(
Services.uriFixup.getFixupURIInfo(aURL).preferredURI
);
},
/**
* By calling this before visiting an URL, the visit will be associated to a
* TRANSITION_BOOKMARK transition.
* This is used when visiting pages from the bookmarks menu,
* personal toolbar, and bookmarks from within the places organizer.
* If this is not called visits will be marked as TRANSITION_LINK.
*
* @param {string} aURL
* The URL to mark as TRANSITION_BOOKMARK.
*/
markPageAsFollowedBookmark: function PUIU_markPageAsFollowedBookmark(aURL) {
lazy.PlacesUtils.history.markPageAsFollowedBookmark(
Services.uriFixup.getFixupURIInfo(aURL).preferredURI
);
},
/**
* By calling this before visiting an URL, any visit in frames will be
* associated to a TRANSITION_FRAMED_LINK transition.
* This is actually used to distinguish user-initiated visits in frames
* so automatic visits can be correctly ignored.
*
* @param {string} aURL
* The URL to mark as TRANSITION_FRAMED_LINK.
*/
markPageAsFollowedLink: function PUIU_markPageAsFollowedLink(aURL) {
lazy.PlacesUtils.history.markPageAsFollowedLink(
Services.uriFixup.getFixupURIInfo(aURL).preferredURI
);
},
/**
* Sets the character-set for a page. The character set will not be saved
* if the window is determined to be a private browsing window.
*
* @param {string|URL|nsIURI} url The URL of the page to set the charset on.
* @param {string} charset character-set value.
* @param {DOMWindow} window The window that the charset is being set from.
* @returns {Promise}
*/
async setCharsetForPage(url, charset, window) {
if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
return;
}
// UTF-8 is the default. If we are passed the value then set it to null,
// to ensure any charset is removed from the database.
if (charset.toLowerCase() == "utf-8") {
charset = null;
}
await lazy.PlacesUtils.history.update({
url,
annotations: new Map([[lazy.PlacesUtils.CHARSET_ANNO, charset]]),
});
},
/**
* Allows opening of javascript/data URI only if the given node is
* bookmarked (see bug 224521).
*
* @param {object} aURINode
* a URI node
* @param {DOMWindow} aWindow
* a window on which a potential error alert is shown on.
* @returns {boolean} true if it's safe to open the node in the browser, false otherwise.
*
*/
checkURLSecurity: function PUIU_checkURLSecurity(aURINode, aWindow) {
if (lazy.PlacesUtils.nodeIsBookmark(aURINode)) {
return true;
}
var uri = Services.io.newURI(aURINode.uri);
if (uri.schemeIs("javascript") || uri.schemeIs("data")) {
const [title, errorStr] =
PlacesUIUtils.promptLocalization.formatValuesSync([
"places-error-title",
"places-load-js-data-url-error",
]);
Services.prompt.alert(aWindow, title, errorStr);
return false;
}
return true;
},
/**
* Check whether or not the given node represents a removable entry (either in
* history or in bookmarks).
*
* @param {object} aNode
* a node, except the root node of a query.
* @returns {boolean} true if the aNode represents a removable entry, false otherwise.
*/
canUserRemove(aNode) {
let parentNode = aNode.parent;
if (!parentNode) {
// canUserRemove doesn't accept root nodes.
return false;
}
// Is it a query pointing to one of the special root folders?
if (lazy.PlacesUtils.nodeIsQuery(parentNode)) {
if (lazy.PlacesUtils.nodeIsFolder(aNode)) {
let guid = lazy.PlacesUtils.getConcreteItemGuid(aNode);
// If the parent folder is not a folder, it must be a query, and so this node
// cannot be removed.
if (lazy.PlacesUtils.isRootItem(guid)) {
return false;
}
} else if (lazy.PlacesUtils.isVirtualLeftPaneItem(aNode.bookmarkGuid)) {
// If the item is a left-pane top-level item, it can't be removed.
return false;
}
}
// If it's not a bookmark, or it's child of a query, we can remove it.
if (aNode.itemId == -1 || lazy.PlacesUtils.nodeIsQuery(parentNode)) {
return true;
}
// Otherwise it has to be a child of an editable folder.
return !this.isFolderReadOnly(parentNode);
},
/**
* DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH
* TO GUIDS IS COMPLETE (BUG 1071511).
*
* Check whether or not the given Places node points to a folder which
* should not be modified by the user (i.e. its children should be unremovable
* and unmovable, new children should be disallowed, etc).
* These semantics are not inherited, meaning that read-only folder may
* contain editable items (for instance, the places root is read-only, but all
* of its direct children aren't).
*
* You should only pass folder nodes.
*
* @param {object} placesNode
* any folder result node.
* @throws if placesNode is not a folder result node or views is invalid.
* @returns {boolean} true if placesNode is a read-only folder, false otherwise.
*/
isFolderReadOnly(placesNode) {
if (
typeof placesNode != "object" ||
!lazy.PlacesUtils.nodeIsFolder(placesNode)
) {
throw new Error("invalid value for placesNode");
}
return (
lazy.PlacesUtils.getConcreteItemGuid(placesNode) ==
lazy.PlacesUtils.bookmarks.rootGuid
);
},
/**
* @param {Array<object>} aItemsToOpen
* needs to be an array of objects of the form:
* {uri: string, isBookmark: boolean}
* @param {object} aEvent
* The associated event triggering the open.
* @param {DOMWindow} aWindow
* The window associated with the event.
*/
openTabset(aItemsToOpen, aEvent, aWindow) {
if (!aItemsToOpen.length) {
return;
}
let browserWindow = getBrowserWindow(aWindow);
var urls = [];
let isPrivate =
browserWindow && lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow);
for (let item of aItemsToOpen) {
urls.push(item.uri);
if (isPrivate) {
continue;
}
if (item.isBookmark) {
this.markPageAsFollowedBookmark(item.uri);
} else {
this.markPageAsTyped(item.uri);
}
}
// whereToOpenLink doesn't return "window" when there's no browser window
// open (Bug 630255).
var where = browserWindow
? browserWindow.whereToOpenLink(aEvent, false, true)
: "window";
if (where == "window") {
// There is no browser window open, thus open a new one.
let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
let stringsToLoad = Cc["@mozilla.org/array;1"].createInstance(
Ci.nsIMutableArray
);
urls.forEach(url =>
stringsToLoad.appendElement(lazy.PlacesUtils.toISupportsString(url))
);
args.appendElement(stringsToLoad);
let features = "chrome,dialog=no,all";
if (isPrivate) {
features += ",private";
}
browserWindow = Services.ww.openWindow(
aWindow,
AppConstants.BROWSER_CHROME_URL,
null,
features,
args
);
return;
}
var loadInBackground = where == "tabshifted";
// For consistency, we want all the bookmarks to open in new tabs, instead
// of having one of them replace the currently focused tab. Hence we call
// loadTabs with aReplace set to false.
browserWindow.gBrowser.loadTabs(urls, {
inBackground: loadInBackground,
replace: false,
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
},
/**
* Loads a selected node's or nodes' URLs in tabs,
* warning the user when lots of URLs are being opened
*
* @param {object | Array} nodeOrNodes
* Contains the node or nodes that we're opening in tabs
* @param {event} event
* The DOM mouse/key event with modifier keys set that track the
* user's preferred destination window or tab.
* @param {object} view
* The current view that contains the node or nodes selected for
* opening
*/
openMultipleLinksInTabs(nodeOrNodes, event, view) {
let window = view.ownerWindow;
let urlsToOpen = [];
if (lazy.PlacesUtils.nodeIsContainer(nodeOrNodes)) {
urlsToOpen = lazy.PlacesUtils.getURLsForContainerNode(nodeOrNodes);
} else {
for (var i = 0; i < nodeOrNodes.length; i++) {
// Skip over separators and folders.
if (lazy.PlacesUtils.nodeIsURI(nodeOrNodes[i])) {
urlsToOpen.push({
uri: nodeOrNodes[i].uri,
isBookmark: lazy.PlacesUtils.nodeIsBookmark(nodeOrNodes[i]),
});
}
}
}
if (lazy.OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) {
if (window.updateTelemetry) {
window.updateTelemetry(urlsToOpen);
}
this.openTabset(urlsToOpen, event, window);
}
},
/**
* Loads the node's URL in the appropriate tab or window given the
* user's preference specified by modifier keys tracked by a
* DOM mouse/key event.
*
* @param {object} aNode
* An uri result node.
* @param {object} aEvent
* The DOM mouse/key event with modifier keys set that track the
* user's preferred destination window or tab.
*/
openNodeWithEvent: function PUIU_openNodeWithEvent(aNode, aEvent) {
let window = aEvent.target.ownerGlobal;
let where = window.whereToOpenLink(aEvent, false, true);
if (this.loadBookmarksInTabs && lazy.PlacesUtils.nodeIsBookmark(aNode)) {
if (where == "current" && !aNode.uri.startsWith("javascript:")) {
where = "tab";
}
let browserWindow = getBrowserWindow(window);
if (where == "tab" && browserWindow?.gBrowser.selectedTab.isEmpty) {
where = "current";
}
}
this._openNodeIn(aNode, where, window);
},
/**
* Loads the node's URL in the appropriate tab or window.
* see also URILoadingHelper's openWebLinkIn
*
* @param {object} aNode
* An uri result node.
* @param {string} aWhere
* Where to open the URL.
* @param {object} aView
* The associated view of the node being opened.
* @param {boolean} aPrivate
* True if the window being opened is private.
*/
openNodeIn: function PUIU_openNodeIn(aNode, aWhere, aView, aPrivate) {
let window = aView.ownerWindow;
this._openNodeIn(aNode, aWhere, window, { aPrivate });
},
_openNodeIn: function PUIU__openNodeIn(
aNode,
aWhere,
aWindow,
{ aPrivate = false, userContextId = 0 } = {}
) {
if (
aNode &&
lazy.PlacesUtils.nodeIsURI(aNode) &&
this.checkURLSecurity(aNode, aWindow)
) {
let isBookmark = lazy.PlacesUtils.nodeIsBookmark(aNode);
if (!lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow)) {
if (isBookmark) {
this.markPageAsFollowedBookmark(aNode.uri);
} else {
this.markPageAsTyped(aNode.uri);
}
} else {
// This is a targeted fix for bug 1792163, where it was discovered
// that if you open the Library from a Private Browsing window, and then
// use the "Open in New Window" context menu item to open a new window,
// that the window will open under the wrong icon on the Windows taskbar.
aPrivate = true;
}
const isJavaScriptURL = aNode.uri.startsWith("javascript:");
aWindow.openTrustedLinkIn(aNode.uri, aWhere, {
allowPopups: isJavaScriptURL,
inBackground: this.loadBookmarksInBackground,
allowInheritPrincipal: isJavaScriptURL,
private: aPrivate,
userContextId,
});
if (aWindow.updateTelemetry) {
aWindow.updateTelemetry([aNode]);
}
}
},
/**
* Helper for guessing scheme from an url string.
* Used to avoid nsIURI overhead in frequently called UI functions. This is not
* supposed be perfect, so use it only for UI purposes.
*
* @param {string} href The url to guess the scheme from.
* @returns {string} guessed scheme for this url string.
*/
guessUrlSchemeForUI(href) {
return href.substr(0, href.indexOf(":"));
},
getBestTitle: function PUIU_getBestTitle(aNode, aDoNotCutTitle) {
var title;
if (!aNode.title && lazy.PlacesUtils.nodeIsURI(aNode)) {
// if node title is empty, try to set the label using host and filename
// Services.io.newURI will throw if aNode.uri is not a valid URI
try {
var uri = Services.io.newURI(aNode.uri);
var host = uri.host;
var fileName = uri.QueryInterface(Ci.nsIURL).fileName;
// if fileName is empty, use path to distinguish labels
if (aDoNotCutTitle) {
title = host + uri.pathQueryRef;
} else {
title =
host +
(fileName
? (host ? "/" + this.ellipsis + "/" : "") + fileName
: uri.pathQueryRef);
}
} catch (e) {
// Use (no title) for non-standard URIs (data:, javascript:, ...)
title = "";
}
} else {
title = aNode.title;
}
return title || this.promptLocalization.formatValueSync("places-no-title");
},
shouldShowTabsFromOtherComputersMenuitem() {
let weaveOK =
lazy.Weave.Status.checkSetup() != lazy.CLIENT_NOT_CONFIGURED &&
lazy.Weave.Svc.PrefBranch.getCharPref("firstSync", "") != "notReady";
return weaveOK;
},
/**
* WARNING TO ADDON AUTHORS: DO NOT USE THIS METHOD. IT'S LIKELY TO BE REMOVED IN A
* FUTURE RELEASE.
*
* Checks if a place: href represents a folder shortcut.
*
* @param {string} queryString
* the query string to check (a place: href)
* @returns {boolean} whether or not queryString represents a folder shortcut.
* @throws if queryString is malformed.
*/
isFolderShortcutQueryString(queryString) {
// Based on GetSimpleBookmarksQueryFolder in nsNavHistory.cpp.
let query = {},
options = {};
lazy.PlacesUtils.history.queryStringToQuery(queryString, query, options);
query = query.value;
options = options.value;
return (
query.folderCount == 1 &&
!query.hasBeginTime &&
!query.hasEndTime &&
!query.hasDomain &&
!query.hasURI &&
!query.hasSearchTerms &&
!query.tags.length == 0 &&
options.maxResults == 0
);
},
/**
* Helpers for consumers of editBookmarkOverlay which don't have a node as their input.
*
* Given a bookmark object for either a url bookmark or a folder, returned by
* Bookmarks.fetch (see Bookmark.jsm), this creates a node-like object suitable for
* initialising the edit overlay with it.
*
* @param {object} aFetchInfo
* a bookmark object returned by Bookmarks.fetch.
* @returns {object} a node-like object suitable for initialising editBookmarkOverlay.
* @throws if aFetchInfo is representing a separator.
*/
async promiseNodeLikeFromFetchInfo(aFetchInfo) {
if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR) {
throw new Error("promiseNodeLike doesn't support separators");
}
let parent = {
bookmarkGuid: aFetchInfo.parentGuid,
type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
};
return Object.freeze({
bookmarkGuid: aFetchInfo.guid,
title: aFetchInfo.title,
uri: aFetchInfo.url !== undefined ? aFetchInfo.url.href : "",
get type() {
if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_FOLDER) {
return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER;
}
if (!this.uri.length) {
throw new Error("Unexpected item type");
}
if (/^place:/.test(this.uri)) {
if (this.isFolderShortcutQueryString(this.uri)) {
return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;
}
return Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY;
}
return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
},
get parent() {
return parent;
},
});
},
/**
* This function wraps potentially large places transaction operations
* with batch notifications to the result node, hence switching the views
* to batch mode. If resultNode is not supplied, the function will
* pass-through to functionToWrap.
*
* @param {nsINavHistoryResult} resultNode The result node to turn on batching.
* @param {number} itemsBeingChanged The count of items being changed. If the
* count is lower than a threshold, then
* batching won't be set.
* @param {Function} functionToWrap The function to
* @returns {object} forwards the functionToWrap return value.
*/
async batchUpdatesForNode(resultNode, itemsBeingChanged, functionToWrap) {
if (!resultNode) {
return functionToWrap();
}
if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) {
resultNode.onBeginUpdateBatch();
}
try {
return await functionToWrap();
} finally {
if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) {
resultNode.onEndUpdateBatch();
}
}
},
/**
* Processes a set of transfer items that have been dropped or pasted.
* Batching will be applied where necessary.
*
* @param {Array} items A list of unwrapped nodes to process.
* @param {object} insertionPoint The requested point for insertion.
* @param {boolean} doCopy Set to true to copy the items, false will move them
* if possible.
* @param {object} view The view that should be used for batching.
* @returns {Array} Returns an empty array when the insertion point is a tag, else
* returns an array of copied or moved guids.
*/
async handleTransferItems(items, insertionPoint, doCopy, view) {
let transactions;
let itemsCount;
if (insertionPoint.isTag) {
let urls = items.filter(item => "uri" in item).map(item => item.uri);
itemsCount = urls.length;
transactions = [
lazy.PlacesTransactions.Tag({ urls, tag: insertionPoint.tagName }),
];
} else {
let insertionIndex = await insertionPoint.getIndex();
itemsCount = items.length;
transactions = getTransactionsForTransferItems(
items,
insertionIndex,
insertionPoint.guid,
!doCopy
);
}
// Check if we actually have something to add, if we don't it probably wasn't
// valid, or it was moving to the same location, so just ignore it.
if (!transactions.length) {
return [];
}
let guidsToSelect = await this.batchUpdatesForNode(
getResultForBatching(view),
itemsCount,
async () =>
lazy.PlacesTransactions.batch(transactions, "handleTransferItems")
);
// If we're inserting into a tag, we don't get the resulting guids.
return insertionPoint.isTag ? [] : guidsToSelect.flat();
},
onSidebarTreeClick(event) {
// right-clicks are not handled here
if (event.button == 2) {
return;
}
let tree = event.target.parentNode;
let cell = tree.getCellAt(event.clientX, event.clientY);
if (cell.row == -1 || cell.childElt == "twisty") {
return;
}
// getCoordsForCellItem returns the x coordinate in logical coordinates
// (i.e., starting from the left and right sides in LTR and RTL modes,
// respectively.) Therefore, we make sure to exclude the blank area
// before the tree item icon (that is, to the left or right of it in
// LTR and RTL modes, respectively) from the click target area.
let win = tree.ownerGlobal;
let rect = tree.getCoordsForCellItem(cell.row, cell.col, "image");
let isRTL = win.getComputedStyle(tree).direction == "rtl";
let mouseInGutter = isRTL ? event.clientX > rect.x : event.clientX < rect.x;
let metaKey =
AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey;
let modifKey = metaKey || event.shiftKey;
let isContainer = tree.view.isContainer(cell.row);
let openInTabs =
isContainer &&
(event.button == 1 || (event.button == 0 && modifKey)) &&
lazy.PlacesUtils.hasChildURIs(tree.view.nodeForTreeIndex(cell.row));
if (event.button == 0 && isContainer && !openInTabs) {
tree.view.toggleOpenState(cell.row);
} else if (
!mouseInGutter &&
openInTabs &&
event.originalTarget.localName == "treechildren"
) {
tree.view.selection.select(cell.row);
this.openMultipleLinksInTabs(tree.selectedNode, event, tree);
} else if (
!mouseInGutter &&
!isContainer &&
event.originalTarget.localName == "treechildren"
) {
// Clear all other selection since we're loading a link now. We must
// do this *before* attempting to load the link since openURL uses
// selection as an indication of which link to load.
tree.view.selection.select(cell.row);
this.openNodeWithEvent(tree.selectedNode, event);
}
},
onSidebarTreeKeyPress(event) {
let node = event.target.selectedNode;
if (node) {
if (event.keyCode == event.DOM_VK_RETURN) {
PlacesUIUtils.openNodeWithEvent(node, event);
}
}
},
/**
* The following function displays the URL of a node that is being
* hovered over.
*
* @param {object} event
* The event that triggered the hover.
*/
onSidebarTreeMouseMove(event) {
let treechildren = event.target;
if (treechildren.localName != "treechildren") {
return;
}
let tree = treechildren.parentNode;
let cell = tree.getCellAt(event.clientX, event.clientY);
// cell.row is -1 when the mouse is hovering an empty area within the tree.
// To avoid showing a URL from a previously hovered node for a currently
// hovered non-url node, we must clear the moused-over URL in these cases.
if (cell.row != -1) {
let node = tree.view.nodeForTreeIndex(cell.row);
if (lazy.PlacesUtils.nodeIsURI(node)) {
this.setMouseoverURL(node.uri, tree.ownerGlobal);
return;
}
}
this.setMouseoverURL("", tree.ownerGlobal);
},
setMouseoverURL(url, win) {
// When the browser window is closed with an open sidebar, the sidebar
// unload event happens after the browser's one. In this case
// top.XULBrowserWindow has been nullified already.
if (win.top.XULBrowserWindow) {
win.top.XULBrowserWindow.setOverLink(url);
}
},
/**
* Uncollapses PersonalToolbar if its collapsed status is not
* persisted, and user customized it or changed default bookmarks.
*
* If the user does not have a persisted value for the toolbar's
* "collapsed" attribute, try to determine whether it's customized.
*
* @param {boolean} aForceVisible Set to true to ignore if the user had
* previously collapsed the toolbar manually.
*/
NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE: 3,
async maybeToggleBookmarkToolbarVisibility(aForceVisible = false) {
const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL;
let xulStore = Services.xulStore;
if (
aForceVisible ||
!xulStore.hasValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed")
) {
function uncollapseToolbar() {
Services.obs.notifyObservers(
null,
"browser-set-toolbar-visibility",
JSON.stringify([lazy.CustomizableUI.AREA_BOOKMARKS, "true"])
);
}
// We consider the toolbar customized if it has more than
// NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE children, or if it has a persisted
// currentset value.
let toolbarIsCustomized = xulStore.hasValue(
BROWSER_DOCURL,
"PersonalToolbar",
"currentset"
);
if (aForceVisible || toolbarIsCustomized) {
uncollapseToolbar();
return;
}
let numBookmarksOnToolbar = (
await lazy.PlacesUtils.bookmarks.fetch(
lazy.PlacesUtils.bookmarks.toolbarGuid
)
).childCount;
if (numBookmarksOnToolbar > this.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE) {
uncollapseToolbar();
}
}
},
async managedPlacesContextShowing(event) {
let menupopup = event.target;
let document = menupopup.ownerDocument;
let window = menupopup.ownerGlobal;
// We need to populate the submenus in order to have information
// to show the context menu.
if (
menupopup.triggerNode.id == "managed-bookmarks" &&
!menupopup.triggerNode.menupopup.hasAttribute("hasbeenopened")
) {
await window.PlacesToolbarHelper.populateManagedBookmarks(
menupopup.triggerNode.menupopup
);
}
let linkItems = [
"placesContext_open:newtab",
"placesContext_open:newwindow",
"placesContext_openSeparator",
"placesContext_copy",
];
// Hide everything. We'll unhide the things we need.
Array.from(menupopup.children).forEach(function (child) {
child.hidden = true;
});
// Store triggerNode in controller for checking if commands are enabled
this.managedBookmarksController.triggerNode = menupopup.triggerNode;
// Container in this context means a folder.
let isFolder = menupopup.triggerNode.hasAttribute("container");
if (isFolder) {
// Disable the openContainerInTabs menuitem if there
// are no children of the menu that have links.
let openContainerInTabs_menuitem = document.getElementById(
"placesContext_openContainer:tabs"
);
let menuitems = menupopup.triggerNode.menupopup.children;
let openContainerInTabs = Array.from(menuitems).some(
menuitem => menuitem.link
);
openContainerInTabs_menuitem.disabled = !openContainerInTabs;
openContainerInTabs_menuitem.hidden = false;
} else {
linkItems.forEach(id => (document.getElementById(id).hidden = false));
document.getElementById("placesContext_open:newprivatewindow").hidden =
lazy.PrivateBrowsingUtils.isWindowPrivate(window) ||
!lazy.PrivateBrowsingUtils.enabled;
document.getElementById("placesContext_open:newcontainertab").hidden =
!Services.prefs.getBoolPref("privacy.userContext.enabled", false);
}
event.target.ownerGlobal.updateCommands("places");
},
placesContextShowing(event) {
let menupopup = event.target;
if (menupopup.id != "placesContext") {
// Ignore any popupshowing events from submenus
return true;
}
PlacesUIUtils.lastContextMenuTriggerNode = menupopup.triggerNode;
if (Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs", false)) {
menupopup.ownerDocument
.getElementById("placesContext_open")
.removeAttribute("default");
menupopup.ownerDocument
.getElementById("placesContext_open:newtab")
.setAttribute("default", "true");
// else clause ensures correct behavior if pref is repeatedly toggled
} else {
menupopup.ownerDocument
.getElementById("placesContext_open:newtab")
.removeAttribute("default");
menupopup.ownerDocument
.getElementById("placesContext_open")
.setAttribute("default", "true");
}
let isManaged = !!menupopup.triggerNode.closest("#managed-bookmarks");
if (isManaged) {
this.managedPlacesContextShowing(event);
return true;
}
menupopup._view = this.getViewForNode(menupopup.triggerNode);
if (!menupopup._view) {
// This can happen if we try to invoke the context menu on
// an uninitialized places toolbar. Just bail out:
event.preventDefault();
return false;
}
if (!this.openInTabClosesMenu) {
menupopup.ownerDocument
.getElementById("placesContext_open:newtab")
.setAttribute("closemenu", "single");
}
return menupopup._view.buildContextMenu(menupopup);
},
placesContextHiding(event) {
let menupopup = event.target;
if (menupopup._view) {
menupopup._view.destroyContextMenu();
}
if (menupopup.id == "placesContext") {
PlacesUIUtils.lastContextMenuTriggerNode = null;