Tear off tab with SDK

Hi everyone!

I managed to login here only with Firefox.

I’ve spent a lot of time digging into SDK sources and trying to make it work (the setWindow() thing), it worked till FF 43.
It looks like we have to invest more time and energy to make it work properly, and still have to use low-level API, which is not guaranteed to work in newer versions of FF.
WebExtensions are coming soon (or later), which would fix this issue.
I’m wandering, is it worth the effort to somehow temporarily fix this issue?
I’m going to give it one last try…

1 Like

I’ve got your code here, @DUzun. (and I hereby MPL 2.0 it). Have you made any progress with your last try?

///////////  monkey-patch (model-side) Tab objects to be better ////////////////

/* Tab.detach(): tear off this tab and spawn a new window just for it.
 *
 * If the tab is the only one on its window nothing happens.
 * Private browsing is preserved: if this.window is a private browsing window, so is will the new window be.
 */
require("sdk/tabs/tab").Tab.prototype.detach = function() {
        // ((the single-tab check is handled by replaceTabWithWindow, so we don't need to do it))
        // ((as is the preservation of private browsing ))
        // ((really this is just a Jetpack SDK-friendly wrapper for XUL's replaceTabWithWindow())
        viewFor(this.window).gBrowser.replaceTabWithWindow(viewFor(this));
}

/* Tab.setWindow(window): Move tab to the given window.
 *
 * window should be a 'high level' or 'model' window object from the https://developer.mozilla.org/en-US/Add-ons/SDK/High-Level_APIs/windows module.
 * If window is null, a new window is created.
 *
 * if given, index specifies where in the list of tabs to insert. By default, insertion is done at the end. (XXX not implemented)
 *
 * precondition: unless it's null, window has passed the 'open' state
 *   That is, you cannot call this on a newly created window.
 *
 *   Incorrect:
 *   > tab.setWindow(windows.open("about:blank"));
 *   ( causes TypeError: viewFor(...).gBrowser is undefined )
 *
 *   Correct:
 *   > let win = windows.open("about:blank");
 *   > win.on('on', function() {
 *   >   tab.setWindow(win)
 *   > }));
 *
 * XXX how does this interact with private browsing? This could be bad...
 * XXX this doesn't handle the 'index' argument.
 * XXX write a similar method attached to the Window prototype: window.adopt(tab)
 */
require("sdk/tabs/tab").Tab.prototype.setWindow = function(window, index = -1) {
        console.log("index = " + index);
        if(window) {
                viewFor(window).gBrowser.tabContainer.appendChild(viewFor(this));
        } else {
                this.detach();
        }
}

It turns out it really is that simple. No need to spawn windows and swap docShells or anything: just append it to the .tabbrowser-tabs XUL element on the new window. Firefox recognizes that the tab has been moved and removes it from the original window – though that’s not promised anywhere in the docs, so maybe it’s not safe to rely upon.

1 Like

I figured this out by reading as much of the gBrowser source code as I could. Here’s gBrowser.addTab():

[object XULElement].addTab = function addTab(aURI, aReferrerURI, aCharset, aPostData, aOwner, aAllowThirdPartyFixup) {

          
            const NS_XUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
            var aReferrerPolicy;
            var aFromExternal;
            var aRelatedToCurrent;
            var aSkipAnimation;
            var aAllowMixedContent;
            var aForceNotRemote;
            var aNoReferrer;
            var aUserContextId;
            if (arguments.length == 2 &&
                typeof arguments[1] == "object" &&
                !(arguments[1] instanceof Ci.nsIURI)) {
              let params = arguments[1];
              aReferrerURI          = params.referrerURI;
              aReferrerPolicy       = params.referrerPolicy;
              aCharset              = params.charset;
              aPostData             = params.postData;
              aOwner                = params.ownerTab;
              aAllowThirdPartyFixup = params.allowThirdPartyFixup;
              aFromExternal         = params.fromExternal;
              aRelatedToCurrent     = params.relatedToCurrent;
              aSkipAnimation        = params.skipAnimation;
              aAllowMixedContent    = params.allowMixedContent;
              aForceNotRemote       = params.forceNotRemote;
              aNoReferrer           = params.noReferrer;
              aUserContextId        = params.userContextId;
            }

            // if we're adding tabs, we're past interrupt mode, ditch the owner
            if (this.mCurrentTab.owner)
              this.mCurrentTab.owner = null;

            var t = document.createElementNS(NS_XUL, "tab");

            var uriIsAboutBlank = !aURI || aURI == "about:blank";

            if (aUserContextId)
              t.setAttribute("usercontextid", aUserContextId);
            t.setAttribute("crop", "end");
            t.setAttribute("onerror", "this.removeAttribute('image');");
            t.className = "tabbrowser-tab";

            // The new browser should be remote if this is an e10s window and
            // the uri to load can be loaded remotely.
            let remote = gMultiProcessBrowser &&
                         !aForceNotRemote &&
                         E10SUtils.canLoadURIInProcess(aURI, Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT);

            this.tabContainer._unlockTabSizing();

            // When overflowing, new tabs are scrolled into view smoothly, which
            // doesn't go well together with the width transition. So we skip the
            // transition in that case.
            let animate = !aSkipAnimation &&
                          this.tabContainer.getAttribute("overflow") != "true" &&
                          Services.prefs.getBoolPref("browser.tabs.animate");
            if (!animate) {
              t.setAttribute("fadein", "true");
              setTimeout(function (tabContainer) {
                tabContainer._handleNewTab(t);
              }, 0, this.tabContainer);
            }

            // invalidate caches
            this._browsers = null;
            this._visibleTabs = null;

            this.tabContainer.appendChild(t);

            // If this new tab is owned by another, assert that relationship
            if (aOwner)
              t.owner = aOwner;

            let b;
            let usingPreloadedContent = false;

            // If we open a new tab with the newtab URL in the default
            // userContext, check if there is a preloaded browser ready.
            // Private windows are not included because both the label and the
            // icon for the tab would be set incorrectly (see bug 1195981).
            if (aURI == BROWSER_NEW_TAB_URL && !aUserContextId &&
                !PrivateBrowsingUtils.isWindowPrivate(window)) {
              b = this._getPreloadedBrowser();
              usingPreloadedContent = !!b;
            }

            if (!b) {
              // No preloaded browser found, create one.
              b = this._createBrowser({remote: remote,
                                       uriIsAboutBlank: uriIsAboutBlank,
                                       userContextId: aUserContextId});
            }

            let notificationbox = this.getNotificationBox(b);
            var position = this.tabs.length - 1;
            var uniqueId = this._generateUniquePanelID();
            notificationbox.id = uniqueId;
            t.linkedPanel = uniqueId;
            t.linkedBrowser = b;
            this._tabForBrowser.set(b, t);
            t._tPos = position;
            t.lastAccessed = Date.now();
            this.tabContainer._setPositionalAttributes();

            // Inject the <browser> into the DOM if necessary.
            if (!notificationbox.parentNode) {
              // NB: this appendChild call causes us to run constructors for the
              // browser element, which fires off a bunch of notifications. Some
              // of those notifications can cause code to run that inspects our
              // state, so it is important that the tab element is fully
              // initialized by this point.
              this.mPanelContainer.appendChild(notificationbox);
            }

            // We've waited until the tab is in the DOM to set the label. This
            // allows the TabLabelModified event to be properly dispatched.
            if (!aURI || isBlankPageURL(aURI)) {
              t.label = this.mStringBundle.getString("tabs.emptyTabTitle");
            } else if (aURI.toLowerCase().startsWith("javascript:")) {
              // This can go away when bug 672618 or bug 55696 are fixed.
              t.label = aURI;
            }

            this.tabContainer.updateVisibility();

            // wire up a progress listener for the new browser object.
            var tabListener = this.mTabProgressListener(t, b, uriIsAboutBlank, usingPreloadedContent);
            const filter = Components.classes["@mozilla.org/appshell/component/browser-status-filter;1"]
                                     .createInstance(Components.interfaces.nsIWebProgress);
            filter.addProgressListener(tabListener, Components.interfaces.nsIWebProgress.NOTIFY_ALL);
            b.webProgress.addProgressListener(filter, Components.interfaces.nsIWebProgress.NOTIFY_ALL);
            this.mTabListeners[position] = tabListener;
            this.mTabFilters[position] = filter;

            b.droppedLinkHandler = handleDroppedLink;

            // Swap in a preloaded customize tab, if available.
            if (aURI == "about:customizing") {
              usingPreloadedContent = gCustomizationTabPreloader.newTab(t);
            }

            // Dispatch a new tab notification.  We do this once we're
            // entirely done, so that things are in a consistent state
            // even if the event listener opens or closes tabs.
            var evt = document.createEvent("Events");
            evt.initEvent("TabOpen", true, false);
            t.dispatchEvent(evt);

            // If we didn't swap docShells with a preloaded browser
            // then let's just continue loading the page normally.
            if (!usingPreloadedContent && !uriIsAboutBlank) {
              // pretend the user typed this so it'll be available till
              // the document successfully loads
              if (aURI && gInitialPages.indexOf(aURI) == -1)
                b.userTypedValue = aURI;

              let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
              if (aAllowThirdPartyFixup) {
                flags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP;
                flags |= Ci.nsIWebNavigation.LOAD_FLAGS_FIXUP_SCHEME_TYPOS;
              }
              if (aFromExternal)
                flags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
              if (aAllowMixedContent)
                flags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_MIXED_CONTENT;
              try {
                b.loadURIWithFlags(aURI, {
                                   flags: flags,
                                   referrerURI: aNoReferrer ? null: aReferrerURI,
                                   referrerPolicy: aReferrerPolicy,
                                   charset: aCharset,
                                   postData: aPostData,
                                   });
              } catch (ex) {
                Cu.reportError(ex);
              }
            }

            // We start our browsers out as inactive, and then maintain
            // activeness in the tab switcher.
            b.docShellIsActive = false;

            // When addTab() is called with an URL that is not "about:blank" we
            // set the "nodefaultsrc" attribute that prevents a frameLoader
            // from being created as soon as the linked <browser> is inserted
            // into the DOM. We thus have to register the new outerWindowID
            // for non-remote browsers after we have called browser.loadURI().
            //
            // Note: Only do this of we still have a docShell. The TabOpen
            // event was dispatched above and a gBrowser.removeTab() call from
            // one of its listeners could cause us to fail here.
            if (!remote && b.docShell) {
              this._outerWindowIDBrowserMap.set(b.outerWindowID, b);
            }

            // Check if we're opening a tab related to the current tab and
            // move it to after the current tab.
            // aReferrerURI is null or undefined if the tab is opened from
            // an external application or bookmark, i.e. somewhere other
            // than the current tab.
            if ((aRelatedToCurrent == null ? aReferrerURI : aRelatedToCurrent) &&
                Services.prefs.getBoolPref("browser.tabs.insertRelatedAfterCurrent")) {
              let newTabPos = (this._lastRelatedTab ||
                               this.selectedTab)._tPos + 1;
              if (this._lastRelatedTab)
                this._lastRelatedTab.owner = null;
              else
                t.owner = this.selectedTab;
              this.moveTabTo(t, newTabPos);
              this._lastRelatedTab = t;
            }

            if (animate) {
              requestAnimationFrame(function () {
                this.tabContainer._handleTabTelemetryStart(t, aURI);

                // kick the animation off
                t.setAttribute("fadein", "true");
              }.bind(this));
            }

            return t;
          
        
}

Most of it is boilerplate or leftover cruft from old features. I am a little bit surprised that the one line I keyed in on in the middle, this.tabContainer.appendChild(t);, worked out. I was just trying to see if I could at least manipulate the UI, and that seemed like a simple place to start. It turned out instead to be exactly what you wanted.

1 Like

Welcome DUzun! :slight_smile:

You guys are doing some excellent work here! Love how you and @kousu are sharing as you go through the process. Fantastic use of the forums!

1 Like

You solution looks good @kousu, thanks! I’ll play with it this evening and let you know how it works.

Yesterday I didn’t have the chance to do anything about it, as I had to deal with Safari Extension issues (same project, different set of issues).

My initial thought is to have a callback on setWindow(window, index, cb) and when .gBrowser is undefined, assume window not open yet and listen for open event before continuing. Basically by making it async we have more flexibility in the way we can handle different states.

I’ve done some testing on you methods, @kousu.

There is a serious source of issues!

After calling tab.detach(), a new window with a new tab is created, even though the view of the old tab is transferred to the new window and state is preserved. The tab gets a new id and open event is emited on sdk/tabs collection.
The new tab doesn’t catch any tab events afterwards! Neither does the old tab model.
If you access tab properties from old tab model, it still works, but after the tab is moved to the new window, before fully initialized, trying to access for example tab.url throws an error, because viewFor(tab) == undefined at some point.
I’m not sure, but there is a chance that the events are not emited for the new tab across other extensions as well.
This is unacceptable!

gBrowser.tabContainer.appendChild(tabView) is not working.
gBrowser.tabContainer is simply a DOM element, so is tabView = viewFor(sdkTab).

By moving tabView to a different window, the associated panel (tab’s browser) is not moved automatically.

In gBrowser.addTab, after appending the tab to new window, a browser is created:

this.tabContainer.appendChild(t);
// ...
b = this._createBrowser(...); 
/// ...
t.linkedBrowser = b;

Really? What Firefox version? I’m 44.0.2 on Arch Linux. It works for me.

FF 44.0.2 and 47.0a1 (Nightly) on Windows.

I’ve done some testing on you methods, @kousu.

There is a serious source of issues!

After calling tab.detach(), a new window with a new tab is created,
even though the view of the old tab is transferred to the new window
and state is preserved. The tab gets a new id and open event is
emited on sdk/tabs collection.
The new tab doesn’t catch any tab events afterwards! Neither does the
old tab model.

I don’t see that tabs.on('open'). In fact, I never see one for any first tab in a window, which seems like a bug to me but you never know.

It would be odd for moving a tab to trigger an open if its still the same tab.

Can you share your experiments as test code? Its easiest to interrogate this if each bug you’re seeing comes in a separate extension that I can jpm run, though ActionButtons embedded within a single extension would be okay.

If you access tab properties from old tab model, it still works, but
after the tab is moved to the new window, before fully initialized,
trying to access for example tab.url throws an error, because
viewFor(tab) == undefined at some point.

This is the same error I had above. The SDK docs explicitly say you can’t trust tab.url until tab.on('ready'), and while they don’t explicitly say when you can or can’t trust having viewFor(.) or view for(tab).gBrowser, I found that you need to wait as well. Just do your code in a tab.on('ready') handler and deal with it.

I’m not sure, but there is a chance that the events are not emited for
the new tab across other extensions as well.
This is unacceptable!

It’s not a new tab though so it shouldn’t be emitting these events anyway. But can you show this in a test case?

Here is the index.js file:

    // -------------------------------------------------------------
    /// Testing and playing
    // -------------------------------------------------------------
    var sw = require('./setWindow');
    // -------------------------------------------------------------
    const {
        setTimeout,
        setImmediate,
    } = require("sdk/timers");

    var TABS = require("sdk/tabs");
    var browserWindows = require("sdk/windows").browserWindows;
    // -------------------------------------------------------------

    // Marck start time
    var tmr = Date.now();

    // -------------------------------------------------------------
    /// Listeners:
    browserWindows.on('open', function (win) {
        log('~win', getWinId(win), 'open', win.tabs.length, win.tabs[0].id);
    });

    // on open tabs might be uninitialized and it is dangerous to alter it in any way
    TABS.on('open', function (tab, event) {
        log('~tab', tab.id, 'open', tab.readyState, tab.url, event?true:event);
    });

    // This one is not documented, but interesting
    TABS.on('create', function (tab, ...args) {
        log('~tab', tab.id, 'create', tab.readyState, tab.url, ...args);
    });
    TABS.on('ready', function (tab) {
        log('~tab', tab.id, 'ready', tab.readyState, tab.url);
    });
    TABS.on('load', function (tab) {
        log('~tab', tab.id, 'load', tab.readyState, tab.url);
    });
    TABS.on('move', function (tab, ...args) {
        log('~tab', tab.id, 'move', tab.readyState, tab.url, ...args);
    });

    /// Some URLs
    var urls = [
        'https://duzun.me/?window1_tab0_open_before_window',
        'https://www.google.com/?window1_tab1_detatched_to_window3',
        'https://nodejs.org/en/?window1_tab3_next_to_detatched_tab',
        'https://discourse.mozilla-community.org/t/tear-off-tab-with-sdk/7085/19/',
        'http://npmjs.org/?window2_tab1_moved_to_window1',
    ];

    /// Experiments
    var w0 = browserWindows[0];
    w0.tabs[0].url = urls[3];

    var w1 = browserWindows.open({
        url: urls[0],
        onOpen: function (w) {
            // Can't open any tab on this window until t0.readyState != 'uninitialized', so use what SDK offers - onReady,
            // even though it depends on document.readyState == 'interactive' inside tab, which could take a lot of time to happen :-(
            var t0 = w.tabs[0];
            t0.once('ready', function (t0) {
                w.tabs.open({
                    url: urls[1],
                    index: 1,
                    onOpen: function (t1) {
                        log('~t1', t1.id,'detaching', getWinId(t1.window));
                        t1.detach();

                        // This one would try to access .url multiple times, thus throws an error
                        wait4url(t1, log);
                    },
                });
                w.tabs.open({
                    url: urls[2],
                    index: 3,
                });
            });

            var t4 = w0.tabs[0];
            t4.once('ready', function (t4) {
                w0.tabs.open({
                    url: urls[4],
                    onReady: (tab) => {
                        log('~move tab', tab.id, tab.url, 'from', getWinId(w0), 'to', getWinId(w1));
                        tab.setWindow(w1);
                    },
                });
            });
        }
    });

    /// Helpers:

    function passed() {
        return Date.now() - tmr;
    }

    function log(...args) {
        console.log(passed(), '~', ...args);
    }

    function getWinId(win) {
        if ( !win ) return undefined;
        var id = win.id;
        if ( id ) return id;
        win.id = getWinId._id = id = (getWinId._id||0) + 1;
        return id;
    }

    function wait4url(tab, cb) {
        var readyState;
        return (function _wait() {
            var url = tab.url;
            // new tab's url is 'about:newtab'
            if ( (url == 'about:blank' || !url) && (readyState = tab.readyState) != 'complete' && readyState != 'interactive' ) {
                return setTimeout(_wait, 4);
            }
            cb(tab, url);
            return url;
        }())
    }

Also save your patches of Tab to setWindow.js file.

With this test the detached window emits events, but earlier today while playing with code I’ve got in a situation where it didn’t. I guess becouse of an error right after .detatch call.

setWindow() behaves as described earlier, and there is an error in the console after detatch().

You can filter console output by “~” and follow events…

Finally I’ve come up with a complete solution! :mask:

I’m not 100% sure about “detaching”, but haven’t seen issues if used after everything is initialized.

1 Like

I’ve dropped your code into my project at https://github.com/kousu/disabletabs/tree/duzun. Detaching seems to work fine, though it’s a bit counterintuitive to say .setWindow(null) to mean “make a new window” to me, so I’ve wrapped it up in a new .detach().

I’m very pleased for you! How long have you been working on this? 4 months?

Some followups:

  • Why do you return a Promise and also take a callback? Aren’t promises supposed to replace (well, adopt) callbacks?
  • Were the issues you ran into entirely solved by deferring work until .gBrowser was allocated or is there some kind of hidden Linux-Windows incompatibility?
  • how does newBrowser.docShell; make sure it has a docshell? Is that secretly invoking some C extension code, or is that just a forgotten line?
  • is // For some reason this doesn't seem to work :-( if ( selected ) { gBrowser.selectedTab = newTab; }
    necessary?
  • Instead of checking if ( gBrowser.adoptTab ) { in the handler, check it at load time and monkey-patch.
  • I really appreciate all the comments you left. The comments in the internal FF code are lacking: I keep getting confused about which data structure is which: a model tab, a view tab, a browser, the other kind of browser…

detach is not a good name, at least beacause there is tab.attach(), which attaches a script to the document, so it might be confusing.
So far I could think of setWindow(null), which to me is intuitive and lets you use same API:
setWindow(window1) moves to window1
setWindow(null||undefined||false||0||"") moves to no (existing) window -> new window.

For two reasons:

  1. callback receives the new tab and old tab’s ID, which is important to know in some cases - cb(newTab, oldTabId)
  2. callback is called synchronously when gBrowser exists, but promise is always async.

I don’t see any reason to not support callbacks, even though it returns a promise at the same time.

Actually I’m organazing the extension code in such a way that it interacts with tabs and windows only after initialization. I’m still testing and adjusting the extension, so if I have any issues, I’ll update setWindow's code. So far everything looks good and no issues (I’m not using “detach” in my extension, yet).


To answer the rest:

  • gBrowser.adoptTab is not defined in FF44 and I don’t want to touch “future” methods, as this might affect other libraries. But I’ll do the check at load time.

  • selectedTab didn’t work at some point, but I have to retest it with the final version, it might work now. Anyways it is a minore issue (my extension takes care of this by itself :slight_smile: ).

  • A lot of properties in FF code are getters and setters and some of them get initialized on first call, so is docShell. Because the loading of browser stops, docShell might never get initialized.

I also get confused by what is what in FF code. The documentation is not exhaustive and I’m doing a lot of guessing and testing for every API method.

I think this discussion might be interesting:

Bug 1214007 - Implement chrome.tabs.move

I’ve updated the code for setWindow(null).
gBrowser.replaceTabWithWindow returns the new window, not the new tab, so I had to update the code to wait for window to open, then return model for new tab.

How is it working @kousu ? Did you test it?
On my side it looks good so far.

I haven’t put it through its paces. With the previous tear off method, a bunch of scrap about:blank pages are made that you can see if you find the right nook of Developer Tools. It sounds like your update should fix that though.

This was an excellent open source experience! Thanks for building on this @DUzun and @kousu. If I ever need this, I will use the method you two collaborated on rather then mine. Long live open source!

Please do share once you put it through the ringer and see how it behaves @kousu