/* -*- 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", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.sys.mjs", PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", Weave: "resource://services-sync/main.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.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, 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 dialogURL = "chrome://browser/content/places/bookmarkProperties.xhtml"; 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.} URIList * The list of URIs to bookmark. * @param {Array.} [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 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} 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.sys.mjs), 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; } }, createContainerTabMenu(event) { let window = event.target.ownerGlobal; return window.createUserContextMenu(event, { isContextMenu: true }); }, openInContainerTab(event) { let userContextId = parseInt( event.target.getAttribute("data-usercontextid") ); let triggerNode = this.lastContextMenuTriggerNode; let isManaged = !!triggerNode?.closest("#managed-bookmarks"); if (isManaged) { let window = triggerNode.ownerGlobal; window.openTrustedLinkIn(triggerNode.link, "tab", { userContextId }); return; } let view = this.getViewForNode(triggerNode); this._openNodeIn(view.selectedNode, "tab", view.ownerWindow, { userContextId, }); }, openSelectionInTabs(event) { let isManaged = !!event.target.parentNode.triggerNode.closest("#managed-bookmarks"); let controller; if (isManaged) { controller = this.managedBookmarksController; } else { controller = PlacesUIUtils.getViewForNode( PlacesUIUtils.lastContextMenuTriggerNode ).controller; } controller.openSelectionInTabs(event); }, managedBookmarksController: { triggerNode: null, openSelectionInTabs(event) { let window = event.target.ownerGlobal; let menuitems = event.target.parentNode.triggerNode.menupopup.children; let items = []; for (let i = 0; i < menuitems.length; i++) { if (menuitems[i].link) { let item = {}; item.uri = menuitems[i].link; item.isBookmark = true; items.push(item); } } PlacesUIUtils.openTabset(items, event, window); }, isCommandEnabled(command) { switch (command) { case "placesCmd_copy": case "placesCmd_open:window": case "placesCmd_open:privatewindow": case "placesCmd_open:tab": { return true; } } return false; }, doCommand(command) { let window = this.triggerNode.ownerGlobal; switch (command) { case "placesCmd_copy": // This is a little hacky, but there is a lot of code in Places that handles // clipboard stuff, so it's easier to reuse. let node = {}; node.type = 0; node.title = this.triggerNode.label; node.uri = this.triggerNode.link; // Copied from _populateClipboard in controller.js // This order is _important_! It controls how this and other applications // select data to be inserted based on type. let contents = [ { type: lazy.PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, { type: lazy.PlacesUtils.TYPE_HTML, entries: [] }, { type: lazy.PlacesUtils.TYPE_PLAINTEXT, entries: [] }, ]; contents.forEach(function (content) { content.entries.push(lazy.PlacesUtils.wrapNode(node, content.type)); }); let xferable = Cc[ "@mozilla.org/widget/transferable;1" ].createInstance(Ci.nsITransferable); xferable.init(null); function addData(type, data) { xferable.addDataFlavor(type); xferable.setTransferData( type, lazy.PlacesUtils.toISupportsString(data) ); } contents.forEach(function (content) { addData(content.type, content.entries.join(lazy.PlacesUtils.endl)); }); Services.clipboard.setData( xferable, null, Ci.nsIClipboard.kGlobalClipboard ); break; case "placesCmd_open:privatewindow": window.openTrustedLinkIn(this.triggerNode.link, "window", { private: true, }); break; case "placesCmd_open:window": window.openTrustedLinkIn(this.triggerNode.link, "window", { private: false, }); break; case "placesCmd_open:tab": { window.openTrustedLinkIn(this.triggerNode.link, "tab"); } } }, }, async maybeAddImportButton() { if (!Services.policies.isAllowed("profileImport")) { return; } let numberOfBookmarks = await lazy.PlacesUtils.withConnectionWrapper( "PlacesUIUtils: maybeAddImportButton", async db => { let rows = await db.execute( `SELECT COUNT(*) as n FROM moz_bookmarks b JOIN moz_bookmarks p ON p.id = b.parent WHERE p.guid = :guid`, { guid: lazy.PlacesUtils.bookmarks.toolbarGuid } ); return rows[0].getResultByName("n"); } ).catch(e => { // We want to report errors, but we still want to add the button then: console.error(e); return 0; }); if (numberOfBookmarks < 3) { lazy.CustomizableUI.addWidgetToArea( "import-button", lazy.CustomizableUI.AREA_BOOKMARKS, 0 ); Services.prefs.setBoolPref("browser.bookmarks.addedImportButton", true); this.removeImportButtonWhenImportSucceeds(); } }, removeImportButtonWhenImportSucceeds() { // If the user (re)moved the button, clear the pref and stop worrying about // moving the item. let placement = lazy.CustomizableUI.getPlacementOfWidget("import-button"); if (placement?.area != lazy.CustomizableUI.AREA_BOOKMARKS) { Services.prefs.clearUserPref("browser.bookmarks.addedImportButton"); return; } // Otherwise, wait for a successful migration: let obs = (subject, topic, data) => { if ( data == lazy.MigrationUtils.resourceTypes.BOOKMARKS && lazy.MigrationUtils.getImportedCount("bookmarks") > 0 ) { lazy.CustomizableUI.removeWidgetFromArea("import-button"); Services.prefs.clearUserPref("browser.bookmarks.addedImportButton"); Services.obs.removeObserver(obs, "Migration:ItemAfterMigrate"); Services.obs.removeObserver(obs, "Migration:ItemError"); } }; Services.obs.addObserver(obs, "Migration:ItemAfterMigrate"); Services.obs.addObserver(obs, "Migration:ItemError"); }, /** * Tries to initiate a speculative connection to a given url. This is not * infallible, if a speculative connection cannot be initialized, it will be a * no-op. * * @param {nsIURI|URL|string} url entity to initiate * a speculative connection for. * @param {window} window the window from where the connection is initialized. */ setupSpeculativeConnection(url, window) { if ( !Services.prefs.getBoolPref( "browser.places.speculativeConnect.enabled", true ) ) { return; } if (!url.startsWith("http")) { return; } try { let uri = url instanceof Ci.nsIURI ? url : Services.io.newURI(url); Services.io.speculativeConnect( uri, window.gBrowser.contentPrincipal, null, false ); } catch (ex) { // Can't setup speculative connection for this url, just ignore it. } }, /** * Generates a cached-favicon: link for an icon URL, that will allow to fetch * the icon from the local favicons cache, rather than from the network. * If the icon URL is invalid, fallbacks to the default favicon URL. * * @param {string} icon The url of the icon to load from local cache. * @returns {string} a "cached-favicon:" prefixed URL, unless the original * URL protocol refers to a local resource, then it will just pass-through * unchanged. */ getImageURL(icon) { // don't initiate a connection just to fetch a favicon (see bug 467828) try { return lazy.PlacesUtils.favicons.getFaviconLinkForIcon( Services.io.newURI(icon) ).spec; } catch (ex) {} return lazy.PlacesUtils.favicons.defaultFavicon.spec; }, /** * Determines the string indexes where titles differ from similar titles (where * the first n characters are the same) in the provided list of items, and * adds that into the item. * * This assumes the titles will be displayed along the lines of * `Start of title ... place where differs` the index would be reference * the `p` here. * * @param {object[]} candidates * An array of candidates to modify. The candidates should have a `title` * property which should be a string or null. * The order of the array does not matter. The objects are modified * in-place. * If a difference to other similar titles is found then a * `titleDifferentIndex` property will be inserted into all similar * candidates with the index of the start of the difference. */ insertTitleStartDiffs(candidates) { function findStartDifference(a, b) { let i; // We already know the start is the same, so skip that part. for (i = PlacesUIUtils.similarTitlesMinChars; i < a.length; i++) { if (a[i] != b[i]) { return i; } } if (b.length > i) { return i; } // They are the same. return -1; } let longTitles = new Map(); for (let candidate of candidates) { // Title is too short for us to care about, simply continue. if ( !candidate.title || candidate.title.length < this.similarTitlesMinChars ) { continue; } let titleBeginning = candidate.title.slice(0, this.similarTitlesMinChars); let matches = longTitles.get(titleBeginning); if (matches) { for (let match of matches) { let startDiff = findStartDifference(candidate.title, match.title); if (startDiff > 0) { candidate.titleDifferentIndex = startDiff; // If we have an existing difference index for the match, move // it forward if this one is earlier in the string. if ( !("titleDifferentIndex" in match) || match.titleDifferentIndex > startDiff ) { match.titleDifferentIndex = startDiff; } } } matches.push(candidate); } else { longTitles.set(titleBeginning, [candidate]); } } }, }; /** * Promise used by the toolbar view browser-places to determine whether we * can start loading its content (which involves IO, and so is postponed * during startup). */ PlacesUIUtils.canLoadToolbarContentPromise = new Promise(resolve => { PlacesUIUtils.unblockToolbars = resolve; }); // These are lazy getters to avoid importing PlacesUtils immediately. ChromeUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => { return [ lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, lazy.PlacesUtils.TYPE_X_MOZ_PLACE, ]; }); ChromeUtils.defineLazyGetter(PlacesUIUtils, "URI_FLAVORS", () => { return [ lazy.PlacesUtils.TYPE_X_MOZ_URL, TAB_DROP_TYPE, lazy.PlacesUtils.TYPE_PLAINTEXT, ]; }); ChromeUtils.defineLazyGetter(PlacesUIUtils, "SUPPORTED_FLAVORS", () => { return [...PlacesUIUtils.PLACES_FLAVORS, ...PlacesUIUtils.URI_FLAVORS]; }); ChromeUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function () { return Services.prefs.getComplexValue( "intl.ellipsis", Ci.nsIPrefLocalizedString ).data; }); ChromeUtils.defineLazyGetter(PlacesUIUtils, "promptLocalization", () => { return new Localization( ["browser/placesPrompts.ftl", "branding/brand.ftl"], true ); }); XPCOMUtils.defineLazyPreferenceGetter( PlacesUIUtils, "similarTitlesMinChars", "browser.places.similarTitlesMinChars", 20 ); XPCOMUtils.defineLazyPreferenceGetter( PlacesUIUtils, "loadBookmarksInBackground", "browser.tabs.loadBookmarksInBackground", false ); XPCOMUtils.defineLazyPreferenceGetter( PlacesUIUtils, "loadBookmarksInTabs", "browser.tabs.loadBookmarksInTabs", false ); XPCOMUtils.defineLazyPreferenceGetter( PlacesUIUtils, "openInTabClosesMenu", "browser.bookmarks.openInTabClosesMenu", false ); XPCOMUtils.defineLazyPreferenceGetter( PlacesUIUtils, "maxRecentFolders", "browser.bookmarks.editDialog.maxRecentFolders", 7 ); XPCOMUtils.defineLazyPreferenceGetter( PlacesUIUtils, "defaultParentGuid", "browser.bookmarks.defaultLocation", "", // Avoid eagerly loading PlacesUtils. null, async prefValue => { if (!prefValue) { return lazy.PlacesUtils.bookmarks.toolbarGuid; } if (["toolbar", "menu", "unfiled"].includes(prefValue)) { return lazy.PlacesUtils.bookmarks[prefValue + "Guid"]; } try { return await lazy.PlacesUtils.bookmarks .fetch({ guid: prefValue }) .then(bm => bm.guid); } catch (ex) { // The guid may have an invalid format. return lazy.PlacesUtils.bookmarks.toolbarGuid; } } ); /** * Determines if an unwrapped node can be moved. * * @param {object} unwrappedNode * A node unwrapped by PlacesUtils.unwrapNodes(). * @returns {boolean} True if the node can be moved, false otherwise. */ function canMoveUnwrappedNode(unwrappedNode) { if ( (unwrappedNode.concreteGuid && lazy.PlacesUtils.isRootItem(unwrappedNode.concreteGuid)) || (unwrappedNode.guid && lazy.PlacesUtils.isRootItem(unwrappedNode.guid)) ) { return false; } let parentGuid = unwrappedNode.parentGuid; if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) { return false; } return true; } /** * This gets the most appropriate item for using for batching. In the case of multiple * views being related, the method returns the most expensive result to batch. * For example, if it detects the left-hand library pane, then it will look for * and return the reference to the right-hand pane. * * @param {object} viewOrElement The item to check. * @returns {object} Will return the best result node to batch, or null * if one could not be found. */ function getResultForBatching(viewOrElement) { if ( viewOrElement && Element.isInstance(viewOrElement) && viewOrElement.id === "placesList" ) { // Note: fall back to the existing item if we can't find the right-hane pane. viewOrElement = viewOrElement.ownerDocument.getElementById("placeContent") || viewOrElement; } if (viewOrElement && viewOrElement.result) { return viewOrElement.result; } return null; } /** * Processes a set of transfer items and returns transactions to insert or * move them. * * @param {Array} items A list of unwrapped nodes to get transactions for. * @param {number} insertionIndex The requested index for insertion. * @param {string} insertionParentGuid The guid of the parent folder to insert * or move the items to. * @param {boolean} doMove Set to true to MOVE the items if possible, false will * copy them. * @returns {Array} Returns an array of created PlacesTransactions. */ function getTransactionsForTransferItems( items, insertionIndex, insertionParentGuid, doMove ) { let canMove = true; for (let item of items) { if (!PlacesUIUtils.SUPPORTED_FLAVORS.includes(item.type)) { throw new Error(`Unsupported '${item.type}' data type`); } // Work out if this is data from the same app session we're running in. if ( !("instanceId" in item) || item.instanceId != lazy.PlacesUtils.instanceId ) { if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { throw new Error( "Can't copy a container from a legacy-transactions build" ); } // Only log if this is one of "our" types as external items, e.g. drag from // url bar to toolbar, shouldn't complain. if (PlacesUIUtils.PLACES_FLAVORS.includes(item.type)) { console.error( "Tried to move an unmovable Places " + "node, reverting to a copy operation." ); } // We can never move from an external copy. canMove = false; } if (doMove && canMove) { canMove = canMoveUnwrappedNode(item); } } if (doMove && !canMove) { doMove = false; } if (doMove) { // Move is simple, we pass the transaction a list of GUIDs and where to move // them to. return [ lazy.PlacesTransactions.Move({ guids: items.map(item => item.itemGuid), newParentGuid: insertionParentGuid, newIndex: insertionIndex, }), ]; } return getTransactionsForCopy(items, insertionIndex, insertionParentGuid); } /** * Processes a set of transfer items and returns an array of transactions. * * @param {Array} items A list of unwrapped nodes to get transactions for. * @param {number} insertionIndex The requested index for insertion. * @param {string} insertionParentGuid The guid of the parent folder to insert * or move the items to. * @returns {Array} Returns an array of created PlacesTransactions. */ function getTransactionsForCopy(items, insertionIndex, insertionParentGuid) { let transactions = []; let index = insertionIndex; for (let item of items) { let transaction; let guid = item.itemGuid; if ( PlacesUIUtils.PLACES_FLAVORS.includes(item.type) && // For anything that is comming from within this session, we do a // direct copy, otherwise we fallback and form a new item below. "instanceId" in item && item.instanceId == lazy.PlacesUtils.instanceId && // If the Item doesn't have a guid, this could be a virtual tag query or // other item, so fallback to inserting a new bookmark with the URI. guid && // For virtual root items, we fallback to creating a new bookmark, as // we want a shortcut to be created, not a full tree copy. !lazy.PlacesUtils.bookmarks.isVirtualRootItem(guid) && !lazy.PlacesUtils.isVirtualLeftPaneItem(guid) ) { transaction = lazy.PlacesTransactions.Copy({ guid, newIndex: index, newParentGuid: insertionParentGuid, }); } else if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { transaction = lazy.PlacesTransactions.NewSeparator({ index, parentGuid: insertionParentGuid, }); } else { let title = item.type != lazy.PlacesUtils.TYPE_PLAINTEXT ? item.title : item.uri; transaction = lazy.PlacesTransactions.NewBookmark({ index, parentGuid: insertionParentGuid, title, url: item.uri, }); } transactions.push(transaction); if (index != -1) { index++; } } return transactions; } function getBrowserWindow(aWindow) { // Prefer the caller window if it's a browser window, otherwise use // the top browser window. return aWindow && aWindow.document.documentElement.getAttribute("windowtype") == "navigator:browser" ? aWindow : lazy.BrowserWindowTracker.getTopWindow(); }