Files
iptvnator/apps/electron-backend-e2e/src/back-gestures.e2e.ts
T
aecb9acb8e feat(workspace): route platform Back gestures to the header Back (#1943)
* feat(workspace): route platform Back gestures to the header Back

The mouse Back button, Alt+Left (Cmd+[ on macOS) and, in Electron, the
window's app-command browser-backward (Windows/Linux) and trackpad swipe
(macOS) now run the workspace header's Back, one step each.

- WorkspaceBackGesturesService (shell-provided) dispatches every gesture
  through WorkspaceBackNavigationService.goBack(). A gesture is swallowed
  while a dialog, menu, the phone drawer or a fullscreen player is up, and
  left to the browser only where the header has no Back.
- The chord is a navigate-back entry in the shortcuts dialog, handled in a
  capture-phase listener of WorkspaceKeyboardShortcutsService; ignored while
  typing, repeats swallowed.
- Electron forwards app-command and swipe over the push-only channel
  WINDOW:BACK_REQUESTED (preload onWindowBackRequested), gated in the
  renderer on RuntimeCapabilitiesService.supportsWindowBackGestures.
- A mouse press that also arrives as app-command goes back once, however
  long the button is held; Electron's own Chromium history step on the
  mouse button is always prevented.
- No Forward binding.

Docs: workspace-shell (Platform gestures), portal-detail-navigation,
electron-security, UI guidelines, README shortcuts table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(workspace): drop NgZone from the Back gestures service

Master runs zoneless change detection and no longer uses NgZone; the
bridge callback calls goBack() directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): keep Back gestures where the Fullscreen API is missing

iPhone Safari has no unprefixed document.fullscreenElement; the
undefined value read as "a player is fullscreen" and blocked every
gesture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): keep a held Back chord's repeats swallowed after Back

Repeats asked for the current result, so once the first press reached a
page with no Back left they were released to the browser, which could take
another history step. Repeats now follow the chord's first press until a
key is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): one Back per press of the Linux Browser Back key

On Linux Electron raises app-command browser-backward for every
unhandled event of the Browser Back key (NativeWindowViews::
HandleKeyboardEvent), keydown and keyup alike, so one press went back
twice. The main process now consumes that key in before-input-event and
requests Back once per press; mouse Back buttons still arrive as
app-command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:15:42 +02:00

265 lines
9.7 KiB
TypeScript

import type { CDPSession, Page } from '@playwright/test';
import {
addXtreamPortal,
clickFirstGridListCard,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
type LaunchedElectronApp,
} from './electron-test-fixtures';
// ---------------------------------------------------------------------------
// Platform Back gestures run the workspace header's Back: the mouse Back
// button, Alt+← (Cmd+[ on macOS), and the window's `app-command`
// (`browser-backward`, Windows/Linux) and trackpad `swipe` (macOS), which the
// main process forwards to the renderer. Each goes back exactly one step. A
// mouse Back press that reaches the app both as a renderer `mouseup` and as
// an `app-command` (Windows and Linux) still goes back once. There is no
// Forward. Gestures are ignored while typing (the chord) and while a dialog
// is open.
//
// The window events are emitted on the main process's BrowserWindow, so the
// specs run on every platform even though Electron raises each event only
// on its own.
// ---------------------------------------------------------------------------
const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/;
const backChord =
process.platform === 'darwin' ? 'Meta+BracketLeft' : 'Alt+ArrowLeft';
type WindowGesture =
| { event: 'app-command'; value: 'browser-backward' | 'browser-forward' }
| { event: 'swipe'; value: 'left' | 'right' };
async function emitWindowGesture(
app: LaunchedElectronApp,
gesture: WindowGesture
): Promise<void> {
await app.electronApp.evaluate(({ BrowserWindow }, { event, value }) => {
const [win] = BrowserWindow.getAllWindows();
win.emit(event, {}, value);
}, gesture);
}
/** Half of a mouse Back press through Chromium's input pipeline. */
async function mouseBack(
cdp: CDPSession,
page: Page,
type: 'mousePressed' | 'mouseReleased'
): Promise<void> {
const box = await page.locator('main').first().boundingBox();
await cdp.send('Input.dispatchMouseEvent', {
type,
x: Math.round((box?.x ?? 0) + (box?.width ?? 400) / 2),
y: Math.round((box?.y ?? 0) + (box?.height ?? 400) / 2),
button: 'back',
buttons: type === 'mousePressed' ? 8 : 0,
clickCount: 1,
});
}
async function pressMouseBack(cdp: CDPSession, page: Page): Promise<void> {
await mouseBack(cdp, page, 'mousePressed');
await mouseBack(cdp, page, 'mouseReleased');
}
async function openFirstSeries(page: Page): Promise<string> {
await clickFirstGridListCard(page);
await page.waitForURL(detailUrlPattern);
await expect(page.locator('app-portal-detail-shell')).toBeVisible({
timeout: 20_000,
});
return page.url();
}
/** The history index Back gestures must land on, and nothing below it. */
function historyIndex(page: Page): Promise<number> {
return page.evaluate(() => window.navigation?.currentEntry?.index ?? -1);
}
/** Time for a late second step (a duplicated press) to surface. */
function settle(page: Page): Promise<unknown> {
return page.evaluate(
() => new Promise((resolve) => setTimeout(resolve, 400))
);
}
/**
* Waits for a gesture to return to the list, then for any late second step
* to surface before checking the history index.
*/
async function expectBackToList(
page: Page,
listUrl: string,
listIndex: number
): Promise<void> {
await expect(page).toHaveURL(listUrl);
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
await settle(page);
await expect(page).toHaveURL(listUrl);
expect(await historyIndex(page)).toBe(listIndex);
}
/** The Xtream series list, reached by navigation from the start page. */
async function openSeriesList(
page: Page
): Promise<{ listUrl: string; listIndex: number }> {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.getByRole('link', { name: 'Series', exact: true }).click();
await page.waitForURL(/\/series$/);
await expect(
page.locator('.category-content-layout mat-card').first()
).toBeVisible({ timeout: 20_000 });
return { listUrl: page.url(), listIndex: await historyIndex(page) };
}
test.describe('Platform Back gestures', () => {
test('@xtream @electron mouse Back, the Back chord and the window gestures each go back one step', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const cdp = await app.electronApp.context().newCDPSession(page);
const { listUrl, listIndex } = await openSeriesList(page);
await openFirstSeries(page);
await pressMouseBack(cdp, page);
await expectBackToList(page, listUrl, listIndex);
await openFirstSeries(page);
await page.keyboard.press(backChord);
await expectBackToList(page, listUrl, listIndex);
await openFirstSeries(page);
await emitWindowGesture(app, {
event: 'app-command',
value: 'browser-backward',
});
await expectBackToList(page, listUrl, listIndex);
await openFirstSeries(page);
await emitWindowGesture(app, { event: 'swipe', value: 'left' });
await expectBackToList(page, listUrl, listIndex);
// No Forward: Stalker inline details are not history entries.
await emitWindowGesture(app, {
event: 'app-command',
value: 'browser-forward',
});
await emitWindowGesture(app, { event: 'swipe', value: 'right' });
await expectBackToList(page, listUrl, listIndex);
} finally {
await closeElectronApp(app);
}
});
test('@xtream @electron a mouse Back press that also arrives as an app command goes back once', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const cdp = await app.electronApp.context().newCDPSession(page);
const { listUrl, listIndex } = await openSeriesList(page);
await openFirstSeries(page);
await Promise.all([
pressMouseBack(cdp, page),
emitWindowGesture(app, {
event: 'app-command',
value: 'browser-backward',
}),
]);
await expectBackToList(page, listUrl, listIndex);
// The pairing is per press: the next one goes back again.
await openFirstSeries(page);
await emitWindowGesture(app, {
event: 'app-command',
value: 'browser-backward',
});
await pressMouseBack(cdp, page);
await expectBackToList(page, listUrl, listIndex);
// Linux sends the command on press: a long hold is one press.
await openFirstSeries(page);
await mouseBack(cdp, page, 'mousePressed');
await emitWindowGesture(app, {
event: 'app-command',
value: 'browser-backward',
});
await expect(page).toHaveURL(listUrl);
await settle(page);
await settle(page);
await mouseBack(cdp, page, 'mouseReleased');
await expectBackToList(page, listUrl, listIndex);
} finally {
await closeElectronApp(app);
}
});
test('@xtream @electron gestures wait while typing or while a dialog is open', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const cdp = await app.electronApp.context().newCDPSession(page);
const { listUrl, listIndex } = await openSeriesList(page);
// Alt/Option arrows edit text in a field. The list has the
// history fallback, so the chord would otherwise go back.
const search = page.locator(
'app-workspace-shell-header input[type="search"]'
);
await expect(search).toBeEnabled();
await search.focus();
await page.keyboard.press(backChord);
await settle(page);
await expect(page).toHaveURL(listUrl);
await search.blur();
const detailUrl = await openFirstSeries(page);
// One at a time: a gesture that slipped through would navigate,
// and the dialog would close with the navigation.
await page.locator('app-portal-detail-shell').focus();
await page.keyboard.press('?');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
for (const gesture of [
() => page.keyboard.press(backChord),
// Electron's Chromium would step back on its own.
() => pressMouseBack(cdp, page),
() => emitWindowGesture(app, { event: 'swipe', value: 'left' }),
]) {
await gesture();
await settle(page);
await expect(page).toHaveURL(detailUrl);
await expect(dialog).toBeVisible();
}
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await page.keyboard.press(backChord);
await expectBackToList(page, listUrl, listIndex);
} finally {
await closeElectronApp(app);
}
});
});