Skip to content

Commit

Permalink
Switch to the previous tab if initialization fails (#67)
Browse files Browse the repository at this point in the history
* Extract tab initializer

* Switch to the previous tab if initialization fails
Update chrome typings

* Prevent message sending to uninitialized tab

* Add a note about testing the switching on forbidden pages

* Increase version
  • Loading branch information
dvdvdmt authored Sep 18, 2022
1 parent 8c0d638 commit f7ff76f
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 55 deletions.
12 changes: 12 additions & 0 deletions e2e/popup-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ describe('popup', function TestPopup() {
assert.strictEqual(elText, 'Wikipedia')
})

it.skip(`switches instantly or after a timeout if the page can't show the popup`, async () => {
/*
Examples of the pages that can't show the switcher are:
- Utility pages like Settings, History, New tab, etc.
- Broken or non-existent pages with a message like "This site can't be reached".
- Slowly loading pages or those that were broken during the load.
These include errors like ERR_INVALID_CHUNKED_ENCODING when the DOM was not created.
Currently, it is impossible to test the switching from such pages because they don't allow
content script initialization which is crucial to simulate keyboard pressings with sendCommandOnShortcut()
*/
})

it('focuses previously active window on a tab closing', async () => {
/*
opens Wikipedia in first window
Expand Down
32 changes: 23 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.10",
"@babel/register": "^7.12.10",
"@types/chrome": "0.0.95",
"@types/chrome": "^0.0.197",
"@types/mocha": "^7.0.1",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
Expand Down
70 changes: 36 additions & 34 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ async function initForE2ETests(handlers: Partial<IHandlers>) {
}
}

async function switchToPreviousTab() {
const registry = await ServiceFactory.getTabRegistry()
const previousTab = registry.getPreviouslyActive()
if (previousTab) {
await activateTab(previousTab)
}
}

async function handleCommand(command: string) {
const activeTab = await getActiveTab()
if (!activeTab) {
Expand All @@ -102,16 +110,19 @@ async function handleCommand(command: string) {
if (isCodeExecutionForbidden(active)) {
// If the content script can't be initialized then switch to the previous tab.
// TODO: Create popup window in the center of a screen and show PTS in it.
const registry = await ServiceFactory.getTabRegistry()
const previousTab = registry.getPreviouslyActive()
if (previousTab) {
await activateTab(previousTab)
}
await switchToPreviousTab()
return
}
await initializeContentScript(active)
// send the command to the content script
await browser.tabs.sendMessage(active.id, selectTab(command === Command.NEXT ? 1 : -1))
if (await initializeContentScript(active)) {
// send the command to the content script
await browser.tabs.sendMessage(active.id, selectTab(command === Command.NEXT ? 1 : -1))
} else {
// Tab initialization may fail due to different reasons:
// - the page is not loaded,
// - the initialization timeout passed.
// If this happens we are switching to the previous tab
await switchToPreviousTab()
}
}

async function handleWindowActivation(windowId: number) {
Expand Down Expand Up @@ -206,9 +217,11 @@ function messageHandlers(): Partial<IHandlers> {
}
return
}
await initializeContentScript(active)
// send a command to the content script
await browser.tabs.sendMessage(active.id, demoSettings())
if (await initializeContentScript(active)) {
await browser.tabs.sendMessage(active.id, demoSettings())
} else {
// TODO: Show extension in a separate window
}
},
[Message.GET_MODEL]: async () => {
const settings = await ServiceFactory.getSettings()
Expand All @@ -222,30 +235,18 @@ function messageHandlers(): Partial<IHandlers> {
}
}

async function initializeContentScript(tab: ITab): Promise<void> {
async function initializeContentScript(tab: ITab): Promise<boolean> {
const registry = await ServiceFactory.getTabRegistry()
if (!registry.isInitialized(tab.id)) {
const initialization = registry.tabInitializations.get(tab.id)
if (initialization) {
log('[tab initialisation is in progress', tab)
return initialization.promise
}
let resolver = () => {}
const promise = new Promise<void>((resolve, reject) => {
resolver = resolve
browser.scripting
.executeScript({
target: {tabId: tab.id, allFrames: false},
files: ['content.js'],
})
.catch((e) => {
log(`[tab initialization failed]`, tab)
reject(e)
})
})
registry.tabInitializations.set(tab.id, {resolver, promise})
return promise
if (registry.isInitialized(tab)) {
return true
}
const initialization = registry.tabInitializations.get(tab.id)
if (initialization) {
log('[tab initialisation is in progress]', tab)
return initialization.promise
}
const newInitialization = registry.startInitialization(tab)
return newInitialization.promise
}

async function getActiveTab(): Promise<Tab | undefined> {
Expand Down Expand Up @@ -279,8 +280,9 @@ function handleConnection(port: Runtime.Port) {
}

async function closeSwitcherInActiveTab() {
const registry = await ServiceFactory.getTabRegistry()
const currentTab = await getActiveTab()
if (currentTab) {
if (currentTab && registry.isInitialized(checkTab(currentTab))) {
await browser.tabs.sendMessage(checkTab(currentTab).id, closePopup())
}
}
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
"version": "2022.3",
"version": "2022.4",
"name": "Popup Tab Switcher",
"description": "Makes switching between tabs more convenient.",
"permissions": ["activeTab", "favicon", "scripting", "storage", "tabs"],
Expand Down
57 changes: 47 additions & 10 deletions src/utils/tab-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ interface IInitializedTabs {
[key: number]: ITab
}

export interface ITabInitialization {
resolver: (status: boolean) => void
promise: Promise<boolean>
}

export default class TabRegistry {
tabInitializations: Map<number, ITabInitialization>

private tabs: ITab[]

private numberOfTabsToShow: number
Expand All @@ -20,8 +27,6 @@ export default class TabRegistry {

private onUpdate: (tabs: ITab[]) => void

tabInitializations: Map<number, {resolver: () => void; promise: Promise<void>}>

constructor({
tabs = [],
numberOfTabsToShow = 7,
Expand All @@ -40,20 +45,15 @@ export default class TabRegistry {

addToInitialized(tab: ITab) {
this.initializedTabs[tab.id] = tab
const initialization = this.tabInitializations.get(tab.id)
if (initialization) {
log('[tab initialized]', tab)
initialization.resolver()
this.tabInitializations.delete(tab.id)
}
this.endInitialization(tab)
}

removeFromInitialized(tabId: number) {
delete this.initializedTabs[tabId]
}

isInitialized(tabId: number) {
return this.initializedTabs[tabId]
isInitialized(tab: ITab) {
return this.initializedTabs[tab.id]
}

push(current: ITab) {
Expand Down Expand Up @@ -126,6 +126,43 @@ export default class TabRegistry {
return this.tabs.map((tab) => `#${tab.id} ${tab.title}`).join(', ')
}

startInitialization(tab: ITab): ITabInitialization {
let resolver: (status: boolean) => void = () => {}
const promise = new Promise<boolean>((resolve) => {
resolver = (status: boolean) => {
this.tabInitializations.delete(tab.id)
resolve(status)
}
chrome.scripting
.executeScript({
target: {tabId: tab.id, allFrames: false},
files: ['content.js'],
})
.catch((e) => {
log(`[tab initialization failed due to executeScript()]`, tab, e)
resolver(false)
})
})
const tabSwitchingTimeoutMs = 400
setTimeout(() => {
if (this.tabInitializations.has(tab.id)) {
log(`[tab initialization failed due to timeout]`, tab)
resolver(false)
}
}, tabSwitchingTimeoutMs)
const result = {resolver, promise}
this.tabInitializations.set(tab.id, result)
return result
}

private endInitialization(tab: ITab) {
const initialization = this.tabInitializations.get(tab.id)
if (initialization) {
log('[tab initialized]', tab)
initialization.resolver(true)
}
}

private removeTab(tabId: number): ITab[] {
return this.tabs.filter(({id}) => id !== tabId)
}
Expand Down

0 comments on commit f7ff76f

Please sign in to comment.