Давно пользовался расширением The Great Suspender для приостановки вкладок, но оно давно заброшено и обновлений не планируется, а найти полноценную замену с Manifest V3 не удалось.
Тем временем Chrome окончательно отключает поддержку Manifest V2 для расширений начиная с версии 139, которая выйдет на днях (30 июля - Early Stable Release, 5 августа - Stable Release) и вопрос замены стал очень актуальным.
Посмотрел на исходный код The Great Suspender и решил, что проще написать с нуля, чем исправить. Изучил на API для расширений, всё должно быть просто: по таймеру проверяем вкладки, приостанавливаем (переадресовываем на страницу расширения) давно не использующиеся вкладки (определяем по свойству вкладки lastAccessed
), по клику на приостановленной страницы возвращаем обратно на оригинальную страницу. Казалось бы, какие тут могут быть проблемы...
Для тех кому лень читать стену текста
В документации API расширений хватает пропущенных или плохо описанных моментов, бонусом особенности service worker'ов и разрешений добавили много неочевидных вещей и сложностей в реализации.
Исходный код расширения на Github
Требования
указание времени, по истечении которого вкладка должна быть приостановлена;
опциональное исключение вкладок по условиям: закреплённые вкладки, не сохранённые поля ввода, проигрывающимся аудио, активные вкладки, не приостанавливать в оффлайн или при питании от сети, исключения по домену или URL;
временное отключение таймера для конкретной вкладки (постановка на паузу);
приостановка/возобновлении группы/окна/всех вкладок из контекстного меню (или всплывающего окна);
горячие клавиши;
сохранять позицию прокрутки и текущее время (таймстамп видео) для YouTube;
на вкладке должна сохраняться иконка оригинальной страницы, и по ней должно быть видно, что страница приостановлена (по сути сделать иконку полупрозрачной);
сохранение, импорт, экспорт всех вкладок.
И сразу отвечу на вопрос "Почему не используешь стандартный механизм неактивных вкладок?": он не сохраняет позиция прокрутки и время на YouTube, несохраненные данные теряются; вкладка активируется сразу при переключении на неё.
Минимальная версия
За день сделал минимально рабочую версию на TypeScript, изначально все разрешения сделал обязательными.
Определил enum
со списком доступных действий:
export enum MESSAGE
{
TogglePauseTab = 'toggle_pause_tab',
PauseTab = 'pause_tab',
UnpauseTab = 'unpause_tab',
ToggleSuspendTab = 'toggle_suspend_tab',
SuspendTab = 'suspend_tab',
UnsuspendTab = 'unsuspend_tab',
// и т.д.
}
и интерфейс для сообщений service worker'а из других страниц:
export interface SuspenderRequest
{
action: MESSAGE;
// опциональные поля в зависимости от MESSAGE
}
С созданием и использованием таймера просто:
chrome.alarms.create(ALARM_TABS, alarm_config);
chrome.alarms.onAlarm.addListener(async (alarm) =>
{
if (alarm.name === ALARM_TABS)
{
const config = await Configuration.load();
if (config.autoSuspend())
{
await (new Suspender(config)).suspendAuto();
}
}
});
Контекстное меню тоже создается просто:
chrome.contextMenus.create({
id: MESSAGE.ToggleSuspendTab,
title: chrome.i18n.getMessage('action_toggle_suspend_tab'),
contexts: contexts,
});
Горячие клавиши задаются прямо в манифесте, для названия команд использую MESSAGE
, в __MSG_{key}__
передается ключ текста для локализации:
"commands": {
"toggle_suspend_tab": {
"description": "__MSG_action_toggle_suspend_tab__"
},
"toggle_pause_tab": {
"description": "__MSG_action_toggle_pause_tab__"
},
// ...
}
И обработка тоже простая:
// контекстное меню
chrome.contextMenus.onClicked.addListener(async (info, tab) =>
{
if (!isEnumValue(MESSAGE, info.menuItemId) || !isValidTab(tab))
{
console.error(`Unsupported menu item: ${info.menuItemId}`);
return;
}
await executeMessage(tab, {action: info.menuItemId, url: info.linkUrl, });
});
// горячие клавиши
chrome.commands.onCommand.addListener(async (action, tab) =>
{
if (!isEnumValue(MESSAGE, action) || !isValidTab(tab))
{
console.error(`Unsupported command: ${action}`);
return;
}
await executeMessage(tab, {action: action});
});
// обработка сообщений от всплывающего окна и приостановленных страниц
chrome.runtime.onMessage.addListener(async (
request: SuspenderRequest,
sender: chrome.runtime.MessageSender,
sendResponse: (response: any) => void
) =>
{
// processMessage() проверяет входные данные и вызывает executeMessage()
sendResponse(await processMessage(request, sender));
});
При приостановке вкладки параметры оригинальной страницы сохраняю в hash
(для внимательных: таймстампа видео с YouTube тут нет сознательно).
const p = new URLSearchParams();
p.set('uri', uri);
p.set('ttl', title);
p.set('pos', scrollPosition.toString());
if (icon !== null)
{
p.set('icon', icon);
}
const suspended_url = chrome.runtime.getURL('suspended.html') + '#' + p.toString();
Стал тестировать и часть функций работает, часть нет. Как оказалось chrome.runtime.onMessage
это особое событие, но в документации к ней это решили не описывать: если sendResponse
вызывается асинхронно, то функция должна возвращать true
, поэтому рабочий вариант выглядит так:
chrome.runtime.onMessage.addListener((
request: SuspenderRequest,
sender: chrome.runtime.MessageSender,
sendResponse: (response: any) => void
) =>
{
processMessage(request, sender).then(sendResponse);
return true;
});
Справедливости ради есть страница, где это описано, но для меня так и останется загадкой почему это не указано в документации к самому событию или в API не используется проверка (result === true) || (result instanceof Promise)
. При это есть chrome.scripting.executeScript()
который прекрасно работает с асинхронными функциями.
И это оказалось далеко не единственной проблемой с API и документацией.
Полноценная версия
Минимальная версия оказалось вполне работоспособной и дальше стал реализовывать оставшиеся требования.
Проверка оффлайн режима элементарта:
const online = navigator.onLine;
.
Логично предположить, что и проверка работы от сети будет аналогичной:
const power_on = (await navigator.getBattery()).charging;
,
но не тут то было, в service worker'e метод navigator.getBattery()
недоступен.
Поэтому есть специально спроектированный обходной путь:
в манифесте добавить разрешение
offscreen
в список обязательных-
создать страницу offscreen.html
<!doctype html> <script src="offscreen.js" type="module"></script>
-
создать скрипт offscreen.js для получения статуса работы от сети
async function processMessage(): Promise<SuspenderResponse<boolean>> { return { success: true, // реальный код чуть сложнее data: (await navigator.getBattery()).charging, }; } chrome.runtime.onMessage.addListener(( message: SuspenderRequest, _sender: chrome.runtime.MessageSender, sendResponse: (response?: SuspenderResponse<boolean>) => void ) => { if (message.target !== MESSAGE_TARGET.Offscreen) { return; } if (message.action === MESSAGE.BatteryStatus) { processMessage().then(sendResponse); } return true; });
-
запросить доступ к offscreen.html
export class Offscreen { private static creating: Record<string, Promise<void>> = {}; public static async requireOffscreenDocument(path: string): Promise<void> { const url = chrome.runtime.getURL(path); const existing = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'], documentUrls: [url] }); if (existing.length > 0) { return; } if (Offscreen.creating[path] === undefined) { Offscreen.creating[path] = chrome.offscreen.createDocument({ url: path, reasons: ['BATTERY_STATUS'], justification: 'Request battery status', }); } await Offscreen.creating[path]; delete Offscreen.creating[path]; } }
-
и теперь легко и просто (табличка сарказм) получаю статус питания от сети или аккумулятора
await Offscreen.requireOffscreenDocument(PAGE.Offscreen); const battery = await Messenger.send<boolean>({ action: MESSAGE.BatteryStatus, target: MESSAGE_TARGET.Offscreen, }); return new DeviceStatus(navigator.onLine, battery?.data ?? true);
К этому времени начал раздражать огромный switch
в коде, чтобы обработать все 27 вариантов MESSAGE
. И после недолгих размышлений удалось переделать switch
в отдельный класс с гарантией, что на каждый вариант будет свой метод:
type MessageProcessorMap = {
[K in MESSAGE]: () => MessageProcessorResult;
};
export class MessageProcessor implements MessageProcessorMap
{
async [MESSAGE.PauseTab](): Promise<SuspenderResponse<void>>
{
await TabInfo.pause(this.tab.id);
return { success: true, };
}
async [MESSAGE.WhitelistDomain](): Promise<SuspenderResponse<void>>
{
await this.config.whitelistDomain(this.tab);
return { success: true, };
}
async [MESSAGE.SuspendTab](): Promise<SuspenderResponse<void>>
{
await this.suspender.suspend(this.tab);
return { success: true, };
}
// ...
}
И использование:
const processor = new MessageProcessor(tab, request, config, suspender);
return await processor[request.action].bind(processor)();
Получение данных страницы: позиция прокрутки, время видео на YouTube, проверка несохраненных форм
Для получения данных страница используется метод chrome.scripting.executeScript()
, который выполняет переданную ему функцию в контексте страницы.
return await chrome.scripting.executeScript({
target: { tabId: tabId, },
func: (scroll: boolean, time: boolean, changed_fields: boolean): InternalPageInfo =>
{
const youtube_time = () =>
{
const video = document.querySelector<HTMLVideoElement>('video.video-stream.html5-main-video');
return video !== null ? Math.floor(video.currentTime) : null;
};
const any_form_changed = () =>
{
// проверка полей POST форм в зависимости от типа:
// (от поддержки GET и полей вне форм отказался и-за ложных срабатываний)
// el.checked !== el.defaultChecked
// el.value !== el.defaultValue
// opt.selected !== opt.defaultSelected
// el.files && el.files.length > 0
return false;
};
const scroll_position = scroll
? document.documentElement.scrollTop || 0
: 0;
return {
scrollPosition: Math.floor(scroll_position),
time: time ? youtube_time() : null,
changedFields: changed_fields ? any_form_changed() : false,
};
},
args: [scroll, time, changed_fields, ],
});
Но тут опять не обошлось без проблем:
в коде инжектируемой функции очень легко пропустить использование внешней функции, которая не будет захвачена, что приведет к ошибке;
отладка усложнена: никаких точек останова в DevTools, только через
debugger;
в коде;вызов функции бросает exception на вкладках с ошибками браузера (connection error и подобные), о чём в документации опять же ни слова, в итоге обернул вызов
executeScript()
в блокtry/catch
. И надёжного способа отличить вкладку с ошибкой нет,chrome.webNavigation.onErrorOccurred
не расскажет про ошибки до установки расширения.
Для сохранения таймстампа YouTube добавляю метку времени в ссылку на оригинальную страницу:
if (info.time !== null)
{
const url = new URL(tab.url);
url.searchParams.set('t', info.time + 's');
data.uri = url.href;
}
С позицией прокрутки уже сложнее, при возобновлении страницы сохраняю позицию во временном хранилище.
const storage = await ScrollPositions.load();
await storage.set(tab.id, original.scrollPosition);
А на событие chrome.tabs.onUpdated
инжектирую скрипт для восстановления прокрутки:
const positions = await ScrollPositions.load();
const scroll = positions.get(tabId);
if (scroll !== null)
{
await chrome.scripting.executeScript({
target: { tabId: tabId, },
world: 'MAIN',
func: (scroll: number) =>
{
document.documentElement.scrollTop = scroll;
},
args: [scroll],
});
await positions.remove(tabId);
}
Проверял работу этой функции на первой попавшейся странице (это была страница Steam'а), и потратил прилично времени на попытки разобраться почему работает неправильно: восстанавливает не на той позиции, на которой должно быть. Как оказалось работает правильно, просто Steam после загрузки страницы загружает ещё дополнительные данные, из-за чего позиция сбивается.
Поэтому в итоговом варианте добавлена задержка на полсекунды для повторного восстановления позиции прокрутки:
func: (scroll: number) =>
{
document.documentElement.scrollTop = scroll;
setTimeout(() => document.documentElement.scrollTop = scroll, 500);
},
Опциональные разрешения
Пришло время сделать обязательными только минимально необходимые разрешения для базовой работы расширения, а остальные запрашивать по необходимости при включении опций, которым они требуются.
return await chrome.permissions.contains(permissions)
|| await chrome.permissions.request(permissions);
И опять подводные камни, которые выяснились только после публикации расширения в Chrome Web Store.
Разрешение file://*/*
это особое разрешение, которое нельзя запросить - будет ошибка при попытке это сделать.
Вместо этого пользователь сам должен в браузере на странице разрешения включить опцию Разрешить открывать локальные файлы по ссылкам. Кстати, включение этой опции закрывает все страницы расширения, то есть все приостановленные страницы пропадают.
Почему не заметил этого раньше? Для распакованных расширений (не из магазина) эта опция по умолчанию включена, поэтому при тестировании прошло полностью незамеченным.
Иконки
Для получения иконки вкладки достаточно добавить разрешение favicon и становится доступным свойство вкладки favIconUrl
.
Для вкладок, перенесенных из других расширений, для которых неизвестна иконка, используется гугловский сервис иконок: https://www.google.com/s2/favicons?sz=32&domain=${domain}
.
Сделать изображение полупрозрачным несложно: рисуем на канвасе, после чего умножаем альфа-канал на 0.5.
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (ctx === null)
{
resolve(this.url);
return;
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// make semi-transparent
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = data.data;
for (let x = 0; x < pixels.length; x += 4)
{
pixels[x + 3] = Math.round((pixels[x + 3] ?? 0) * 0.5);
}
ctx.putImageData(data, 0, 0);
resolve(canvas.toDataURL());
Но canvas.toDataURL()
не работает, если нет разрешения на доступ к сайту, на котором расположена иконка.
Поэтому в настройках появилось три режима работы иконок:
реальная иконка без полупрозрачности, за исключением сайтов без иконок (для них используется иконка расширения) или с иконкой в data/image
иконка от Google с полупрозрачностью, не всегда совпадает с реальной, требует доступа только к google.com
реальная иконка с полупрозрачностью, требует доступа ко всем сайтам
Страницы расширения
Скажу честно, страница настроек была сгенерирована в ChatGPT по предоставленной базовой структуре HTML с описанием, стили для всех страниц тоже сгенерированы в ChatGPT. Но конечный результат всё равно необходимо дорабатывать руками.
Миграция с устаревших разрешений
Для перехода с The Great Suspender и аналогов можно возобновить все вкладки в старом расширении, а потом приостановить в новом, но хотелось способа удобнее и проще.
Поэтому добавил возможность автоматического переноса вкладок по ID устаревшего расширения. Скрипт проходит по всем страницам расширения, из hash
URL'a получает данные приостановленной страницы, если данные корректные (как минимум есть ссылка на оригинал), то заменяет вкладку на страницу своего расширения.
Доработки по результатам эксплуатации
В процессе полноценного использования расширения выявились новые проблемы.
Миграция процесс медленный (несколько минут для несколько сотен вкладок) и визуально малозаметный, поэтому кажется, что ничего не происходит, поэтому добавил прогресс бар с обновляющимся количеством обработанных страниц.
Такая же проблема с приостановкой/возобновлением нескольких вкладок из всплывающего окна, тут просто добавил прогресс бар, чтобы показать, что процесс идёт.
В фоновой вкладке играет музыка, ставлю на паузу через медиа-кнопку на клавиатуре, через пару минут пытаюсь восстановить, а вкладка уже приостановлена, хотя таймер на час.
Причина в свойстве вкладки lastAccessed
, которое не то, чем кажется: это не когда пользователь взаимодействовал с вкладкой, а когда вкладка была активной в последний раз (если кто сомневается в важности правильного наименования, то вот пример как не надо делать).
Решение: отслеживать переключения проигрывания аудио и сохранять это время отдельно, а при приостановки вкладки проверять наибольшее время из lastAccessed
и переключения аудио.
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) =>
{
if (!isValidTab(tab))
{
return;
}
if ('audible' in changeInfo)
{
await TabInfo.activated(tabId);
}
// ...
}
Та же проблема с возобновлением нескольких вкладок: после возобновления вкладки тут же приостанавливаются обратно. Решение такое же: сохранять время возобновления.
Решил немного оптимизировать процесс проверки вкладок для приостановки: исходя из предположения, что большинство вкладок уже приостановлены, то быстрее запросить потенциально нужные (http://, https://, file://) вместо получения всех вкладок и последующей фильтрации (по тестам быстрее в 5-6 раз).
Вместо return await chrome.tabs.query(query);
, который вернёт почти все вкладки, включая приостановленные, получилось:
query.url = 'https://*/*';
const secure = await chrome.tabs.query(query);
query.url = 'http://*/*';
const unsecure = await chrome.tabs.query(query);
query.url = 'file://*/*';
const file = filesSchemeAllowed
? await chrome.tabs.query(query)
: [];
return secure.concat(unsecure, file);
Но тут уже сам накосячил и забыл, что эта же функция используется для получения вкладов для возобновления, поэтому возобновление нескольких вкладок сломалось, в итоге разделил на две функции - отдельно для приостанавливаемых вкладок и возобновляемых вкладок.
query.url = chrome.runtime.getURL(PAGE.Suspended);
return await chrome.tabs.query(query);
Итог
Изначально казалось, что разработка расширения займёт несколько часов, на деле ушло три дня на версию 1.0 c последующими небольшими доработками на протяжении пары недель.
Не рассказал про создание страницы настроек и сохранение/импорт/экспорт вкладок, но там по большей части банально и мало интересного.