Skip to content

Commit

Permalink
Track hidden tabs using session data
Browse files Browse the repository at this point in the history
Whenever we hide a tab, tag it with a marker in the browser session data
so we can tell it was Tab Stash that hid it.  This allows us to more
accurately identify tabs that need to be cleaned up later.

We were previously cleaning up hidden tabs by looking only at what URLs
were removed from the stash and closing the corresponding tabs.
However, that doesn't always work, because the tab's URL could
spontaneously change (e.g. a user getting logged out), and then we
wouldn't think it belonged to us anymore.

Now, if we see a tab with the Tab Stash marker but with a URL that isn't
stashed, we know it's one of ours and we can close it, regardless of why
the URL is unrecognized.

Closes #425.
  • Loading branch information
josh-berry committed May 28, 2024
1 parent b48dadf commit fd7f93e
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 35 deletions.
35 changes: 28 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@ import {logErrorsFrom} from "./util/oops";
logErrorsFrom(async () => {
// BEGIN FILE-WIDE ASYNC BLOCK

//
// Migrations -- these are old DBs which are in the wrong format
//

indexedDB.deleteDatabase("cache:favicons");
indexedDB.deleteDatabase("cache:bookmarks");

//
// Start our various services and set global variables used throughout the rest
// of this file.
Expand All @@ -28,6 +21,34 @@ logErrorsFrom(async () => {
const model = await service_model();
(<any>globalThis).model = model;

//
// Migrations
//

// Delete old DBs that are in the wrong format
indexedDB.deleteDatabase("cache:favicons");
indexedDB.deleteDatabase("cache:bookmarks");

// Tag hidden tabs which were hidden before upgrading to a version of Tab
// Stash that keeps track of which tabs it was responsible for hiding.
if (!model.options.local.state.migrated_tab_markers_applied) {
logErrorsFrom(async () => {
// Don't do anything if the browser doesn't support hiding tabs.
if (!browser.tabs.hide) return;

const tabs = await browser.tabs.query({hidden: true});

for (const t of tabs) {
if (model.bookmarks.isURLStashed(t.url!)) {
// This applies the tag as a side effect
await model.tabs.hide([t.id! as TabID]);
}
}

await model.options.local.set({migrated_tab_markers_applied: true});
});
}

//
// User-triggered commands thru menu items, etc. IDs in the menu items
// correspond to field names in the commands object.
Expand Down
3 changes: 2 additions & 1 deletion src/mock/browser/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import bookmarks from "./bookmarks";
import containers from "./containers";
import runtime from "./runtime";
import sessions from "./sessions";
import storage from "./storage";
import tabs_and_windows from "./tabs-and-windows";

export {bookmarks, containers, runtime, storage, tabs_and_windows};
export {bookmarks, containers, runtime, sessions, storage, tabs_and_windows};
106 changes: 106 additions & 0 deletions src/mock/browser/sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type {Sessions, Tabs as T, Windows as W} from "webextension-polyfill";

import * as events from "../events";
import type {State} from "./tabs-and-windows";
import tabs_and_windows from "./tabs-and-windows";

type Metadata = Map<string, any>;

class MockSessions implements Sessions.Static {
readonly MAX_SESSION_RESULTS = 25;

readonly onChanged: events.MockEvent<() => void> = new events.MockEvent(
"browser.sessions.onChanged",
);

private readonly _state: State;
private readonly _windows = new WeakMap<W.Window, Metadata>();
private readonly _tabs = new WeakMap<T.Tab, Metadata>();

constructor(state: State) {
this._state = state;
}

forgetClosedTab(windowId: number, sessionId: string): Promise<void> {
throw new Error("Method not implemented.");
}
forgetClosedWindow(sessionId: string): Promise<void> {
throw new Error("Method not implemented.");
}
getRecentlyClosed(
filter?: Sessions.Filter | undefined,
): Promise<Sessions.Session[]> {
throw new Error("Method not implemented.");
}
restore(sessionId?: string | undefined): Promise<Sessions.Session> {
throw new Error("Method not implemented.");
}

async setTabValue(tabId: number, key: string, value: any): Promise<void> {
const md = this._tab(tabId);
md.set(key, JSON.stringify(value));
}
async getTabValue(tabId: number, key: string): Promise<any> {
const md = this._tab(tabId);
const val = md.get(key);
if (val === undefined) return undefined;
return JSON.parse(val);
}
async removeTabValue(tabId: number, key: string): Promise<void> {
const md = this._tab(tabId);
md.delete(key);
}

async setWindowValue(
windowId: number,
key: string,
value: any,
): Promise<void> {
const md = this._window(windowId);
md.set(key, JSON.stringify(value));
}
async getWindowValue(windowId: number, key: string): Promise<any> {
const md = this._window(windowId);
const val = md.get(key);
if (val === undefined) return undefined;
return JSON.parse(val);
}
async removeWindowValue(windowId: number, key: string): Promise<void> {
const md = this._window(windowId);
md.delete(key);
}

private _window(id: number): Metadata {
const win = this._state.win(id);
let md = this._windows.get(win);
if (!md) {
md = new Map();
this._windows.set(win, md);
}
return md;
}

private _tab(id: number): Metadata {
const tab = this._state.tab(id);
let md = this._tabs.get(tab);
if (!md) {
md = new Map();
this._tabs.set(tab, md);
}
return md;
}
}

export default (() => {
const exports = {
sessions: new MockSessions(tabs_and_windows.state),
reset() {
exports.sessions = new MockSessions(tabs_and_windows.state);
(<any>globalThis).browser.sessions = exports.sessions;
},
};

exports.reset();

return exports;
})();
6 changes: 4 additions & 2 deletions src/mock/browser/tabs-and-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import * as events from "../events";
type Window = Omit<W.Window, "id" | "tabs"> & {id: number; tabs: Tab[]};
type Tab = Omit<T.Tab, "id" | "windowId"> & {id: number; windowId: number};

class State {
// Exported because it's also used by the sessions mock
export class State {
readonly onWindowCreated: events.MockEvent<(window: W.Window) => void>;
readonly onWindowRemoved: events.MockEvent<(windowId: number) => void>;
readonly onWindowFocusChanged: events.MockEvent<(windowId: number) => void>;
Expand Down Expand Up @@ -910,11 +911,12 @@ export default (() => {
let state = new State();

const exports = {
state,
windows: new MockWindows(state),
tabs: new MockTabs(state),

reset() {
state = new State();
exports.state = state = new State();
exports.windows = new MockWindows(state);
exports.tabs = new MockTabs(state);
(<any>globalThis).browser.windows = exports.windows;
Expand Down
1 change: 1 addition & 0 deletions src/mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const mochaHooks: RootHookObject = {
mock_browser.storage.reset();
mock_browser.bookmarks.reset();
mock_browser.tabs_and_windows.reset();
mock_browser.sessions.reset();
mock_browser.containers.reset();
},
async afterEach() {
Expand Down
8 changes: 8 additions & 0 deletions src/model/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ export class Model {
return index;
}

/** Returns a (non-reactive) array of bookmarks in the stash with the
* specified URL. */
bookmarksWithURLInStash(url: string): Bookmark[] {
return Array.from(this.bookmarksWithURL(url)).filter(bm =>
this.isNodeInStashRoot(bm),
);
}

isParent(node: Node): node is Folder {
return isFolder(node);
}
Expand Down
62 changes: 62 additions & 0 deletions src/model/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,54 @@ describe("model", () => {
);
}
});

describe("hidden tabs", () => {
it("leaves stashed tabs alone", async () => {
await browser.sessions.setTabValue(
tabs.real_doug_2.id,
M.Tabs.SK_HIDDEN_BY_TAB_STASH,
true,
);

await model.closeOrphanedHiddenTabs();

const hidden = await browser.tabs.query({hidden: true});
expect(hidden.map(t => t.id!)).to.deep.equal([
tabs.real_doug_2.id,
tabs.real_harry.id,
tabs.real_helen.id,
]);
});

it("closes orphaned stashed tabs", async () => {
await browser.sessions.setTabValue(
tabs.real_doug_2.id,
M.Tabs.SK_HIDDEN_BY_TAB_STASH,
true,
);

await model.bookmarks.remove(bookmarks.doug_2.id);

await model.closeOrphanedHiddenTabs();

const hidden = await browser.tabs.query({hidden: true});
expect(hidden.map(t => t.id!)).to.deep.equal([
tabs.real_harry.id,
tabs.real_helen.id,
]);
});

it("leaves tabs hidden by other extensions alone", async () => {
await model.closeOrphanedHiddenTabs();

const hidden = await browser.tabs.query({hidden: true});
expect(hidden.map(t => t.id!)).to.deep.equal([
tabs.real_doug_2.id,
tabs.real_harry.id,
tabs.real_helen.id,
]);
});
});
});

describe("choosing stashable tabs in a window", () => {
Expand Down Expand Up @@ -291,6 +339,12 @@ describe("model", () => {
hidden: true,
});
expect(model.tabs.tab(tabs.right_doug.id)!.discarded).not.to.be.ok;

const bm_id = await browser.sessions.getTabValue(
tabs.right_doug.id,
M.Tabs.SK_HIDDEN_BY_TAB_STASH,
);
expect(bm_id).to.equal(true);
});

it("hides and unloads tabs", async () => {
Expand Down Expand Up @@ -318,6 +372,12 @@ describe("model", () => {
hidden: true,
discarded: true,
});

const bm_id = await browser.sessions.getTabValue(
tabs.right_doug.id,
M.Tabs.SK_HIDDEN_BY_TAB_STASH,
);
expect(bm_id).to.equal(true);
});

it("closes tabs", async () => {
Expand Down Expand Up @@ -354,6 +414,7 @@ describe("model", () => {
id => model.tabs.tab(id)!,
),
);
await events.next(browser.tabs.onHighlighted); // un-highlight current tab
await events.next(browser.tabs.onCreated);
await events.next(browser.tabs.onActivated);
await events.next(browser.tabs.onHighlighted);
Expand Down Expand Up @@ -402,6 +463,7 @@ describe("model", () => {

it("refocuses away from an active tab that is to be closed", async () => {
await model.hideOrCloseStashedTabs([model.tabs.tab(tabs.left_alice.id)!]);
await events.next(browser.tabs.onHighlighted); // un-highlight current tab
await events.next(browser.tabs.onActivated);
await events.next(browser.tabs.onHighlighted);
await events.next(browser.tabs.onUpdated); // hidden
Expand Down
Loading

0 comments on commit fd7f93e

Please sign in to comment.