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>
This commit is contained in:
authored and GitHub committed 2026-10-15 22:15:42 +02:00
1 parent 002faebb08
commit aecb9acb8e
44 files changed
+1758 -8

No files matched your search

+65 -1
View File
@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import type { CDPSession, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
addXtreamPortal,
@@ -49,6 +49,41 @@ async function openAsFirstEntry(page: Page, url: string): Promise<Page> {
return firstEntryPage;
}
/**
* The Back chord for the platform the page reports (the web project emulates
* Windows): Cmd+[ on macOS, Alt+← elsewhere.
*/
async function backChord(page: Page): Promise<string> {
const isMac = await page.evaluate(() =>
/Mac|iPhone|iPad|iPod/i.test(
(navigator as Navigator & { userAgentData?: { platform?: string } })
.userAgentData?.platform || navigator.userAgent
)
);
return isMac ? 'Meta+BracketLeft' : 'Alt+ArrowLeft';
}
/** One mouse Back press through Chromium's input pipeline. */
async function pressMouseBack(cdp: CDPSession, page: Page): Promise<void> {
const box = await page.locator('main').first().boundingBox();
const x = Math.round((box?.x ?? 0) + (box?.width ?? 400) / 2);
const y = Math.round((box?.y ?? 0) + (box?.height ?? 400) / 2);
for (const type of ['mousePressed', 'mouseReleased'] as const) {
await cdp.send('Input.dispatchMouseEvent', {
type,
x,
y,
button: 'back',
buttons: type === 'mousePressed' ? 8 : 0,
clickCount: 1,
});
}
}
function historyIndex(page: Page): Promise<number> {
return page.evaluate(() => window.navigation?.currentEntry?.index ?? -1);
}
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
@@ -82,6 +117,35 @@ test('@web @xtream the in-portal search page reached in the app goes back in his
await page.waitForURL(/\/workspace\/dashboard$/);
});
test('@web @xtream the mouse Back button and the Back chord go back one step', async ({
page,
}) => {
await page.getByRole('link', { name: 'Series', exact: true }).click();
await page.waitForURL(/\/series$/);
const listUrl = page.url();
const listIndex = await historyIndex(page);
const cdp = await page.context().newCDPSession(page);
// The workspace runs Back and keeps the browser from its own step, which
// would go back a second time.
for (const gesture of [
() => pressMouseBack(cdp, page),
async () => page.keyboard.press(await backChord(page)),
]) {
await page.locator('.category-content-layout mat-card').first().click();
await page.waitForURL(/\/series\/[^/]+\/[^/]+$/);
await expect(page.locator('app-portal-detail-shell')).toBeVisible();
await gesture();
await page.waitForURL(listUrl);
await page.evaluate(
() => new Promise((resolve) => setTimeout(resolve, 400))
);
expect(page.url()).toBe(listUrl);
expect(await historyIndex(page)).toBe(listIndex);
}
});
for (const { name, path, selector, parent } of [
{
name: 'movie Discover',