diff --git a/e2e/editor-sidepanel.spec.ts b/e2e/editor-sidepanel.spec.ts index bc3602a37..a1d21ab27 100644 --- a/e2e/editor-sidepanel.spec.ts +++ b/e2e/editor-sidepanel.spec.ts @@ -181,12 +181,9 @@ test('a side panel that shows the editor stays the chosen position', async ({ await extension.evaluate(async () => { const items = await chrome.storage.local.get([ 'options', - 'sidePanelIgnoresTabs', + 'chromiumVariant', ]); - return [ - items['sidePanelIgnoresTabs'], - items['options'].layout.dockLocation, - ]; + return [items['chromiumVariant'], items['options'].layout.dockLocation]; }) ).toEqual([undefined, 'sidepanel']); expect(await isSidePanelOpen(extension)).toBe(true); diff --git a/src/apps/background/global-commands.test.ts b/src/apps/background/global-commands.test.ts index 728123018..d6d4dae07 100644 --- a/src/apps/background/global-commands.test.ts +++ b/src/apps/background/global-commands.test.ts @@ -3,7 +3,7 @@ jest.mock('./restore-tabs', () => ({ restoreTab: jest.fn(async () => undefined), })); jest.mock('./side-panel-check', () => ({ - canUseEditorSidePanel: () => !!chrome.sidePanel, + opensEditorInSidePanel: () => !!chrome.sidePanel && mockDockedInSidePanel, onEditorSidePanelOpened: jest.fn(), })); jest.mock('./styles', () => ({ @@ -16,6 +16,8 @@ import { restoreTab } from './restore-tabs'; import { applyStylesToAllTabs, toggleForPage } from './styles'; import { handleCommand } from './global-commands'; +let mockDockedInSidePanel = true; + const PAGE = { id: 7, url: 'https://example.com/' } as chrome.tabs.Tab; /** @@ -68,6 +70,7 @@ const flush = () => new Promise(resolve => setTimeout(resolve)); describe('handleCommand', () => { beforeEach(() => { jest.clearAllMocks(); + mockDockedInSidePanel = true; }); it('asks the page to carry out a shortcut that acts on it', async () => { @@ -106,7 +109,7 @@ describe('handleCommand', () => { expect(api.tabs.sendMessage).not.toBeCalled(); }); - it('leaves the editor to the page where the side panel isn’t the dock', async () => { + it('leaves the editor to the page where the side panel can’t open', async () => { const api = makeChrome({ panelSetUp: false }); handleCommand('stylebot', PAGE); @@ -119,6 +122,21 @@ describe('handleCommand', () => { ); }); + it('leaves the editor to the page where the side panel isn’t the dock', async () => { + mockDockedInSidePanel = false; + const api = makeChrome({ panelSetUp: true }); + + handleCommand('stylebot', PAGE); + await flush(); + + expect(api.sidePanel?.open).not.toBeCalled(); + expect(api.tabs.sendMessage).toBeCalledWith( + 7, + { name: 'RunCommand', command: 'stylebot' }, + expect.any(Function) + ); + }); + it('leaves the editor to the page in a browser without a side panel', async () => { const api = makeChrome({ sidePanel: false }); diff --git a/src/apps/background/global-commands.ts b/src/apps/background/global-commands.ts index 1472cb08d..f5f9871b1 100644 --- a/src/apps/background/global-commands.ts +++ b/src/apps/background/global-commands.ts @@ -10,8 +10,8 @@ import * as editorWindow from './editor-window'; import { restoreTab } from './restore-tabs'; import { applyStylesToAllTabs, toggleForPage } from './styles'; import { - canUseEditorSidePanel, onEditorSidePanelOpened, + opensEditorInSidePanel, } from './side-panel-check'; const isCommandName = (command: string): command is StylebotCommandName => @@ -59,7 +59,7 @@ const runInPage = (tabId: number, command: StylebotCommandName): void => { /** * Toggles the editor where the side panel is the dock. The panel is set up * on every tab ahead of time then, so it opens here, before anything is - * awaited and the shortcut's gesture lapses; elsewhere the open fails and + * awaited and the shortcut's gesture lapses. Where the open fails anyway, * the page toggles its own editor. */ const toggleEditorWithSidePanel = (tabId: number): void => { @@ -109,7 +109,7 @@ export const handleCommand = ( if (command === 'stylebot') { if (target.fromEditorWindow) { editorWindow.close(target.tabId); - } else if (canUseEditorSidePanel()) { + } else if (opensEditorInSidePanel()) { toggleEditorWithSidePanel(target.tabId); } else { runInPage(target.tabId, command); diff --git a/src/apps/background/listeners.test.ts b/src/apps/background/listeners.test.ts index 6cb23bd21..4469f957d 100644 --- a/src/apps/background/listeners.test.ts +++ b/src/apps/background/listeners.test.ts @@ -18,6 +18,10 @@ jest.mock('./restore-tabs', () => ({ })); jest.mock('./badge', () => ({ updateBadge: jest.fn() })); jest.mock('./options', () => ({ get: jest.fn(), pruneRetired: jest.fn() })); +jest.mock('./side-panel-check', () => ({ + initSidePanelCheck: jest.fn(), + onEditorSidePanelOpened: jest.fn(), +})); jest.mock('./sync-scheduler', () => ({ AWAY_SECONDS: 60, isSyncAlarm: jest.fn(), diff --git a/src/apps/background/listeners.ts b/src/apps/background/listeners.ts index d1191e4d5..5caced08f 100644 --- a/src/apps/background/listeners.ts +++ b/src/apps/background/listeners.ts @@ -365,6 +365,9 @@ export const initListeners = (): void => { case 'EditorSidePanelOpened': onEditorSidePanelOpened(message.tabId); break; + case 'RunPageCommand': + handleCommand(message.command, sender.tab); + break; case 'RestoreTab': restoreTab(message.tabId).then(() => sendResponse({ ok: true })); break; diff --git a/src/apps/background/options.test.ts b/src/apps/background/options.test.ts index 59f4b245a..ae9fe6159 100644 --- a/src/apps/background/options.test.ts +++ b/src/apps/background/options.test.ts @@ -18,11 +18,21 @@ beforeEach(() => { storage: { local: { get: jest.fn( - (key: string) => + (keys: string | Array) => new Promise(resolve => { // Simulate the read taking a tick, so a second set() call can // start before the first one's read-modify-write finishes. - setTimeout(() => resolve({ [key]: store[key] }), 10); + setTimeout( + () => + resolve( + Object.fromEntries( + ([] as Array) + .concat(keys) + .map(key => [key, store[key]]) + ) + ), + 10 + ); }) ), set: jest.fn( @@ -81,7 +91,25 @@ describe('getAll', () => { expect((await getAll()).layout.dockLocation).toBe('right'); }); - it('docks right where the browser ignores per-tab side panels', async () => { + it('docks right where the browser won’t show the side panel', async () => { + store = { + chromiumVariant: 'arc', + options: { + layout: { + width: 360, + adjustPageLayout: false, + dockLocation: 'sidepanel', + }, + }, + }; + (chrome as unknown as { sidePanel: object }).sidePanel = { + open: jest.fn(), + }; + + expect((await getAll()).layout.dockLocation).toBe('right'); + }); + + it('docks right in Vivaldi, as 4.0 recorded it', async () => { store = { sidePanelIgnoresTabs: true, options: { diff --git a/src/apps/background/options.ts b/src/apps/background/options.ts index fb143a624..e1afd65cd 100644 --- a/src/apps/background/options.ts +++ b/src/apps/background/options.ts @@ -1,7 +1,8 @@ import type { StylebotOptions } from '@stylebot/types'; import { defaultOptions } from '@stylebot/settings'; import { - isSidePanelIgnoringTabs, + getChromiumVariant, + doesSidePanelWorkInChromiumVariant, supportsEditorSidePanel, } from '@stylebot/utils'; @@ -13,15 +14,20 @@ export const getAll = async (): Promise => { const items = await chrome.storage.local.get('options'); const options: StylebotOptions = { ...defaultOptions, ...items['options'] }; - // Without a side panel the editor docks in the page, where it used to. - if ( - options.layout.dockLocation === 'sidepanel' && - (!supportsEditorSidePanel() || (await isSidePanelIgnoringTabs())) - ) { - return { ...options, layout: { ...options.layout, dockLocation: 'right' } }; + if (options.layout.dockLocation !== 'sidepanel') { + return options; } - return options; + const variant = await getChromiumVariant(); + const hasSidePanel = + supportsEditorSidePanel() && doesSidePanelWorkInChromiumVariant(variant); + + if (hasSidePanel) { + return options; + } + + // Without a side panel the editor docks in the page, where it used to. + return { ...options, layout: { ...options.layout, dockLocation: 'right' } }; }; export const get = async ( diff --git a/src/apps/background/side-panel-check.test.ts b/src/apps/background/side-panel-check.test.ts index 5d7e9289d..a4b8bd6e6 100644 --- a/src/apps/background/side-panel-check.test.ts +++ b/src/apps/background/side-panel-check.test.ts @@ -1,15 +1,16 @@ -import { - isEditorSidePanelOpen, - isSidePanelIgnoringTabs, -} from '@stylebot/utils'; +import { isEditorSidePanelOpen, getChromiumVariant } from '@stylebot/utils'; import { get as getOption, set as setOption } from './options'; -import { onEditorSidePanelOpened } from './side-panel-check'; +import { + initSidePanelCheck, + onEditorSidePanelOpened, + opensEditorInSidePanel, +} from './side-panel-check'; jest.mock('@stylebot/utils', () => ({ ...jest.requireActual('@stylebot/utils'), isEditorSidePanelOpen: jest.fn(), - isSidePanelIgnoringTabs: jest.fn(), + getChromiumVariant: jest.fn(), supportsEditorSidePanel: () => true, })); jest.mock('./options', () => ({ @@ -41,7 +42,7 @@ beforeEach(() => { jest.clearAllMocks(); (getOption as jest.Mock).mockResolvedValue(layout); (isEditorSidePanelOpen as jest.Mock).mockResolvedValue(false); - (isSidePanelIgnoringTabs as jest.Mock).mockResolvedValue(false); + (getChromiumVariant as jest.Mock).mockResolvedValue(undefined); global.chrome = { tabs: { sendMessage: jest.fn() }, runtime: {}, @@ -70,9 +71,9 @@ describe('onEditorSidePanelOpened', () => { }); it('docks the editor in the page once the panel reports it is hosted as a tab', async () => { - (isSidePanelIgnoringTabs as jest.Mock) - .mockResolvedValueOnce(false) - .mockResolvedValue(true); + (getChromiumVariant as jest.Mock) + .mockResolvedValueOnce(undefined) + .mockResolvedValue('vivaldi'); await check(); @@ -84,7 +85,7 @@ describe('onEditorSidePanelOpened', () => { }); it('opens the editor in the page at once when the browser is already known', async () => { - (isSidePanelIgnoringTabs as jest.Mock).mockResolvedValue(true); + (getChromiumVariant as jest.Mock).mockResolvedValue('arc'); (getOption as jest.Mock).mockResolvedValue({ ...layout, dockLocation: 'right', @@ -97,3 +98,52 @@ describe('onEditorSidePanelOpened', () => { openStylebot(); }); }); + +describe('opensEditorInSidePanel', () => { + let onChanged: ( + changes: Record, + area: string + ) => void; + + beforeEach(() => { + global.chrome = { + sidePanel: { open: jest.fn() }, + storage: { + onChanged: { + addListener: jest.fn(listener => { + onChanged = listener; + }), + }, + }, + } as unknown as typeof chrome; + }); + + it('tries the side panel until the dock is read', () => { + jest.isolateModules(() => { + const check = jest.requireActual('./side-panel-check'); + expect(check.opensEditorInSidePanel()).toBe(true); + }); + }); + + it('follows the dock as it changes', async () => { + initSidePanelCheck(); + await jest.runAllTimersAsync(); + expect(opensEditorInSidePanel()).toBe(true); + + onChanged( + { + options: { newValue: { layout: { ...layout, dockLocation: 'right' } } }, + }, + 'local' + ); + expect(opensEditorInSidePanel()).toBe(false); + }); + + it('stays in the page once the browser is found not to show it', async () => { + initSidePanelCheck(); + await jest.runAllTimersAsync(); + + onChanged({ chromiumVariant: { newValue: 'arc' } }, 'local'); + expect(opensEditorInSidePanel()).toBe(false); + }); +}); diff --git a/src/apps/background/side-panel-check.ts b/src/apps/background/side-panel-check.ts index 3da786bb1..b2127789a 100644 --- a/src/apps/background/side-panel-check.ts +++ b/src/apps/background/side-panel-check.ts @@ -1,49 +1,74 @@ import type { OpenStylebot, StylebotLayout } from '@stylebot/types'; import { - SIDE_PANEL_IGNORED_KEY, + CHROMIUM_VARIANT_KEY, + getChromiumVariant, isEditorSidePanelOpen, - isSidePanelIgnoringTabs, openEditorSidePanel, + doesSidePanelWorkInChromiumVariant, supportsEditorSidePanel, } from '@stylebot/utils'; +import type { ChromiumVariant } from '@stylebot/utils'; import { get as getOption, set as setOption } from './options'; const LOAD_TIMEOUT_MS = 2000; const POLL_MS = 250; -let ignoresTabs = false; +let chromiumVariant: ChromiumVariant | undefined; +let isDock: boolean | undefined; /** * Whether the editor can open in the browser's side panel: it has one, and - * hasn't been found to ignore per-tab panels. Answers at once, so callers - * can still open the panel during the user's gesture. + * shows it. Answers at once, so callers can still open the panel during the + * user's gesture. */ export const canUseEditorSidePanel = (): boolean => - supportsEditorSidePanel() && !ignoresTabs; + supportsEditorSidePanel() && + doesSidePanelWorkInChromiumVariant(chromiumVariant); /** - * Keeps canUseEditorSidePanel's answer in step with storage. + * Whether the editor opens in the side panel. Says yes until the dock is + * read after the worker wakes; callers fall back to the page. + */ +export const opensEditorInSidePanel = (): boolean => + isDock !== false && canUseEditorSidePanel(); + +/** + * Keeps canUseEditorSidePanel's and opensEditorInSidePanel's answers in step + * with storage. */ export const initSidePanelCheck = (): void => { - isSidePanelIgnoringTabs().then(value => { - ignoresTabs = value; + getChromiumVariant().then(variant => { + chromiumVariant = variant; + }); + + getOption('layout').then(layout => { + isDock = (layout as StylebotLayout).dockLocation === 'sidepanel'; }); chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local' && changes[SIDE_PANEL_IGNORED_KEY]) { - ignoresTabs = !!changes[SIDE_PANEL_IGNORED_KEY].newValue; + if (area !== 'local') { + return; + } + + if (changes[CHROMIUM_VARIANT_KEY]) { + chromiumVariant = changes[CHROMIUM_VARIANT_KEY].newValue; + } + + if (changes['options']) { + isDock = + changes['options'].newValue?.layout?.dockLocation === 'sidepanel'; } }); }; /** - * Waits for the side panel to report that the browser hosts it as a tab, - * which it does as it loads (Vivaldi), or for the editor to show up in it. + * Waits for the side panel to find, as it loads, that the browser won't show + * it (Vivaldi, Arc), or for the editor to show up in it. */ -const waitForPanelHostedAsTab = async (tabId: number): Promise => { +const waitForHiddenPanel = async (tabId: number): Promise => { for (let waited = 0; waited < LOAD_TIMEOUT_MS; waited += POLL_MS) { - if (await isSidePanelIgnoringTabs()) { + if (!doesSidePanelWorkInChromiumVariant(await getChromiumVariant())) { return true; } if (await isEditorSidePanelOpen(tabId)) { @@ -55,12 +80,12 @@ const waitForPanelHostedAsTab = async (tabId: number): Promise => { }; /** - * Follows up a side panel just opened on the tab. In a browser that ignores - * per-tab panels (Vivaldi) the panel closes itself, so the editor docks in - * the page from now on, and opens there. + * Follows up a side panel just opened on the tab. In a browser that won't + * show it (Vivaldi, Arc) the panel closes itself, so the editor docks in the + * page from now on, and opens there. */ export const onEditorSidePanelOpened = async (tabId: number): Promise => { - if (!(await waitForPanelHostedAsTab(tabId))) { + if (!(await waitForHiddenPanel(tabId))) { return; } diff --git a/src/apps/editor/content-script.ts b/src/apps/editor/content-script.ts index 5f4a2b1f5..d0402ae8f 100644 --- a/src/apps/editor/content-script.ts +++ b/src/apps/editor/content-script.ts @@ -10,6 +10,7 @@ import type { TabMessage } from '@stylebot/types'; import { REMOTE_PAGE_BRIDGE_PORT } from '@stylebot/page-bridge'; +import { initPageShortcuts } from './page-shortcuts'; import type { EditorApp } from './load-editor'; import { isEditorLoading, loadEditor } from './load-editor'; import { loadInspector } from './load-inspector'; @@ -201,4 +202,5 @@ const removeDeadEditor = (): void => { if (isStylableDocument(document.contentType)) { removeDeadEditor(); listen(); + initPageShortcuts(); } diff --git a/src/apps/editor/page-shortcuts.test.ts b/src/apps/editor/page-shortcuts.test.ts new file mode 100644 index 000000000..79e3b85ae --- /dev/null +++ b/src/apps/editor/page-shortcuts.test.ts @@ -0,0 +1,122 @@ +import { CHROMIUM_VARIANT_KEY } from '@stylebot/utils'; + +import { initPageShortcuts, toCombo } from './page-shortcuts'; + +const press = (init: KeyboardEventInit) => { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...init, + }); + document.body.dispatchEvent(event); + return event; +}; + +describe('toCombo', () => { + it('names the physical key, whatever Option types', () => { + expect( + toCombo( + new KeyboardEvent('keydown', { + code: 'KeyM', + key: 'Â', + altKey: true, + shiftKey: true, + }) + ) + ).toBe('alt+shift+m'); + }); + + it('orders modifiers as the browser shortcut combos do', () => { + expect( + toCombo( + new KeyboardEvent('keydown', { + code: 'Digit1', + ctrlKey: true, + metaKey: true, + shiftKey: true, + }) + ) + ).toBe('ctrl+shift+command+1'); + }); + + it('ignores a modifier pressed on its own', () => { + expect(toCombo(new KeyboardEvent('keydown', { code: 'ShiftLeft' }))).toBe( + '' + ); + }); +}); + +describe('initPageShortcuts', () => { + let sendMessage: jest.Mock; + + const init = async (arc: boolean) => { + if (arc) { + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + } + initPageShortcuts(); + await jest.runAllTimersAsync(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + document.documentElement.removeAttribute('style'); + jest + .spyOn(navigator, 'userAgent', 'get') + .mockReturnValue('Mozilla/5.0 Chrome/141.0.0.0 Safari/537.36'); + sendMessage = jest.fn(async (message: { name: string }) => + message.name === 'GetCommands' + ? { stylebot: 'alt+shift+m', style: 'alt+shift+s' } + : undefined + ); + global.chrome = { + runtime: { sendMessage }, + storage: { + local: { + get: jest.fn(async () => ({})), + set: jest.fn(async () => undefined), + }, + }, + } as unknown as typeof chrome; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + // First, since the listeners the Arc tests add stay on the window. + it('leaves shortcuts to the browser elsewhere', async () => { + await init(false); + + press({ code: 'KeyS', altKey: true, shiftKey: true }); + + expect(sendMessage).not.toBeCalled(); + expect(chrome.storage.local.set).not.toBeCalled(); + }); + + it('has the background carry out a shortcut pressed in Arc', async () => { + await init(true); + + const event = press({ code: 'KeyS', altKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(sendMessage).toBeCalledWith({ + name: 'RunPageCommand', + command: 'style', + }); + expect(chrome.storage.local.set).toBeCalledWith({ + [CHROMIUM_VARIANT_KEY]: 'arc', + }); + }); + + it('lets other keys through in Arc', async () => { + await init(true); + + const event = press({ code: 'KeyS', altKey: true }); + + expect(event.defaultPrevented).toBe(false); + expect(sendMessage).not.toBeCalledWith( + expect.objectContaining({ name: 'RunPageCommand' }) + ); + }); +}); diff --git a/src/apps/editor/page-shortcuts.ts b/src/apps/editor/page-shortcuts.ts new file mode 100644 index 000000000..1476b49f8 --- /dev/null +++ b/src/apps/editor/page-shortcuts.ts @@ -0,0 +1,109 @@ +import type { + GetCommands, + RunPageCommand, + StylebotCommands, +} from '@stylebot/types'; +import { + COMMAND_NAMES, + detectChromiumVariant, + doShortcutsWorkInChromiumVariant, +} from '@stylebot/utils'; + +import { whenDomReady } from './utils/dom-ready'; + +const NAMED_CODES: Record = { + Comma: ',', + Period: '.', + PageUp: 'pageup', + PageDown: 'pagedown', + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + Space: 'space', + Home: 'home', + End: 'end', + Insert: 'insert', + Delete: 'delete', +}; + +/** + * The key a keydown is for, as a Stylebot combo names it. Read from the + * physical key, since Option on macOS turns the typed character into another. + */ +const keyOf = (code: string): string | undefined => { + const letter = /^Key([A-Z])$/.exec(code); + if (letter) { + return letter[1].toLowerCase(); + } + + const digit = /^Digit([0-9])$/.exec(code); + if (digit) { + return digit[1]; + } + + if (/^F([1-9]|1[0-2])$/.test(code)) { + return code.toLowerCase(); + } + + return NAMED_CODES[code]; +}; + +/** + * Turns a keydown into a Stylebot combo ("alt+shift+m"), in the modifier + * order fromBrowserShortcut writes. + */ +export const toCombo = (event: KeyboardEvent): string => { + const key = keyOf(event.code); + if (!key) { + return ''; + } + + return [ + event.ctrlKey && 'ctrl', + event.altKey && 'alt', + event.shiftKey && 'shift', + event.metaKey && 'command', + key, + ] + .filter(Boolean) + .join('+'); +}; + +/** + * Catches Stylebot's global shortcuts in the page where the browser doesn't + * pass them on, and has the background carry them out as it would a + * browser's. + */ +export const initPageShortcuts = (): void => { + whenDomReady(async () => { + if (doShortcutsWorkInChromiumVariant(await detectChromiumVariant())) { + return; + } + + const getCommands: GetCommands = { name: 'GetCommands' }; + const commands: StylebotCommands = await chrome.runtime.sendMessage( + getCommands + ); + + window.addEventListener( + 'keydown', + event => { + const combo = toCombo(event); + const command = COMMAND_NAMES.find( + name => combo && commands[name] === combo + ); + if (!command) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const message: RunPageCommand = { name: 'RunPageCommand', command }; + chrome.runtime.sendMessage(message); + }, + true + ); + }); +}; diff --git a/src/apps/editor/utils/side-panel.ts b/src/apps/editor/utils/side-panel.ts index 54886e341..0aff48a24 100644 --- a/src/apps/editor/utils/side-panel.ts +++ b/src/apps/editor/utils/side-panel.ts @@ -1,13 +1,17 @@ -import { isSidePanelIgnoringTabs } from '@stylebot/utils'; +import { + getChromiumVariant, + doesSidePanelWorkInChromiumVariant, +} from '@stylebot/utils'; +import type { ChromiumVariant } from '@stylebot/utils'; -let ignoresTabs = false; +let chromiumVariant: ChromiumVariant | undefined; /** - * Reads whether this browser was found to ignore per-tab side panels, for + * Reads whether this is a browser that won't show the side panel, for * hasSidePanel. The store calls this as it initializes. */ export const loadSidePanelSupport = async (): Promise => { - ignoresTabs = await isSidePanelIgnoringTabs().catch(() => false); + chromiumVariant = await getChromiumVariant().catch(() => undefined); }; /** @@ -16,4 +20,5 @@ export const loadSidePanelSupport = async (): Promise => { * and content scripts can't see chrome.sidePanel, so this goes by the browser. */ export const hasSidePanel = (): boolean => - /Chrome\//.test(navigator.userAgent) && !ignoresTabs; + /Chrome\//.test(navigator.userAgent) && + doesSidePanelWorkInChromiumVariant(chromiumVariant); diff --git a/src/apps/editor/window/index.ts b/src/apps/editor/window/index.ts index bf5acd83f..16e08a35d 100644 --- a/src/apps/editor/window/index.ts +++ b/src/apps/editor/window/index.ts @@ -2,7 +2,13 @@ import { t } from '@stylebot/i18n'; import { setPageBridge, RemotePageBridge } from '@stylebot/page-bridge'; import { createStore, mountEditor } from '@stylebot/editor'; -import { getPageSupport, markSidePanelIgnoringTabs } from '@stylebot/utils'; +import type { EditorSidePanelOpened } from '@stylebot/types'; +import { + detectChromiumVariant, + detectVivaldiSidePanel, + getPageSupport, + doesSidePanelWorkInChromiumVariant, +} from '@stylebot/utils'; import { openEditorWindow } from '../utils/chrome'; import { initWindowListeners, initTabInfo } from './listeners'; @@ -130,19 +136,33 @@ const init = async (): Promise => { return; } - const [tab, hostTab] = await Promise.all([ + const [tab, inVivaldi] = await Promise.all([ chrome.tabs.get(tabId).catch(() => undefined), - host === 'sidepanel' ? chrome.tabs.getCurrent() : undefined, + host === 'sidepanel' && detectVivaldiSidePanel(), ]); - // Vivaldi hosts side panels as tabs and shares one panel across them all, - // which the editor can't work in; the background then docks it in the page. - if (hostTab) { - await markSidePanelIgnoringTabs(); + // The background docks the editor in the page instead. + if (inVivaldi) { window.close(); return; } + // A browser this page can tell (Arc) loads the panel without showing it, + // so the background has to be told to dock the editor in the page. + if (host === 'sidepanel') { + detectChromiumVariant().then(variant => { + if (!doesSidePanelWorkInChromiumVariant(variant)) { + const message: EditorSidePanelOpened = { + name: 'EditorSidePanelOpened', + tabId, + }; + + chrome.runtime.sendMessage(message); + window.close(); + } + }); + } + const canStyle = await canStyleTab(tab); watchTab(canStyle); diff --git a/src/apps/options/components/basics/ThePosition.vue b/src/apps/options/components/basics/ThePosition.vue index b0991a754..e8adef136 100644 --- a/src/apps/options/components/basics/ThePosition.vue +++ b/src/apps/options/components/basics/ThePosition.vue @@ -39,7 +39,13 @@ import { UndockIcon, } from '@stylebot/icons'; import type { StylebotDockLocation, StylebotLayout } from '@stylebot/types'; -import { supportsEditorSidePanel } from '@stylebot/utils'; +import { + detectChromiumVariant, + getChromiumVariant, + doesSidePanelWorkInChromiumVariant, + supportsEditorSidePanel, +} from '@stylebot/utils'; +import type { ChromiumVariant } from '@stylebot/utils'; type DockOption = { value: StylebotDockLocation; @@ -60,9 +66,16 @@ export default Vue.extend({ UndockIcon, }, + data(): { chromiumVariant: ChromiumVariant | undefined } { + return { chromiumVariant: undefined }; + }, + computed: { hasSidePanel(): boolean { - return supportsEditorSidePanel(); + return ( + supportsEditorSidePanel() && + doesSidePanelWorkInChromiumVariant(this.chromiumVariant) + ); }, layout(): StylebotLayout { @@ -113,6 +126,15 @@ export default Vue.extend({ ]; }, }, + + created() { + const setChromiumVariant = (variant: ChromiumVariant | undefined) => { + this.chromiumVariant = this.chromiumVariant ?? variant; + }; + + getChromiumVariant().then(setChromiumVariant, () => undefined); + detectChromiumVariant().then(setChromiumVariant, () => undefined); + }, }); diff --git a/src/apps/popup/App.vue b/src/apps/popup/App.vue index 497cf605f..4a9c9c6d7 100644 --- a/src/apps/popup/App.vue +++ b/src/apps/popup/App.vue @@ -134,6 +134,7 @@ import type { StylebotLayout, StylebotDockLocation, } from '@stylebot/types'; +import { detectChromiumVariant } from '@stylebot/utils'; import type { PageSupport } from '@stylebot/utils'; export default Vue.extend({ @@ -261,6 +262,8 @@ export default Vue.extend({ }, created() { + detectChromiumVariant(); + getCurrentTab(async tab => { this.tab = tab; this.pageSupport = await getRestoredPageSupport(tab); diff --git a/src/apps/popup/utils.ts b/src/apps/popup/utils.ts index 1743b59f6..20c45905f 100644 --- a/src/apps/popup/utils.ts +++ b/src/apps/popup/utils.ts @@ -21,6 +21,8 @@ import { openDonatePage, supportsEditorSidePanel, openEditorSidePanel, + detectChromiumVariantSync, + doesSidePanelWorkInChromiumVariant, getBrowserCommands, getPageSupport, getRestorePendingKey, @@ -156,10 +158,15 @@ export const toggleStylebot = (tab: chrome.tabs.Tab): void => { /** * Opens the editor in the tab's side panel straight from the popup's click, - * as the page couldn't once its gesture is gone; falls back to the page. + * as the page couldn't once its gesture is gone; falls back to the page, + * as where the browser won't show the panel. */ export const openStylebotSidePanel = (tab: chrome.tabs.Tab): void => { - if (!tab.id || !supportsEditorSidePanel()) { + if ( + !tab.id || + !supportsEditorSidePanel() || + !doesSidePanelWorkInChromiumVariant(detectChromiumVariantSync()) + ) { toggleStylebot(tab); return; } diff --git a/src/core/types/BackgroundPageMessage.ts b/src/core/types/BackgroundPageMessage.ts index 3082ee50f..06753cf3f 100644 --- a/src/core/types/BackgroundPageMessage.ts +++ b/src/core/types/BackgroundPageMessage.ts @@ -1,6 +1,7 @@ import type { StyleMap } from './styles'; import type { StylebotOptions } from './options'; import type { ReadabilitySettings } from './readability'; +import type { StylebotCommandName } from './commands'; export type SetStyle = { name: 'SetStyle'; @@ -225,6 +226,13 @@ export type EditorSidePanelOpened = { tabId: number; }; +// A global shortcut the page caught itself, where the browser doesn't pass +// it on (Arc). +export type RunPageCommand = { + name: 'RunPageCommand'; + command: StylebotCommandName; +}; + export type CloseEditorSidePanel = { name: 'CloseEditorSidePanel'; tabId?: number; @@ -282,6 +290,7 @@ type BackgroundPageMessage = | OpenEditorSidePanel | CloseEditorSidePanel | EditorSidePanelOpened + | RunPageCommand | RestoreTab; export default BackgroundPageMessage; diff --git a/src/core/utils/chromium-variants.test.ts b/src/core/utils/chromium-variants.test.ts new file mode 100644 index 000000000..27df9e8bb --- /dev/null +++ b/src/core/utils/chromium-variants.test.ts @@ -0,0 +1,84 @@ +import { + CHROMIUM_VARIANT_KEY, + detectChromiumVariant, + getChromiumVariant, + doShortcutsWorkInChromiumVariant, + doesSidePanelWorkInChromiumVariant, +} from './chromium-variants'; + +let store: Record; + +beforeEach(() => { + jest.useFakeTimers(); + document.documentElement.removeAttribute('style'); + jest + .spyOn(navigator, 'userAgent', 'get') + .mockReturnValue('Mozilla/5.0 Chrome/141.0.0.0 Safari/537.36'); + store = {}; + global.chrome = { + storage: { + local: { + get: jest.fn(async (keys: Array) => + Object.fromEntries(keys.map(key => [key, store[key]])) + ), + set: jest.fn(async (items: Record) => { + Object.assign(store, items); + }), + }, + }, + } as unknown as typeof chrome; +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('what each variant breaks', () => { + it('works the side panel everywhere but Vivaldi and Arc', () => { + expect(doesSidePanelWorkInChromiumVariant(undefined)).toBe(true); + expect(doesSidePanelWorkInChromiumVariant('vivaldi')).toBe(false); + expect(doesSidePanelWorkInChromiumVariant('arc')).toBe(false); + }); + + it('passes shortcuts on everywhere but Arc', () => { + expect(doShortcutsWorkInChromiumVariant(undefined)).toBe(true); + expect(doShortcutsWorkInChromiumVariant('vivaldi')).toBe(true); + expect(doShortcutsWorkInChromiumVariant('arc')).toBe(false); + }); +}); + +describe('getChromiumVariant', () => { + it('reads Vivaldi as 4.0 recorded it', async () => { + store = { sidePanelIgnoresTabs: true }; + await expect(getChromiumVariant()).resolves.toBe('vivaldi'); + }); +}); + +describe('detectChromiumVariant', () => { + it('finds and remembers Arc once it adds its palette', async () => { + const found = detectChromiumVariant(); + await jest.advanceTimersByTimeAsync(500); + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + await jest.advanceTimersByTimeAsync(500); + + await expect(found).resolves.toBe('arc'); + expect(store[CHROMIUM_VARIANT_KEY]).toBe('arc'); + }); + + it('leaves storage alone when Arc is already known', async () => { + store = { [CHROMIUM_VARIANT_KEY]: 'arc' }; + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + + await expect(detectChromiumVariant()).resolves.toBe('arc'); + expect(chrome.storage.local.set).not.toBeCalled(); + }); + + it('finds nothing in other browsers', async () => { + const found = detectChromiumVariant(); + await jest.advanceTimersByTimeAsync(3000); + + await expect(found).resolves.toBeUndefined(); + expect(chrome.storage.local.set).not.toBeCalled(); + }); +}); diff --git a/src/core/utils/chromium-variants.ts b/src/core/utils/chromium-variants.ts new file mode 100644 index 000000000..1d6459662 --- /dev/null +++ b/src/core/utils/chromium-variants.ts @@ -0,0 +1,124 @@ +/* + * Chromium variants that have Chrome's extension APIs but don't honour them, + * and the only place Stylebot names them. Each is found once, from a page, + * and remembered for every other context. + */ + +export type ChromiumVariant = 'vivaldi' | 'arc'; + +type BrokenFeatures = { + sidePanel: boolean; + shortcuts: boolean; +}; + +const BROKEN_FEATURES: Record = { + // Hosts side panels as tabs, one shared across all tabs. + vivaldi: { sidePanel: true, shortcuts: false }, + // Loads side panels but never shows them, and registers extension + // shortcuts but never passes them on. + arc: { sidePanel: true, shortcuts: true }, +}; + +export const CHROMIUM_VARIANT_KEY = 'chromiumVariant'; + +// What 4.0.0-4.0.3 stored on finding Vivaldi. +const LEGACY_VIVALDI_KEY = 'sidePanelIgnoresTabs'; + +/** + * Whether the editor's side panel works in the variant, or in Chrome and + * the rest when there is none. + */ +export const doesSidePanelWorkInChromiumVariant = ( + variant: ChromiumVariant | undefined +): boolean => !variant || !BROKEN_FEATURES[variant].sidePanel; + +/** + * Whether Stylebot's shortcuts work in the variant, or in Chrome and the + * rest when there is none. + */ +export const doShortcutsWorkInChromiumVariant = ( + variant: ChromiumVariant | undefined +): boolean => !variant || !BROKEN_FEATURES[variant].shortcuts; + +/** + * The Chromium variant Stylebot found itself in, if any. + */ +export const getChromiumVariant = async (): Promise< + ChromiumVariant | undefined +> => { + const items = await chrome.storage.local.get([ + CHROMIUM_VARIANT_KEY, + LEGACY_VIVALDI_KEY, + ]); + + return ( + items[CHROMIUM_VARIANT_KEY] ?? + (items[LEGACY_VIVALDI_KEY] ? 'vivaldi' : undefined) + ); +}; + +/** + * Remembers the Chromium variant Stylebot found itself in. + */ +export const rememberChromiumVariant = async ( + variant: ChromiumVariant +): Promise => { + if ((await getChromiumVariant()) !== variant) { + await chrome.storage.local.set({ [CHROMIUM_VARIANT_KEY]: variant }); + } +}; + +/** + * The Chromium variant showing this page, as far as the page can tell at + * once: only Arc, by the palette variables it adds to every page, since its + * user agent matches Chrome's. Arc adds them a moment after the page loads. + */ +export const detectChromiumVariantSync = (): ChromiumVariant | undefined => { + const style = getComputedStyle(document.documentElement); + + if (style.getPropertyValue('--arc-palette-title')) { + return 'arc'; + } + + return undefined; +}; + +/** + * Finds the Chromium variant showing this page and remembers it, waiting a + * while for Arc to add its palette. + */ +export const detectChromiumVariant = async (): Promise< + ChromiumVariant | undefined +> => { + if (!/Chrome\//.test(navigator.userAgent)) { + return undefined; + } + + const waitMs = 2000; + const pollMs = 250; + + for (let waited = 0; waited <= waitMs; waited += pollMs) { + const variant = detectChromiumVariantSync(); + if (variant) { + await rememberChromiumVariant(variant); + return variant; + } + + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + + return undefined; +}; + +/** + * Finds whether the side panel page was opened as a tab, which is how + * Vivaldi hosts side panels, and remembers it. + */ +export const detectVivaldiSidePanel = async (): Promise => { + if (!(await chrome.tabs.getCurrent())) { + return false; + } + + await rememberChromiumVariant('vivaldi'); + return true; +}; diff --git a/src/core/utils/editor-side-panel.ts b/src/core/utils/editor-side-panel.ts index efc619959..ca0e42cfd 100644 --- a/src/core/utils/editor-side-panel.ts +++ b/src/core/utils/editor-side-panel.ts @@ -1,21 +1,3 @@ -export const SIDE_PANEL_IGNORED_KEY = 'sidePanelIgnoresTabs'; - -/** - * Whether this browser was found to ignore per-tab side panels (Vivaldi), - * which the editor needs. Readable from content scripts too. - */ -export const isSidePanelIgnoringTabs = async (): Promise => - !!(await chrome.storage.local.get(SIDE_PANEL_IGNORED_KEY))[ - SIDE_PANEL_IGNORED_KEY - ]; - -/** - * Remembers that this browser ignores per-tab side panels, so the editor - * stays in the page from now on. - */ -export const markSidePanelIgnoringTabs = (): Promise => - chrome.storage.local.set({ [SIDE_PANEL_IGNORED_KEY]: true }); - /** * Whether this browser can host the editor in its side panel. Content * scripts can't see chrome.sidePanel, so they can't use this. diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 9480cc889..5b409da75 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -41,14 +41,22 @@ export { } from './open-page'; export { supportsEditorSidePanel, - SIDE_PANEL_IGNORED_KEY, - isSidePanelIgnoringTabs, - markSidePanelIgnoringTabs, configureEditorSidePanel, openEditorSidePanel, closeEditorSidePanel, isEditorSidePanelOpen, } from './editor-side-panel'; +export { + CHROMIUM_VARIANT_KEY, + doesSidePanelWorkInChromiumVariant, + doShortcutsWorkInChromiumVariant, + getChromiumVariant, + rememberChromiumVariant, + detectChromiumVariantSync, + detectChromiumVariant, + detectVivaldiSidePanel, +} from './chromium-variants'; +export type { ChromiumVariant } from './chromium-variants'; export { fromBrowserShortcut, toBrowserShortcut } from './browser-shortcut'; export { COMMAND_NAMES,