From d21ff850a597e8c17628b19ca60c284f90444421 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:41:22 +0000 Subject: [PATCH 01/29] feat(pages): page dialogs over the page (NV-24) A page's alert, confirm and prompt, "Leave site?" and HTTP sign-in now show in Orbit's own small dialog over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up). The sidebar, other tabs and the command bar stay usable; a tab's dialog waits while another tab shows. Enter answers, Esc cancels, Tab stays inside, and the page gets the keyboard back. - The page preload patches the main frame's alert, confirm and prompt to ask main synchronously (orbit-page:dialog), as D40 allows. - Leave site?: navigations a page's beforeunload stops (will-prevent-unload) ask, and Leave starts them again; closing a tab or a window asks each page that would warn (the preload's world answers whether it would); auto-archive and sleep keep such a tab, and a tab waiting on its dialog. - Sign-in from the login event, for a server or a proxy. - Shared edits: TabOps.closeGuard and mayDiscard hooks (tabs/ops.ts), maintenance asks mayDiscard, isActivation exported (tabs/runtime.ts), rpc.ts and site.ts additions, ARCHITECTURE section 10. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 6 +- docs/ARCHITECTURE.md | 3 +- docs/feature-status.json | 1 + src/main/features.ts | 2 + src/main/pages/dialogs.ts | 482 +++++++++++++++++++++++++++ src/main/pages/index.ts | 60 ++++ src/main/pages/leave.ts | 375 +++++++++++++++++++++ src/main/tabs/maintenance.ts | 8 +- src/main/tabs/ops.ts | 27 +- src/main/tabs/runtime.ts | 2 +- src/preload/page.ts | 174 +++++++++- src/renderer/src/main.tsx | 15 + src/renderer/src/pages/DialogApp.tsx | 224 +++++++++++++ src/renderer/src/pages/dialog.css | 147 ++++++++ src/shared/commands/pages.ts | 9 + src/shared/pages.ts | 126 +++++++ src/shared/rpc.ts | 6 +- src/shared/site.ts | 7 + tests/e2e/pages-dialogs.spec.ts | 392 ++++++++++++++++++++++ tests/e2e/pages-support.ts | 191 +++++++++++ tests/unit/pages-dialogs.test.ts | 98 ++++++ 21 files changed, 2340 insertions(+), 15 deletions(-) create mode 100644 src/main/pages/dialogs.ts create mode 100644 src/main/pages/index.ts create mode 100644 src/main/pages/leave.ts create mode 100644 src/renderer/src/pages/DialogApp.tsx create mode 100644 src/renderer/src/pages/dialog.css create mode 100644 src/shared/commands/pages.ts create mode 100644 src/shared/pages.ts create mode 100644 tests/e2e/pages-dialogs.spec.ts create mode 100644 tests/e2e/pages-support.ts create mode 100644 tests/unit/pages-dialogs.test.ts diff --git a/FEATURES.md b/FEATURES.md index 7b4aba7a..187871a0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 237 built (86 partial) · 330 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 237 built (87 partial) · 331 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 140 | 168 | -| P1 | 219 | 82 | 145 | +| P1 | 219 | 82 | 146 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -251,7 +251,7 @@ Spec: `spec/navigation.md` | NV-21 | Save page and view source | P2 | – | – | – | | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | -| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | – | – | – | | +| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it) or its window (Cancel keeps it), a pop-up's window; a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep keep a tab whose page warns, or waits on its dialog, until the user closes it; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | | NV-25 | Share the current page | P2 | – | – | – | | ## Peek link preview (prefix PK) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 456183bc..8b37e658 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,8 +145,9 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:notification-click` (NV-15): a trusted click on one of the page's notifications. Main brings the tab forward only for a site allowed to show notifications, and at most once per `NOTIFICATION_CLICK_GAP_MS` per tab. - `orbit-page:internal-*` (DT-13, D53): Orbit's internal pages only. In the main frame of an `orbit:` page, which is Orbit's own UI, the preload patches nothing and gives `window.orbit`: the store's state and changes, and the commands listed for that page (`INTERNAL_PAGE_COMMANDS`), with no events. Main answers only a tab's main frame showing an `orbit:` page. In a web page it does nothing. - `orbit-page:escape` (KS-19, D59): one bit after a trusted Esc reaches the main frame, sent once every handler of the page has run: whether one called `preventDefault`. The listener is in the preload's own world. Main reads the bit only while it waits after an Esc in a loading tab with no overlay open (at most 250 ms; no answer counts as not used), only from that tab's main frame, and only for a press still waiting: a bit with none waiting does nothing. A page that always uses Esc can keep Esc from stopping its load; the Stop button still stops it. +- `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. While the page is going (`beforeunload`, `pagehide`, `unload`) the calls return at once, as the engine's do. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. -Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. +Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): whether the page would warn if it went now (it runs the page's `beforeunload` handlers with a synthetic event, only after a user gesture in the page), what kind of navigation a warning stopped, and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JS (user-authored, per site) is injected only through `webFrame.executeJavaScript` in an isolated world, never with Node. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). diff --git a/docs/feature-status.json b/docs/feature-status.json index 42804f9a..6174dcfa 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -201,6 +201,7 @@ "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, + "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it) or its window (Cancel keeps it), a pop-up's window; a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep keep a tab whose page warns, or waits on its dialog, until the user closes it; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, diff --git a/src/main/features.ts b/src/main/features.ts index dcb8d140..d31a2e92 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -12,6 +12,7 @@ import { registerKeys } from './keys' import { registerMedia } from './media' import { registerMiniWindow } from './miniwindow' import { registerNav } from './nav' +import { registerPages } from './pages' import { registerPeek } from './peek' import { registerSettings } from './settings' import { registerSpaces } from './spaces/commands' @@ -27,6 +28,7 @@ export function registerFeatures(ctx: MainContext): void { registerCore(ctx) const { ops } = registerTabs(ctx) registerNav(ctx, ops) + registerPages(ctx, ops) // Before Peek and Mini Windows: a DevTools key held down is ignored before they act on it. registerDevtools(ctx) registerSpaces(ctx) diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts new file mode 100644 index 00000000..a4eb659e --- /dev/null +++ b/src/main/pages/dialogs.ts @@ -0,0 +1,482 @@ +import { + webContents as allContents, + BrowserWindow, + type IpcMainEvent, + ipcMain, + type WebContents, + WebContentsView, +} from 'electron' +import { type PageDialogAnswer, type PageDialogContent, scriptAnswer, scriptDialog } from '../../shared/pages' +import type { Rect } from '../../shared/rpc' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { activeTabOf, loadRenderer, rendererEntry, uiWebPreferences, viewContents } from '../windows' + +/** + * Where a page's dialogs show: over a tab's page in a main window (a split pane included), over a + * Peek's or a Mini Window's page, or over a pop-up window's page. + */ +type HostKind = 'tab' | 'floating' | 'popup' + +interface Host { + kind: HostKind + win: BrowserWindow + /** The Orbit window: the main window (a tab, a Peek) or the Mini Window. None for a pop-up. */ + windowId?: WindowId +} + +interface Dialog { + content: PageDialogContent + resolve: (answer: PageDialogAnswer | null) => void + /** The page's own alert, confirm or prompt: it goes when the page navigates. */ + script: boolean +} + +/** One page's dialogs: the first shows, the rest wait their turn. */ +interface PageDialogs { + page: WebContents + queue: Dialog[] + view?: WebContentsView + host?: Host + /** The view's page has loaded: only then does the view go into its window (fix check A, F1). */ + ready: boolean + /** The dialog the view's page was last sent. */ + sent?: number + /** The view is in its window, over the page. */ + onScreen: boolean + off: () => void +} + +export interface PageDialogInfo { + id: number + kind: PageDialogContent['kind'] + title: string + message: string + tabId: TabId | null + /** Where it shows; null while the page has no window. */ + host: HostKind | null + /** Its view is on screen over the page. */ + onScreen: boolean + /** Its view has the keyboard. */ + focused: boolean + bounds?: Rect + /** Later dialogs of the same page, waiting their turn. */ + waiting: number +} + +export interface PageDialogs$ { + /** Shows a dialog over a page (once the page is on screen); resolves with the answer, or null if it went away. */ + open(page: WebContents, content: Omit, script?: boolean): Promise + /** Closes a page's dialogs (its own alert, confirm and prompt, or all), each as Cancel. */ + dismiss(page: WebContents, which: 'script' | 'all'): void + /** Whether a page has a dialog open or waiting: its own alert, confirm or prompt (the page waits), or any. */ + pending(page: WebContents, which?: 'script' | 'all'): boolean + /** Whether Orbit can show a dialog over a page: a tab's (a Peek's and a Mini Window's too) or a pop-up's. */ + hostable(page: WebContents): boolean + /** Tests: every page's first dialog. */ + info(): PageDialogInfo[] +} + +/** + * Page dialogs (NV-24): a page's alert, confirm and prompt, the leave-page warning and HTTP sign-in, + * in Orbit's own small dialog over that page's area only. The rest of the window stays usable: + * other tabs, the sidebar, the command bar. A dialog waits while its page is off screen (another + * tab is selected, the window is closed to the Library) and shows again when the page does. Each + * shows in a transparent view of its own over the page, which takes the keyboard (Enter, Esc and + * Tab go to the dialog first, KS-19) and gives it back to the page when it closes (KS-16). + */ +export function registerDialogs(ctx: MainContext): PageDialogs$ { + const { store, tabs, windows, commands } = ctx + const pages = new Map() + /** Each main window's dialog views, made when first needed and kept for the next dialog. */ + const pools = new Map>() + const inUse = new Set() + let nextId = 1 + + const hostOf = (page: WebContents): Host | undefined => { + const tabId = tabs.tabIdFor(page) + if (tabId) { + const windowId = windows.windowOfTab(tabId) + if (!windowId) return undefined + if (store.state.tabs[tabId]?.floating) { + const win = BrowserWindow.fromWebContents(page) + return win && !win.isDestroyed() ? { kind: 'floating', win, windowId } : undefined + } + const win = windows.get(windowId) + return win && !win.isDestroyed() ? { kind: 'tab', win, windowId } : undefined + } + // A pop-up window's page: the window's own contents. + if (windows.isUi(page)) return undefined + const win = BrowserWindow.fromWebContents(page) + return win && !win.isDestroyed() && win.webContents === page ? { kind: 'popup', win } : undefined + } + + const sameHost = (a: Host, b: Host) => a.kind === b.kind && a.win === b.win + + /** Where the dialog's view goes (window coordinates): over the page, while the page is on screen. */ + const placeOf = (p: PageDialogs, host: Host): Rect | undefined => { + if (host.kind === 'popup') { + if (!host.win.isVisible()) return undefined + const [width = 0, height = 0] = host.win.getContentSize() + return { x: 0, y: 0, width, height } + } + const tabId = tabs.tabIdFor(p.page) + if (!tabId) return undefined + if (host.kind === 'tab') return windows.tabViewBounds(tabId) + const view = tabs.get(tabId) + if (!view?.getVisible() || !host.win.contentView.children.includes(view)) return undefined + return view.getBounds() + } + + /** The page is the one the user is on: its window has focus, and nothing covers the page. */ + const pageIsCurrent = (p: PageDialogs, host: Host): boolean => { + if (!host.win.isFocused()) return false + if (host.kind !== 'tab' || !host.windowId) return true + const tabId = tabs.tabIdFor(p.page) + const win = store.state.windows[host.windowId] + return ( + !!tabId && + activeTabOf(store.state, host.windowId) === tabId && + win?.overlay.kind === 'none' && + !windows.hasPageCover(host.windowId) + ) + } + + const hasKeyboard = (view: WebContentsView | undefined) => { + const contents = viewContents(view) + return !!contents && allContents.getFocusedWebContents() === contents + } + + /** Orbit's UI never navigates away or opens windows (as WindowManager's own views). */ + const harden = (contents: WebContents) => { + contents.on('will-navigate', (event, url) => { + const entry = rendererEntry() + if (!(entry.url && url.startsWith(entry.url))) event.preventDefault() + }) + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + } + + const watchLoads = (view: WebContentsView) => { + const contents = viewContents(view) + contents?.on('did-finish-load', () => { + for (const p of pages.values()) { + if (p.view !== view) continue + p.ready = true + p.sent = undefined + sync(p) + } + }) + contents?.on('render-process-gone', () => { + for (const p of pages.values()) { + if (p.view !== view) continue + p.ready = false + p.onScreen = false + } + }) + } + + /** A main window's free dialog view, made if none is free. */ + const pooledView = (windowId: WindowId): WebContentsView | undefined => { + const pool = pools.get(windowId) ?? new Set() + pools.set(windowId, pool) + for (const view of pool) { + if (!viewContents(view)) pool.delete(view) + else if (!inUse.has(view)) return view + } + const view = windows.createUiView(windowId, 'dialog') + if (!view) return undefined + pool.add(view) + watchLoads(view) + windows.get(windowId)?.once('closed', () => { + for (const v of pool) inUse.delete(v) + pools.delete(windowId) + }) + return view + } + + /** A view over a Peek's, a Mini Window's or a pop-up's page: its own, closed after its dialogs. */ + const looseView = (host: Host): WebContentsView => { + const windowId = host.windowId ?? windows.focusedWindowId() ?? windows.ids()[0] ?? '' + const view = new WebContentsView({ webPreferences: uiWebPreferences(windowId, 'dialog') }) + view.setBackgroundColor('#00000000') + view.setVisible(false) + const contents = view.webContents + windows.registerUi(contents, windowId) + windows.attachShortcuts(contents) + harden(contents) + // A view that crashed loads again, once: its dialog shows again. + contents.once('render-process-gone', (_event, details) => { + if (details.reason !== 'clean-exit' && !contents.isDestroyed()) loadRenderer(contents, 'dialog') + }) + watchLoads(view) + loadRenderer(contents, 'dialog') + host.win.once('closed', () => viewContents(view)?.close()) + return view + } + + const ensureView = (p: PageDialogs, host: Host): WebContentsView | undefined => { + if (p.view && viewContents(p.view) && p.host && sameHost(p.host, host)) return p.view + release(p) + p.host = host + p.ready = false + p.sent = undefined + const view = host.kind === 'tab' && host.windowId ? pooledView(host.windowId) : looseView(host) + if (!view) return undefined + p.view = view + inUse.add(view) + // A pooled view that has loaded already is ready now. + const contents = viewContents(view) + if (contents && !contents.isLoading() && contents.getURL() !== '') p.ready = true + return view + } + + /** Takes a page's view off screen; `focusPage` gives the page the keyboard if the view had it. */ + const hide = (p: PageDialogs, focusPage: boolean) => { + const view = p.view + const host = p.host + p.onScreen = false + if (!view || !host || !viewContents(view)) return + const had = hasKeyboard(view) + if (host.kind === 'tab' && host.windowId) windows.hideUiView(host.windowId, view) + else { + if (!host.win.isDestroyed() && host.win.contentView.children.includes(view)) + host.win.contentView.removeChildView(view) + view.setVisible(false) + } + if (had && focusPage && !p.page.isDestroyed()) p.page.focus() + } + + /** Gives a page's view back: a pooled one to its window's pool, a loose one is closed. */ + const release = (p: PageDialogs) => { + const view = p.view + if (!view) return + hide(p, false) + inUse.delete(view) + p.view = undefined + p.host = undefined + p.ready = false + p.sent = undefined + const pooled = [...pools.values()].some((pool) => pool.has(view)) + if (!pooled) viewContents(view)?.close() + } + + const send = (p: PageDialogs, content: PageDialogContent) => { + if (p.sent === content.id) return + p.sent = content.id + viewContents(p.view)?.send('orbit:dialog:content', content) + } + + /** Brings a page's dialog view in line with its first dialog and where its page is. */ + const sync = (p: PageDialogs) => { + const first = p.queue[0] + if (!first || p.page.isDestroyed()) { + hide(p, true) + return + } + const host = hostOf(p.page) + const bounds = host ? placeOf(p, host) : undefined + if (!host || !bounds || bounds.width < 1 || bounds.height < 1) { + hide(p, false) + return + } + const view = ensureView(p, host) + if (!view || !p.ready) return + send(p, first.content) + const appearing = !p.onScreen + if (host.kind === 'tab' && host.windowId) windows.showUiView(host.windowId, view, bounds) + else { + // Over the page, which sits above the window's overlay (a Peek) or alone in its window. + const parent = host.win.contentView + if (parent.children[parent.children.length - 1] !== view) parent.addChildView(view) + const at = view.getBounds() + if (at.x !== bounds.x || at.y !== bounds.y || at.width !== bounds.width || at.height !== bounds.height) { + view.setBounds(bounds) + } + if (!view.getVisible()) view.setVisible(true) + } + p.onScreen = true + if (appearing && pageIsCurrent(p, host)) viewContents(view)?.focus() + } + + const syncAll = () => { + for (const p of pages.values()) sync(p) + } + + const entryFor = (page: WebContents): PageDialogs => { + const existing = pages.get(page.id) + if (existing) return existing + // The page has the keyboard while its dialog shows: the dialog takes it (KS-19). + const onFocus = () => { + const p = pages.get(page.id) + if (p?.queue.length && p.onScreen) viewContents(p.view)?.focus() + } + const gone = () => { + const p = pages.get(page.id) + if (!p) return + pages.delete(page.id) + p.off() + for (const dialog of p.queue.splice(0)) dialog.resolve(null) + release(p) + } + const onBounds = () => { + const p = pages.get(page.id) + if (p?.queue.length) sync(p) + } + page.on('focus', onFocus) + page.once('destroyed', gone) + // A Peek's or a Mini Window's page moves on its own: the dialog follows it. + const tabId = tabs.tabIdFor(page) + const pageView = tabId ? tabs.get(tabId) : undefined + pageView?.on('bounds-changed', onBounds) + const p: PageDialogs = { + page, + queue: [], + ready: false, + onScreen: false, + off: () => { + if (!page.isDestroyed()) { + page.off('focus', onFocus) + page.off('destroyed', gone) + } + pageView?.off('bounds-changed', onBounds) + }, + } + pages.set(page.id, p) + return p + } + + const settle = (p: PageDialogs, dialog: Dialog, answer: PageDialogAnswer | null) => { + const at = p.queue.indexOf(dialog) + if (at < 0) return + p.queue.splice(at, 1) + const wasFirst = at === 0 + dialog.resolve(answer) + if (p.queue.length === 0) { + // The keyboard goes back to the page (KS-16); a main window keeps the view for its next dialog. + hide(p, true) + release(p) + p.off() + pages.delete(p.page.id) + return + } + if (wasFirst) sync(p) + } + + const service: PageDialogs$ = { + open(page, content, script = false) { + return new Promise((resolve) => { + if (page.isDestroyed()) { + resolve(null) + return + } + const p = entryFor(page) + const dialog: Dialog = { content: { ...content, id: nextId++ }, resolve, script } + p.queue.push(dialog) + if (p.queue.length === 1) sync(p) + }) + }, + dismiss(page, which) { + const p = pages.get(page.id) + if (!p) return + for (const dialog of [...p.queue]) if (which === 'all' || dialog.script) settle(p, dialog, null) + }, + pending(page, which = 'all') { + const queue = pages.get(page.id)?.queue ?? [] + return which === 'all' ? queue.length > 0 : queue.some((dialog) => dialog.script) + }, + hostable: (page) => hostable(page), + info() { + return [...pages.values()].flatMap((p) => { + const first = p.queue[0] + if (!first) return [] + return [ + { + id: first.content.id, + kind: first.content.kind, + title: first.content.title, + message: first.content.message, + tabId: tabs.tabIdFor(p.page) ?? null, + host: hostOf(p.page)?.kind ?? null, + onScreen: p.onScreen, + focused: hasKeyboard(p.view), + ...(p.onScreen && p.view ? { bounds: p.view.getBounds() } : {}), + waiting: p.queue.length - 1, + }, + ] + }) + }, + } + + commands.register('pages.dialogContent', (_args, c) => { + for (const p of pages.values()) { + if (!c.sender || viewContents(p.view) !== c.sender) continue + const first = p.queue[0] + if (first) p.sent = first.content.id + return first?.content ?? null + } + return null + }) + + commands.register('pages.dialogAnswer', (answer, c) => { + for (const p of pages.values()) { + const first = p.queue[0] + if (!first || first.content.id !== answer.id || !c.sender || viewContents(p.view) !== c.sender) continue + settle(p, first, { + id: answer.id, + ok: answer.ok === true, + ...(typeof answer.value === 'string' ? { value: answer.value } : {}), + ...(typeof answer.user === 'string' ? { user: answer.user } : {}), + ...(typeof answer.password === 'string' ? { password: answer.password } : {}), + }) + return + } + }) + + // Pages move: tab switches, splits, resizes, full screen, DevTools docking, the Library. + windows.onLayout(() => syncAll()) + store.subscribe(() => { + if (pages.size > 0) syncAll() + }) + + // A page's alert, confirm or prompt (the page preload, NV-24): the page waits for the answer. + ipcMain.on(PAGE_CHANNEL.dialog, (event: IpcMainEvent, kind: unknown, message: unknown, value: unknown) => { + let replied = false + const reply = (result: unknown) => { + if (replied) return + replied = true + event.returnValue = result + } + try { + const page = event.sender + const frame = event.senderFrame + if (kind !== 'alert' && kind !== 'confirm' && kind !== 'prompt') { + reply(null) + return + } + // Only a page's main frame has the preload (D40), and only a tab's or a pop-up's page has a + // place for the dialog. + if (!frame || frame.parent || page.isDestroyed() || !hostable(page)) { + reply(scriptAnswer(kind, null)) + return + } + const content = scriptDialog(kind, frame.url, String(message ?? ''), String(value ?? '')) + service.open(page, content, true).then( + (answer) => reply(scriptAnswer(kind, answer)), + () => reply(scriptAnswer(kind, null)), + ) + } catch (err) { + console.error('[pages] page dialog', err) + reply(null) + } + }) + + /** A page whose dialogs Orbit can place: a tab's (Peek and Mini Window pages are tabs) or a pop-up's. */ + const hostable = (page: WebContents): boolean => { + if (tabs.tabIdFor(page)) return true + if (windows.isUi(page)) return false + return BrowserWindow.fromWebContents(page)?.webContents === page + } + + return service +} diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts new file mode 100644 index 00000000..2c256007 --- /dev/null +++ b/src/main/pages/index.ts @@ -0,0 +1,60 @@ +import { app } from 'electron' +import { authDialog } from '../../shared/pages' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' +import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialogs' +import { type LeaveInfo, registerLeave } from './leave' + +/** Test-only handle (ORBIT_E2E=1): globalThis.__orbitPages. */ +export interface PagesTestHooks { + /** Every page's dialog on show or waiting to (the first of each page's). */ + dialogs: () => PageDialogInfo[] + /** What Orbit knows of a tab's page about its leave-page warning. */ + leave: (tabId: TabId) => LeaveInfo | null + /** Whether auto-archive and sleep may let a tab's page go (NV-24). */ + mayDiscard: (tabId: TabId) => boolean +} + +/** + * The pages lane: what a page does beyond loading. Page dialogs (NV-24): a page's alert, confirm and + * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. + */ +export function registerPages(ctx: MainContext, ops: TabOps): void { + const dialogs = registerDialogs(ctx) + const leave = registerLeave(ctx, ops, dialogs) + registerSignIn(dialogs) + + if (ctx.e2e) { + const hooks: PagesTestHooks = { + dialogs: () => dialogs.info(), + leave: (tabId) => { + const page = ctx.tabs.contents(tabId) + return page ? leave.info(page) : null + }, + mayDiscard: (tabId) => leave.mayDiscard(tabId), + } + ;(globalThis as unknown as { __orbitPages: PagesTestHooks }).__orbitPages = hooks + } +} + +/** + * HTTP sign-in (Basic and Digest, NV-24): a server or a proxy asks for a username and password, and + * Orbit asks the user over the page that asked. Cancel (or the page going) sends none, and the page + * gets the server's answer to that. + */ +function registerSignIn(dialogs: PageDialogs$): void { + app.on('login', (event, page, details, authInfo, callback) => { + // Without a page to ask over (a request of Orbit's own, a utility process): no sign-in. + if (!page || page.isDestroyed() || !dialogs.hostable(page)) return + event.preventDefault() + void dialogs.open(page, authDialog(authInfo, details.url)).then((answer) => { + try { + if (answer?.ok) callback(answer.user ?? '', answer.password ?? '') + else callback() + } catch { + // The request went meanwhile. + } + }) + }) +} diff --git a/src/main/pages/leave.ts b/src/main/pages/leave.ts new file mode 100644 index 00000000..8500e1bb --- /dev/null +++ b/src/main/pages/leave.ts @@ -0,0 +1,375 @@ +import { app, BrowserWindow, type WebContents } from 'electron' +import { leaveDialog } from '../../shared/pages' +import type { ArchivedTab, TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { CloseTabsOptions, TabOps } from '../tabs/ops' +import { isActivation } from '../tabs/runtime' +import type { PageDialogs$ } from './dialogs' + +/** How long Orbit waits for a page to say whether it would warn: a busy page doesn't. */ +export const PROBE_MS = 250 +/** After Leave, the warning of the navigation Orbit starts again goes through within this time. */ +const ALLOW_MS = 5_000 +/** + * After Leave on a navigation the page started and Orbit can't start again (a page's own Back, a + * frame's own navigation), the user's next try goes through within this time. + */ +const ALLOW_RETRY_MS = 10_000 +/** A navigation Orbit started is the one a warning stopped, within this time. */ +const STARTED_MS = 10_000 +/** A pop-up's window began closing: its page's warning comes within this time. */ +const CLOSING_MS = 5_000 +/** The page preload's own world, where it answers main (`__orbitLeave`, src/preload/page.ts). */ +const PRELOAD_WORLD = 999 + +const PROBE = `typeof __orbitLeave === 'object' ? __orbitLeave.probe() : false` +const STOPPED = `typeof __orbitLeave === 'object' ? __orbitLeave.stopped() : ''` +const REDO = `typeof __orbitLeave === 'object' ? __orbitLeave.redo() : false` + +/** A navigation Orbit started (the address field, Back, Reload…), to start again on Leave. */ +interface Started { + redo: () => void + reload: boolean + until: number +} + +type Stopped = Started | 'page' | 'close' + +/** What Orbit knows of one page about its leave-page warning. */ +interface LeavePage { + /** User gestures that reached the page: a page without one never warns (Chromium's rule). */ + gestures: number + /** Main-frame documents so far. */ + doc: number + /** The page warned in this document: auto-archive and sleep leave it alone. */ + warnedDoc?: number + /** The last time Orbit asked whether the page would warn: the answer, and what it saw. */ + probed?: { gestures: number; doc: number; warns: boolean } + /** The user chose Leave: the page's next warning goes through until then. */ + allowUntil: number + /** The navigation Orbit started last, until it starts or a warning stops it. */ + started?: Started + /** A pop-up's window is closing (its 'close' came; the warning comes after it). */ + closingUntil: number + /** "Leave site?" is open over the page, for this stopped navigation or close. */ + asking?: Stopped +} + +export interface LeaveInfo { + gestures: number + /** The page warned in its current document. */ + warned: boolean + /** The page's next warning goes through (the user chose Leave). */ + allowed: boolean +} + +export interface PageLeave { + /** Whether Orbit may let a live tab's page go without asking: auto-archive and sleep (ops.mayDiscard). */ + mayDiscard(tabId: TabId): boolean + /** Tests: what Orbit knows of a page. */ + info(page: WebContents): LeaveInfo | null +} + +/** + * The leave-page warning (NV-24): "Leave site? Changes you made may not be saved" when closing or + * navigating away from a page that asks for it (`beforeunload`), in Orbit's own dialog over the page. + * + * - **Navigations.** The engine runs the page's `beforeunload`, and where Chromium would ask (the + * page has had a user gesture and cancels the event), Electron says so (`will-prevent-unload`) and + * stops the navigation unless told there and then to go on. So Orbit goes on only where the user + * just chose Leave; otherwise it asks, and on Leave starts the navigation again: one Orbit started + * (the address field, Back, Reload…) from here, one the page started (a link, a form, `location`, + * its own reload) from the page preload. A page's own Back and a frame's own navigation can't be + * started again: the user's next try within ALLOW_RETRY_MS goes through. + * - **Closing a tab or a window.** The engine runs no `beforeunload` when Orbit closes a page, so + * Orbit asks the page (`__orbitLeave.probe()` in the preload's world) when it has had a gesture, and + * for each page that would warn shows its tab and asks there. Cancel keeps that tab (or the + * window). Quitting doesn't ask. + * - **A pop-up's window.** Closing it runs the page's `beforeunload` as a navigation does. + * - **Auto-archive and sleep** never discard a page that would warn: they leave it until the user + * closes it (`mayDiscard`). + * + * A page's own alert, confirm or prompt goes as soon as Orbit navigates the page: the page can't + * answer its warning while it waits for one. + */ +export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialogs$): PageLeave { + const { store, tabs, windows } = ctx + const pages = new Map() + /** Windows the user agreed to close (the next 'close' goes through). */ + const confirmedWindows = new Set() + /** Windows and tabs Orbit is asking about now. */ + const guardingWindows = new Set() + const guardingTabs = new Set() + + const stateOf = (page: WebContents): LeavePage => { + let p = pages.get(page.id) + if (!p) { + p = { gestures: 0, doc: 0, allowUntil: 0, closingUntil: 0 } + pages.set(page.id, p) + } + return p + } + + /** Whether a page could warn now: Chromium's warning needs a user gesture in the page first. */ + const mayWarn = (page: WebContents | undefined): page is WebContents => + !!page && !page.isDestroyed() && (pages.get(page.id)?.gestures ?? 0) > 0 && !dialogs.pending(page, 'script') + + /** Runs one of the preload's answers in its world; `fallback` when the page is gone or too busy. */ + const ask = (page: WebContents, code: string, fallback: T, timeout = PROBE_MS): Promise => { + if (page.isDestroyed()) return Promise.resolve(fallback) + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(fallback), timeout) + page.executeJavaScriptInIsolatedWorld(PRELOAD_WORLD, [{ code }]).then( + (value: unknown) => { + clearTimeout(timer) + resolve(value as T) + }, + () => { + clearTimeout(timer) + resolve(fallback) + }, + ) + }) + } + + /** Whether the page would warn if it went now. */ + const probe = async (page: WebContents): Promise => { + if (!mayWarn(page)) return false + const p = stateOf(page) + const seen = { gestures: p.gestures, doc: p.doc } + const warns = (await ask(page, PROBE, false)) === true + p.probed = { ...seen, warns } + if (warns) p.warnedDoc = seen.doc + return warns + } + + /** Puts a tab's page on screen, so its dialog shows: its window shows the tab. */ + const reveal = (tabId: TabId) => { + if (store.state.tabs[tabId]?.floating || windows.tabViewBounds(tabId)) return + const windowId = windows.windowOfTab(tabId) ?? windows.focusedWindowId() ?? windows.ids()[0] + if (windowId) ops.activate(windowId, tabId) + } + + /** "Leave site?" over a tab about to close: true to close it. */ + const askToClose = async (tabId: TabId): Promise => { + const page = tabs.contents(tabId) + if (!page) return true + reveal(tabId) + const answer = await dialogs.open(page, leaveDialog(false)) + // The page went meanwhile: nothing is left to keep. + if (!answer && page.isDestroyed()) return true + return answer?.ok === true + } + + // ---- Navigations ---- + + /** A warning stopped a navigation (or a pop-up's close): asks, and on Leave goes on with it. */ + const askToLeave = async (page: WebContents, p: LeavePage, stopped: Stopped) => { + if (p.asking) { + // A later navigation stopped while asking: Leave goes on with that one. + p.asking = stopped + return + } + p.asking = stopped + try { + const reload = + stopped === 'page' ? (await ask(page, STOPPED, '')) === 'reload' : stopped !== 'close' && stopped.reload + if (page.isDestroyed()) return + const answer = await dialogs.open(page, leaveDialog(reload)) + const now = p.asking + if (!answer?.ok || !now || page.isDestroyed()) return + p.allowUntil = Date.now() + (now === 'page' ? ALLOW_RETRY_MS : ALLOW_MS) + if (now === 'close') BrowserWindow.fromWebContents(page)?.close() + else if (now === 'page') void ask(page, REDO, false, ALLOW_MS) + else now.redo() + } finally { + p.asking = undefined + } + } + + /** + * Navigations Orbit starts in a tab: the page's own alert, confirm or prompt goes first (it can't + * answer its warning while it waits), and the navigation is noted, to start it again on Leave. + */ + const watchNavigations = (page: WebContents, p: LeavePage) => { + const wrap = (target: object, name: string, reload: boolean, navigates: (args: unknown[]) => boolean) => { + const original = (target as Record)[name] + if (typeof original !== 'function') return + const replacement = function (this: unknown, ...args: unknown[]) { + if (!page.isDestroyed() && navigates(args)) { + dialogs.dismiss(page, 'script') + p.started = { + reload, + until: Date.now() + STARTED_MS, + redo: () => { + const result: unknown = Reflect.apply(original, target, args) + // A load a warning stops again is ERR_ABORTED: its failure is the page's to show. + if (result instanceof Promise) result.catch(() => {}) + }, + } + } + return Reflect.apply(original, this, args) + } + try { + Object.defineProperty(target, name, { value: replacement, configurable: true, writable: true }) + } catch { + // Not replaceable: a dialog still goes when the navigation starts, and Leave lets the next try through. + } + } + const always = () => true + wrap(page, 'loadURL', false, always) + wrap(page, 'reload', true, always) + wrap(page, 'reloadIgnoringCache', true, always) + const history = page.navigationHistory + const moves = (offset: number) => offset !== 0 && history.canGoToOffset(offset) + wrap(history, 'goBack', false, () => history.canGoBack()) + wrap(history, 'goForward', false, () => history.canGoForward()) + wrap(history, 'goToOffset', false, ([offset]) => typeof offset === 'number' && moves(offset)) + wrap(history, 'goToIndex', false, ([index]) => typeof index === 'number' && moves(index - history.getActiveIndex())) + } + + /** Watches a page's gestures, navigations and warnings: a tab's or a pop-up's. */ + const watched = new WeakSet() + const watch = (page: WebContents, kind: 'tab' | 'popup') => { + if (watched.has(page)) return + watched.add(page) + const p = stateOf(page) + page.on('input-event', (_event, input) => { + if (isActivation(input)) p.gestures++ + }) + page.on('did-start-navigation', (details) => { + if (!details.isMainFrame) return + // The navigation goes on: nothing is stopped, and a Leave was used or no longer needed. + p.started = undefined + p.allowUntil = 0 + // A page that goes takes its dialogs with it. + if (!details.isSameDocument) dialogs.dismiss(page, 'all') + }) + page.on('did-navigate', () => { + p.doc++ + }) + page.on('will-prevent-unload', (event) => { + if (windows.isUi(page)) return + p.warnedDoc = p.doc + const now = Date.now() + if (now <= p.allowUntil) { + p.allowUntil = 0 + event.preventDefault() + return + } + const started = p.started && now <= p.started.until ? p.started : undefined + p.started = undefined + const closing = now <= p.closingUntil + p.closingUntil = 0 + void askToLeave(page, p, closing ? 'close' : (started ?? 'page')) + }) + if (kind === 'tab') watchNavigations(page, p) + page.once('destroyed', () => pages.delete(page.id)) + } + + ctx.hooks.tabCreated.push((_tabId, page) => watch(page, 'tab')) + + // A pop-up's page is its window's own: closing the window runs its `beforeunload`. + app.on('browser-window-created', (_event, win) => { + const page = win.webContents + watch(page, 'popup') + win.on('close', () => { + if (!page.isDestroyed() && !windows.isUi(page) && !tabs.tabIdFor(page)) { + stateOf(page).closingUntil = Date.now() + CLOSING_MS + } + }) + }) + + // ---- Closing tabs ---- + + /** Closes tabs once each page that would warn was asked about: Cancel keeps that tab. */ + const closeAsking = async ( + tabIds: TabId[], + asking: TabId[], + reason: ArchivedTab['reason'], + opts: CloseTabsOptions, + ) => { + for (const id of asking) guardingTabs.add(id) + const keep = new Set() + try { + const warns = await Promise.all(asking.map((id) => probe(tabs.contents(id) as WebContents))) + for (const [i, tabId] of asking.entries()) { + if (warns[i] && store.state.tabs[tabId] && !(await askToClose(tabId))) keep.add(tabId) + } + } finally { + for (const id of asking) guardingTabs.delete(id) + } + const closing = tabIds.filter((id) => !keep.has(id) && store.state.tabs[id]) + if (closing.length > 0) ops.closeTabs(closing, reason, { ...opts, confirmed: true }) + } + + ops.closeGuard = (tabIds, reason, opts) => { + // Orbit's own closes (a private window's tabs, a download's empty tab) go: auto-archive asks mayDiscard. + if (reason === 'auto') return false + const ids = [...new Set(tabIds)] + // Asked about already: that answer closes it. + if (ids.some((id) => guardingTabs.has(id))) return true + const asking = ids.filter((id) => mayWarn(tabs.contents(id))) + if (asking.length === 0) return false + void closeAsking(ids, asking, reason, opts) + return true + } + + // ---- Closing a window ---- + + windows.onWindowCreated((windowId, win) => { + win.on('close', (event) => { + // Quitting doesn't ask; another guard asking first (downloads in progress) goes first. + if (windows.quitting || event.defaultPrevented) return + if (confirmedWindows.delete(windowId)) return + const asking = tabs + .liveTabIds() + .filter((id) => windows.windowOfTab(id) === windowId && mayWarn(tabs.contents(id))) + if (asking.length === 0) return + event.preventDefault() + if (guardingWindows.has(windowId)) return + guardingWindows.add(windowId) + void (async () => { + try { + const warns = await Promise.all(asking.map((id) => probe(tabs.contents(id) as WebContents))) + for (const [i, tabId] of asking.entries()) { + // Cancel keeps the window open. + if (warns[i] && store.state.tabs[tabId] && !(await askToClose(tabId))) return + } + if (win.isDestroyed()) return + confirmedWindows.add(windowId) + win.close() + } finally { + guardingWindows.delete(windowId) + } + })() + }) + win.once('closed', () => confirmedWindows.delete(windowId)) + }) + + // ---- Auto-archive and sleep ---- + + const service: PageLeave = { + mayDiscard(tabId) { + const page = tabs.contents(tabId) + if (!page) return true + // A page waiting on the user (its dialog, or a sign-in) waits for them. + if (dialogs.pending(page, 'all')) return false + if (!mayWarn(page)) return true + const p = stateOf(page) + // It warned in this document: it stays until the user closes it (NV-24). + if (p.warnedDoc === p.doc) return false + if (p.probed && p.probed.doc === p.doc && p.probed.gestures === p.gestures) return !p.probed.warns + // Unknown since the page's last gesture: ask it now, and decide on the next pass. + void probe(page) + return false + }, + info(page) { + const p = pages.get(page.id) + if (!p) return null + return { gestures: p.gestures, warned: p.warnedDoc === p.doc, allowed: Date.now() <= p.allowUntil } + }, + } + ops.mayDiscard = (tabId) => service.mayDiscard(tabId) + return service +} diff --git a/src/main/tabs/maintenance.ts b/src/main/tabs/maintenance.ts index c7e3cab9..0c9e7865 100644 --- a/src/main/tabs/maintenance.ts +++ b/src/main/tabs/maintenance.ts @@ -65,12 +65,16 @@ export class TabMaintenance { /** One pass: archive idle Today items, then put idle background tabs to sleep. */ run(now = this.now()): { archived: ItemId[]; slept: TabId[] } { - const archived = autoArchiveCandidates(this.ctx.store.state, now) + // A page that would warn before it goes stays until the user closes it (NV-24). + const mayGo = (tabId: TabId) => this.ops.mayDiscard?.(tabId) ?? true + const archived = autoArchiveCandidates(this.ctx.store.state, now).filter((id) => + tabsInItem(this.ctx.store.state, id).every(mayGo), + ) if (archived.length > 0) { const tabIds = archived.flatMap((id) => tabsInItem(this.ctx.store.state, id)) this.ops.closeTabs(tabIds, 'auto', { undoLabel: false }) } - const slept = sleepCandidates(this.ctx.store.state, now, this.ctx.tabs.liveTabIds()) + const slept = sleepCandidates(this.ctx.store.state, now, this.ctx.tabs.liveTabIds()).filter(mayGo) for (const id of slept) this.ctx.tabs.sleep(id) return { archived, slept } } diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index 02c3f15c..72ae9044 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -119,9 +119,29 @@ function unarchive(d: OrbitState, tabId: TabId, archiveId: string | undefined): } } +/** How `closeTabs` closes. */ +export interface CloseTabsOptions { + remove?: boolean + reselect?: 'neighbor' | 'pinned' + undoLabel?: string | false + /** The user was asked already: no `closeGuard`. */ + confirmed?: boolean +} + export class TabOps { readonly recentlyClosed: ClosedTab[] = [] readonly undo = new UndoStack() + /** + * Asks before closing a page that warns before it goes (NV-24, pages lane): returns true when it + * takes the close over (it closes the tabs itself once the user answers, `confirmed`), false to + * close now. + */ + closeGuard?: (tabIds: TabId[], reason: ArchivedTab['reason'], opts: CloseTabsOptions) => boolean + /** + * Whether a live tab's page may go without asking (NV-24, pages lane): auto-archive and sleep + * leave a page that would warn before it goes. + */ + mayDiscard?: (tabId: TabId) => boolean constructor( private ctx: MainContext, @@ -289,11 +309,8 @@ export class TabOps { * and Favorites stay and unload, resetting to their pinned URL (PN-06, FV-06). * `remove` also removes pinned tabs (delete from sidebar). */ - closeTabs( - tabIds: TabId[], - reason: ArchivedTab['reason'], - opts: { remove?: boolean; reselect?: 'neighbor' | 'pinned'; undoLabel?: string | false } = {}, - ): void { + closeTabs(tabIds: TabId[], reason: ArchivedTab['reason'], opts: CloseTabsOptions = {}): void { + if (!opts.confirmed && this.closeGuard?.(tabIds, reason, opts)) return const state = this.state const removing: TabId[] = [] const unloading: TabId[] = [] diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index d49416a1..14d66530 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -33,7 +33,7 @@ function liveContents(view: WebContentsView | undefined): WebContents | undefine const NON_ACTIVATING_KEYS = new Set(['Escape', 'Shift', 'Control', 'Alt', 'Meta', 'Command', 'Cmd', 'CapsLock']) /** Input that counts as a user activation for opening tabs and pop-ups (NV-09, HTML user activation). */ -function isActivation(input: Electron.InputEvent): boolean { +export function isActivation(input: Electron.InputEvent): boolean { if (input.type === 'mouseDown' || input.type === 'touchEnd' || input.type === 'gestureTap') return true if (input.type !== 'rawKeyDown' && input.type !== 'keyDown') return false if (input.modifiers?.includes('isautorepeat')) return false diff --git a/src/preload/page.ts b/src/preload/page.ts index d833c20d..595774f1 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -14,6 +14,12 @@ * nothing. See `exposeInternalPage`. * * In every page's main frame, it also tells main whether the page used an Esc (D59, `watchEscape`). + * + * In a web page's main frame, the page's `alert`, `confirm` and `prompt` ask main, which shows + * Orbit's dialog over that page only (NV-24): Electron has no `prompt`, and shows the others as + * app-wide boxes. A frame has no preload (D40), so a dialog a frame asks for itself is the engine's + * own box (and a frame's `prompt` returns null). It also answers main about the leave-page warning + * (`exposeLeave`). */ import { contextBridge, ipcRenderer } from 'electron' import type { OrbitBridge, StatePatch } from '../shared/rpc' @@ -40,7 +46,7 @@ const notificationClicked = () => { * the page's globals). Built-ins are taken up front, so a page that replaces them later can't reach * Orbit's functions through them. */ -function patchPage(stateOf: StateOf, clicked: () => void) { +function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog) { // Calls go through the captured `apply`, never a function's own `.call`: a page that replaces // `Function.prototype.call` (or `Reflect.apply`) would otherwise receive Orbit's functions. const apply = Reflect.apply @@ -132,6 +138,166 @@ function patchPage(stateOf: StateOf, clicked: () => void) { }) } } + + // alert, confirm and prompt: Orbit's dialog over this page (NV-24). The arguments convert as the + // engine's do (a template literal throws on a Symbol, as DOMString does); `confirm` and `prompt` + // take "" for a missing or undefined argument, `alert` only for a missing one. + const asText = (args: unknown[], at: number, optional: boolean): string => + args.length <= at || (optional && args[at] === undefined) ? '' : `${args[at]}` + const answers: Record<'alert' | 'confirm' | 'prompt', (args: unknown[]) => unknown> = { + alert: (args) => { + ask('alert', asText(args, 0, false), '') + return undefined + }, + confirm: (args) => ask('confirm', asText(args, 0, true), '') === true, + prompt: (args) => { + const value = ask('prompt', asText(args, 0, true), asText(args, 1, true)) + return typeof value === 'string' ? value : null + }, + } + for (const name of ['alert', 'confirm', 'prompt'] as const) { + const native = window[name] + const own = describe(window, name) + if (typeof native !== 'function' || !own?.configurable) continue + const answer = answers[name] + // A Proxy keeps the engine's name, length and toString. + define(window, name, { ...own, value: new Proxy(native, { apply: (_target, _self, args) => answer(args) }) }) + } +} + +type AskDialog = (kind: 'alert' | 'confirm' | 'prompt', message: string, value: string) => unknown + +/** Set while the page is going (beforeunload, pagehide, unload): the engine shows no dialog then. */ +let dismissing = false + +const askDialog: AskDialog = (kind, message, value) => { + if (dismissing) return null + try { + return ipcRenderer.sendSync(PAGE_CHANNEL.dialog, kind, message, value) + } catch { + return null + } +} + +/** + * The engine ignores `alert`, `confirm` and `prompt` while a page is going: so does Orbit's dialog. + * These listeners come before the page's, so the flag is up for all of its handlers. + */ +function watchDismissal(): void { + for (const type of ['beforeunload', 'pagehide', 'unload']) { + window.addEventListener( + type, + (event) => { + if (!event.isTrusted) return + dismissing = true + // A navigation the warning stopped leaves the page as it was. + setTimeout(() => { + dismissing = false + }, 0) + }, + true, + ) + } + window.addEventListener('pageshow', () => { + dismissing = false + }) +} + +interface StoppedNavigation { + url: string + type: string + /** The form a submission came from, and its submit button. */ + form?: WeakRef + submitter?: WeakRef +} + +/** + * The leave-page warning (NV-24), asked from main in this preload's own world, where the page can't + * see or replace it (src/main/pages/leave.ts): `globalThis.__orbitLeave` there. + * + * - `probe()`: whether the page would warn before it goes, where Orbit closes or discards a page + * itself (closing a tab or a window, auto-archive), since the engine runs the real `beforeunload` + * only for navigations. As Chromium, only a page that has had a user gesture warns, and only if + * its handlers cancel the event or set a `returnValue`. The handlers see an untrusted event, and + * their dialogs are ignored, as during the real one. + * - `stopped()`: what the page's own navigation that a warning just stopped was (`reload`, `push`, + * `replace`, `traverse`), or "" when there is none this frame knows of: a navigation is known + * here from the Navigation API's `navigate` event, which comes just before `beforeunload`. + * - `redo()`: starts that navigation again (the user chose Leave; main lets the next warning + * through): a link or `location` loads its URL, a form is submitted again, a reload reloads. + */ +function exposeLeave(): void { + const nav = (window as { navigation?: EventTarget }).navigation + /** The last cross-document navigation the page started, until its task ends. */ + let starting: StoppedNavigation | null = null + /** The navigation the last real `beforeunload` was for. */ + let stopped: StoppedNavigation | null = null + nav?.addEventListener('navigate', (event) => { + const e = event as Event & { + destination?: { url?: string; sameDocument?: boolean } + navigationType?: string + formData?: FormData | null + sourceElement?: Element | null + } + if (!e.destination?.url || e.destination.sameDocument) return + const source = e.sourceElement ?? null + const form = e.formData + ? source instanceof HTMLFormElement + ? source + : (source as HTMLButtonElement | null)?.form + : null + starting = { + url: e.destination.url, + type: e.navigationType ?? 'push', + ...(form ? { form: new WeakRef(form) } : {}), + ...(form && source instanceof HTMLElement && source !== form ? { submitter: new WeakRef(source) } : {}), + } + setTimeout(() => { + starting = null + }, 0) + }) + window.addEventListener( + 'beforeunload', + (event) => { + if (event.isTrusted) stopped = starting + }, + true, + ) + const probe = (): boolean => { + if (!navigator.userActivation?.hasBeenActive) return false + const event = document.createEvent('BeforeUnloadEvent') + event.initEvent('beforeunload', false, true) + const was = dismissing + dismissing = true + try { + window.dispatchEvent(event) + } finally { + dismissing = was + } + return event.defaultPrevented || (typeof event.returnValue === 'string' && event.returnValue !== '') + } + const redo = (): boolean => { + const again = stopped + stopped = null + if (!again || again.type === 'traverse') return false + if (again.type === 'reload') location.reload() + else if (again.form) { + const form = again.form.deref() + const submitter = again.submitter?.deref() + if (!form?.isConnected) return false + try { + form.requestSubmit((submitter as HTMLButtonElement | undefined)?.form === form ? submitter : null) + } catch { + // The form changed: the user submits it again. + return false + } + } else if (again.type === 'replace') location.replace(again.url) + else location.assign(again.url) + return true + } + Object.defineProperty(globalThis, '__orbitLeave', { + value: Object.freeze({ probe, stopped: () => stopped?.type ?? '', redo }), + }) } type PatchListener = (seq: number, patches: StatePatch[]) => void @@ -222,8 +388,12 @@ if (location.protocol === 'orbit:') { } } else { try { - contextBridge.executeInMainWorld({ func: patchPage, args: [permissionState, notificationClicked] }) + contextBridge.executeInMainWorld({ func: patchPage, args: [permissionState, notificationClicked, askDialog] }) } catch { // A page without its own world to patch keeps the engine's answers. } + if (window.top === window) { + watchDismissal() + exposeLeave() + } } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index d4be3a36..8cf3d714 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -26,6 +26,21 @@ async function boot(): Promise { ) return } + // A page's dialog over that page (pages lane, NV-24): alert, confirm, prompt, leave-page, sign-in. + if (view === 'dialog') { + const app = import('./pages/DialogApp') + await initStore() + installShortcutFallback() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { DialogApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } // An internal page (DT-13): Orbit's UI in a normal tab. Keys go to it as to any page. if (view === 'internal') { const app = import('./internal/InternalApp') diff --git a/src/renderer/src/pages/DialogApp.tsx b/src/renderer/src/pages/DialogApp.tsx new file mode 100644 index 00000000..b983d5ca --- /dev/null +++ b/src/renderer/src/pages/DialogApp.tsx @@ -0,0 +1,224 @@ +import { + type CSSProperties, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' +import type { PageDialogContent } from '../../../shared/pages' +import { invoke } from '../api' +import { useWindow } from '../store' +import { paletteVars, usePalette } from '../theme' +import './dialog.css' + +/** + * A page's dialog (NV-24), in a transparent view main keeps over that page only: the page's alert, + * confirm or prompt, "Leave site?", or HTTP sign-in. It takes the keyboard while it shows (KS-19): + * Enter answers with the accepting button, Esc cancels, and Tab stays inside the card. Main gives + * the keyboard back to the page when it goes (KS-16). + */ +export function DialogApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [content, setContent] = useState(null) + /** The dialog answered last: its card is gone until main sends the next one. */ + const answered = useRef(null) + + useEffect(() => { + let heard = false + const take = (next: PageDialogContent | null) => { + if (next && next.id === answered.current) return + setContent(next) + } + const off = window.orbit.on('dialog:content', (payload) => { + heard = true + take(payload as PageDialogContent) + }) + // What main sent before this page listened is lost: ask for the dialog now. + void invoke('pages.dialogContent', {}).then((first) => { + if (!heard) take(first) + }) + return off + }, []) + + const answer = useCallback((dialog: PageDialogContent, ok: boolean, fields: Fields = {}) => { + if (answered.current === dialog.id) return + answered.current = dialog.id + // Gone at once: the view may show another page's dialog next, and must not show this one first. + setContent(null) + void invoke('pages.dialogAnswer', { id: dialog.id, ok, ...fields }) + }, []) + + return ( +
+ {content && } +
+ ) +} + +interface Fields { + value?: string + user?: string + password?: string +} + +function DialogCard({ + content, + answer, +}: { + content: PageDialogContent + answer: (dialog: PageDialogContent, ok: boolean, fields?: Fields) => void +}) { + const card = useRef(null) + const firstField = useRef(null) + const okButton = useRef(null) + const [value, setValue] = useState(content.value ?? '') + const [user, setUser] = useState('') + const [password, setPassword] = useState('') + const titleId = `page-dialog-title-${content.id}` + const messageId = `page-dialog-message-${content.id}` + + // The prompt's text is selected, sign-in starts at the username, the rest at the accepting button. + useLayoutEffect(() => { + const field = firstField.current + if (field) { + field.focus() + if (content.kind === 'prompt') field.select() + } else okButton.current?.focus() + }, [content.kind]) + + // The view can get the keyboard after the card shows (main focuses it when the page is current). + useEffect(() => { + const onFocus = () => { + if (card.current?.contains(document.activeElement)) return + const field = firstField.current + if (field) field.focus() + else okButton.current?.focus() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, []) + + const accept = () => { + if (content.kind === 'prompt') answer(content, true, { value }) + else if (content.kind === 'auth') answer(content, true, { user, password }) + else answer(content, true) + } + const cancel = () => answer(content, false) + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + accept() + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + // An alert has one button: Esc closes it as OK does. + if (content.noCancel) accept() + else cancel() + return + } + if (e.key !== 'Tab' || !card.current) return + // Tab goes round the card's own controls. + const controls = [...card.current.querySelectorAll('input, button')].filter( + (el) => !(el as HTMLButtonElement).disabled, + ) + if (controls.length === 0) return + const at = controls.indexOf(document.activeElement as HTMLElement) + const next = e.shiftKey + ? at <= 0 + ? controls.length - 1 + : at - 1 + : at < 0 || at >= controls.length - 1 + ? 0 + : at + 1 + e.preventDefault() + controls[next]?.focus() + } + + const alert = + content.kind === 'alert' || content.kind === 'confirm' || content.kind === 'leave' || content.kind === 'reload' + return ( +
+

+ {content.title} +

+ {content.message && ( +

+ {content.message} +

+ )} + {content.kind === 'prompt' && ( + setValue(e.target.value)} + /> + )} + {content.kind === 'auth' && ( +
+ + +
+ )} + {content.note && ( +

+ {content.note} +

+ )} +
+ {!content.noCancel && ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/pages/dialog.css b/src/renderer/src/pages/dialog.css new file mode 100644 index 00000000..e70b5960 --- /dev/null +++ b/src/renderer/src/pages/dialog.css @@ -0,0 +1,147 @@ +/* + * A page's dialog (NV-24): a card at the top of that page's area, in a transparent view over the + * page only. A light veil says the page waits for it; the rest of the window stays as it is. + */ +.page-dialog-view { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: flex-start; + height: 100%; + padding: 16px 8px; + background: color-mix(in srgb, #000 8%, transparent); + font: 13px / 1.4 var(--font, system-ui, sans-serif); + color: var(--text, #1c1c1f); + cursor: default; + user-select: none; +} + +.page-dialog { + --dialog-line: color-mix(in srgb, var(--text, #1c1c1f) 14%, transparent); + --dialog-fill: color-mix(in srgb, var(--text, #1c1c1f) 6%, transparent); + --dialog-fill-hover: color-mix(in srgb, var(--text, #1c1c1f) 11%, transparent); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 10px; + width: min(420px, 100%); + max-height: 100%; + padding: 16px; + border-radius: 12px; + overflow: auto; + background: var(--surface, #fff); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.16), + 0 12px 32px rgba(0, 0, 0, 0.22), + 0 2px 6px rgba(0, 0, 0, 0.08); + animation: page-dialog-in 150ms var(--ease-out, ease-out); + transform-origin: 50% 0; +} + +@keyframes page-dialog-in { + from { + opacity: 0; + transform: scale(0.97) translateY(-4px); + } +} + +@media (prefers-reduced-motion: reduce) { + .page-dialog { + animation: none; + } +} + +.page-dialog__title { + margin: 0; + font-size: 14px; + font-weight: 650; + overflow-wrap: anywhere; +} + +.page-dialog__message { + margin: 0; + max-height: 40vh; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} + +.page-dialog__note { + margin: 0; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 12px; +} + +.page-dialog__fields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.page-dialog__label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); +} + +.page-dialog__field { + box-sizing: border-box; + width: 100%; + min-height: 30px; + padding: 5px 8px; + border: 0; + border-radius: 8px; + outline: none; + background: var(--dialog-fill); + box-shadow: inset 0 0 0 1px var(--dialog-line); + color: var(--text, #1c1c1f); + font: inherit; + font-size: 13px; + user-select: text; +} + +.page-dialog__field:focus { + box-shadow: inset 0 0 0 2px var(--accent, #5865d8); +} + +.page-dialog__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + margin-top: 2px; +} + +.page-dialog__button { + min-height: 30px; + min-width: 72px; + padding: 5px 14px; + border: 0; + border-radius: 8px; + background: var(--dialog-fill); + color: inherit; + font: inherit; + font-weight: 600; + transition: background var(--dur-fast, 120ms) ease; +} + +.page-dialog__button:hover { + background: var(--dialog-fill-hover); +} + +.page-dialog__button.is-primary { + background: var(--accent, #5865d8); + color: #fff; +} + +.page-dialog__button.is-primary:hover { + background: color-mix(in srgb, var(--accent, #5865d8) 88%, #000); +} + +.page-dialog__button:focus-visible { + outline: 2px solid var(--accent, #5865d8); + outline-offset: 1px; +} diff --git a/src/shared/commands/pages.ts b/src/shared/commands/pages.ts new file mode 100644 index 00000000..fb3df4c1 --- /dev/null +++ b/src/shared/commands/pages.ts @@ -0,0 +1,9 @@ +import type { PageDialogAnswer, PageDialogContent } from '../pages' + +/** Owned by the pages lane: what a page asks of the user (NV-24). */ +export interface PagesCommands { + /** A page dialog view's content: the dialog it shows now, or null (the view asks when it loads). */ + 'pages.dialogContent': { args: Record; result: PageDialogContent | null } + /** The user's answer to the dialog a page dialog view shows; ignored from any other view. */ + 'pages.dialogAnswer': { args: PageDialogAnswer; result: void } +} diff --git a/src/shared/pages.ts b/src/shared/pages.ts new file mode 100644 index 00000000..eda18d41 --- /dev/null +++ b/src/shared/pages.ts @@ -0,0 +1,126 @@ +/** + * What a page asks of the user, shown in Orbit's own dialogs over that page (pages lane, NV-24): + * the page's `alert`, `confirm` and `prompt`, "Leave site?" before a page that asks for it goes, + * and HTTP sign-in. + */ + +/** The kinds of page dialog. `leave` and `reload` are the leave-page warning (`beforeunload`). */ +export type PageDialogKind = 'alert' | 'confirm' | 'prompt' | 'leave' | 'reload' | 'auth' + +/** What a dialog view shows. */ +export interface PageDialogContent { + id: number + kind: PageDialogKind + /** "localhost:3000 says", "Leave site?", "Sign in". */ + title: string + /** The page's own text (alert, confirm, prompt) or Orbit's. Never HTML. */ + message: string + /** A second, quieter line (sign-in: the connection isn't private). */ + note?: string + /** prompt: the text the field starts with. */ + value?: string + /** The accepting button: "OK", "Leave", "Reload", "Sign in". */ + okLabel: string + /** alert: one button only. */ + noCancel?: boolean +} + +/** The user's answer; `ok: false` is Cancel (or Esc, or the dialog going away). */ +export interface PageDialogAnswer { + id: number + ok: boolean + /** prompt: the field's text. */ + value?: string + /** auth: what the user typed. */ + user?: string + password?: string +} + +/** A dialog shows at most this much of a page's text (the rest is cut, with an ellipsis). */ +export const DIALOG_TEXT_MAX = 10_000 + +/** A page's text for a dialog: cut to DIALOG_TEXT_MAX characters. */ +export function dialogText(text: string): string { + return text.length > DIALOG_TEXT_MAX ? `${text.slice(0, DIALOG_TEXT_MAX)}…` : text +} + +/** + * Who a dialog says asks (Chromium's wording): " says" for a web page, its host with the + * port; "This page says" for anything else (a local file, `data:`, `about:blank`). + */ +export function dialogTitle(pageUrl: string | undefined): string { + try { + const url = new URL(pageUrl ?? '') + // A blob: URL's own origin is the page's. + const inner = url.protocol === 'blob:' ? new URL(url.pathname) : url + if ((inner.protocol === 'http:' || inner.protocol === 'https:') && inner.host) return `${inner.host} says` + } catch { + // Not a URL Orbit can name. + } + return 'This page says' +} + +/** The dialog a page's `alert`, `confirm` or `prompt` shows. */ +export function scriptDialog( + kind: 'alert' | 'confirm' | 'prompt', + pageUrl: string | undefined, + message: string, + value: string, +): Omit { + return { + kind, + title: dialogTitle(pageUrl), + message: dialogText(message), + ...(kind === 'prompt' ? { value: dialogText(value) } : {}), + okLabel: 'OK', + ...(kind === 'alert' ? { noCancel: true } : {}), + } +} + +/** "Leave site?" (or "Reload site?" for a reload): Chromium's leave-page warning. */ +export function leaveDialog(reload: boolean): Omit { + return { + kind: reload ? 'reload' : 'leave', + title: reload ? 'Reload site?' : 'Leave site?', + message: 'Changes you made may not be saved.', + okLabel: reload ? 'Reload' : 'Leave', + } +} + +/** What Electron says about a sign-in request (`login` event's authInfo). */ +export interface AuthRequest { + isProxy: boolean + scheme: string + host: string + port: number + realm?: string +} + +/** + * HTTP sign-in (Basic or Digest): names the server (or the proxy) asking, and says so when the + * connection the password would go over isn't private. `requestUrl` is the address that asked. + */ +export function authDialog(request: AuthRequest, requestUrl: string | undefined): Omit { + let secure = false + try { + secure = new URL(requestUrl ?? '').protocol === 'https:' + } catch { + // Unknown: say it isn't private. + } + const server = request.port && ![80, 443].includes(request.port) ? `${request.host}:${request.port}` : request.host + const who = request.isProxy ? `The proxy ${server}` : secure ? `https://${server}` : `http://${server}` + return { + kind: 'auth', + title: 'Sign in', + message: `${who} requires a username and password.`, + ...(!request.isProxy && !secure ? { note: 'Your connection to this site is not private.' } : {}), + okLabel: 'Sign in', + } +} + +/** What a page's `alert`, `confirm` or `prompt` returns for an answer (or none: the dialog went away). */ +export function scriptAnswer(kind: 'alert' | 'confirm' | 'prompt', answer: PageDialogAnswer | null): unknown { + if (kind === 'alert') return undefined + if (kind === 'confirm') return answer?.ok === true + return answer?.ok ? (answer.value ?? '') : null +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 1945f41f..72793b8e 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -19,6 +19,7 @@ import type { ImportCommands } from './commands/import' import type { KeyCommands } from './commands/keys' import type { MediaCommands, MediaEvents } from './commands/media' import type { NavCommands } from './commands/nav' +import type { PagesCommands } from './commands/pages' import type { PeekCommands, PeekEvents } from './commands/peek' import type { SettingsCommands } from './commands/settings' import type { SpaceCommands } from './commands/spaces' @@ -43,7 +44,8 @@ export interface OrbitCommands ImportCommands, NavCommands, MediaCommands, - KeyCommands {} + KeyCommands, + PagesCommands {} export interface OrbitEvents extends CoreEvents, PeekEvents, MediaEvents {} @@ -89,6 +91,8 @@ export type RendererView = | 'import' /** Downloads lane: an internal page, `orbit:///` in a tab (DT-13). */ | 'internal' + /** Pages lane: a page's dialog over its page (alert, confirm, prompt, leave-page, sign-in; NV-24). */ + | 'dialog' /** The API the preload exposes on `window.orbit` to Orbit's own UI renderers. */ export interface OrbitBridge { diff --git a/src/shared/site.ts b/src/shared/site.ts index 8ed55ef1..6f903d3d 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -198,6 +198,13 @@ export const PAGE_CHANNEL = { * Esc in a loading tab, and only from that tab's main frame (src/main/keys/escape.ts). */ escape: 'orbit-page:escape', + /** + * sendSync(kind, message, value): the page called `alert`, `confirm` or `prompt` (NV-24). Main + * shows Orbit's dialog over that page, answers only a tab's or a pop-up's main frame, and replies + * when the user does: the page waits meanwhile, as it would for the engine's dialog + * (src/main/pages/dialogs.ts). + */ + dialog: 'orbit-page:dialog', } as const /** A tab comes forward for its notifications' clicks at most this often (a flood can't stall the shell). */ diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts new file mode 100644 index 00000000..4d8fa8a6 --- /dev/null +++ b/tests/e2e/pages-dialogs.spec.ts @@ -0,0 +1,392 @@ +import { expect, test } from '@playwright/test' +import { action, launchOrbit, type Orbit } from './helpers' +import { clickInPage, closeWindow, cmdIn } from './nav-support' +import { + AUTH_PASSWORD, + AUTH_USER, + dialogPage, + leaveInfo, + mayDiscard, + type PagesFixtures, + pageDialogs, + startPagesFixtures, + waitDialog, +} from './pages-support' +import { act, activeId, cmd, liveTabs, newTab, row, tab, tabsHook, waitTab } from './tabs-support' + +let fx: PagesFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +type Rect = { x: number; y: number; width: number; height: number } + +/** Runs a script in the page whose URL is `url`, without waiting on what it starts. */ +function inPage(app: Orbit['app'], url: string, code: string): Promise { + return app.evaluate( + ({ webContents }, [u, c]) => + webContents + .getAllWebContents() + .find((w) => w.getURL() === u) + ?.executeJavaScript(c), + [url, code] as const, + ) +} + +/** Waits until a tab's page title says what its `/ask` call returned. */ +async function answered(tabId: string, text: string) { + await expect.poll(async () => (await tab(orbit.app, tabId))?.title).toBe(`Answered ${text}`) +} + +test('[NV-24] confirm shows over its page only; other tabs stay usable and it waits for its tab', async () => { + const { app, shell } = orbit + const other = await newTab(app, fx.url('/page/Other')) + await waitTab(app, other, '/page/Other') + const url = fx.url('/ask?kind=confirm&msg=Keep%20going%3F') + const asking = await newTab(app, url) + await waitTab(app, asking, '/ask') + await clickInPage(app, url, '#ask') + + const shown = await waitDialog(app, (d) => d.tabId === asking && d.onScreen) + expect(shown.kind).toBe('confirm') + expect(shown.title).toBe(`localhost:${fx.port} says`) + expect(shown.message).toBe('Keep going?') + // Over the page's own area, and nothing else. + const pageBounds = await app.evaluate((_e, id) => { + const g = globalThis as unknown as { __orbit: { tabView: (id: string) => { bounds: unknown } | null } } + return g.__orbit.tabView(id)?.bounds + }, asking) + expect(shown.bounds).toEqual(pageBounds) + // It has the keyboard (KS-19). + await expect.poll(async () => (await waitDialog(app, (d) => d.id === shown.id)).focused).toBe(true) + + // The sidebar still works: another tab comes up, and the dialog waits off screen. + await row(shell, other).click() + await expect.poll(async () => (await waitDialog(app, (d) => d.id === shown.id)).onScreen).toBe(false) + // Back on its tab, it shows again. + await row(shell, asking).click() + await expect.poll(async () => (await waitDialog(app, (d) => d.id === shown.id)).onScreen).toBe(true) + + const view = await dialogPage(app, shown.id) + await expect(view.getByTestId('page-dialog-title')).toHaveText(`localhost:${fx.port} says`) + await expect(view.getByTestId('page-dialog-message')).toHaveText('Keep going?') + await view.getByTestId('page-dialog-ok').click() + await answered(asking, 'true') + await expect.poll(async () => (await waitDialog(app, () => true, 1000).catch(() => null)) === null).toBe(true) +}) + +/** The URL of the page that has the keyboard. */ +const focusedUrl = () => + orbit.app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL() ?? 'nothing') + +test('[NV-24][KS-19][KS-16] prompt and alert: Enter answers, Esc cancels, and the page gets the keyboard back', async () => { + const { app } = orbit + const url = fx.url('/ask?kind=prompt&msg=Your%20name&value=first') + const asking = await newTab(app, url) + await waitTab(app, asking, '/ask') + await clickInPage(app, url, '#ask') + const first = await waitDialog(app, (d) => d.tabId === asking && d.onScreen) + expect(first.kind).toBe('prompt') + const view = await dialogPage(app, first.id) + const field = view.getByTestId('page-dialog-input') + await expect(field).toHaveValue('first') + await expect(field).toBeFocused() + // The page's text starts selected: typing replaces it. + expect(await field.evaluate((el: HTMLInputElement) => [el.selectionStart, el.selectionEnd])).toEqual([0, 5]) + await field.fill('typed') + await field.press('Enter') + await answered(asking, '"typed"') + await expect.poll(focusedUrl).toBe(url) + + await clickInPage(app, url, '#ask') + const second = await waitDialog(app, (d) => d.tabId === asking && d.onScreen && d.id !== first.id) + await (await dialogPage(app, second.id)).keyboard.press('Escape') + await answered(asking, 'null') + await expect.poll(focusedUrl).toBe(url) + + const alertUrl = fx.url('/ask?kind=alert&msg=Saved') + const alerting = await newTab(app, alertUrl) + await waitTab(app, alerting, '/ask') + await clickInPage(app, alertUrl, '#ask') + const alert = await waitDialog(app, (d) => d.tabId === alerting && d.onScreen) + expect(alert.kind).toBe('alert') + const alertView = await dialogPage(app, alert.id) + // One button only. + await expect(alertView.getByTestId('page-dialog-cancel')).toHaveCount(0) + await expect(alertView.getByTestId('page-dialog-ok')).toBeFocused() + await alertView.keyboard.press('Enter') + await answered(alerting, 'undefined') +}) + +test('[NV-24] a background tab’s dialog waits until its tab shows', async () => { + const { app, shell } = orbit + const url = fx.url('/ask?kind=confirm&msg=Later') + const asking = await newTab(app, url) + await waitTab(app, asking, '/ask') + const other = await newTab(app, fx.url('/page/Other')) + await waitTab(app, other, '/page/Other') + // The page asks from its own timer while another tab shows. + await inPage(app, url, 'setTimeout(ask, 0); 0') + const waiting = await waitDialog(app, (d) => d.tabId === asking) + expect(waiting.onScreen).toBe(false) + await row(shell, asking).click() + await expect.poll(async () => (await waitDialog(app, (d) => d.id === waiting.id)).onScreen).toBe(true) + await (await dialogPage(app, waiting.id)).getByTestId('page-dialog-cancel').click() + await answered(asking, 'false') +}) + +test('[NV-24] a split pane’s dialog covers that pane only', async () => { + const { app } = orbit + const left = await newTab(app, fx.url('/page/Left')) + await waitTab(app, left, '/page/Left') + const url = fx.url('/ask?kind=confirm&msg=Right%20side') + const right = await newTab(app, url) + await waitTab(app, right, '/ask') + await app.evaluate( + (_e, ids) => { + const g = globalThis as unknown as { + __orbit: { run: (n: string, a: unknown, w?: string) => Promise; windowIds: () => string[] } + } + return g.__orbit.run('split.create', { tabIds: ids, focus: ids[1] }, g.__orbit.windowIds()[0]) + }, + [left, right], + ) + const boundsOf = (id: string) => + app.evaluate((_e, tabId) => { + const g = globalThis as unknown as { + __orbit: { tabView: (id: string) => { bounds: { x: number; y: number; width: number; height: number } } | null } + } + return g.__orbit.tabView(tabId)?.bounds ?? null + }, id) + // Side by side (the sidebar's order decides which pane is where). + const apart = (a: Rect | null, b: Rect | null) => + !!a && !!b && a.width > 0 && b.width > 0 && (a.x + a.width <= b.x || b.x + b.width <= a.x) + await expect.poll(async () => apart(await boundsOf(left), await boundsOf(right))).toBe(true) + await clickInPage(app, url, '#ask') + const shown = await waitDialog(app, (d) => d.tabId === right && d.onScreen) + expect(shown.bounds).toEqual(await boundsOf(right)) + expect(apart(shown.bounds ?? null, await boundsOf(left))).toBe(true) + await (await dialogPage(app, shown.id)).getByTestId('page-dialog-ok').click() + await answered(right, 'true') +}) + +test('[NV-24] a Peek’s and a Mini Window’s page show their dialogs over themselves', async () => { + const { app } = orbit + const under = await newTab(app, fx.url('/page/Under')) + await waitTab(app, under, '/page/Under') + const peekTab = (await app.evaluate((_e, u) => { + const g = globalThis as unknown as { + __orbit: { run: (n: string, a: unknown, w?: string) => Promise; windowIds: () => string[] } + } + return g.__orbit.run('peek.open', { url: u }, g.__orbit.windowIds()[0]) + }, fx.url('/ask?kind=confirm&msg=In%20a%20Peek&on=load'))) as string + expect(peekTab).toBeTruthy() + const peek = await waitDialog(app, (d) => d.tabId === peekTab && d.onScreen) + expect(peek.host).toBe('floating') + const pageBounds = (id: string) => + app.evaluate((_e, tabId) => { + const g = globalThis as unknown as { + __orbit: { tabView: (id: string) => { bounds: { x: number; y: number; width: number; height: number } } | null } + } + return g.__orbit.tabView(tabId)?.bounds ?? null + }, id) + // It follows the card as it springs open. + await expect + .poll(async () => JSON.stringify((await waitDialog(app, (d) => d.id === peek.id)).bounds)) + .toBe(JSON.stringify(await pageBounds(peekTab))) + await (await dialogPage(app, peek.id)).getByTestId('page-dialog-ok').click() + await answered(peekTab, 'true') + + await app.evaluate((_e, u) => { + const g = globalThis as unknown as { + __orbit: { run: (n: string, a: unknown, w?: string) => Promise; windowIds: () => string[] } + } + return g.__orbit.run('mini.open', { url: u }, g.__orbit.windowIds()[0]) + }, fx.url('/ask?kind=confirm&msg=In%20a%20Mini%20Window&on=load')) + const mini = await waitDialog(app, (d) => d.message === 'In a Mini Window' && d.onScreen) + expect(mini.host).toBe('floating') + const miniTab = mini.tabId as string + expect((await tab(app, miniTab))?.floating).toBe('mini') + await expect + .poll(async () => JSON.stringify((await waitDialog(app, (d) => d.id === mini.id)).bounds)) + .toBe(JSON.stringify(await pageBounds(miniTab))) + await (await dialogPage(app, mini.id)).getByTestId('page-dialog-cancel').click() + await answered(miniTab, 'false') +}) + +/** Opens `/warn` in a new tab and clicks in it: a page warns only after the user did something there. */ +async function warningTab(app: Orbit['app'], opts: { windowId?: string } = {}): Promise { + const url = fx.url('/warn') + const id = opts.windowId ? await cmdIn(app, opts.windowId, 'tabs.create', { url }) : await newTab(app, url) + await waitTab(app, id, '/warn') + await clickInPage(app, url, '#area') + await expect.poll(async () => (await leaveInfo(app, id))?.gestures ?? 0).toBeGreaterThan(0) + return id +} + +/** Answers the leave-page dialog over a tab's page. */ +async function answerLeave(app: Orbit['app'], tabId: string, choice: 'ok' | 'cancel', kind = 'leave') { + const d = await waitDialog(app, (x) => x.tabId === tabId && x.onScreen && x.kind === kind) + const view = await dialogPage(app, d.id) + if (kind === 'reload') { + await expect(view.getByTestId('page-dialog-title')).toHaveText('Reload site?') + await expect(view.getByTestId('page-dialog-ok')).toHaveText('Reload') + } else { + await expect(view.getByTestId('page-dialog-title')).toHaveText('Leave site?') + await expect(view.getByTestId('page-dialog-ok')).toHaveText('Leave') + } + await expect(view.getByTestId('page-dialog-message')).toHaveText('Changes you made may not be saved.') + await view.getByTestId(choice === 'ok' ? 'page-dialog-ok' : 'page-dialog-cancel').click() + await expect.poll(async () => (await pageDialogs(app)).some((x) => x.id === d.id)).toBe(false) +} + +test('[NV-24] leaving a page that warns asks first: a link, Orbit’s Reload and a form; Cancel stays, Leave goes on', async () => { + const { app } = orbit + const url = fx.url('/warn') + const warn = await warningTab(app) + + // A link: Cancel stays on the page. + await clickInPage(app, url, '#away') + await answerLeave(app, warn, 'cancel') + expect((await tab(app, warn))?.url).toBe(url) + expect(fx.seen()).not.toContain('GET /page/Away') + // Leave goes where the link goes. + await clickInPage(app, url, '#away') + await answerLeave(app, warn, 'ok') + await waitTab(app, warn, '/page/Away') + + // Orbit's Reload: "Reload site?", and Reload reloads. + await cmd(app, 'tabs.navigate', { tabId: warn, url }) + await waitTab(app, warn, '/warn') + await clickInPage(app, url, '#area') + const loads = () => fx.seen().filter((r) => r === 'GET /warn').length + const before = loads() + await act(app, 'nav.reload') + await answerLeave(app, warn, 'cancel', 'reload') + expect(loads()).toBe(before) + await act(app, 'nav.reload') + await answerLeave(app, warn, 'ok', 'reload') + await expect.poll(loads).toBe(before + 1) + await waitTab(app, warn, '/warn') + + // A form the page sends: Leave sends it. + await clickInPage(app, url, '#send') + await answerLeave(app, warn, 'ok') + await waitTab(app, warn, '/posted') + expect(fx.seen()).toContain('POST /posted') + + // A page nobody touched leaves without asking (Chromium's rule). + await cmd(app, 'tabs.navigate', { tabId: warn, url }) + await waitTab(app, warn, '/warn') + await cmd(app, 'tabs.navigate', { tabId: warn, url: fx.url('/page/Untouched') }) + await waitTab(app, warn, '/page/Untouched') + expect(await pageDialogs(app)).toEqual([]) +}) + +test('[NV-24] closing a tab whose page warns asks over it: Cancel keeps the tab, Leave closes it', async () => { + const { app } = orbit + const warn = await warningTab(app) + const other = await newTab(app, fx.url('/page/Other')) + await waitTab(app, other, '/page/Other') + // Closing it from the background: it shows first, then asks. + await cmd(app, 'tabs.close', { tabIds: [warn] }) + await answerLeave(app, warn, 'cancel') + expect(await tab(app, warn)).toBeTruthy() + expect(await activeId(app)).toBe(warn) + await act(app, 'tab.close') + await answerLeave(app, warn, 'ok') + await expect.poll(async () => await tab(app, warn)).toBeUndefined() + expect(await tab(app, other)).toBeTruthy() + // A page nobody touched closes at once. + const untouched = await newTab(app, fx.url('/warn')) + await waitTab(app, untouched, '/warn') + await act(app, 'tab.close') + expect(await tab(app, untouched)).toBeUndefined() +}) + +test('[NV-24] closing a window with a page that warns asks first: Cancel keeps the window, Leave closes it', async () => { + const { app } = orbit + const ids = () => + app.evaluate(() => (globalThis as unknown as { __orbit: { windowIds: () => string[] } }).__orbit.windowIds()) + const before = await ids() + await action(app, 'window.new') + let windowId = '' + await expect + .poll(async () => { + windowId = (await ids()).find((w) => !before.includes(w)) ?? '' + return windowId + }) + .not.toBe('') + const warn = await warningTab(app, { windowId }) + await closeWindow(app, windowId) + await answerLeave(app, warn, 'cancel') + expect(await ids()).toContain(windowId) + expect(await tab(app, warn)).toBeTruthy() + await closeWindow(app, windowId) + await answerLeave(app, warn, 'ok') + await expect.poll(ids).not.toContain(windowId) +}) + +test('[NV-24][TD-01][TB-16] auto-archive and sleep leave a page that warns, or one waiting on its dialog, until the user closes it', async () => { + const { app } = orbit + const warn = await warningTab(app) + const askUrl = fx.url('/ask?kind=confirm&msg=Still%20there%3F') + const asking = await newTab(app, askUrl) + await waitTab(app, asking, '/ask') + const plain = await newTab(app, fx.url('/page/Plain')) + await waitTab(app, plain, '/page/Plain') + const shown = await newTab(app, fx.url('/page/Shown')) + await waitTab(app, shown, '/page/Shown') + await inPage(app, askUrl, 'setTimeout(ask, 0); 0') + await waitDialog(app, (d) => d.tabId === asking) + + const later = Date.now() + 13 * 3600_000 + // The first pass asks the page; every pass after leaves it. + for (const pass of [1, 2]) { + const r = await tabsHook<{ archived: string[]; slept: string[] }>(app, 'maintenance', later + pass) + expect(r.archived).not.toContain(warn) + expect(r.archived).not.toContain(asking) + expect(r.slept).not.toContain(warn) + expect(r.slept).not.toContain(asking) + if (pass === 1) expect(r.archived).toContain(plain) + } + expect(await mayDiscard(app, warn)).toBe(false) + expect(await mayDiscard(app, asking)).toBe(false) + expect(await tab(app, plain)).toBeUndefined() + expect(await tab(app, warn)).toBeTruthy() + expect(await tab(app, asking)).toBeTruthy() + expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, asking])) +}) + +test('[NV-24] HTTP sign-in asks over the page: Cancel gets the server’s refusal, Sign in signs in', async () => { + const { app } = orbit + const url = fx.url('/auth') + const t = await newTab(app, url) + const first = await waitDialog(app, (d) => d.tabId === t && d.onScreen && d.kind === 'auth') + expect(first.title).toBe('Sign in') + expect(first.message).toBe(`http://localhost:${fx.port} requires a username and password.`) + const view = await dialogPage(app, first.id) + await expect(view.getByTestId('page-dialog-note')).toHaveText('Your connection to this site is not private.') + await expect(view.getByTestId('page-dialog-user')).toBeFocused() + await view.getByTestId('page-dialog-cancel').click() + await expect.poll(async () => (await tab(app, t))?.title).toBe('No entry') + + await act(app, 'nav.reload') + const second = await waitDialog(app, (d) => d.tabId === t && d.onScreen && d.kind === 'auth') + const again = await dialogPage(app, second.id) + await again.getByTestId('page-dialog-user').fill(AUTH_USER) + await again.getByTestId('page-dialog-password').fill(AUTH_PASSWORD) + await again.getByTestId('page-dialog-password').press('Enter') + await expect.poll(async () => (await tab(app, t))?.title).toBe('Signed in') +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts new file mode 100644 index 00000000..ada3b259 --- /dev/null +++ b/tests/e2e/pages-support.ts @@ -0,0 +1,191 @@ +/** + * Helpers for the pages lane's e2e specs (pages*.spec.ts): fixture pages that ask things of the + * user, and handles on Orbit's page dialogs (NV-24). Builds on helpers.ts. + */ +import { createServer, type Server } from 'node:http' +import type { ElectronApplication, Page } from '@playwright/test' +import { expect } from '@playwright/test' +import type { PageDialogInfo } from '../../src/main/pages/dialogs' +import type { LeaveInfo } from '../../src/main/pages/leave' +import { listenInRange } from './helpers' + +export interface PagesFixtures { + port: number + url: (path: string) => string + /** Requests the server saw, newest last (method and path). */ + seen: () => string[] + close: () => Promise +} + +/** The sign-in fixture's account. */ +export const AUTH_USER = 'orbit' +export const AUTH_PASSWORD = 'secret word' + +const html = (title: string, body: string, head = '') => ` +${title}${head} +${body}` + +/** + * - `/ask?kind=alert|confirm|prompt&msg=…&value=…&on=click|load`: asks once (on a click of `#ask`, or + * as soon as it loads) and writes what the call returned to `#result` (JSON) and the title. + * - `/warn`: warns before it goes (`beforeunload`), with a link (`#away`), a form (`#form`) and an + * area to click for a user gesture (`#area`). + * - `/auth`: HTTP Basic sign-in (AUTH_USER, AUTH_PASSWORD). + * - `/page/`: a plain page. + * - `/opener?to=…`: opens `to` in a pop-up window on a click of `#open`. + */ +export async function startPagesFixtures(base = 3340): Promise { + const seen: string[] = [] + const server: Server = createServer((req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost') + seen.push(`${req.method} ${u.pathname}${u.search}`) + const send = (body: string, status = 200, headers: Record = {}) => { + res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers }) + res.end(body) + } + if (u.pathname === '/ask') { + const kind = u.searchParams.get('kind') ?? 'confirm' + const msg = u.searchParams.get('msg') ?? 'Are you sure?' + const value = u.searchParams.get('value') ?? '' + const on = u.searchParams.get('on') ?? 'click' + const call = + kind === 'alert' + ? `alert(${JSON.stringify(msg)})` + : kind === 'prompt' + ? `prompt(${JSON.stringify(msg)}, ${JSON.stringify(value)})` + : `confirm(${JSON.stringify(msg)})` + send( + html( + 'Ask', + `none
+`, + ), + ) + return + } + if (u.pathname === '/warn') { + send( + html( + 'Warns', + `

Away

+
+
+`, + ), + ) + return + } + if (u.pathname === '/posted') { + send(html('Posted', '

Posted

')) + return + } + if (u.pathname === '/auth') { + const expected = `Basic ${Buffer.from(`${AUTH_USER}:${AUTH_PASSWORD}`).toString('base64')}` + if (req.headers.authorization === expected) send(html('Signed in', `

Signed in as ${AUTH_USER}

`)) + else send(html('No entry', '

No entry

'), 401, { 'www-authenticate': 'Basic realm="Orbit test"' }) + return + } + if (u.pathname === '/opener') { + const to = u.searchParams.get('to') ?? '/page/Popup' + send( + html( + 'Opener', + `
+`, + ), + ) + return + } + if (u.pathname.startsWith('/page/')) { + const name = decodeURIComponent(u.pathname.slice('/page/'.length)) + send(html(name, `

${name}

`)) + return + } + send('not found', 404) + }) + const port = await listenInRange(server, base) + return { + port, + url: (path) => `http://localhost:${port}${path}`, + seen: () => [...seen], + close: () => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }), + } +} + +type PagesGlobal = { + __orbitPages: { + dialogs: () => PageDialogInfo[] + leave: (tabId: string) => LeaveInfo | null + mayDiscard: (tabId: string) => boolean + } +} + +/** Every page's dialog on show or waiting (the first of each page's). */ +export function pageDialogs(app: ElectronApplication): Promise { + return app.evaluate(() => (globalThis as unknown as PagesGlobal).__orbitPages.dialogs()) +} + +/** Waits for a dialog that matches, and returns it. */ +export async function waitDialog( + app: ElectronApplication, + match: (d: PageDialogInfo) => boolean, + timeout = 15_000, +): Promise { + let found: PageDialogInfo | undefined + await expect + .poll( + async () => { + found = (await pageDialogs(app)).find(match) + return !!found + }, + { timeout }, + ) + .toBe(true) + return found as PageDialogInfo +} + +export function leaveInfo(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.leave(id), tabId) +} + +export function mayDiscard(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.mayDiscard(id), tabId) +} + +/** The dialog view (a Playwright page) showing the dialog with this id. */ +export async function dialogPage(app: ElectronApplication, id: number): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + for (const page of app.windows()) { + if (!page.url().includes('view=dialog')) continue + const shows = await page + .locator(`[data-dialog-id="${id}"]`) + .count() + .catch(() => 0) + if (shows > 0) return page + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error(`no dialog view shows dialog ${id}`) +} + +/** What a `/ask` page's call returned (JSON text), once the page is free to answer. */ +export async function askResult(page: Page): Promise { + return (await page.locator('#result').textContent()) ?? '' +} diff --git a/tests/unit/pages-dialogs.test.ts b/tests/unit/pages-dialogs.test.ts new file mode 100644 index 00000000..cf5e4b72 --- /dev/null +++ b/tests/unit/pages-dialogs.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + authDialog, + DIALOG_TEXT_MAX, + dialogText, + dialogTitle, + leaveDialog, + scriptAnswer, + scriptDialog, +} from '../../src/shared/pages' + +describe('[NV-24] page dialogs: who asks', () => { + it('names a web page by its host and port', () => { + expect(dialogTitle('http://localhost:3341/ask')).toBe('localhost:3341 says') + expect(dialogTitle('https://example.com/a?b')).toBe('example.com says') + expect(dialogTitle('https://[::1]:8443/')).toBe('[::1]:8443 says') + }) + + it('names a blob: page by the page that made it', () => { + expect(dialogTitle('blob:https://example.com/5f0c0d2e-1111-2222-3333-444455556666')).toBe('example.com says') + }) + + it('says "This page says" for anything else', () => { + for (const url of ['file:///home/me/a.html', 'data:text/html,hi', 'about:blank', '', undefined, 'not a url']) { + expect(dialogTitle(url)).toBe('This page says') + } + }) +}) + +describe('[NV-24] page dialogs: the page’s alert, confirm and prompt', () => { + it('alert has one button, confirm two, prompt a field with the page’s text', () => { + expect(scriptDialog('alert', 'https://a.test/', 'Saved', '')).toEqual({ + kind: 'alert', + title: 'a.test says', + message: 'Saved', + okLabel: 'OK', + noCancel: true, + }) + expect(scriptDialog('confirm', 'https://a.test/', 'Sure?', '')).toEqual({ + kind: 'confirm', + title: 'a.test says', + message: 'Sure?', + okLabel: 'OK', + }) + expect(scriptDialog('prompt', 'https://a.test/', 'Name', 'Ada')).toMatchObject({ kind: 'prompt', value: 'Ada' }) + }) + + it('cuts a very long text', () => { + const long = 'x'.repeat(DIALOG_TEXT_MAX + 5) + expect(dialogText(long)).toBe(`${'x'.repeat(DIALOG_TEXT_MAX)}…`) + expect(dialogText('short')).toBe('short') + expect(scriptDialog('prompt', undefined, long, long).value?.length).toBe(DIALOG_TEXT_MAX + 1) + }) + + it('gives the page what Chromium’s calls return', () => { + expect(scriptAnswer('alert', { id: 1, ok: true })).toBeUndefined() + expect(scriptAnswer('alert', null)).toBeUndefined() + expect(scriptAnswer('confirm', { id: 1, ok: true })).toBe(true) + expect(scriptAnswer('confirm', { id: 1, ok: false })).toBe(false) + // The dialog went away (its page navigated, the tab closed): Cancel. + expect(scriptAnswer('confirm', null)).toBe(false) + expect(scriptAnswer('prompt', { id: 1, ok: true, value: 'typed' })).toBe('typed') + expect(scriptAnswer('prompt', { id: 1, ok: true })).toBe('') + expect(scriptAnswer('prompt', { id: 1, ok: false, value: 'typed' })).toBeNull() + expect(scriptAnswer('prompt', null)).toBeNull() + }) +}) + +describe('[NV-24] page dialogs: leaving and signing in', () => { + it('"Leave site?" and, for a reload, "Reload site?"', () => { + expect(leaveDialog(false)).toEqual({ + kind: 'leave', + title: 'Leave site?', + message: 'Changes you made may not be saved.', + okLabel: 'Leave', + }) + expect(leaveDialog(true)).toMatchObject({ kind: 'reload', title: 'Reload site?', okLabel: 'Reload' }) + }) + + it('names the server or proxy asking, and says when the connection isn’t private', () => { + const server = { isProxy: false, scheme: 'basic', host: 'intranet.test', port: 80, realm: 'Staff' } + expect(authDialog(server, 'http://intranet.test/wiki')).toEqual({ + kind: 'auth', + title: 'Sign in', + message: 'http://intranet.test requires a username and password.', + note: 'Your connection to this site is not private.', + okLabel: 'Sign in', + }) + const secure = authDialog({ ...server, port: 8443 }, 'https://intranet.test:8443/wiki') + expect(secure.message).toBe('https://intranet.test:8443 requires a username and password.') + expect(secure.note).toBeUndefined() + const proxy = authDialog({ ...server, isProxy: true, host: 'proxy.test', port: 3128 }, 'http://site.test/') + expect(proxy.message).toBe('The proxy proxy.test:3128 requires a username and password.') + expect(proxy.note).toBeUndefined() + // No address to go by: not private. + expect(authDialog(server, undefined).note).toBe('Your connection to this site is not private.') + }) +}) From 1ddfac585dbacd39bcd2217948e8fbd488269992 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:56:41 +0000 Subject: [PATCH 02/29] fix(pages): no beforeunload or unload listener in every page (NV-24, D60) The page preload listened for beforeunload, pagehide and unload in every main frame, to ignore a page's dialogs while it goes and to note which navigation a warning stopped. A beforeunload listener makes the engine run every page's beforeunload before each navigation Orbit starts, so a busy page held up Back (keys.spec "goes back at once" took 1.5 s), and an unload listener keeps every page out of the back-forward cache. - The patched alert, confirm and prompt now read the event being handled (the engine's own window.event and Event.type getters) and return at once in the page's beforeunload, pagehide or unload handlers. - The page's last navigation comes from the Navigation API's navigate event; main asks right after the warning, so one that started within 2 s counts. - New e2e test: a page's confirm and prompt in its beforeunload and pagehide are ignored (link and tab close), and a busy page doesn't hold up a navigation Orbit starts. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 +- src/main/pages/leave.ts | 4 +- src/preload/page.ts | 98 +++++++++++++++++---------------- tests/e2e/pages-dialogs.spec.ts | 53 ++++++++++++++++++ tests/e2e/pages-support.ts | 21 +++++++ 5 files changed, 130 insertions(+), 50 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8b37e658..bd68e9ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,9 +145,9 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:notification-click` (NV-15): a trusted click on one of the page's notifications. Main brings the tab forward only for a site allowed to show notifications, and at most once per `NOTIFICATION_CLICK_GAP_MS` per tab. - `orbit-page:internal-*` (DT-13, D53): Orbit's internal pages only. In the main frame of an `orbit:` page, which is Orbit's own UI, the preload patches nothing and gives `window.orbit`: the store's state and changes, and the commands listed for that page (`INTERNAL_PAGE_COMMANDS`), with no events. Main answers only a tab's main frame showing an `orbit:` page. In a web page it does nothing. - `orbit-page:escape` (KS-19, D59): one bit after a trusted Esc reaches the main frame, sent once every handler of the page has run: whether one called `preventDefault`. The listener is in the preload's own world. Main reads the bit only while it waits after an Esc in a loading tab with no overlay open (at most 250 ms; no answer counts as not used), only from that tab's main frame, and only for a press still waiting: a bit with none waiting does nothing. A page that always uses Esc can keep Esc from stopping its load; the Stop button still stops it. -- `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. While the page is going (`beforeunload`, `pagehide`, `unload`) the calls return at once, as the engine's do. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. +- `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. In the page's `beforeunload`, `pagehide` or `unload` handlers the calls return at once, as the engine's do: the patch reads the event being handled through the engine's own getters. The preload adds no `beforeunload` or `unload` listener of its own: with one, the engine would run every page's `beforeunload` before each navigation (a busy page would hold up Back, D60), and keep every page out of the back-forward cache. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. -Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): whether the page would warn if it went now (it runs the page's `beforeunload` handlers with a synthetic event, only after a user gesture in the page), what kind of navigation a warning stopped, and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. +Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): whether the page would warn if it went now (it runs the page's `beforeunload` handlers with a synthetic event, only after a user gesture in the page), what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JS (user-authored, per site) is injected only through `webFrame.executeJavaScript` in an isolated world, never with Node. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). diff --git a/src/main/pages/leave.ts b/src/main/pages/leave.ts index 8500e1bb..10475123 100644 --- a/src/main/pages/leave.ts +++ b/src/main/pages/leave.ts @@ -166,8 +166,10 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog /** A warning stopped a navigation (or a pop-up's close): asks, and on Leave goes on with it. */ const askToLeave = async (page: WebContents, p: LeavePage, stopped: Stopped) => { if (p.asking) { - // A later navigation stopped while asking: Leave goes on with that one. + // A later navigation stopped while asking: Leave goes on with that one (the page's own: the + // preload notes which, as it does when asked). p.asking = stopped + if (stopped === 'page') void ask(page, STOPPED, '') return } p.asking = stopped diff --git a/src/preload/page.ts b/src/preload/page.ts index 595774f1..eca7b96e 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -144,14 +144,33 @@ function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog) { // take "" for a missing or undefined argument, `alert` only for a missing one. const asText = (args: unknown[], at: number, optional: boolean): string => args.length <= at || (optional && args[at] === undefined) ? '' : `${args[at]}` + // The engine shows no dialog while the page is going: in its `beforeunload`, `pagehide` or + // `unload` handlers (Orbit's probe of `beforeunload` too). Orbit tells from the event the page's + // handler runs for, read through the engine's own getters (the page can't hide it), and adds no + // listener of its own: with a `beforeunload` listener the engine would ask every page before each + // navigation, so a busy page would hold up Back (D60), and an `unload` one would keep every page + // out of the back-forward cache. + const currentEvent = describe(window, 'event')?.get + const eventType = describe(Event.prototype, 'type')?.get + const going = (): boolean => { + if (!currentEvent || !eventType) return false + try { + const type: unknown = apply(eventType, apply(currentEvent, window, []), []) + return type === 'beforeunload' || type === 'pagehide' || type === 'unload' + } catch { + // No event: the page isn't in a handler. + return false + } + } + const askNow: AskDialog = (kind, message, value) => (going() ? null : ask(kind, message, value)) const answers: Record<'alert' | 'confirm' | 'prompt', (args: unknown[]) => unknown> = { alert: (args) => { - ask('alert', asText(args, 0, false), '') + askNow('alert', asText(args, 0, false), '') return undefined }, - confirm: (args) => ask('confirm', asText(args, 0, true), '') === true, + confirm: (args) => askNow('confirm', asText(args, 0, true), '') === true, prompt: (args) => { - const value = ask('prompt', asText(args, 0, true), asText(args, 1, true)) + const value = askNow('prompt', asText(args, 0, true), asText(args, 1, true)) return typeof value === 'string' ? value : null }, } @@ -167,7 +186,7 @@ function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog) { type AskDialog = (kind: 'alert' | 'confirm' | 'prompt', message: string, value: string) => unknown -/** Set while the page is going (beforeunload, pagehide, unload): the engine shows no dialog then. */ +/** Set while Orbit probes the page's `beforeunload` (`exposeLeave`): the page's dialogs are ignored then. */ let dismissing = false const askDialog: AskDialog = (kind, message, value) => { @@ -179,29 +198,8 @@ const askDialog: AskDialog = (kind, message, value) => { } } -/** - * The engine ignores `alert`, `confirm` and `prompt` while a page is going: so does Orbit's dialog. - * These listeners come before the page's, so the flag is up for all of its handlers. - */ -function watchDismissal(): void { - for (const type of ['beforeunload', 'pagehide', 'unload']) { - window.addEventListener( - type, - (event) => { - if (!event.isTrusted) return - dismissing = true - // A navigation the warning stopped leaves the page as it was. - setTimeout(() => { - dismissing = false - }, 0) - }, - true, - ) - } - window.addEventListener('pageshow', () => { - dismissing = false - }) -} +/** Main asks which navigation a warning stopped just after it; one the page started longer ago isn't it. */ +const STOPPED_MS = 2_000 interface StoppedNavigation { url: string @@ -221,16 +219,19 @@ interface StoppedNavigation { * its handlers cancel the event or set a `returnValue`. The handlers see an untrusted event, and * their dialogs are ignored, as during the real one. * - `stopped()`: what the page's own navigation that a warning just stopped was (`reload`, `push`, - * `replace`, `traverse`), or "" when there is none this frame knows of: a navigation is known - * here from the Navigation API's `navigate` event, which comes just before `beforeunload`. + * `replace`, `traverse`), or "" when there is none this frame knows of. A navigation is known + * here from the Navigation API's `navigate` event, which comes in the same task as the + * `beforeunload` the warning comes from; main asks right after the warning, so the page's last + * navigation counts if it started within STOPPED_MS. (A `beforeunload` listener of Orbit's + * would tell for sure, but would cost every page: see `going` in `patchPage`.) * - `redo()`: starts that navigation again (the user chose Leave; main lets the next warning * through): a link or `location` loads its URL, a form is submitted again, a reload reloads. */ function exposeLeave(): void { const nav = (window as { navigation?: EventTarget }).navigation - /** The last cross-document navigation the page started, until its task ends. */ - let starting: StoppedNavigation | null = null - /** The navigation the last real `beforeunload` was for. */ + /** The last cross-document navigation the page started, its event, and when (`performance.now()`). */ + let starting: (StoppedNavigation & { event: Event; at: number }) | null = null + /** The navigation main last asked about: the one a warning stopped. */ let stopped: StoppedNavigation | null = null nav?.addEventListener('navigate', (event) => { const e = event as Event & { @@ -251,18 +252,14 @@ function exposeLeave(): void { type: e.navigationType ?? 'push', ...(form ? { form: new WeakRef(form) } : {}), ...(form && source instanceof HTMLElement && source !== form ? { submitter: new WeakRef(source) } : {}), + event, + at: performance.now(), } - setTimeout(() => { - starting = null - }, 0) }) - window.addEventListener( - 'beforeunload', - (event) => { - if (event.isTrusted) stopped = starting - }, - true, - ) + // The page's own same-document navigation that finished isn't one a warning stops. + nav?.addEventListener('navigatesuccess', () => { + starting = null + }) const probe = (): boolean => { if (!navigator.userActivation?.hasBeenActive) return false const event = document.createEvent('BeforeUnloadEvent') @@ -296,7 +293,17 @@ function exposeLeave(): void { return true } Object.defineProperty(globalThis, '__orbitLeave', { - value: Object.freeze({ probe, stopped: () => stopped?.type ?? '', redo }), + value: Object.freeze({ + probe, + stopped: () => { + const last = starting + starting = null + // A navigation the page cancelled itself didn't reach its `beforeunload`. + stopped = last && !last.event.defaultPrevented && performance.now() - last.at <= STOPPED_MS ? last : null + return stopped?.type ?? '' + }, + redo, + }), }) } @@ -392,8 +399,5 @@ if (location.protocol === 'orbit:') { } catch { // A page without its own world to patch keeps the engine's answers. } - if (window.top === window) { - watchDismissal() - exposeLeave() - } + if (window.top === window) exposeLeave() } diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index 4d8fa8a6..2b79a2a3 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -294,6 +294,59 @@ test('[NV-24] leaving a page that warns asks first: a link, Orbit’s Reload and expect(await pageDialogs(app)).toEqual([]) }) +test('[NV-24][KS-16] a page’s confirm or prompt while it goes is ignored, as the engine’s is, and Orbit adds no handler that would slow it', async () => { + const { app } = orbit + const url = fx.url('/going') + const beacons = (at: string) => fx.seen().filter((r) => r.startsWith(`POST /beacon?at=${at}&`)) + const going = await newTab(app, url) + await waitTab(app, going, '/going') + await clickInPage(app, url, '#area') + await expect.poll(async () => (await leaveInfo(app, going))?.gestures ?? 0).toBeGreaterThan(0) + // Its link: the page's beforeunload and pagehide ask, get the engine's answers at once, and it goes. + await clickInPage(app, url, '#away') + await waitTab(app, going, '/page/Away') + await expect.poll(() => beacons('beforeunload')).toEqual([`POST /beacon?at=beforeunload&r=false`]) + await expect.poll(() => beacons('pagehide')).toEqual([`POST /beacon?at=pagehide&r=null`]) + expect(await pageDialogs(app)).toEqual([]) + + // Closing a tab Orbit asks whether the page would warn: its beforeunload runs, and its confirm is + // ignored there too. + const closing = await newTab(app, url) + await waitTab(app, closing, '/going') + await clickInPage(app, url, '#area') + await expect.poll(async () => (await leaveInfo(app, closing))?.gestures ?? 0).toBeGreaterThan(0) + await act(app, 'tab.close') + await expect.poll(async () => await tab(app, closing)).toBeUndefined() + await expect.poll(() => beacons('beforeunload').length).toBe(2) + expect(beacons('beforeunload')[1]).toBe(`POST /beacon?at=beforeunload&r=false`) + expect(await pageDialogs(app)).toEqual([]) + + // A busy page doesn't hold up a navigation Orbit starts: the engine runs a page's beforeunload + // first only when it has a handler, and Orbit adds none (D60). Nor an unload one, which would + // keep every page out of the back-forward cache. + const plain = fx.url('/page/Plain') + const id = await newTab(app, plain) + await waitTab(app, id, '/page/Plain') + const waited = await app.evaluate( + async ({ webContents }, [u, next]) => { + const page = webContents.getAllWebContents().find((w) => w.getURL() === u) + if (!page) return -1 + void page.executeJavaScript('{ const end = Date.now() + 3000; while (Date.now() < end) {} }').catch(() => {}) + await new Promise((r) => setTimeout(r, 200)) + const t0 = Date.now() + const started = new Promise((resolve) => { + page.once('did-start-navigation', () => resolve(Date.now() - t0)) + }) + page.loadURL(next).catch(() => {}) + return started + }, + [plain, fx.url('/page/Next')] as const, + ) + expect(waited).toBeGreaterThanOrEqual(0) + expect(waited).toBeLessThan(1000) + await waitTab(app, id, '/page/Next') +}) + test('[NV-24] closing a tab whose page warns asks over it: Cancel keeps the tab, Leave closes it', async () => { const { app } = orbit const warn = await warningTab(app) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index ada3b259..e468e714 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -31,6 +31,8 @@ const html = (title: string, body: string, head = '') => ` * as soon as it loads) and writes what the call returned to `#result` (JSON) and the title. * - `/warn`: warns before it goes (`beforeunload`), with a link (`#away`), a form (`#form`) and an * area to click for a user gesture (`#area`). + * - `/going`: asks `confirm` in its `beforeunload` and `prompt` in its `pagehide`, and sends what they + * returned to `/beacon?at=…&r=…` (JSON); a link (`#away`) and an area to click (`#area`). * - `/auth`: HTTP Basic sign-in (AUTH_USER, AUTH_PASSWORD). * - `/page/`: a plain page. * - `/opener?to=…`: opens `to` in a pop-up window on a click of `#open`. @@ -85,6 +87,25 @@ ${on === 'load' ? "addEventListener('load', () => setTimeout(ask, 0))" : ''} ) return } + if (u.pathname === '/going') { + send( + html( + 'Going', + `

Away

+`, + ), + ) + return + } + if (u.pathname === '/beacon') { + res.writeHead(204) + res.end() + return + } if (u.pathname === '/posted') { send(html('Posted', '

Posted

')) return From 9eacd5ebd9adf00dacbeca1af68275283d6268fe Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:02:36 +0000 Subject: [PATCH 03/29] feat(pages): error and crash screens over the page (NV-13, NV-14) A main-frame load that fails shows Orbit's screen over that page's area only, over the engine's own error page, so the failed address, Reload and Back stay the engine's: a title, one plain sentence, the site, the error code and Try again. Certificates lead with Back to safety and keep Proceed behind Advanced (remembered per profile, host and certificate until quit). Offline pages load again once when the network returns. Orbit stops waiting for a server after 120 s (forms being sent are left alone; the page preload says so over orbit-page:form-sent), and Back shows the page as it was. The row keeps its title and icon for the site it showed. A crashed or killed page shows "This page crashed" with Reload; a page that crashed in the background shows it when selected, until reloaded (windows.ts no longer reloads it by itself; core.spec.ts's F11 test and devtools.spec.ts's DT-14 test follow). A hung page gets "Page unresponsive" with Wait or Close page. Page dialogs and screens share one placement module (covers.ts). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 8 +- docs/ARCHITECTURE.md | 5 +- docs/feature-status.json | 2 + src/main/pages/covers.ts | 380 ++++++++++++++++++++ src/main/pages/dialogs.ts | 439 ++++------------------- src/main/pages/index.ts | 11 +- src/main/pages/screens.ts | 509 +++++++++++++++++++++++++++ src/main/windows.ts | 4 +- src/preload/page.ts | 4 + src/renderer/src/main.tsx | 15 + src/renderer/src/pages/DialogApp.tsx | 19 +- src/renderer/src/pages/ScreenApp.tsx | 143 ++++++++ src/renderer/src/pages/screen.css | 152 ++++++++ src/shared/commands/pages.ts | 8 +- src/shared/pages.ts | 223 +++++++++++- src/shared/rpc.ts | 2 + src/shared/site.ts | 7 + tests/e2e/core.spec.ts | 6 +- tests/e2e/devtools.spec.ts | 5 +- tests/e2e/pages-crash.spec.ts | 174 +++++++++ tests/e2e/pages-dialogs.spec.ts | 5 +- tests/e2e/pages-errors.spec.ts | 380 ++++++++++++++++++++ tests/e2e/pages-support.ts | 317 ++++++++++++++++- tests/unit/pages-screens.test.ts | 136 +++++++ 24 files changed, 2546 insertions(+), 408 deletions(-) create mode 100644 src/main/pages/covers.ts create mode 100644 src/main/pages/screens.ts create mode 100644 src/renderer/src/pages/ScreenApp.tsx create mode 100644 src/renderer/src/pages/screen.css create mode 100644 tests/e2e/pages-crash.spec.ts create mode 100644 tests/e2e/pages-errors.spec.ts create mode 100644 tests/unit/pages-screens.test.ts diff --git a/FEATURES.md b/FEATURES.md index 187871a0..bc00eabb 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 237 built (87 partial) · 331 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 239 built (87 partial) · 332 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 140 | 168 | -| P1 | 219 | 82 | 146 | +| P1 | 219 | 84 | 147 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -240,8 +240,8 @@ Spec: `spec/navigation.md` | NV-10 | New window | P0 | 🟡 partial | ✅ e2e | – | Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built | | NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10 | | NV-12 | Page context menu | P0 | ✅ | ✅ e2e | – | native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built | -| NV-13 | Load-error pages | P1 | – | – | – | | -| NV-14 | Crashed or unresponsive tab | P1 | – | ✅ e2e | – | | +| NV-13 | Load-error pages | P1 | ✅ | ✅ e2e+unit | – | a main-frame load that fails shows Orbit's screen over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up): an icon, a title, one plain sentence in Chromium's words, the site, the engine's error code and Try again, which has the keyboard when the page had it (never taken from the sidebar or the command bar); it is a view over the engine's own error page rather than an orbit: page (D39), so the address keeps the failed URL and ⌘R, Try again, Back and history stay the engine's; a host that doesn't resolve, a refused connection, no internet, an empty response and other net errors each say what happened; the screen stays through a retry that fails again; the row keeps its title and icon when the site that failed is the one it showed, and otherwise shows that site's name (and its icon if Orbit knows one); Orbit gives up on a server that hasn't answered within 120 s (Chromium waits until the user stops it): "took too long to respond", the address stays where it was going, Try again and ⌘R ask again, and Back shows the page before as it was, its screen included; a form being sent is never stopped (it may be uploading); certificates: "Your connection is not private" with Back to safety on the keyboard (back, or closing a tab with nowhere to go back to), Proceed only behind Advanced and never for a revoked certificate, remembered for that certificate and host per profile until Orbit quits; offline: when the network comes back while the screen shows, the page loads again, once (e2e dispatch the engine's online event, as the emulation is per page); a site that asks for HTTPS only (HSTS) still gets Proceed: Electron doesn't say which sites do; gap, as DL-12's (D54): no Safe Browsing interstitial, which needs a reputation service none is free to use; the http not-secure icon is SB-04 | +| NV-14 | Crashed or unresponsive tab | P1 | ✅ | ✅ e2e+unit | – | a page that crashes or is killed shows "This page crashed" over its area, with the reason (out of memory, killed…) and Reload, which has the keyboard when the page had it; Reload, ⌘R and ⌘⇧R bring the page back, and closing the tab works as ever; the row keeps its title and icon; a page that crashed behind another shows its crash screen when its row is selected, until the user reloads it (it no longer reloads by itself when shown: the review F11 test in core.spec.ts now reloads it); the same screen shows over a Peek's, a Mini Window's or a pop-up's page (e2e cover tabs); a page that stops responding (the engine's own signal, after about 15 s of unanswered input) gets "Page unresponsive" over it with Wait (Enter, Esc) or Close page, which ends the page's process: its screen then says "This page was closed" with Reload, and other tabs that share that process end too, as in Chromium; the dialog goes by itself when the page answers again; after Wait, a page that stays hung isn't asked about again until it answers and hangs anew (the engine says it once); e2e: the engine says nothing of a hang while a debugger is attached, and Playwright attaches to every page, so the hang tests keep a page really busy and emit the engine's signal; TB-16 (busy background tabs paused) and EX-13 (crashing extensions) are their own rows | | NV-15 | Permission prompts | P0 | ✅ | ✅ e2e+unit | – | prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); the recording indicators wait on TB-15; toolbar anchor waits on SB-17, Site settings is ST-13 | | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bd68e9ff..36efe154 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) - **Main process** (`src/main`) owns all state, all web contents, sessions, windows, menus, shortcuts and persistence. - **Shell renderer** (`src/renderer`, `?view=shell`) draws the sidebar and an empty rounded "frame". It measures the frame (and split panes) and reports pane rectangles to main (`layout.panes` event). Main then places the native tab views exactly over them. Pages are never iframes. - **Overlay renderer** (`?view=overlay`) is a transparent full-window view above the tabs. It's shown only while something floats (command bar, Peek card chrome, menus). Clicking its backdrop closes it. -- **Views over the pages**, bottom to top: the tab views; the page's small views (page tools, permission prompt, loading indicator, link pill: `WindowManager.createUiView` and kin); a page cover while one is open (the Library, `WindowManager.setPageCover`), which those small views go under; then a Peek, the floating sidebar card and the overlay. +- **Views over the pages**, bottom to top: the tab views; the page's small views (page tools, permission prompt, loading indicator, link pill, a page's dialog or its error or crash screen: `WindowManager.createUiView` and kin); a page cover while one is open (the Library, `WindowManager.setPageCover`), which those small views go under; then a Peek, the floating sidebar card and the overlay. - **Other renderer views**: `mini` (Mini Window chrome) and `settings` (settings window). They come from the same bundle and are selected by the `view` query. - **Preload** (`src/preload/shell.ts`) runs sandboxed with context isolation. It exposes `window.orbit` (`OrbitBridge` in `src/shared/rpc.ts`) only to Orbit UI contents, and its `orbit:*` IPC handlers reject any sender that isn't registered Orbit UI. Web pages get one preload of their own, `src/preload/page.ts` (NV-15), on D40's terms. It runs in main frames only and adds nothing to page globals: it patches only the built-ins NV-15 needs (`Notification.permission`, `Notification.requestPermission`, the `Notification` constructor, with its prototype's `constructor` pointing back at it, and `PermissionStatus.prototype.state`), so a permission the user hasn't decided reads "default"/"prompt" as in Chromium and a click on one of the page's notifications reaches main. It talks to main only over its own `orbit-page:*` channels, separate from the UI's `orbit:*` channels, and main takes the origin and profile from `event.senderFrame`, never from the message. - **Internal pages** (`orbit:///`, DT-13) are Orbit UI in a normal tab, served from the renderer bundle by a protocol handler on each profile session. The web page preload (`src/preload/page.ts`) gives an `orbit:` page's main frame, and nothing else, the same bridge on channels of its own (`PAGE_CHANNEL.internal*`), which main answers only for such frames, and only with the commands listed for the page (`INTERNAL_PAGE_COMMANDS`; no events), D53. Their documents run only their own scripts (`script-src 'self'`). Web content can't navigate, frame, redirect to, open, load or fetch them. How they work and how to add a page: `src/main/devtools/internal-pages.ts`. @@ -146,6 +146,9 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:internal-*` (DT-13, D53): Orbit's internal pages only. In the main frame of an `orbit:` page, which is Orbit's own UI, the preload patches nothing and gives `window.orbit`: the store's state and changes, and the commands listed for that page (`INTERNAL_PAGE_COMMANDS`), with no events. Main answers only a tab's main frame showing an `orbit:` page. In a web page it does nothing. - `orbit-page:escape` (KS-19, D59): one bit after a trusted Esc reaches the main frame, sent once every handler of the page has run: whether one called `preventDefault`. The listener is in the preload's own world. Main reads the bit only while it waits after an Esc in a loading tab with no overlay open (at most 250 ms; no answer counts as not used), only from that tab's main frame, and only for a press still waiting: a bit with none waiting does nothing. A page that always uses Esc can keep Esc from stopping its load; the Stop button still stops it. - `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. In the page's `beforeunload`, `pagehide` or `unload` handlers the calls return at once, as the engine's do: the patch reads the event being handled through the engine's own getters. The preload adds no `beforeunload` or `unload` listener of its own: with one, the engine would run every page's `beforeunload` before each navigation (a busy page would hold up Back, D60), and keep every page out of the back-forward cache. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. +- `orbit-page:form-sent` (NV-13): the main frame is about to send a form (POST), from the Navigation API's `navigate` event in the preload's own world: the address it goes to. Main reads it only from a tab's or a pop-up's main frame, and it counts only for that frame's next navigation, to that address: Orbit's time limit for a server's first answer leaves that navigation alone, since it may be a large upload. A page that says so falsely only keeps the time limit off its own load. + +A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog takes the keyboard when it shows over the page the user is on, as the engine's own does; a screen takes it only where the page had it, never from Orbit's own fields. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): whether the page would warn if it went now (it runs the page's `beforeunload` handlers with a synthetic event, only after a user gesture in the page), what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. diff --git a/docs/feature-status.json b/docs/feature-status.json index 6174dcfa..aa0e87ea 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -197,6 +197,8 @@ "NV-10": {"built": "partial", "note": "Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built"}, "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10"}, "NV-12": {"built": "yes", "note": "native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built"}, + "NV-13": {"built": "yes", "note": "a main-frame load that fails shows Orbit's screen over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up): an icon, a title, one plain sentence in Chromium's words, the site, the engine's error code and Try again, which has the keyboard when the page had it (never taken from the sidebar or the command bar); it is a view over the engine's own error page rather than an orbit: page (D39), so the address keeps the failed URL and ⌘R, Try again, Back and history stay the engine's; a host that doesn't resolve, a refused connection, no internet, an empty response and other net errors each say what happened; the screen stays through a retry that fails again; the row keeps its title and icon when the site that failed is the one it showed, and otherwise shows that site's name (and its icon if Orbit knows one); Orbit gives up on a server that hasn't answered within 120 s (Chromium waits until the user stops it): \"took too long to respond\", the address stays where it was going, Try again and ⌘R ask again, and Back shows the page before as it was, its screen included; a form being sent is never stopped (it may be uploading); certificates: \"Your connection is not private\" with Back to safety on the keyboard (back, or closing a tab with nowhere to go back to), Proceed only behind Advanced and never for a revoked certificate, remembered for that certificate and host per profile until Orbit quits; offline: when the network comes back while the screen shows, the page loads again, once (e2e dispatch the engine's online event, as the emulation is per page); a site that asks for HTTPS only (HSTS) still gets Proceed: Electron doesn't say which sites do; gap, as DL-12's (D54): no Safe Browsing interstitial, which needs a reputation service none is free to use; the http not-secure icon is SB-04"}, + "NV-14": {"built": "yes", "note": "a page that crashes or is killed shows \"This page crashed\" over its area, with the reason (out of memory, killed…) and Reload, which has the keyboard when the page had it; Reload, ⌘R and ⌘⇧R bring the page back, and closing the tab works as ever; the row keeps its title and icon; a page that crashed behind another shows its crash screen when its row is selected, until the user reloads it (it no longer reloads by itself when shown: the review F11 test in core.spec.ts now reloads it); the same screen shows over a Peek's, a Mini Window's or a pop-up's page (e2e cover tabs); a page that stops responding (the engine's own signal, after about 15 s of unanswered input) gets \"Page unresponsive\" over it with Wait (Enter, Esc) or Close page, which ends the page's process: its screen then says \"This page was closed\" with Reload, and other tabs that share that process end too, as in Chromium; the dialog goes by itself when the page answers again; after Wait, a page that stays hung isn't asked about again until it answers and hangs anew (the engine says it once); e2e: the engine says nothing of a hang while a debugger is attached, and Playwright attaches to every page, so the hang tests keep a page really busy and emit the engine's signal; TB-16 (busy background tabs paused) and EX-13 (crashing extensions) are their own rows"}, "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); the recording indicators wait on TB-15; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, diff --git a/src/main/pages/covers.ts b/src/main/pages/covers.ts new file mode 100644 index 00000000..9dcb6b20 --- /dev/null +++ b/src/main/pages/covers.ts @@ -0,0 +1,380 @@ +import { webContents as allContents, BrowserWindow, type WebContents, WebContentsView } from 'electron' +import type { Rect, RendererView } from '../../shared/rpc' +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { activeTabOf, loadRenderer, rendererEntry, uiWebPreferences, viewContents } from '../windows' + +/** + * Where a page's cover shows: over a tab's page in a main window (a split pane included), over a + * Peek's or a Mini Window's page, or over a pop-up window's page. + */ +export type HostKind = 'tab' | 'floating' | 'popup' + +interface Host { + kind: HostKind + win: BrowserWindow + /** The Orbit window: the main window (a tab, a Peek) or the Mini Window. None for a pop-up. */ + windowId?: WindowId +} + +/** What a cover's view shows; its page is sent each new one (by id). */ +export interface CoverContent { + id: number +} + +/** One page's cover. */ +interface Cover { + page: WebContents + view?: WebContentsView + host?: Host + /** The view's page has loaded: only then does the view go into its window. */ + ready: boolean + /** What the view's page was last sent. */ + sent?: number + /** The view is in its window, over the page. */ + onScreen: boolean + off: () => void +} + +export interface CoverInfo { + /** Where it shows; null while the page has no window. */ + host: HostKind | null + /** Its view is on screen over the page. */ + onScreen: boolean + /** Its view has the keyboard. */ + focused: boolean + bounds?: Rect +} + +export interface PageCovers { + /** Brings a page's cover in line with what it shows now (`content`): shown, moved or gone. */ + update(page: WebContents): void + /** What the cover view `sender` shows, and over which page (for the view's own requests). */ + shownBy(sender: WebContents): { page: WebContents; content: C } | undefined + /** Whether Orbit can place a cover over a page: a tab's (a Peek's and a Mini Window's too) or a pop-up's. */ + hostable(page: WebContents): boolean + info(page: WebContents): CoverInfo +} + +/** + * Orbit's own views over one page's area only (NV-24, NV-13, NV-14): a transparent view of the + * renderer (`view`) placed over the page while `content(page)` gives it something to show, and + * kept there as the page moves (tab switches, splits, resizes, a Peek springing open). It waits + * while the page is off screen and shows again with it. When it appears over the page the user is + * on, it takes the keyboard (unless `mayTakeKeyboard` says no), and keeps it while it shows: the + * page gets it back when it goes. + * + * A main window's views are made when first needed and kept for the next cover; a Peek's, a Mini + * Window's or a pop-up's view is its own and is closed when the cover goes. + */ +export function createCovers( + ctx: MainContext, + opts: { + view: RendererView + content: (page: WebContents) => C | undefined + /** + * Whether a cover that appears over the page the user is on may take the keyboard from + * `focused` (what has it now). By default it always does, as a page's own dialog does. + */ + mayTakeKeyboard?: (page: WebContents, focused: WebContents | null) => boolean + }, +): PageCovers { + const { store, tabs, windows } = ctx + const channel = `orbit:${opts.view}:content` + const covers = new Map() + const pools = new Map>() + const inUse = new Set() + + const hostOf = (page: WebContents): Host | undefined => { + const tabId = tabs.tabIdFor(page) + if (tabId) { + const windowId = windows.windowOfTab(tabId) + if (!windowId) return undefined + if (store.state.tabs[tabId]?.floating) { + const win = BrowserWindow.fromWebContents(page) + return win && !win.isDestroyed() ? { kind: 'floating', win, windowId } : undefined + } + const win = windows.get(windowId) + return win && !win.isDestroyed() ? { kind: 'tab', win, windowId } : undefined + } + // A pop-up window's page: the window's own contents. + if (windows.isUi(page)) return undefined + const win = BrowserWindow.fromWebContents(page) + return win && !win.isDestroyed() && win.webContents === page ? { kind: 'popup', win } : undefined + } + + const hostable = (page: WebContents): boolean => { + if (tabs.tabIdFor(page)) return true + if (windows.isUi(page)) return false + return BrowserWindow.fromWebContents(page)?.webContents === page + } + + const sameHost = (a: Host, b: Host) => a.kind === b.kind && a.win === b.win + + /** Where the cover goes (window coordinates): over the page, while the page is on screen. */ + const placeOf = (c: Cover, host: Host): Rect | undefined => { + if (host.kind === 'popup') { + if (!host.win.isVisible()) return undefined + const [width = 0, height = 0] = host.win.getContentSize() + return { x: 0, y: 0, width, height } + } + const tabId = tabs.tabIdFor(c.page) + if (!tabId) return undefined + if (host.kind === 'tab') return windows.tabViewBounds(tabId) + const view = tabs.get(tabId) + if (!view?.getVisible() || !host.win.contentView.children.includes(view)) return undefined + return view.getBounds() + } + + /** The page is the one the user is on: its window has focus, and nothing covers the page. */ + const pageIsCurrent = (c: Cover, host: Host): boolean => { + if (!host.win.isFocused()) return false + if (host.kind !== 'tab' || !host.windowId) return true + const tabId = tabs.tabIdFor(c.page) + const win = store.state.windows[host.windowId] + return ( + !!tabId && + activeTabOf(store.state, host.windowId) === tabId && + win?.overlay.kind === 'none' && + !windows.hasPageCover(host.windowId) + ) + } + + const hasKeyboard = (view: WebContentsView | undefined) => { + const contents = viewContents(view) + return !!contents && allContents.getFocusedWebContents() === contents + } + + /** Orbit's UI never navigates away or opens windows (as WindowManager's own views). */ + const harden = (contents: WebContents) => { + contents.on('will-navigate', (event, url) => { + const entry = rendererEntry() + if (!(entry.url && url.startsWith(entry.url))) event.preventDefault() + }) + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + } + + const watchLoads = (view: WebContentsView) => { + const contents = viewContents(view) + contents?.on('did-finish-load', () => { + for (const c of covers.values()) { + if (c.view !== view) continue + c.ready = true + c.sent = undefined + update(c.page) + } + }) + contents?.on('render-process-gone', () => { + for (const c of covers.values()) { + if (c.view !== view) continue + c.ready = false + c.onScreen = false + } + }) + } + + /** A main window's free view, made if none is free. */ + const pooledView = (windowId: WindowId): WebContentsView | undefined => { + const pool = pools.get(windowId) ?? new Set() + pools.set(windowId, pool) + for (const view of pool) { + if (!viewContents(view)) pool.delete(view) + else if (!inUse.has(view)) return view + } + const view = windows.createUiView(windowId, opts.view) + if (!view) return undefined + pool.add(view) + watchLoads(view) + windows.get(windowId)?.once('closed', () => { + for (const v of pool) inUse.delete(v) + pools.delete(windowId) + }) + return view + } + + /** A view over a Peek's, a Mini Window's or a pop-up's page: its own, closed after the cover goes. */ + const looseView = (host: Host): WebContentsView => { + const windowId = host.windowId ?? windows.focusedWindowId() ?? windows.ids()[0] ?? '' + const view = new WebContentsView({ webPreferences: uiWebPreferences(windowId, opts.view) }) + view.setBackgroundColor('#00000000') + view.setVisible(false) + const contents = view.webContents + windows.registerUi(contents, windowId) + windows.attachShortcuts(contents) + harden(contents) + // A view that crashed loads again, once: what it showed shows again. + contents.once('render-process-gone', (_event, details) => { + if (details.reason !== 'clean-exit' && !contents.isDestroyed()) loadRenderer(contents, opts.view) + }) + watchLoads(view) + loadRenderer(contents, opts.view) + host.win.once('closed', () => viewContents(view)?.close()) + return view + } + + const ensureView = (c: Cover, host: Host): WebContentsView | undefined => { + if (c.view && viewContents(c.view) && c.host && sameHost(c.host, host)) return c.view + release(c) + c.host = host + c.ready = false + c.sent = undefined + const view = host.kind === 'tab' && host.windowId ? pooledView(host.windowId) : looseView(host) + if (!view) return undefined + c.view = view + inUse.add(view) + // A pooled view that has loaded already is ready now. + const contents = viewContents(view) + if (contents && !contents.isLoading() && contents.getURL() !== '') c.ready = true + return view + } + + /** Takes a page's view off screen; `focusPage` gives the page the keyboard if the view had it. */ + const hide = (c: Cover, focusPage: boolean) => { + const view = c.view + const host = c.host + c.onScreen = false + if (!view || !host || !viewContents(view)) return + const had = hasKeyboard(view) + if (host.kind === 'tab' && host.windowId) windows.hideUiView(host.windowId, view) + else { + if (!host.win.isDestroyed() && host.win.contentView.children.includes(view)) + host.win.contentView.removeChildView(view) + view.setVisible(false) + } + if (had && focusPage && !c.page.isDestroyed()) c.page.focus() + } + + /** Gives a page's view back: a pooled one to its window's pool, a loose one is closed. */ + const release = (c: Cover) => { + const view = c.view + if (!view) return + hide(c, false) + inUse.delete(view) + c.view = undefined + c.host = undefined + c.ready = false + c.sent = undefined + const pooled = [...pools.values()].some((pool) => pool.has(view)) + if (!pooled) viewContents(view)?.close() + } + + /** The cover's gone: the keyboard goes back to the page (KS-16), and a main window keeps the view. */ + const drop = (c: Cover) => { + covers.delete(c.page.id) + hide(c, true) + release(c) + c.off() + } + + const entryFor = (page: WebContents): Cover => { + const existing = covers.get(page.id) + if (existing) return existing + // The page has the keyboard while its cover shows: the cover takes it (KS-19). + const onFocus = () => { + const c = covers.get(page.id) + if (c?.onScreen) viewContents(c.view)?.focus() + } + const gone = () => { + const c = covers.get(page.id) + if (!c) return + covers.delete(page.id) + c.off() + release(c) + } + const onBounds = () => { + if (covers.has(page.id)) update(page) + } + page.on('focus', onFocus) + page.once('destroyed', gone) + // A Peek's or a Mini Window's page moves on its own: the cover follows it. + const tabId = tabs.tabIdFor(page) + const pageView = tabId ? tabs.get(tabId) : undefined + pageView?.on('bounds-changed', onBounds) + const c: Cover = { + page, + ready: false, + onScreen: false, + off: () => { + if (!page.isDestroyed()) { + page.off('focus', onFocus) + page.off('destroyed', gone) + } + pageView?.off('bounds-changed', onBounds) + }, + } + covers.set(page.id, c) + return c + } + + const update = (page: WebContents) => { + const content = page.isDestroyed() ? undefined : opts.content(page) + const existing = covers.get(page.id) + if (!content) { + if (existing) drop(existing) + return + } + const c = existing ?? entryFor(page) + const host = hostOf(page) + const bounds = host ? placeOf(c, host) : undefined + if (!host || !bounds || bounds.width < 1 || bounds.height < 1) { + hide(c, false) + return + } + const view = ensureView(c, host) + if (!view || !c.ready) return + if (c.sent !== content.id) { + c.sent = content.id + viewContents(view)?.send(channel, content) + } + const appearing = !c.onScreen + if (host.kind === 'tab' && host.windowId) windows.showUiView(host.windowId, view, bounds) + else { + // Over the page, which sits above the window's overlay (a Peek) or alone in its window. + const parent = host.win.contentView + if (parent.children[parent.children.length - 1] !== view) parent.addChildView(view) + const at = view.getBounds() + if (at.x !== bounds.x || at.y !== bounds.y || at.width !== bounds.width || at.height !== bounds.height) { + view.setBounds(bounds) + } + if (!view.getVisible()) view.setVisible(true) + } + c.onScreen = true + if (!appearing || !pageIsCurrent(c, host)) return + const focused = allContents.getFocusedWebContents() ?? null + if (opts.mayTakeKeyboard && !opts.mayTakeKeyboard(page, focused)) return + viewContents(view)?.focus() + } + + const updateAll = () => { + for (const c of [...covers.values()]) update(c.page) + } + // Pages move: tab switches, splits, resizes, full screen, DevTools docking, the Library. + windows.onLayout(() => updateAll()) + store.subscribe(() => { + if (covers.size > 0) updateAll() + }) + + return { + update, + shownBy(sender) { + for (const c of covers.values()) { + if (viewContents(c.view) !== sender) continue + const content = c.page.isDestroyed() ? undefined : opts.content(c.page) + if (!content) return undefined + c.sent = content.id + return { page: c.page, content } + } + return undefined + }, + hostable, + info(page) { + const c = covers.get(page.id) + return { + host: page.isDestroyed() ? null : (hostOf(page)?.kind ?? null), + onScreen: !!c?.onScreen, + focused: hasKeyboard(c?.view), + ...(c?.onScreen && c.view ? { bounds: c.view.getBounds() } : {}), + } + }, + } +} diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts index a4eb659e..6d7cbd5c 100644 --- a/src/main/pages/dialogs.ts +++ b/src/main/pages/dialogs.ts @@ -1,30 +1,10 @@ -import { - webContents as allContents, - BrowserWindow, - type IpcMainEvent, - ipcMain, - type WebContents, - WebContentsView, -} from 'electron' +import { type IpcMainEvent, ipcMain, type WebContents } from 'electron' import { type PageDialogAnswer, type PageDialogContent, scriptAnswer, scriptDialog } from '../../shared/pages' import type { Rect } from '../../shared/rpc' import { PAGE_CHANNEL } from '../../shared/site' -import type { TabId, WindowId } from '../../shared/state' +import type { TabId } from '../../shared/state' import type { MainContext } from '../context' -import { activeTabOf, loadRenderer, rendererEntry, uiWebPreferences, viewContents } from '../windows' - -/** - * Where a page's dialogs show: over a tab's page in a main window (a split pane included), over a - * Peek's or a Mini Window's page, or over a pop-up window's page. - */ -type HostKind = 'tab' | 'floating' | 'popup' - -interface Host { - kind: HostKind - win: BrowserWindow - /** The Orbit window: the main window (a tab, a Peek) or the Mini Window. None for a pop-up. */ - windowId?: WindowId -} +import { createCovers, type HostKind } from './covers' interface Dialog { content: PageDialogContent @@ -33,21 +13,6 @@ interface Dialog { script: boolean } -/** One page's dialogs: the first shows, the rest wait their turn. */ -interface PageDialogs { - page: WebContents - queue: Dialog[] - view?: WebContentsView - host?: Host - /** The view's page has loaded: only then does the view go into its window (fix check A, F1). */ - ready: boolean - /** The dialog the view's page was last sent. */ - sent?: number - /** The view is in its window, over the page. */ - onScreen: boolean - off: () => void -} - export interface PageDialogInfo { id: number kind: PageDialogContent['kind'] @@ -65,13 +30,16 @@ export interface PageDialogInfo { waiting: number } +/** Which of a page's dialogs: its own alert, confirm and prompt, "Page unresponsive", or all. */ +export type DialogsWhich = 'script' | 'hang' | 'all' + export interface PageDialogs$ { /** Shows a dialog over a page (once the page is on screen); resolves with the answer, or null if it went away. */ open(page: WebContents, content: Omit, script?: boolean): Promise - /** Closes a page's dialogs (its own alert, confirm and prompt, or all), each as Cancel. */ - dismiss(page: WebContents, which: 'script' | 'all'): void + /** Closes some of a page's dialogs, each as Cancel. */ + dismiss(page: WebContents, which: DialogsWhich): void /** Whether a page has a dialog open or waiting: its own alert, confirm or prompt (the page waits), or any. */ - pending(page: WebContents, which?: 'script' | 'all'): boolean + pending(page: WebContents, which?: DialogsWhich): boolean /** Whether Orbit can show a dialog over a page: a tab's (a Peek's and a Mini Window's too) or a pop-up's. */ hostable(page: WebContents): boolean /** Tests: every page's first dialog. */ @@ -79,288 +47,34 @@ export interface PageDialogs$ { } /** - * Page dialogs (NV-24): a page's alert, confirm and prompt, the leave-page warning and HTTP sign-in, - * in Orbit's own small dialog over that page's area only. The rest of the window stays usable: - * other tabs, the sidebar, the command bar. A dialog waits while its page is off screen (another - * tab is selected, the window is closed to the Library) and shows again when the page does. Each - * shows in a transparent view of its own over the page, which takes the keyboard (Enter, Esc and - * Tab go to the dialog first, KS-19) and gives it back to the page when it closes (KS-16). + * Page dialogs (NV-24): a page's alert, confirm and prompt, the leave-page warning, HTTP sign-in and + * "Page unresponsive", in Orbit's own small dialog over that page's area only. The rest of the + * window stays usable: other tabs, the sidebar, the command bar. A dialog waits while its page is + * off screen (another tab is selected, the window is closed to the Library) and shows again when + * the page does. Each page's dialogs show one at a time in a transparent view over the page + * (`createCovers`), which takes the keyboard (Enter, Esc and Tab go to the dialog first, KS-19) and + * gives it back to the page when the last one closes (KS-16). */ export function registerDialogs(ctx: MainContext): PageDialogs$ { - const { store, tabs, windows, commands } = ctx - const pages = new Map() - /** Each main window's dialog views, made when first needed and kept for the next dialog. */ - const pools = new Map>() - const inUse = new Set() + const { tabs, commands } = ctx + /** Each page's dialogs, by its contents' id: the first shows, the rest wait their turn. */ + const queues = new Map() let nextId = 1 - const hostOf = (page: WebContents): Host | undefined => { - const tabId = tabs.tabIdFor(page) - if (tabId) { - const windowId = windows.windowOfTab(tabId) - if (!windowId) return undefined - if (store.state.tabs[tabId]?.floating) { - const win = BrowserWindow.fromWebContents(page) - return win && !win.isDestroyed() ? { kind: 'floating', win, windowId } : undefined - } - const win = windows.get(windowId) - return win && !win.isDestroyed() ? { kind: 'tab', win, windowId } : undefined - } - // A pop-up window's page: the window's own contents. - if (windows.isUi(page)) return undefined - const win = BrowserWindow.fromWebContents(page) - return win && !win.isDestroyed() && win.webContents === page ? { kind: 'popup', win } : undefined - } - - const sameHost = (a: Host, b: Host) => a.kind === b.kind && a.win === b.win - - /** Where the dialog's view goes (window coordinates): over the page, while the page is on screen. */ - const placeOf = (p: PageDialogs, host: Host): Rect | undefined => { - if (host.kind === 'popup') { - if (!host.win.isVisible()) return undefined - const [width = 0, height = 0] = host.win.getContentSize() - return { x: 0, y: 0, width, height } - } - const tabId = tabs.tabIdFor(p.page) - if (!tabId) return undefined - if (host.kind === 'tab') return windows.tabViewBounds(tabId) - const view = tabs.get(tabId) - if (!view?.getVisible() || !host.win.contentView.children.includes(view)) return undefined - return view.getBounds() - } - - /** The page is the one the user is on: its window has focus, and nothing covers the page. */ - const pageIsCurrent = (p: PageDialogs, host: Host): boolean => { - if (!host.win.isFocused()) return false - if (host.kind !== 'tab' || !host.windowId) return true - const tabId = tabs.tabIdFor(p.page) - const win = store.state.windows[host.windowId] - return ( - !!tabId && - activeTabOf(store.state, host.windowId) === tabId && - win?.overlay.kind === 'none' && - !windows.hasPageCover(host.windowId) - ) - } + const covers = createCovers(ctx, { view: 'dialog', content: (page) => queues.get(page.id)?.list[0]?.content }) - const hasKeyboard = (view: WebContentsView | undefined) => { - const contents = viewContents(view) - return !!contents && allContents.getFocusedWebContents() === contents - } - - /** Orbit's UI never navigates away or opens windows (as WindowManager's own views). */ - const harden = (contents: WebContents) => { - contents.on('will-navigate', (event, url) => { - const entry = rendererEntry() - if (!(entry.url && url.startsWith(entry.url))) event.preventDefault() - }) - contents.setWindowOpenHandler(() => ({ action: 'deny' })) - } - - const watchLoads = (view: WebContentsView) => { - const contents = viewContents(view) - contents?.on('did-finish-load', () => { - for (const p of pages.values()) { - if (p.view !== view) continue - p.ready = true - p.sent = undefined - sync(p) - } - }) - contents?.on('render-process-gone', () => { - for (const p of pages.values()) { - if (p.view !== view) continue - p.ready = false - p.onScreen = false - } - }) - } - - /** A main window's free dialog view, made if none is free. */ - const pooledView = (windowId: WindowId): WebContentsView | undefined => { - const pool = pools.get(windowId) ?? new Set() - pools.set(windowId, pool) - for (const view of pool) { - if (!viewContents(view)) pool.delete(view) - else if (!inUse.has(view)) return view - } - const view = windows.createUiView(windowId, 'dialog') - if (!view) return undefined - pool.add(view) - watchLoads(view) - windows.get(windowId)?.once('closed', () => { - for (const v of pool) inUse.delete(v) - pools.delete(windowId) - }) - return view - } - - /** A view over a Peek's, a Mini Window's or a pop-up's page: its own, closed after its dialogs. */ - const looseView = (host: Host): WebContentsView => { - const windowId = host.windowId ?? windows.focusedWindowId() ?? windows.ids()[0] ?? '' - const view = new WebContentsView({ webPreferences: uiWebPreferences(windowId, 'dialog') }) - view.setBackgroundColor('#00000000') - view.setVisible(false) - const contents = view.webContents - windows.registerUi(contents, windowId) - windows.attachShortcuts(contents) - harden(contents) - // A view that crashed loads again, once: its dialog shows again. - contents.once('render-process-gone', (_event, details) => { - if (details.reason !== 'clean-exit' && !contents.isDestroyed()) loadRenderer(contents, 'dialog') - }) - watchLoads(view) - loadRenderer(contents, 'dialog') - host.win.once('closed', () => viewContents(view)?.close()) - return view - } + const matches = (dialog: Dialog, which: DialogsWhich) => + which === 'all' || (which === 'script' ? dialog.script : dialog.content.kind === 'hang') - const ensureView = (p: PageDialogs, host: Host): WebContentsView | undefined => { - if (p.view && viewContents(p.view) && p.host && sameHost(p.host, host)) return p.view - release(p) - p.host = host - p.ready = false - p.sent = undefined - const view = host.kind === 'tab' && host.windowId ? pooledView(host.windowId) : looseView(host) - if (!view) return undefined - p.view = view - inUse.add(view) - // A pooled view that has loaded already is ready now. - const contents = viewContents(view) - if (contents && !contents.isLoading() && contents.getURL() !== '') p.ready = true - return view - } - - /** Takes a page's view off screen; `focusPage` gives the page the keyboard if the view had it. */ - const hide = (p: PageDialogs, focusPage: boolean) => { - const view = p.view - const host = p.host - p.onScreen = false - if (!view || !host || !viewContents(view)) return - const had = hasKeyboard(view) - if (host.kind === 'tab' && host.windowId) windows.hideUiView(host.windowId, view) - else { - if (!host.win.isDestroyed() && host.win.contentView.children.includes(view)) - host.win.contentView.removeChildView(view) - view.setVisible(false) - } - if (had && focusPage && !p.page.isDestroyed()) p.page.focus() - } - - /** Gives a page's view back: a pooled one to its window's pool, a loose one is closed. */ - const release = (p: PageDialogs) => { - const view = p.view - if (!view) return - hide(p, false) - inUse.delete(view) - p.view = undefined - p.host = undefined - p.ready = false - p.sent = undefined - const pooled = [...pools.values()].some((pool) => pool.has(view)) - if (!pooled) viewContents(view)?.close() - } - - const send = (p: PageDialogs, content: PageDialogContent) => { - if (p.sent === content.id) return - p.sent = content.id - viewContents(p.view)?.send('orbit:dialog:content', content) - } - - /** Brings a page's dialog view in line with its first dialog and where its page is. */ - const sync = (p: PageDialogs) => { - const first = p.queue[0] - if (!first || p.page.isDestroyed()) { - hide(p, true) - return - } - const host = hostOf(p.page) - const bounds = host ? placeOf(p, host) : undefined - if (!host || !bounds || bounds.width < 1 || bounds.height < 1) { - hide(p, false) - return - } - const view = ensureView(p, host) - if (!view || !p.ready) return - send(p, first.content) - const appearing = !p.onScreen - if (host.kind === 'tab' && host.windowId) windows.showUiView(host.windowId, view, bounds) - else { - // Over the page, which sits above the window's overlay (a Peek) or alone in its window. - const parent = host.win.contentView - if (parent.children[parent.children.length - 1] !== view) parent.addChildView(view) - const at = view.getBounds() - if (at.x !== bounds.x || at.y !== bounds.y || at.width !== bounds.width || at.height !== bounds.height) { - view.setBounds(bounds) - } - if (!view.getVisible()) view.setVisible(true) - } - p.onScreen = true - if (appearing && pageIsCurrent(p, host)) viewContents(view)?.focus() - } - - const syncAll = () => { - for (const p of pages.values()) sync(p) - } - - const entryFor = (page: WebContents): PageDialogs => { - const existing = pages.get(page.id) - if (existing) return existing - // The page has the keyboard while its dialog shows: the dialog takes it (KS-19). - const onFocus = () => { - const p = pages.get(page.id) - if (p?.queue.length && p.onScreen) viewContents(p.view)?.focus() - } - const gone = () => { - const p = pages.get(page.id) - if (!p) return - pages.delete(page.id) - p.off() - for (const dialog of p.queue.splice(0)) dialog.resolve(null) - release(p) - } - const onBounds = () => { - const p = pages.get(page.id) - if (p?.queue.length) sync(p) - } - page.on('focus', onFocus) - page.once('destroyed', gone) - // A Peek's or a Mini Window's page moves on its own: the dialog follows it. - const tabId = tabs.tabIdFor(page) - const pageView = tabId ? tabs.get(tabId) : undefined - pageView?.on('bounds-changed', onBounds) - const p: PageDialogs = { - page, - queue: [], - ready: false, - onScreen: false, - off: () => { - if (!page.isDestroyed()) { - page.off('focus', onFocus) - page.off('destroyed', gone) - } - pageView?.off('bounds-changed', onBounds) - }, - } - pages.set(page.id, p) - return p - } - - const settle = (p: PageDialogs, dialog: Dialog, answer: PageDialogAnswer | null) => { - const at = p.queue.indexOf(dialog) - if (at < 0) return - p.queue.splice(at, 1) - const wasFirst = at === 0 + const settle = (page: WebContents, dialog: Dialog, answer: PageDialogAnswer | null) => { + const queue = queues.get(page.id)?.list + const at = queue?.indexOf(dialog) ?? -1 + if (!queue || at < 0) return + queue.splice(at, 1) + if (queue.length === 0) queues.delete(page.id) dialog.resolve(answer) - if (p.queue.length === 0) { - // The keyboard goes back to the page (KS-16); a main window keeps the view for its next dialog. - hide(p, true) - release(p) - p.off() - pages.delete(p.page.id) - return - } - if (wasFirst) sync(p) + // The next dialog shows, or the view goes and the page gets the keyboard back (KS-16). + if (at === 0) covers.update(page) } const service: PageDialogs$ = { @@ -370,38 +84,47 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { resolve(null) return } - const p = entryFor(page) - const dialog: Dialog = { content: { ...content, id: nextId++ }, resolve, script } - p.queue.push(dialog) - if (p.queue.length === 1) sync(p) + let queue = queues.get(page.id) + if (!queue) { + const made = { page, list: [] as Dialog[] } + queue = made + queues.set(page.id, made) + // A page that goes takes its dialogs with it. + page.once('destroyed', () => { + if (queues.get(page.id) !== made) return + queues.delete(page.id) + for (const dialog of made.list.splice(0)) dialog.resolve(null) + }) + } + queue.list.push({ content: { ...content, id: nextId++ }, resolve, script }) + if (queue.list.length === 1) covers.update(page) }) }, dismiss(page, which) { - const p = pages.get(page.id) - if (!p) return - for (const dialog of [...p.queue]) if (which === 'all' || dialog.script) settle(p, dialog, null) + for (const dialog of [...(queues.get(page.id)?.list ?? [])]) + if (matches(dialog, which)) settle(page, dialog, null) }, pending(page, which = 'all') { - const queue = pages.get(page.id)?.queue ?? [] - return which === 'all' ? queue.length > 0 : queue.some((dialog) => dialog.script) + return (queues.get(page.id)?.list ?? []).some((dialog) => matches(dialog, which)) }, - hostable: (page) => hostable(page), + hostable: (page) => covers.hostable(page), info() { - return [...pages.values()].flatMap((p) => { - const first = p.queue[0] - if (!first) return [] + return [...queues.values()].flatMap(({ page, list: queue }) => { + const first = queue[0] + if (!first || page.isDestroyed()) return [] + const shown = covers.info(page) return [ { id: first.content.id, kind: first.content.kind, title: first.content.title, message: first.content.message, - tabId: tabs.tabIdFor(p.page) ?? null, - host: hostOf(p.page)?.kind ?? null, - onScreen: p.onScreen, - focused: hasKeyboard(p.view), - ...(p.onScreen && p.view ? { bounds: p.view.getBounds() } : {}), - waiting: p.queue.length - 1, + tabId: tabs.tabIdFor(page) ?? null, + host: shown.host, + onScreen: shown.onScreen, + focused: shown.focused, + ...(shown.bounds ? { bounds: shown.bounds } : {}), + waiting: queue.length - 1, }, ] }) @@ -409,34 +132,21 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { } commands.register('pages.dialogContent', (_args, c) => { - for (const p of pages.values()) { - if (!c.sender || viewContents(p.view) !== c.sender) continue - const first = p.queue[0] - if (first) p.sent = first.content.id - return first?.content ?? null - } - return null + if (!c.sender) return null + return covers.shownBy(c.sender)?.content ?? null }) commands.register('pages.dialogAnswer', (answer, c) => { - for (const p of pages.values()) { - const first = p.queue[0] - if (!first || first.content.id !== answer.id || !c.sender || viewContents(p.view) !== c.sender) continue - settle(p, first, { - id: answer.id, - ok: answer.ok === true, - ...(typeof answer.value === 'string' ? { value: answer.value } : {}), - ...(typeof answer.user === 'string' ? { user: answer.user } : {}), - ...(typeof answer.password === 'string' ? { password: answer.password } : {}), - }) - return - } - }) - - // Pages move: tab switches, splits, resizes, full screen, DevTools docking, the Library. - windows.onLayout(() => syncAll()) - store.subscribe(() => { - if (pages.size > 0) syncAll() + const page = c.sender ? covers.shownBy(c.sender)?.page : undefined + const first = page ? queues.get(page.id)?.list[0] : undefined + if (!page || !first || first.content.id !== answer.id) return + settle(page, first, { + id: answer.id, + ok: answer.ok === true, + ...(typeof answer.value === 'string' ? { value: answer.value } : {}), + ...(typeof answer.user === 'string' ? { user: answer.user } : {}), + ...(typeof answer.password === 'string' ? { password: answer.password } : {}), + }) }) // A page's alert, confirm or prompt (the page preload, NV-24): the page waits for the answer. @@ -456,7 +166,7 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { } // Only a page's main frame has the preload (D40), and only a tab's or a pop-up's page has a // place for the dialog. - if (!frame || frame.parent || page.isDestroyed() || !hostable(page)) { + if (!frame || frame.parent || page.isDestroyed() || !covers.hostable(page)) { reply(scriptAnswer(kind, null)) return } @@ -471,12 +181,5 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { } }) - /** A page whose dialogs Orbit can place: a tab's (Peek and Mini Window pages are tabs) or a pop-up's. */ - const hostable = (page: WebContents): boolean => { - if (tabs.tabIdFor(page)) return true - if (windows.isUi(page)) return false - return BrowserWindow.fromWebContents(page)?.webContents === page - } - return service } diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts index 2c256007..bbf724f6 100644 --- a/src/main/pages/index.ts +++ b/src/main/pages/index.ts @@ -5,6 +5,7 @@ import type { MainContext } from '../context' import type { TabOps } from '../tabs/ops' import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialogs' import { type LeaveInfo, registerLeave } from './leave' +import { LOAD_TIMEOUT_MS, type PageScreenInfo, registerScreens } from './screens' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitPages. */ export interface PagesTestHooks { @@ -14,16 +15,23 @@ export interface PagesTestHooks { leave: (tabId: TabId) => LeaveInfo | null /** Whether auto-archive and sleep may let a tab's page go (NV-24). */ mayDiscard: (tabId: TabId) => boolean + /** Every page's error or crash screen (NV-13, NV-14). */ + screens: () => PageScreenInfo[] } /** * The pages lane: what a page does beyond loading. Page dialogs (NV-24): a page's alert, confirm and - * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. + * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. Error and + * crash screens (NV-13, NV-14): what the page's area shows when its page couldn't load, crashed or + * stopped responding. */ export function registerPages(ctx: MainContext, ops: TabOps): void { const dialogs = registerDialogs(ctx) const leave = registerLeave(ctx, ops, dialogs) registerSignIn(dialogs) + // Tests give a navigation's first answer less time (ORBIT_E2E_LOAD_TIMEOUT_MS). + const testTimeout = ctx.e2e ? Number(process.env.ORBIT_E2E_LOAD_TIMEOUT_MS) : Number.NaN + const screens = registerScreens(ctx, ops, dialogs, testTimeout > 0 ? testTimeout : LOAD_TIMEOUT_MS) if (ctx.e2e) { const hooks: PagesTestHooks = { @@ -33,6 +41,7 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { return page ? leave.info(page) : null }, mayDiscard: (tabId) => leave.mayDiscard(tabId), + screens: () => screens.info(), } ;(globalThis as unknown as { __orbitPages: PagesTestHooks }).__orbitPages = hooks } diff --git a/src/main/pages/screens.ts b/src/main/pages/screens.ts new file mode 100644 index 00000000..d016cea7 --- /dev/null +++ b/src/main/pages/screens.ts @@ -0,0 +1,509 @@ +import { + webContents as allContents, + app, + BrowserWindow, + type IpcMainEvent, + ipcMain, + type Session, + type WebContents, +} from 'electron' +import { + crashScreen, + errorScreen, + hangDialog, + type PageScreenAction, + type PageScreenContent, + screenSite, + timeoutScreen, +} from '../../shared/pages' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' +import { type CoverInfo, createCovers } from './covers' +import type { PageDialogs$ } from './dialogs' + +/** + * A main-frame navigation that gets no answer from its server within this time is stopped and gets + * the timeout screen. Chromium has no such limit (it waits for a server that never answers until + * the user stops it); Orbit's is generous, so a slow server still gets its time. + */ +export const LOAD_TIMEOUT_MS = 120_000 + +/** Chromium's net::ERR_ABORTED: a navigation stopped or replaced, or turned into a download. */ +const ERR_ABORTED = -3 + +/** What a page's row shows: its title and icon. */ +interface RowLook { + title: string + favicon?: string +} + +interface Screen { + content: PageScreenContent + /** The address it's for: what Try again loads. */ + url: string + /** + * Orbit's own time limit stopped the navigation: it never committed, so the page underneath is + * the one before it, and Try again (or Reload) loads the address again rather than reloading. + */ + timedOut: boolean + /** The page had the keyboard when this happened (or nothing had): the screen takes it. */ + hadKeyboard: boolean + /** What the page's row shows while the screen is up; none for a crash (the row keeps what it had). */ + row?: RowLook + /** A timeout's: the screen the page showed when it timed out, which Back shows again. */ + under?: Screen +} + +/** What Orbit knows of one page for its screens. */ +interface PageState { + screen?: Screen + /** The address of the page's last document that loaded (not an error page). */ + committed?: string + /** + * The row when the page's last navigation started with no screen up: what an error on the site + * it showed keeps, and what Back from a timeout puts back. + */ + row?: RowLook + /** The navigation being timed: where it goes, and the timer. */ + timing?: { url: string; timer: ReturnType } + /** The page said it sends a form to this address (`PAGE_CHANNEL.formSent`): its navigation is next. */ + formTo?: string + /** The navigation under way sends a form, so it isn't timed: it may be uploading. */ + sending?: boolean + /** The address the network's return already retried once, until a page loads. */ + autoRetried?: string + /** The last certificate error the page met: Proceed lets that certificate through for its host. */ + cert?: { host: string; fingerprint: string } + /** "Page unresponsive" is open over the page. */ + hangOpen?: boolean + /** The user chose Close page there: the crash screen says so. */ + closedHung?: boolean +} + +export interface PageScreenInfo extends CoverInfo { + id: number + kind: PageScreenContent['kind'] + title: string + message: string + site: string + code?: string + /** The address Try again loads. */ + url: string + timedOut: boolean + tabId: TabId | null +} + +export interface PageScreens { + /** Tests: every page's screen. */ + info(): PageScreenInfo[] +} + +/** + * What a page's area shows when its page couldn't load (NV-13) or crashed (NV-14), in a view of + * Orbit's over that page only (`createCovers`), as a page dialog is. The page stays where it was: + * an error page keeps the address that failed (the engine's own error document is under the + * screen, so Reload and Try again load it again, and Back leaves it), and a crashed page keeps its + * tab and its row until it is reloaded. + * + * - **Load errors.** A main-frame navigation that fails shows a title, one plain sentence, the site + * and Try again (Chromium's wording, Orbit's look). The screen stays until a page loads, so a + * retry that fails again doesn't flash. The row keeps its title and icon when the site that + * failed is the one it showed; another site's failure shows that site's name instead (and its + * icon, if Orbit knows one). + * - **Time limit.** A navigation with no answer within LOAD_TIMEOUT_MS is stopped and shows "took + * too long to respond"; the address stays, and Reload or Try again loads it again. Back shows + * the page as it was before, screen and row and all. A navigation that sends a form isn't timed: + * it may be a large upload (the page preload says so, `PAGE_CHANNEL.formSent`). + * - **Certificates.** "Your connection is not private": Back to safety leads (goes back, or closes + * a tab with nowhere to go back to); Advanced, then Proceed, lets that certificate through for + * that host until Orbit quits (per profile). + * - **Offline.** When the network comes back while an error screen shows, the page loads again, + * once (the screen's view hears the engine's `online` event). + * - **Crashes.** "This page crashed" with Reload; ⌘R and ⌘⇧R reload as well. + * - **Hangs.** A page that stops responding (`unresponsive`) gets "Page unresponsive" over it: + * Wait, or Close page, which ends the page's process (its crash screen says so). It goes by + * itself when the page responds again. + */ +export function registerScreens( + ctx: MainContext, + ops: TabOps, + dialogs: PageDialogs$, + loadTimeout = LOAD_TIMEOUT_MS, +): PageScreens { + const { store, tabs, commands } = ctx + const states = new Map() + let nextId = 1 + + /** Whether nothing, or the page itself, has the keyboard. */ + const pageHasKeyboard = (page: WebContents, focused = allContents.getFocusedWebContents() ?? null) => + !focused || focused.isDestroyed() || focused === page + + const covers = createCovers(ctx, { + view: 'screen', + content: (page) => states.get(page.id)?.state.screen?.content, + // A screen takes the keyboard only where the page would have it: not from the sidebar's field. + mayTakeKeyboard: (page, focused) => + pageHasKeyboard(page, focused) || states.get(page.id)?.state.screen?.hadKeyboard === true, + }) + + const stateOf = (page: WebContents): PageState => { + let entry = states.get(page.id) + if (!entry) { + entry = { page, state: {} } + states.set(page.id, entry) + } + return entry.state + } + + type Tab = NonNullable<(typeof store.state.tabs)[TabId]> + const tabOf = (page: WebContents): Tab | undefined => { + const tabId = tabs.tabIdFor(page) + return tabId ? store.state.tabs[tabId] : undefined + } + + const setTab = (page: WebContents, change: (tab: Tab) => void) => { + const tabId = tabs.tabIdFor(page) + if (!tabId || !store.state.tabs[tabId]) return + store.update((d) => { + const tab = d.tabs[tabId] + if (tab) change(tab) + }) + } + + const rowOf = (page: WebContents): RowLook | undefined => { + const tab = tabOf(page) + return tab && { title: tab.title, ...(tab.favicon ? { favicon: tab.favicon } : {}) } + } + + /** Puts a title and icon on the page's row (if it has one). */ + const showRow = (page: WebContents, row: RowLook) => { + const tab = tabOf(page) + if (!tab || (tab.title === row.title && tab.favicon === row.favicon)) return + setTab(page, (t) => { + t.title = row.title + if (row.favicon) t.favicon = row.favicon + else delete t.favicon + }) + } + + // ---- Showing and clearing ---- + + const show = (page: WebContents, content: Omit, url: string, timedOut: boolean) => { + const st = stateOf(page) + const before = st.screen + const screen: Screen = { content: { ...content, id: nextId++ }, url, timedOut, hadKeyboard: pageHasKeyboard(page) } + // A timeout leaves the page as it was, screen and all: Back shows that again. + if (timedOut && before) screen.under = before.timedOut ? before.under : before + // The row keeps the title and icon of the site it showed. Another site's failure is named by + // its site (with its icon, if Orbit knows one), as the engine names its own error page by its + // address. + if (content.kind !== 'crash' && content.site) { + const sameSite = screenSite(st.committed) === content.site + const icon = tabs.faviconFor(url) + screen.row = sameSite && st.row ? st.row : { title: content.site, ...(icon ? { favicon: icon } : {}) } + showRow(page, screen.row) + } + st.screen = screen + covers.update(page) + } + + const clear = (page: WebContents) => { + const st = states.get(page.id)?.state + if (!st?.screen) return + st.screen = undefined + covers.update(page) + } + + // ---- Orbit's time limit ---- + + const stopTiming = (st: PageState) => { + if (st.timing) clearTimeout(st.timing.timer) + st.timing = undefined + } + + const timedOut = (page: WebContents, url: string) => { + const st = states.get(page.id)?.state + if (!st || st.timing?.url !== url || page.isDestroyed() || !page.isLoading()) return + st.timing = undefined + // The screen first: stopping updates the tab's Back button, which the screen keeps on (Back + // shows the page underneath). + show(page, timeoutScreen(url), url, true) + // The address stays where it was going (as when the user stops a load), so it can be tried again. + page.once('did-stop-loading', () => { + if (page.isDestroyed() || !stateOf(page).screen?.timedOut) return + setTab(page, (tab) => { + tab.pendingUrl = url + }) + }) + page.stop() + } + + const startTiming = (page: WebContents, url: string) => { + const st = stateOf(page) + stopTiming(st) + if (st.sending || !/^https?:/i.test(url)) return + st.timing = { url, timer: setTimeout(() => timedOut(page, url), loadTimeout) } + } + + // ---- What the screen's buttons do ---- + + /** Loads the screen's address again: a crashed or failed page reloads, a timed-out one loads its address. */ + const retry = (page: WebContents) => { + const screen = states.get(page.id)?.state.screen + if (!screen || page.isDestroyed()) return + if (screen.timedOut) void page.loadURL(screen.url).catch(() => {}) + else page.reload() + } + + /** Back to safety: back where the user came from; a tab with nowhere to go back to closes. */ + const back = (page: WebContents) => { + if (page.navigationHistory.canGoBack()) { + page.navigationHistory.goBack() + return + } + const tabId = tabs.tabIdFor(page) + if (tabId && !store.state.tabs[tabId]?.floating) { + ops.closeTabs([tabId], 'closed') + return + } + const win = BrowserWindow.fromWebContents(page) + if (!tabId && win?.webContents === page) win.close() + else void page.loadURL('about:blank').catch(() => {}) + } + + /** Certificates the user chose to go on with, by profile session: `|`. */ + const allowed = new WeakMap>() + + const proceed = (page: WebContents) => { + const st = states.get(page.id)?.state + const screen = st?.screen + if (!st || screen?.content.kind !== 'cert' || !screen.content.advanced?.proceedLabel || !st.cert) return + if (st.cert.host !== hostOf(screen.url)) return + const set = allowed.get(page.session) ?? new Set() + allowed.set(page.session, set) + set.add(`${st.cert.host}|${st.cert.fingerprint}`) + page.reload() + } + + const act = (page: WebContents, action: PageScreenAction) => { + const st = states.get(page.id)?.state + const screen = st?.screen + if (!st || !screen) return + if (action === 'proceed') proceed(page) + else if (action === 'back') back(page) + else if (action === 'online') { + // The network came back: an error page loads again, once (until a page loads). + if (screen.content.kind === 'crash' || screen.content.kind === 'cert') return + if (st.autoRetried === screen.url || page.isLoading()) return + st.autoRetried = screen.url + retry(page) + } else retry(page) + } + + // ---- Hangs ---- + + const askHang = (page: WebContents) => { + const st = stateOf(page) + // A page waiting on its own dialog isn't hung: it waits for the user. + if (st.hangOpen || dialogs.pending(page, 'script') || !covers.hostable(page)) return + st.hangOpen = true + void dialogs.open(page, hangDialog(page.getURL())).then((answer) => { + st.hangOpen = false + if (!answer || answer.ok || page.isDestroyed()) return + st.closedHung = true + page.forcefullyCrashRenderer() + }) + } + + // ---- Watching pages ---- + + /** + * A timed-out page's Reload loads the address it was going to, and Back shows the page it was on + * (the navigation never committed, so the page underneath is that page, with the screen it had if + * it had one): Back is on while the screen shows, as it would be with an error page. (Wraps the + * page's own methods, as leave.ts does.) + */ + const wrapNavigation = (page: WebContents) => { + const wrap = (target: object, name: string, instead: () => unknown) => { + const original = (target as Record)[name] + if (typeof original !== 'function') return + const replacement = function (this: unknown, ...args: unknown[]) { + if (!page.isDestroyed() && states.get(page.id)?.state.screen?.timedOut) return instead() + return Reflect.apply(original, this, args) + } + try { + Object.defineProperty(target, name, { value: replacement, configurable: true, writable: true }) + } catch { + // Not replaceable: Reload reloads the page underneath. + } + } + const history = page.navigationHistory + wrap(page, 'reload', () => retry(page)) + wrap(page, 'reloadIgnoringCache', () => retry(page)) + wrap(history, 'canGoBack', () => true) + wrap(history, 'goBack', () => { + const st = stateOf(page) + stopTiming(st) + // A retry still on its way stops: the page underneath is where Back leads. + if (page.isLoading()) page.stop() + // That page, with the screen it had if it had one, and its row as it was. + const under = st.screen?.under + const row = under ? under.row : st.row + st.screen = under + covers.update(page) + if (row) showRow(page, row) + setTab(page, (tab) => { + delete tab.pendingUrl + tab.canGoBack = history.canGoBack() + tab.canGoForward = history.canGoForward() + }) + }) + } + + const watched = new WeakSet() + const watch = (page: WebContents, tab: boolean) => { + if (watched.has(page)) return + watched.add(page) + page.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument || !covers.hostable(page)) return + const st = stateOf(page) + st.sending = st.formTo === details.url + st.formTo = undefined + if (!st.screen) st.row = rowOf(page) + startTiming(page, details.url) + }) + // The server answered: where it sends the page is timed afresh (a form's redirect isn't). + page.on('did-redirect-navigation', (details) => { + if (details.isMainFrame && states.has(page.id)) startTiming(page, details.url) + }) + page.on('did-navigate', (_event, url) => { + if (!covers.hostable(page)) return + const st = stateOf(page) + stopTiming(st) + st.sending = false + st.committed = url + st.autoRetried = undefined + st.closedHung = false + clear(page) + }) + page.on('did-fail-provisional-load', (_event, code, description, url, isMainFrame) => { + if (!isMainFrame || !covers.hostable(page)) return + const st = stateOf(page) + stopTiming(st) + st.sending = false + if (code === ERR_ABORTED) return + show(page, errorScreen(code, description, url), url, false) + }) + page.on('did-stop-loading', () => { + const st = states.get(page.id)?.state + if (!st) return + stopTiming(st) + st.sending = false + }) + // The engine names an error page by nothing, and a page under a timeout may still rename + // itself: while a screen is up, its row shows what the screen chose. (After the tab runtime's + // own listener, which adds the title to the store.) + page.on('page-title-updated', () => { + const row = states.get(page.id)?.state.screen?.row + if (row) showRow(page, row) + }) + page.on('render-process-gone', (_event, details) => { + if (!covers.hostable(page)) return + const st = stateOf(page) + stopTiming(st) + if (details.reason === 'clean-exit') return + dialogs.dismiss(page, 'all') + const url = page.getURL() + show(page, crashScreen(st.closedHung ? 'hung' : details.reason, url), url, false) + }) + page.on('unresponsive', () => askHang(page)) + page.on('responsive', () => dialogs.dismiss(page, 'hang')) + page.once('destroyed', () => { + const st = states.get(page.id)?.state + if (st) stopTiming(st) + states.delete(page.id) + }) + if (tab) wrapNavigation(page) + } + + // The page is about to send a form: its navigation isn't timed (it may be a large upload). + ipcMain.on(PAGE_CHANNEL.formSent, (event: IpcMainEvent, url: unknown) => { + if (typeof url !== 'string') return + const page = event.sender + try { + const frame = event.senderFrame + if (!frame || page.isDestroyed() || frame.frameTreeNodeId !== page.mainFrame.frameTreeNodeId) return + } catch { + // A frame or page that went away while the message was on its way. + return + } + if (!watched.has(page) || !covers.hostable(page)) return + const st = stateOf(page) + // Its navigation started first: it isn't timed from now on. + if (st.timing?.url === url) { + stopTiming(st) + st.sending = true + } else st.formTo = url + }) + + ctx.hooks.tabCreated.push((_tabId, page) => watch(page, true)) + // A pop-up's page is its window's own. + app.on('browser-window-created', (_event, win) => watch(win.webContents, false)) + + // A certificate the user chose to go on with goes through for its host; any other is refused, + // and the page's error screen offers the choice. + app.on('certificate-error', (event, page, url, _error, certificate, callback, isMainFrame) => { + const host = hostOf(url) + if (isMainFrame && covers.hostable(page)) stateOf(page).cert = { host, fingerprint: certificate.fingerprint } + if (allowed.get(page.session)?.has(`${host}|${certificate.fingerprint}`)) { + event.preventDefault() + callback(true) + } + }) + + commands.register('pages.screenContent', (_args, c) => { + if (!c.sender) return null + return covers.shownBy(c.sender)?.content ?? null + }) + + commands.register('pages.screenAction', (args, c) => { + const shown = c.sender ? covers.shownBy(c.sender) : undefined + if (!shown || shown.content.id !== args.id) return + act(shown.page, args.action) + }) + + return { + info() { + return [...states.values()].flatMap(({ page, state }) => { + const screen = state.screen + if (!screen || page.isDestroyed()) return [] + const { content } = screen + return [ + { + id: content.id, + kind: content.kind, + title: content.title, + message: content.message, + site: content.site, + ...(content.code ? { code: content.code } : {}), + url: screen.url, + timedOut: screen.timedOut, + tabId: tabs.tabIdFor(page) ?? null, + ...covers.info(page), + }, + ] + }) + }, + } +} + +/** A URL's host with its port, or "". */ +function hostOf(url: string): string { + try { + return new URL(url).host + } catch { + return '' + } +} diff --git a/src/main/windows.ts b/src/main/windows.ts index 9c77dab1..55a321ad 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -686,8 +686,8 @@ export class WindowManager { view.setBorderRadius(corner) rec.corners.set(pane.tabId, corner) view.setVisible(true) - // A tab whose page crashed while in the background reloads when it is shown again. - if (!rec.visible.has(pane.tabId) && view.webContents.isCrashed()) view.webContents.reload() + // A tab whose page crashed while in the background shows its crash screen when it is shown + // again, until the user reloads it (NV-14: the pages lane's screen over the page). } rec.visible = wanted if (panes.length === 1) rec.lastSingleRect = panes[0]?.rect diff --git a/src/preload/page.ts b/src/preload/page.ts index eca7b96e..e25d1e9f 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -226,6 +226,9 @@ interface StoppedNavigation { * would tell for sure, but would cost every page: see `going` in `patchPage`.) * - `redo()`: starts that navigation again (the user chose Leave; main lets the next warning * through): a link or `location` loads its URL, a form is submitted again, a reload reloads. + * + * The same `navigate` event tells main when the page sends a form (`PAGE_CHANNEL.formSent`): it may + * be uploading, so Orbit's time limit for a server's answer leaves that navigation alone (NV-13). */ function exposeLeave(): void { const nav = (window as { navigation?: EventTarget }).navigation @@ -241,6 +244,7 @@ function exposeLeave(): void { sourceElement?: Element | null } if (!e.destination?.url || e.destination.sameDocument) return + if (e.formData) ipcRenderer.send(PAGE_CHANNEL.formSent, e.destination.url) const source = e.sourceElement ?? null const form = e.formData ? source instanceof HTMLFormElement diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 8cf3d714..789fedf4 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -41,6 +41,21 @@ async function boot(): Promise { ) return } + // What a page's area shows when its page couldn't load or crashed (pages lane, NV-13, NV-14). + if (view === 'screen') { + const app = import('./pages/ScreenApp') + await initStore() + installShortcutFallback() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { ScreenApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } // An internal page (DT-13): Orbit's UI in a normal tab. Keys go to it as to any page. if (view === 'internal') { const app = import('./internal/InternalApp') diff --git a/src/renderer/src/pages/DialogApp.tsx b/src/renderer/src/pages/DialogApp.tsx index b983d5ca..cb04e0cd 100644 --- a/src/renderer/src/pages/DialogApp.tsx +++ b/src/renderer/src/pages/DialogApp.tsx @@ -16,9 +16,10 @@ import './dialog.css' /** * A page's dialog (NV-24), in a transparent view main keeps over that page only: the page's alert, - * confirm or prompt, "Leave site?", or HTTP sign-in. It takes the keyboard while it shows (KS-19): - * Enter answers with the accepting button, Esc cancels, and Tab stays inside the card. Main gives - * the keyboard back to the page when it goes (KS-16). + * confirm or prompt, "Leave site?", HTTP sign-in, or "Page unresponsive" (NV-14). It takes the + * keyboard while it shows (KS-19): Enter answers with the accepting button, Esc cancels (or gives + * the safe answer: an alert's OK, Wait), and Tab stays inside the card. Main gives the keyboard back + * to the page when it goes (KS-16). */ export function DialogApp() { const win = useWindow() @@ -118,8 +119,8 @@ function DialogCard({ if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() - // An alert has one button: Esc closes it as OK does. - if (content.noCancel) accept() + // An alert has one button: Esc closes it as OK does; "Page unresponsive" waits. + if (content.noCancel || content.escapeAccepts) accept() else cancel() return } @@ -142,7 +143,11 @@ function DialogCard({ } const alert = - content.kind === 'alert' || content.kind === 'confirm' || content.kind === 'leave' || content.kind === 'reload' + content.kind === 'alert' || + content.kind === 'confirm' || + content.kind === 'leave' || + content.kind === 'reload' || + content.kind === 'hang' return (
{!content.noCancel && ( )} + )} + + + {content.advanced && advanced && ( +
+

{content.advanced.text}

+ {content.advanced.proceedLabel && ( + + )} +
+ )} + + ) +} diff --git a/src/renderer/src/pages/screen.css b/src/renderer/src/pages/screen.css new file mode 100644 index 00000000..ff929f4c --- /dev/null +++ b/src/renderer/src/pages/screen.css @@ -0,0 +1,152 @@ +/* + * What a page's area shows when its page couldn't load or crashed (NV-13, NV-14): Orbit's page in + * place of the page, over that page's area only. Quiet: an icon, a title, one sentence, the site + * and the way on. + */ +.page-screen-view { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: flex-start; + height: 100%; + padding: max(48px, 14vh) 24px 24px; + overflow: auto; + background: var(--surface, #fff); + font: 14px / 1.5 var(--font, system-ui, sans-serif); + color: var(--text, #1c1c1f); + cursor: default; +} + +.page-screen { + --screen-fill: color-mix(in srgb, var(--text, #1c1c1f) 7%, transparent); + --screen-fill-hover: color-mix(in srgb, var(--text, #1c1c1f) 12%, transparent); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + width: min(520px, 100%); + animation: page-screen-in 180ms var(--ease-out, ease-out); +} + +@keyframes page-screen-in { + from { + opacity: 0; + transform: translateY(4px); + } +} + +@media (prefers-reduced-motion: reduce) { + .page-screen { + animation: none; + } +} + +.page-screen__icon { + margin-bottom: 10px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); +} + +.page-screen.is-cert .page-screen__icon { + color: #c4372c; +} + +.page-screen__title { + margin: 0; + font-size: 22px; + font-weight: 650; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.page-screen__message { + margin: 0; + overflow-wrap: anywhere; + user-select: text; +} + +.page-screen__site { + margin: 0; + font-weight: 600; + overflow-wrap: anywhere; + user-select: text; +} + +.page-screen__code { + margin: 0; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font: + 11px / 1.4 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.02em; + user-select: text; +} + +.page-screen__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.page-screen__button { + min-height: 32px; + padding: 6px 16px; + border: 0; + border-radius: 8px; + background: var(--screen-fill); + color: inherit; + font: inherit; + font-size: 13px; + font-weight: 600; + transition: background var(--dur-fast, 120ms) ease; +} + +.page-screen__button:hover { + background: var(--screen-fill-hover); +} + +.page-screen__button.is-primary { + background: var(--accent, #5865d8); + color: #fff; +} + +.page-screen__button.is-primary:hover { + background: color-mix(in srgb, var(--accent, #5865d8) 88%, #000); +} + +.page-screen__button:focus-visible, +.page-screen__link:focus-visible { + outline: 2px solid var(--accent, #5865d8); + outline-offset: 2px; +} + +.page-screen__advanced { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 14px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 13px; +} + +.page-screen__advanced p { + margin: 0; + overflow-wrap: anywhere; + user-select: text; +} + +.page-screen__link { + padding: 0; + border: 0; + background: none; + color: #c4372c; + font: inherit; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} diff --git a/src/shared/commands/pages.ts b/src/shared/commands/pages.ts index fb3df4c1..4c8bd368 100644 --- a/src/shared/commands/pages.ts +++ b/src/shared/commands/pages.ts @@ -1,9 +1,13 @@ -import type { PageDialogAnswer, PageDialogContent } from '../pages' +import type { PageDialogAnswer, PageDialogContent, PageScreenAction, PageScreenContent } from '../pages' -/** Owned by the pages lane: what a page asks of the user (NV-24). */ +/** Owned by the pages lane: what a page asks of the user (NV-24), and what shows when it fails (NV-13, NV-14). */ export interface PagesCommands { /** A page dialog view's content: the dialog it shows now, or null (the view asks when it loads). */ 'pages.dialogContent': { args: Record; result: PageDialogContent | null } /** The user's answer to the dialog a page dialog view shows; ignored from any other view. */ 'pages.dialogAnswer': { args: PageDialogAnswer; result: void } + /** A screen view's content: the error or crash screen it shows now, or null. */ + 'pages.screenContent': { args: Record; result: PageScreenContent | null } + /** A screen's button (or the network coming back) for the screen a view shows; ignored from any other view. */ + 'pages.screenAction': { args: { id: number; action: PageScreenAction }; result: void } } diff --git a/src/shared/pages.ts b/src/shared/pages.ts index eda18d41..05a59f5a 100644 --- a/src/shared/pages.ts +++ b/src/shared/pages.ts @@ -1,11 +1,15 @@ /** * What a page asks of the user, shown in Orbit's own dialogs over that page (pages lane, NV-24): * the page's `alert`, `confirm` and `prompt`, "Leave site?" before a page that asks for it goes, - * and HTTP sign-in. + * HTTP sign-in, and "Page unresponsive" (NV-14). And what the page's area shows when the page + * couldn't load or crashed (NV-13, NV-14). */ -/** The kinds of page dialog. `leave` and `reload` are the leave-page warning (`beforeunload`). */ -export type PageDialogKind = 'alert' | 'confirm' | 'prompt' | 'leave' | 'reload' | 'auth' +/** + * The kinds of page dialog. `leave` and `reload` are the leave-page warning (`beforeunload`), + * `hang` the page that stopped responding. + */ +export type PageDialogKind = 'alert' | 'confirm' | 'prompt' | 'leave' | 'reload' | 'auth' | 'hang' /** What a dialog view shows. */ export interface PageDialogContent { @@ -19,10 +23,14 @@ export interface PageDialogContent { note?: string /** prompt: the text the field starts with. */ value?: string - /** The accepting button: "OK", "Leave", "Reload", "Sign in". */ + /** The accepting button: "OK", "Leave", "Reload", "Sign in", "Wait". */ okLabel: string + /** The other button, when it isn't "Cancel": "Close page". */ + cancelLabel?: string /** alert: one button only. */ noCancel?: boolean + /** Esc answers with the accepting button (the safe answer), not the other one. */ + escapeAccepts?: boolean } /** The user's answer; `ok: false` is Cancel (or Esc, or the dialog going away). */ @@ -124,3 +132,210 @@ export function scriptAnswer(kind: 'alert' | 'confirm' | 'prompt', answer: PageD if (kind === 'confirm') return answer?.ok === true return answer?.ok ? (answer.value ?? '') : null } + +/** + * "Page unresponsive" (NV-14, Chromium's hung-page dialog): Wait is the safe answer (Enter and Esc + * give it); Close page stops the page. + */ +export function hangDialog(pageUrl: string | undefined): Omit { + const site = screenSite(pageUrl) + return { + kind: 'hang', + title: 'Page unresponsive', + message: `${site || 'This page'} isn’t responding. You can wait for it, or close the page.`, + okLabel: 'Wait', + cancelLabel: 'Close page', + escapeAccepts: true, + } +} + +// ---- Error and crash screens (NV-13, NV-14) ---- + +/** + * What a page's area shows instead of its page: it couldn't load (`error`), the device is offline + * (`offline`), the site's certificate isn't trusted (`cert`), or the page crashed (`crash`). + */ +export type PageScreenKind = 'error' | 'offline' | 'cert' | 'crash' + +/** What a screen view shows. */ +export interface PageScreenContent { + id: number + kind: PageScreenKind + /** "This site can’t be reached". */ + title: string + /** One plain sentence. */ + message: string + /** The site the page is for: its host with the port, or a file's name. */ + site: string + /** Chromium's name for what went wrong, shown small: "ERR_NAME_NOT_RESOLVED". */ + code?: string + /** The main button: load the address again, reload a crashed page, or leave a site that isn't safe. */ + action: 'retry' | 'reload' | 'back' + actionLabel: string + /** A certificate error: what Advanced says, and, where the user may go on anyway, its button. */ + advanced?: { text: string; proceedLabel?: string } +} + +/** What a screen view asks main to do; `online` is the network coming back while it shows. */ +export type PageScreenAction = 'retry' | 'reload' | 'back' | 'proceed' | 'online' + +/** The site a screen names: the host with its port, a file's name, or "" when there is none. */ +export function screenSite(pageUrl: string | undefined): string { + try { + let url = new URL(pageUrl ?? '') + // A page's source is for the page. + if (url.protocol === 'view-source:') url = new URL(url.href.slice('view-source:'.length)) + if (url.protocol === 'file:') { + const name = url.pathname.split('/').filter(Boolean).pop() ?? '' + try { + return decodeURIComponent(name) + } catch { + return name + } + } + return url.host + } catch { + return '' + } +} + +/** Chromium's name for a load error, as Electron gives it ("ERR_NAME_NOT_RESOLVED"). */ +function errorName(code: number, description: string): string { + const name = description.replace(/^net::/, '').trim() + return /^ERR_[A-Z0-9_]+$/.test(name) ? name : `ERR_${Math.abs(code)}` +} + +/** A certificate error (Chromium's -200 to -299). */ +export function isCertError(code: number): boolean { + return code <= -200 && code > -300 +} + +/** Certificate errors nobody may go past: a revoked certificate, a known interception. */ +const CERT_FATAL = new Set([-206, -217]) + +const UNREACHABLE = 'This site can’t be reached' +const NOT_WORKING = 'This page isn’t working' + +/** The screen for a page that couldn't load, by Chromium's error code (`did-fail-provisional-load`). */ +export function errorScreen( + code: number, + description: string, + pageUrl: string | undefined, +): Omit { + const site = screenSite(pageUrl) + const name = errorName(code, description) + const retry = { site, code: name, action: 'retry' as const, actionLabel: 'Try again' } + if (isCertError(code)) return certScreen(code, name, site) + const say = (title: string, message: string, kind: PageScreenKind = 'error') => ({ kind, title, message, ...retry }) + switch (code) { + case -106: // ERR_INTERNET_DISCONNECTED + return say('No internet', 'Your device isn’t connected to the internet.', 'offline') + case -105: // ERR_NAME_NOT_RESOLVED + case -137: // ERR_NAME_RESOLUTION_FAILED + return say(UNREACHABLE, 'Orbit couldn’t find the server at this address.') + case -102: // ERR_CONNECTION_REFUSED + return say(UNREACHABLE, 'The server refused to connect.') + case -7: // ERR_TIMED_OUT + case -118: // ERR_CONNECTION_TIMED_OUT + return say(UNREACHABLE, 'The server took too long to respond.') + case -101: // ERR_CONNECTION_RESET + return say(UNREACHABLE, 'The connection was reset.') + case -100: // ERR_CONNECTION_CLOSED + case -324: // ERR_EMPTY_RESPONSE + return say(NOT_WORKING, 'The server closed the connection without sending anything.') + case -21: // ERR_NETWORK_CHANGED + return say(UNREACHABLE, 'Your connection was interrupted.') + case -109: // ERR_ADDRESS_UNREACHABLE + return say(UNREACHABLE, 'The server’s address can’t be reached from this network.') + case -130: // ERR_PROXY_CONNECTION_FAILED + case -111: // ERR_TUNNEL_CONNECTION_FAILED + return say(UNREACHABLE, 'Orbit couldn’t connect through the proxy server.') + case -310: // ERR_TOO_MANY_REDIRECTS + return say(NOT_WORKING, 'The site redirected too many times.') + case -320: // ERR_INVALID_RESPONSE + case -337: // ERR_INVALID_HTTP_RESPONSE + return say(NOT_WORKING, 'The site sent a response Orbit can’t read.') + case -107: // ERR_SSL_PROTOCOL_ERROR + case -113: // ERR_SSL_VERSION_OR_CIPHER_MISMATCH + return say('This site can’t provide a secure connection', 'The site sent a response Orbit can’t trust.') + case -20: // ERR_BLOCKED_BY_CLIENT + case -22: // ERR_BLOCKED_BY_ADMINISTRATOR + return say('This page has been blocked', 'Something in Orbit, such as an extension, blocked it.') + case -27: // ERR_BLOCKED_BY_RESPONSE + return say('This page has been blocked', 'The site doesn’t allow it to be shown here.') + case -6: // ERR_FILE_NOT_FOUND + return say('This file can’t be opened', 'It may have been moved, renamed or deleted.') + case -10: // ERR_ACCESS_DENIED + return say('This file can’t be opened', 'Orbit isn’t allowed to read it.') + case -312: // ERR_UNSAFE_PORT + return say(UNREACHABLE, 'The address uses a port that browsers block for safety.') + default: + return say(UNREACHABLE, 'Something went wrong while loading it.') + } +} + +/** The screen for a navigation that got no answer within Orbit's own time limit. */ +export function timeoutScreen(pageUrl: string | undefined): Omit { + return errorScreen(-7, 'ERR_TIMED_OUT', pageUrl) +} + +/** + * "Your connection is not private" (Chromium's certificate interstitial): Back to safety is the + * main button, and going on needs Advanced, then Proceed. + */ +function certScreen(code: number, name: string, site: string): Omit { + const who = site || 'this site' + const why = + code === -201 // ERR_CERT_DATE_INVALID + ? 'its security certificate has expired or isn’t valid yet' + : code === -200 // ERR_CERT_COMMON_NAME_INVALID + ? 'its security certificate is for another site' + : code === -202 // ERR_CERT_AUTHORITY_INVALID + ? 'its security certificate isn’t trusted by your device' + : code === -206 // ERR_CERT_REVOKED + ? 'its security certificate has been revoked' + : 'its security certificate has a problem' + return { + kind: 'cert', + title: 'Your connection is not private', + message: + 'Someone might be trying to steal your information from this site, such as passwords, messages or card numbers.', + site, + code: name, + action: 'back', + actionLabel: 'Back to safety', + advanced: { + text: `The server couldn’t prove that it’s ${who}: ${why}.`, + ...(CERT_FATAL.has(code) ? {} : { proceedLabel: `Proceed to ${who} (unsafe)` }), + }, + } +} + +/** + * The screen for a crashed page (Chromium's "sad tab"): Reload loads it again. `reason` is + * `render-process-gone`'s, or `hung` for a page the user closed from "Page unresponsive". + */ +export function crashScreen(reason: string, pageUrl: string | undefined): Omit { + const site = screenSite(pageUrl) + const say = (title: string, message: string) => ({ + kind: 'crash' as const, + title, + message, + site, + code: reason.toUpperCase().replace(/[^A-Z0-9]+/g, '_'), + action: 'reload' as const, + actionLabel: 'Reload', + }) + switch (reason) { + case 'hung': + return say('This page was closed', 'It stopped responding, so Orbit closed it.') + case 'oom': + return say('This page crashed', 'It ran out of memory.') + case 'memory-eviction': + return say('This page crashed', 'It was closed to free up memory.') + case 'killed': + return say('This page crashed', 'Its process was ended.') + default: + return say('This page crashed', 'Something went wrong while showing it.') + } +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 72793b8e..93bb7142 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -93,6 +93,8 @@ export type RendererView = | 'internal' /** Pages lane: a page's dialog over its page (alert, confirm, prompt, leave-page, sign-in; NV-24). */ | 'dialog' + /** Pages lane: what a page's area shows when its page couldn't load or crashed (NV-13, NV-14). */ + | 'screen' /** The API the preload exposes on `window.orbit` to Orbit's own UI renderers. */ export interface OrbitBridge { diff --git a/src/shared/site.ts b/src/shared/site.ts index 6f903d3d..ca0c64bd 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -198,6 +198,13 @@ export const PAGE_CHANNEL = { * Esc in a loading tab, and only from that tab's main frame (src/main/keys/escape.ts). */ escape: 'orbit-page:escape', + /** + * send(url): the page's main frame is about to send a form (POST) to `url`, from the Navigation + * API's `navigate` event. It may be a large upload, so Orbit's time limit for a server's answer + * leaves that navigation alone (NV-13; main side: src/main/pages/screens.ts). Main reads it only + * from a tab's or a pop-up's main frame. + */ + formSent: 'orbit-page:form-sent', /** * sendSync(kind, message, value): the page called `alert`, `confirm` or `prompt` (NV-24). Main * shows Orbit's dialog over that page, answers only a tab's or a pop-up's main frame, and replies diff --git a/tests/e2e/core.spec.ts b/tests/e2e/core.spec.ts index 8d1c7130..c961ad17 100644 --- a/tests/e2e/core.spec.ts +++ b/tests/e2e/core.spec.ts @@ -658,12 +658,13 @@ test('[TB-19] a tab view that replaces another stays below the open command bar expect(await overlay.getByTestId('command-input').isVisible()).toBe(true) }) -test('[NV-14] a crashed shell renderer reloads, and a crashed tab reloads when shown (review F11)', async () => { +test('[NV-14] a crashed shell renderer reloads, and a crashed tab comes back when reloaded (review F11)', async () => { const a = (await run(orbit.app, 'tabs.create', { url: fx.url('/page/Crashy') })) as string await tabPage(orbit.app, '/page/Crashy') const b = (await run(orbit.app, 'tabs.create', { url: fx.url('/page/Calm') })) as string await tabPage(orbit.app, '/page/Calm') - // A background tab crashes; selecting it reloads the page. + // A background tab crashes. Selecting it shows its crash screen (pages-crash.spec.ts); reloading + // brings the page back. await orbit.app.evaluate( (_e, id) => (globalThis as unknown as OrbitGlobal).__orbit.ctx.tabs.contents(id)?.forcefullyCrashRenderer(), a, @@ -675,6 +676,7 @@ test('[NV-14] a crashed shell renderer reloads, and a crashed tab reloads when s ) await expect.poll(() => crashed(a)).toBe(true) await run(orbit.app, 'tabs.activate', { tabId: a }) + await run(orbit.app, 'tabs.reload', { tabId: a }) await expect.poll(() => crashed(a)).toBe(false) await expect .poll(() => diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index d8716cdf..857be963 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -646,12 +646,13 @@ test('[DT-14] ⇧⌘R reloads bypassing the cache, and brings back a crashed tab expect(docs()[2]).toMatchObject({ cacheControl: 'no-cache', pragma: 'no-cache' }) expect(assets()[1]).toMatchObject({ cacheControl: 'no-cache', pragma: 'no-cache' }) expect(await actionsRun()).toEqual(['nav.reload', 'nav.hardReload']) - // A crashed tab: the window keeps the keyboard, and ⇧⌘R brings the page back. + // A crashed tab: the window keeps the keyboard (its crash screen has it, NV-14), and ⇧⌘R brings + // the page back. await orbit.app.evaluate((_e, id) => { ;(globalThis as unknown as OrbitGlobal).__orbit.ctx.tabs.contents(id)?.forcefullyCrashRenderer() }, tabId) await expect.poll(async () => (await state(orbit.app)).tabs[tabId]?.error?.code).toBe(-1000) - await expect.poll(focusedUrl).toContain('view=shell') + await expect.poll(focusedUrl).toContain('view=screen') await press('focused', 'Shift+Mod+R') await expect.poll(() => inPage(tabId, 'document.title')).toBe('Cached') expect(docs()[3]).toMatchObject({ cacheControl: 'no-cache' }) diff --git a/tests/e2e/pages-crash.spec.ts b/tests/e2e/pages-crash.spec.ts new file mode 100644 index 00000000..4538e119 --- /dev/null +++ b/tests/e2e/pages-crash.spec.ts @@ -0,0 +1,174 @@ +import { type ElectronApplication, expect, test } from '@playwright/test' +import { launchOrbit, MOD, type Orbit } from './helpers' +import { + crashTab, + dialogPage, + emitOnTab, + type PagesFixtures, + pageDialogs, + screenPage, + startInTab, + startPagesFixtures, + tabCrashed, + waitDialog, + waitNoScreen, + waitScreen, +} from './pages-support' +import { newTab, row, tab, waitTab } from './tabs-support' + +let fx: PagesFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +/** A tab on fixture page `name`, loaded, with its icon. */ +async function tabOn(app: ElectronApplication, name: string): Promise { + const tabId = await newTab(app, fx.url(`/page/${name}`)) + await waitTab(app, tabId, `/page/${name}`) + await expect.poll(async () => (await tab(app, tabId))?.favicon).toBeTruthy() + return tabId +} + +/** Keeps a tab's page busy for `ms`: it answers nothing meanwhile. */ +const keepBusy = (app: ElectronApplication, tabId: string, ms: number) => + startInTab(app, tabId, `{ const end = Date.now() + ${ms}; while (Date.now() < end) {} }`) + +/** Whether a tab's page answers a script within `ms`. */ +const answers = (app: ElectronApplication, tabId: string, ms = 1_000) => + app.evaluate( + (_e, [id, wait]) => { + const g = globalThis as unknown as { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } + } + const page = g.__orbit.ctx.tabs.contents(id) + if (!page) return false + return Promise.race([ + page.executeJavaScript('true').catch(() => false), + new Promise((r) => setTimeout(() => r(false), wait)), + ]) + }, + [tabId, ms] as const, + ) + +test('[NV-14] a page that crashes shows “This page crashed” with Reload in its place; its row keeps its title and icon; Reload brings it back', async () => { + const { app, shell } = orbit + const tabId = await tabOn(app, 'Crashy') + const before = await tab(app, tabId) + await crashTab(app, tabId) + const shown = await waitScreen(app, (s) => s.tabId === tabId && s.onScreen) + expect(shown).toMatchObject({ + kind: 'crash', + title: 'This page crashed', + message: 'Something went wrong while showing it.', + site: `localhost:${fx.port}`, + code: 'CRASHED', + host: 'tab', + }) + // It has the keyboard, on Reload. + await expect.poll(async () => (await waitScreen(app, (s) => s.id === shown.id)).focused).toBe(true) + const view = await screenPage(app, shown.id) + await expect(view.getByTestId('page-screen-title')).toHaveText('This page crashed') + const reload = view.getByTestId('page-screen-action') + await expect(reload).toHaveText('Reload') + await expect(reload).toBeFocused() + // The row stays, with its title and icon. + await expect(row(shell, tabId)).toContainText('Crashy') + expect(await tab(app, tabId)).toMatchObject({ title: 'Crashy', favicon: before?.favicon, url: before?.url }) + // Enter reloads: the page is back, and the screen goes. + await view.keyboard.press('Enter') + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Crashy') + expect(await tabCrashed(app, tabId)).toBe(false) +}) + +test('[NV-14] a page that crashed behind another shows its crash screen when its row is selected, and stays so until ⌘⇧R reloads it', async () => { + const { app, shell } = orbit + const behind = await tabOn(app, 'Behind') + const front = await tabOn(app, 'Front') + await crashTab(app, behind) + const shown = await waitScreen(app, (s) => s.tabId === behind) + // Its tab isn't on screen, so neither is its screen. + expect(shown.onScreen).toBe(false) + await row(shell, behind).click() + await expect.poll(async () => (await waitScreen(app, (s) => s.id === shown.id)).onScreen).toBe(true) + // Selecting it doesn't reload it: the crash screen shows until the user reloads. + expect(await tabCrashed(app, behind)).toBe(true) + await expect.poll(async () => (await waitScreen(app, (s) => s.id === shown.id)).focused).toBe(true) + expect(await tabCrashed(app, behind)).toBe(true) + const view = await screenPage(app, shown.id) + await view.keyboard.press(`Shift+${MOD}+R`) + await waitNoScreen(app, behind) + await waitTab(app, behind, '/page/Behind') + expect(await tabCrashed(app, behind)).toBe(false) + expect(await tabCrashed(app, front)).toBe(false) +}) + +// A busy page that gets input tells main (`unresponsive`) after about 15 s, and again when it +// answers (`responsive`). The engine says nothing while a debugger is attached to the page, and +// Playwright attaches to every page, so these tests make a page busy and emit what the engine would. + +test('[NV-14] a page that stops responding gets “Page unresponsive” over it, with Wait or Close page; it goes by itself when the page answers again', async () => { + const { app } = orbit + const tabId = await tabOn(app, 'Busy') + await keepBusy(app, tabId, 3_000) + expect(await answers(app, tabId, 500)).toBe(false) + await emitOnTab(app, tabId, 'unresponsive') + const hang = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'hang' && d.onScreen) + expect(hang).toMatchObject({ + title: 'Page unresponsive', + message: `localhost:${fx.port} isn’t responding. You can wait for it, or close the page.`, + }) + // It has the keyboard, on Wait. + await expect.poll(async () => (await waitDialog(app, (d) => d.id === hang.id)).focused).toBe(true) + const view = await dialogPage(app, hang.id) + await expect(view.getByTestId('page-dialog-ok')).toHaveText('Wait') + await expect(view.getByTestId('page-dialog-ok')).toBeFocused() + await expect(view.getByTestId('page-dialog-cancel')).toHaveText('Close page') + // The page answers again: the dialog goes by itself, and the page is as it was. + await expect.poll(() => answers(app, tabId), { timeout: 15_000 }).toBe(true) + await emitOnTab(app, tabId, 'responsive') + await expect.poll(async () => (await pageDialogs(app)).some((d) => d.kind === 'hang')).toBe(false) + expect(await tabCrashed(app, tabId)).toBe(false) + + // Wait (Esc gives it too): the dialog goes, and the page carries on. + await keepBusy(app, tabId, 3_000) + await emitOnTab(app, tabId, 'unresponsive') + const again = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'hang' && d.id !== hang.id && d.onScreen) + await (await dialogPage(app, again.id)).keyboard.press('Escape') + await expect.poll(async () => (await pageDialogs(app)).some((d) => d.kind === 'hang')).toBe(false) + await expect.poll(() => answers(app, tabId), { timeout: 15_000 }).toBe(true) + expect(await tabCrashed(app, tabId)).toBe(false) +}) + +test('[NV-14] Close page ends a page that stopped responding; its screen says so, and Reload brings it back', async () => { + const { app } = orbit + const tabId = await tabOn(app, 'Stuck') + await keepBusy(app, tabId, 10 * 60_000) + expect(await answers(app, tabId, 500)).toBe(false) + await emitOnTab(app, tabId, 'unresponsive') + const hang = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'hang' && d.onScreen) + await (await dialogPage(app, hang.id)).getByTestId('page-dialog-cancel').click() + const shown = await waitScreen(app, (s) => s.tabId === tabId && s.onScreen) + expect(shown).toMatchObject({ + kind: 'crash', + title: 'This page was closed', + message: 'It stopped responding, so Orbit closed it.', + code: 'HUNG', + }) + expect(await tabCrashed(app, tabId)).toBe(true) + await (await screenPage(app, shown.id)).getByTestId('page-screen-action').click() + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Stuck') + expect(await tabCrashed(app, tabId)).toBe(false) +}) diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index 2b79a2a3..40efe2ee 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -4,6 +4,7 @@ import { clickInPage, closeWindow, cmdIn } from './nav-support' import { AUTH_PASSWORD, AUTH_USER, + clickClosing, dialogPage, leaveInfo, mayDiscard, @@ -205,7 +206,7 @@ test('[NV-24] a Peek’s and a Mini Window’s page show their dialogs over them await expect .poll(async () => JSON.stringify((await waitDialog(app, (d) => d.id === peek.id)).bounds)) .toBe(JSON.stringify(await pageBounds(peekTab))) - await (await dialogPage(app, peek.id)).getByTestId('page-dialog-ok').click() + await clickClosing((await dialogPage(app, peek.id)).getByTestId('page-dialog-ok')) await answered(peekTab, 'true') await app.evaluate((_e, u) => { @@ -221,7 +222,7 @@ test('[NV-24] a Peek’s and a Mini Window’s page show their dialogs over them await expect .poll(async () => JSON.stringify((await waitDialog(app, (d) => d.id === mini.id)).bounds)) .toBe(JSON.stringify(await pageBounds(miniTab))) - await (await dialogPage(app, mini.id)).getByTestId('page-dialog-cancel').click() + await clickClosing((await dialogPage(app, mini.id)).getByTestId('page-dialog-cancel')) await answered(miniTab, 'false') }) diff --git a/tests/e2e/pages-errors.spec.ts b/tests/e2e/pages-errors.spec.ts new file mode 100644 index 00000000..a16b671f --- /dev/null +++ b/tests/e2e/pages-errors.spec.ts @@ -0,0 +1,380 @@ +import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { action, launchOrbit, MOD, type Orbit } from './helpers' +import { clickInPage } from './nav-support' +import { + canMakeCertificate, + countNavigations, + networkSwitch, + type PagesFixtures, + pageBounds, + pageScreens, + screenPage, + startPagesFixtures, + startSecureFixtures, + startSilentServer, + waitNoScreen, + waitScreen, +} from './pages-support' +import { cmd, newTab, row, tab, waitTab } from './tabs-support' + +/** A host no rule answers (D26): with the dead proxy out of its way, its lookup fails. */ +const MISSING_HOST = 'nonexistent.invalid' +/** Orbit's time limit for a navigation's first answer, in these tests (ORBIT_E2E_LOAD_TIMEOUT_MS). */ +const TIME_LIMIT = 2_000 + +let fx: PagesFixtures +let orbit: Orbit | undefined + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.afterEach(async () => { + await orbit?.app.close() + orbit = undefined +}) + +/** A tab on fixture page `name`, with a page before it. */ +async function tabOn(app: ElectronApplication, name: string): Promise { + const tabId = await newTab(app, fx.url('/page/First')) + await waitTab(app, tabId, '/page/First') + await cmd(app, 'tabs.navigate', { tabId, url: fx.url(`/page/${name}`) }) + await waitTab(app, tabId, `/page/${name}`) + return tabId +} + +/** Waits for a screen on a tab's page that is on screen. */ +const shownOn = (app: ElectronApplication, tabId: string) => waitScreen(app, (s) => s.tabId === tabId && s.onScreen) + +/** Dispatches the engine's `online` event in a screen view, as when the network comes back. */ +const backOnline = (view: Page) => view.evaluate(() => window.dispatchEvent(new Event('online'))) + +/** The address of the web contents that has the keyboard. */ +const focusedUrl = (app: ElectronApplication) => + app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL() ?? '') + +test('[NV-13] a host that doesn’t resolve: the page’s area says so with the host and Try again, the address keeps it, ⌘R tries again and Back leaves it', async () => { + orbit = await launchOrbit({ args: [`--proxy-bypass-list=${MISSING_HOST}`] }) + const { app, shell } = orbit + const tabId = await tabOn(app, 'Start') + const url = `http://${MISSING_HOST}/` + await cmd(app, 'tabs.navigate', { tabId, url }) + const shown = await shownOn(app, tabId) + expect(shown).toMatchObject({ + kind: 'error', + title: 'This site can’t be reached', + message: 'Orbit couldn’t find the server at this address.', + site: MISSING_HOST, + code: 'ERR_NAME_NOT_RESOLVED', + url, + timedOut: false, + host: 'tab', + }) + // Over the page's own area only, and it has the keyboard. + expect(shown.bounds).toEqual(await pageBounds(app, tabId)) + await expect.poll(async () => (await waitScreen(app, (s) => s.id === shown.id)).focused).toBe(true) + // The address keeps the host that failed, and the row names it: it showed another site. + await expect(shell.getByTestId('address')).toContainText(MISSING_HOST) + await expect.poll(async () => (await tab(app, tabId))?.title).toBe(MISSING_HOST) + expect(await tab(app, tabId)).toMatchObject({ url }) + expect((await tab(app, tabId))?.favicon).toBeUndefined() + + const view = await screenPage(app, shown.id) + await expect(view.getByTestId('page-screen-title')).toHaveText('This site can’t be reached') + await expect(view.getByTestId('page-screen-message')).toHaveText('Orbit couldn’t find the server at this address.') + await expect(view.getByTestId('page-screen-site')).toHaveText(MISSING_HOST) + await expect(view.getByTestId('page-screen-code')).toHaveText('ERR_NAME_NOT_RESOLVED') + const tryAgain = view.getByTestId('page-screen-action') + await expect(tryAgain).toHaveText('Try again') + await expect(tryAgain).toBeFocused() + + // Try again asks again: it fails again, and its screen takes the last one's place. + const navigations = await countNavigations(app, tabId) + await tryAgain.click() + const again = await waitScreen(app, (s) => s.tabId === tabId && s.id !== shown.id) + expect(again).toMatchObject({ code: 'ERR_NAME_NOT_RESOLVED', url, onScreen: true }) + expect(await navigations()).toBe(1) + // ⌘R too, from the screen, which has the keyboard. + await view.keyboard.press(`${MOD}+r`) + await waitScreen(app, (s) => s.tabId === tabId && s.id > again.id) + expect(await navigations()).toBe(2) + + // Back leaves it: the page before, with its title. + await action(app, 'nav.back') + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Start') + await expect.poll(async () => (await tab(app, tabId))?.title).toBe('Start') + await expect(shell.getByTestId('address')).toContainText(`localhost:${fx.port}`) +}) + +test('[NV-13] a refused connection: the row keeps the site’s title and icon, and Try again loads the page once its server is back', async () => { + orbit = await launchOrbit() + const { app, shell } = orbit + const site = await startPagesFixtures(3360) + const url = site.url('/page/Kept') + const tabId = await newTab(app, url) + await waitTab(app, tabId, '/page/Kept') + await expect.poll(async () => (await tab(app, tabId))?.favicon).toBeTruthy() + const icon = (await tab(app, tabId))?.favicon + // Its server goes, so nothing listens on that port now: ⌘R is refused. + await site.close() + await action(app, 'nav.reload') + const shown = await shownOn(app, tabId) + expect(shown).toMatchObject({ + kind: 'error', + title: 'This site can’t be reached', + message: 'The server refused to connect.', + site: `localhost:${site.port}`, + code: 'ERR_CONNECTION_REFUSED', + url, + }) + // The row keeps the site's title and icon, and the tab its address. + expect(await tab(app, tabId)).toMatchObject({ title: 'Kept', favicon: icon, url }) + await expect(row(shell, tabId)).toContainText('Kept') + // Back on its port: Try again loads the page. + const back = await startPagesFixtures(3360, { port: site.port }) + try { + await (await screenPage(app, shown.id)).getByTestId('page-screen-action').click() + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Kept') + expect(back.seen()).toContain('GET /page/Kept') + } finally { + await back.close() + } +}) + +test('[NV-13] a server that never answers: Orbit stops waiting and says so; the address keeps it, Try again and ⌘R ask again, and Back shows the page before', async () => { + orbit = await launchOrbit({ env: { ORBIT_E2E_LOAD_TIMEOUT_MS: String(TIME_LIMIT) } }) + const { app, shell } = orbit + const silent = await startSilentServer() + try { + // The tab's first page: Back has no entry to go to, and still leads back here. + const tabId = await newTab(app, fx.url('/page/Start')) + await waitTab(app, tabId, '/page/Start') + const url = silent.url('/never') + await cmd(app, 'tabs.navigate', { tabId, url }) + const shown = await shownOn(app, tabId) + expect(shown).toMatchObject({ + kind: 'error', + title: 'This site can’t be reached', + message: 'The server took too long to respond.', + site: `localhost:${silent.port}`, + code: 'ERR_TIMED_OUT', + url, + timedOut: true, + }) + expect(silent.hits('/never')).toBe(1) + // It stopped loading; the address keeps where it was going, and Back is on. + const tabNow = async () => { + const t = await tab(app, tabId) + return { loading: t?.loading, pendingUrl: t?.pendingUrl, canGoBack: t?.canGoBack, title: t?.title } + } + await expect + .poll(tabNow) + .toEqual({ loading: false, pendingUrl: url, canGoBack: true, title: `localhost:${silent.port}` }) + await expect(shell.getByTestId('address')).toContainText(`localhost:${silent.port}`) + + // Try again asks again, and gives up again. + const view = await screenPage(app, shown.id) + await view.getByTestId('page-screen-action').click() + await expect.poll(() => silent.hits('/never')).toBe(2) + const again = await waitScreen(app, (s) => s.tabId === tabId && s.id !== shown.id) + expect(again).toMatchObject({ timedOut: true, url }) + // ⌘R asks again too. + await view.keyboard.press(`${MOD}+r`) + await expect.poll(() => silent.hits('/never')).toBe(3) + // Back, while it waits again: the page before, which never went away. + await action(app, 'nav.back') + await waitNoScreen(app, tabId) + await expect.poll(tabNow).toEqual({ loading: false, pendingUrl: undefined, canGoBack: false, title: 'Start' }) + await expect(shell.getByTestId('address')).toContainText(`localhost:${fx.port}`) + expect(silent.hits('/never')).toBe(3) + } finally { + await silent.close() + } +}) + +test('[NV-13] Back from a timeout shows the page as it was, even an error page with its screen', async () => { + orbit = await launchOrbit({ + args: [`--proxy-bypass-list=${MISSING_HOST}`], + env: { ORBIT_E2E_LOAD_TIMEOUT_MS: String(TIME_LIMIT) }, + }) + const { app } = orbit + const silent = await startSilentServer() + try { + const tabId = await tabOn(app, 'Start') + const missing = `http://${MISSING_HOST}/` + await cmd(app, 'tabs.navigate', { tabId, url: missing }) + const error = await shownOn(app, tabId) + // From the error page, an address whose server never answers. + const never = silent.url('/never') + await cmd(app, 'tabs.navigate', { tabId, url: never }) + await waitScreen(app, (s) => s.tabId === tabId && s.timedOut && s.url === never) + await expect.poll(async () => (await tab(app, tabId))?.title).toBe(`localhost:${silent.port}`) + // Back: the error page, with its screen and its row as they were. + await action(app, 'nav.back') + const again = await waitScreen(app, (s) => s.tabId === tabId && !s.timedOut) + expect(again).toMatchObject({ id: error.id, code: 'ERR_NAME_NOT_RESOLVED', url: missing, onScreen: true }) + expect(await tab(app, tabId)).toMatchObject({ title: MISSING_HOST, url: missing, loading: false }) + expect((await tab(app, tabId))?.pendingUrl).toBeUndefined() + // Back again: the page before it. + await action(app, 'nav.back') + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Start') + } finally { + await silent.close() + } +}) + +test('[NV-13] a screen that comes up while the user types in Orbit’s own UI leaves the keyboard there', async () => { + orbit = await launchOrbit({ args: [`--proxy-bypass-list=${MISSING_HOST}`] }) + const { app, shell } = orbit + const tabId = await tabOn(app, 'Start') + // The user names a new folder in the sidebar, which has the keyboard (as after a click there)... + await action(app, 'folder.new') + const field = shell.getByTestId('rename-field') + await expect(field).toBeFocused() + await cmd(app, 'spaces.focusShell') + await expect.poll(() => focusedUrl(app)).toContain('view=shell') + // ...as the page fails to load: its screen shows, and the keyboard stays in the field. + await cmd(app, 'tabs.navigate', { tabId, url: `http://${MISSING_HOST}/` }) + const shown = await shownOn(app, tabId) + expect(shown.focused).toBe(false) + expect(await focusedUrl(app)).toContain('view=shell') + await expect(field).toBeFocused() +}) + +test('[NV-13] a form still on its way isn’t stopped by Orbit’s time limit: it may be uploading', async () => { + orbit = await launchOrbit({ env: { ORBIT_E2E_LOAD_TIMEOUT_MS: String(TIME_LIMIT) } }) + const { app } = orbit + const silent = await startSilentServer() + try { + const form = fx.url(`/upload?to=${encodeURIComponent(silent.url('/upload'))}`) + const sending = await newTab(app, form) + await waitTab(app, sending, '/upload') + await clickInPage(app, form, '#send') + await expect.poll(() => silent.hits('/upload')).toBe(1) + // A plain load from the same server, started after the form: Orbit gives up on that one, and + // would have given up on the form first. + const plain = await newTab(app, silent.url('/plain')) + await waitScreen(app, (s) => s.tabId === plain) + expect((await pageScreens(app)).filter((s) => s.tabId === sending)).toEqual([]) + expect((await tab(app, sending))?.loading).toBe(true) + } finally { + await silent.close() + } +}) + +test('[NV-13] a certificate no one trusts: “Your connection is not private” with Back to safety on the keyboard; going on takes Advanced, then Proceed, and is remembered for the site', async () => { + test.skip(!canMakeCertificate(), 'needs openssl to make a certificate') + orbit = await launchOrbit() + const { app } = orbit + const secure = await startSecureFixtures() + try { + const host = `localhost:${secure.port}` + const tabId = await tabOn(app, 'Start') + const url = secure.url('/page/Secret') + await cmd(app, 'tabs.navigate', { tabId, url }) + const shown = await shownOn(app, tabId) + expect(shown).toMatchObject({ + kind: 'cert', + title: 'Your connection is not private', + site: host, + code: 'ERR_CERT_AUTHORITY_INVALID', + url, + }) + const view = await screenPage(app, shown.id) + await expect(view.getByTestId('page-screen-message')).toHaveText( + 'Someone might be trying to steal your information from this site, such as passwords, messages or card numbers.', + ) + const safety = view.getByTestId('page-screen-action') + await expect(safety).toHaveText('Back to safety') + await expect(safety).toBeFocused() + // Going on is behind Advanced. + await expect(view.getByTestId('page-screen-proceed')).toHaveCount(0) + // Enter goes back to safety: the page before. Nothing reached the site. + await view.keyboard.press('Enter') + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Start') + expect(secure.seen()).toEqual([]) + + // A tab with nowhere to go back to closes. + const fresh = await newTab(app, url) + const freshScreen = await shownOn(app, fresh) + expect(freshScreen.kind).toBe('cert') + await (await screenPage(app, freshScreen.id)).getByTestId('page-screen-action').click() + await expect.poll(async () => (await tab(app, fresh)) === undefined).toBe(true) + + // Advanced, then Proceed: the page loads. + await cmd(app, 'tabs.navigate', { tabId, url }) + const again = await shownOn(app, tabId) + const view2 = await screenPage(app, again.id) + await view2.getByTestId('page-screen-advanced').click() + await expect(view2.getByTestId('page-screen-details')).toContainText( + `The server couldn’t prove that it’s ${host}: its security certificate isn’t trusted by your device.`, + ) + const proceed = view2.getByTestId('page-screen-proceed') + await expect(proceed).toHaveText(`Proceed to ${host} (unsafe)`) + await proceed.click() + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Secret') + // Remembered for the site until Orbit quits: another of its pages loads at once. + const other = await newTab(app, secure.url('/page/Other')) + await waitTab(app, other, '/page/Other') + expect((await pageScreens(app)).filter((s) => s.tabId === other)).toEqual([]) + } finally { + await secure.close() + } +}) + +test('[NV-13] offline: “No internet”; when the network comes back while it shows, the page loads again, once', async () => { + orbit = await launchOrbit() + const { app } = orbit + const tabId = await tabOn(app, 'Start') + const network = await networkSwitch(app, '/page/Start') + await network.offline(true) + try { + await cmd(app, 'tabs.navigate', { tabId, url: fx.url('/page/Later') }) + const shown = await shownOn(app, tabId) + expect(shown).toMatchObject({ + kind: 'offline', + title: 'No internet', + message: 'Your device isn’t connected to the internet.', + code: 'ERR_INTERNET_DISCONNECTED', + }) + // The network comes back. The engine tells Orbit's view (`online`; the emulation here is the + // page's only, so the test says it), and the page loads again by itself. + await network.offline(false) + await backOnline(await screenPage(app, shown.id)) + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Later') + + // Once: a retry that fails as well waits for Try again. + await network.offline(true) + await cmd(app, 'tabs.navigate', { tabId, url: fx.url('/page/Again') }) + const first = await shownOn(app, tabId) + const navigations = await countNavigations(app, tabId) + const view = await screenPage(app, first.id) + await backOnline(view) + const second = await waitScreen(app, (s) => s.tabId === tabId && s.id !== first.id) + expect(await navigations()).toBe(1) + await backOnline(view) + // What the view sends reaches main in order: once this is answered, so was `online`. + await view.evaluate(() => + (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise } }).orbit.invoke( + 'pages.screenContent', + {}, + ), + ) + expect(await navigations()).toBe(1) + expect((await waitScreen(app, (s) => s.tabId === tabId)).id).toBe(second.id) + await network.offline(false) + await view.getByTestId('page-screen-action').click() + await waitNoScreen(app, tabId) + await waitTab(app, tabId, '/page/Again') + } finally { + await network.close() + } +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index e468e714..91ada94e 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -1,13 +1,20 @@ /** * Helpers for the pages lane's e2e specs (pages*.spec.ts): fixture pages that ask things of the - * user, and handles on Orbit's page dialogs (NV-24). Builds on helpers.ts. + * user or fail to load, and handles on Orbit's page dialogs (NV-24) and on what a page's area shows + * when its page couldn't load or crashed (NV-13, NV-14). Builds on helpers.ts. */ -import { createServer, type Server } from 'node:http' -import type { ElectronApplication, Page } from '@playwright/test' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { createServer as createSecureServer } from 'node:https' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ElectronApplication, Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' import type { PageDialogInfo } from '../../src/main/pages/dialogs' import type { LeaveInfo } from '../../src/main/pages/leave' -import { listenInRange } from './helpers' +import type { PageScreenInfo } from '../../src/main/pages/screens' +import { listenInRange, tabPage } from './helpers' export interface PagesFixtures { port: number @@ -21,8 +28,12 @@ export interface PagesFixtures { export const AUTH_USER = 'orbit' export const AUTH_PASSWORD = 'secret word' +/** Every fixture page's icon, so a test can see a row keep it. */ +export const PAGE_ICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='4' fill='%23d2691e'/%3E%3C/svg%3E" + const html = (title: string, body: string, head = '') => ` -${title}${head} +${title}${head} ${body}` @@ -36,10 +47,44 @@ const html = (title: string, body: string, head = '') => ` * - `/auth`: HTTP Basic sign-in (AUTH_USER, AUTH_PASSWORD). * - `/page/`: a plain page. * - `/opener?to=…`: opens `to` in a pop-up window on a click of `#open`. + * - `/upload?to=…`: a form (`#form`) that posts to `to`. + * + * Every page has an icon (PAGE_ICON). `port` listens on that port only (a server back on the port + * it left). */ -export async function startPagesFixtures(base = 3340): Promise { +export async function startPagesFixtures(base = 3340, opts: { port?: number } = {}): Promise { const seen: string[] = [] - const server: Server = createServer((req, res) => { + const server: Server = createServer(pagesHandler(seen)) + const port = opts.port ? await listenOn(server, opts.port) : await listenInRange(server, base) + return { + port, + url: (path) => `http://localhost:${port}${path}`, + seen: () => [...seen], + close: () => closeServer(server), + } +} + +/** Listens on loopback at exactly `port`. */ +function listenOn(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, '127.0.0.1', () => { + server.off('error', reject) + resolve(port) + }) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }) +} + +/** The fixture pages' answers, for the plain and the secure server alike. */ +function pagesHandler(seen: string[]): (req: IncomingMessage, res: ServerResponse) => void { + return (req, res) => { const u = new URL(req.url ?? '/', 'http://localhost') seen.push(`${req.method} ${u.pathname}${u.search}`) const send = (body: string, status = 200, headers: Record = {}) => { @@ -134,18 +179,108 @@ addEventListener('pagehide', () => note('pagehide', prompt('Your name?', 'Ada')) send(html(name, `

${name}

`)) return } + if (u.pathname === '/upload') { + send( + html( + 'Upload', + ` +
`, + ), + ) + return + } send('not found', 404) + } +} + +/** Whether this machine can make a certificate for the secure fixture. */ +export function canMakeCertificate(): boolean { + try { + execFileSync('openssl', ['version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** + * The fixture pages over HTTPS, with a certificate for localhost that no one trusts (made now by + * openssl, and valid for a day): every page gets Chromium's ERR_CERT_AUTHORITY_INVALID. + */ +export async function startSecureFixtures(base = 3365): Promise { + const dir = mkdtempSync(join(tmpdir(), 'orbit-e2e-cert-')) + let key: Buffer + let cert: Buffer + try { + const config = join(dir, 'cert.cnf') + writeFileSync( + config, + `[req] +distinguished_name = dn +x509_extensions = ext +prompt = no +[dn] +CN = localhost +[ext] +subjectAltName = DNS:localhost, IP:127.0.0.1 +basicConstraints = CA:FALSE +`, + ) + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-days', + '1', + '-config', + config, + '-keyout', + join(dir, 'key.pem'), + '-out', + join(dir, 'cert.pem'), + ], + { stdio: 'ignore' }, + ) + key = readFileSync(join(dir, 'key.pem')) + cert = readFileSync(join(dir, 'cert.pem')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + const seen: string[] = [] + const server = createSecureServer({ key, cert }, pagesHandler(seen)) + const port = await listenInRange(server as unknown as Server, base) + return { + port, + url: (path) => `https://localhost:${port}${path}`, + seen: () => [...seen], + close: () => closeServer(server as unknown as Server), + } +} + +export interface SilentServer { + port: number + url: (path: string) => string + /** How many requests for a path reached it. */ + hits: (path: string) => number + close: () => Promise +} + +/** A server that takes every request and never answers one (NV-13's time limit). */ +export async function startSilentServer(base = 3360): Promise { + const seen: string[] = [] + const server = createServer((req) => { + seen.push(new URL(req.url ?? '/', 'http://localhost').pathname) }) const port = await listenInRange(server, base) return { port, url: (path) => `http://localhost:${port}${path}`, - seen: () => [...seen], - close: () => - new Promise((resolve) => { - server.closeAllConnections() - server.close(() => resolve()) - }), + hits: (path) => seen.filter((p) => p === path).length, + close: () => closeServer(server), } } @@ -154,6 +289,19 @@ type PagesGlobal = { dialogs: () => PageDialogInfo[] leave: (tabId: string) => LeaveInfo | null mayDiscard: (tabId: string) => boolean + screens: () => PageScreenInfo[] + } +} + +/** + * Clicks a button whose click closes the page it's in: a Peek's, a Mini Window's or a pop-up's + * dialog has a view of its own, which goes with the dialog, sometimes before the click returns. + */ +export async function clickClosing(target: Locator): Promise { + try { + await target.click() + } catch (error) { + if (!String(error).includes('has been closed')) throw error } } @@ -210,3 +358,146 @@ export async function dialogPage(app: ElectronApplication, id: number): Promise< export async function askResult(page: Page): Promise { return (await page.locator('#result').textContent()) ?? '' } + +/** Every page's error or crash screen (shown or waiting for its page to show). */ +export function pageScreens(app: ElectronApplication): Promise { + return app.evaluate(() => (globalThis as unknown as PagesGlobal).__orbitPages.screens()) +} + +/** Waits for a screen that matches, and returns it. */ +export async function waitScreen( + app: ElectronApplication, + match: (s: PageScreenInfo) => boolean, + timeout = 15_000, +): Promise { + let found: PageScreenInfo | undefined + await expect + .poll( + async () => { + found = (await pageScreens(app)).find(match) + return !!found + }, + { timeout }, + ) + .toBe(true) + return found as PageScreenInfo +} + +/** Waits until a tab has no screen. */ +export async function waitNoScreen(app: ElectronApplication, tabId: string): Promise { + await expect.poll(async () => (await pageScreens(app)).filter((s) => s.tabId === tabId).length).toBe(0) +} + +/** The screen view (a Playwright page) showing the screen with this id. */ +export async function screenPage(app: ElectronApplication, id: number): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + for (const page of app.windows()) { + if (!page.url().includes('view=screen')) continue + const shows = await page + .locator(`[data-screen-id="${id}"]`) + .count() + .catch(() => 0) + if (shows > 0) return page + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error(`no screen view shows screen ${id}`) +} + +type TabsGlobal = { __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } } + +/** Crashes a tab's page, as a renderer that dies does. */ +export async function crashTab(app: ElectronApplication, tabId: string): Promise { + await app.evaluate((_e, id) => { + ;(globalThis as unknown as TabsGlobal).__orbit.ctx.tabs.contents(id)?.forcefullyCrashRenderer() + }, tabId) +} + +export function tabCrashed(app: ElectronApplication, tabId: string): Promise { + return app.evaluate( + (_e, id) => (globalThis as unknown as TabsGlobal).__orbit.ctx.tabs.contents(id)?.isCrashed() ?? null, + tabId, + ) +} + +/** + * Emits one of a tab's page's events as the engine does (`unresponsive`, `responsive`). The engine + * itself says nothing of a hung page while a debugger is attached to it, and Playwright attaches + * to every page. + */ +export async function emitOnTab(app: ElectronApplication, tabId: string, name: string): Promise { + await app.evaluate( + (_e, [id, n]) => { + ;(globalThis as unknown as TabsGlobal).__orbit.ctx.tabs.contents(id)?.emit(n) + }, + [tabId, name] as const, + ) +} + +/** Starts a script in a tab's page and doesn't wait for it (it may never end). */ +export async function startInTab(app: ElectronApplication, tabId: string, code: string): Promise { + await app.evaluate( + (_e, [id, c]) => { + void (globalThis as unknown as TabsGlobal).__orbit.ctx.tabs + .contents(id) + ?.executeJavaScript(c) + .catch(() => {}) + }, + [tabId, code] as const, + ) +} + +/** + * Counts a tab's main-frame navigations from now on (a listener of the test's own on the page). + * Returns a reader. + */ +export async function countNavigations(app: ElectronApplication, tabId: string): Promise<() => Promise> { + const key = `__pagesNavigations_${tabId}` + await app.evaluate( + (_e, [id, k]) => { + const g = globalThis as unknown as TabsGlobal & Record + g[k] = 0 + g.__orbit.ctx.tabs.contents(id)?.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) g[k] = (g[k] ?? 0) + 1 + }) + }, + [tabId, key] as const, + ) + return () => app.evaluate((_e, k) => (globalThis as unknown as Record)[k] ?? 0, key) +} + +export interface NetworkSwitch { + /** Takes the page off the network (its loads fail with ERR_INTERNET_DISCONNECTED), or puts it back. */ + offline(on: boolean): Promise + close(): Promise +} + +/** + * Takes one tab's page off the network and puts it back, as DevTools' Offline does (the profile's + * own network emulation doesn't reach a page's navigations). `urlPart` finds the page. + */ +export async function networkSwitch(app: ElectronApplication, urlPart: string): Promise { + const page = await tabPage(app, urlPart) + const cdp = await page.context().newCDPSession(page) + await cdp.send('Network.enable') + return { + offline: async (on) => { + await cdp.send('Network.emulateNetworkConditions', { + offline: on, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + }, + close: () => cdp.detach().catch(() => {}), + } +} + +/** A tab's page area (window coordinates). */ +export function pageBounds(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => { + const g = globalThis as unknown as { __orbit: { tabView: (id: string) => { bounds: unknown } | null } } + return g.__orbit.tabView(id)?.bounds + }, tabId) +} diff --git a/tests/unit/pages-screens.test.ts b/tests/unit/pages-screens.test.ts new file mode 100644 index 00000000..e1754df2 --- /dev/null +++ b/tests/unit/pages-screens.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { crashScreen, errorScreen, hangDialog, isCertError, screenSite, timeoutScreen } from '../../src/shared/pages' + +describe('[NV-13] error screens: the site', () => { + it('names a web page by its host and port, a file by its name, a page’s source by its page', () => { + expect(screenSite('http://nonexistent.invalid/')).toBe('nonexistent.invalid') + expect(screenSite('https://localhost:3341/a?b#c')).toBe('localhost:3341') + expect(screenSite('file:///home/me/My%20notes.html')).toBe('My notes.html') + expect(screenSite('view-source:https://example.com/x')).toBe('example.com') + }) + + it('names nothing it can’t read', () => { + for (const url of ['', undefined, 'not a url', 'about:blank']) expect(screenSite(url)).toBe('') + }) +}) + +describe('[NV-13] error screens: what went wrong', () => { + it('says each failure in one plain sentence, with the site, the engine’s name for it and Try again', () => { + expect(errorScreen(-105, 'net::ERR_NAME_NOT_RESOLVED', 'http://nonexistent.invalid/')).toEqual({ + kind: 'error', + title: 'This site can’t be reached', + message: 'Orbit couldn’t find the server at this address.', + site: 'nonexistent.invalid', + code: 'ERR_NAME_NOT_RESOLVED', + action: 'retry', + actionLabel: 'Try again', + }) + expect(errorScreen(-102, 'ERR_CONNECTION_REFUSED', 'http://localhost:3360/').message).toBe( + 'The server refused to connect.', + ) + expect(errorScreen(-324, 'ERR_EMPTY_RESPONSE', 'http://a.test/')).toMatchObject({ + title: 'This page isn’t working', + message: 'The server closed the connection without sending anything.', + }) + }) + + it('has a screen of its own for being offline', () => { + expect(errorScreen(-106, 'ERR_INTERNET_DISCONNECTED', 'https://a.test/')).toMatchObject({ + kind: 'offline', + title: 'No internet', + message: 'Your device isn’t connected to the internet.', + action: 'retry', + }) + }) + + it('says Orbit’s own time limit as Chromium says a timeout', () => { + expect(timeoutScreen('http://localhost:3360/never')).toMatchObject({ + kind: 'error', + message: 'The server took too long to respond.', + site: 'localhost:3360', + code: 'ERR_TIMED_OUT', + action: 'retry', + }) + }) + + it('names a code it doesn’t know by number, and still offers Try again', () => { + expect(errorScreen(-999, 'something odd', 'https://a.test/')).toMatchObject({ + message: 'Something went wrong while loading it.', + code: 'ERR_999', + action: 'retry', + }) + }) +}) + +describe('[NV-13] certificate screens', () => { + it('knows Chromium’s certificate errors', () => { + expect(isCertError(-200)).toBe(true) + expect(isCertError(-202)).toBe(true) + expect(isCertError(-299)).toBe(true) + expect(isCertError(-300)).toBe(false) + expect(isCertError(-105)).toBe(false) + }) + + it('leads with Back to safety, and keeps Proceed behind Advanced', () => { + expect(errorScreen(-202, 'net::ERR_CERT_AUTHORITY_INVALID', 'https://localhost:3365/x')).toEqual({ + kind: 'cert', + title: 'Your connection is not private', + message: + 'Someone might be trying to steal your information from this site, such as passwords, messages or card numbers.', + site: 'localhost:3365', + code: 'ERR_CERT_AUTHORITY_INVALID', + action: 'back', + actionLabel: 'Back to safety', + advanced: { + text: 'The server couldn’t prove that it’s localhost:3365: its security certificate isn’t trusted by your device.', + proceedLabel: 'Proceed to localhost:3365 (unsafe)', + }, + }) + expect(errorScreen(-201, 'ERR_CERT_DATE_INVALID', 'https://a.test/').advanced?.text).toBe( + 'The server couldn’t prove that it’s a.test: its security certificate has expired or isn’t valid yet.', + ) + }) + + it('offers no way on past a revoked certificate', () => { + const revoked = errorScreen(-206, 'ERR_CERT_REVOKED', 'https://a.test/') + expect(revoked.action).toBe('back') + expect(revoked.advanced?.proceedLabel).toBeUndefined() + }) +}) + +describe('[NV-14] crash screens and hung pages', () => { + it('says the page crashed, with Reload', () => { + expect(crashScreen('crashed', 'https://a.test/x')).toEqual({ + kind: 'crash', + title: 'This page crashed', + message: 'Something went wrong while showing it.', + site: 'a.test', + code: 'CRASHED', + action: 'reload', + actionLabel: 'Reload', + }) + expect(crashScreen('oom', 'https://a.test/')).toMatchObject({ message: 'It ran out of memory.', code: 'OOM' }) + expect(crashScreen('killed', 'https://a.test/')).toMatchObject({ title: 'This page crashed', code: 'KILLED' }) + expect(crashScreen('memory-eviction', 'https://a.test/').code).toBe('MEMORY_EVICTION') + }) + + it('says a page closed from “Page unresponsive” was closed', () => { + expect(crashScreen('hung', 'https://a.test/')).toMatchObject({ + title: 'This page was closed', + message: 'It stopped responding, so Orbit closed it.', + action: 'reload', + }) + }) + + it('“Page unresponsive” offers Wait (Enter and Esc) or Close page', () => { + expect(hangDialog('https://a.test/x')).toEqual({ + kind: 'hang', + title: 'Page unresponsive', + message: 'a.test isn’t responding. You can wait for it, or close the page.', + okLabel: 'Wait', + cancelLabel: 'Close page', + escapeAccepts: true, + }) + expect(hangDialog(undefined).message).toBe('This page isn’t responding. You can wait for it, or close the page.') + }) +}) From 75355bb72aad8185c9a74d32e02e885de7650dac Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:39:12 +0000 Subject: [PATCH 04/29] feat(pages): links that open other apps ask first (NV-19) A mailto:, tel:, zoommtg: ... link no longer reaches the OS unasked. The engine hands the request to Orbit (nav/permissions.ts refuses it and calls the pages lane's handler), and Orbit asks in a page dialog over the page that followed the link: the app's name and icon where the OS gives them, the site of the frame that asked, and "Always allow to open links of this type in the associated app" for web sites, kept per profile, site and kind of link (private profiles in memory only). Cancel is the default: the keyboard starts there, and Enter anywhere but on Open gives it. A tab, Peek, Mini Window or pop-up opened only to launch an app never shows a page: it closes itself once its navigation ends, its opener is selected again, and nothing goes to the Archive. The question shows over the page that opened it. The launch is one function (launchInApp, shell.openExternal); test runs put a recorder in its place and name the OS's apps through ORBIT_E2E_APPS, so no test starts an app. Tests: pages-apps.spec.ts (3), tests/unit/pages-apps.test.ts (8). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 6 +- docs/ARCHITECTURE.md | 2 + docs/feature-status.json | 1 + src/main/nav/permissions.ts | 19 ++ src/main/pages/dialogs.ts | 5 +- src/main/pages/index.ts | 20 +- src/main/pages/other-apps.ts | 312 +++++++++++++++++++++++++++ src/renderer/src/pages/DialogApp.tsx | 70 ++++-- src/renderer/src/pages/dialog.css | 27 +++ src/shared/pages.ts | 129 ++++++++++- tests/e2e/pages-apps.spec.ts | 174 +++++++++++++++ tests/e2e/pages-support.ts | 50 +++++ tests/unit/pages-apps.test.ts | 92 ++++++++ 13 files changed, 883 insertions(+), 24 deletions(-) create mode 100644 src/main/pages/other-apps.ts create mode 100644 tests/e2e/pages-apps.spec.ts create mode 100644 tests/unit/pages-apps.test.ts diff --git a/FEATURES.md b/FEATURES.md index bc00eabb..c241f575 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 239 built (87 partial) · 332 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 240 built (87 partial) · 333 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 140 | 168 | -| P1 | 219 | 84 | 147 | +| P1 | 219 | 85 | 148 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -246,7 +246,7 @@ Spec: `spec/navigation.md` | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | -| NV-19 | Links that open other apps (external protocols) | P1 | – | – | – | | +| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | | NV-20 | Full screen | P1 | – | ✅ e2e | – | | | NV-21 | Save page and view source | P2 | – | – | – | | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36efe154..4c51defa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -150,6 +150,8 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog takes the keyboard when it shows over the page the user is on, as the engine's own does; a screen takes it only where the page had it, never from Orbit's own fields. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. +Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. + Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): whether the page would warn if it went now (it runs the page's `beforeunload` handlers with a synthetic event, only after a user gesture in the page), what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JS (user-authored, per site) is injected only through `webFrame.executeJavaScript` in an isolated world, never with Node. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). diff --git a/docs/feature-status.json b/docs/feature-status.json index aa0e87ea..49adadad 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -202,6 +202,7 @@ "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); the recording indicators wait on TB-15; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, + "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it) or its window (Cancel keeps it), a pop-up's window; a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep keep a tab whose page warns, or waits on its dialog, until the user closes it; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items"}, diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index 074add4c..68e43b87 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -54,6 +54,18 @@ const ALWAYS_ALLOW = new Set([ /** Device APIs: a site may ask, and the chooser asks the user for each device. */ const DEVICE_APIS = new Set(['serial', 'usb', 'hid']) +/** Who takes a page's link to another app (`openExternal`): the pages lane asks the user (NV-19). */ +export type AppLinkHandler = (profileId: ProfileId, contents: WebContents, details: PermissionRequestDetails) => void +let appLinkHandler: AppLinkHandler | undefined + +/** + * Sets who handles a page's links to other apps (`mailto:`, `zoommtg:`…; pages lane, NV-19). The + * engine never launches one itself: until a handler is set, each is refused. + */ +export function setAppLinkHandler(handler: AppLinkHandler): void { + appLinkHandler = handler +} + /** The prompt's card in its own view (sidebar hidden): width, and the shadow's margin around it. */ export const PROMPT_VIEW = { width: 320, margin: 12, inset: 10, estimate: 168 } @@ -418,6 +430,13 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss return kinds.every((k) => allowedNow(profileId, origin, k)) }, request: (profileId, contents, permission, callback, details) => { + if (permission === 'openExternal') { + // A link to another app: refused to the engine, which would launch it unasked; the pages + // lane asks the user and launches it itself (NV-19). + callback(false) + appLinkHandler?.(profileId, contents, details) + return + } if (ALWAYS_ALLOW.has(permission)) return callback(true) if (permission === 'fileSystem') return askFile(profileId, contents, details, callback) const media = 'mediaTypes' in details ? details.mediaTypes : undefined diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts index 6d7cbd5c..d77dd068 100644 --- a/src/main/pages/dialogs.ts +++ b/src/main/pages/dialogs.ts @@ -47,8 +47,8 @@ export interface PageDialogs$ { } /** - * Page dialogs (NV-24): a page's alert, confirm and prompt, the leave-page warning, HTTP sign-in and - * "Page unresponsive", in Orbit's own small dialog over that page's area only. The rest of the + * Page dialogs (NV-24): a page's alert, confirm and prompt, the leave-page warning, HTTP sign-in, + * "Page unresponsive" and "Open Mail?" (NV-19), in Orbit's own small dialog over that page's area only. The rest of the * window stays usable: other tabs, the sidebar, the command bar. A dialog waits while its page is * off screen (another tab is selected, the window is closed to the Library) and shows again when * the page does. Each page's dialogs show one at a time in a transparent view over the page @@ -146,6 +146,7 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { ...(typeof answer.value === 'string' ? { value: answer.value } : {}), ...(typeof answer.user === 'string' ? { user: answer.user } : {}), ...(typeof answer.password === 'string' ? { password: answer.password } : {}), + ...(first.content.check && typeof answer.checked === 'boolean' ? { checked: answer.checked } : {}), }) }) diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts index bbf724f6..7b512d67 100644 --- a/src/main/pages/index.ts +++ b/src/main/pages/index.ts @@ -5,6 +5,7 @@ import type { MainContext } from '../context' import type { TabOps } from '../tabs/ops' import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialogs' import { type LeaveInfo, registerLeave } from './leave' +import { findSystemApp, findTestApp, launchInApp, registerOtherApps } from './other-apps' import { LOAD_TIMEOUT_MS, type PageScreenInfo, registerScreens } from './screens' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitPages. */ @@ -17,13 +18,15 @@ export interface PagesTestHooks { mayDiscard: (tabId: TabId) => boolean /** Every page's error or crash screen (NV-13, NV-14). */ screens: () => PageScreenInfo[] + /** Links Orbit opened in another app, oldest first (NV-19): test runs never start one. */ + launched: string[] } /** * The pages lane: what a page does beyond loading. Page dialogs (NV-24): a page's alert, confirm and * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. Error and * crash screens (NV-13, NV-14): what the page's area shows when its page couldn't load, crashed or - * stopped responding. + * stopped responding. Links that open other apps (NV-19): asked about over the page. */ export function registerPages(ctx: MainContext, ops: TabOps): void { const dialogs = registerDialogs(ctx) @@ -32,6 +35,20 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { // Tests give a navigation's first answer less time (ORBIT_E2E_LOAD_TIMEOUT_MS). const testTimeout = ctx.e2e ? Number(process.env.ORBIT_E2E_LOAD_TIMEOUT_MS) : Number.NaN const screens = registerScreens(ctx, ops, dialogs, testTimeout > 0 ? testTimeout : LOAD_TIMEOUT_MS) + // Test runs record each launch instead, and take the OS's apps from ORBIT_E2E_APPS. + const launched: string[] = [] + registerOtherApps( + ctx, + dialogs, + ctx.e2e + ? { + launch: async (url) => { + launched.push(url) + }, + findApp: findTestApp(process.env.ORBIT_E2E_APPS), + } + : { launch: launchInApp, findApp: findSystemApp }, + ) if (ctx.e2e) { const hooks: PagesTestHooks = { @@ -42,6 +59,7 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { }, mayDiscard: (tabId) => leave.mayDiscard(tabId), screens: () => screens.info(), + launched, } ;(globalThis as unknown as { __orbitPages: PagesTestHooks }).__orbitPages = hooks } diff --git a/src/main/pages/other-apps.ts b/src/main/pages/other-apps.ts new file mode 100644 index 00000000..fbff5709 --- /dev/null +++ b/src/main/pages/other-apps.ts @@ -0,0 +1,312 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { app, BrowserWindow, shell, type WebContents } from 'electron' +import { + appDialog, + appName, + appScheme, + launchableUrl, + noAppMessage, + type OtherApp, + type PageDialogAnswer, +} from '../../shared/pages' +import { isPrivateProfile } from '../../shared/private' +import { permissionOrigin } from '../../shared/site' +import type { ProfileId, TabId } from '../../shared/state' +import { sectionOf } from '../../shared/tree' +import type { MainContext } from '../context' +import { setAppLinkHandler } from '../nav/permissions' +import { activeTabOf } from '../windows' +import type { PageDialogs$ } from './dialogs' + +/** + * Opens a link in the app the OS has for it: the one place Orbit launches another app (NV-19). + * Fails when the OS couldn't. Test runs put a recorder in its place, so no test ever starts an app. + */ +export type LaunchApp = (url: string) => Promise + +/** + * The app the OS opens a link in: its name and icon, `undefined` when the OS can't say (Linux may + * not know), or `null` when the OS says no app opens links of that kind. + */ +export type FindApp = (url: string) => Promise + +/** The real launch: the OS opens the link in its app. */ +export const launchInApp: LaunchApp = (url) => shell.openExternal(launchableUrl(url)) + +/** The app the OS has for a link: macOS and Windows name it and give its icon; Linux names its desktop entry. */ +export const findSystemApp: FindApp = async (url) => { + let name = '' + try { + name = app.getApplicationNameForProtocol(url) + } catch { + // The OS couldn't say. + } + if (process.platform !== 'darwin' && process.platform !== 'win32') { + const shown = appName(name, process.platform) + return shown ? { name: shown } : undefined + } + let icon: string | undefined + try { + const info = await app.getApplicationInfoForProtocol(url) + if (!info.icon.isEmpty()) icon = info.icon.resize({ width: 64, height: 64 }).toDataURL() + name ||= info.name + } catch { + // No app, or none the OS will describe. + } + const shown = appName(name, process.platform) + return shown ? { name: shown, ...(icon ? { icon } : {}) } : null +} + +/** + * The apps a test run's OS has (ORBIT_E2E_APPS: JSON, by scheme, `{ name, icon? }`, or null for "no + * app opens these"); any other scheme's app goes unnamed, as on Linux. + */ +export function findTestApp(spec: string | undefined): FindApp { + let apps: Record = {} + try { + const parsed = JSON.parse(spec ?? '{}') as unknown + if (parsed && typeof parsed === 'object') apps = parsed as Record + } catch { + // None. + } + return async (url) => { + const found = apps[appScheme(url)] + if (found === null) return null + return found && typeof found.name === 'string' ? found : undefined + } +} + +/** + * The sites always allowed to open links of a kind in their app ("Always allow", NV-19), per + * profile, origin and scheme. Kept in their own file; a private profile's stay in memory and go + * with it. + */ +class AlwaysAllowed { + private saved: Record> = {} + private memory: Record> = {} + + constructor( + private file: string, + private isPrivate: (profileId: ProfileId) => boolean, + ) {} + + load(): void { + let raw: unknown + try { + raw = (JSON.parse(readFileSync(this.file, 'utf8')) as { allowed?: unknown }).allowed + } catch { + return + } + if (!raw || typeof raw !== 'object') return + for (const [profileId, origins] of Object.entries(raw as Record)) { + if (!origins || typeof origins !== 'object') continue + for (const [origin, schemes] of Object.entries(origins as Record)) { + if (!Array.isArray(schemes) || permissionOrigin(origin) !== origin) continue + const kept = schemes.filter((s): s is string => typeof s === 'string' && appScheme(`${s}:`) === s) + if (kept.length === 0) continue + const origins = this.saved[profileId] ?? {} + origins[origin] = kept + this.saved[profileId] = origins + } + } + } + + has(profileId: ProfileId, origin: string, scheme: string): boolean { + return this.of(profileId)[profileId]?.[origin]?.includes(scheme) ?? false + } + + add(profileId: ProfileId, origin: string, scheme: string): void { + const all = this.of(profileId) + const origins = all[profileId] ?? {} + const schemes = origins[origin] ?? [] + if (schemes.includes(scheme)) return + origins[origin] = [...schemes, scheme] + all[profileId] = origins + if (!this.isPrivate(profileId)) this.save() + } + + /** A profile that was deleted, or a private session that ended. */ + forget(profileId: ProfileId): void { + if (profileId in this.memory) delete this.memory[profileId] + if (!(profileId in this.saved)) return + delete this.saved[profileId] + this.save() + } + + private of(profileId: ProfileId): Record> { + return this.isPrivate(profileId) ? this.memory : this.saved + } + + /** Written at once: a choice is rare, and small. */ + private save(): void { + try { + mkdirSync(dirname(this.file), { recursive: true }) + const next = `${this.file}.tmp` + writeFileSync(next, JSON.stringify({ allowed: this.saved })) + renameSync(next, this.file) + } catch (err) { + console.error('[pages] saving “Always allow”', err) + } + } +} + +/** Said when the OS couldn't open a link in its app. */ +const NOT_OPENED = 'Couldn’t open the link in another app' + +/** A page that never showed one: nothing committed, nothing to go back to. */ +function neverShowedPage(page: WebContents): boolean { + const url = page.getURL() + return (url === '' || url === 'about:blank') && !page.navigationHistory.canGoBack() +} + +/** + * Links that open other apps (NV-19): `mailto:`, `tel:`, `zoommtg:`… The engine hands each to + * Orbit instead of launching it (`setAppLinkHandler`, nav/permissions.ts), naming the frame that + * followed the link. Orbit asks over that page, in a page dialog: the app with its icon, the site, + * and "Always allow" for a web site; Cancel is the default. Open launches the app through + * `launch`, the one place Orbit starts another app. A site always allowed for a kind of link opens + * it without asking. A page asks one question at a time: a link it follows meanwhile is dropped. + * + * A tab, Peek, Mini Window or pop-up opened only for such a link (`target=_blank`, `window.open`, + * or a redirect there) never shows a page: it closes itself once its navigation ends, and the tab + * it came from is selected again. The engine names the page that opened it as the one asking, so + * the question shows there. When the one asking is that page itself (nothing opened it), it closes + * after the answer. + */ +export function registerOtherApps( + ctx: MainContext, + dialogs: PageDialogs$, + deps: { launch: LaunchApp; findApp: FindApp }, +): void { + const { store, tabs, windows, commands } = ctx + const choices = new AlwaysAllowed(join(ctx.paths.userData, 'orbit-app-links.json'), (id) => + isPrivateProfile(store.state.profiles[id]), + ) + choices.load() + // A deleted profile's choices, and a private session's, go with it. + store.subscribe((next, prev) => { + if (next.profiles === prev.profiles) return + for (const id of Object.keys(prev.profiles)) if (!next.profiles[id]) choices.forget(id) + }) + + /** Pages asking now (by contents id): one question at a time. */ + const asking = new Set() + /** Where each watched page's main frame is going, or went last: the address after any redirect. */ + const going = new Map() + + const launchOnly = (page: WebContents) => + !page.isDestroyed() && !asking.has(page.id) && !!appScheme(going.get(page.id)) && neverShowedPage(page) + + /** Closes a page opened only for another app's link: its tab (selecting its opener again), Peek, Mini Window or pop-up. */ + const closeLaunchOnly = (page: WebContents) => { + if (!launchOnly(page)) return + const tabId = tabs.tabIdFor(page) + if (tabId) { + closeTab(tabId) + return + } + const win = BrowserWindow.fromWebContents(page) + // A pop-up's page is its window's own contents (a browser window's is Orbit's UI). + if (win && !win.isDestroyed() && win.webContents === page) win.close() + } + + const closeTab = (tabId: TabId) => { + const tab = store.state.tabs[tabId] + if (!tab) return + const windowId = windows.windowOfTab(tabId) + if (tab.floating === 'peek') { + if (windowId) void commands.run('peek.close', {}, { windowId }) + return + } + if (tab.floating === 'mini') { + if (windowId) void commands.run('mini.control', { op: 'close' }, { windowId }) + return + } + // Pinned tabs and Favorites stay, whatever they were asked to load. + if (sectionOf(store.state, tabId) !== 'today') return + const wasShown = !!windowId && activeTabOf(store.state, windowId) === tabId + const opener = tab.openerTabId && store.state.tabs[tab.openerTabId] ? tab.openerTabId : undefined + // It never showed a page: no Archive entry, and nothing for Reopen Closed Tab. + store.update((d) => { + const t = d.tabs[tabId] + if (t) t.url = 'about:blank' + }) + // The opener first: closing the selected tab would select a neighbour on the way. + const back = wasShown && opener ? commands.run('tabs.activate', { tabId: opener }, { windowId }) : undefined + void Promise.resolve(back) + .then(() => commands.run('tabs.close', { tabId }, { windowId })) + .catch(() => {}) + } + + const watch = (page: WebContents) => { + const { id } = page + page.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) going.set(id, details.url) + }) + page.on('did-redirect-navigation', (details) => { + if (details.isMainFrame) going.set(id, details.url) + }) + page.on('did-stop-loading', () => { + if (launchOnly(page)) setImmediate(() => closeLaunchOnly(page)) + }) + page.once('destroyed', () => { + going.delete(id) + asking.delete(id) + }) + } + ctx.hooks.tabCreated.push((_tabId, contents) => { + watch(contents) + contents.on('did-create-window', (popup) => watch(popup.webContents)) + }) + + const toastFor = (page: WebContents, message: string) => { + const tabId = page.isDestroyed() ? undefined : tabs.tabIdFor(page) + ctx.toast((tabId && windows.windowOfTab(tabId)) || windows.focusedWindowId(), message) + } + + const launch = (page: WebContents, url: string) => { + deps.launch(url).catch((err: unknown) => { + console.error('[pages] opening another app', err) + toastFor(page, NOT_OPENED) + }) + } + + const ask = async (profileId: ProfileId, page: WebContents, url: string, scheme: string, origin: string | null) => { + const { id } = page + const at = page.getURL() + let answer: PageDialogAnswer | null = null + try { + const found = await deps.findApp(url) + // The page went, or moved on, while the OS was asked. + if (page.isDestroyed() || page.getURL() !== at) return + if (found === null) { + toastFor(page, noAppMessage(scheme)) + return + } + answer = await dialogs.open(page, appDialog(found, origin)) + } finally { + asking.delete(id) + } + if (answer?.ok) { + if (answer.checked === true && origin && /^https?:\/\//i.test(origin)) choices.add(profileId, origin, scheme) + launch(page, url) + } + if (!page.isDestroyed()) closeLaunchOnly(page) + } + + setAppLinkHandler((profileId, page, details) => { + const url = 'externalURL' in details && typeof details.externalURL === 'string' ? details.externalURL : '' + const scheme = appScheme(url) + if (!scheme || page.isDestroyed() || !dialogs.hostable(page)) return + // The site asking is the frame that followed the link (the page's own, or one in it). + const origin = permissionOrigin(details.requestingUrl) + if (origin && /^https?:\/\//i.test(origin) && choices.has(profileId, origin, scheme)) { + launch(page, url) + return + } + if (asking.has(page.id)) return + asking.add(page.id) + void ask(profileId, page, url, scheme, origin).catch((err: unknown) => console.error('[pages] link to an app', err)) + }) +} diff --git a/src/renderer/src/pages/DialogApp.tsx b/src/renderer/src/pages/DialogApp.tsx index cb04e0cd..122b9352 100644 --- a/src/renderer/src/pages/DialogApp.tsx +++ b/src/renderer/src/pages/DialogApp.tsx @@ -16,10 +16,11 @@ import './dialog.css' /** * A page's dialog (NV-24), in a transparent view main keeps over that page only: the page's alert, - * confirm or prompt, "Leave site?", HTTP sign-in, or "Page unresponsive" (NV-14). It takes the - * keyboard while it shows (KS-19): Enter answers with the accepting button, Esc cancels (or gives - * the safe answer: an alert's OK, Wait), and Tab stays inside the card. Main gives the keyboard back - * to the page when it goes (KS-16). + * confirm or prompt, "Leave site?", HTTP sign-in, "Page unresponsive" (NV-14), or "Open Mail?" for + * a link to another app (NV-19). It takes the keyboard while it shows (KS-19): Enter answers with + * the accepting button (with Cancel, where Cancel is the safe answer), Esc cancels (or gives the + * safe answer: an alert's OK, Wait), and Tab stays inside the card. Main gives the keyboard back to + * the page when it goes (KS-16). */ export function DialogApp() { const win = useWindow() @@ -64,6 +65,7 @@ interface Fields { value?: string user?: string password?: string + checked?: boolean } function DialogCard({ @@ -76,20 +78,24 @@ function DialogCard({ const card = useRef(null) const firstField = useRef(null) const okButton = useRef(null) + const cancelButton = useRef(null) const [value, setValue] = useState(content.value ?? '') const [user, setUser] = useState('') const [password, setPassword] = useState('') + const [checked, setChecked] = useState(false) const titleId = `page-dialog-title-${content.id}` const messageId = `page-dialog-message-${content.id}` - // The prompt's text is selected, sign-in starts at the username, the rest at the accepting button. + // The prompt's text is selected, sign-in starts at the username, a dialog whose safe answer is + // Cancel at Cancel, the rest at the accepting button. + const startButton = content.defaultCancel ? cancelButton : okButton useLayoutEffect(() => { const field = firstField.current if (field) { field.focus() if (content.kind === 'prompt') field.select() - } else okButton.current?.focus() - }, [content.kind]) + } else startButton.current?.focus() + }, [content.kind, startButton]) // The view can get the keyboard after the card shows (main focuses it when the page is current). useEffect(() => { @@ -97,15 +103,16 @@ function DialogCard({ if (card.current?.contains(document.activeElement)) return const field = firstField.current if (field) field.focus() - else okButton.current?.focus() + else startButton.current?.focus() } window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) - }, []) + }, [startButton]) const accept = () => { if (content.kind === 'prompt') answer(content, true, { value }) else if (content.kind === 'auth') answer(content, true, { user, password }) + else if (content.check) answer(content, true, { checked }) else answer(content, true) } const cancel = () => answer(content, false) @@ -124,6 +131,13 @@ function DialogCard({ else cancel() return } + // Where Cancel is the safe answer, Enter gives it anywhere but on the accepting button. + if (e.key === 'Enter' && content.defaultCancel && document.activeElement !== okButton.current) { + e.preventDefault() + e.stopPropagation() + cancel() + return + } if (e.key !== 'Tab' || !card.current) return // Tab goes round the card's own controls. const controls = [...card.current.querySelectorAll('input, button')].filter( @@ -147,7 +161,13 @@ function DialogCard({ content.kind === 'confirm' || content.kind === 'leave' || content.kind === 'reload' || - content.kind === 'hang' + content.kind === 'hang' || + content.kind === 'app' + const title = ( +

+ {content.title} +

+ ) return (
-

- {content.title} -

+ {content.icon ? ( +
+ + {title} +
+ ) : ( + title + )} {content.message && (

{content.message} @@ -214,9 +239,26 @@ function DialogCard({ {content.note}

)} + {content.check && ( + + )}
{!content.noCancel && ( - )} diff --git a/src/renderer/src/pages/dialog.css b/src/renderer/src/pages/dialog.css index e70b5960..84ea1313 100644 --- a/src/renderer/src/pages/dialog.css +++ b/src/renderer/src/pages/dialog.css @@ -58,6 +58,20 @@ overflow-wrap: anywhere; } +.page-dialog__head { + display: flex; + align-items: center; + gap: 10px; +} + +/* An app's icon, as the OS gives it (NV-19). */ +.page-dialog__icon { + flex: none; + width: 32px; + height: 32px; + object-fit: contain; +} + .page-dialog__message { margin: 0; max-height: 40vh; @@ -73,6 +87,19 @@ font-size: 12px; } +.page-dialog__check { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; +} + +.page-dialog__check input { + flex: none; + margin: 1px 0 0; + accent-color: var(--accent, #5865d8); +} + .page-dialog__fields { display: flex; flex-direction: column; diff --git a/src/shared/pages.ts b/src/shared/pages.ts index 05a59f5a..4fa31421 100644 --- a/src/shared/pages.ts +++ b/src/shared/pages.ts @@ -1,15 +1,16 @@ /** * What a page asks of the user, shown in Orbit's own dialogs over that page (pages lane, NV-24): * the page's `alert`, `confirm` and `prompt`, "Leave site?" before a page that asks for it goes, - * HTTP sign-in, and "Page unresponsive" (NV-14). And what the page's area shows when the page - * couldn't load or crashed (NV-13, NV-14). + * HTTP sign-in, "Page unresponsive" (NV-14) and "Open Mail?" for a link to another app (NV-19). + * And what the page's area shows when the page couldn't load or crashed (NV-13, NV-14). */ +import { siteLabel } from './site' /** * The kinds of page dialog. `leave` and `reload` are the leave-page warning (`beforeunload`), - * `hang` the page that stopped responding. + * `hang` the page that stopped responding, `app` a link that opens another app. */ -export type PageDialogKind = 'alert' | 'confirm' | 'prompt' | 'leave' | 'reload' | 'auth' | 'hang' +export type PageDialogKind = 'alert' | 'confirm' | 'prompt' | 'leave' | 'reload' | 'auth' | 'hang' | 'app' /** What a dialog view shows. */ export interface PageDialogContent { @@ -31,6 +32,12 @@ export interface PageDialogContent { noCancel?: boolean /** Esc answers with the accepting button (the safe answer), not the other one. */ escapeAccepts?: boolean + /** Cancel is the safe answer: the keyboard starts on it, and Enter anywhere but on the accepting button gives it. */ + defaultCancel?: boolean + /** An app's icon, as the OS gives it (a `data:` URL). */ + icon?: string + /** A checkbox under the message, unchecked at first: "Always allow …". */ + check?: string } /** The user's answer; `ok: false` is Cancel (or Esc, or the dialog going away). */ @@ -42,6 +49,8 @@ export interface PageDialogAnswer { /** auth: what the user typed. */ user?: string password?: string + /** The checkbox, for a dialog with one: checked or not. */ + checked?: boolean } /** A dialog shows at most this much of a page's text (the rest is cut, with an ellipsis). */ @@ -149,6 +158,118 @@ export function hangDialog(pageUrl: string | undefined): Omit gap + c.toUpperCase()) + : entry +} + +/** Printable characters Chromium escapes in a link it hands to another app. */ +const ESCAPED_FOR_APPS = new Set('"<>\\^`{|}') + +/** + * A link as Orbit hands it to the OS: characters an app's handler could take for more than one + * argument are escaped, as Chromium does (spaces and other controls, quotes, angle brackets…). + */ +export function launchableUrl(url: string): string { + let out = '' + for (const c of url) { + const code = c.charCodeAt(0) + const unsafe = code <= 0x20 || code === 0x7f || ESCAPED_FOR_APPS.has(c) + out += unsafe ? `%${code.toString(16).toUpperCase().padStart(2, '0')}` : c + } + return out +} + +/** + * "Open Mail?" (NV-19, Chromium's external-protocol dialog): the app a link opens, with its icon, + * and the site asking (`origin`, the frame that followed the link). Cancel is the default; Open + * launches the app. Only a web site (http or https) may be always allowed to open links of that kind. + */ +export function appDialog(app: OtherApp | undefined, origin: string | null): Omit { + const who = origin ? siteLabel(origin) : 'A website' + const web = !!origin && /^https?:\/\//i.test(origin) + return { + kind: 'app', + title: app ? `Open ${app.name}?` : 'Open this link in another app?', + message: app ? `${who} wants to open this application.` : `${who} wants to open a link in another app.`, + ...(app?.icon ? { icon: app.icon } : {}), + ...(web ? { check: `Always allow ${who} to open links of this type in the associated app` } : {}), + okLabel: app ? `Open ${app.name}` : 'Open link', + defaultCancel: true, + } +} + +/** Said instead of asking when the OS has no app for a link's kind. */ +export function noAppMessage(scheme: string): string { + return `No app on this device opens ${scheme}: links` +} + // ---- Error and crash screens (NV-13, NV-14) ---- /** diff --git a/tests/e2e/pages-apps.spec.ts b/tests/e2e/pages-apps.spec.ts new file mode 100644 index 00000000..abf9f007 --- /dev/null +++ b/tests/e2e/pages-apps.spec.ts @@ -0,0 +1,174 @@ +import { type ElectronApplication, expect, test } from '@playwright/test' +import { activeTabId, launchOrbit, type Orbit, state, tabPage } from './helpers' +import { + APP_ICON, + APPS_ENV, + dialogPage, + launchedLinks, + type PagesFixtures, + pageDialogs, + startPagesFixtures, + waitDialog, +} from './pages-support' +import { newTab, tab, waitTab } from './tabs-support' + +let fx: PagesFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit({ env: APPS_ENV }) +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const MAIL = 'mailto:someone@example.com?subject=Hello%20there' + +/** A tab on the `/apps` fixture (optionally with a frame), loaded, and its page. */ +async function openApps(app: ElectronApplication, frame?: string) { + const path = `/apps${frame ? `?frame=${encodeURIComponent(frame)}` : ''}` + const tabId = await newTab(app, fx.url(path)) + await waitTab(app, tabId, '/apps') + return { tabId, page: await tabPage(app, '/apps') } +} + +const tabIds = async (app: ElectronApplication) => Object.keys((await state(app)).tabs).sort() +const windowCount = (app: ElectronApplication) => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length) +const noDialogs = async (app: ElectronApplication) => (await pageDialogs(app)).length === 0 + +test('[NV-19] a mailto: link asks first, naming the app with its icon and the site; Cancel, the default, leaves the page as it was and opens no tab', async () => { + const { app } = orbit + const { tabId, page } = await openApps(app) + const before = await tabIds(app) + await page.locator('#mail').click() + const asked = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'app' && d.onScreen) + expect(asked).toMatchObject({ title: 'Open Mail?', message: `localhost:${fx.port} wants to open this application.` }) + await expect.poll(async () => (await waitDialog(app, (d) => d.id === asked.id)).focused).toBe(true) + const view = await dialogPage(app, asked.id) + await expect(view.getByTestId('page-dialog-icon')).toHaveAttribute('src', APP_ICON) + await expect(view.getByTestId('page-dialog-ok')).toHaveText('Open Mail') + const check = view.getByTestId('page-dialog-check') + await expect(check).not.toBeChecked() + await expect(view.locator('label', { has: check })).toHaveText( + `Always allow localhost:${fx.port} to open links of this type in the associated app`, + ) + // The keyboard starts on Cancel, and Tab goes round the card: the checkbox, Cancel, Open. + const cancel = view.getByTestId('page-dialog-cancel') + await expect(cancel).toBeFocused() + await view.keyboard.press('Tab') + await expect(view.getByTestId('page-dialog-ok')).toBeFocused() + await view.keyboard.press('Tab') + await expect(check).toBeFocused() + // Space checks it; Enter there still gives Cancel, the safe answer. + await view.keyboard.press('Space') + await expect(check).toBeChecked() + await view.keyboard.press('Enter') + await expect.poll(() => noDialogs(app)).toBe(true) + + // Esc cancels too, and Enter on Cancel. + await page.locator('#mail').click() + const again = await waitDialog(app, (d) => d.kind === 'app' && d.id !== asked.id && d.focused) + await (await dialogPage(app, again.id)).keyboard.press('Escape') + await expect.poll(() => noDialogs(app)).toBe(true) + await page.locator('#mail').click() + const third = await waitDialog(app, (d) => d.kind === 'app' && d.id > again.id && d.focused) + await (await dialogPage(app, third.id)).keyboard.press('Enter') + await expect.poll(() => noDialogs(app)).toBe(true) + + // Nothing opened, nothing was always allowed, and the page is as it was, with no new tab. + expect(await launchedLinks(app)).toEqual([]) + expect(await page.evaluate(() => location.href)).toBe(fx.url('/apps')) + expect(await tab(app, tabId)).toMatchObject({ url: fx.url('/apps'), title: 'Apps', loading: false }) + expect((await tab(app, tabId))?.pendingUrl).toBeUndefined() + expect(await tabIds(app)).toEqual(before) + await page.locator('#mail').click() + await waitDialog(app, (d) => d.kind === 'app' && d.id > third.id && d.onScreen) +}) + +test('[NV-19] Open launches the app; “Always allow” opens that site’s links of that kind without asking, after a restart too, and no other site’s', async () => { + const frameSite = `127.0.0.1:${fx.port}` + const first = await openApps(orbit.app, `http://${frameSite}/app-frame`) + await first.page.locator('#mail').click() + const asked = await waitDialog(orbit.app, (d) => d.kind === 'app' && d.focused) + const view = await dialogPage(orbit.app, asked.id) + await view.getByTestId('page-dialog-check').check() + await view.getByTestId('page-dialog-ok').click() + await expect.poll(() => launchedLinks(orbit.app)).toEqual([MAIL]) + expect(await noDialogs(orbit.app)).toBe(true) + // The same site's next mailto: link opens without asking. + await first.page.locator('#mail').click() + await expect.poll(() => launchedLinks(orbit.app)).toEqual([MAIL, MAIL]) + expect(await noDialogs(orbit.app)).toBe(true) + // Another kind of link still asks; its app has no name here, so the dialog names none. + await first.page.locator('#call').click() + const call = await waitDialog(orbit.app, (d) => d.kind === 'app' && d.focused) + expect(call).toMatchObject({ + title: 'Open this link in another app?', + message: `localhost:${fx.port} wants to open a link in another app.`, + }) + const callView = await dialogPage(orbit.app, call.id) + await expect(callView.getByTestId('page-dialog-ok')).toHaveText('Open link') + await expect(callView.getByTestId('page-dialog-icon')).toHaveCount(0) + await callView.getByTestId('page-dialog-cancel').click() + await expect.poll(() => noDialogs(orbit.app)).toBe(true) + // A frame from another site asks in its own name: the page's choice isn't its. + await first.page.frameLocator('#frame').locator('#frame-mail').click() + const framed = await waitDialog(orbit.app, (d) => d.kind === 'app' && d.focused) + expect(framed.message).toBe(`${frameSite} wants to open this application.`) + await (await dialogPage(orbit.app, framed.id)).keyboard.press('Escape') + await expect.poll(() => noDialogs(orbit.app)).toBe(true) + expect(await launchedLinks(orbit.app)).toEqual([MAIL, MAIL]) + + // After a restart, the choice holds. + const { userData } = orbit + await orbit.app.close() + orbit = await launchOrbit({ userData, env: APPS_ENV }) + const second = await openApps(orbit.app) + await second.page.locator('#mail').click() + await expect.poll(() => launchedLinks(orbit.app)).toEqual([MAIL]) + expect(await noDialogs(orbit.app)).toBe(true) +}) + +test('[NV-19] a tab or pop-up opened only to launch an app closes itself: the question shows over the page it came from', async () => { + const { app } = orbit + const { tabId, page } = await openApps(app) + const before = await tabIds(app) + // A mailto: link that opens a new tab. + await page.locator('#mail-tab').click() + const asked = await waitDialog(app, (d) => d.kind === 'app' && d.tabId === tabId && d.onScreen) + expect(asked.message).toBe(`localhost:${fx.port} wants to open this application.`) + await expect.poll(() => tabIds(app)).toEqual(before) + expect(await activeTabId(app)).toBe(tabId) + await (await dialogPage(app, asked.id)).getByTestId('page-dialog-cancel').click() + await expect.poll(() => noDialogs(app)).toBe(true) + + // A new tab for a web address that redirects to another app's link. + await page.locator('#app-tab').click() + const join = await waitDialog(app, (d) => d.kind === 'app' && d.tabId === tabId && d.id > asked.id && d.onScreen) + expect(join.title).toBe('Open this link in another app?') + await expect.poll(() => tabIds(app)).toEqual(before) + expect(await activeTabId(app)).toBe(tabId) + await (await dialogPage(app, join.id)).getByTestId('page-dialog-ok').click() + await expect.poll(() => launchedLinks(app)).toEqual(['zoommtg://join?confno=123']) + + // A pop-up window for a mailto: link. + const windows = await windowCount(app) + await page.locator('#pop').click() + const pop = await waitDialog(app, (d) => d.kind === 'app' && d.tabId === tabId && d.id > join.id) + await expect.poll(() => windowCount(app)).toBe(windows) + await (await dialogPage(app, pop.id)).getByTestId('page-dialog-cancel').click() + await expect.poll(() => noDialogs(app)).toBe(true) + + // Nothing left behind: no tab, no Archive entry. + expect(await tabIds(app)).toEqual(before) + const { archive } = await state(app) + expect(archive.filter((a) => /^(mailto|zoommtg):|\/to-app/.test(a.url))).toEqual([]) + expect(await launchedLinks(app)).toEqual(['zoommtg://join?confno=123']) +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index 91ada94e..9daa1666 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -48,6 +48,10 @@ const html = (title: string, body: string, head = '') => ` * - `/page/`: a plain page. * - `/opener?to=…`: opens `to` in a pop-up window on a click of `#open`. * - `/upload?to=…`: a form (`#form`) that posts to `to`. + * - `/apps?frame=…`: links to other apps (NV-19): `#mail` (mailto:), `#call` (tel:), `#mail-tab` + * (mailto: in a new tab), `#app-tab` (a new tab that redirects to zoommtg:), `#pop` (a pop-up + * window for mailto:), and an `#frame` loading `frame` (`/app-frame`: a `#frame-mail` link). + * - `/to-app?to=…`: redirects to `to`. * * Every page has an icon (PAGE_ICON). `port` listens on that port only (a server back on the port * it left). @@ -179,6 +183,36 @@ addEventListener('pagehide', () => note('pagehide', prompt('Your name?', 'Ada')) send(html(name, `

${name}

`)) return } + if (u.pathname === '/apps') { + const frame = u.searchParams.get('frame') + send( + html( + 'Apps', + `

Mail +Call +Mail in a new tab +Join +

+${frame ? `` : ''}
+`, + ), + ) + return + } + if (u.pathname === '/app-frame') { + send(html('Frame', 'Mail from the frame')) + return + } + if (u.pathname === '/to-app') { + res.writeHead(302, { + location: u.searchParams.get('to') ?? 'mailto:someone@example.com', + 'cache-control': 'no-store', + }) + res.end() + return + } if (u.pathname === '/upload') { send( html( @@ -290,9 +324,25 @@ type PagesGlobal = { leave: (tabId: string) => LeaveInfo | null mayDiscard: (tabId: string) => boolean screens: () => PageScreenInfo[] + launched: string[] } } +/** The icon test runs' OS gives its mail app (NV-19). */ +export const APP_ICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232a7de1'/%3E%3C/svg%3E" + +/** + * The apps a test run's OS has (ORBIT_E2E_APPS, for launchOrbit's `env`): Mail opens `mailto:` + * links, with an icon; any other kind's app goes unnamed, as on Linux (NV-19). + */ +export const APPS_ENV = { ORBIT_E2E_APPS: JSON.stringify({ mailto: { name: 'Mail', icon: APP_ICON } }) } + +/** Links Orbit opened in another app, oldest first: test runs record each and start none (NV-19). */ +export function launchedLinks(app: ElectronApplication): Promise { + return app.evaluate(() => [...(globalThis as unknown as PagesGlobal).__orbitPages.launched]) +} + /** * Clicks a button whose click closes the page it's in: a Peek's, a Mini Window's or a pop-up's * dialog has a view of its own, which goes with the dialog, sometimes before the click returns. diff --git a/tests/unit/pages-apps.test.ts b/tests/unit/pages-apps.test.ts new file mode 100644 index 00000000..a374c93c --- /dev/null +++ b/tests/unit/pages-apps.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { appDialog, appName, appScheme, launchableUrl, noAppMessage } from '../../src/shared/pages' + +describe('[NV-19] links that open other apps', () => { + it('knows a link to another app by its scheme', () => { + expect(appScheme('mailto:someone@example.com')).toBe('mailto') + expect(appScheme('MAILTO:someone@example.com')).toBe('mailto') + expect(appScheme('tel:+15550100')).toBe('tel') + expect(appScheme('zoommtg://join?confno=123')).toBe('zoommtg') + expect(appScheme('ms-teams:/l/meetup-join')).toBe('ms-teams') + }) + + it('never hands the OS a web address, a local file, a script or a scheme Chromium refuses', () => { + for (const url of [ + 'https://example.com/', + 'http://localhost:3341/', + 'file:///etc/hosts', + 'about:blank', + 'data:text/html,hi', + 'blob:https://example.com/x', + 'javascript:alert(1)', + 'view-source:https://example.com/', + 'orbit://history', + 'chrome://gpu', + 'devtools://devtools/bundled/inspector.html', + 'shell:startup', + 'vbscript:msgbox', + 'ms-msdt:/id', + 'search-ms:query=x', + 'hcp://system', + '', + 'not a link', + ]) { + expect(appScheme(url), url).toBe('') + } + expect(appScheme(undefined)).toBe('') + }) + + it('names a Linux desktop entry as its app, and leaves other systems’ names as they are', () => { + expect(appName('thunderbird.desktop', 'linux')).toBe('Thunderbird') + expect(appName('org.gnome.Evolution.desktop', 'linux')).toBe('Evolution') + expect(appName('mozilla-thunderbird.desktop', 'linux')).toBe('Mozilla Thunderbird') + expect(appName('org.kde.kmail2.desktop', 'linux')).toBe('Kmail2') + expect(appName('Zoom.desktop', 'linux')).toBe('Zoom') + expect(appName('', 'linux')).toBe('') + expect(appName('zoom.us', 'darwin')).toBe('zoom.us') + expect(appName(' Mail ', 'win32')).toBe('Mail') + }) + + it('escapes what an app’s handler could take for more than one argument, as Chromium does', () => { + expect(launchableUrl('mailto:a@b.c?subject=Hi there"x"')).toBe('mailto:a@b.c?subject=Hi%20there%22x%22') + expect(launchableUrl('app:\\b^c`d{e|f}g')).toBe('app:%3Ca%3E%5Cb%5Ec%60d%7Be%7Cf%7Dg') + expect(launchableUrl('tel:+1\n555\t0100')).toBe('tel:+1%0A555%090100') + expect(launchableUrl('mailto:a@b.c?subject=Hello%20there')).toBe('mailto:a@b.c?subject=Hello%20there') + }) + + it('asks “Open Mail?” naming the site, with its icon and Always allow; Cancel is the default', () => { + expect(appDialog({ name: 'Mail', icon: 'data:image/png;base64,AA==' }, 'https://example.com')).toEqual({ + kind: 'app', + title: 'Open Mail?', + message: 'example.com wants to open this application.', + icon: 'data:image/png;base64,AA==', + check: 'Always allow example.com to open links of this type in the associated app', + okLabel: 'Open Mail', + defaultCancel: true, + }) + expect(appDialog({ name: 'Zoom' }, 'http://localhost:3341').check).toBe( + 'Always allow localhost:3341 to open links of this type in the associated app', + ) + }) + + it('names no app the OS can’t name', () => { + expect(appDialog(undefined, 'https://example.com')).toMatchObject({ + title: 'Open this link in another app?', + message: 'example.com wants to open a link in another app.', + okLabel: 'Open link', + }) + }) + + it('offers Always allow to a web site only', () => { + const file = appDialog({ name: 'Mail' }, 'file://') + expect(file.message).toBe('This file wants to open this application.') + expect(file.check).toBeUndefined() + const unnamed = appDialog({ name: 'Mail' }, null) + expect(unnamed.message).toBe('A website wants to open this application.') + expect(unnamed.check).toBeUndefined() + }) + + it('says so when no app opens a kind of link', () => { + expect(noAppMessage('zoommtg')).toBe('No app on this device opens zoommtg: links') + }) +}) From 21869ca9d6cdfd46e25f7e8e20a64f6db759d1b7 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:25:31 +0000 Subject: [PATCH 05/29] feat(pages): window full screen with the sidebar hidden and Esc that prefers websites (NV-20) Toggle Full Screen (Cmd+Ctrl+F; F11 and Alt+Shift+F on Windows and Linux; View menu on macOS; the command bar) hides the sidebar, which the left edge brings in over the page, and docks it again on leaving unless the user chose meanwhile. Esc in a page goes to the page first; a second press within a second, or holding it, leaves full screen. Exit Full Screen prefers websites by default and can prefer Orbit in Settings -> Shortcuts (KS-02, D68). A video's own full screen stays Chromium's. The Esc that leaves does not stop a load. Other lanes' tests updated for the hidden sidebar: SB-19 (chrome.spec) and KS-20 (keys.spec); keymap audit rows added for the two actions. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 20 +- docs/ARCHITECTURE.md | 1 + docs/feature-status.json | 15 +- src/main/menu.ts | 3 +- src/main/nav/index.ts | 7 +- src/main/pages/covers.ts | 15 +- src/main/pages/fullscreen.ts | 233 +++++++++++++++++ src/main/pages/index.ts | 12 +- src/main/pages/screens.ts | 7 + src/shared/actions.ts | 21 ++ tests/e2e/chrome.spec.ts | 4 +- tests/e2e/keys.spec.ts | 2 +- tests/e2e/pages-fullscreen.spec.ts | 380 ++++++++++++++++++++++++++++ tests/e2e/pages-support.ts | 29 +++ tests/unit/keymap-defaults.test.ts | 7 +- tests/unit/pages-fullscreen.test.ts | 88 +++++++ 16 files changed, 813 insertions(+), 31 deletions(-) create mode 100644 src/main/pages/fullscreen.ts create mode 100644 tests/e2e/pages-fullscreen.spec.ts create mode 100644 tests/unit/pages-fullscreen.test.ts diff --git a/FEATURES.md b/FEATURES.md index c241f575..8f62bae4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 240 built (87 partial) · 333 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 241 built (87 partial) · 333 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 140 | 168 | -| P1 | 219 | 85 | 148 | +| P1 | 219 | 86 | 148 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -39,7 +39,7 @@ Spec: `spec/sidebar.md` | SB-17 | Optional top toolbar | P1 | – | – | – | | | SB-18 | Developer-mode tab chrome | P2 | – | – | – | | | SB-19 | Inset rounded page frame ("card" look) | P0 | ✅ | ✅ e2e | – | 8 px margins (6 px with the sidebar hidden), 10 px corners on every page and pane while loading, hidden or floating; fine edge and shadow; native and video full screen drop the frame; the card and the page view take the page background colour so resizes show no white | -| SB-20 | Top-of-window behaviour and window dragging | P1 | 🟡 partial | – | – | macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; the rest of the hidden-sidebar chrome is not built | +| SB-20 | Top-of-window behaviour and window dragging | P1 | 🟡 partial | – | – | macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; native full screen hides the sidebar with hover reveal, ⌘N there opens a full-screen window, and Esc in a page goes to the page first, held or pressed twice leaving full screen (NV-20); the rest of the hidden-sidebar chrome is not built | | SB-21 | Dim the sidebar when the window is inactive | P1 | ✅ | ✅ e2e | – | an inactive window's sidebar (and its floating card) fades to 84% over 150 ms on blur and is back at once on focus; no fade with reduced motion | | SB-22 | Keyboard focus and navigation inside the sidebar | P2 | ✅ | ✅ e2e | – | Cmd/Ctrl+Shift+E focuses the sidebar; arrows, Enter, Escape, F2 | | SB-23 | Scrolling, auto-reveal of the active tab and overflow indicator | P1 | ✅ | ✅ e2e | – | selected tab scrolled into view (smooth, then settles); pill points to it while it is out of view; rows use content-visibility | @@ -247,7 +247,7 @@ Spec: `spec/navigation.md` | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | | NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | -| NV-20 | Full screen | P1 | – | ✅ e2e | – | | +| NV-20 | Full screen | P1 | ✅ | ✅ e2e+unit | – | ⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7) | | NV-21 | Save page and view source | P2 | – | – | – | | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | @@ -347,13 +347,13 @@ Spec: `spec/shortcuts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | KS-01 | One keymap, remapping and persistence | P0 | ✅ | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now | -| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | -| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving window full screen (NV-20) prefers websites by default: the page gets the first press, and pressing it again within a second or holding it leaves; Settings → Shortcuts' Exit Full Screen row sets it, and with prefer Orbit one Esc leaves at once (D68); a page's Esc gets no toast, as nothing asks the page; Esc leaving a video's own full screen keeps Chromium's rule; nothing holds Cmd+E yet | +| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 and Alt+Shift+F for full screen (NV-20); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion | | KS-04 | Command bar and address | P0 | ✅ | ✅ e2e+unit | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | | KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | | KS-06 | Switching tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05) | | KS-07 | Spaces | P0 | ✅ | ✅ e2e+unit | – | Space shortcuts wired through the action table | -| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items | +| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print, save page, reader mode and the rest come with their NV items | | KS-09 | Split view | P0 | ✅ | ✅ e2e+unit | – | ⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page | | KS-10 | Peek and Mini Window | P1 | ✅ | ✅ e2e+unit | – | ⌘O / Ctrl+O expands a Peek into a selected Today tab (PK-07); Esc and ⌘W / Ctrl+W close it (PK-09, Esc fixed); ⌘⇧T and ⌘Z reopen it (PK-10); ⌘⌥N / Ctrl+Alt+N opens a Mini Window (MW-06); in a Mini Window ⌘O moves its page into the most recent Space (MW-05), ⌘⌥O (also ⌘⇧O) / Ctrl+Alt+O offers the Spaces, ⌘W closes it (MW-09). All from the one keymap and remappable but Esc; the tests press the keys (peek, mini-window and keys specs) | | KS-11 | Media | P1 | 🟡 partial | ✅ e2e+unit | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | @@ -361,10 +361,10 @@ Spec: `spec/shortcuts.md` | KS-13 | Developer tools | P1 | 🟡 partial | ✅ e2e+unit | – | ⌥⌘I / F12 (Ctrl+Shift+I / F12) open or close DevTools (DT-01, F12 in e2e), ⌥⌘J (Ctrl+Shift+J) the console, ⌥⌘C the element picker (no default on Windows/Linux, where Ctrl+Shift+C is Copy URL), ⌥⌘U (Ctrl+U) View Source, ⌘⇧R (Ctrl+Shift+R) hard reload: each checked against the spec table per platform, and none fixed; a remapped DevTools key moves in the router and in DevTools' own page, and the picker can take ⇧⌘C from Copy URL (unit tests); ⇧⌘M / Ctrl+Shift+M stays DevTools' device toolbar key (no action has it). A remap reaches DevTools already open only once they open again (devtools lane). Not built: developer mode for a site (DT-06; its ⌃D is a plain ⌃-letter, which macOS keeps for the caret, D60) and the task manager (DT-11, Shift+Esc) | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | | KS-15 | Standard app and window keys | P1 | ✅ | ✅ e2e+unit | – | macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac | -| KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router) | +| KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7) | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | 🟡 partial | ✅ e2e | – | each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click twice on a video (MD-02). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09) | | KS-18 | Shortcut hints everywhere | P1 | ✅ | ✅ e2e+unit | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label | -| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e+unit | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there. Not built: leaving full screen with Esc held or pressed twice (NV-20), the print dialog (NV-07) | +| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e+unit | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20). Not built: the print dialog (NV-07) | | KS-20 | Shortcuts in special windows | P1 | 🟡 partial | ✅ e2e+unit | – | private window: the same keys, but the actions that need Spaces, pinned tabs, Favorites, folders or the Archive (⌘D, ⌃1–9 / Alt+1–9, ⌘⌥← / ⌘⌥→, ⌘⇧K Clear, Favorite, New Folder, View Archive, every Space action) do nothing there, from a key, a menu or the command bar, and a toast says “Not available in a private window”; its sidebar has no Clear button; tab keys work. Mini Window: ⌘W closes it, ⌘O / ⌘⌥O move or offer to move its page (KS-10), ⌘L edits its address with the keyboard in the field and Esc gives the keyboard back to its page, ⌘R, zoom and print act on its page, sidebar and Space keys do nothing; ⌘Y / Ctrl+H History, the Library and Help → Keyboard Shortcuts open in the browser window behind it. Full screen: every key works with the sidebar hidden; ⌘1–9 and ⌘⌥↑ / ⌘⌥↓ switch tabs without bringing it out. Not built: ⌘F in a Mini Window (it has no find bar, Mini Window lane), the blank window (TB-17), extension keys in private windows (EX-06, EX-10) | ## Interaction feel and motion targets (prefix FL) @@ -542,7 +542,7 @@ Spec: `spec/settings.md` | ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | – | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | | ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | – | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | | ST-07 | Per-profile: download location, new-document app, data shortcuts | P1 | – | – | – | | -| ST-08 | Shortcuts section (remapping) | P1 | 🟡 partial | ✅ e2e+unit | – | Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device. Not built: the Esc → exit full screen row, which comes when full screen (NV-20) registers that as an action | +| ST-08 | Shortcuts section (remapping) | P1 | 🟡 partial | ✅ e2e+unit | – | Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device; Exit Full Screen (Esc in full screen, NV-20) has its row with no keys of its own, preferring websites by default, and its priority sets Esc's | | ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | – | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | | ST-10 | Advanced section | P1 | 🟡 partial | ✅ e2e+unit | – | tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet | | ST-11 | Appearance section | P1 | 🟡 partial | ✅ e2e | – | Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4c51defa..d80a73d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,6 +55,7 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) Electron runs no isolated-world script while a main frame loads, so a page on its way can't answer; Orbit takes the keys at once then. - **Focus mirror** (`page-focus.ts`, D60). Main knows before a press where the keyboard is in each tab's page: a script in the page's own world says whether a text field, the page, a frame inside it, or nothing there has it, and says it again when focus moves. The frame inside the page that has the keyboard (`contents.focusedFrame`) is followed too, and notes the presses Orbit may ask about. So Back and Forward run at once outside text fields, however busy the page, and a press in a frame is judged by that frame. - **Esc on a loading page** (KS-19, D59, `escape.ts`): the page gets Esc first. The page preload says whether a handler used it, and Orbit stops the load only if none did. +- **Esc in full screen** (NV-20, KS-02, `src/main/pages/fullscreen.ts`). Esc is no binding: an open overlay must take it first (KS-19). The pages lane hears each Esc before the rest of main does (`prependListener` on each tab page's `before-input-event`, and on its error and crash screens) and follows the priority of the hidden Exit Full Screen action. With prefer websites (the default) the page gets the press; a second press within `AGAIN_MS`, or the first repeat of a held one, leaves full screen and is kept from the page (`preventDefault`), which the Esc-stops-a-load hook (`src/main/nav/index.ts`) reads as "not the page's". With prefer Orbit the first press leaves. Nothing asks the page what it did with the press: a single Esc never leaves full screen. - **No web contents takes the keyboard on navigation** (`KEEP_KEYBOARD`, `focus.ts`, KS-16). Electron focuses a web contents each time its page commits a navigation unless `focusOnNavigation` is off, even a page behind another tab. Every web contents Orbit makes for its windows spreads `KEEP_KEYBOARD` into its `webPreferences`: tab views, Orbit's own views, and pop-ups through `overrideBrowserWindowOptions`. Orbit gives a page the keyboard itself when the user selects, opens or clicks it. A lane that makes or shows a page does the same. ## 4. Tabs: lifecycle, sleeping, layout diff --git a/docs/feature-status.json b/docs/feature-status.json index 49adadad..13d21f9c 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -11,7 +11,7 @@ "SB-12": {"built": "yes", "note": "left 6 px edge brings a floating card in its own view over the page, which never moves; main decides whether the pointer moved (not from where it was when the sidebar hid, so Cmd+S at the edge doesn't) and reads it for 300 ms after it leaves a page or the shell, so a flick to the edge (or onto the Linux resize border) brings the card; 0 ms dwell; stays while hovered (16 px band), while a field in it is edited, the command bar is open or a native menu is up in the window (one Chromium refuses counts as closed); goes 400 ms after the pointer moves on, at once on a click into the page (the click reaches the page); not over full-screen video; stays out while the pointer is on another window over it, until it comes back (review A-F1)"}, "SB-14": {"built": "yes", "note": "drag the edge 1:1 with a live width, clamped 180-420 with no drift; double-click resets to 248; 40 px past the minimum hides (width kept); saved per window"}, "SB-19": {"built": "yes", "note": "8 px margins (6 px with the sidebar hidden), 10 px corners on every page and pane while loading, hidden or floating; fine edge and shadow; native and video full screen drop the frame; the card and the page view take the page background colour so resizes show no white"}, - "SB-20": {"built": "partial", "note": "macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; the rest of the hidden-sidebar chrome is not built"}, + "SB-20": {"built": "partial", "note": "macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; native full screen hides the sidebar with hover reveal, ⌘N there opens a full-screen window, and Esc in a page goes to the page first, held or pressed twice leaving full screen (NV-20); the rest of the hidden-sidebar chrome is not built"}, "SB-21": {"built": "yes", "note": "an inactive window's sidebar (and its floating card) fades to 84% over 150 ms on blur and is back at once on focus; no fade with reduced motion"}, "SB-22": {"built": "yes", "note": "Cmd/Ctrl+Shift+E focuses the sidebar; arrows, Enter, Escape, F2"}, "SB-23": {"built": "yes", "note": "selected tab scrolled into view (smooth, then settles); pill points to it while it is out of view; rows use content-visibility"}, @@ -138,13 +138,13 @@ "CB-23": {"built": "partial", "note": "private (non-persistent) profiles: no history/archive rows, and their tabs, folders and Spaces never show in normal windows; ⌘L on an empty Space acts like ⌘T; split-pane and Mini Window variants not done"}, "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, - "KS-19": {"built": "partial", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there. Not built: leaving full screen with Esc held or pressed twice (NV-20), the print dialog (NV-07)"}, - "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router)"}, + "KS-19": {"built": "partial", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20). Not built: the print dialog (NV-07)"}, + "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7)"}, "KS-17": {"built": "partial", "note": "each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click twice on a video (MD-02). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09)"}, "KS-01": {"built": "yes", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now"}, - "KS-02": {"built": "partial", "note": "who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, + "KS-02": {"built": "partial", "note": "who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving window full screen (NV-20) prefers websites by default: the page gets the first press, and pressing it again within a second or holding it leaves; Settings → Shortcuts' Exit Full Screen row sets it, and with prefer Orbit one Esc leaves at once (D68); a page's Esc gets no toast, as nothing asks the page; Esc leaving a video's own full screen keeps Chromium's rule; nothing holds Cmd+E yet"}, "KS-18": {"built": "yes", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label"}, - "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion"}, + "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 and Alt+Shift+F for full screen (NV-20); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion"}, "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, "KS-06": {"built": "yes", "note": "⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05)"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, @@ -203,9 +203,10 @@ "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, + "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it) or its window (Cancel keeps it), a pop-up's window; a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep keep a tab whose page warns, or waits on its dialog, until the user closes it; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, - "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items"}, + "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print, save page, reader mode and the rest come with their NV items"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, "FL-23": {"built": "yes", "note": "after 150 ms, a 48x4 theme-accent pill sweeping on a 1.2 s cycle (a pulse with reduced motion); on load end it fills (80 ms) and fades (150 ms), gone 180 ms after; never moves page content"}, @@ -297,7 +298,7 @@ "ST-20": {"built": "partial", "note": "per-profile rows carry the profile's name, the download location (DL-07) too; passwords and site permissions don't exist yet"}, "AP-01": {"built": "yes", "note": "Light / Dark / Match system in Settings and the Space theme picker; about 200 ms cross-fade, no flash"}, "OB-02": {"built": "yes", "note": "audit and e2e: no account UI anywhere, onboarding included; a first run sends nothing off the computer, with or without onboarding, except Chromium's spell-check dictionary download (see tests)"}, - "ST-08": {"built": "partial", "note": "Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device. Not built: the Esc → exit full screen row, which comes when full screen (NV-20) registers that as an action"}, + "ST-08": {"built": "partial", "note": "Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device; Exit Full Screen (Esc in full screen, NV-20) has its row with no keys of its own, preferring websites by default, and its priority sets Esc's"}, "ST-09": {"built": "yes", "note": "links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options"}, "ST-10": {"built": "partial", "note": "tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet"}, "ST-12": {"built": "partial", "note": "privacy promise (Orbit sends nothing); Clear browsing data per profile (⌘⇧⌫ / Ctrl+Shift+Delete): history by time range; cookies and site data, and the cache, always from all time (Electron has no range), so they start ticked only for All time; Secure DNS (Automatic, Off, Cloudflare, Google, Quad9); graphics acceleration lives in Advanced. No HTTPS-first or Safe Browsing (not in Electron), no per-site clearing here"}, diff --git a/src/main/menu.ts b/src/main/menu.ts index 9783464f..0832189b 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -125,7 +125,8 @@ export function installMenu(ctx: MainContext): void { item('page.zoomIn'), item('page.zoomOut'), { type: 'separator' }, - { role: 'togglefullscreen' }, + // Orbit's own (NV-20), so the menu shows its keys as the user set them. + item('window.toggleFullScreen'), { type: 'separator' }, // View → Developer (DT-01, DT-02, DT-04). { diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index dd302ddf..f0209a6d 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -348,9 +348,10 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // Esc stops a loading page (NV-04), unless the page uses it: the page gets the key first, and // Orbit stops the load once the page's preload says no handler took it (D59, keys lane). An // open overlay (command bar, find bar, Peek) holds keyboard focus and takes Esc before the page - // could see it (KS-19). - contents.on('before-input-event', (_event, input) => { - if (input.type !== 'keyDown' || input.key !== 'Escape') return + // could see it (KS-19). An Esc that left full screen (NV-20, src/main/pages/fullscreen.ts) is + // taken from the page: it stops nothing. + contents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.key !== 'Escape' || event.defaultPrevented) return if (input.meta || input.control || input.alt || input.shift) return if (!store.state.tabs[tabId]?.loading) return const windowId = windows.windowOfTab(tabId) diff --git a/src/main/pages/covers.ts b/src/main/pages/covers.ts index 9dcb6b20..5ccd7686 100644 --- a/src/main/pages/covers.ts +++ b/src/main/pages/covers.ts @@ -77,6 +77,8 @@ export function createCovers( * `focused` (what has it now). By default it always does, as a page's own dialog does. */ mayTakeKeyboard?: (page: WebContents, focused: WebContents | null) => boolean + /** A key pressed in the view (`view`) while it shows over `page`. */ + onKey?: (page: WebContents, view: WebContents, event: Electron.Event, input: Electron.Input) => void }, ): PageCovers { const { store, tabs, windows } = ctx @@ -154,8 +156,15 @@ export function createCovers( contents.setWindowOpenHandler(() => ({ action: 'deny' })) } - const watchLoads = (view: WebContentsView) => { + /** Follows a view: its loads, its crashes, and keys pressed in it. */ + const watchView = (view: WebContentsView) => { const contents = viewContents(view) + const { onKey } = opts + if (contents && onKey) { + contents.on('before-input-event', (event, input) => { + for (const c of covers.values()) if (c.view === view && c.onScreen) onKey(c.page, contents, event, input) + }) + } contents?.on('did-finish-load', () => { for (const c of covers.values()) { if (c.view !== view) continue @@ -184,7 +193,7 @@ export function createCovers( const view = windows.createUiView(windowId, opts.view) if (!view) return undefined pool.add(view) - watchLoads(view) + watchView(view) windows.get(windowId)?.once('closed', () => { for (const v of pool) inUse.delete(v) pools.delete(windowId) @@ -206,7 +215,7 @@ export function createCovers( contents.once('render-process-gone', (_event, details) => { if (details.reason !== 'clean-exit' && !contents.isDestroyed()) loadRenderer(contents, opts.view) }) - watchLoads(view) + watchView(view) loadRenderer(contents, opts.view) host.win.once('closed', () => viewContents(view)?.close()) return view diff --git a/src/main/pages/fullscreen.ts b/src/main/pages/fullscreen.ts new file mode 100644 index 00000000..c690b91a --- /dev/null +++ b/src/main/pages/fullscreen.ts @@ -0,0 +1,233 @@ +import type { BrowserWindow, WebContents } from 'electron' +import { ACTIONS_BY_ID, type ActionDef, priorityFor } from '../../shared/actions' +import type { ShortcutPriority } from '../../shared/commands/keys' +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { AGAIN_MS } from '../keys/router' + +/** Isolated world in which main asks a page to leave its own full screen (a video's). */ +const FULLSCREEN_WORLD = 1030 +/** How long main waits for a page to leave its own full screen before it goes on without it. */ +const PAGE_EXIT_WAIT_MS = 1000 + +/** What one page's Esc presses did: what the page got, and what Orbit took. */ +export interface EscapeMemory { + /** When the last press the page got came (main's clock). */ + toPage?: number + /** Who got the press held now: its release goes the same way. */ + down?: 'page' | 'orbit' + /** The press held now left full screen: the rest of its repeats go nowhere. */ + left?: boolean +} + +export interface EscapePress { + type: 'down' | 'up' + /** The keyboard's repeat of a held key. */ + repeat: boolean + /** When it came (main's clock). */ + now: number + /** The window is in full screen, and nothing takes Esc before the page (an overlay, a video's own full screen). */ + fullScreen: boolean +} + +/** 'page': the page gets the key; 'exit': Orbit takes it and leaves full screen; 'swallow': Orbit takes it. */ +export type EscapeVerdict = 'page' | 'exit' | 'swallow' + +/** + * Esc in a page while its window is in full screen (NV-20, KS-02). Pure: takes what the page's + * presses did before and this press, and gives what to do and what to remember. + * - Prefer websites (the default): the page gets the press, so a web app's own Esc never ends full + * screen. Pressing Esc again within AGAIN_MS of the press the page got, or holding it (its first + * repeat), leaves full screen, and the page doesn't get that press. + * - Prefer Orbit: a press leaves full screen at once, and the page never gets it. + * A press Orbit took is Orbit's to its release, and so are the repeats of a held press once it left + * full screen. + */ +export function escapeInFullScreen( + memory: EscapeMemory, + press: EscapePress, + priority: ShortcutPriority, +): { verdict: EscapeVerdict; memory: EscapeMemory } { + if (press.type === 'up') { + return { verdict: memory.down === 'orbit' ? 'swallow' : 'page', memory: { toPage: memory.toPage } } + } + if (press.repeat) { + if (memory.left) return { verdict: 'swallow', memory } + // Held: the page got the press, and its first repeat leaves. A press the page never saw (the + // engine took it to end a video's full screen) leaves nothing more. + if (press.fullScreen && memory.down === 'page') return { verdict: 'exit', memory: { ...memory, left: true } } + return { verdict: 'page', memory } + } + if (!press.fullScreen) return { verdict: 'page', memory: { down: 'page' } } + const again = memory.toPage !== undefined && press.now - memory.toPage <= AGAIN_MS + if (priority === 'orbit' || again) return { verdict: 'exit', memory: { down: 'orbit', left: true } } + return { verdict: 'page', memory: { toPage: press.now, down: 'page' } } +} + +/** A plain Esc: no modifier held. */ +const plainEscape = (input: Electron.Input) => + input.key === 'Escape' && !input.meta && !input.control && !input.alt && !input.shift + +export interface FullScreen$ { + /** + * Esc pressed in `contents` (a tab's page, or Orbit's own view over one) of window `windowId`: + * follows the full-screen rule, and takes the press from the page (`event.preventDefault()`) + * when Orbit uses it. + */ + onKey(windowId: WindowId | undefined, contents: WebContents, event: Electron.Event, input: Electron.Input): void + info(windowId: WindowId): FullScreenInfo +} + +/** A window's full screen as tests see it. */ +export interface FullScreenInfo { + /** The window is in full screen (the OS's, or one a page asked for). */ + fullScreen: boolean + /** Orbit hid the window's sidebar for its full screen, and docks it again when that ends. */ + sidebarHidden: boolean +} + +/** + * Window full screen (NV-20): Toggle Full Screen (⌃⌘F; F11 and Alt+Shift+F elsewhere; the command + * bar; View → Toggle Full Screen on macOS) and Exit Full Screen. + * + * In full screen the sidebar hides, and comes in over the page from the window's left edge (SB-12, + * src/main/nav/sidebar-reveal.ts); the loading indicator stays (SB-28). Orbit docks the sidebar + * again when full screen ends, unless the user chose meanwhile (⌘S). A page's own full screen (a + * video's) is the page's: the sidebar is left as it is, and Esc there is Chromium's. + * + * Esc follows `escapeInFullScreen`, with the priority of Exit Full Screen (Settings → Shortcuts, + * KS-02). Only a main or private window goes full screen this way; a Mini Window doesn't. + */ +export function registerFullScreen(ctx: MainContext): FullScreen$ { + const { store, windows, tabs, actions } = ctx + const exitAction = ACTIONS_BY_ID['window.exitFullScreen'] as ActionDef + /** Windows whose sidebar Orbit hid for full screen. */ + const hiddenFor = new Set() + /** Pages in their own full screen. */ + const inPageFullScreen = new Set() + /** What each page's Esc presses did, by contents id. */ + const escapes = new Map() + + const fullScreenWindow = (windowId: WindowId | undefined): BrowserWindow | undefined => { + const win = windowId ? windows.get(windowId) : undefined + const kind = windowId ? store.state.windows[windowId]?.kind : undefined + return win && !win.isDestroyed() && (kind === 'main' || kind === 'private') ? win : undefined + } + + /** The window's page in its own full screen, if one is. */ + const pageInFullScreen = (windowId: WindowId): WebContents | undefined => { + for (const page of inPageFullScreen) { + const tabId = page.isDestroyed() ? undefined : tabs.tabIdFor(page) + if (tabId && windows.windowOfTab(tabId) === windowId) return page + } + return undefined + } + + /** Asks a page to leave its own full screen; resolves once it has, or after PAGE_EXIT_WAIT_MS. */ + const exitPageFullScreen = (page: WebContents): Promise => + Promise.race([ + page + .executeJavaScriptInIsolatedWorld(FULLSCREEN_WORLD, [ + { code: 'document.fullscreenElement ? document.exitFullscreen().then(() => true, () => false) : false' }, + ]) + .catch(() => false), + new Promise((resolve) => setTimeout(resolve, PAGE_EXIT_WAIT_MS)), + ]) + + /** Leaves full screen: a page's own first (it would stay over the window), then the window's. */ + const leave = async (windowId: WindowId) => { + const page = pageInFullScreen(windowId) + if (page) await exitPageFullScreen(page) + const win = fullScreenWindow(windowId) + if (win?.isFullScreen()) win.setFullScreen(false) + } + + actions.register('window.toggleFullScreen', (c) => { + // A held key toggles once. + if (c.repeat) return + const windowId = c.windowId ?? windows.focusedWindowId() + const win = fullScreenWindow(windowId) + if (!win || !windowId) return + if (win.isFullScreen()) void leave(windowId) + else win.setFullScreen(true) + }) + actions.register('window.exitFullScreen', (c) => { + const windowId = c.windowId ?? windows.focusedWindowId() + if (windowId && fullScreenWindow(windowId)?.isFullScreen()) void leave(windowId) + }) + + // The sidebar in full screen. + const setVisible = (windowId: WindowId, visible: boolean) => + store.update((d) => { + const w = d.windows[windowId] + if (!w || w.sidebar.visible === visible) return + w.sidebar.visible = visible + w.sidebar.peeking = false + }) + const entered = (windowId: WindowId) => { + const win = fullScreenWindow(windowId) + // A page's own full screen (a video's) covers the window by itself: the sidebar stays as it is. + if (!win?.isFullScreen() || windows.inHtmlFullscreen(windowId)) return + if (!store.state.windows[windowId]?.sidebar.visible) return + hiddenFor.add(windowId) + setVisible(windowId, false) + } + const left = (windowId: WindowId) => { + if (hiddenFor.delete(windowId)) setVisible(windowId, true) + } + windows.onWindowCreated((windowId, win) => { + win.on('enter-full-screen', () => { + // A page asking for full screen says so just after the window goes (Linux, Windows) or + // before (macOS): look once both have. + setImmediate(() => entered(windowId)) + }) + win.on('leave-full-screen', () => left(windowId)) + // A window closed in full screen keeps its sidebar for the next time it opens. + win.once('closed', () => left(windowId)) + }) + // The user docked the sidebar in full screen (⌘S): theirs from now on. + store.subscribe((next) => { + for (const windowId of hiddenFor) if (next.windows[windowId]?.sidebar.visible !== false) hiddenFor.delete(windowId) + }) + + ctx.hooks.tabCreated.push((_tabId, contents) => { + const { id } = contents + contents.on('enter-html-full-screen', () => inPageFullScreen.add(contents)) + contents.on('leave-html-full-screen', () => inPageFullScreen.delete(contents)) + // Before the page's other Esc handling (stopping a load, NV-04), which skips a press taken here. + contents.prependListener('before-input-event', (event: Electron.Event, input: Electron.Input) => { + const tabId = tabs.tabIdFor(contents) + onKey(tabId ? windows.windowOfTab(tabId) : undefined, contents, event, input) + }) + contents.once('destroyed', () => { + inPageFullScreen.delete(contents) + escapes.delete(id) + }) + }) + + const onKey: FullScreen$['onKey'] = (windowId, contents, event, input) => { + if (input.key !== 'Escape' || contents.isDestroyed()) return + const type = input.type === 'keyDown' ? 'down' : input.type === 'keyUp' ? 'up' : undefined + if (!type || (type === 'down' && !plainEscape(input))) return + const win = fullScreenWindow(windowId) + const w = windowId ? store.state.windows[windowId] : undefined + // An open overlay (the command bar, a Peek) and a page's own full screen take Esc first (KS-19). + const fullScreen = + !!win?.isFullScreen() && !!windowId && w?.overlay.kind === 'none' && !windows.inHtmlFullscreen(windowId) + const priority = priorityFor(exitAction, process.platform, store.state.settings.shortcutPriority) + const memory = escapes.get(contents.id) ?? {} + const next = escapeInFullScreen(memory, { type, repeat: input.isAutoRepeat, now: Date.now(), fullScreen }, priority) + escapes.set(contents.id, next.memory) + if (next.verdict === 'page') return + event.preventDefault() + if (next.verdict === 'exit' && windowId) void leave(windowId) + } + + return { + onKey, + info: (windowId) => ({ + fullScreen: !!windows.get(windowId)?.isFullScreen(), + sidebarHidden: hiddenFor.has(windowId), + }), + } +} diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts index 7b512d67..bf4faaa7 100644 --- a/src/main/pages/index.ts +++ b/src/main/pages/index.ts @@ -1,9 +1,10 @@ import { app } from 'electron' import { authDialog } from '../../shared/pages' -import type { TabId } from '../../shared/state' +import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { TabOps } from '../tabs/ops' import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialogs' +import { type FullScreenInfo, registerFullScreen } from './fullscreen' import { type LeaveInfo, registerLeave } from './leave' import { findSystemApp, findTestApp, launchInApp, registerOtherApps } from './other-apps' import { LOAD_TIMEOUT_MS, type PageScreenInfo, registerScreens } from './screens' @@ -20,13 +21,16 @@ export interface PagesTestHooks { screens: () => PageScreenInfo[] /** Links Orbit opened in another app, oldest first (NV-19): test runs never start one. */ launched: string[] + /** A window's full screen (NV-20). */ + fullScreen: (windowId: WindowId) => FullScreenInfo } /** * The pages lane: what a page does beyond loading. Page dialogs (NV-24): a page's alert, confirm and * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. Error and * crash screens (NV-13, NV-14): what the page's area shows when its page couldn't load, crashed or - * stopped responding. Links that open other apps (NV-19): asked about over the page. + * stopped responding. Links that open other apps (NV-19): asked about over the page. Window full + * screen (NV-20): the sidebar hidden, and Esc in the page. */ export function registerPages(ctx: MainContext, ops: TabOps): void { const dialogs = registerDialogs(ctx) @@ -34,7 +38,8 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { registerSignIn(dialogs) // Tests give a navigation's first answer less time (ORBIT_E2E_LOAD_TIMEOUT_MS). const testTimeout = ctx.e2e ? Number(process.env.ORBIT_E2E_LOAD_TIMEOUT_MS) : Number.NaN - const screens = registerScreens(ctx, ops, dialogs, testTimeout > 0 ? testTimeout : LOAD_TIMEOUT_MS) + const fullScreen = registerFullScreen(ctx) + const screens = registerScreens(ctx, ops, dialogs, fullScreen, testTimeout > 0 ? testTimeout : LOAD_TIMEOUT_MS) // Test runs record each launch instead, and take the OS's apps from ORBIT_E2E_APPS. const launched: string[] = [] registerOtherApps( @@ -60,6 +65,7 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { mayDiscard: (tabId) => leave.mayDiscard(tabId), screens: () => screens.info(), launched, + fullScreen: (windowId) => fullScreen.info(windowId), } ;(globalThis as unknown as { __orbitPages: PagesTestHooks }).__orbitPages = hooks } diff --git a/src/main/pages/screens.ts b/src/main/pages/screens.ts index d016cea7..6095cb18 100644 --- a/src/main/pages/screens.ts +++ b/src/main/pages/screens.ts @@ -22,6 +22,7 @@ import type { MainContext } from '../context' import type { TabOps } from '../tabs/ops' import { type CoverInfo, createCovers } from './covers' import type { PageDialogs$ } from './dialogs' +import type { FullScreen$ } from './fullscreen' /** * A main-frame navigation that gets no answer from its server within this time is stopped and gets @@ -130,6 +131,7 @@ export function registerScreens( ctx: MainContext, ops: TabOps, dialogs: PageDialogs$, + fullScreen: FullScreen$, loadTimeout = LOAD_TIMEOUT_MS, ): PageScreens { const { store, tabs, commands } = ctx @@ -146,6 +148,11 @@ export function registerScreens( // A screen takes the keyboard only where the page would have it: not from the sidebar's field. mayTakeKeyboard: (page, focused) => pageHasKeyboard(page, focused) || states.get(page.id)?.state.screen?.hadKeyboard === true, + // In full screen, Esc on a screen leaves it as on a page: pressed twice, or held (NV-20). + onKey: (page, view, event, input) => { + const tabId = page.isDestroyed() ? undefined : tabs.tabIdFor(page) + fullScreen.onKey(tabId ? ctx.windows.windowOfTab(tabId) : undefined, view, event, input) + }, }) const stateOf = (page: WebContents): PageState => { diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 8cc4a55f..5a181d16 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -346,6 +346,27 @@ export const ACTIONS: ActionDef[] = [ { id: 'window.new', title: 'New Window', category: 'Window', mac: ['Cmd+N'], other: ['Ctrl+N'] }, // Alt+F4 closes the window too, as the system's key (KS-05). { id: 'window.close', title: 'Close Window', category: 'Window', mac: ['Cmd+Shift+W'], other: ['Ctrl+Shift+W'] }, + { + // NV-20. On macOS fn+F (Globe+F) does it too: the system's key, which never reaches Orbit. + id: 'window.toggleFullScreen', + title: 'Toggle Full Screen', + category: 'Window', + mac: ['Cmd+Ctrl+F'], + other: ['F11', 'Alt+Shift+F'], + keywords: ['full screen', 'fullscreen', 'enter full screen', 'exit full screen', 'presentation'], + }, + { + // Esc in a page leaves full screen (NV-20): pressed twice within about a second, or held; the + // page gets the first press (KS-02, D68). Esc is no binding of it: an overlay's Esc closes the + // overlay first (KS-19), so the rule lives with the page (src/main/pages/fullscreen.ts). Settings + // → Shortcuts sets its priority, and can give it keys of its own. + id: 'window.exitFullScreen', + title: 'Exit Full Screen', + category: 'Window', + hidden: true, + preferWebsites: true, + keywords: ['esc', 'escape', 'hold esc', 'press esc twice', 'leave full screen', 'fullscreen'], + }, { // Keyboard focus between the sidebar and the page (KS-05, SB-22). id: 'focus.cycle', diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 3fc74e1b..fb8d6c3c 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -1167,18 +1167,20 @@ test('[SB-19] native full screen takes the frame away; leaving brings it back', const { width } = await sidebarState() await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(width) await setNativeFullScreen(app, true) + // The sidebar hides in full screen (NV-20): the page has the whole window. await expect .poll(async () => { const size = await contentSize() return ( JSON.stringify((await tabView(app, id))?.bounds) === - JSON.stringify({ x: width, y: 0, width: size.width - width, height: size.height }) + JSON.stringify({ x: 0, y: 0, width: size.width, height: size.height }) ) }) .toBe(true) expect(await pageCorner(app, id)).toBe(0) await setNativeFullScreen(app, false) await expect.poll(async () => (await tabView(app, id))?.bounds.y).toBe(MARGIN) + expect((await tabView(app, id))?.bounds.x).toBe(width) expect(await pageCorner(app, id)).toBe(RADIUS) }) diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts index 3ba15926..d059a61b 100644 --- a/tests/e2e/keys.spec.ts +++ b/tests/e2e/keys.spec.ts @@ -1460,8 +1460,8 @@ test('[KS-20][KS-06] in full screen with the sidebar hidden, ⌘1–9 and ⌘⌥ const b = await openTab(app, fx.url('/page/FsSecond')) const a = await openTab(app, fx.url('/page/FsFirst')) expect((await state(app)).spaces[spaceId]?.today).toEqual([a, b, c]) + // Full screen hides the sidebar by itself (NV-20). await setNativeFullScreen(app, true) - await action(app, 'sidebar.toggle') await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) await selectPage(a, fx.url('/page/FsFirst')) const mod: Mod = isMac ? 'meta' : 'control' diff --git a/tests/e2e/pages-fullscreen.spec.ts b/tests/e2e/pages-fullscreen.spec.ts new file mode 100644 index 00000000..5a9fa225 --- /dev/null +++ b/tests/e2e/pages-fullscreen.spec.ts @@ -0,0 +1,380 @@ +import { type ElectronApplication, expect, test } from '@playwright/test' +import { action, isMac, launchOrbit, MOD, type Orbit, run, state, tabPage, tabView, viewPage } from './helpers' +import { floatingInfo, focusedUrl, pageMouse, pressInPage, sendToPage } from './nav-support' +import { + crashTab, + fullScreenInfo, + type PagesFixtures, + type SilentServer, + startPagesFixtures, + startSilentServer, + waitScreen, +} from './pages-support' +import { openSettings } from './settings-support' +import { newTab, waitTab } from './tabs-support' + +/** + * Window full screen (NV-20). Xvfb has no window manager: the window gets the full-screen events and + * state but never really fills a screen, so these tests check what Orbit keeps and lays out. + */ + +let fx: PagesFixtures +let silent: SilentServer +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() + silent = await startSilentServer() +}) +test.afterAll(async () => { + await fx.close() + await silent.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +type Mod = 'shift' | 'control' | 'alt' | 'meta' + +/** Toggle Full Screen's keys: ⌃⌘F on macOS; F11 and Alt+Shift+F on Windows and Linux. */ +const TOGGLE_KEYS: [string, Mod[]][] = isMac + ? [['F', ['control', 'meta']]] + : [ + ['F11', []], + ['F', ['alt', 'shift']], + ] + +const windowIds = (app: ElectronApplication) => + app.evaluate(() => (globalThis as unknown as { __orbit: { windowIds: () => string[] } }).__orbit.windowIds()) + +const firstWindow = async (app: ElectronApplication) => (await windowIds(app))[0] as string + +/** A window as the state has it: in full screen or not, and its sidebar. */ +async function win(app: ElectronApplication, windowId?: string) { + const id = windowId ?? (await firstWindow(app)) + const w = (await state(app)).windows[id] + if (!w) throw new Error(`no window ${id}`) + return { fullscreen: w.fullscreen, sidebar: w.sidebar.visible, peeking: w.sidebar.peeking } +} + +/** The first window's content size. */ +const contentSize = (app: ElectronApplication) => + app.evaluate(({ BrowserWindow }) => { + const shell = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('view=shell')) + const [width = 0, height = 0] = shell?.getContentSize() ?? [] + return { width, height } + }) + +/** A tab on the `/esc` page (`prevent`: the page uses Esc), loaded, with the keyboard. */ +async function escPage(app: ElectronApplication, prevent = true) { + const path = `/esc${prevent ? '?prevent=1' : ''}` + const tabId = await newTab(app, fx.url(path)) + await waitTab(app, tabId, '/esc') + const page = await tabPage(app, '/esc') + await expect.poll(() => focusedUrl(app)).toBe(fx.url(path)) + return { tabId, page, escapes: () => page.evaluate(() => (window as unknown as { __escapes: string[] }).__escapes) } +} + +/** Presses Esc the way the keyboard does (through the browser process), in the view at `urlPart`; `repeats`: held that long. */ +function pressEscape(app: ElectronApplication, urlPart: string, repeats = 0): Promise { + return sendToPage(app, urlPart, [ + { type: 'keyDown', keyCode: 'Escape' }, + ...Array.from({ length: repeats }, () => ({ type: 'keyDown', keyCode: 'Escape', modifiers: ['isAutoRepeat'] })), + { type: 'keyUp', keyCode: 'Escape' }, + ]) +} + +/** Presses Esc once in a tab's page, as the keyboard does: while it loads another address too. */ +function pressEscapeInTab(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => { + const g = globalThis as unknown as { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents } } } + } + const page = g.__orbit.ctx.tabs.contents(id) + page.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) + page.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) + }, tabId) +} + +/** The Esc presses Orbit waited on while a tab loaded, oldest first (keys lane, NV-04). */ +const escapeLog = (app: ElectronApplication) => + app.evaluate(() => + ( + globalThis as unknown as { __orbitKeys: { escapes: () => { url: string; used: boolean; stopped: boolean }[] } } + ).__orbitKeys.escapes(), + ) + +/** Into full screen with the first toggle key, pressed in the page at `urlPart`. */ +async function enterFullScreen(app: ElectronApplication, urlPart: string): Promise { + const [key, mods] = TOGGLE_KEYS[0] as [string, Mod[]] + await pressInPage(app, urlPart, key, mods) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) +} + +test('[NV-20] ⌃⌘F (F11 and Alt+Shift+F on Windows and Linux) toggles full screen: the sidebar hides and the page fills the screen; the left edge brings the sidebar in over it; leaving docks it again', async () => { + const { app, shell } = orbit + const { tabId } = await escPage(app) + const windowId = await firstWindow(app) + const docked = (await tabView(app, tabId))?.bounds + expect(docked?.x).toBeGreaterThan(0) + for (const [key, mods] of TOGGLE_KEYS) { + await pressInPage(app, '/esc', key, mods) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + expect(await fullScreenInfo(app, windowId)).toEqual({ fullScreen: true, sidebarHidden: true }) + // The page has the whole window: no sidebar, no frame. + await expect + .poll(async () => { + const size = await contentSize(app) + return JSON.stringify((await tabView(app, tabId))?.bounds) === JSON.stringify({ x: 0, y: 0, ...size }) + }) + .toBe(true) + await expect(shell.getByTestId('sidebar')).toBeHidden() + + // The window's left edge, reached in the page, brings the sidebar in over the page; it goes as the pointer moves on. + await pageMouse(app, '/esc', 'mouseMove', 300, 300) + await pageMouse(app, '/esc', 'mouseMove', 2, 300) + await expect.poll(async () => (await win(app)).peeking).toBe(true) + await expect.poll(async () => (await floatingInfo(app)).placed).toBe(true) + await pageMouse(app, '/esc', 'mouseMove', 700, 300) + await expect.poll(async () => (await win(app)).peeking).toBe(false) + + // A held key toggles once (three repeats: toggling on each would end back in full screen). + await sendToPage(app, '/esc', [ + { type: 'keyDown', keyCode: key, modifiers: mods }, + ...Array.from({ length: 3 }, () => ({ type: 'keyDown', keyCode: key, modifiers: [...mods, 'isAutoRepeat'] })), + { type: 'keyUp', keyCode: key, modifiers: mods }, + ]) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + expect(await fullScreenInfo(app, windowId)).toEqual({ fullScreen: false, sidebarHidden: false }) + await expect.poll(async () => (await tabView(app, tabId))?.bounds).toEqual(docked) + } + // The other key leaves what one entered. + if (TOGGLE_KEYS.length > 1) { + const [[enter, enterMods], [leave, leaveMods]] = TOGGLE_KEYS as [[string, Mod[]], [string, Mod[]]] + await pressInPage(app, '/esc', enter, enterMods) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + await pressInPage(app, '/esc', leave, leaveMods) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + } + + // The command bar has it, with its keys. + await shell.keyboard.press(`${MOD}+T`) + const overlay = await viewPage(app, 'overlay') + await expect(overlay.getByTestId('command-input')).toBeFocused() + await overlay.keyboard.type('Toggle Full Screen') + const top = overlay.getByTestId('command-result').first() + await expect(top).toHaveAttribute('data-kind', 'action') + await expect(top.getByTestId('command-result-title')).toHaveText('Toggle Full Screen') + await expect(top.getByTestId('command-shortcut')).toHaveText(isMac ? '⌃⌘F' : 'F11') + await overlay.keyboard.press('Enter') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + if (isMac) { + const item = await app.evaluate(({ Menu }) => { + const view = Menu.getApplicationMenu()?.items.find((i) => i.label === 'View') + return view?.submenu?.items.find((i) => i.label === 'Toggle Full Screen')?.accelerator ?? null + }) + expect(item).toMatch(/F$/) + } +}) + +test('[NV-20][KS-02] Esc goes to the page first: one press keeps full screen, and so do presses a second apart; a second press within a second leaves, and the page never gets it', async () => { + const { app } = orbit + const { escapes } = await escPage(app) + await enterFullScreen(app, '/esc') + // The page uses Esc (a web app closing its own menu): it gets it, and full screen stays. + await pressEscape(app, '/esc') + await expect.poll(escapes).toEqual(['keydown', 'keyup']) + expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + // A second later, it is a first press again. + await new Promise((r) => setTimeout(r, 1_200)) + await pressEscape(app, '/esc') + await expect.poll(escapes).toEqual(['keydown', 'keyup', 'keydown', 'keyup']) + expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + // Again at once: full screen ends, and the page doesn't get that press. + await pressEscape(app, '/esc') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + expect(await escapes()).toEqual(['keydown', 'keyup', 'keydown', 'keyup']) + // Out of full screen, Esc is the page's alone. + await pressEscape(app, '/esc') + await pressEscape(app, '/esc') + await expect.poll(async () => (await escapes()).length).toBe(8) + expect(await win(app)).toMatchObject({ fullscreen: false, sidebar: true }) +}) + +test('[NV-20] holding Esc leaves full screen: the page gets the press, not its repeats', async () => { + const { app } = orbit + const { escapes } = await escPage(app, false) + await enterFullScreen(app, '/esc') + await pressEscape(app, '/esc', 4) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + // Nor its release: the engine keeps a key's release from a page once the browser took its repeat. + await expect.poll(escapes).toEqual(['keydown']) + // Held again out of full screen: the page gets it all. + await pressEscape(app, '/esc', 2) + await expect.poll(escapes).toEqual(['keydown', 'keydown', 'keydown (repeat)', 'keydown (repeat)', 'keyup']) + expect(await win(app)).toMatchObject({ fullscreen: false, sidebar: true }) +}) + +test('[NV-20][KS-02][ST-08] Settings → Shortcuts lists Exit Full Screen, preferring websites; set to prefer Orbit, one Esc leaves at once and the page never sees it', async () => { + const { app } = orbit + const settings = await openSettings(app, { section: 'shortcuts' }) + await settings.getByTestId('shortcuts-search').fill('exit') + const row = settings.getByTestId('setting-shortcut-window.exitFullScreen') + await expect(row).toContainText('Exit Full Screen') + await expect(row).toContainText('Websites first') + await row.click({ button: 'right' }) + const menu = settings.getByTestId('shortcut-menu') + await expect(menu.getByTestId('shortcut-menu-websites')).toHaveAttribute('aria-checked', 'true') + await menu.getByTestId('shortcut-menu-orbit').click() + await expect + .poll(async () => (await state(app)).settings.shortcutPriority) + .toEqual({ 'window.exitFullScreen': 'orbit' }) + await settings.close() + + const { escapes } = await escPage(app) + await enterFullScreen(app, '/esc') + await pressEscape(app, '/esc', 3) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + // Nothing of the press reached the page: not its repeats, nor its release. + expect(await escapes()).toEqual([]) + await pressEscape(app, '/esc') + await expect.poll(escapes).toEqual(['keydown', 'keyup']) +}) + +test('[NV-20] a video’s own full screen inside the window’s: Esc leaves only the video, and a quick Esc after goes to the page; the toggle keys leave both', async () => { + const { app } = orbit + const { page, escapes } = await escPage(app) + const windowId = await firstWindow(app) + await enterFullScreen(app, '/esc') + const inVideo = () => + app.evaluate( + (_e, id) => + ( + globalThis as unknown as { __orbit: { ctx: { windows: { inHtmlFullscreen: (id: string) => boolean } } } } + ).__orbit.ctx.windows.inHtmlFullscreen(id), + windowId, + ) + await page.evaluate(() => document.documentElement.requestFullscreen()) + await expect.poll(inVideo).toBe(true) + // The engine's Esc: the video's full screen ends, the window's stays, with its sidebar hidden. + await pressEscape(app, '/esc') + await expect.poll(inVideo).toBe(false) + await expect.poll(() => page.evaluate(() => !!document.fullscreenElement)).toBe(false) + expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + // The page gets the next press, however soon. + const before = (await escapes()).length + await pressEscape(app, '/esc') + await expect.poll(async () => (await escapes()).slice(before)).toEqual(['keydown', 'keyup']) + expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + + // In the video's full screen again, the toggle keys leave both: the page's first, then the window's. + await page.evaluate(() => document.documentElement.requestFullscreen()) + await expect.poll(inVideo).toBe(true) + const [key, mods] = TOGGLE_KEYS[0] as [string, Mod[]] + await pressInPage(app, '/esc', key, mods) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + await expect.poll(inVideo).toBe(false) + await expect.poll(() => page.evaluate(() => !!document.fullscreenElement)).toBe(false) +}) + +test('[NV-20][SB-28] in full screen the loading indicator still shows a page loading', async () => { + const { app } = orbit + const { tabId } = await escPage(app) + const windowId = await firstWindow(app) + await enterFullScreen(app, '/esc') + const loading = () => + app.evaluate( + (_e, id) => + ( + globalThis as unknown as { __orbitNav: { loading: (id: string) => { visible: boolean; tabId?: string } } } + ).__orbitNav.loading(id), + windowId, + ) + await run(app, 'tabs.navigate', { tabId, url: silent.url('/never') }) + await expect.poll(loading).toMatchObject({ visible: true, tabId }) + expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) +}) + +test('[NV-20][NV-04] on a loading page, the Esc that leaves full screen stops nothing', async () => { + const { app } = orbit + const { tabId } = await escPage(app) + await enterFullScreen(app, '/esc') + await run(app, 'tabs.navigate', { tabId, url: silent.url('/never') }) + await expect.poll(async () => (await state(app)).tabs[tabId]?.loading).toBe(true) + // The page uses the first press, so the load goes on (NV-04). + await pressEscapeInTab(app, tabId) + await expect.poll(() => escapeLog(app)).toMatchObject([{ used: true, stopped: false }]) + await pressEscapeInTab(app, tabId) + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + // Longer than Orbit waits for a page's answer to an Esc (250 ms): no wait for that press, no stop. + await new Promise((r) => setTimeout(r, 600)) + expect(await escapeLog(app)).toHaveLength(1) + expect((await state(app)).tabs[tabId]?.loading).toBe(true) +}) + +test('[NV-20][NV-10] a window opened in full screen opens in full screen with its sidebar hidden, and docks it when it leaves', async () => { + const { app } = orbit + await escPage(app) + const first = await firstWindow(app) + await enterFullScreen(app, '/esc') + await action(app, 'window.new') + await expect.poll(async () => (await windowIds(app)).length).toBe(2) + const second = (await windowIds(app)).find((id) => id !== first) as string + await expect.poll(() => win(app, second)).toMatchObject({ fullscreen: true, sidebar: false }) + await app.evaluate( + (_e, id) => + (globalThis as unknown as { __orbit: { action: (a: string, w?: string) => Promise } }).__orbit.action( + 'window.toggleFullScreen', + id, + ), + second, + ) + await expect.poll(() => win(app, second)).toMatchObject({ fullscreen: false, sidebar: true }) + expect(await win(app, first)).toMatchObject({ fullscreen: true, sidebar: false }) +}) + +test('[NV-20][SB-11] ⌘S in full screen docks the sidebar, and it stays as the user left it; quitting in full screen keeps the docked sidebar for next time', async () => { + const { app } = orbit + await escPage(app) + const windowId = await firstWindow(app) + await enterFullScreen(app, '/esc') + await action(app, 'sidebar.toggle') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: true }) + expect(await fullScreenInfo(app, windowId)).toEqual({ fullScreen: true, sidebarHidden: false }) + await action(app, 'window.toggleFullScreen') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + // Hidden by the user in full screen: hidden after it, too. + await action(app, 'window.toggleFullScreen') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + await action(app, 'sidebar.toggle') + await action(app, 'sidebar.toggle') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + await action(app, 'window.toggleFullScreen') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: false }) + await action(app, 'sidebar.toggle') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) + + // Quit in full screen: the window comes back with its sidebar docked. + await action(app, 'window.toggleFullScreen') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + const { userData } = orbit + await orbit.app.close() + orbit = await launchOrbit({ userData }) + await expect.poll(() => win(orbit.app)).toMatchObject({ fullscreen: false, sidebar: true }) +}) + +test('[NV-20][NV-14] over a crash screen in full screen, Esc twice leaves full screen too', async () => { + const { app } = orbit + const { tabId } = await escPage(app) + await enterFullScreen(app, '/esc') + await crashTab(app, tabId) + const shown = await waitScreen(app, (s) => s.tabId === tabId && s.onScreen) + await expect.poll(async () => (await waitScreen(app, (s) => s.id === shown.id)).focused).toBe(true) + await pressEscape(app, 'view=screen') + expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) + await pressEscape(app, 'view=screen') + await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index 9daa1666..19d9bd01 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -12,6 +12,7 @@ import { join } from 'node:path' import type { ElectronApplication, Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' import type { PageDialogInfo } from '../../src/main/pages/dialogs' +import type { FullScreenInfo } from '../../src/main/pages/fullscreen' import type { LeaveInfo } from '../../src/main/pages/leave' import type { PageScreenInfo } from '../../src/main/pages/screens' import { listenInRange, tabPage } from './helpers' @@ -52,6 +53,8 @@ const html = (title: string, body: string, head = '') => ` * (mailto: in a new tab), `#app-tab` (a new tab that redirects to zoommtg:), `#pop` (a pop-up * window for mailto:), and an `#frame` loading `frame` (`/app-frame`: a `#frame-mail` link). * - `/to-app?to=…`: redirects to `to`. + * - `/esc?prevent=1`: notes each Escape it gets in `window.__escapes` (`keydown`, `keydown (repeat)`, + * `keyup`), and with `prevent=1` uses it (`preventDefault`), as a web app closing its own menu does. * * Every page has an icon (PAGE_ICON). `port` listens on that port only (a server back on the port * it left). @@ -178,6 +181,26 @@ addEventListener('pagehide', () => note('pagehide', prompt('Your name?', 'Ada')) ) return } + if (u.pathname === '/esc') { + const prevent = u.searchParams.get('prevent') === '1' + send( + html( + 'Esc', + `

Esc

+`, + ), + ) + return + } if (u.pathname.startsWith('/page/')) { const name = decodeURIComponent(u.pathname.slice('/page/'.length)) send(html(name, `

${name}

`)) @@ -325,6 +348,7 @@ type PagesGlobal = { mayDiscard: (tabId: string) => boolean screens: () => PageScreenInfo[] launched: string[] + fullScreen: (windowId: string) => FullScreenInfo } } @@ -551,3 +575,8 @@ export function pageBounds(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.fullScreen(id), windowId) +} diff --git a/tests/unit/keymap-defaults.test.ts b/tests/unit/keymap-defaults.test.ts index 02c325a0..ebf22f24 100644 --- a/tests/unit/keymap-defaults.test.ts +++ b/tests/unit/keymap-defaults.test.ts @@ -5,8 +5,8 @@ import { bindingProblem, canonicalAccelerator, type KeyInput } from '../../src/s /** * The default keymap against the shortcut tables of the spec (KS-03 audit): every row Orbit binds, * on macOS and on Windows/Linux. Rows for features no lane has built yet (blank window, top - * toolbar, save page, full screen keys, reader mode, developer mode, notes, canvases, extensions) - * are left out; so are keys that belong to an open overlay (KS-04's Esc, Tab, arrows). + * toolbar, save page, reader mode, developer mode, notes, canvases, extensions) are left out; so + * are keys that belong to an open overlay (KS-04's Esc, Tab, arrows). */ const TABLE: [id: string, mac: string[], other: string[]][] = [ // KS-04 @@ -69,6 +69,9 @@ const TABLE: [id: string, mac: string[], other: string[]][] = [ ['page.zoomReset', ['Cmd+0'], ['Ctrl+0']], ['page.print', ['Cmd+P'], ['Ctrl+P']], ['page.viewSource', ['Cmd+Alt+U'], ['Ctrl+U']], + // fn+F is the system's own key on macOS; Esc leaving full screen is no binding (NV-20). + ['window.toggleFullScreen', ['Cmd+Ctrl+F'], ['F11', 'Alt+Shift+F']], + ['window.exitFullScreen', [], []], ['site.controls', [], []], // KS-09 ['split.add', ['Ctrl+Shift+='], ['Ctrl+Shift+=']], diff --git a/tests/unit/pages-fullscreen.test.ts b/tests/unit/pages-fullscreen.test.ts new file mode 100644 index 00000000..03b34619 --- /dev/null +++ b/tests/unit/pages-fullscreen.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { type EscapeMemory, escapeInFullScreen } from '../../src/main/pages/fullscreen' +import type { ShortcutPriority } from '../../src/shared/commands/keys' + +/** Presses in order through the rule, as one page gets them; returns each verdict. */ +function presses( + list: { type?: 'down' | 'up'; repeat?: boolean; at: number; fullScreen?: boolean }[], + priority: ShortcutPriority = 'websites', + start: EscapeMemory = {}, +) { + let memory = start + return list.map((p) => { + const next = escapeInFullScreen( + memory, + { type: p.type ?? 'down', repeat: p.repeat ?? false, now: p.at, fullScreen: p.fullScreen ?? true }, + priority, + ) + memory = next.memory + return next.verdict + }) +} + +/** A press and its release at `at`. */ +const tap = (at: number, fullScreen = true) => [ + { at, fullScreen }, + { type: 'up' as const, at: at + 50, fullScreen }, +] + +describe('[NV-20][KS-02] Esc in a page of a window in full screen', () => { + it('prefers websites: the page gets one press, and full screen stays', () => { + expect(presses(tap(0))).toEqual(['page', 'page']) + }) + + it('a second press within about a second leaves, and the page gets neither it nor its release', () => { + expect(presses([...tap(0), ...tap(900)])).toEqual(['page', 'page', 'exit', 'swallow']) + expect(presses([...tap(0), ...tap(1_000)])).toEqual(['page', 'page', 'exit', 'swallow']) + }) + + it('presses more than a second apart each go to the page', () => { + expect(presses([...tap(0), ...tap(1_100), ...tap(2_300)])).toEqual(['page', 'page', 'page', 'page', 'page', 'page']) + }) + + it('a held press leaves at its first repeat: the page keeps the press and its release, not the repeats', () => { + expect( + presses([ + { at: 0 }, + { at: 500, repeat: true }, + { at: 530, repeat: true }, + { at: 560, repeat: true }, + { type: 'up', at: 600 }, + ]), + ).toEqual(['page', 'exit', 'swallow', 'swallow', 'page']) + }) + + it('prefers Orbit when set so: one press leaves, and the page gets nothing of it', () => { + expect( + presses([{ at: 0 }, { at: 500, repeat: true }, { at: 530, repeat: true }, { type: 'up', at: 600 }], 'orbit'), + ).toEqual(['exit', 'swallow', 'swallow', 'swallow']) + }) + + it('once full screen has ended, Esc is the page’s alone', () => { + // Left by the second press; the next ones, out of full screen, all go to the page. + expect( + presses([ + ...tap(0), + ...tap(300), + ...tap(600, false), + ...tap(700, false), + { at: 800, fullScreen: false }, + { at: 830, repeat: true, fullScreen: false }, + ]), + ).toEqual(['page', 'page', 'exit', 'swallow', 'page', 'page', 'page', 'page', 'page', 'page']) + }) + + it('a press the page didn’t get (the engine took it to end a video’s full screen) is no first press, and its repeats leave nothing', () => { + // Only its release and repeats are seen; then a quick press goes to the page. + expect(presses([{ at: 0, repeat: true }, { type: 'up', at: 50 }, ...tap(200)])).toEqual([ + 'page', + 'page', + 'page', + 'page', + ]) + }) + + it('a press out of full screen is no first press for one in it', () => { + expect(presses([...tap(0, false), ...tap(300)])).toEqual(['page', 'page', 'page', 'page']) + }) +}) From 774da17ee34a8b5bb4add2202b4c2a220559df02 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:52:07 +0000 Subject: [PATCH 06/29] feat(pages): Orbit's print dialog over the page (NV-07) Cmd+P / Ctrl+P, File > Print, the command bar, the page menu, a Peek's or a Mini Window's Cmd+P and the page's own window.print() open a print dialog over that page only: the page as it prints (the engine's PDF in the PDF viewer) beside destination (Save as PDF, the OS's printers), pages, copies, layout, colour, headers and footers and background graphics. Esc closes it wherever its keyboard is, and the page is left as it was. The next dialog starts from the last one's choices until Orbit quits. One function sends a job out; test runs record each job instead and take their printers from ORBIT_E2E_PRINTERS, so nothing prints or asks where to save. Covers now know a pooled view is ready by its own load (a preview frame can keep isLoading() true), and take the keyboard just after the page's own focus change, so a dialog has it again when its tab comes back. KS-19 is now complete: the print dialog was its last overlay. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 12 +- docs/ARCHITECTURE.md | 3 + docs/feature-status.json | 5 +- src/main/nav/page-menu.ts | 4 +- src/main/pages/covers.ts | 20 +- src/main/pages/entry.ts | 19 ++ src/main/pages/index.ts | 30 ++- src/main/pages/print.ts | 404 ++++++++++++++++++++++++++++ src/main/peek/page-actions.ts | 4 +- src/main/tabs/commands.ts | 7 +- src/preload/page.ts | 27 +- src/renderer/index.html | 2 +- src/renderer/src/main.tsx | 15 ++ src/renderer/src/pages/PrintApp.tsx | 294 ++++++++++++++++++++ src/renderer/src/pages/print.css | 239 ++++++++++++++++ src/shared/commands/pages.ts | 9 +- src/shared/print.ts | 214 +++++++++++++++ src/shared/rpc.ts | 2 + src/shared/site.ts | 5 + tests/e2e/pages-print.spec.ts | 244 +++++++++++++++++ tests/e2e/pages-support.ts | 71 +++++ tests/unit/pages-print.test.ts | 168 ++++++++++++ 22 files changed, 1771 insertions(+), 27 deletions(-) create mode 100644 src/main/pages/entry.ts create mode 100644 src/main/pages/print.ts create mode 100644 src/renderer/src/pages/PrintApp.tsx create mode 100644 src/renderer/src/pages/print.css create mode 100644 src/shared/print.ts create mode 100644 tests/e2e/pages-print.spec.ts create mode 100644 tests/unit/pages-print.test.ts diff --git a/FEATURES.md b/FEATURES.md index 8f62bae4..ee6f978d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 241 built (87 partial) · 333 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 243 built (86 partial) · 334 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 140 | 168 | -| P1 | 219 | 86 | 148 | +| P0 | 174 | 141 | 168 | +| P1 | 219 | 87 | 149 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -234,7 +234,7 @@ Spec: `spec/navigation.md` | NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes | | NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11) | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | -| NV-07 | Print | P1 | – | – | – | | +| NV-07 | Print | P1 | ✅ | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page goes on (Chromium's waits for the dialog); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); no paper size, margins, scale, pages per sheet or two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | | NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e+unit | – | Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links | | NV-09 | Links with target=_blank and script-opened windows | P0 | ✅ | ✅ e2e+unit | – | target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there, and a toast names the file (D49); a tab that only downloads closes and reselects its opener | | NV-10 | New window | P0 | 🟡 partial | ✅ e2e | – | Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built | @@ -353,7 +353,7 @@ Spec: `spec/shortcuts.md` | KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | | KS-06 | Switching tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05) | | KS-07 | Spaces | P0 | ✅ | ✅ e2e+unit | – | Space shortcuts wired through the action table | -| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print, save page, reader mode and the rest come with their NV items | +| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; save page, view source, reader mode and the rest come with their NV items | | KS-09 | Split view | P0 | ✅ | ✅ e2e+unit | – | ⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page | | KS-10 | Peek and Mini Window | P1 | ✅ | ✅ e2e+unit | – | ⌘O / Ctrl+O expands a Peek into a selected Today tab (PK-07); Esc and ⌘W / Ctrl+W close it (PK-09, Esc fixed); ⌘⇧T and ⌘Z reopen it (PK-10); ⌘⌥N / Ctrl+Alt+N opens a Mini Window (MW-06); in a Mini Window ⌘O moves its page into the most recent Space (MW-05), ⌘⌥O (also ⌘⇧O) / Ctrl+Alt+O offers the Spaces, ⌘W closes it (MW-09). All from the one keymap and remappable but Esc; the tests press the keys (peek, mini-window and keys specs) | | KS-11 | Media | P1 | 🟡 partial | ✅ e2e+unit | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | @@ -364,7 +364,7 @@ Spec: `spec/shortcuts.md` | KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7) | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | 🟡 partial | ✅ e2e | – | each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click twice on a video (MD-02). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09) | | KS-18 | Shortcut hints everywhere | P1 | ✅ | ✅ e2e+unit | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label | -| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e+unit | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20). Not built: the print dialog (NV-07) | +| KS-19 | Keys inside overlays | P0 | ✅ | ✅ e2e+unit | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20); the print dialog: Esc closes it wherever its keyboard is, the preview included, Enter prints and Tab stays inside it (NV-07) | | KS-20 | Shortcuts in special windows | P1 | 🟡 partial | ✅ e2e+unit | – | private window: the same keys, but the actions that need Spaces, pinned tabs, Favorites, folders or the Archive (⌘D, ⌃1–9 / Alt+1–9, ⌘⌥← / ⌘⌥→, ⌘⇧K Clear, Favorite, New Folder, View Archive, every Space action) do nothing there, from a key, a menu or the command bar, and a toast says “Not available in a private window”; its sidebar has no Clear button; tab keys work. Mini Window: ⌘W closes it, ⌘O / ⌘⌥O move or offer to move its page (KS-10), ⌘L edits its address with the keyboard in the field and Esc gives the keyboard back to its page, ⌘R, zoom and print act on its page, sidebar and Space keys do nothing; ⌘Y / Ctrl+H History, the Library and Help → Keyboard Shortcuts open in the browser window behind it. Full screen: every key works with the sidebar hidden; ⌘1–9 and ⌘⌥↑ / ⌘⌥↓ switch tabs without bringing it out. Not built: ⌘F in a Mini Window (it has no find bar, Mini Window lane), the blank window (TB-17), extension keys in private windows (EX-06, EX-10) | ## Interaction feel and motion targets (prefix FL) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d80a73d5..8b31b3db 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,9 +148,12 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:escape` (KS-19, D59): one bit after a trusted Esc reaches the main frame, sent once every handler of the page has run: whether one called `preventDefault`. The listener is in the preload's own world. Main reads the bit only while it waits after an Esc in a loading tab with no overlay open (at most 250 ms; no answer counts as not used), only from that tab's main frame, and only for a press still waiting: a bit with none waiting does nothing. A page that always uses Esc can keep Esc from stopping its load; the Stop button still stops it. - `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. In the page's `beforeunload`, `pagehide` or `unload` handlers the calls return at once, as the engine's do: the patch reads the event being handled through the engine's own getters. The preload adds no `beforeunload` or `unload` listener of its own: with one, the engine would run every page's `beforeunload` before each navigation (a busy page would hold up Back, D60), and keep every page out of the back-forward cache. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. - `orbit-page:form-sent` (NV-13): the main frame is about to send a form (POST), from the Navigation API's `navigate` event in the preload's own world: the address it goes to. Main reads it only from a tab's or a pop-up's main frame, and it counts only for that frame's next navigation, to that address: Orbit's time limit for a server's first answer leaves that navigation alone, since it may be a large upload. A page that says so falsely only keeps the time limit off its own load. +- `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), returns at once and asks main for Orbit's print dialog over that page; nothing else goes with it. Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog takes the keyboard when it shows over the page the user is on, as the engine's own does; a screen takes it only where the page had it, never from Orbit's own fields. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. +Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its `print()` opens the system's dialog, so Orbit shows its own dialog as a cover over the page. The preview is the engine's PDF of the page (`printToPDF`, which runs the page's `beforeprint` and `afterprint` and leaves the page as it was), shown in the engine's PDF viewer in a frame of the cover's view (a `blob:` URL: the renderer's CSP allows `blob:` frames for it). One function sends a job out (`deliverPrint`: `print()` to the printer chosen, without the system's dialog, or the PDF saved where the user says); test runs replace it with a recorder, and take their printers from `ORBIT_E2E_PRINTERS`. Callers outside the lane (the Print action for a tab, a Peek or a Mini Window, the page menu) open the dialog through `src/main/pages/entry.ts`. + Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): whether the page would warn if it went now (it runs the page's `beforeunload` handlers with a synthetic event, only after a user gesture in the page), what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. diff --git a/docs/feature-status.json b/docs/feature-status.json index 13d21f9c..88691916 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -138,7 +138,7 @@ "CB-23": {"built": "partial", "note": "private (non-persistent) profiles: no history/archive rows, and their tabs, folders and Spaces never show in normal windows; ⌘L on an empty Space acts like ⌘T; split-pane and Mini Window variants not done"}, "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, - "KS-19": {"built": "partial", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20). Not built: the print dialog (NV-07)"}, + "KS-19": {"built": "yes", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20); the print dialog: Esc closes it wherever its keyboard is, the preview included, Enter prints and Tab stays inside it (NV-07)"}, "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7)"}, "KS-17": {"built": "partial", "note": "each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click twice on a video (MD-02). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09)"}, "KS-01": {"built": "yes", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now"}, @@ -192,6 +192,7 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes"}, "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11)"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, + "NV-07": {"built": "yes", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page goes on (Chromium's waits for the dialog); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); no paper size, margins, scale, pages per sheet or two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links"}, "NV-09": {"built": "yes", "note": "target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there, and a toast names the file (D49); a tab that only downloads closes and reselects its opener"}, "NV-10": {"built": "partial", "note": "Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built"}, @@ -206,7 +207,7 @@ "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it) or its window (Cancel keeps it), a pop-up's window; a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep keep a tab whose page warns, or waits on its dialog, until the user closes it; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, - "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print, save page, reader mode and the rest come with their NV items"}, + "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; save page, view source, reader mode and the rest come with their NV items"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, "FL-23": {"built": "yes", "note": "after 150 ms, a 48x4 theme-accent pill sweeping on a 1.2 s cycle (a pulse with reduced motion); on load end it fills (80 ms) and fades (150 ms), gone 180 ms after; never moves page content"}, diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index a68c2ce0..678251d8 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -5,6 +5,7 @@ import { type PageReferrer, referrerPolicyName } from '../../shared/nav' import { isPrivateTab } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { printPage } from '../pages/entry' import type { TabPage } from '../tabs/ops' import { NAV_WORLD } from './focus' @@ -191,7 +192,8 @@ export function pageItems(ctx: MainContext, tabId: TabId, contents: WebContents) ) }, }, - { label: 'Print…', click: () => contents.print() }, + // Orbit's print dialog over the page (pages lane, NV-07). + { label: 'Print…', click: () => printPage(contents) }, { type: 'separator' }, { label: 'View Page Source', diff --git a/src/main/pages/covers.ts b/src/main/pages/covers.ts index 5ccd7686..7ea0d6df 100644 --- a/src/main/pages/covers.ts +++ b/src/main/pages/covers.ts @@ -86,6 +86,8 @@ export function createCovers( const covers = new Map() const pools = new Map>() const inUse = new Set() + /** Views whose page has loaded, and not crashed since: only these go on screen. */ + const loaded = new WeakSet() const hostOf = (page: WebContents): Host | undefined => { const tabId = tabs.tabIdFor(page) @@ -166,6 +168,7 @@ export function createCovers( }) } contents?.on('did-finish-load', () => { + loaded.add(view) for (const c of covers.values()) { if (c.view !== view) continue c.ready = true @@ -174,6 +177,7 @@ export function createCovers( } }) contents?.on('render-process-gone', () => { + loaded.delete(view) for (const c of covers.values()) { if (c.view !== view) continue c.ready = false @@ -231,9 +235,9 @@ export function createCovers( if (!view) return undefined c.view = view inUse.add(view) - // A pooled view that has loaded already is ready now. - const contents = viewContents(view) - if (contents && !contents.isLoading() && contents.getURL() !== '') c.ready = true + // A pooled view that has loaded already is ready now (`isLoading` can stay true for a frame of + // its last cover's, such as the print preview's). + c.ready = loaded.has(view) return view } @@ -278,10 +282,14 @@ export function createCovers( const entryFor = (page: WebContents): Cover => { const existing = covers.get(page.id) if (existing) return existing - // The page has the keyboard while its cover shows: the cover takes it (KS-19). + // The page has the keyboard while its cover shows: the cover takes it (KS-19), just after the + // page's own focus change (one made during it is lost, as when its tab comes back). const onFocus = () => { - const c = covers.get(page.id) - if (c?.onScreen) viewContents(c.view)?.focus() + setImmediate(() => { + const c = covers.get(page.id) + if (!c?.onScreen || page.isDestroyed() || allContents.getFocusedWebContents() !== page) return + viewContents(c.view)?.focus() + }) } const gone = () => { const c = covers.get(page.id) diff --git a/src/main/pages/entry.ts b/src/main/pages/entry.ts new file mode 100644 index 00000000..ca6cfe61 --- /dev/null +++ b/src/main/pages/entry.ts @@ -0,0 +1,19 @@ +import type { WebContents } from 'electron' + +/** + * The pages lane's ways in for code outside it: the actions for a tab's, a Peek's or a Mini + * Window's page, and the page menu. The lane sets them when it starts; until then they do nothing. + * Kept apart from the lane's modules, so these callers load nothing more. + */ +const entries = { + print: (_page: WebContents): void => {}, +} + +/** Opens the print dialog over `page`, or brings back the one it has (NV-07). */ +export function printPage(page: WebContents): void { + entries.print(page) +} + +export function setPageEntries(next: Partial): void { + Object.assign(entries, next) +} diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts index bf4faaa7..7da58a96 100644 --- a/src/main/pages/index.ts +++ b/src/main/pages/index.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { authDialog } from '../../shared/pages' +import { countPdfPages } from '../../shared/print' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { TabOps } from '../tabs/ops' @@ -7,6 +8,7 @@ import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialo import { type FullScreenInfo, registerFullScreen } from './fullscreen' import { type LeaveInfo, registerLeave } from './leave' import { findSystemApp, findTestApp, launchInApp, registerOtherApps } from './other-apps' +import { deliverPrint, type PrintInfo, type PrintRecord, registerPrint, systemPrinters, testPrinters } from './print' import { LOAD_TIMEOUT_MS, type PageScreenInfo, registerScreens } from './screens' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitPages. */ @@ -23,6 +25,10 @@ export interface PagesTestHooks { launched: string[] /** A window's full screen (NV-20). */ fullScreen: (windowId: WindowId) => FullScreenInfo + /** Every page's print dialog (NV-07). */ + print: () => PrintInfo[] + /** Print jobs, oldest first (NV-07): test runs never print or save one. */ + printed: PrintRecord[] } /** @@ -30,7 +36,8 @@ export interface PagesTestHooks { * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. Error and * crash screens (NV-13, NV-14): what the page's area shows when its page couldn't load, crashed or * stopped responding. Links that open other apps (NV-19): asked about over the page. Window full - * screen (NV-20): the sidebar hidden, and Esc in the page. + * screen (NV-20): the sidebar hidden, and Esc in the page. Print (NV-07): Orbit's print dialog over + * the page. */ export function registerPages(ctx: MainContext, ops: TabOps): void { const dialogs = registerDialogs(ctx) @@ -54,6 +61,25 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { } : { launch: launchInApp, findApp: findSystemApp }, ) + // Test runs record each print job instead, and take the printers from ORBIT_E2E_PRINTERS. + const printed: PrintRecord[] = [] + const print = registerPrint( + ctx, + ctx.e2e + ? { + deliver: async (page, job) => { + const url = page.isDestroyed() ? '' : page.getURL() + if (job.kind === 'printer') printed.push({ kind: 'printer', url, options: job.options }) + else { + const pages = countPdfPages(job.data.toString('latin1')) + printed.push({ kind: 'pdf', url, name: job.name, bytes: job.data.length, pages }) + } + return { ok: true } + }, + printers: testPrinters(process.env.ORBIT_E2E_PRINTERS), + } + : { deliver: deliverPrint, printers: systemPrinters }, + ) if (ctx.e2e) { const hooks: PagesTestHooks = { @@ -66,6 +92,8 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { screens: () => screens.info(), launched, fullScreen: (windowId) => fullScreen.info(windowId), + print: () => print.info(), + printed, } ;(globalThis as unknown as { __orbitPages: PagesTestHooks }).__orbitPages = hooks } diff --git a/src/main/pages/print.ts b/src/main/pages/print.ts new file mode 100644 index 00000000..d96bd8b6 --- /dev/null +++ b/src/main/pages/print.ts @@ -0,0 +1,404 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { app, BrowserWindow, dialog, type IpcMainEvent, ipcMain, type WebContents } from 'electron' +import { + cleanSettings, + countPdfPages, + DEFAULT_PRINT_SETTINGS, + LAYOUT_SETTINGS, + type PrintContent, + type PrintDestination, + type PrintPreview, + type PrintSettings, + pagesProblem, + pdfFileName, + pdfRanges, + printerRanges, + printersFromJson, + SAVE_AS_PDF, +} from '../../shared/print' +import type { Rect } from '../../shared/rpc' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import { createCovers, type HostKind } from './covers' +import { setPageEntries } from './entry' + +/** How long the preview waits for the page to print; a page that hangs gets no preview. */ +const PREVIEW_TIMEOUT_MS = 15_000 +/** How long a page still loading gets to finish before its preview is made (Chromium prints once it has loaded). */ +const LOAD_WAIT_MS = 5_000 + +/** A print job, as it leaves Orbit. */ +export type PrintJob = + | { kind: 'printer'; options: Electron.WebContentsPrintOptions } + | { kind: 'pdf'; data: Buffer; name: string } + +/** + * Sends a job out of Orbit: to a printer, or a PDF to a file the user picks. The one place Orbit + * prints (NV-07); test runs record each job instead, so none ever prints or asks where to save. + * Resolves `ok: false` when nothing went out: with a reason when it failed, without when the user + * cancelled. + */ +export type DeliverPrint = (page: WebContents, job: PrintJob) => Promise<{ ok: boolean; reason?: string }> + +/** The printers a page can print on. */ +export type ListPrinters = (page: WebContents) => Promise + +/** The real delivery: Electron's `print` without its own dialog, or the PDF saved where the user says. */ +export const deliverPrint: DeliverPrint = async (page, job) => { + if (job.kind === 'printer') { + return new Promise((resolve) => + page.print({ ...job.options, silent: true }, (ok, reason) => resolve(ok ? { ok } : { ok, reason })), + ) + } + const options = { + defaultPath: join(app.getPath('downloads'), job.name), + filters: [{ name: 'PDF', extensions: ['pdf'] }], + } + const win = BrowserWindow.fromWebContents(page) + const { canceled, filePath } = await (win ? dialog.showSaveDialog(win, options) : dialog.showSaveDialog(options)) + if (canceled || !filePath) return { ok: false } + await writeFile(filePath, job.data) + return { ok: true } +} + +/** The OS's printers. */ +export const systemPrinters: ListPrinters = async (page) => + (await page.getPrintersAsync()).map((p) => ({ id: p.name, name: p.displayName || p.name })) + +/** Test runs: the printers named in ORBIT_E2E_PRINTERS (JSON: `[{ id, name }]`), else none. */ +export function testPrinters(json: string | undefined): ListPrinters { + const list = printersFromJson(json) + return async () => list +} + +/** A print dialog as tests see it. */ +export interface PrintInfo { + dialog: number + tabId: TabId | null + host: HostKind | null + onScreen: boolean + focused: boolean + bounds?: Rect + preview: PrintPreview['state'] + /** The pages of the preview, once it's ready. */ + pages: number | null + settings: PrintSettings + destinations: string[] + busy: boolean + error: string | null +} + +/** A job a test run's delivery recorded: what a printer would get, or the PDF's name and size. */ +export interface PrintRecord { + kind: PrintJob['kind'] + url: string + options?: Electron.WebContentsPrintOptions + name?: string + bytes?: number + pages?: number +} + +export interface Print$ { + open(page: WebContents): void + info(): PrintInfo[] +} + +interface Session { + page: WebContents + dialog: number + /** The content id the view last got this dialog's state under. */ + contentId: number + settings: PrintSettings + destinations: PrintDestination[] + preview: PrintPreview + /** Bumps with each preview asked for: an older one that comes late is dropped. */ + generation: number + busy: boolean + error?: string + off: () => void +} + +/** + * Print (NV-07): Orbit's print dialog over a page, as Chromium's print preview is (Electron has + * none, and its `print` opens the system's dialog instead). ⌘P / Ctrl+P, File → Print, the command + * bar, the page menu and the page's own `window.print()` open it; Esc closes it and leaves the page + * as it was. It shows the page as it prints (the engine's PDF of it, in the PDF viewer) with the + * destination (the OS's printers, Save as PDF), pages, copies, layout, colour, headers and footers + * and background graphics, and remembers the last choices until Orbit quits. Like a page's dialog + * (NV-24), it covers that page only, waits while the page is off screen, takes the keyboard while + * it shows (KS-19) and gives it back to the page when it closes (KS-16). + */ +export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; printers: ListPrinters }): Print$ { + const { commands, tabs } = ctx + const sessions = new Map() + let nextId = 1 + /** The last dialog's choices: the next one starts from them (Chromium keeps them across runs). */ + let last: PrintSettings = DEFAULT_PRINT_SETTINGS + + const contentOf = (s: Session): PrintContent => ({ + id: s.contentId, + dialog: s.dialog, + title: s.page.isDestroyed() ? '' : s.page.getTitle(), + settings: s.settings, + destinations: s.destinations, + preview: s.preview, + busy: s.busy, + ...(s.error ? { error: s.error } : {}), + }) + + const covers = createCovers(ctx, { + view: 'print', + content: (page) => { + const s = sessions.get(page.id) + return s ? contentOf(s) : undefined + }, + // Esc closes the dialog wherever its keyboard is, the preview included (KS-19). + onKey: (page, _view, event, input) => { + if (input.type !== 'keyDown' || input.key !== 'Escape') return + if (input.meta || input.control || input.alt || input.shift) return + event.preventDefault() + close(page) + }, + }) + + const current = (s: Session) => !s.page.isDestroyed() && sessions.get(s.page.id) === s + const changed = (s: Session) => { + if (!current(s)) return + s.contentId = nextId++ + covers.update(s.page) + } + + const close = (page: WebContents) => { + const s = sessions.get(page.id) + if (!s) return + sessions.delete(page.id) + s.off() + // The view goes, and the page gets the keyboard back (KS-16). + covers.update(page) + } + + const preview = async (s: Session) => { + const generation = ++s.generation + if (s.preview.state !== 'loading') { + s.preview = { state: 'loading' } + changed(s) + } + const { page } = s + // A page that prints while it loads prints once it has (as Chromium's does). + if (page.isLoading()) { + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer) + if (!page.isDestroyed()) page.off('did-stop-loading', done) + resolve() + } + const timer = setTimeout(done, LOAD_WAIT_MS) + page.once('did-stop-loading', done) + }) + } + if (!current(s) || s.generation !== generation) return + let next: PrintPreview + try { + const pdf = await withTimeout(page.printToPDF(pdfOptions(s.settings)), PREVIEW_TIMEOUT_MS) + next = { state: 'ready', version: generation, pages: countPdfPages(pdf.toString('latin1')), pdf } + } catch (err) { + if (current(s)) console.error('[pages] print preview', err) + next = { state: 'error', message: 'This page can’t be previewed.' } + } + if (!current(s) || s.generation !== generation) return + s.preview = next + changed(s) + } + + const open = (page: WebContents) => { + if (page.isDestroyed() || !covers.hostable(page)) return + // Asked again (⌘P, `window.print()`): the dialog it has stays as it is. + if (sessions.has(page.id)) { + covers.update(page) + return + } + const onNavigate = (details: Electron.Event) => { + // The page going elsewhere takes its dialog with it. + if (details.isMainFrame && !details.isSameDocument) close(page) + } + const onGone = () => close(page) + page.on('did-start-navigation', onNavigate) + page.on('render-process-gone', onGone) + page.once('destroyed', onGone) + const s: Session = { + page, + dialog: nextId++, + contentId: nextId++, + settings: { ...last, allPages: true, ranges: '', copies: 1 }, + destinations: [{ id: SAVE_AS_PDF, name: 'Save as PDF' }], + preview: { state: 'loading' }, + generation: 0, + busy: false, + off: () => { + if (page.isDestroyed()) return + page.off('did-start-navigation', onNavigate) + page.off('render-process-gone', onGone) + page.off('destroyed', onGone) + }, + } + sessions.set(page.id, s) + covers.update(page) + void preview(s) + void deps.printers(page).then( + (printers) => { + if (!current(s)) return + s.destinations = [{ id: SAVE_AS_PDF, name: 'Save as PDF' }, ...printers] + // The printer used last, if it's still there; else the PDF. + if (!s.destinations.some((d) => d.id === s.settings.destination)) { + s.settings = { ...s.settings, destination: SAVE_AS_PDF } + } + changed(s) + }, + () => {}, + ) + } + + /** Prints (or saves) with the dialog's settings; the dialog closes once it has. */ + const send = async (s: Session) => { + const { page, settings } = s + if (s.busy || s.preview.state !== 'ready' || pagesProblem(settings, s.preview.pages)) return + const total = s.preview.pages + s.busy = true + s.error = undefined + changed(s) + let result: { ok: boolean; reason?: string } + try { + if (settings.destination === SAVE_AS_PDF) { + const data = await withTimeout( + page.printToPDF({ ...pdfOptions(settings), pageRanges: pdfRanges(settings, total) }), + PREVIEW_TIMEOUT_MS, + ) + result = current(s) + ? await deps.deliver(page, { kind: 'pdf', data, name: pdfFileName(page.getTitle()) }) + : { ok: false } + } else { + result = await deps.deliver(page, { kind: 'printer', options: printerOptions(page, settings, total) }) + } + } catch (err) { + console.error('[pages] print', err) + result = { ok: false, reason: 'Something went wrong' } + } + if (!current(s)) return + s.busy = false + if (result.ok) { + last = { ...settings } + close(page) + return + } + // Cancelled (where to save): the dialog stays as it was. + if (result.reason) s.error = `Couldn’t print: ${result.reason}` + changed(s) + } + + const sessionOf = (sender: WebContents | undefined, dialogId: unknown): Session | undefined => { + const page = sender ? covers.shownBy(sender)?.page : undefined + const s = page ? sessions.get(page.id) : undefined + return s && s.dialog === dialogId ? s : undefined + } + + commands.register('pages.printContent', (_args, c) => { + const shown = c.sender ? covers.shownBy(c.sender) : undefined + return shown?.content ?? null + }) + commands.register('pages.printChange', ({ dialog, settings }, c) => { + const s = sessionOf(c.sender, dialog) + if (!s || s.busy) return + const was = s.settings + s.settings = cleanSettings(settings ?? {}, was, s.destinations) + s.error = undefined + if (LAYOUT_SETTINGS.some((key) => s.settings[key] !== was[key])) void preview(s) + changed(s) + }) + commands.register('pages.printAnswer', ({ dialog, ok }, c) => { + const s = sessionOf(c.sender, dialog) + if (!s) return + if (ok === true) void send(s) + else if (!s.busy) close(s.page) + }) + + // The page's own `window.print()` (the page preload): only a page's main frame has it (D40). + ipcMain.on(PAGE_CHANNEL.print, (event: IpcMainEvent) => { + const frame = event.senderFrame + if (!frame || frame.parent || event.sender.isDestroyed()) return + open(event.sender) + }) + + setPageEntries({ print: open }) + + return { + open, + info: () => + [...sessions.values()].map((s) => { + const cover = covers.info(s.page) + return { + dialog: s.dialog, + tabId: tabs.tabIdFor(s.page) ?? null, + host: cover.host, + onScreen: cover.onScreen, + focused: cover.focused, + ...(cover.bounds ? { bounds: cover.bounds } : {}), + preview: s.preview.state, + pages: s.preview.state === 'ready' ? s.preview.pages : null, + settings: s.settings, + destinations: s.destinations.map((d) => d.id), + busy: s.busy, + error: s.error ?? null, + } + }), + } +} + +/** The PDF the preview shows, and Save as PDF saves: the whole page, laid out as it prints. */ +function pdfOptions(settings: PrintSettings): Electron.PrintToPDFOptions { + return { + landscape: settings.landscape, + displayHeaderFooter: settings.headerFooter, + printBackground: settings.background, + pageSize: paperSize(), + } +} + +/** What a printer gets: the headers and footers name the page's title and address, as the preview does. */ +function printerOptions(page: WebContents, settings: PrintSettings, total: number): Electron.WebContentsPrintOptions { + const ranges = printerRanges(settings, total) + return { + deviceName: settings.destination, + copies: settings.copies, + landscape: settings.landscape, + color: settings.color, + printBackground: settings.background, + ...(ranges ? { pageRanges: ranges } : {}), + ...(settings.headerFooter ? { header: page.getTitle(), footer: page.getURL() } : {}), + } +} + +/** Letter where the system's region uses it (the Americas' Letter countries), else A4 (as Chromium's PDF). */ +function paperSize(): 'Letter' | 'A4' { + const region = app.getLocaleCountryCode().toUpperCase() + return ['US', 'CA', 'MX', 'PR', 'PH', 'CL', 'CO', 'VE', 'GT', 'CR', 'PA', 'DO', 'SV', 'NI', 'BO'].includes(region) + ? 'Letter' + : 'A4' +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out')), ms) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (err: unknown) => { + clearTimeout(timer) + reject(err) + }, + ) + }) +} diff --git a/src/main/peek/page-actions.ts b/src/main/peek/page-actions.ts index ed5d1507..4852654b 100644 --- a/src/main/peek/page-actions.ts +++ b/src/main/peek/page-actions.ts @@ -3,6 +3,7 @@ import { sameZoom, zoomPercent, zoomStep } from '../../shared/nav' import { cleanUrl, markdownLink } from '../../shared/tabs' import { fallbackTitle } from '../../shared/url' import { runDevtoolsAction } from '../devtools/control' +import { printPage } from '../pages/entry' /** Zooms a page one step in (1) or out (-1), or back to 100% (0), and returns its new zoom factor. */ export type PageZoom = (contents: WebContents, direction: 1 | -1 | 0) => number @@ -50,8 +51,9 @@ export function runPageAction(id: string, contents: WebContents, toast: (message toast(`Zoom ${zoomPercent(factor)}%`) return true } + // Orbit's print dialog over the page (pages lane, NV-07). case 'page.print': - contents.print() + printPage(contents) return true // DevTools dock inside the page's own view, as for a tab (DT-01). case 'devtools.toggle': diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index e1997536..461d66a6 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -26,6 +26,7 @@ import { engineForWindow, parseInput } from '../../shared/url' import type { MainContext } from '../context' import { KEEP_KEYBOARD } from '../keys/focus' import { TAB_MENU_KEYS, withKeyHints } from '../keys/menu-hints' +import { printPage } from '../pages/entry' import type { CallContext } from '../registry' import { tabSplitMenuItems } from '../split/menu' import { TabMaintenance } from './maintenance' @@ -783,10 +784,8 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const contents = tabs.contents(tabId) if (contents) fn(contents) }) - actions.register( - 'page.print', - onContents((w) => w.print()), - ) + // Orbit's print dialog over the page (pages lane, NV-07). + actions.register('page.print', onContents(printPage)) actions.register( 'page.viewSource', withActive((tabId, windowId) => { diff --git a/src/preload/page.ts b/src/preload/page.ts index e25d1e9f..cd8c6d51 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -18,8 +18,9 @@ * In a web page's main frame, the page's `alert`, `confirm` and `prompt` ask main, which shows * Orbit's dialog over that page only (NV-24): Electron has no `prompt`, and shows the others as * app-wide boxes. A frame has no preload (D40), so a dialog a frame asks for itself is the engine's - * own box (and a frame's `prompt` returns null). It also answers main about the leave-page warning - * (`exposeLeave`). + * own box (and a frame's `prompt` returns null). Its `window.print()` opens Orbit's print dialog over + * the page (NV-07); a frame's is the engine's, the system's own dialog. It also answers main about + * the leave-page warning (`exposeLeave`). */ import { contextBridge, ipcRenderer } from 'electron' import type { OrbitBridge, StatePatch } from '../shared/rpc' @@ -46,7 +47,7 @@ const notificationClicked = () => { * the page's globals). Built-ins are taken up front, so a page that replaces them later can't reach * Orbit's functions through them. */ -function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog) { +function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog, print: () => void) { // Calls go through the captured `apply`, never a function's own `.call`: a page that replaces // `Function.prototype.call` (or `Reflect.apply`) would otherwise receive Orbit's functions. const apply = Reflect.apply @@ -182,10 +183,25 @@ function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog) { // A Proxy keeps the engine's name, length and toString. define(window, name, { ...own, value: new Proxy(native, { apply: (_target, _self, args) => answer(args) }) }) } + + // window.print(): Orbit's print dialog over this page (NV-07), where the engine's would be the + // system's own dialog. It returns at once, and the page goes on while the dialog shows. + const nativePrint = window.print + const ownPrint = describe(window, 'print') + if (typeof nativePrint === 'function' && ownPrint?.configurable) { + const printNow = () => { + if (!going()) print() + } + define(window, 'print', { ...ownPrint, value: new Proxy(nativePrint, { apply: printNow }) }) + } } type AskDialog = (kind: 'alert' | 'confirm' | 'prompt', message: string, value: string) => unknown +const printDialog = () => { + ipcRenderer.send(PAGE_CHANNEL.print) +} + /** Set while Orbit probes the page's `beforeunload` (`exposeLeave`): the page's dialogs are ignored then. */ let dismissing = false @@ -399,7 +415,10 @@ if (location.protocol === 'orbit:') { } } else { try { - contextBridge.executeInMainWorld({ func: patchPage, args: [permissionState, notificationClicked, askDialog] }) + contextBridge.executeInMainWorld({ + func: patchPage, + args: [permissionState, notificationClicked, askDialog, printDialog], + }) } catch { // A page without its own world to patch keeps the engine's answers. } diff --git a/src/renderer/index.html b/src/renderer/index.html index 5f9d680c..df0cd9f0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ Orbit diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 789fedf4..93976fdc 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -56,6 +56,21 @@ async function boot(): Promise { ) return } + // The print dialog over a page (pages lane, NV-07). + if (view === 'print') { + const app = import('./pages/PrintApp') + await initStore() + installShortcutFallback() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { PrintApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } // An internal page (DT-13): Orbit's UI in a normal tab. Keys go to it as to any page. if (view === 'internal') { const app = import('./internal/InternalApp') diff --git a/src/renderer/src/pages/PrintApp.tsx b/src/renderer/src/pages/PrintApp.tsx new file mode 100644 index 00000000..2d78c6c1 --- /dev/null +++ b/src/renderer/src/pages/PrintApp.tsx @@ -0,0 +1,294 @@ +import { type CSSProperties, type KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { type PrintContent, type PrintSettings, pagesProblem, paperText, SAVE_AS_PDF } from '../../../shared/print' +import { invoke } from '../api' +import { useWindow } from '../store' +import { paletteVars, usePalette } from '../theme' +import './print.css' + +/** + * The print dialog (NV-07), in a transparent view main keeps over the page being printed: the page + * as it prints (the engine's PDF of it, in the PDF viewer, without its toolbar) beside the settings, + * and Print (Save, for Save as PDF) and Cancel. Main closes it on Esc wherever its keyboard is, the + * preview included (KS-19); Enter prints; Tab stays inside it. Main gives the keyboard back to the + * page when it goes (KS-16). + */ +export function PrintApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [content, setContent] = useState(null) + /** The preview's PDF as a blob URL, kept while the next one is made. */ + const [pdf, setPdf] = useState<{ version: number; url: string } | null>(null) + + useEffect(() => { + let heard = false + const take = (next: PrintContent | null) => { + setContent(next) + const preview = next?.preview + if (preview?.state !== 'ready') return + setPdf((was) => { + if (was?.version === preview.version) return was + if (was) URL.revokeObjectURL(was.url) + const url = URL.createObjectURL(new Blob([preview.pdf as Uint8Array], { type: 'application/pdf' })) + return { version: preview.version, url } + }) + } + const off = window.orbit.on('print:content', (payload) => { + heard = true + take(payload as PrintContent) + }) + // What main sent before this page listened is lost: ask for the dialog now. + void invoke('pages.printContent', {}).then((first) => { + if (!heard) take(first) + }) + return off + }, []) + + return ( +
+ {content && } +
+ ) +} + +function PrintDialog({ content, pdfUrl }: { content: PrintContent; pdfUrl?: string }) { + const form = useRef(null) + const primary = useRef(null) + // The dialog's own copy, so typing isn't held up by main's answer; main's comes back the same. + const [settings, setSettings] = useState(content.settings) + const [copies, setCopies] = useState(String(content.settings.copies)) + const { preview, busy } = content + const total = preview.state === 'ready' ? preview.pages : undefined + const problem = total === undefined ? undefined : pagesProblem(settings, total) + const pdfDestination = settings.destination === SAVE_AS_PDF + const ready = total !== undefined && !problem && !busy + + // Main may have changed what the dialog shows: a printer gone, the destination back to the PDF. + useEffect(() => { + setSettings((was) => + was.destination === content.settings.destination ? was : { ...was, destination: content.settings.destination }, + ) + }, [content.settings.destination]) + + const change = (next: Partial) => { + setSettings((was) => ({ ...was, ...next })) + void invoke('pages.printChange', { dialog: content.dialog, settings: next }) + } + const answer = (ok: boolean) => void invoke('pages.printAnswer', { dialog: content.dialog, ok }) + + // The keyboard starts on Print, once there is something to print; till then on the dialog. + useLayoutEffect(() => { + if (ready && (document.activeElement === document.body || document.activeElement === form.current)) { + primary.current?.focus() + } else if (!form.current?.contains(document.activeElement)) form.current?.focus() + }, [ready]) + // The view can get the keyboard after the dialog shows (main focuses it when the page is current). + useEffect(() => { + const onFocus = () => { + if (form.current?.contains(document.activeElement) && document.activeElement !== form.current) return + if (primary.current && !primary.current.disabled) primary.current.focus() + else form.current?.focus() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, []) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab' || !form.current) return + // Tab goes round the dialog's own controls. + const controls = [...form.current.querySelectorAll('select, input, button')].filter( + (el) => !(el as HTMLButtonElement).disabled, + ) + if (controls.length === 0) return + const at = controls.indexOf(document.activeElement as HTMLElement) + const next = e.shiftKey + ? at <= 0 + ? controls.length - 1 + : at - 1 + : at < 0 || at >= controls.length - 1 + ? 0 + : at + 1 + e.preventDefault() + controls[next]?.focus() + } + + return ( + { + e.preventDefault() + if (ready) answer(true) + }} + autoComplete="off" + > +
+ {pdfUrl && preview.state !== 'error' && ( +
`, + ), + ) + return + } if (u.pathname.startsWith('/page/')) { const name = decodeURIComponent(u.pathname.slice('/page/'.length)) send(html(name, `

${name}

`)) @@ -387,7 +401,6 @@ type PagesGlobal = { __orbitPages: { dialogs: () => PageDialogInfo[] leave: (tabId: string) => LeaveInfo | null - mayDiscard: (tabId: string) => boolean screens: () => PageScreenInfo[] launched: string[] fullScreen: (windowId: string) => FullScreenInfo @@ -448,12 +461,30 @@ export async function waitDialog( return found as PageDialogInfo } -export function leaveInfo(app: ElectronApplication, tabId: string): Promise { - return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.leave(id), tabId) +/** Runs a script in the frame (`#frame`) of the page whose URL is `url`; null when there's none yet. */ +export function inFrame(app: ElectronApplication, url: string, code: string): Promise { + return app.evaluate( + async ({ webContents }, [u, c]) => { + const frame = webContents.getAllWebContents().find((w) => w.getURL() === u)?.mainFrame.frames[0] + return frame ? frame.executeJavaScript(c).catch(() => null) : null + }, + [url, code] as const, + ) +} + +/** + * Clicks in the middle of a `/framed` page's frame, a `/warn` page, the way a person does (through + * the browser process), once the frame has loaded, and waits until the frame got the press. + */ +export async function clickInFrame(app: ElectronApplication, url: string): Promise { + await expect.poll(() => inFrame(app, url, 'document.readyState')).toBe('complete') + const before = Number(await inFrame(app, url, 'window.__clicks || 0')) + await clickInPage(app, url, '#frame') + await expect.poll(async () => Number(await inFrame(app, url, 'window.__clicks || 0'))).toBeGreaterThan(before) } -export function mayDiscard(app: ElectronApplication, tabId: string): Promise { - return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.mayDiscard(id), tabId) +export function leaveInfo(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.leave(id), tabId) } /** The dialog view (a Playwright page) showing the dialog with this id. */ From f5fe657f40704b12be42bbe736efa92aac1c87db Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:44:53 +0000 Subject: [PATCH 13/29] fix(pages): a page's close or navigation right after print() waits for the print dialog (NV-07) A page that writes a pop-up or a new tab, prints it and closes it at once, or prints and then sets location.href, lost the print dialog with the page. Chromium's print() blocks the script until its dialog closes; Orbit's dialog is its own view, so the page preload now holds the page's window.close() and the navigation it starts (push, replace, reload) while the dialog is open, and lets them go on when main says the dialog closed. The navigation goes on to the same URL and a new history entry, as it would have. orbit-page:print is now sendSync and replies whether the page has a print dialog open; orbit-page:print-closed (main to page) is new, and the preload patches window.close (D40 (4)). A form the page posts or a move through history right after print() isn't held: NV-07 is partial, with these and the missing print settings named in its note. Tests: the pop-up, new tab and print-then-location.href cases (REPRO J). Without the fix each fails: no print dialog shows (pages-print.spec.ts :272, :289, :307). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 6 +- docs/ARCHITECTURE.md | 2 +- docs/feature-status.json | 2 +- src/main/pages/print.ts | 20 ++++- src/preload/page.ts | 156 ++++++++++++++++++++++++++-------- src/shared/site.ts | 8 +- tests/e2e/pages-print.spec.ts | 72 +++++++++++++++- tests/e2e/pages-support.ts | 28 ++++++ 8 files changed, 246 insertions(+), 48 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index c660e168..3b7f51f3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 247 built (85 partial) · 337 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 246 built (86 partial) · 337 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 141 | 168 | -| P1 | 219 | 90 | 151 | +| P1 | 219 | 89 | 151 | | P2 | 79 | 16 | 18 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -234,7 +234,7 @@ Spec: `spec/navigation.md` | NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads | | NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | -| NV-07 | Print | P1 | ✅ | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page goes on (Chromium's waits for the dialog); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); no paper size, margins, scale, pages per sheet or two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | +| NV-07 | Print | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | | NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e+unit | – | Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links | | NV-09 | Links with target=_blank and script-opened windows | P0 | ✅ | ✅ e2e+unit | – | target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener | | NV-10 | New window | P0 | 🟡 partial | ✅ e2e | – | Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 300f635e..901bd868 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,7 +149,7 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. In the page's `beforeunload`, `pagehide` or `unload` handlers the calls return at once, as the engine's do: the patch reads the event being handled through the engine's own getters. The preload adds no `beforeunload` or `unload` listener of its own: with one, the engine would run every page's `beforeunload` before each navigation (a busy page would hold up Back, D60), and keep every page out of the back-forward cache. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. - `orbit-page:form-sent` (NV-13): the main frame is about to send a form (POST), from the Navigation API's `navigate` event in the preload's own world: the address it goes to. Main reads it only from a tab's or a pop-up's main frame, and it counts only for that frame's next navigation, to that address: Orbit's time limit for a server's first answer leaves that navigation alone, since it may be a large upload. A page that says so falsely only keeps the time limit off its own load. - `orbit-page:swipe` (NV-02): Electron reports no scroll phases to main, so a two-finger swipe over the page is told from the page's own wheel events. A passive listener in the preload's own world takes trusted pixel-delta wheel events in the main frame, after the page's handlers have run, and recognises a horizontal swipe (`src/preload/swipe.ts`). Unless something under the pointer can still scroll that way or the page called `preventDefault`, it sends how far the swipe has gone (`move`, a direction and px) and whether it was let go with the arrow fully in (`end`); nothing else. Main takes it only from a main frame of a page in a window's page card, clamps the travel, shows the arrow only when that page can go that way, and goes back or forward as the Back button does: no more than the page could do itself (`history.back()`). A new use under D40 (4), for the lead to log. -- `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), returns at once and asks main for Orbit's print dialog over that page; nothing else goes with it. Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. +- `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the page has one open now; nothing else goes with it. Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. While the dialog the page asked for is open, what would take the page away waits, as it would behind the engine's `print()`, which returns only once its dialog has closed: the page's `window.close()`, patched (D40), and a navigation the page starts that the Navigation API lets the preload stop, from its own world, and start again as it was (a link, `location`, a form that gets its page, a reload). When the dialog closes, main tells the page on `orbit-page:print-closed` (main → page, with nothing in it), and the close goes on, or else the page's last such navigation starts again. A form the page posts and a move through history go at once, and take the dialog with them. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog takes the keyboard when it shows over the page the user is on, as the engine's own does; a screen takes it only where the page had it, never from Orbit's own fields. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. diff --git a/docs/feature-status.json b/docs/feature-status.json index c1f87b20..408917e4 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -193,7 +193,7 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads"}, "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, - "NV-07": {"built": "yes", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page goes on (Chromium's waits for the dialog); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); no paper size, margins, scale, pages per sheet or two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, + "NV-07": {"built": "partial", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links"}, "NV-09": {"built": "yes", "note": "target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener"}, "NV-10": {"built": "partial", "note": "Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built"}, diff --git a/src/main/pages/print.ts b/src/main/pages/print.ts index d96bd8b6..0a286093 100644 --- a/src/main/pages/print.ts +++ b/src/main/pages/print.ts @@ -177,6 +177,8 @@ export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; p s.off() // The view goes, and the page gets the keyboard back (KS-16). covers.update(page) + // A close or a navigation the page started after its `print()` goes on now (the page preload). + if (!page.isDestroyed()) page.send(PAGE_CHANNEL.printClosed) } const preview = async (s: Session) => { @@ -323,11 +325,21 @@ export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; p else if (!s.busy) close(s.page) }) - // The page's own `window.print()` (the page preload): only a page's main frame has it (D40). + // The page's own `window.print()` (the page preload): only a page's main frame has it (D40). The + // reply says whether the page has a print dialog open now: its close and navigations wait for it. + // The reply goes out as soon as returnValue is set, so it is set once. ipcMain.on(PAGE_CHANNEL.print, (event: IpcMainEvent) => { - const frame = event.senderFrame - if (!frame || frame.parent || event.sender.isDestroyed()) return - open(event.sender) + let opened = false + try { + const frame = event.senderFrame + if (frame && !frame.parent && !event.sender.isDestroyed()) { + open(event.sender) + opened = sessions.has(event.sender.id) + } + } catch (err) { + console.error('[pages] print', err) + } + event.returnValue = opened }) setPageEntries({ print: open }) diff --git a/src/preload/page.ts b/src/preload/page.ts index c22f2b01..b316456f 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -20,8 +20,9 @@ * Orbit's dialog over that page only (NV-24): Electron has no `prompt`, and shows the others as * app-wide boxes. A frame has no preload (D40), so a dialog a frame asks for itself is the engine's * own box (and a frame's `prompt` returns null). Its `window.print()` opens Orbit's print dialog over - * the page (NV-07); a frame's is the engine's, the system's own dialog. It also answers main about - * the leave-page warning (`exposeLeave`). + * the page (NV-07), and its `window.close()` waits while that dialog is open (`holdForPrint`); a + * frame's `print()` is the engine's, the system's own dialog. It also answers main about the + * leave-page warning (`exposeLeave`). */ import { contextBridge, ipcRenderer } from 'electron' import { isWheelNotch } from '../renderer/src/shell/spaces/physics' @@ -50,7 +51,7 @@ const notificationClicked = () => { * the page's globals). Built-ins are taken up front, so a page that replaces them later can't reach * Orbit's functions through them. */ -function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog, print: () => void) { +function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog, print: () => void, holdClose: () => boolean) { // Calls go through the captured `apply`, never a function's own `.call`: a page that replaces // `Function.prototype.call` (or `Reflect.apply`) would otherwise receive Orbit's functions. const apply = Reflect.apply @@ -187,7 +188,9 @@ function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog, print: } // window.print(): Orbit's print dialog over this page (NV-07), where the engine's would be the - // system's own dialog. It returns at once, and the page goes on while the dialog shows. + // system's own dialog. It returns at once, and the page goes on while the dialog shows; what + // would take the page away waits for the dialog, as after the engine's `print()`, which returns + // only once its dialog closes (`holdForPrint`). const nativePrint = window.print const ownPrint = describe(window, 'print') if (typeof nativePrint === 'function' && ownPrint?.configurable) { @@ -196,12 +199,51 @@ function patchPage(stateOf: StateOf, clicked: () => void, ask: AskDialog, print: } define(window, 'print', { ...ownPrint, value: new Proxy(nativePrint, { apply: printNow }) }) } + // window.close() while the print dialog the page asked for is open: the page closes once the + // dialog does (`holdForPrint`). Otherwise it's the engine's. + const nativeClose = window.close + const ownClose = describe(window, 'close') + if (typeof nativeClose === 'function' && ownClose?.configurable) { + const closeNow = (target: () => void, self: unknown, args: unknown[]) => + holdClose() ? undefined : apply(target, self, args) + define(window, 'close', { ...ownClose, value: new Proxy(nativeClose, { apply: closeNow }) }) + } } type AskDialog = (kind: 'alert' | 'confirm' | 'prompt', message: string, value: string) => unknown +/** A navigation the page started, as the Navigation API's `navigate` event told it. */ +interface PageNavigation { + url: string + type: string + /** The form a submission came from, and its submit button. */ + form?: WeakRef + submitter?: WeakRef +} + +/** + * The print dialog the page asked for (NV-07): whether it's open, and what the page started while it + * was (`holdForPrint`). + */ +const printing: { open: boolean; close: boolean; navigation: PageNavigation | null } = { + open: false, + close: false, + navigation: null, +} + const printDialog = () => { - ipcRenderer.send(PAGE_CHANNEL.print) + try { + if (ipcRenderer.sendSync(PAGE_CHANNEL.print) === true) printing.open = true + } catch { + // No dialog: nothing waits for one. + } +} + +/** The page's `window.close()`: true when it waits for the print dialog. */ +const holdClose = (): boolean => { + if (!printing.open) return false + printing.close = true + return true } const askDialog: AskDialog = (kind, message, value) => { @@ -215,12 +257,36 @@ const askDialog: AskDialog = (kind, message, value) => { /** Main asks which navigation a warning stopped just after it; one the page started longer ago isn't it. */ const STOPPED_MS = 2_000 -interface StoppedNavigation { - url: string - type: string - /** The form a submission came from, and its submit button. */ - form?: WeakRef - submitter?: WeakRef +/** The Navigation API's `navigate` event, as far as this reads it. */ +type NavigateEventLike = Event & { + destination?: { url?: string; sameDocument?: boolean } + navigationType?: string + formData?: FormData | null + sourceElement?: Element | null + downloadRequest?: string | null +} + +/** + * Starts a navigation of the page's again, from here: a link or `location` loads its URL (as a new + * history entry or in place of this one, as it did), a form is submitted again, a reload reloads. + * False when it can't be: a move through history, or a form that's gone or changed. + */ +function restart(again: PageNavigation): boolean { + if (again.type === 'traverse') return false + if (again.type === 'reload') location.reload() + else if (again.form) { + const form = again.form.deref() + const submitter = again.submitter?.deref() + if (!form?.isConnected) return false + try { + form.requestSubmit((submitter as HTMLButtonElement | undefined)?.form === form ? submitter : null) + } catch { + // The form changed: the user submits it again. + return false + } + } else if (again.type === 'replace') location.replace(again.url) + else location.assign(again.url) + return true } /** @@ -242,16 +308,11 @@ interface StoppedNavigation { function exposeLeave(): void { const nav = (window as { navigation?: EventTarget }).navigation /** The last cross-document navigation the page started, its event, and when (`performance.now()`). */ - let starting: (StoppedNavigation & { event: Event; at: number }) | null = null + let starting: (PageNavigation & { event: Event; at: number }) | null = null /** The navigation main last asked about: the one a warning stopped. */ - let stopped: StoppedNavigation | null = null + let stopped: PageNavigation | null = null nav?.addEventListener('navigate', (event) => { - const e = event as Event & { - destination?: { url?: string; sameDocument?: boolean } - navigationType?: string - formData?: FormData | null - sourceElement?: Element | null - } + const e = event as NavigateEventLike if (!e.destination?.url || e.destination.sameDocument) return if (e.formData) ipcRenderer.send(PAGE_CHANNEL.formSent, e.destination.url) const source = e.sourceElement ?? null @@ -276,21 +337,7 @@ function exposeLeave(): void { const redo = (): boolean => { const again = stopped stopped = null - if (!again || again.type === 'traverse') return false - if (again.type === 'reload') location.reload() - else if (again.form) { - const form = again.form.deref() - const submitter = again.submitter?.deref() - if (!form?.isConnected) return false - try { - form.requestSubmit((submitter as HTMLButtonElement | undefined)?.form === form ? submitter : null) - } catch { - // The form changed: the user submits it again. - return false - } - } else if (again.type === 'replace') location.replace(again.url) - else location.assign(again.url) - return true + return !!again && restart(again) } Object.defineProperty(globalThis, '__orbitLeave', { value: Object.freeze({ @@ -306,6 +353,40 @@ function exposeLeave(): void { }) } +/** + * While the print dialog the page asked for is open (NV-07), what would take the page away waits, + * as it would behind the engine's `print()`, which returns only once its dialog has closed: the + * page's `window.close()` (patched in `patchPage`), and a navigation it starts that the Navigation + * API lets this stop and that can start again as it was (a link, `location`, a form that gets its + * page, a reload: the same address, and the same kind of history entry). Once main says the dialog + * closed (`PAGE_CHANNEL.printClosed`), the close goes on, or else the page's last such navigation + * starts again. A form the page posts goes at once (sending it again would run the page's own + * submit handlers twice), and so does a move through history, which the engine doesn't let a + * script stop: either takes the dialog with it. + */ +function holdForPrint(): void { + const nav = (window as { navigation?: EventTarget }).navigation + nav?.addEventListener('navigate', (event) => { + const e = event as NavigateEventLike + if (!printing.open || !e.cancelable || !e.destination?.url || e.destination.sameDocument) return + // A download leaves the page where it is; a posted form isn't sent twice. + if (e.downloadRequest != null || e.formData) return + const type = e.navigationType ?? 'push' + if (type !== 'push' && type !== 'replace' && type !== 'reload') return + e.preventDefault() + printing.navigation = { url: e.destination.url, type } + }) + ipcRenderer.on(PAGE_CHANNEL.printClosed, () => { + if (!printing.open) return + const { close, navigation } = printing + printing.open = false + printing.close = false + printing.navigation = null + if (close) window.close() + else if (navigation) restart(navigation) + }) +} + type PatchListener = (seq: number, patches: StatePatch[]) => void /** @@ -432,10 +513,13 @@ if (location.protocol === 'orbit:') { try { contextBridge.executeInMainWorld({ func: patchPage, - args: [permissionState, notificationClicked, askDialog, printDialog], + args: [permissionState, notificationClicked, askDialog, printDialog, holdClose], }) } catch { // A page without its own world to patch keeps the engine's answers. } - if (window.top === window) exposeLeave() + if (window.top === window) { + exposeLeave() + holdForPrint() + } } diff --git a/src/shared/site.ts b/src/shared/site.ts index 3877326c..d16d0401 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -213,10 +213,14 @@ export const PAGE_CHANNEL = { */ dialog: 'orbit-page:dialog', /** - * send(): the page called `window.print()` (NV-07). Main shows Orbit's print dialog over that - * page, for a tab's or a pop-up's main frame only (src/main/pages/print.ts). + * sendSync(): the page called `window.print()` (NV-07). Main shows Orbit's print dialog over that + * page, for a tab's or a pop-up's main frame only, and replies at once whether the page has one + * open now (src/main/pages/print.ts). While it does, the page's `window.close()` and the + * navigations it starts wait (src/preload/page.ts). */ print: 'orbit-page:print', + /** Main → page: the page's print dialog closed (NV-07); what waited on it goes on. */ + printClosed: 'orbit-page:print-closed', /** * send('move', direction, travel) and send('end', direction, commit): a two-finger swipe over the * page that the page doesn't take (NV-02, src/preload/swipe.ts). Main shows the arrow and goes back diff --git a/tests/e2e/pages-print.spec.ts b/tests/e2e/pages-print.spec.ts index 7a056aa3..364e094a 100644 --- a/tests/e2e/pages-print.spec.ts +++ b/tests/e2e/pages-print.spec.ts @@ -11,7 +11,7 @@ import { startPagesFixtures, waitPrint, } from './pages-support' -import { newTab, waitTab } from './tabs-support' +import { newTab, tab, waitTab } from './tabs-support' /** * Print (NV-07): Orbit's print dialog over the page. Test runs print nothing and save nothing: the @@ -62,6 +62,24 @@ async function printTab(app: ElectronApplication, scrolled = false) { return { tabId, page, pageState } } +/** Pages titled "Receipt copy" that are open: the copies `/receipt` writes and prints. */ +function receiptCopies(app: ElectronApplication): Promise { + return app.evaluate( + ({ webContents }) => + webContents.getAllWebContents().filter((w) => !w.isDestroyed() && w.getTitle() === 'Receipt copy').length, + ) +} + +/** A tab's history: its entries' addresses, and which one it's on. */ +function tabHistory(app: ElectronApplication, url: string): Promise<{ urls: string[]; index: number } | null> { + return app.evaluate(({ webContents }, u) => { + const page = webContents.getAllWebContents().find((w) => w.getURL() === u) + if (!page) return null + const history = page.navigationHistory + return { urls: history.getAllEntries().map((e) => e.url), index: history.getActiveIndex() } + }, url) +} + /** Presses Esc where the keyboard is, as the keyboard does (through the browser process). */ function pressEscape(app: ElectronApplication): Promise { return app.evaluate(({ webContents }) => { @@ -242,3 +260,55 @@ test('[NV-07] the page menu’s Print… and a Peek’s ⌘P open it over that p expect((await state(app)).windows[windowId]?.overlay.kind).toBe('peek') expect(await printedJobs(app)).toEqual([]) }) + +test('[NV-07] a pop-up window the page writes, prints and closes at once shows the print dialog over it; the window closes once the dialog has printed', async () => { + const { app } = orbit + const url = fx.url('/receipt?how=popup') + const tabId = await newTab(app, url) + await waitTab(app, tabId, '/receipt') + await clickInPage(app, url, '#go') + // The page's close waits for the dialog, as it would behind the engine's print(), which returns + // only once its dialog has closed. + const dialog = await waitPrint(app, (d) => d.host === 'popup' && d.onScreen && d.preview === 'ready') + expect(await receiptCopies(app)).toBe(1) + const view = await printPage(app, dialog.dialog) + await view.getByTestId('print-destination').selectOption('Office') + await view.getByTestId('print-ok').click() + await expect.poll(() => receiptCopies(app)).toBe(0) + expect(await printedJobs(app)).toEqual([expect.objectContaining({ kind: 'printer', url: 'about:blank' })]) + expect(await printDialogs(app)).toEqual([]) + expect(await tab(app, tabId)).toBeTruthy() +}) + +test('[NV-07] a new tab the page writes, prints and closes at once shows the print dialog over it; Cancel closes the dialog, and then the tab', async () => { + const { app } = orbit + const url = fx.url('/receipt?how=tab') + const tabId = await newTab(app, url) + await waitTab(app, tabId, '/receipt') + await clickInPage(app, url, '#go') + const opened = await waitPrint(app, (d) => d.host === 'tab' && !!d.tabId && d.tabId !== tabId) + const copy = opened.tabId as string + await run(app, 'tabs.activate', { tabId: copy }) + const dialog = await waitPrint(app, (d) => d.tabId === copy && d.onScreen && d.preview === 'ready') + expect(await tab(app, copy)).toBeTruthy() + const view = await printPage(app, dialog.dialog) + await view.getByTestId('print-cancel').click() + await expect.poll(async () => await tab(app, copy)).toBeUndefined() + expect(await printedJobs(app)).toEqual([]) + expect(await tab(app, tabId)).toBeTruthy() +}) + +test('[NV-07] a page that prints and goes to another page at once shows the print dialog first; once it closes, the page goes on as it would have, to a new history entry', async () => { + const { app } = orbit + const url = fx.url('/receipt?how=nav') + const tabId = await newTab(app, url) + await waitTab(app, tabId, '/receipt') + await clickInPage(app, url, '#go') + const dialog = await waitPrint(app, (d) => d.tabId === tabId && d.onScreen && d.preview === 'ready') + expect((await tab(app, tabId))?.url).toBe(url) + const view = await printPage(app, dialog.dialog) + await view.getByTestId('print-cancel').click() + await waitTab(app, tabId, '/page/Printed') + expect(await printDialogs(app)).toEqual([]) + expect(await tabHistory(app, fx.url('/page/Printed'))).toEqual({ urls: [url, fx.url('/page/Printed')], index: 1 }) +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index 00082bcc..82737fb5 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -63,6 +63,9 @@ const html = (title: string, body: string, head = '') => ` * - `/print`: a few pages long, with a field (`#field`), a button that calls `window.print()` * (`#print`), and `window.__print`: its `beforeprint` and `afterprint` events, whether `print()` * returned, and how many times the tab has loaded it (`loads`). + * - `/receipt?how=popup|tab|nav`: a button (`#go`) that prints a receipt as shops' pages do: it + * writes "Receipt copy" into a new pop-up window (`popup`) or tab (`tab`), prints it and closes it + * at once; or (`nav`) prints this page and goes on to `/page/Printed` at once. * - `/swipe`: a strip that scrolls sideways (`#strip`, scrolled to its middle), and an area that uses * the wheel itself (`#grab`, preventDefault), as a map does. * @@ -227,6 +230,31 @@ document.getElementById('print').addEventListener('click', () => { window.print() window.__print.returned = true }) +`, + ), + ) + return + } + if (u.pathname === '/receipt') { + const how = u.searchParams.get('how') ?? 'popup' + send( + html( + 'Receipt', + `

Receipt

+`, ), ) From cf8ed965bcc1e2dbeab3b951f6e12053f28622eb Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:14 +0000 Subject: [PATCH 14/29] fix(pages): a page's dialog takes the keyboard only where the page had it (D72) A page could time a confirm() to catch keys meant for Orbit: the dialog took the keyboard from the find bar or a sidebar field (a tab being renamed), and the user's next Enter answered the page. A dialog now follows the screens' rule: it takes the keyboard when it shows over the page the user is on only if the page had it. The find bar, a sidebar field and the address field keep theirs; the dialog takes it once the user clicks it or the keyboard next goes to the page. Tests: the rename field and the find bar keep their keys while a timed confirm() shows (review REPRO G and H); the dialog then takes the keyboard. Without the fix both fail: the dialog has the keyboard as soon as it shows (pages-dialogs.spec.ts :172, :197). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/feature-status.json | 2 +- src/main/pages/dialogs.ts | 25 ++++++++--- tests/e2e/pages-dialogs.spec.ts | 74 ++++++++++++++++++++++++++++++++- 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 3b7f51f3..58498c69 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -251,7 +251,7 @@ Spec: `spec/navigation.md` | NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | -| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | +| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | | NV-25 | Share the current page | P2 | – | – | – | | ## Peek link preview (prefix PK) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 901bd868..78eed02d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -151,7 +151,7 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:swipe` (NV-02): Electron reports no scroll phases to main, so a two-finger swipe over the page is told from the page's own wheel events. A passive listener in the preload's own world takes trusted pixel-delta wheel events in the main frame, after the page's handlers have run, and recognises a horizontal swipe (`src/preload/swipe.ts`). Unless something under the pointer can still scroll that way or the page called `preventDefault`, it sends how far the swipe has gone (`move`, a direction and px) and whether it was let go with the arrow fully in (`end`); nothing else. Main takes it only from a main frame of a page in a window's page card, clamps the travel, shows the arrow only when that page can go that way, and goes back or forward as the Back button does: no more than the page could do itself (`history.back()`). A new use under D40 (4), for the lead to log. - `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the page has one open now; nothing else goes with it. Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. While the dialog the page asked for is open, what would take the page away waits, as it would behind the engine's `print()`, which returns only once its dialog has closed: the page's `window.close()`, patched (D40), and a navigation the page starts that the Navigation API lets the preload stop, from its own world, and start again as it was (a link, `location`, a form that gets its page, a reload). When the dialog closes, main tells the page on `orbit-page:print-closed` (main → page, with nothing in it), and the close goes on, or else the page's last such navigation starts again. A form the page posts and a move through history go at once, and take the dialog with them. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. -A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog takes the keyboard when it shows over the page the user is on, as the engine's own does; a screen takes it only where the page had it, never from Orbit's own fields. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. +A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog or a screen takes the keyboard when it shows over the page the user is on, but only where the page had it, never from Orbit's own fields (the find bar, a sidebar field, the address field), so a page can't time a dialog to catch keys meant for Orbit (D72); it takes the keyboard once the user clicks it or the keyboard next goes to the page. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its `print()` opens the system's dialog, so Orbit shows its own dialog as a cover over the page. The preview is the engine's PDF of the page (`printToPDF`, which runs the page's `beforeprint` and `afterprint` and leaves the page as it was), shown in the engine's PDF viewer in a frame of the cover's view (a `blob:` URL: the renderer's CSP allows `blob:` frames for it). One function sends a job out (`deliverPrint`: `print()` to the printer chosen, without the system's dialog, or the PDF saved where the user says); test runs replace it with a recorder, and take their printers from `ORBIT_E2E_PRINTERS`. Callers outside the lane (the Print action for a tab, a Peek or a Mini Window, the page menu) open the dialog through `src/main/pages/entry.ts`, as they open a page's source (DT-04, `view-source.ts`: the page's own address as `view-source:` in a new tab; a Mini Window's in a main window). diff --git a/docs/feature-status.json b/docs/feature-status.json index 408917e4..1c1ca90a 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -208,7 +208,7 @@ "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, - "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, + "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts index d77dd068..b6697795 100644 --- a/src/main/pages/dialogs.ts +++ b/src/main/pages/dialogs.ts @@ -1,4 +1,4 @@ -import { type IpcMainEvent, ipcMain, type WebContents } from 'electron' +import { webContents as allContents, type IpcMainEvent, ipcMain, type WebContents } from 'electron' import { type PageDialogAnswer, type PageDialogContent, scriptAnswer, scriptDialog } from '../../shared/pages' import type { Rect } from '../../shared/rpc' import { PAGE_CHANNEL } from '../../shared/site' @@ -11,6 +11,8 @@ interface Dialog { resolve: (answer: PageDialogAnswer | null) => void /** The page's own alert, confirm or prompt: it goes when the page navigates. */ script: boolean + /** The page had the keyboard when the dialog came: it takes the keyboard when it shows (D72). */ + hadKeyboard: boolean } export interface PageDialogInfo { @@ -52,8 +54,10 @@ export interface PageDialogs$ { * window stays usable: other tabs, the sidebar, the command bar. A dialog waits while its page is * off screen (another tab is selected, the window is closed to the Library) and shows again when * the page does. Each page's dialogs show one at a time in a transparent view over the page - * (`createCovers`), which takes the keyboard (Enter, Esc and Tab go to the dialog first, KS-19) and - * gives it back to the page when the last one closes (KS-16). + * (`createCovers`), which takes the keyboard where the page had it (Enter, Esc and Tab go to the + * dialog first, KS-19) and gives it back to the page when the last one closes (KS-16). Where Orbit's + * own UI has the keyboard (the find bar, a sidebar field, the address field), it stays there, and + * the dialog takes it once the user clicks the dialog or the keyboard next goes to the page (D72). */ export function registerDialogs(ctx: MainContext): PageDialogs$ { const { tabs, commands } = ctx @@ -61,7 +65,18 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { const queues = new Map() let nextId = 1 - const covers = createCovers(ctx, { view: 'dialog', content: (page) => queues.get(page.id)?.list[0]?.content }) + /** Whether nothing, or the page itself, has the keyboard. */ + const pageHasKeyboard = (page: WebContents, focused = allContents.getFocusedWebContents() ?? null) => + !focused || focused.isDestroyed() || focused === page + + const covers = createCovers(ctx, { + view: 'dialog', + content: (page) => queues.get(page.id)?.list[0]?.content, + // A dialog takes the keyboard only where the page had it, as a screen does (D72): a page can't + // time one to catch keys meant for the find bar or a sidebar field. + mayTakeKeyboard: (page, focused) => + pageHasKeyboard(page, focused) || queues.get(page.id)?.list[0]?.hadKeyboard === true, + }) const matches = (dialog: Dialog, which: DialogsWhich) => which === 'all' || (which === 'script' ? dialog.script : dialog.content.kind === 'hang') @@ -96,7 +111,7 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { for (const dialog of made.list.splice(0)) dialog.resolve(null) }) } - queue.list.push({ content: { ...content, id: nextId++ }, resolve, script }) + queue.list.push({ content: { ...content, id: nextId++ }, resolve, script, hadKeyboard: pageHasKeyboard(page) }) if (queue.list.length === 1) covers.update(page) }) }, diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index a7edbdc3..2dbc1d96 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { action, isMac, launchOrbit, type Orbit, state } from './helpers' -import { clickInPage, closeWindow, cmdIn, pressInPage, windowState } from './nav-support' +import { clickInPage, closeWindow, cmdIn, pressInPage, toolsPage, windowState } from './nav-support' import { AUTH_PASSWORD, AUTH_USER, @@ -134,6 +134,78 @@ test('[NV-24][KS-19][KS-16] prompt and alert: Enter answers, Esc cancels, and th await answered(alerting, 'undefined') }) +/** + * Types each of `keys` (a character, or a key such as 'Enter') wherever the keyboard is, as the + * keyboard does: Enter comes with its character too, which a form's submit button acts on. + * Returns the address of the view that took each. + */ +function typeWhereKeyboardIs(keys: string[]): Promise { + return orbit.app.evaluate( + ({ webContents }, list) => + list.map((key) => { + const contents = webContents.getFocusedWebContents() + if (!contents) return 'nothing' + const char = key === 'Enter' ? '\r' : [...key].length === 1 ? key : null + contents.sendInputEvent({ type: 'keyDown', keyCode: key }) + if (char) contents.sendInputEvent({ type: 'char', keyCode: char }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key }) + return contents.getURL() + }), + keys, + ) +} + +test('[NV-24][KS-19] a dialog the page times leaves the keyboard in a tab being renamed; it takes it when the keyboard goes back to the page (D72)', async () => { + const { app, shell } = orbit + const url = fx.url('/ask?kind=confirm&msg=Timed') + const asking = await newTab(app, url) + await waitTab(app, asking, '/ask') + // The user renames the tab in the sidebar, which has the keyboard (as after a click there)... + await row(shell, asking).getByTestId('tab-title').dblclick() + const field = shell.getByTestId('rename-field') + await expect(field).toBeFocused() + await cmd(app, 'spaces.focusShell') + await expect.poll(focusedUrl).toContain('view=shell') + // ...as the page asks (review REPRO G). + await inPage(app, url, 'setTimeout(ask, 0); 0') + const shown = await waitDialog(app, (d) => d.tabId === asking && d.onScreen) + expect(shown.focused).toBe(false) + // What the user types goes on into the field, and Enter saves the name: the page isn't answered. + const typed = await typeWhereKeyboardIs(['M', 'a', 'i', 'l', 'Enter']) + expect(typed.every((at) => at.includes('view=shell'))).toBe(true) + await expect(row(shell, asking).getByTestId('tab-title')).toHaveText('Mail') + expect(await waitDialog(app, (d) => d.id === shown.id)).toMatchObject({ onScreen: true, focused: false }) + // Once the keyboard goes back to the page, the dialog has it, and Enter answers. + await cmd(app, 'tabs.focusPage') + await expect.poll(async () => (await waitDialog(app, (d) => d.id === shown.id)).focused).toBe(true) + expect(await typeWhereKeyboardIs(['Enter'])).toEqual([expect.stringContaining('view=dialog')]) + await answered(asking, 'true') +}) + +test('[NV-24][KS-19][NV-05] a dialog the page times leaves the keyboard in the find bar; it takes it when the bar closes (D72)', async () => { + const { app } = orbit + const url = fx.url('/ask?kind=confirm&msg=Timed') + const asking = await newTab(app, url) + await waitTab(app, asking, '/ask') + await pressInPage(app, url, 'F', [MOD_KEY]) + const input = (await toolsPage(app)).getByTestId('find-input') + await expect(input).toBeFocused() + await expect.poll(focusedUrl).toContain('view=tools') + // The page asks while the user types a search (review REPRO H). + await inPage(app, url, 'setTimeout(ask, 0); 0') + const shown = await waitDialog(app, (d) => d.tabId === asking && d.onScreen) + expect(shown.focused).toBe(false) + const typed = await typeWhereKeyboardIs(['A', 's', 'k', 'Enter']) + expect(typed.every((at) => at.includes('view=tools'))).toBe(true) + await expect(input).toHaveValue('Ask') + expect(await waitDialog(app, (d) => d.id === shown.id)).toMatchObject({ onScreen: true, focused: false }) + // Esc closes the bar and gives the page the keyboard: the dialog takes it, and Esc there cancels. + expect(await typeWhereKeyboardIs(['Escape'])).toEqual([expect.stringContaining('view=tools')]) + await expect.poll(async () => (await waitDialog(app, (d) => d.id === shown.id)).focused).toBe(true) + expect(await typeWhereKeyboardIs(['Escape'])).toEqual([expect.stringContaining('view=dialog')]) + await answered(asking, 'false') +}) + test('[NV-24] a background tab’s dialog waits until its tab shows', async () => { const { app, shell } = orbit const url = fx.url('/ask?kind=confirm&msg=Later') From 02588904c7ab5f78a7099fb60f1ccf8c92946120 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:39 +0000 Subject: [PATCH 15/29] fix(pages): an always-allowed site opens another app once per user gesture (NV-19) With Always allow on, nothing limited launches: one click and a timer that followed a mailto: link 20 times opened the mail app 19 times (review REPRO B). As in Chromium, each link to another app now uses up its page's user gesture (a mouse press or a key in the page, counted as the tab runtime counts them), and an always-allowed link opens the app only with a gesture no such link has used yet. It doesn't use the tab runtime's consumeGesture, as the review suggested: a target=_blank link to the app has already used that gesture to open its tab (NV-09), and the engine names the opener as the page that asks, so Always allow would never open such a link. Test: one click, then a timer that follows the link 20 times: the app opens once, and again on the next click. Without the fix it fails: 22 launches instead of 3 (pages-apps.spec.ts :162). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/feature-status.json | 2 +- src/main/pages/other-apps.ts | 15 +++++++++++++-- tests/e2e/pages-apps.spec.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 58498c69..93ca6f4d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -246,7 +246,7 @@ Spec: `spec/navigation.md` | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | -| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | +| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | | NV-20 | Full screen | P1 | ✅ | ✅ e2e+unit | – | ⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7) | | NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 78eed02d..7500ea23 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -155,7 +155,7 @@ A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit U Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its `print()` opens the system's dialog, so Orbit shows its own dialog as a cover over the page. The preview is the engine's PDF of the page (`printToPDF`, which runs the page's `beforeprint` and `afterprint` and leaves the page as it was), shown in the engine's PDF viewer in a frame of the cover's view (a `blob:` URL: the renderer's CSP allows `blob:` frames for it). One function sends a job out (`deliverPrint`: `print()` to the printer chosen, without the system's dialog, or the PDF saved where the user says); test runs replace it with a recorder, and take their printers from `ORBIT_E2E_PRINTERS`. Callers outside the lane (the Print action for a tab, a Peek or a Mini Window, the page menu) open the dialog through `src/main/pages/entry.ts`, as they open a page's source (DT-04, `view-source.ts`: the page's own address as `view-source:` in a new tab; a Mini Window's in a main window). -Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. +Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. An always-allowed link opens the app once per user gesture in its page (a mouse press or a key in the page, as the tab runtime counts them), as in Chromium: each link to another app uses up the page's gesture, so a page can't open the app again and again unasked. It isn't the tab runtime's `consumeGesture`: that one a `target=_blank` link to the app has already used to open its tab, and the engine names the opener as the page that asks. Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change which of that page's own navigations "Leave site?" names and starts again. Whether a page warns is never the preload's word: closing a tab, a window or a Space, auto-archive and sleep close the page through the engine (`webContents.close({ waitForBeforeUnload: true })`, D73), which runs every frame's `beforeunload` only once the close has started and says when one warns (`will-prevent-unload`); the page then stays, and Orbit asks (auto-archive and sleep keep it without asking). diff --git a/docs/feature-status.json b/docs/feature-status.json index 1c1ca90a..5806bb8d 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -204,7 +204,7 @@ "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators wait on TB-15; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, - "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, + "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, diff --git a/src/main/pages/other-apps.ts b/src/main/pages/other-apps.ts index fbff5709..057da46e 100644 --- a/src/main/pages/other-apps.ts +++ b/src/main/pages/other-apps.ts @@ -16,6 +16,7 @@ import type { ProfileId, TabId } from '../../shared/state' import { sectionOf } from '../../shared/tree' import type { MainContext } from '../context' import { setAppLinkHandler } from '../nav/permissions' +import { isActivation } from '../tabs/runtime' import { activeTabOf } from '../windows' import type { PageDialogs$ } from './dialogs' @@ -166,7 +167,8 @@ function neverShowedPage(page: WebContents): boolean { * followed the link. Orbit asks over that page, in a page dialog: the app with its icon, the site, * and "Always allow" for a web site; Cancel is the default. Open launches the app through * `launch`, the one place Orbit starts another app. A site always allowed for a kind of link opens - * it without asking. A page asks one question at a time: a link it follows meanwhile is dropped. + * it without asking, once per user gesture in the page, as in Chromium: a page can't open the app + * again and again unasked. A page asks one question at a time: a link it follows meanwhile is dropped. * * A tab, Peek, Mini Window or pop-up opened only for such a link (`target=_blank`, `window.open`, * or a redirect there) never shows a page: it closes itself once its navigation ends, and the tab @@ -194,6 +196,8 @@ export function registerOtherApps( const asking = new Set() /** Where each watched page's main frame is going, or went last: the address after any redirect. */ const going = new Map() + /** Pages (by contents id) with a user gesture that no link to another app has used yet. */ + const gestured = new Set() const launchOnly = (page: WebContents) => !page.isDestroyed() && !asking.has(page.id) && !!appScheme(going.get(page.id)) && neverShowedPage(page) @@ -250,9 +254,13 @@ export function registerOtherApps( page.on('did-stop-loading', () => { if (launchOnly(page)) setImmediate(() => closeLaunchOnly(page)) }) + page.on('input-event', (_event, input) => { + if (isActivation(input)) gestured.add(id) + }) page.once('destroyed', () => { going.delete(id) asking.delete(id) + gestured.delete(id) }) } ctx.hooks.tabCreated.push((_tabId, contents) => { @@ -301,8 +309,11 @@ export function registerOtherApps( if (!scheme || page.isDestroyed() || !dialogs.hostable(page)) return // The site asking is the frame that followed the link (the page's own, or one in it). const origin = permissionOrigin(details.requestingUrl) + // Each link to another app uses up the page's last user gesture (Chromium's rule). + const fresh = gestured.delete(page.id) if (origin && /^https?:\/\//i.test(origin) && choices.has(profileId, origin, scheme)) { - launch(page, url) + // Always allowed: it opens without asking, but only for a gesture no link used yet (NV-19). + if (fresh) launch(page, url) return } if (asking.has(page.id)) return diff --git a/tests/e2e/pages-apps.spec.ts b/tests/e2e/pages-apps.spec.ts index abf9f007..75c3d03e 100644 --- a/tests/e2e/pages-apps.spec.ts +++ b/tests/e2e/pages-apps.spec.ts @@ -136,6 +136,33 @@ test('[NV-19] Open launches the app; “Always allow” opens that site’s link expect(await noDialogs(orbit.app)).toBe(true) }) +test('[NV-19] an always-allowed site opens the app once per user gesture: a timer that follows its link again and again opens it once', async () => { + const { app } = orbit + const { page } = await openApps(app) + await page.locator('#mail').click() + const asked = await waitDialog(app, (d) => d.kind === 'app' && d.focused) + const view = await dialogPage(app, asked.id) + await view.getByTestId('page-dialog-check').check() + await view.getByTestId('page-dialog-ok').click() + await expect.poll(() => launchedLinks(app)).toEqual([MAIL]) + // One click in the page, then a timer that follows the link 20 times (review REPRO B). + await page.locator('#area').click() + await page.evaluate((mail) => { + const w = window as unknown as { __tries: number } + w.__tries = 0 + const timer = setInterval(() => { + location.href = mail + if (++w.__tries === 20) clearInterval(timer) + }, 20) + }, MAIL) + await expect.poll(() => page.evaluate(() => (window as unknown as { __tries: number }).__tries)).toBe(20) + // The timer's first link used the click and opened the app; the other 19 opened nothing. A new + // click opens it again. + await page.locator('#mail').click() + await expect.poll(() => launchedLinks(app)).toEqual([MAIL, MAIL, MAIL]) + expect(await noDialogs(app)).toBe(true) +}) + test('[NV-19] a tab or pop-up opened only to launch an app closes itself: the question shows over the page it came from', async () => { const { app } = orbit const { tabId, page } = await openApps(app) From 0754dd3eab9c7ebf17420cf1c53319152759efff Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:50 +0000 Subject: [PATCH 16/29] fix(pages): a dialog from an about:blank page names its opener's site (NV-24) A tab or pop-up that its opener opens empty (about:blank) and scripts said "This page says" and named no site, though the page has its opener's origin. The dialog's title now names the asking page's site from its origin when its address isn't a web one, as Chromium's does: an about:blank page its opener writes into names the opener's (" says"); a data: or file: page, with an origin that names no site, still says "This page says". Tests: an e2e test of an empty tab and an empty pop-up that its opener has ask confirm() (review REPRO I), and a unit test of the title from an origin. Without the fix the e2e test fails: "This page says" (pages-dialogs.spec.ts :219). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/pages/dialogs.ts | 5 ++++- src/shared/pages.ts | 4 +++- tests/e2e/pages-dialogs.spec.ts | 19 +++++++++++++++++++ tests/e2e/pages-support.ts | 20 ++++++++++++++++++++ tests/unit/pages-dialogs.test.ts | 7 +++++++ 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 93ca6f4d..3d1fb11e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -251,7 +251,7 @@ Spec: `spec/navigation.md` | NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | -| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | +| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (" says"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | | NV-25 | Share the current page | P2 | – | – | – | | ## Peek link preview (prefix PK) diff --git a/docs/feature-status.json b/docs/feature-status.json index 5806bb8d..62eb09ac 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -208,7 +208,7 @@ "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, - "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, + "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts index b6697795..386b6a3a 100644 --- a/src/main/pages/dialogs.ts +++ b/src/main/pages/dialogs.ts @@ -186,7 +186,10 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { reply(scriptAnswer(kind, null)) return } - const content = scriptDialog(kind, frame.url, String(message ?? ''), String(value ?? '')) + // An empty page (`about:blank`) that its opener writes into is the opener's: the dialog names + // the opener's site, as Chromium's does, from the page's origin when its address isn't a web one. + const asking = /^https?:/i.test(frame.url) ? frame.url : frame.origin + const content = scriptDialog(kind, asking, String(message ?? ''), String(value ?? '')) service.open(page, content, true).then( (answer) => reply(scriptAnswer(kind, answer)), () => reply(scriptAnswer(kind, null)), diff --git a/src/shared/pages.ts b/src/shared/pages.ts index 4fa31421..0be80ee1 100644 --- a/src/shared/pages.ts +++ b/src/shared/pages.ts @@ -63,7 +63,9 @@ export function dialogText(text: string): string { /** * Who a dialog says asks (Chromium's wording): " says" for a web page, its host with the - * port; "This page says" for anything else (a local file, `data:`, `about:blank`). + * port; "This page says" for anything else (a local file, `data:`, an origin that names no site). + * Give it the page's address, or its origin where the address isn't a web one: an `about:blank` + * page its opener writes into has the opener's origin. */ export function dialogTitle(pageUrl: string | undefined): string { try { diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index 2dbc1d96..8715441a 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -206,6 +206,25 @@ test('[NV-24][KS-19][NV-05] a dialog the page times leaves the keyboard in the f await answered(asking, 'false') }) +test('[NV-24] a dialog from an empty tab or pop-up that its opener scripts names the opener’s site', async () => { + const { app } = orbit + for (const how of ['tab', 'popup'] as const) { + const url = fx.url(`/blank-ask?how=${how}`) + const opener = await newTab(app, url) + await waitTab(app, opener, '/blank-ask') + await clickInPage(app, url, '#go') + // The empty page is about:blank, with its opener's origin (review REPRO I). + const shown = await waitDialog(app, (d) => d.message === 'From the empty page' && d.host === how && d.onScreen) + if (how === 'tab') expect(shown.tabId).not.toBe(opener) + expect(shown.title).toBe(`localhost:${fx.port} says`) + const view = await dialogPage(app, shown.id) + await expect(view.getByTestId('page-dialog-title')).toHaveText(`localhost:${fx.port} says`) + // A pop-up's dialog has a view of its own, which goes with the dialog. + await clickClosing(view.getByTestId('page-dialog-ok')) + await expect.poll(async () => (await pageDialogs(app)).some((d) => d.id === shown.id)).toBe(false) + } +}) + test('[NV-24] a background tab’s dialog waits until its tab shows', async () => { const { app, shell } = orbit const url = fx.url('/ask?kind=confirm&msg=Later') diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index 82737fb5..72dfab9a 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -66,6 +66,8 @@ const html = (title: string, body: string, head = '') => ` * - `/receipt?how=popup|tab|nav`: a button (`#go`) that prints a receipt as shops' pages do: it * writes "Receipt copy" into a new pop-up window (`popup`) or tab (`tab`), prints it and closes it * at once; or (`nav`) prints this page and goes on to `/page/Printed` at once. + * - `/blank-ask?how=tab|popup`: a button (`#go`) that opens an empty (`about:blank`) tab or pop-up + * window and has it ask `confirm('From the empty page')`; that page's title says what it returned. * - `/swipe`: a strip that scrolls sideways (`#strip`, scrolled to its middle), and an area that uses * the wheel itself (`#grab`, preventDefault), as a map does. * @@ -230,6 +232,24 @@ document.getElementById('print').addEventListener('click', () => { window.print() window.__print.returned = true }) +`, + ), + ) + return + } + if (u.pathname === '/blank-ask') { + const how = u.searchParams.get('how') ?? 'tab' + send( + html( + 'Blank asker', + `
+`, ), ) diff --git a/tests/unit/pages-dialogs.test.ts b/tests/unit/pages-dialogs.test.ts index cf5e4b72..9605f38e 100644 --- a/tests/unit/pages-dialogs.test.ts +++ b/tests/unit/pages-dialogs.test.ts @@ -25,6 +25,13 @@ describe('[NV-24] page dialogs: who asks', () => { expect(dialogTitle(url)).toBe('This page says') } }) + + it('names the site of an origin: an about:blank page’s is its opener’s', () => { + expect(dialogTitle('http://localhost:3341')).toBe('localhost:3341 says') + expect(dialogTitle('https://example.com')).toBe('example.com says') + // An opaque origin (a data: page, a sandboxed one) or a file's names no site. + for (const origin of ['null', 'file://']) expect(dialogTitle(origin)).toBe('This page says') + }) }) describe('[NV-24] page dialogs: the page’s alert, confirm and prompt', () => { From bfb3aa9120bc23399e3f3a75d89caa339da0875f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:55 +0000 Subject: [PATCH 17/29] test(pages): the four page channels refuse a sender that isn't a page's main frame (review follow-up 13) Frames have no preload, so e2e can't send from one. A unit test with a fake senderFrame sends the dialog, print, swipe and form-sent messages from a frame and from the page's main frame: a frame gets the engine's answer at once and opens no dialog, opens no print dialog, shows no swipe arrow, and can't take the page's form off the load timer. Without the frame checks (in a scratch copy) each of the four cases fails. Co-Authored-By: Claude Opus 5.5 --- tests/unit/pages-senders.test.ts | 159 +++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/unit/pages-senders.test.ts diff --git a/tests/unit/pages-senders.test.ts b/tests/unit/pages-senders.test.ts new file mode 100644 index 00000000..8a60a95f --- /dev/null +++ b/tests/unit/pages-senders.test.ts @@ -0,0 +1,159 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' + +/** What each module listens for on ipcMain: the handlers under test. */ +const handlers = new Map void>() + +vi.mock('electron', () => ({ + app: { on: () => {}, getLocaleCountryCode: () => 'CH', getPath: () => '/tmp' }, + BrowserWindow: { fromWebContents: () => null, getAllWindows: () => [] }, + dialog: {}, + ipcMain: { + on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => handlers.set(channel, handler), + handle: () => {}, + }, + systemPreferences: { isSwipeTrackingFromScrollEventsEnabled: () => true }, + webContents: { getFocusedWebContents: () => null, getAllWebContents: () => [] }, + WebContentsView: class {}, +})) +// The windows module pulls in the whole shell; these modules use only these of it. +vi.mock('../../src/main/windows', () => ({ + activeTabOf: () => 't1', + loadRenderer: () => {}, + rendererEntry: () => '', + shownTabs: () => new Set(['t1']), + uiWebPreferences: () => ({}), + viewContents: () => undefined, +})) + +import type { MainContext } from '../../src/main/context' +import { type PageDialogs$, registerDialogs } from '../../src/main/pages/dialogs' +import type { FullScreen$ } from '../../src/main/pages/fullscreen' +import { registerPrint } from '../../src/main/pages/print' +import { registerScreens } from '../../src/main/pages/screens' +import { registerSwipe } from '../../src/main/pages/swipe' +import type { TabOps } from '../../src/main/tabs/ops' +import { PAGE_CHANNEL } from '../../src/shared/site' + +const MAIN_FRAME = { frameTreeNodeId: 1, parent: null, url: 'https://a.test/', origin: 'https://a.test' } +/** A frame in the page, from another site: it has no preload, but a compromised renderer can send. */ +const SUB_FRAME = { frameTreeNodeId: 2, parent: MAIN_FRAME, url: 'https://b.test/', origin: 'https://b.test' } + +/** A tab's page (tab `t1`, in window `w1`), as far as these modules look at it. */ +function fakePage() { + return Object.assign(new EventEmitter(), { + id: 7, + mainFrame: MAIN_FRAME, + session: {}, + navigationHistory: { canGoBack: () => true, canGoForward: () => true }, + isDestroyed: () => false, + isLoading: () => false, + getURL: () => 'https://a.test/', + getTitle: () => 'A', + printToPDF: async () => Buffer.from('%PDF-1.4\n'), + send: () => {}, + focus: () => {}, + }) +} + +/** + * The context the modules get: one window `w1` showing tab `t1`, and nothing drawn (its page has + * no place, unless `windows` gives one). + */ +function fakeContext(windows: Record = {}) { + const win = Object.assign(new EventEmitter(), { isDestroyed: () => false, isFocused: () => true }) + const ctx = { + store: { state: { tabs: { t1: { id: 't1', title: 'A' } }, windows: {}, splits: {} }, subscribe: () => () => {} }, + tabs: { tabIdFor: () => 't1', get: () => undefined, contents: () => undefined }, + windows: { + get: () => win, + windowOfTab: () => 'w1', + onLayout: () => {}, + isUi: () => false, + focusedWindowId: () => 'w1', + ids: () => ['w1'], + hasPageCover: () => false, + tabViewBounds: () => undefined, + createUiView: () => undefined, + showUiView: () => {}, + hideUiView: () => {}, + ...windows, + }, + commands: { register: () => {}, run: async () => {} }, + hooks: { tabCreated: [] as ((tabId: string, page: unknown) => void)[] }, + } + return ctx as unknown as MainContext & { hooks: { tabCreated: ((tabId: string, page: unknown) => void)[] } } +} + +/** A message from the page, sent by `frame`. */ +function from(page: ReturnType, frame: typeof MAIN_FRAME | typeof SUB_FRAME) { + return { sender: page, senderFrame: frame, returnValue: undefined as unknown } +} + +function send(channel: string, event: ReturnType, ...args: unknown[]) { + const handler = handlers.get(channel) + if (!handler) throw new Error(`nothing listens on ${channel}`) + handler(event, ...args) +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('[NV-24][NV-07][NV-02][NV-13] the page channels take a page’s main frame only (D40, review follow-up 13)', () => { + it('alert, confirm and prompt: a frame gets the engine’s answer at once, and no dialog shows', () => { + const dialogs = registerDialogs(fakeContext()) + const page = fakePage() + const framed = from(page, SUB_FRAME) + send(PAGE_CHANNEL.dialog, framed, 'confirm', 'Sure?', '') + expect(framed.returnValue).toBe(false) + expect(dialogs.pending(page as unknown as Electron.WebContents)).toBe(false) + // The page's own: it waits for the user's answer. + const own = from(page, MAIN_FRAME) + send(PAGE_CHANNEL.dialog, own, 'confirm', 'Sure?', '') + expect(own.returnValue).toBeUndefined() + expect(dialogs.pending(page as unknown as Electron.WebContents)).toBe(true) + }) + + it('print: a frame’s print() opens no print dialog', () => { + const print = registerPrint(fakeContext(), { deliver: async () => ({ ok: true }), printers: async () => [] }) + const page = fakePage() + const framed = from(page, SUB_FRAME) + send(PAGE_CHANNEL.print, framed) + expect(framed.returnValue).toBe(false) + expect(print.info()).toEqual([]) + const own = from(page, MAIN_FRAME) + send(PAGE_CHANNEL.print, own) + expect(own.returnValue).toBe(true) + expect(print.info()).toHaveLength(1) + }) + + it('swipe: a frame’s swipe shows no arrow', () => { + const swipe = registerSwipe( + fakeContext({ tabViewBounds: () => ({ x: 0, y: 0, width: 800, height: 600 }), createUiView: () => ({}) }), + ) + const page = fakePage() + send(PAGE_CHANNEL.swipe, from(page, SUB_FRAME), 'move', 'back', 40) + expect(swipe.info('w1').tabId).toBeNull() + send(PAGE_CHANNEL.swipe, from(page, MAIN_FRAME), 'move', 'back', 40) + expect(swipe.info('w1').tabId).toBe('t1') + }) + + it('a form on its way: a frame can’t take the page’s navigation off the load timer', () => { + vi.useFakeTimers() + const timedAfter = (frame: typeof MAIN_FRAME | typeof SUB_FRAME) => { + const ctx = fakeContext() + const dialogs = { dismiss: () => {}, pending: () => false, hostable: () => true } as unknown as PageDialogs$ + registerScreens(ctx, {} as TabOps, dialogs, { onKey: () => {} } as unknown as FullScreen$, 30_000) + const page = fakePage() + for (const hook of ctx.hooks.tabCreated) hook('t1', page) + send(PAGE_CHANNEL.formSent, from(page, frame), 'https://a.test/upload') + const before = vi.getTimerCount() + page.emit('did-start-navigation', { url: 'https://a.test/upload', isMainFrame: true, isSameDocument: false }) + return vi.getTimerCount() - before + } + // The page's own form isn't timed (it may be a large upload); one a frame claims is. + expect(timedAfter(MAIN_FRAME)).toBe(0) + expect(timedAfter(SUB_FRAME)).toBe(1) + }) +}) From bde5a0e165d953bd3e25b90c0bdb97fab87396fe Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:04:01 +0000 Subject: [PATCH 18/29] test(pages): full screen's paired Esc presses go in one call; the busy-page check takes D46 rounds (review follow-up 7) Two checks could fail under load: - Full screen: the second Esc must come within a second of the first, and polls ran between the two presses. The presses that must be paired now go in one call, with nothing between them. - The busy page: a navigation Orbit starts on a page kept busy must start within a second. It was measured once; it is now judged on the best of up to three rounds (D46). Co-Authored-By: Claude Opus 5.5 --- tests/e2e/pages-dialogs.spec.ts | 49 +++++++++++++----------- tests/e2e/pages-fullscreen.spec.ts | 60 ++++++++++++++++++------------ 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index 8715441a..a943907a 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -439,28 +439,33 @@ test('[NV-24][KS-16] a page’s confirm or prompt while it goes is ignored, as t // A busy page doesn't hold up a navigation Orbit starts: the engine runs a page's beforeunload // first only when it has a handler, and Orbit adds none (D60). Nor an unload one, which would - // keep every page out of the back-forward cache. - const plain = fx.url('/page/Plain') - const id = await newTab(app, plain) - await waitTab(app, id, '/page/Plain') - const waited = await app.evaluate( - async ({ webContents }, [u, next]) => { - const page = webContents.getAllWebContents().find((w) => w.getURL() === u) - if (!page) return -1 - void page.executeJavaScript('{ const end = Date.now() + 3000; while (Date.now() < end) {} }').catch(() => {}) - await new Promise((r) => setTimeout(r, 200)) - const t0 = Date.now() - const started = new Promise((resolve) => { - page.once('did-start-navigation', () => resolve(Date.now() - t0)) - }) - page.loadURL(next).catch(() => {}) - return started - }, - [plain, fx.url('/page/Next')] as const, - ) - expect(waited).toBeGreaterThanOrEqual(0) - expect(waited).toBeLessThan(1000) - await waitTab(app, id, '/page/Next') + // keep every page out of the back-forward cache. Judged on the best of up to three rounds (D46). + const rounds: number[] = [] + for (let round = 1; round <= 3; round++) { + const busy = fx.url(`/page/Busy${round}`) + const id = await newTab(app, busy) + await waitTab(app, id, `/page/Busy${round}`) + const waited = await app.evaluate( + async ({ webContents }, [u, next]) => { + const page = webContents.getAllWebContents().find((w) => w.getURL() === u) + if (!page) return -1 + void page.executeJavaScript('{ const end = Date.now() + 3000; while (Date.now() < end) {} }').catch(() => {}) + await new Promise((r) => setTimeout(r, 200)) + const t0 = Date.now() + const started = new Promise((resolve) => { + page.once('did-start-navigation', () => resolve(Date.now() - t0)) + }) + page.loadURL(next).catch(() => {}) + return started + }, + [busy, fx.url(`/page/Next${round}`)] as const, + ) + expect(waited).toBeGreaterThanOrEqual(0) + rounds.push(waited) + await waitTab(app, id, `/page/Next${round}`) + if (waited < 1000) break + } + expect(Math.min(...rounds), `rounds: ${rounds.join(', ')} ms`).toBeLessThan(1000) }) test('[NV-24] closing a tab whose page warns asks over it: Cancel keeps the tab, Leave closes it', async () => { diff --git a/tests/e2e/pages-fullscreen.spec.ts b/tests/e2e/pages-fullscreen.spec.ts index 5a9fa225..8fee3cf1 100644 --- a/tests/e2e/pages-fullscreen.spec.ts +++ b/tests/e2e/pages-fullscreen.spec.ts @@ -78,25 +78,37 @@ async function escPage(app: ElectronApplication, prevent = true) { return { tabId, page, escapes: () => page.evaluate(() => (window as unknown as { __escapes: string[] }).__escapes) } } -/** Presses Esc the way the keyboard does (through the browser process), in the view at `urlPart`; `repeats`: held that long. */ -function pressEscape(app: ElectronApplication, urlPart: string, repeats = 0): Promise { - return sendToPage(app, urlPart, [ +/** + * Presses Esc the way the keyboard does (through the browser process), in the view at `urlPart`; + * `repeats`: held that long. `presses` presses go in one call, so no wait comes between them. + */ +function pressEscape(app: ElectronApplication, urlPart: string, repeats = 0, presses = 1): Promise { + const press = [ { type: 'keyDown', keyCode: 'Escape' }, ...Array.from({ length: repeats }, () => ({ type: 'keyDown', keyCode: 'Escape', modifiers: ['isAutoRepeat'] })), { type: 'keyUp', keyCode: 'Escape' }, - ]) + ] + return sendToPage(app, urlPart, Array.from({ length: presses }, () => press).flat()) } -/** Presses Esc once in a tab's page, as the keyboard does: while it loads another address too. */ -function pressEscapeInTab(app: ElectronApplication, tabId: string): Promise { - return app.evaluate((_e, id) => { - const g = globalThis as unknown as { - __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents } } } - } - const page = g.__orbit.ctx.tabs.contents(id) - page.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) - page.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) - }, tabId) +/** + * Presses Esc in a tab's page, as the keyboard does: while it loads another address too. + * `presses` presses go in one call, so no wait comes between them. + */ +function pressEscapeInTab(app: ElectronApplication, tabId: string, presses = 1): Promise { + return app.evaluate( + (_e, [id, count]) => { + const g = globalThis as unknown as { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents } } } + } + const page = g.__orbit.ctx.tabs.contents(id) + for (let i = 0; i < count; i++) { + page.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) + page.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) + } + }, + [tabId, presses] as const, + ) } /** The Esc presses Orbit waited on while a tab loaded, oldest first (keys lane, NV-04). */ @@ -193,14 +205,16 @@ test('[NV-20][KS-02] Esc goes to the page first: one press keeps full screen, an await pressEscape(app, '/esc') await expect.poll(escapes).toEqual(['keydown', 'keyup', 'keydown', 'keyup']) expect(await win(app)).toMatchObject({ fullscreen: true, sidebar: false }) - // Again at once: full screen ends, and the page doesn't get that press. - await pressEscape(app, '/esc') + // Two presses at once: full screen ends, and the page doesn't get the press that ends it. (Which + // of the two that is depends on how long the check above took: a press within a second of the + // one before leaves, and out of full screen the next press is the page's.) + const pair = ['keydown', 'keyup', 'keydown', 'keyup', 'keydown', 'keyup'] + await pressEscape(app, '/esc', 0, 2) await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) - expect(await escapes()).toEqual(['keydown', 'keyup', 'keydown', 'keyup']) + await expect.poll(escapes).toEqual(pair) // Out of full screen, Esc is the page's alone. - await pressEscape(app, '/esc') - await pressEscape(app, '/esc') - await expect.poll(async () => (await escapes()).length).toBe(8) + await pressEscape(app, '/esc', 0, 2) + await expect.poll(async () => (await escapes()).length).toBe(10) expect(await win(app)).toMatchObject({ fullscreen: false, sidebar: true }) }) @@ -304,10 +318,10 @@ test('[NV-20][NV-04] on a loading page, the Esc that leaves full screen stops no await enterFullScreen(app, '/esc') await run(app, 'tabs.navigate', { tabId, url: silent.url('/never') }) await expect.poll(async () => (await state(app)).tabs[tabId]?.loading).toBe(true) - // The page uses the first press, so the load goes on (NV-04). - await pressEscapeInTab(app, tabId) + // The page uses the first press, so the load goes on (NV-04); the second, in the same call, leaves + // full screen. + await pressEscapeInTab(app, tabId, 2) await expect.poll(() => escapeLog(app)).toMatchObject([{ used: true, stopped: false }]) - await pressEscapeInTab(app, tabId) await expect.poll(() => win(app)).toMatchObject({ fullscreen: false, sidebar: true }) // Longer than Orbit waits for a page's answer to an Esc (250 ms): no wait for that press, no stop. await new Promise((r) => setTimeout(r, 600)) From 441805027b12d108414882cc5ff62c1998e657b7 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:04:07 +0000 Subject: [PATCH 19/29] docs(pages): NV-05's held-keys limits and NV-02's hosts in the notes (review follow-ups 10, 15) NV-05: "text as text" holds for characters on the keyboard's own layout; a held character that an auto-type tool maps onto the keyboard on the fly, which Chromium names "\u0000", is lost (N3's third case). NV-02: the swipe isn't offered over a Peek's, a Mini Window's or a pop-up's page, where it does nothing; the spec doesn't say. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 4 ++-- docs/feature-status.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 3d1fb11e..8bfd050f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -229,10 +229,10 @@ Spec: `spec/navigation.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | NV-01 | Back and forward | P0 | ✅ | ✅ e2e+unit | – | buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert | -| NV-02 | Trackpad two-finger swipe back/forward | P1 | ✅ | ✅ e2e+unit | – | a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check) | +| NV-02 | Trackpad two-finger swipe back/forward | P1 | ✅ | ✅ e2e+unit | – | a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Not offered over a Peek's, a Mini Window's or a pop-up's page, where the swipe does nothing (the spec doesn't say). Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check) | | NV-03 | Reload and hard reload | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page | | NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads | -| NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed | +| NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names "\u0000", is lost (chrome fix check 4) | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | | NV-07 | Print | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | | NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e+unit | – | Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links | diff --git a/docs/feature-status.json b/docs/feature-status.json index 62eb09ac..ec34980f 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -188,10 +188,10 @@ "PR-08": {"built": "partial", "note": "profiles.delete refuses Default and profiles in use, erases the profile's Favorites and clears its stored data; the partition folder stays on disk and the Settings button is the settings lane's"}, "PR-09": {"built": "partial", "note": "profiles.rename command; the Settings UI is the settings lane's"}, "NV-01": {"built": "yes", "note": "buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert"}, - "NV-02": {"built": "yes", "note": "a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check)"}, + "NV-02": {"built": "yes", "note": "a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Not offered over a Peek's, a Mini Window's or a pop-up's page, where the swipe does nothing (the spec doesn't say). Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check)"}, "NV-03": {"built": "yes", "note": "Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page"}, "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads"}, - "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed"}, + "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names \"\\u0000\", is lost (chrome fix check 4)"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, "NV-07": {"built": "partial", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links"}, From 512a0a8cc41e0727c3ed30e91c1d246956ea8a4d Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:58:41 +0000 Subject: [PATCH 20/29] =?UTF-8?q?fix(pages):=20the=20player=20keeps=20clea?= =?UTF-8?q?r=20of=20a=20page's=20dialogs=20and=20screens,=20and=20of=20Sav?= =?UTF-8?q?e=20Page=20As=E2=80=A6=20and=20Save=20as=20PDF=20(MD-06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a cover (a page's dialog, the print dialog, an error, crash or hang screen) is on screen, its window counts as showing a dialog in dialog-watch; the note ends when the cover leaves the screen. Save Page As… and Print's Save as PDF wrap their save dialogs in withDialog; the page menu's Save path goes through savePage, so nothing is wrapped twice. pages-player.spec.ts: the player moves to the nearest clear corner while a page's confirm, the print dialog and a crash screen show, goes home while the dialog waits off screen in a background tab, and comes back when each closes. Co-Authored-By: Claude Opus 5.5 --- src/main/pages/covers.ts | 25 ++++++ src/main/pages/print.ts | 6 +- src/main/pages/save-page.ts | 5 +- tests/e2e/pages-player.spec.ts | 145 +++++++++++++++++++++++++++++++++ tests/e2e/pages-support.ts | 23 ++++++ 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/pages-player.spec.ts diff --git a/src/main/pages/covers.ts b/src/main/pages/covers.ts index 7ea0d6df..5917e918 100644 --- a/src/main/pages/covers.ts +++ b/src/main/pages/covers.ts @@ -2,6 +2,7 @@ import { webContents as allContents, BrowserWindow, type WebContents, WebContent import type { Rect, RendererView } from '../../shared/rpc' import type { WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { withDialog } from '../media/dialog-watch' import { activeTabOf, loadRenderer, rendererEntry, uiWebPreferences, viewContents } from '../windows' /** @@ -33,6 +34,8 @@ interface Cover { sent?: number /** The view is in its window, over the page. */ onScreen: boolean + /** While it is: ends the dialog-watch note that its window shows a dialog (MD-06). */ + unnote?: () => void off: () => void } @@ -66,6 +69,9 @@ export interface PageCovers { * * A main window's views are made when first needed and kept for the next cover; a Peek's, a Mini * Window's or a pop-up's view is its own and is closed when the cover goes. + * + * While a cover is on screen, its window counts as showing a dialog (src/main/media/dialog-watch.ts), + * so the picture-in-picture player keeps clear of it (MD-06). */ export function createCovers( ctx: MainContext, @@ -144,6 +150,13 @@ export function createCovers( ) } + /** The cover left the screen: its window no longer shows it as a dialog (MD-06). */ + const unnote = (c: Cover) => { + const end = c.unnote + c.unnote = undefined + end?.() + } + const hasKeyboard = (view: WebContentsView | undefined) => { const contents = viewContents(view) return !!contents && allContents.getFocusedWebContents() === contents @@ -182,6 +195,7 @@ export function createCovers( if (c.view !== view) continue c.ready = false c.onScreen = false + unnote(c) } }) } @@ -246,6 +260,7 @@ export function createCovers( const view = c.view const host = c.host c.onScreen = false + unnote(c) if (!view || !host || !viewContents(view)) return const had = hasKeyboard(view) if (host.kind === 'tab' && host.windowId) windows.hideUiView(host.windowId, view) @@ -356,6 +371,16 @@ export function createCovers( if (!view.getVisible()) view.setVisible(true) } c.onScreen = true + if (!c.unnote) { + let end = () => {} + void withDialog( + host.win, + new Promise((resolve) => { + end = resolve + }), + ) + c.unnote = end + } if (!appearing || !pageIsCurrent(c, host)) return const focused = allContents.getFocusedWebContents() ?? null if (opts.mayTakeKeyboard && !opts.mayTakeKeyboard(page, focused)) return diff --git a/src/main/pages/print.ts b/src/main/pages/print.ts index 0a286093..43971e00 100644 --- a/src/main/pages/print.ts +++ b/src/main/pages/print.ts @@ -21,6 +21,7 @@ import type { Rect } from '../../shared/rpc' import { PAGE_CHANNEL } from '../../shared/site' import type { TabId } from '../../shared/state' import type { MainContext } from '../context' +import { withDialog } from '../media/dialog-watch' import { createCovers, type HostKind } from './covers' import { setPageEntries } from './entry' @@ -57,7 +58,10 @@ export const deliverPrint: DeliverPrint = async (page, job) => { filters: [{ name: 'PDF', extensions: ['pdf'] }], } const win = BrowserWindow.fromWebContents(page) - const { canceled, filePath } = await (win ? dialog.showSaveDialog(win, options) : dialog.showSaveDialog(options)) + // The picture-in-picture player keeps clear of it (MD-06). + const { canceled, filePath } = await (win + ? withDialog(win, dialog.showSaveDialog(win, options)) + : dialog.showSaveDialog(options)) if (canceled || !filePath) return { ok: false } await writeFile(filePath, job.data) return { ok: true } diff --git a/src/main/pages/save-page.ts b/src/main/pages/save-page.ts index 966e9426..1e256da8 100644 --- a/src/main/pages/save-page.ts +++ b/src/main/pages/save-page.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { app, dialog, type WebContents } from 'electron' import type { MainContext } from '../context' +import { withDialog } from '../media/dialog-watch' import { saveFormat, savesComplete } from '../nav/page-menu' import { setPageEntries } from './entry' @@ -35,7 +36,9 @@ export function registerSavePage(ctx: MainContext): void { const options = complete ? { defaultPath: `${name}.html`, filters: [html, single] } : { defaultPath: `${name}.mhtml`, filters: [single, html] } - const asked = win && !win.isDestroyed() ? dialog.showSaveDialog(win, options) : dialog.showSaveDialog(options) + // The picture-in-picture player keeps clear of it (MD-06). + const asked = + win && !win.isDestroyed() ? withDialog(win, dialog.showSaveDialog(win, options)) : dialog.showSaveDialog(options) void asked.then(({ canceled, filePath }) => { if (canceled || !filePath || page.isDestroyed()) return // Asked again: the page may have changed while the dialog was open. diff --git a/tests/e2e/pages-player.spec.ts b/tests/e2e/pages-player.spec.ts new file mode 100644 index 00000000..1dffb34c --- /dev/null +++ b/tests/e2e/pages-player.spec.ts @@ -0,0 +1,145 @@ +import { type ElectronApplication, expect, test } from '@playwright/test' +import type { MediaTestHooks } from '../../src/main/media' +import type { PlayerInfo } from '../../src/main/media/player' +import { isMac, launchOrbit, type Orbit, tabPage } from './helpers' +import { clickInPage, pressInPage } from './nav-support' +import { + crashTab, + dialogPage, + type PagesFixtures, + PRINTERS_ENV, + printDialogs, + printPage, + screenPage, + startPagesFixtures, + waitDialog, + waitNoScreen, + waitPrint, + waitScreen, +} from './pages-support' +import { cmd, newTab, waitTab } from './tabs-support' + +/** + * Orbit's floating player keeps clear of what covers a page (MD-06): the page's own dialogs + * (NV-24), the print dialog (NV-07) and the page's screens (NV-13, NV-14), as of Orbit's alerts. + */ + +let fx: PagesFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit({ env: PRINTERS_ENV }) +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +type Box = { x: number; y: number; width: number; height: number } + +/** ⌘ on macOS, Ctrl elsewhere, for pressInPage. */ +const MOD_KEY = isMac ? 'meta' : 'control' + +/** Orbit's floating player, as the main process sees it. */ +function player(app: ElectronApplication): Promise { + return app.evaluate(() => + (globalThis as unknown as { __orbitMedia: MediaTestHooks }).__orbitMedia.manager.player.info(), + ) +} + +/** The player's place for a corner of the work area (16 px margins). */ +function cornerBox(area: Box, corner: string, width: number, height: number): Box { + const left = corner.endsWith('left') + const top = corner.startsWith('top') + return { + x: left ? area.x + 16 : area.x + area.width - 16 - width, + y: top ? area.y + 16 : area.y + area.height - 16 - height, + width, + height, + } +} + +/** Moves Orbit's first window, as the user would drag it. */ +function placeWindow(app: ElectronApplication, bounds: Box): Promise { + return app.evaluate((_e, b) => { + const { __orbit } = globalThis as unknown as { + __orbit: { + windowIds: () => string[] + ctx: { windows: { get: (id: string) => Electron.BrowserWindow | undefined } } + } + } + __orbit.ctx.windows.get(__orbit.windowIds()[0] as string)?.setBounds(b) + }, bounds) +} + +test('[MD-06][NV-24][NV-07][NV-14] the player keeps clear of a page’s dialog, the print dialog and a crash screen while they show, and goes back when they close', async () => { + const { app } = orbit + // A video plays in a tab; another tab comes up, and the video goes on in the player. + const video = await newTab(app, fx.url('/video/Film')) + await waitTab(app, video, '/video/Film') + const film = await tabPage(app, '/video/Film') + await expect.poll(() => film.evaluate(() => document.body.dataset.state ?? ''), { timeout: 20_000 }).toBe('playing') + const url = fx.url('/ask?kind=confirm&msg=Go%20on%3F') + const asking = await newTab(app, url) + await waitTab(app, asking, '/ask') + await expect + .poll(async () => { + const p = await player(app) + return p.tabId === video && p.visible && p.holdsView && !p.animating + }) + .toBe(true) + const area = (await player(app)).workArea as Box + const home = cornerBox(area, 'bottom-right', 320, 180) + const aside = cornerBox(area, 'top-right', 320, 180) + /** Where the player shows: null when it is out of sight. */ + const where = async () => { + const p = await player(app) + return p.tabId === video && p.visible && !p.animating ? p.bounds : null + } + expect(await where()).toEqual(home) + + // Orbit's window sits in the bottom-right of the screen, under the player: the window alone + // doesn't move it. + await placeWindow(app, { x: area.x + area.width - 600, y: area.y + area.height - 500, width: 600, height: 500 }) + expect(await where()).toEqual(home) + + // The page asks: the player moves to the nearest corner clear of the dialog, the top-right one. + await clickInPage(app, url, '#ask') + const asked = await waitDialog(app, (d) => d.tabId === asking && d.onScreen) + await expect.poll(where).toEqual(aside) + // While the dialog waits off screen, its tab in the background, the player goes back... + const other = await newTab(app, fx.url('/page/Other')) + await waitTab(app, other, '/page/Other') + await expect.poll(async () => (await waitDialog(app, (d) => d.id === asked.id)).onScreen).toBe(false) + await expect.poll(where).toEqual(home) + // ...and moves aside again when it shows. + await cmd(app, 'tabs.activate', { tabId: asking }) + await expect.poll(async () => (await waitDialog(app, (d) => d.id === asked.id)).onScreen).toBe(true) + await expect.poll(where).toEqual(aside) + // Answered, the dialog goes, and the player goes back. + await (await dialogPage(app, asked.id)).getByTestId('page-dialog-ok').click() + await expect.poll(where).toEqual(home) + + // The print dialog over the page: the same. + await pressInPage(app, url, 'P', [MOD_KEY]) + const printing = await waitPrint(app, (d) => d.tabId === asking && d.onScreen) + await expect.poll(where).toEqual(aside) + await (await printPage(app, printing.dialog)).getByTestId('print-cancel').click() + await expect.poll(() => printDialogs(app)).toEqual([]) + await expect.poll(where).toEqual(home) + + // The page crashes: its screen shows in its place, and the player keeps clear of it until Reload. + await crashTab(app, asking) + const crashed = await waitScreen(app, (s) => s.tabId === asking && s.onScreen) + await expect.poll(where).toEqual(aside) + await (await screenPage(app, crashed.id)).getByTestId('page-screen-action').click() + await waitNoScreen(app, asking) + await expect.poll(where).toEqual(home) + // The video played on throughout. + expect(await film.evaluate(() => (document.getElementById('v') as HTMLVideoElement).paused)).toBe(false) +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index 72dfab9a..5746c994 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -41,6 +41,23 @@ const html = (title: string, body: string, head = '') => ` ${body}` +/** + * A video that plays with sound as it loads, drawn on a canvas (no media file): the body's + * `data-state` is `playing` once it plays. Leaving its tab floats it in Orbit's player (MD-01). + */ +const VIDEO = ` + +` + /** * - `/ask?kind=alert|confirm|prompt&msg=…&value=…&on=click|load`: asks once (on a click of `#ask`, or * as soon as it loads) and writes what the call returned to `#result` (JSON) and the title. @@ -52,6 +69,7 @@ const html = (title: string, body: string, head = '') => ` * returned to `/beacon?at=…&r=…` (JSON); a link (`#away`) and an area to click (`#area`). * - `/auth`: HTTP Basic sign-in (AUTH_USER, AUTH_PASSWORD). * - `/page/`: a plain page. + * - `/video/`: a page whose video plays with sound as it loads (`VIDEO`). * - `/opener?to=…`: opens `to` in a pop-up window on a click of `#open`. * - `/upload?to=…`: a form (`#form`) that posts to `to`. * - `/apps?frame=…`: links to other apps (NV-19): `#mail` (mailto:), `#call` (tel:), `#mail-tab` @@ -310,6 +328,11 @@ document.getElementById('grab').addEventListener('wheel', (e) => e.preventDefaul send(html(name, `

${name}

`)) return } + if (u.pathname.startsWith('/video/')) { + const name = decodeURIComponent(u.pathname.slice('/video/'.length)) + send(html(name, VIDEO)) + return + } if (u.pathname === '/apps') { const frame = u.searchParams.get('frame') send( From 325b76a6c39ee9d4fad348f4cae8417dd7287c3f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:58:51 +0000 Subject: [PATCH 21/29] fix(pages): a page's dialog and the print dialog stay above the find bar, in a tab and over a Peek (PK-11, NV-24, D72) WindowManager.keepAboveTools() marks a view that stays above the page tools wherever either goes: showTools() places the tools under such a view (with `top`, over a Peek's page, only one above the overlay band), and placeAbovePages() puts such a view above the tools when they are under the ceiling. Page dialogs and the print dialog ask for it; error and crash screens don't. The find bar keeps the keyboard it has under D72's rule. pages-dialogs.spec.ts: a dialog in a Peek shows above the find bar there, also after the bar is placed again; the bar keeps its keys, and the dialog takes them when the bar closes; the Peek stays open. Co-Authored-By: Claude Opus 5.5 --- src/main/pages/covers.ts | 8 ++++ src/main/pages/dialogs.ts | 2 + src/main/pages/print.ts | 2 + src/main/windows.ts | 29 ++++++++++++-- tests/e2e/pages-dialogs.spec.ts | 69 ++++++++++++++++++++++++++++++++- 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/main/pages/covers.ts b/src/main/pages/covers.ts index 5917e918..2b9d6323 100644 --- a/src/main/pages/covers.ts +++ b/src/main/pages/covers.ts @@ -85,6 +85,12 @@ export function createCovers( mayTakeKeyboard?: (page: WebContents, focused: WebContents | null) => boolean /** A key pressed in the view (`view`) while it shows over `page`. */ onKey?: (page: WebContents, view: WebContents, event: Electron.Event, input: Electron.Input) => void + /** + * The view stays above the window's page tools (the find bar), as a page's own dialog does: in + * a tab and over a Peek's page alike (`WindowManager.keepAboveTools`). An error or crash screen + * isn't kept so: the find bar can show over it. + */ + aboveTools?: boolean }, ): PageCovers { const { store, tabs, windows } = ctx @@ -210,6 +216,7 @@ export function createCovers( } const view = windows.createUiView(windowId, opts.view) if (!view) return undefined + if (opts.aboveTools) windows.keepAboveTools(view) pool.add(view) watchView(view) windows.get(windowId)?.once('closed', () => { @@ -225,6 +232,7 @@ export function createCovers( const view = new WebContentsView({ webPreferences: uiWebPreferences(windowId, opts.view) }) view.setBackgroundColor('#00000000') view.setVisible(false) + if (opts.aboveTools) windows.keepAboveTools(view) const contents = view.webContents windows.registerUi(contents, windowId) windows.attachShortcuts(contents) diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts index 386b6a3a..5e281d64 100644 --- a/src/main/pages/dialogs.ts +++ b/src/main/pages/dialogs.ts @@ -76,6 +76,8 @@ export function registerDialogs(ctx: MainContext): PageDialogs$ { // time one to catch keys meant for the find bar or a sidebar field. mayTakeKeyboard: (page, focused) => pageHasKeyboard(page, focused) || queues.get(page.id)?.list[0]?.hadKeyboard === true, + // Above the find bar, in a tab or a Peek, where the find bar keeps the keyboard (D72). + aboveTools: true, }) const matches = (dialog: Dialog, which: DialogsWhich) => diff --git a/src/main/pages/print.ts b/src/main/pages/print.ts index 43971e00..cd6089e8 100644 --- a/src/main/pages/print.ts +++ b/src/main/pages/print.ts @@ -154,6 +154,8 @@ export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; p const covers = createCovers(ctx, { view: 'print', + // Above the find bar, as a page's dialog is. + aboveTools: true, content: (page) => { const s = sessions.get(page.id) return s ? contentOf(s) : undefined diff --git a/src/main/windows.ts b/src/main/windows.ts index 415b9550..b0ecad1e 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -223,6 +223,11 @@ export class WindowManager { /** Tabs whose page closed its own web contents (window.close()): not reopened until selected again. */ private selfClosed = new Set() private floatingOwners: ((tabId: TabId) => WindowId | undefined)[] = [] + /** + * Views that stay above the page tools wherever either goes: a page's own dialog or print dialog + * over its page, in a tab or a Peek (pages lane, NV-24, NV-07; keepAboveTools()). + */ + private aboveTools = new WeakSet() /** Main window that most recently had focus (fallback when no window has focus). */ private lastFocused: WindowId | undefined /** The last main window, closed while the app keeps running (macOS) or is about to quit: its state is kept. */ @@ -736,9 +741,14 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - const above = opts.top - ? [] - : [rec.overlay, rec.floating, rec.cover].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const band = [rec.overlay, rec.floating, rec.cover].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const above = opts.top ? [] : [...band] + // A page's own dialog over its page stays above them (keepAboveTools()): with `top`, one over a + // Peek's page, above the overlay. + const floor = opts.top && band.length > 0 ? Math.max(...band) : -1 + children.forEach((child, i) => { + if (i > floor && this.aboveTools.has(child)) above.push(i) + }) const ceiling = above.length > 0 ? Math.min(...above) : -1 if (ceiling < 0) { if (at !== children.length - 1) parent.addChildView(view) @@ -855,6 +865,15 @@ export class WindowManager { return { exists: true, placed, covered: placed && !!rec.cover, bounds: view.getBounds() } } + /** + * Keeps `view` above the window's page tools (the find bar, the zoom indicator) wherever either + * goes: a page's own dialog or print dialog over its page (pages lane, NV-24, NV-07), as a + * createUiView() view or a view of its own over a Peek's page. + */ + keepAboveTools(view: WebContentsView): void { + this.aboveTools.add(view) + } + /** * A small transparent Orbit UI view for another module (the loading indicator, SB-28; the link * pill, SB-27; page dialogs, NV-24): out of the window until showUiView(), then above the window's @@ -948,6 +967,10 @@ export class WindowManager { for (const entry of this.attached.values()) { if (entry.windowId === rec.id) topPage = Math.max(topPage, children.indexOf(entry.view)) } + // A view kept above the page tools (keepAboveTools()) goes above them too, where they are under + // the ceiling (over a Peek's page they are above it, and so is a dialog over that page). + const tools = rec.tools && this.aboveTools.has(view) ? children.indexOf(rec.tools) : -1 + if (tools < ceiling) topPage = Math.max(topPage, tools) if (at > topPage && at < ceiling) return if (ceiling >= children.length) parent.addChildView(view) else parent.addChildView(view, at >= 0 && at < ceiling ? ceiling - 1 : ceiling) diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index a943907a..d954d23c 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -1,6 +1,15 @@ import { expect, test } from '@playwright/test' import { action, isMac, launchOrbit, type Orbit, state } from './helpers' -import { clickInPage, closeWindow, cmdIn, pressInPage, toolsPage, windowState } from './nav-support' +import { + clickInPage, + closeWindow, + cmdIn, + pressInPage, + toolsInfo, + toolsPage, + waitLoaded, + windowState, +} from './nav-support' import { AUTH_PASSWORD, AUTH_USER, @@ -206,6 +215,64 @@ test('[NV-24][KS-19][NV-05] a dialog the page times leaves the keyboard in the f await answered(asking, 'false') }) +/** The views of the window that has the page tools, bottom to top: 'dialog', 'tools', or a page's address. */ +function stacking(app: Orbit['app']): Promise { + return app.evaluate(({ BrowserWindow }) => { + for (const w of BrowserWindow.getAllWindows()) { + const views = w.contentView.children.map((v) => { + const contents = (v as Electron.WebContentsView).webContents + if (!contents || contents.isDestroyed()) return '' + const url = contents.getURL() + return /[?&]view=(dialog|tools)(&|$)/.exec(url)?.[1] ?? url + }) + if (views.includes('tools')) return views + } + return [] + }) +} + +test('[NV-24][PK-11][KS-19] a dialog in a Peek shows above the find bar there, which keeps its keys; the dialog takes them when the bar closes (D72)', async () => { + const { app } = orbit + const under = await newTab(app, fx.url('/page/Under')) + await waitTab(app, under, '/page/Under') + const url = fx.url('/ask?kind=confirm&msg=In%20a%20Peek') + const peekTab = await cmd(app, 'peek.open', { url }) + await waitLoaded(app, peekTab, url) + await expect.poll(focusedUrl).toBe(url) + // The find bar opens over the Peek's card, with the keyboard (PK-11)... + await pressInPage(app, url, 'F', [MOD_KEY]) + const input = (await toolsPage(app)).getByTestId('find-input') + await expect(input).toBeFocused() + await expect.poll(focusedUrl).toContain('view=tools') + // ...as the page in the Peek asks. + await inPage(app, url, 'setTimeout(ask, 0); 0') + const shown = await waitDialog(app, (d) => d.tabId === peekTab && d.onScreen) + expect(shown).toMatchObject({ host: 'floating', focused: false }) + const dialogAboveTools = async () => { + const views = await stacking(app) + return views.indexOf('dialog') > views.indexOf('tools') && views.indexOf('tools') > views.indexOf(url) + } + await expect.poll(dialogAboveTools).toBe(true) + // What the user types goes on into the find bar, and the dialog stays above it, also when the bar + // is placed again (an emptied field lays it out anew). + const typed = await typeWhereKeyboardIs(['A', 's', 'k', 'Enter']) + expect(typed.every((at) => at.includes('view=tools'))).toBe(true) + await expect(input).toHaveValue('Ask') + await expect.poll(async () => (await toolsInfo(app)).find.query).toBe('Ask') + const erased = await typeWhereKeyboardIs(['Backspace', 'Backspace', 'Backspace']) + expect(erased.every((at) => at.includes('view=tools'))).toBe(true) + await expect.poll(async () => (await toolsInfo(app)).find.query).toBe('') + expect(await dialogAboveTools()).toBe(true) + expect(await waitDialog(app, (d) => d.id === shown.id)).toMatchObject({ onScreen: true, focused: false }) + // Esc closes the bar and gives the Peek's page the keyboard: the dialog takes it, and Esc there + // cancels. The Peek stays open. + expect(await typeWhereKeyboardIs(['Escape'])).toEqual([expect.stringContaining('view=tools')]) + await expect.poll(async () => (await waitDialog(app, (d) => d.id === shown.id)).focused).toBe(true) + expect(await typeWhereKeyboardIs(['Escape'])).toEqual([expect.stringContaining('view=dialog')]) + await answered(peekTab, 'false') + expect((await windowState(app)).overlay.kind).toBe('peek') +}) + test('[NV-24] a dialog from an empty tab or pop-up that its opener scripts names the opener’s site', async () => { const { app } = orbit for (const how of ['tab', 'popup'] as const) { From a79b505513326cde8d916036bfd9b5ec7611e413 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:59:00 +0000 Subject: [PATCH 22/29] fix(pages): print-closed and the page's hold only for the page whose print() opened the dialog (D76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A print session notes whether the page's own window.print() opened it (`byPage`). Main's sync print handler still answers at once, waiting on nothing; it now replies true only for that page's own dialog, so a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does. print-closed, with no data, goes only to the page that printed. pages-print.spec.ts: the page's print() while a ⌘P dialog shows, then a navigation, goes on at once and takes the dialog with it. Co-Authored-By: Claude Opus 5.5 --- src/main/pages/print.ts | 23 ++++++++++++++++++----- tests/e2e/pages-print.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/pages/print.ts b/src/main/pages/print.ts index cd6089e8..c47bc825 100644 --- a/src/main/pages/print.ts +++ b/src/main/pages/print.ts @@ -121,6 +121,11 @@ interface Session { generation: number busy: boolean error?: string + /** + * The page's own `window.print()` opened it (D76): its close and navigations wait for it, and it + * alone hears when it closes (`PAGE_CHANNEL.printClosed`). + */ + byPage: boolean off: () => void } @@ -184,7 +189,8 @@ export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; p // The view goes, and the page gets the keyboard back (KS-16). covers.update(page) // A close or a navigation the page started after its `print()` goes on now (the page preload). - if (!page.isDestroyed()) page.send(PAGE_CHANNEL.printClosed) + // Only the page whose `print()` opened the dialog waits for it (D76). + if (s.byPage && !page.isDestroyed()) page.send(PAGE_CHANNEL.printClosed) } const preview = async (s: Session) => { @@ -244,6 +250,7 @@ export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; p preview: { state: 'loading' }, generation: 0, busy: false, + byPage: false, off: () => { if (page.isDestroyed()) return page.off('did-start-navigation', onNavigate) @@ -332,15 +339,21 @@ export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; p }) // The page's own `window.print()` (the page preload): only a page's main frame has it (D40). The - // reply says whether the page has a print dialog open now: its close and navigations wait for it. - // The reply goes out as soon as returnValue is set, so it is set once. + // reply says whether the dialog the page's own `print()` opened is open now: its close and + // navigations wait for it. A `print()` while a dialog that ⌘P or a menu opened shows returns at + // once and holds nothing, as Chromium's does (D76). The reply goes out at once, waiting on + // nothing, as soon as returnValue is set, so it is set once. ipcMain.on(PAGE_CHANNEL.print, (event: IpcMainEvent) => { let opened = false try { const frame = event.senderFrame if (frame && !frame.parent && !event.sender.isDestroyed()) { - open(event.sender) - opened = sessions.has(event.sender.id) + const page = event.sender + const had = sessions.has(page.id) + open(page) + const s = sessions.get(page.id) + if (s && !had) s.byPage = true + opened = s?.byPage === true } } catch (err) { console.error('[pages] print', err) diff --git a/tests/e2e/pages-print.spec.ts b/tests/e2e/pages-print.spec.ts index 364e094a..4374ac60 100644 --- a/tests/e2e/pages-print.spec.ts +++ b/tests/e2e/pages-print.spec.ts @@ -312,3 +312,25 @@ test('[NV-07] a page that prints and goes to another page at once shows the prin expect(await printDialogs(app)).toEqual([]) expect(await tabHistory(app, fx.url('/page/Printed'))).toEqual({ urls: [url, fx.url('/page/Printed')], index: 1 }) }) + +test('[NV-07] the page’s print() while a dialog ⌘P opened shows returns at once and holds nothing: the page goes on to another page at once, and the dialog goes with it (D76)', async () => { + const { app } = orbit + const { tabId, page } = await printTab(app) + await pressInPage(app, '/print', 'P', [MOD_KEY]) + const dialog = await waitPrint(app, (d) => d.tabId === tabId && d.onScreen) + // The page prints and goes on: as in Chromium, its print() doesn't wait for a dialog it didn't open. + await page.evaluate(() => { + window.print() + ;(window as unknown as { __print: { returned: boolean } }).__print.returned = true + setTimeout(() => { + location.href = '/page/Printed' + }, 0) + }) + await waitTab(app, tabId, '/page/Printed') + await expect.poll(async () => (await printDialogs(app)).some((d) => d.dialog === dialog.dialog)).toBe(false) + expect(await tabHistory(app, fx.url('/page/Printed'))).toEqual({ + urls: [fx.url('/print'), fx.url('/page/Printed')], + index: 1, + }) + expect(await printedJobs(app)).toEqual([]) +}) From 28fe136899cfed316dcb627ba8d45ce1405458b7 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:59:00 +0000 Subject: [PATCH 23/29] fix(pages): after Cancel, a page asks to open another app again only with a new gesture; a click in a cross-site frame counts (NV-19) A page whose question the user answered Cancel (or Esc) asks again only after a new user gesture in it, as in Chromium; a link it follows before then is dropped. A mouse press in any of the page's frames now counts as its gesture: an out-of-process frame's input reaches main only as before-mouse-event, not input-event (so an always-allowed frame's click opens its app too). Test runs record what came of each link (__orbitPages.appLinks: asked, opened or dropped), so tests wait on the handler instead of on time. pages-apps.spec.ts: after Cancel, a gesture-less link asks nothing, a click asks again, and after Esc the same holds. Co-Authored-By: Claude Opus 5.5 --- src/main/pages/index.ts | 9 +++++++- src/main/pages/other-apps.ts | 33 +++++++++++++++++++++++++++-- tests/e2e/pages-apps.spec.ts | 41 ++++++++++++++++++++++++++++++++++++ tests/e2e/pages-support.ts | 6 ++++++ 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts index 215da21d..9a8db580 100644 --- a/src/main/pages/index.ts +++ b/src/main/pages/index.ts @@ -8,7 +8,7 @@ import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialo import { type FullScreenInfo, registerFullScreen } from './fullscreen' import { registerHistoryPage } from './history' import { type LeaveInfo, registerLeave } from './leave' -import { findSystemApp, findTestApp, launchInApp, registerOtherApps } from './other-apps' +import { type AppLinkOutcome, findSystemApp, findTestApp, launchInApp, registerOtherApps } from './other-apps' import { deliverPrint, type PrintInfo, type PrintRecord, registerPrint, systemPrinters, testPrinters } from './print' import { registerSavePage } from './save-page' import { LOAD_TIMEOUT_MS, type PageScreenInfo, registerScreens } from './screens' @@ -25,6 +25,8 @@ export interface PagesTestHooks { screens: () => PageScreenInfo[] /** Links Orbit opened in another app, oldest first (NV-19): test runs never start one. */ launched: string[] + /** Links to other apps that pages followed, oldest first, and what came of each (NV-19). */ + appLinks: { url: string; outcome: AppLinkOutcome }[] /** A window's full screen (NV-20). */ fullScreen: (windowId: WindowId) => FullScreenInfo /** Every page's print dialog (NV-07). */ @@ -55,6 +57,7 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { const screens = registerScreens(ctx, ops, dialogs, fullScreen, testTimeout > 0 ? testTimeout : LOAD_TIMEOUT_MS) // Test runs record each launch instead, and take the OS's apps from ORBIT_E2E_APPS. const launched: string[] = [] + const appLinks: PagesTestHooks['appLinks'] = [] registerOtherApps( ctx, dialogs, @@ -64,6 +67,9 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { launched.push(url) }, findApp: findTestApp(process.env.ORBIT_E2E_APPS), + note: (url, outcome) => { + appLinks.push({ url, outcome }) + }, } : { launch: launchInApp, findApp: findSystemApp }, ) @@ -101,6 +107,7 @@ export function registerPages(ctx: MainContext, ops: TabOps): void { }, screens: () => screens.info(), launched, + appLinks, fullScreen: (windowId) => fullScreen.info(windowId), print: () => print.info(), printed, diff --git a/src/main/pages/other-apps.ts b/src/main/pages/other-apps.ts index 057da46e..6e0e2aab 100644 --- a/src/main/pages/other-apps.ts +++ b/src/main/pages/other-apps.ts @@ -32,6 +32,12 @@ export type LaunchApp = (url: string) => Promise */ export type FindApp = (url: string) => Promise +/** + * What came of a page's link to another app (NV-19): Orbit asked, opened it without asking (always + * allowed), or dropped it (a question was up already, or the page had no new user gesture for it). + */ +export type AppLinkOutcome = 'asked' | 'opened' | 'dropped' + /** The real launch: the OS opens the link in its app. */ export const launchInApp: LaunchApp = (url) => shell.openExternal(launchableUrl(url)) @@ -169,6 +175,8 @@ function neverShowedPage(page: WebContents): boolean { * `launch`, the one place Orbit starts another app. A site always allowed for a kind of link opens * it without asking, once per user gesture in the page, as in Chromium: a page can't open the app * again and again unasked. A page asks one question at a time: a link it follows meanwhile is dropped. + * Once the user answers Cancel, the page asks again only after a new user gesture in it, as in + * Chromium: a page can't keep asking once the user said no. * * A tab, Peek, Mini Window or pop-up opened only for such a link (`target=_blank`, `window.open`, * or a redirect there) never shows a page: it closes itself once its navigation ends, and the tab @@ -179,7 +187,12 @@ function neverShowedPage(page: WebContents): boolean { export function registerOtherApps( ctx: MainContext, dialogs: PageDialogs$, - deps: { launch: LaunchApp; findApp: FindApp }, + deps: { + launch: LaunchApp + findApp: FindApp + /** Test runs: hears what came of each link (NV-19). */ + note?: (url: string, outcome: AppLinkOutcome) => void + }, ): void { const { store, tabs, windows, commands } = ctx const choices = new AlwaysAllowed(join(ctx.paths.userData, 'orbit-app-links.json'), (id) => @@ -198,6 +211,8 @@ export function registerOtherApps( const going = new Map() /** Pages (by contents id) with a user gesture that no link to another app has used yet. */ const gestured = new Set() + /** Pages (by contents id) whose last question the user answered Cancel: they ask again only with a new gesture. */ + const refused = new Set() const launchOnly = (page: WebContents) => !page.isDestroyed() && !asking.has(page.id) && !!appScheme(going.get(page.id)) && neverShowedPage(page) @@ -257,10 +272,15 @@ export function registerOtherApps( page.on('input-event', (_event, input) => { if (isActivation(input)) gestured.add(id) }) + // A click in a frame from another site (in a process of its own) comes only as a mouse event. + page.on('before-mouse-event', (_event, mouse) => { + if (mouse.type === 'mouseDown') gestured.add(id) + }) page.once('destroyed', () => { going.delete(id) asking.delete(id) gestured.delete(id) + refused.delete(id) }) } ctx.hooks.tabCreated.push((_tabId, contents) => { @@ -296,6 +316,8 @@ export function registerOtherApps( } finally { asking.delete(id) } + // Cancel (or Esc): the page's next link asks only with a new gesture (Chromium's rule). + if (answer && !answer.ok) refused.add(id) if (answer?.ok) { if (answer.checked === true && origin && /^https?:\/\//i.test(origin)) choices.add(profileId, origin, scheme) launch(page, url) @@ -314,10 +336,17 @@ export function registerOtherApps( if (origin && /^https?:\/\//i.test(origin) && choices.has(profileId, origin, scheme)) { // Always allowed: it opens without asking, but only for a gesture no link used yet (NV-19). if (fresh) launch(page, url) + deps.note?.(url, fresh ? 'opened' : 'dropped') + return + } + // One question at a time; and after a Cancel, none until the user does something in the page. + if (asking.has(page.id) || (refused.has(page.id) && !fresh)) { + deps.note?.(url, 'dropped') return } - if (asking.has(page.id)) return + refused.delete(page.id) asking.add(page.id) + deps.note?.(url, 'asked') void ask(profileId, page, url, scheme, origin).catch((err: unknown) => console.error('[pages] link to an app', err)) }) } diff --git a/tests/e2e/pages-apps.spec.ts b/tests/e2e/pages-apps.spec.ts index 75c3d03e..bac60030 100644 --- a/tests/e2e/pages-apps.spec.ts +++ b/tests/e2e/pages-apps.spec.ts @@ -3,6 +3,7 @@ import { activeTabId, launchOrbit, type Orbit, state, tabPage } from './helpers' import { APP_ICON, APPS_ENV, + appLinks, dialogPage, launchedLinks, type PagesFixtures, @@ -199,3 +200,43 @@ test('[NV-19] a tab or pop-up opened only to launch an app closes itself: the qu expect(archive.filter((a) => /^(mailto|zoommtg):|\/to-app/.test(a.url))).toEqual([]) expect(await launchedLinks(app)).toEqual(['zoommtg://join?confno=123']) }) + +test('[NV-19] after Cancel, the page asks again only with a new user gesture: a link it follows without one asks nothing, and a click asks again', async () => { + const { app } = orbit + const { tabId, page } = await openApps(app) + const CALL = 'tel:+15550100' + await page.locator('#mail').click() + const asked = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'app' && d.focused) + await (await dialogPage(app, asked.id)).getByTestId('page-dialog-cancel').click() + await expect.poll(() => noDialogs(app)).toBe(true) + // With no new gesture (a timer, say), the page follows a link to another app: nothing asks, as in + // Chromium. The page can't keep asking once the user said no. + await page.evaluate((call) => { + location.href = call + }, CALL) + await expect + .poll(() => appLinks(app)) + .toEqual([ + { url: MAIL, outcome: 'asked' }, + { url: CALL, outcome: 'dropped' }, + ]) + expect(await noDialogs(app)).toBe(true) + // A click is a new gesture: its link asks. + await page.locator('#call').click() + const again = await waitDialog(app, (d) => d.kind === 'app' && d.id > asked.id && d.focused) + expect(again.title).toBe('Open this link in another app?') + // Esc is Cancel too: once more, a link without a gesture asks nothing. + await (await dialogPage(app, again.id)).keyboard.press('Escape') + await expect.poll(() => noDialogs(app)).toBe(true) + await page.evaluate((mail) => { + location.href = mail + }, MAIL) + await expect + .poll(async () => (await appLinks(app)).slice(2)) + .toEqual([ + { url: CALL, outcome: 'asked' }, + { url: MAIL, outcome: 'dropped' }, + ]) + expect(await noDialogs(app)).toBe(true) + expect(await launchedLinks(app)).toEqual([]) +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index 5746c994..c03971fe 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -474,6 +474,7 @@ type PagesGlobal = { leave: (tabId: string) => LeaveInfo | null screens: () => PageScreenInfo[] launched: string[] + appLinks: { url: string; outcome: string }[] fullScreen: (windowId: string) => FullScreenInfo print: () => PrintInfo[] printed: PrintRecord[] @@ -496,6 +497,11 @@ export function launchedLinks(app: ElectronApplication): Promise { return app.evaluate(() => [...(globalThis as unknown as PagesGlobal).__orbitPages.launched]) } +/** Links to other apps that pages followed, oldest first, and what came of each (NV-19). */ +export function appLinks(app: ElectronApplication): Promise<{ url: string; outcome: string }[]> { + return app.evaluate(() => [...(globalThis as unknown as PagesGlobal).__orbitPages.appLinks]) +} + /** * Clicks a button whose click closes the page it's in: a Peek's, a Mini Window's or a pop-up's * dialog has a view of its own, which goes with the dialog, sometimes before the click returns. From 54a2866e103defc2161ddea656387ca5f28ee976 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:59:00 +0000 Subject: [PATCH 24/29] docs(pages): covers and the player, dialogs above the find bar, D76's print reply, NV-19 after Cancel; regenerate FEATURES.md Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 10 +++++----- docs/ARCHITECTURE.md | 6 +++--- docs/feature-status.json | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 9d704408..7586b641 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -234,7 +234,7 @@ Spec: `spec/navigation.md` | NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads | | NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names "\u0000", is lost (chrome fix check 4) | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | -| NV-07 | Print | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | +| NV-07 | Print | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | | NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e+unit | – | Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links | | NV-09 | Links with target=_blank and script-opened windows | P0 | ✅ | ✅ e2e+unit | – | target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener | | NV-10 | New window | P0 | 🟡 partial | ✅ e2e | – | Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built | @@ -246,12 +246,12 @@ Spec: `spec/navigation.md` | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | -| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | +| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once the user answers Cancel (or Esc) it asks again only after a new click or key in it, as in Chromium: a link it follows before then gets nothing (a click in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | | NV-20 | Full screen | P1 | ✅ | ✅ e2e+unit | – | ⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7) | | NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | -| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (" says"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | +| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (" says"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | | NV-25 | Share the current page | P2 | – | – | – | | ## Peek link preview (prefix PK) @@ -270,7 +270,7 @@ Spec: `spec/peek.md` | PK-08 | Open a Peek in Split View | P1 | ✅ | ✅ e2e | – | the card slides to the right half, then split.addTab makes the source and the live Peek page one split in the source's place (a pinned source gives a pinned split), the page focused; from a full split the Peek stays open and the SV-25 toast shows | | PK-09 | Close a Peek | P0 | ✅ | ✅ e2e+unit | – | Esc (after fullscreen), ⌘W, ×, click outside; archived as from 'peek' | | PK-10 | Reopen a Peek closed by mistake | P1 | ✅ | ✅ e2e | – | ⌘Z right after closing, or ⌘⇧T, reopens on the same page with history | -| PK-11 | Navigating and acting inside a Peek | P1 | 🟡 partial | ✅ e2e+unit | – | links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11 | +| PK-11 | Navigating and acting inside a Peek | P1 | 🟡 partial | ✅ e2e+unit | – | links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; a dialog the Peek's page asks for shows above the find bar, which keeps the keyboard it has until it closes (D72); the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11 | | PK-12 | Peek settings | P1 | ✅ | ✅ e2e+unit | – | Settings → Links has both toggles ("Open links from pinned tabs and Favorites in Peek", "Shift-click opens links in Peek"), on by default on every platform; off, a pinned tab's or Favorite's other-site link opens as a Today tab and ⇧-click opens a new window. Tests: settings.spec ST-09/MW-11 (toggles, defaults), peek.spec PK-12, nav-links.spec NV-08 ⇧-click, link-routing unit PK-12 | | PK-13 | Peek and the sidebar | P0 | ✅ | ✅ e2e | – | no sidebar row; sidebar stays usable; selecting another tab, Space or window dismisses (reopenable) | | PK-14 | Canvases, notes and Library items open in Peek | P2 | – | – | – | | @@ -310,7 +310,7 @@ Spec: `spec/media.md` | MD-03 | The floating player's look and placement | P0 | ✅ | ✅ e2e+unit | – | frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform | | MD-04 | Floating player controls | P0 | ✅ | ✅ e2e+unit | – | on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab | | MD-05 | Keyboard and media keys for the floating player | P1 | ✅ | ✅ e2e | – | with the player focused (clicked) Space plays and pauses and the arrow keys seek 5 s, its buttons 15 s (tested); hardware media keys reach the playing video through Chromium's media key handling, which Orbit turns on: only a person can check them (no media keys under Xvfb) | -| MD-06 | Move, resize and zoom the floating player | P1 | ✅ | ✅ e2e+unit | – | drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers say so), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it | +| MD-06 | Move, resize and zoom the floating player | P1 | ✅ | ✅ e2e+unit | – | drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers and save dialogs say so; so do Orbit's dialogs and screens over a page while they show: a page's alert, confirm, prompt and "Open Mail?", the print dialog, and the error, crash and "Page unresponsive" screens), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert, and with a page's confirm, the print dialog and a crash screen). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it | | MD-07 | Ending picture-in-picture | P0 | ✅ | ✅ e2e | – | returning (select, double-click, Back to tab) ends it with the video playing in the page; x closes it and the media keeps playing in the sidebar controls; closing or unloading the tab stops media and player; navigating the tab or the video ending closes it, without replay | | MD-08 | Turning automatic picture-in-picture off | P0 | ✅ | ✅ e2e+unit | – | site controls panel (NV-16) Picture in Picture switch, on by default: off is Always turn off for this site, on undoes it and Turn off for today, which it shows too; player ... menu Turn Off for Today / Always Turn Off for This Site (also in the tray controller menu); global switch Settings > Advanced (ST-10) and the Picture in Picture action; turning off keeps playback (sidebar controls); muted tabs never trigger | | MD-09 | Sidebar media controls for background audio | P0 | ✅ | ✅ e2e+unit | – | controller above the bottom bar: the Media Session's artwork (loaded with the tab's session, kept small) or the favicon, Media Session title/artist or tab title (marquee), play/pause, next, previous where supported, x pauses and hides; click jumps to the tab (switching Space); quiet audio counts; hidden while its tab is selected; media in frames (embedded players, cross-site too) has the same controls | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca1078a0..7e73f0f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,14 +149,14 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. In the page's `beforeunload`, `pagehide` or `unload` handlers the calls return at once, as the engine's do: the patch reads the event being handled through the engine's own getters. The preload adds no `beforeunload` or `unload` listener of its own: with one, the engine would run every page's `beforeunload` before each navigation (a busy page would hold up Back, D60), and keep every page out of the back-forward cache. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. - `orbit-page:form-sent` (NV-13): the main frame is about to send a form (POST), from the Navigation API's `navigate` event in the preload's own world: the address it goes to. Main reads it only from a tab's or a pop-up's main frame, and it counts only for that frame's next navigation, to that address: Orbit's time limit for a server's first answer leaves that navigation alone, since it may be a large upload. A page that says so falsely only keeps the time limit off its own load. - `orbit-page:swipe` (NV-02): Electron reports no scroll phases to main, so a two-finger swipe over the page is told from the page's own wheel events. A passive listener in the preload's own world takes trusted pixel-delta wheel events in the main frame, after the page's handlers have run, and recognises a horizontal swipe (`src/preload/swipe.ts`). Unless something under the pointer can still scroll that way or the page called `preventDefault`, it sends how far the swipe has gone (`move`, a direction and px) and whether it was let go with the arrow fully in (`end`); nothing else. Main takes it only from a main frame of a page in a window's page card, clamps the travel, shows the arrow only when that page can go that way, and goes back or forward as the Back button does: no more than the page could do itself (`history.back()`). A new use under D40 (4), for the lead to log. -- `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the page has one open now; nothing else goes with it. Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. While the dialog the page asked for is open, what would take the page away waits, as it would behind the engine's `print()`, which returns only once its dialog has closed: the page's `window.close()`, patched (D40), and a navigation the page starts that the Navigation API lets the preload stop, from its own world, and start again as it was (a link, `location`, a form that gets its page, a reload). When the dialog closes, main tells the page on `orbit-page:print-closed` (main → page, with nothing in it), and the close goes on, or else the page's last such navigation starts again. A form the page posts and a move through history go at once, and take the dialog with them. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. +- `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the dialog the page's own `print()` opened is open now; main answers at once and waits on nothing, and nothing else goes with it. A `print()` while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76). Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. While the dialog the page asked for is open, what would take the page away waits, as it would behind the engine's `print()`, which returns only once its dialog has closed: the page's `window.close()`, patched (D40) with the built-ins it calls taken before the page runs, which waits only while that dialog is open (D76), and a navigation the page starts that the Navigation API lets the preload stop, from its own world, and start again as it was (a link, `location`, a form that gets its page, a reload). When the dialog closes, main tells the page whose `print()` opened it, and only that page, on `orbit-page:print-closed` (main → page, with nothing in it, D76), and the close goes on, or else the page's last such navigation starts again. A form the page posts and a move through history go at once, and take the dialog with them. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. - `orbit-page:screen-share` (MD-11, TB-15, D69): the count of the main frame's live shared-screen tracks, sent when it changes. The preload wraps `getDisplayMedia` and `MediaStreamTrack` `stop` and `clone` to follow the tracks that call returns. Main takes the count only from a tab's main frame (same process and routing id as `mainFrame`), clamps it to 0…1000, and uses it only for that tab's sharing indicators (the row, and the recent-tab switcher once it shows one): nothing that grants, refuses, sleeps or archives reads it (`TabMedia.screen` vs `screenGranted`). It sees the shares the system's own picker grants without asking Orbit (macOS 15 and later). Known limit: neither this nor main's watcher (below) holds against a page that wants to hide its share; it can clone a track with another realm's built-ins, such as a same-origin frame's, and stop the one Orbit follows. The OS's own capture indicator is the backstop where the OS has one. -A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog or a screen takes the keyboard when it shows over the page the user is on, but only where the page had it, never from Orbit's own fields (the find bar, a sidebar field, the address field), so a page can't time a dialog to catch keys meant for Orbit (D72); it takes the keyboard once the user clicks it or the keyboard next goes to the page. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. +A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog or a screen takes the keyboard when it shows over the page the user is on, but only where the page had it, never from Orbit's own fields (the find bar, a sidebar field, the address field), so a page can't time a dialog to catch keys meant for Orbit (D72); it takes the keyboard once the user clicks it or the keyboard next goes to the page. A page's dialog and the print dialog stay above the window's page tools (the find bar) in a tab and over a Peek's page alike (`WindowManager.keepAboveTools`, PK-11), so the find bar keeps the keyboard it has under a dialog it can't cover; error and crash screens aren't kept so, and the find bar can show over them. While a cover is on screen, its window counts as showing a dialog for the floating player (`withDialog`, `src/main/media/dialog-watch.ts`, MD-06), which keeps clear of it; so do Save Page As… and Save as PDF's save dialogs. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its `print()` opens the system's dialog, so Orbit shows its own dialog as a cover over the page. The preview is the engine's PDF of the page (`printToPDF`, which runs the page's `beforeprint` and `afterprint` and leaves the page as it was), shown in the engine's PDF viewer in a frame of the cover's view (a `blob:` URL: the renderer's CSP allows `blob:` frames for it). One function sends a job out (`deliverPrint`: `print()` to the printer chosen, without the system's dialog, or the PDF saved where the user says); test runs replace it with a recorder, and take their printers from `ORBIT_E2E_PRINTERS`. Callers outside the lane (the Print action for a tab, a Peek or a Mini Window, the page menu) open the dialog through `src/main/pages/entry.ts`, as they open a page's source (DT-04, `view-source.ts`: the page's own address as `view-source:` in a new tab; a Mini Window's in a main window). -Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. An always-allowed link opens the app once per user gesture in its page (a mouse press or a key in the page, as the tab runtime counts them), as in Chromium: each link to another app uses up the page's gesture, so a page can't open the app again and again unasked. It isn't the tab runtime's `consumeGesture`: that one a `target=_blank` link to the app has already used to open its tab, and the engine names the opener as the page that asks. +Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. An always-allowed link opens the app once per user gesture in its page (a mouse press or a key in the page, as the tab runtime counts them, or a mouse press in any of its frames: a frame from another site, in its own process, reports its input only as `before-mouse-event`), as in Chromium: each link to another app uses up the page's gesture, so a page can't open the app again and again unasked. This gesture isn't the tab runtime's `consumeGesture`: that one a `target=_blank` link to the app has already used to open its tab, and the engine names the opener as the page that asks. After the user answers Cancel, the page asks again only with a new gesture, as in Chromium, so it can't keep asking. Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change which of that page's own navigations "Leave site?" names and starts again. Whether a page warns is never the preload's word: closing a tab, a window or a Space, auto-archive and sleep close the page through the engine (`webContents.close({ waitForBeforeUnload: true })`, D73), which runs every frame's `beforeunload` only once the close has started and says when one warns (`will-prevent-unload`); the page then stays, and Orbit asks (auto-archive and sleep keep it without asking). diff --git a/docs/feature-status.json b/docs/feature-status.json index 6583f3f4..f756d887 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -193,7 +193,7 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads"}, "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names \"\\u0000\", is lost (chrome fix check 4)"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, - "NV-07": {"built": "partial", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, + "NV-07": {"built": "partial", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links"}, "NV-09": {"built": "yes", "note": "target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener"}, "NV-10": {"built": "partial", "note": "Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built"}, @@ -204,11 +204,11 @@ "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, - "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time; an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, + "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once the user answers Cancel (or Esc) it asks again only after a new click or key in it, as in Chromium: a link it follows before then gets nothing (a click in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, - "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, + "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "yes", "note": "every icon button presses to 0.94 at once and springs back (pop), the Library button too; Back/Forward nudge, Reload's glyph turns once, the + scales to 0.92; restarts on repeat; none when disabled; no spring with reduced motion"}, @@ -221,7 +221,7 @@ "MD-03": {"built": "yes", "note": "frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform"}, "MD-04": {"built": "yes", "note": "on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab"}, "MD-05": {"built": "yes", "note": "with the player focused (clicked) Space plays and pauses and the arrow keys seek 5 s, its buttons 15 s (tested); hardware media keys reach the playing video through Chromium's media key handling, which Orbit turns on: only a person can check them (no media keys under Xvfb)"}, - "MD-06": {"built": "yes", "note": "drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers say so), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it"}, + "MD-06": {"built": "yes", "note": "drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers and save dialogs say so; so do Orbit's dialogs and screens over a page while they show: a page's alert, confirm, prompt and \"Open Mail?\", the print dialog, and the error, crash and \"Page unresponsive\" screens), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert, and with a page's confirm, the print dialog and a crash screen). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it"}, "MD-07": {"built": "yes", "note": "returning (select, double-click, Back to tab) ends it with the video playing in the page; x closes it and the media keeps playing in the sidebar controls; closing or unloading the tab stops media and player; navigating the tab or the video ending closes it, without replay"}, "MD-08": {"built": "yes", "note": "site controls panel (NV-16) Picture in Picture switch, on by default: off is Always turn off for this site, on undoes it and Turn off for today, which it shows too; player ... menu Turn Off for Today / Always Turn Off for This Site (also in the tray controller menu); global switch Settings > Advanced (ST-10) and the Picture in Picture action; turning off keeps playback (sidebar controls); muted tabs never trigger"}, "MD-09": {"built": "yes", "note": "controller above the bottom bar: the Media Session's artwork (loaded with the tab's session, kept small) or the favicon, Media Session title/artist or tab title (marquee), play/pause, next, previous where supported, x pauses and hides; click jumps to the tab (switching Space); quiet audio counts; hidden while its tab is selected; media in frames (embedded players, cross-site too) has the same controls"}, @@ -250,7 +250,7 @@ "PK-08": {"built": "yes", "note": "the card slides to the right half, then split.addTab makes the source and the live Peek page one split in the source's place (a pinned source gives a pinned split), the page focused; from a full split the Peek stays open and the SV-25 toast shows"}, "PK-09": {"built": "yes", "note": "Esc (after fullscreen), ⌘W, ×, click outside; archived as from 'peek'"}, "PK-10": {"built": "yes", "note": "⌘Z right after closing, or ⌘⇧T, reopens on the same page with history"}, - "PK-11": {"built": "partial", "note": "links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11"}, + "PK-11": {"built": "partial", "note": "links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; a dialog the Peek's page asks for shows above the find bar, which keeps the keyboard it has until it closes (D72); the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11"}, "PK-12": {"built": "yes", "note": "Settings → Links has both toggles (\"Open links from pinned tabs and Favorites in Peek\", \"Shift-click opens links in Peek\"), on by default on every platform; off, a pinned tab's or Favorite's other-site link opens as a Today tab and ⇧-click opens a new window. Tests: settings.spec ST-09/MW-11 (toggles, defaults), peek.spec PK-12, nav-links.spec NV-08 ⇧-click, link-routing unit PK-12"}, "PK-13": {"built": "yes", "note": "no sidebar row; sidebar stays usable; selecting another tab, Space or window dismisses (reopenable)"}, "PK-15": {"built": "yes", "note": "no hover-to-Peek trigger: hovering links or pinned rows never opens a Peek (hover cards are TB-21)"}, From 08b41a9aab6e13e25d548ae3ee06bc89dbfce29c Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:43:26 +0000 Subject: [PATCH 25/29] fix(pages): a press in a frame from another site counts as a user gesture (D77 (1)) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One helper (src/main/pages/gestures.ts) keeps each page's gesture count, fed by every frame: a mouse-down from before-mouse-event or input-event (one press counts once), a key from before-input-event that reaches the page (not one Orbit takes as its own shortcut, so ⌘W never counts for the page it closes; nor Esc, a modifier alone or a held key), a tap. The leave guard (NV-24), links to other apps (NV-19) and the pop-up rule (NV-09: tabs/runtime.ts, its listener only) read it. Tests, with a frame from another site (127.0.0.1 in a page on localhost) clicked through frameLocator and typed in with the real keyboard (xdotool; skipped where there is none): ⌘W, the window, the Space, auto-archive and sleep; NV-19 after a key in the frame; a pop-up opened by a click in the frame. A unit test covers the count's rules. Co-Authored-By: Claude Opus 5.5 --- src/main/pages/gestures.ts | 79 ++++++++++++ src/main/pages/leave.ts | 37 +++--- src/main/pages/other-apps.ts | 32 +++-- src/main/tabs/runtime.ts | 9 +- tests/e2e/pages-dialogs.spec.ts | 72 ++++++++--- tests/e2e/pages-gestures.spec.ts | 199 ++++++++++++++++++++++++++++++ tests/e2e/pages-support.ts | 18 +++ tests/unit/pages-gestures.test.ts | 143 +++++++++++++++++++++ 8 files changed, 531 insertions(+), 58 deletions(-) create mode 100644 src/main/pages/gestures.ts create mode 100644 tests/e2e/pages-gestures.spec.ts create mode 100644 tests/unit/pages-gestures.test.ts diff --git a/src/main/pages/gestures.ts b/src/main/pages/gestures.ts new file mode 100644 index 00000000..8eafce1f --- /dev/null +++ b/src/main/pages/gestures.ts @@ -0,0 +1,79 @@ +import type { WebContents } from 'electron' + +/** + * A page's user gestures (D77): one count per page, which a press in any of its frames feeds, as + * Chromium's user activation does. The leave-page warning (NV-24, D73), links to other apps (NV-19) + * and the pop-up rule (NV-09) all read it. + * + * - **A mouse-down.** `before-mouse-event` sees every frame's; `input-event` sees only the main + * frame's widget, so never a press in a frame from another site, which has a widget of its own. + * One press in the main frame brings both, in the same task: it counts once. + * - **A key that reaches the page** (`before-input-event`, every frame's). Not one Orbit takes as its + * own shortcut: a listener prevents it (`windows.attachShortcuts`), so ⌘W never counts for the + * page it closes. Nor, as in Chromium, Esc, a modifier alone, or a key held down. + * - **A touch's end or a tap** (`input-event`). + * + * Orbit's own calls into a page (its scripts, a navigation it starts) are no gesture, and nor is + * the user activation the engine gives a page it asks to leave (D77 (3)). + */ + +/** Keys that never count as a gesture (HTML user activation: Escape, and a modifier alone). */ +const NOT_A_GESTURE = new Set(['Escape', 'Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock', 'Fn', 'FnLock']) + +interface Gestures { + count: number + listeners: Set<() => void> + /** A mouse-down came this task (`before-mouse-event`): the main frame's `input-event` is the same press. */ + pressing: boolean +} + +const pages = new WeakMap() + +function watched(page: WebContents): Gestures { + const known = pages.get(page) + if (known) return known + const g: Gestures = { count: 0, listeners: new Set(), pressing: false } + pages.set(page, g) + const press = () => { + g.count++ + for (const listener of [...g.listeners]) listener() + } + page.on('before-mouse-event', (_event, mouse) => { + if (mouse.type !== 'mouseDown') return + if (!g.pressing) { + g.pressing = true + setImmediate(() => { + g.pressing = false + }) + } + press() + }) + page.on('input-event', (_event, input) => { + if (input.type === 'mouseDown') { + if (!g.pressing) press() + } else if (input.type === 'touchEnd' || input.type === 'gestureTap') press() + }) + page.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.isAutoRepeat || NOT_A_GESTURE.has(input.key)) return + // Whether a listener took the key for Orbit is known once every listener has run. + queueMicrotask(() => { + if (!event.defaultPrevented && !page.isDestroyed()) press() + }) + }) + return g +} + +/** Counts a page's gestures from now on: as it is created, so that none is missed. */ +export function watchGestures(page: WebContents): void { + watched(page) +} + +/** The gestures a page has had since it was watched: it only grows. */ +export function gestureCount(page: WebContents): number { + return pages.get(page)?.count ?? 0 +} + +/** Calls `listener` on each of the page's gestures from now on. */ +export function onGesture(page: WebContents, listener: () => void): void { + watched(page).listeners.add(listener) +} diff --git a/src/main/pages/leave.ts b/src/main/pages/leave.ts index 5a931066..f956bc13 100644 --- a/src/main/pages/leave.ts +++ b/src/main/pages/leave.ts @@ -3,8 +3,8 @@ import { leaveDialog } from '../../shared/pages' import type { ArchivedTab, TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { CloseTabsOptions, TabOps } from '../tabs/ops' -import { isActivation } from '../tabs/runtime' import type { PageDialogs$ } from './dialogs' +import { gestureCount, watchGestures } from './gestures' /** How long Orbit waits for the page preload to say which navigation a warning stopped. */ const ASK_MS = 250 @@ -39,15 +39,16 @@ type Stopped = Started | 'page' | 'close' /** What Orbit knows of one page about its leave-page warning. */ interface LeavePage { /** - * User gestures that reached the page's current document: a page without one never warns - * (Chromium's rule), so Orbit closes it without asking the engine. + * The page's gesture count (`gestureCount`, D77) when its current document began: a document + * that no gesture has reached, in any of its frames, never warns (Chromium's rule), so Orbit + * closes it without asking the engine. */ - gestures: number + since: number /** Main-frame documents so far. */ doc: number /** * Auto-archive or sleep tried to let the page go, and it warned: they leave it until it navigates - * or gets a new user gesture (D73). + * or gets a new user gesture (D73). `gestures`: its gesture count then. */ refused?: { doc: number; gestures: number } /** The user chose Leave: the page's next warning goes through until then. */ @@ -61,7 +62,7 @@ interface LeavePage { } export interface LeaveInfo { - /** User gestures in the page's current document. */ + /** User gestures in the page's current document, from any of its frames. */ gestures: number /** Auto-archive and sleep found the page warning, and leave it until it navigates or gets a new gesture. */ refused: boolean @@ -112,21 +113,24 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog const stateOf = (page: WebContents): LeavePage => { let p = pages.get(page.id) if (!p) { - p = { gestures: 0, doc: 0, allowUntil: 0, closingUntil: 0 } + p = { since: gestureCount(page), doc: 0, allowUntil: 0, closingUntil: 0 } pages.set(page.id, p) } return p } + /** User gestures in the page's current document, from any of its frames (D77). */ + const gesturesIn = (page: WebContents, p: LeavePage | undefined): number => (p ? gestureCount(page) - p.since : 0) + /** * Whether a page could warn now: Chromium's warning needs a user gesture in the page first, and a * page waiting on its own alert, confirm or prompt goes with it. */ const mayWarn = (page: WebContents | undefined): page is WebContents => - !!page && !page.isDestroyed() && (pages.get(page.id)?.gestures ?? 0) > 0 && !dialogs.pending(page, 'script') + !!page && !page.isDestroyed() && gesturesIn(page, pages.get(page.id)) > 0 && !dialogs.pending(page, 'script') - const isRefused = (p: LeavePage | undefined): boolean => - !!p?.refused && p.refused.doc === p.doc && p.refused.gestures === p.gestures + const isRefused = (page: WebContents, p: LeavePage | undefined): boolean => + !!p?.refused && p.refused.doc === p.doc && p.refused.gestures === gestureCount(page) /** Runs one of the preload's answers in its world; `fallback` when the page is gone or too busy. */ const ask = (page: WebContents, code: string, fallback: T, timeout = ASK_MS): Promise => { @@ -187,7 +191,7 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog const page = tabs.contents(tabId) if (!page) return const p = stateOf(page) - p.refused = { doc: p.doc, gestures: p.gestures } + p.refused = { doc: p.doc, gestures: gestureCount(page) } } // ---- Navigations ---- @@ -264,10 +268,9 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog const watch = (page: WebContents, kind: 'tab' | 'popup') => { if (watched.has(page)) return watched.add(page) + // A press in any of its frames counts (D77): an embedded editor is often another site's. + watchGestures(page) const p = stateOf(page) - page.on('input-event', (_event, input) => { - if (isActivation(input)) p.gestures++ - }) page.on('did-start-navigation', (details) => { if (!details.isMainFrame) return // The navigation goes on: nothing is stopped, and a Leave was used or no longer needed. @@ -279,7 +282,7 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog page.on('did-navigate', () => { // A new document: no gesture has reached it yet. p.doc++ - p.gestures = 0 + p.since = gestureCount(page) }) page.on('will-prevent-unload', (event) => { if (windows.isUi(page)) return @@ -451,12 +454,12 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog // A page waiting on the user (its dialog, or a sign-in) waits for them. if (dialogs.pending(page, 'all')) return false // Being let go already, or it warned when last tried (D73). - return !guardingTabs.has(tabId) && !isRefused(pages.get(page.id)) + return !guardingTabs.has(tabId) && !isRefused(page, pages.get(page.id)) }, info(page) { const p = pages.get(page.id) if (!p) return null - return { gestures: p.gestures, refused: isRefused(p), allowed: Date.now() <= p.allowUntil } + return { gestures: gesturesIn(page, p), refused: isRefused(page, p), allowed: Date.now() <= p.allowUntil } }, } ops.mayDiscard = (tabId) => service.mayDiscard(tabId) diff --git a/src/main/pages/other-apps.ts b/src/main/pages/other-apps.ts index 6e0e2aab..5d79da58 100644 --- a/src/main/pages/other-apps.ts +++ b/src/main/pages/other-apps.ts @@ -16,9 +16,9 @@ import type { ProfileId, TabId } from '../../shared/state' import { sectionOf } from '../../shared/tree' import type { MainContext } from '../context' import { setAppLinkHandler } from '../nav/permissions' -import { isActivation } from '../tabs/runtime' import { activeTabOf } from '../windows' import type { PageDialogs$ } from './dialogs' +import { gestureCount, watchGestures } from './gestures' /** * Opens a link in the app the OS has for it: the one place Orbit launches another app (NV-19). @@ -173,10 +173,11 @@ function neverShowedPage(page: WebContents): boolean { * followed the link. Orbit asks over that page, in a page dialog: the app with its icon, the site, * and "Always allow" for a web site; Cancel is the default. Open launches the app through * `launch`, the one place Orbit starts another app. A site always allowed for a kind of link opens - * it without asking, once per user gesture in the page, as in Chromium: a page can't open the app - * again and again unasked. A page asks one question at a time: a link it follows meanwhile is dropped. - * Once the user answers Cancel, the page asks again only after a new user gesture in it, as in - * Chromium: a page can't keep asking once the user said no. + * it without asking, once per user gesture in the page (a click or a key in any of its frames, + * `gestureCount`, D77), as in Chromium: a page can't open the app again and again unasked. A page + * asks one question at a time: a link it follows meanwhile is dropped. Once the user answers Cancel, + * the page asks again only after a new user gesture in it, as in Chromium: a page can't keep asking + * once the user said no. * * A tab, Peek, Mini Window or pop-up opened only for such a link (`target=_blank`, `window.open`, * or a redirect there) never shows a page: it closes itself once its navigation ends, and the tab @@ -209,8 +210,8 @@ export function registerOtherApps( const asking = new Set() /** Where each watched page's main frame is going, or went last: the address after any redirect. */ const going = new Map() - /** Pages (by contents id) with a user gesture that no link to another app has used yet. */ - const gestured = new Set() + /** Each page's gesture count (by contents id) when it last followed a link to another app: that link used them up. */ + const used = new Map() /** Pages (by contents id) whose last question the user answered Cancel: they ask again only with a new gesture. */ const refused = new Set() @@ -269,17 +270,12 @@ export function registerOtherApps( page.on('did-stop-loading', () => { if (launchOnly(page)) setImmediate(() => closeLaunchOnly(page)) }) - page.on('input-event', (_event, input) => { - if (isActivation(input)) gestured.add(id) - }) - // A click in a frame from another site (in a process of its own) comes only as a mouse event. - page.on('before-mouse-event', (_event, mouse) => { - if (mouse.type === 'mouseDown') gestured.add(id) - }) + // A click or a key in any of its frames, one from another site's too (D77). + watchGestures(page) page.once('destroyed', () => { going.delete(id) asking.delete(id) - gestured.delete(id) + used.delete(id) refused.delete(id) }) } @@ -331,8 +327,10 @@ export function registerOtherApps( if (!scheme || page.isDestroyed() || !dialogs.hostable(page)) return // The site asking is the frame that followed the link (the page's own, or one in it). const origin = permissionOrigin(details.requestingUrl) - // Each link to another app uses up the page's last user gesture (Chromium's rule). - const fresh = gestured.delete(page.id) + // Each link to another app uses up the page's user gestures so far (Chromium's rule). + const count = gestureCount(page) + const fresh = count > (used.get(page.id) ?? 0) + used.set(page.id, count) if (origin && /^https?:\/\//i.test(origin) && choices.has(profileId, origin, scheme)) { // Always allowed: it opens without asking, but only for a gesture no link used yet (NV-19). if (fresh) launch(page, url) diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 3f676666..6731c8bd 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -16,6 +16,7 @@ import type { Tab, TabId, WindowId } from '../../shared/state' import { isAwayFromPinned, sameDocUrl } from '../../shared/tabs' import type { MainContext } from '../context' import { KEEP_KEYBOARD } from '../keys/focus' +import { onGesture } from '../pages/gestures' import { showPageContextMenu } from './context-menu' import { FaviconStore } from './favicon-store' import { isIco, readIco } from './ico' @@ -311,12 +312,12 @@ export class TabRuntime { const icon = favicons.find((f) => /^(https?|data):/.test(f)) if (icon) void this.storeFavicon(tabId, contents, icon) }) - // Pop-ups need a recent user gesture in the opener (NV-09). - contents.on('input-event', (_e, input) => { - if (!isActivation(input)) return + // Pop-ups need a recent user gesture in the opener (NV-09): a click or a key in any of its + // frames, one from another site's too (D77). + onGesture(contents, () => { const now = Date.now() const last = this.gestures.get(tabId) - // One physical press can arrive as two events (raw key down + key down): one activation. + // Two presses within 30 ms are one activation. if (last && now - last.at < 30) last.at = now else this.gestures.set(tabId, { at: now, used: false }) }) diff --git a/tests/e2e/pages-dialogs.spec.ts b/tests/e2e/pages-dialogs.spec.ts index d954d23c..ffbb2ad3 100644 --- a/tests/e2e/pages-dialogs.spec.ts +++ b/tests/e2e/pages-dialogs.spec.ts @@ -15,9 +15,11 @@ import { AUTH_USER, clickClosing, clickInFrame, + clickInOtherSiteFrame, dialogPage, inFrame, leaveInfo, + otherSite, type PagesFixtures, pageDialogs, startPagesFixtures, @@ -411,6 +413,28 @@ async function framedTab(app: Orbit['app'], name: string, opts: { windowId?: str return { id, url } } +/** + * Opens `/framed` with its frame, a `/warn` page, on another site, and clicks in the frame: a press + * the engine reports only to Orbit's mouse events, never as the page's input (D77). + */ +async function otherSiteFramedTab(app: Orbit['app'], name: string, opts: { windowId?: string } = {}) { + const url = fx.url(`/framed?name=${name}&src=${encodeURIComponent(otherSite(fx, '/warn'))}`) + const id = opts.windowId ? await cmdIn(app, opts.windowId, 'tabs.create', { url }) : await newTab(app, url) + await waitTab(app, id, '/framed') + await clickInOtherSiteFrame(app, url) + return { id, url } +} + +/** Answers Leave on the leave-page dialog over each of these tabs in turn, whichever shows first. */ +async function leaveEach(app: Orbit['app'], tabIds: string[], before?: () => Promise): Promise { + let left = tabIds + while (left.length > 0) { + await before?.() + const answered = await answerNextLeave(app, left, 'ok') + left = left.filter((id) => id !== answered) + } +} + /** Answers the leave-page dialog that shows next, over whichever tab; returns that tab. */ async function answerNextLeave(app: Orbit['app'], tabIds: string[], choice: 'ok' | 'cancel'): Promise { const d = await waitDialog(app, (x) => !!x.tabId && tabIds.includes(x.tabId) && x.onScreen && x.kind === 'leave') @@ -570,7 +594,7 @@ test('[NV-24][KS-08] ⌘W (Ctrl+W) on a page whose frame warns asks too: Cancel await expect.poll(async () => await tab(app, id)).toBeUndefined() }) -test('[NV-24] closing a window with pages that warn, from the page or a frame, asks over each: Cancel keeps the window, Leave on each closes it', async () => { +test('[NV-24] closing a window with pages that warn, from the page, a frame or a frame from another site, asks over each: Cancel keeps the window, Leave on each closes it', async () => { const { app } = orbit const ids = () => app.evaluate(() => (globalThis as unknown as { __orbit: { windowIds: () => string[] } }).__orbit.windowIds()) @@ -585,51 +609,54 @@ test('[NV-24] closing a window with pages that warn, from the page or a frame, a .not.toBe('') const warn = await warningTab(app, { windowId }) const framed = (await framedTab(app, 'Window', { windowId })).id + // A click in a frame from another site is a gesture in its page too (D77). + const crossed = (await otherSiteFramedTab(app, 'WindowCrossed', { windowId })).id + const all = [warn, framed, crossed] await closeWindow(app, windowId) - await answerNextLeave(app, [warn, framed], 'cancel') + await answerNextLeave(app, all, 'cancel') expect(await ids()).toContain(windowId) - expect(await tab(app, warn)).toBeTruthy() - expect(await tab(app, framed)).toBeTruthy() - expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, framed])) + for (const id of all) expect(await tab(app, id)).toBeTruthy() + expect(await liveTabs(app)).toEqual(expect.arrayContaining(all)) expect(await pageDialogs(app)).toEqual([]) await closeWindow(app, windowId) - const first = await answerNextLeave(app, [warn, framed], 'ok') - expect(await ids()).toContain(windowId) - await answerNextLeave(app, [first === warn ? framed : warn], 'ok') + await leaveEach(app, all, async () => { + expect(await ids()).toContain(windowId) + }) await expect.poll(ids).not.toContain(windowId) }) -test('[NV-24][SP-12] deleting a Space with pages that warn, from the page or a frame, asks over each: Cancel keeps the Space, Leave on each deletes it', async () => { +test('[NV-24][SP-12] deleting a Space with pages that warn, from the page, a frame or a frame from another site, asks over each: Cancel keeps the Space, Leave on each deletes it', async () => { const { app } = orbit const home = (await space(app)).id const spaceId = await cmd(app, 'spaces.create', { name: 'Drafts' }) await expect.poll(async () => (await windowState(app)).spaceId).toBe(spaceId) const warn = await warningTab(app) const framed = (await framedTab(app, 'Space')).id + const crossed = (await otherSiteFramedTab(app, 'SpaceCrossed')).id + const all = [warn, framed, crossed] await cmd(app, 'spaces.switch', { spaceId: home }) await expect.poll(async () => (await windowState(app)).spaceId).toBe(home) // Each page shows (its Space with it) as it asks. const deleting = cmd(app, 'spaces.delete', { spaceId }) - await answerNextLeave(app, [warn, framed], 'cancel') + await answerNextLeave(app, all, 'cancel') expect(await deleting).toBe(false) expect((await state(app)).spaces[spaceId]).toBeTruthy() - expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, framed])) + expect(await liveTabs(app)).toEqual(expect.arrayContaining(all)) const again = cmd(app, 'spaces.delete', { spaceId }) - const first = await answerNextLeave(app, [warn, framed], 'ok') - await answerNextLeave(app, [first === warn ? framed : warn], 'ok') + await leaveEach(app, all) expect(await again).toBe(true) expect((await state(app)).spaces[spaceId]).toBeUndefined() - expect(await tab(app, warn)).toBeUndefined() - expect(await tab(app, framed)).toBeUndefined() + for (const id of all) expect(await tab(app, id)).toBeUndefined() }) -test('[NV-24][TD-01] auto-archive keeps a page that warns, from the page or a frame, or one waiting on its dialog, without asking', async () => { +test('[NV-24][TD-01] auto-archive keeps a page that warns, from the page, a frame or a frame from another site, or one waiting on its dialog, without asking', async () => { const { app } = orbit const warn = await warningTab(app) const framed = (await framedTab(app, 'Archive')).id + const crossed = (await otherSiteFramedTab(app, 'ArchiveCrossed')).id const askUrl = fx.url('/ask?kind=confirm&msg=Still%20there%3F') const asking = await newTab(app, askUrl) await waitTab(app, asking, '/ask') @@ -649,23 +676,25 @@ test('[NV-24][TD-01] auto-archive keeps a page that warns, from the page or a fr const later = Date.now() + 13 * 3600_000 const first = await tabsHook<{ archived: string[]; slept: string[] }>(app, 'maintenance', later) expect(first.archived).not.toContain(asking) + await expect.poll(async () => (await leaveInfo(app, crossed))?.refused).toBe(true) await expect.poll(async () => (await leaveInfo(app, framed))?.refused).toBe(true) await expect.poll(async () => (await leaveInfo(app, warn))?.refused).toBe(true) await expect.poll(async () => [await tab(app, touched), await tab(app, plain)]).toEqual([undefined, undefined]) // Not asked again until it navigates or gets a new gesture. const second = await tabsHook<{ archived: string[]; slept: string[] }>(app, 'maintenance', later + 1) - for (const id of [warn, framed, asking]) { + for (const id of [warn, framed, crossed, asking]) { expect(second.archived).not.toContain(id) expect(second.slept).not.toContain(id) } - expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, framed, asking])) + expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, framed, crossed, asking])) expect((await pageDialogs(app)).map((d) => d.tabId)).toEqual([asking]) }) -test('[NV-24][TB-16] sleep keeps a page that warns, from the page or a frame, awake without asking, until it navigates or gets a new gesture', async () => { +test('[NV-24][TB-16] sleep keeps a page that warns, from the page, a frame or a frame from another site, awake without asking, until it navigates or gets a new gesture', async () => { const { app } = orbit const warn = await warningTab(app) const { id: framed, url: framedUrl } = await framedTab(app, 'Sleep') + const crossed = (await otherSiteFramedTab(app, 'SleepCrossed')).id const used = fx.url('/page/Used') const touched = await newTab(app, used) await waitTab(app, touched, '/page/Used') @@ -678,15 +707,18 @@ test('[NV-24][TB-16] sleep keeps a page that warns, from the page or a frame, aw const later = Date.now() + 3600_000 const first = await tabsHook<{ archived: string[]; slept: string[] }>(app, 'maintenance', later) expect(first.archived).toEqual([]) + await expect.poll(async () => (await leaveInfo(app, crossed))?.refused).toBe(true) await expect.poll(async () => (await leaveInfo(app, framed))?.refused).toBe(true) await expect.poll(async () => (await leaveInfo(app, warn))?.refused).toBe(true) await expect.poll(async () => (await tab(app, touched))?.asleep).toBe(true) - expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, framed])) + expect(await liveTabs(app)).toEqual(expect.arrayContaining([warn, framed, crossed])) expect((await tab(app, framed))?.asleep).toBe(false) + expect((await tab(app, crossed))?.asleep).toBe(false) expect(await pageDialogs(app)).toEqual([]) const second = await tabsHook<{ archived: string[]; slept: string[] }>(app, 'maintenance', later + 1) expect(second.slept).not.toContain(warn) expect(second.slept).not.toContain(framed) + expect(second.slept).not.toContain(crossed) // A new gesture: the next pass asks the page again, and it still warns. await clickInFrame(app, framedUrl) diff --git a/tests/e2e/pages-gestures.spec.ts b/tests/e2e/pages-gestures.spec.ts new file mode 100644 index 00000000..50f314f3 --- /dev/null +++ b/tests/e2e/pages-gestures.spec.ts @@ -0,0 +1,199 @@ +import { execFileSync } from 'node:child_process' +import { type ElectronApplication, expect, test } from '@playwright/test' +import { isMac, launchOrbit, type Orbit, state, tabPage } from './helpers' +import { pressInPage } from './nav-support' +import { + APPS_ENV, + appLinks, + clickInOtherSiteFrame, + dialogPage, + inFrame, + launchedLinks, + leaveInfo, + otherSite, + type PagesFixtures, + pageDialogs, + startPagesFixtures, + waitDialog, +} from './pages-support' +import { liveTabs, newTab, tab, waitTab } from './tabs-support' + +/** + * A press in a frame from another site is a user gesture in its page (D77): the leave-page warning + * (NV-24), links to other apps (NV-19) and pop-ups (NV-09) count it as one in the page's own frame. + * Such a frame has a process of its own: Playwright's click reaches it through the browser, and only + * the real keyboard's keys do (xdotool, Linux). + */ + +let fx: PagesFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit({ env: APPS_ENV }) +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +/** ⌘ on macOS, Ctrl elsewhere, for pressInPage. */ +const MOD_KEY = isMac ? 'meta' : 'control' + +/** The real X keyboard on the test's display (xdotool, Linux), or null where there is none. */ +const keyboard = (() => { + if (process.platform !== 'linux' || !process.env.DISPLAY) return null + try { + execFileSync('xdotool', ['version'], { stdio: 'ignore' }) + } catch { + return null + } + return { + type: (text: string) => execFileSync('xdotool', ['type', '--delay', '40', text], { stdio: 'ignore' }), + press: (key: string) => execFileSync('xdotool', ['key', key], { stdio: 'ignore' }), + } +})() +const NO_KEYBOARD = 'the keys have to come from the real keyboard: test input never reaches a frame from another site' + +/** A `/framed` page whose frame is `path` on another site. */ +const framedUrl = (name: string, path = '/warn') => + fx.url(`/framed?name=${name}&src=${encodeURIComponent(otherSite(fx, path))}`) + +/** Runs a script in the page whose URL is `url`, as its own: no user gesture. */ +function inPage(app: ElectronApplication, url: string, code: string): Promise { + return app.evaluate( + ({ webContents }, [u, c]) => + webContents + .getAllWebContents() + .find((w) => w.getURL() === u) + ?.executeJavaScript(c), + [url, code] as const, + ) +} + +/** + * Puts the keyboard on an element of the frame of the page whose URL is `url`, from the page's and + * the frame's own scripts: no user gesture. + */ +async function keyboardInFrame(app: ElectronApplication, url: string, selector: string): Promise { + await expect.poll(() => inFrame(app, url, 'document.readyState')).toBe('complete') + await app.evaluate( + ({ webContents }, u) => + webContents + .getAllWebContents() + .find((w) => w.getURL() === u) + ?.focus(), + url, + ) + await expect + .poll(() => app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL() ?? '')) + .toBe(url) + await inPage(app, url, `document.getElementById('frame').focus(); 0`) + const target = `document.querySelector(${JSON.stringify(selector)})` + await inFrame(app, url, `${target}.focus(); 0`) + await expect.poll(() => inFrame(app, url, `document.hasFocus() && document.activeElement === ${target}`)).toBe(true) +} + +/** Answers the leave-page dialog over a tab's page. */ +async function answerLeave(app: ElectronApplication, tabId: string, choice: 'ok' | 'cancel'): Promise { + const d = await waitDialog(app, (x) => x.tabId === tabId && x.onScreen && x.kind === 'leave') + const view = await dialogPage(app, d.id) + await view.getByTestId(choice === 'ok' ? 'page-dialog-ok' : 'page-dialog-cancel').click() + await expect.poll(async () => (await pageDialogs(app)).some((x) => x.id === d.id)).toBe(false) +} + +test('[NV-24][KS-08] a click in a frame from another site counts: ⌘W (Ctrl+W) on its page asks when the frame warns; ⌘W is no gesture, so a page nobody touched closes at once (D77)', async () => { + const { app } = orbit + const url = framedUrl('Clicked') + const id = await newTab(app, url) + await waitTab(app, id, '/framed') + await clickInOtherSiteFrame(app, url) + await pressInPage(app, url, 'W', [MOD_KEY]) + await answerLeave(app, id, 'cancel') + expect(await liveTabs(app)).toContain(id) + await pressInPage(app, url, 'W', [MOD_KEY]) + await answerLeave(app, id, 'ok') + await expect.poll(() => tab(app, id)).toBeUndefined() + + // A page that warns but nobody touched: ⌘W, a key Orbit takes, gives it no gesture, and the + // engine (which counts the close as one) is never asked. It closes at once. + const untouchedUrl = fx.url('/warn?name=Untouched') + const untouched = await newTab(app, untouchedUrl) + await waitTab(app, untouched, '/warn') + expect((await leaveInfo(app, untouched))?.gestures).toBe(0) + await pressInPage(app, untouchedUrl, 'W', [MOD_KEY]) + await expect.poll(() => tab(app, untouched)).toBeUndefined() + expect(await pageDialogs(app)).toEqual([]) +}) + +test('[NV-24][KS-08] typing in a frame from another site counts: ⌘W (Ctrl+W) on its page asks when the frame warns, and Cancel keeps what was typed (D77)', async () => { + test.skip(!keyboard, NO_KEYBOARD) + const keys = keyboard as NonNullable + const { app } = orbit + const url = framedUrl('Typed') + const id = await newTab(app, url) + await waitTab(app, id, '/framed') + const field = `document.querySelector('input')` + await expect.poll(() => inFrame(app, url, `${field}.value = ''`)).toBe('') + await keyboardInFrame(app, url, 'input') + expect((await leaveInfo(app, id))?.gestures).toBe(0) + keys.type('note') + await expect.poll(() => inFrame(app, url, `${field}.value`)).toBe('note') + keys.press('ctrl+w') + await answerLeave(app, id, 'cancel') + expect(await liveTabs(app)).toContain(id) + expect(await inFrame(app, url, `${field}.value`)).toBe('note') + await pressInPage(app, url, 'W', [MOD_KEY]) + await answerLeave(app, id, 'ok') + await expect.poll(() => tab(app, id)).toBeUndefined() +}) + +test('[NV-19] a key in a frame from another site is a new user gesture: after the page’s question, a link there asks again (D77)', async () => { + test.skip(!keyboard, NO_KEYBOARD) + const keys = keyboard as NonNullable + const { app } = orbit + const frameSite = `127.0.0.1:${fx.port}` + const url = fx.url(`/apps?frame=${encodeURIComponent(otherSite(fx, '/app-frame'))}`) + const tabId = await newTab(app, url) + await waitTab(app, tabId, '/apps') + const page = await tabPage(app, url) + // The page asks once: Cancel. Until a new gesture, it asks nothing more. + await page.locator('#mail').click() + const asked = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'app' && d.focused) + await (await dialogPage(app, asked.id)).getByTestId('page-dialog-cancel').click() + await expect.poll(async () => (await pageDialogs(app)).length).toBe(0) + // The keyboard goes to the frame's link from the pages' own scripts; Enter there follows it. + await keyboardInFrame(app, url, '#frame-mail') + keys.press('Return') + const framed = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'app' && d.id > asked.id && d.onScreen) + expect(framed.message).toBe(`${frameSite} wants to open this application.`) + expect(await appLinks(app)).toEqual([ + { url: 'mailto:someone@example.com?subject=Hello%20there', outcome: 'asked' }, + { url: 'mailto:frame@example.com', outcome: 'asked' }, + ]) + await (await dialogPage(app, framed.id)).getByTestId('page-dialog-cancel').click() + await expect.poll(async () => (await pageDialogs(app)).length).toBe(0) + expect(await launchedLinks(app)).toEqual([]) +}) + +test('[NV-09] a click in a frame from another site lets the frame open a pop-up (D77)', async () => { + const { app } = orbit + const popped = otherSite(fx, '/page/Popped') + const url = framedUrl('Opener', `/opener?to=${encodeURIComponent('/page/Popped')}`) + const id = await newTab(app, url) + await waitTab(app, id, '/framed') + await expect.poll(() => inFrame(app, url, 'document.readyState')).toBe('complete') + const popUps = () => + app.evaluate( + ({ BrowserWindow }, u) => + BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed() && w.webContents.getURL() === u).length, + popped, + ) + await (await tabPage(app, url)).frameLocator('#frame').locator('#open').click() + await expect.poll(popUps).toBe(1) + expect((await state(app)).tabs[id]?.blockedPopups).toBeUndefined() +}) diff --git a/tests/e2e/pages-support.ts b/tests/e2e/pages-support.ts index c03971fe..3f41f4a5 100644 --- a/tests/e2e/pages-support.ts +++ b/tests/e2e/pages-support.ts @@ -560,6 +560,24 @@ export async function clickInFrame(app: ElectronApplication, url: string): Promi await expect.poll(async () => Number(await inFrame(app, url, 'window.__clicks || 0'))).toBeGreaterThan(before) } +/** A fixture page's address on 127.0.0.1: another site than `fx.url`'s (localhost), so a frame from it has a process of its own. */ +export function otherSite(fx: PagesFixtures, path: string): string { + return `http://127.0.0.1:${fx.port}${path}` +} + +/** + * Clicks in a `/framed` page's frame from another site (a `/warn` page) the way a person does, once + * the frame has loaded, and waits until the frame got the press. Playwright's click goes through the + * browser, which sends it to the frame's own process; `sendInputEvent` (clickInFrame) reaches only + * the page's. + */ +export async function clickInOtherSiteFrame(app: ElectronApplication, url: string): Promise { + await expect.poll(() => inFrame(app, url, 'document.readyState')).toBe('complete') + const before = Number(await inFrame(app, url, 'window.__clicks || 0')) + await (await tabPage(app, url)).frameLocator('#frame').locator('#area').click() + await expect.poll(async () => Number(await inFrame(app, url, 'window.__clicks || 0'))).toBeGreaterThan(before) +} + export function leaveInfo(app: ElectronApplication, tabId: string): Promise { return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.leave(id), tabId) } diff --git a/tests/unit/pages-gestures.test.ts b/tests/unit/pages-gestures.test.ts new file mode 100644 index 00000000..d96aa5bf --- /dev/null +++ b/tests/unit/pages-gestures.test.ts @@ -0,0 +1,143 @@ +import { EventEmitter } from 'node:events' +import type { WebContents } from 'electron' +import { describe, expect, it } from 'vitest' +import { gestureCount, onGesture, watchGestures } from '../../src/main/pages/gestures' + +/** A page's web contents, as far as the gesture count uses it. */ +class FakePage extends EventEmitter { + destroyed = false + isDestroyed = () => this.destroyed + get contents(): WebContents { + return this as unknown as WebContents + } +} + +/** An Electron event, which a listener may prevent. */ +function event() { + const e = { + defaultPrevented: false, + preventDefault() { + e.defaultPrevented = true + }, + } + return e +} + +const at = { x: 10, y: 10 } + +function setup() { + const page = new FakePage() + watchGestures(page.contents) + const count = () => gestureCount(page.contents) + /** A mouse-down in the page's own frame: the engine reports it twice, in one task. */ + const clickPage = () => { + page.emit('before-mouse-event', event(), { type: 'mouseDown', ...at }) + page.emit('input-event', event(), { type: 'mouseDown', ...at }) + } + /** A mouse-down in a frame from another site, which has a widget of its own. */ + const clickFrame = () => page.emit('before-mouse-event', event(), { type: 'mouseDown', ...at }) + /** A key down, in whichever frame has focus. */ + const key = (key: string, more: Record = {}) => + page.emit('before-input-event', event(), { type: 'keyDown', key, isAutoRepeat: false, ...more }) + return { page, count, clickPage, clickFrame, key } +} + +/** Lets the events of one task settle. */ +const nextTask = () => new Promise((resolve) => setImmediate(resolve)) + +describe('A page’s user gestures, from any of its frames (D77)', () => { + it('[NV-24][NV-19][NV-09] a mouse-down counts once, in the page’s own frame or in a frame from another site', async () => { + const { page, count, clickPage, clickFrame } = setup() + clickPage() + await nextTask() + expect(count()).toBe(1) + clickFrame() + await nextTask() + expect(count()).toBe(2) + // Two presses, each in a task of its own, are two. + clickPage() + await nextTask() + clickPage() + await nextTask() + expect(count()).toBe(4) + // A mouse-down the engine reports only as input counts too; a move or a release doesn't. + page.emit('input-event', event(), { type: 'mouseDown', ...at }) + page.emit('before-mouse-event', event(), { type: 'mouseUp', ...at }) + page.emit('before-mouse-event', event(), { type: 'mouseMove', ...at }) + page.emit('input-event', event(), { type: 'mouseUp', ...at }) + await nextTask() + expect(count()).toBe(5) + }) + + it('[NV-24][NV-19][NV-09] a key that reaches the page counts; one Orbit takes as its own shortcut doesn’t, so ⌘W never counts for the page it closes', async () => { + const { page, count, key } = setup() + key('a') + await nextTask() + expect(count()).toBe(1) + // Orbit's shortcuts: a listener prevents the key, before or after the count's own. + const shortcut = (e: ReturnType, input: { key: string; control?: boolean; meta?: boolean }) => { + if ((input.control || input.meta) && ['w', 't'].includes(input.key)) e.preventDefault() + } + page.on('before-input-event', shortcut) + key('w', { meta: true }) + await nextTask() + expect(count()).toBe(1) + page.off('before-input-event', shortcut) + page.prependListener('before-input-event', shortcut) + key('t', { control: true }) + await nextTask() + expect(count()).toBe(1) + // A key with a modifier that Orbit leaves to the page counts. + key('b', { control: true }) + await nextTask() + expect(count()).toBe(2) + }) + + it('[NV-24][NV-19][NV-09] as in Chromium, Esc, a modifier alone, a key held down and a key’s release count for nothing', async () => { + const { page, count, key } = setup() + for (const alone of ['Escape', 'Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock']) key(alone) + key('a', { isAutoRepeat: true }) + page.emit('before-input-event', event(), { type: 'keyUp', key: 'a', isAutoRepeat: false }) + // The engine's own report of a key that reached the main frame: the key was counted already. + page.emit('input-event', event(), { type: 'rawKeyDown', keyCode: 'a' }) + page.emit('input-event', event(), { type: 'keyDown', keyCode: 'a' }) + page.emit('input-event', event(), { type: 'char', keyCode: 'a' }) + await nextTask() + expect(count()).toBe(0) + // A key that closes the page as it comes counts for no page. + key('a') + page.destroyed = true + await nextTask() + expect(count()).toBe(0) + }) + + it('[NV-09] a tap and a touch’s end count', async () => { + const { page, count } = setup() + page.emit('input-event', event(), { type: 'gestureTap', ...at }) + page.emit('input-event', event(), { type: 'touchEnd' }) + page.emit('input-event', event(), { type: 'touchStart' }) + await nextTask() + expect(count()).toBe(2) + }) + + it('[NV-24][NV-19][NV-09] each page counts its own, from when it was watched, once however often it was; a listener hears each gesture', async () => { + const { page, count, clickFrame, key } = setup() + const other = new FakePage() + expect(gestureCount(other.contents)).toBe(0) + // Watched again (the leave guard, links to other apps, the pop-up rule): still one count. + watchGestures(page.contents) + const heard: number[] = [] + onGesture(page.contents, () => heard.push(count())) + clickFrame() + key('x') + await nextTask() + expect(count()).toBe(2) + expect(heard).toEqual([1, 2]) + expect(gestureCount(other.contents)).toBe(0) + // A listener on a page not watched yet starts its count. + onGesture(other.contents, () => heard.push(-1)) + other.emit('before-mouse-event', event(), { type: 'mouseDown', ...at }) + expect(gestureCount(other.contents)).toBe(1) + expect(heard).toEqual([1, 2, -1]) + }) +}) From d00f87971b5096145359423b576ec0f74b0bd86f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:43:32 +0000 Subject: [PATCH 26/29] fix(pages): a link to another app uses up the gesture whatever the answer, Open or Cancel (D77 (2)) Once a page has asked (or an always-allowed site opened its app), its next link to another app asks or opens only after a new user gesture in it. Before, only Cancel used the gesture up, so after Open a page's timer could ask again at once (fix check 1, follow-up 4). Co-Authored-By: Claude Opus 5.5 --- src/main/pages/other-apps.ts | 26 ++++++++++++++------------ tests/e2e/pages-apps.spec.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/main/pages/other-apps.ts b/src/main/pages/other-apps.ts index 5d79da58..c914ba01 100644 --- a/src/main/pages/other-apps.ts +++ b/src/main/pages/other-apps.ts @@ -175,9 +175,9 @@ function neverShowedPage(page: WebContents): boolean { * `launch`, the one place Orbit starts another app. A site always allowed for a kind of link opens * it without asking, once per user gesture in the page (a click or a key in any of its frames, * `gestureCount`, D77), as in Chromium: a page can't open the app again and again unasked. A page - * asks one question at a time: a link it follows meanwhile is dropped. Once the user answers Cancel, - * the page asks again only after a new user gesture in it, as in Chromium: a page can't keep asking - * once the user said no. + * asks one question at a time: a link it follows meanwhile is dropped. Once it has asked, it asks + * again only after a new user gesture in it, whatever the answer was, Open or Cancel (D77): a page + * can't keep asking. * * A tab, Peek, Mini Window or pop-up opened only for such a link (`target=_blank`, `window.open`, * or a redirect there) never shows a page: it closes itself once its navigation ends, and the tab @@ -212,8 +212,8 @@ export function registerOtherApps( const going = new Map() /** Each page's gesture count (by contents id) when it last followed a link to another app: that link used them up. */ const used = new Map() - /** Pages (by contents id) whose last question the user answered Cancel: they ask again only with a new gesture. */ - const refused = new Set() + /** Pages (by contents id) that asked, or opened an app, already: they do again only with a new gesture. */ + const spent = new Set() const launchOnly = (page: WebContents) => !page.isDestroyed() && !asking.has(page.id) && !!appScheme(going.get(page.id)) && neverShowedPage(page) @@ -276,7 +276,7 @@ export function registerOtherApps( going.delete(id) asking.delete(id) used.delete(id) - refused.delete(id) + spent.delete(id) }) } ctx.hooks.tabCreated.push((_tabId, contents) => { @@ -312,8 +312,6 @@ export function registerOtherApps( } finally { asking.delete(id) } - // Cancel (or Esc): the page's next link asks only with a new gesture (Chromium's rule). - if (answer && !answer.ok) refused.add(id) if (answer?.ok) { if (answer.checked === true && origin && /^https?:\/\//i.test(origin)) choices.add(profileId, origin, scheme) launch(page, url) @@ -333,16 +331,20 @@ export function registerOtherApps( used.set(page.id, count) if (origin && /^https?:\/\//i.test(origin) && choices.has(profileId, origin, scheme)) { // Always allowed: it opens without asking, but only for a gesture no link used yet (NV-19). - if (fresh) launch(page, url) + if (fresh) { + spent.add(page.id) + launch(page, url) + } deps.note?.(url, fresh ? 'opened' : 'dropped') return } - // One question at a time; and after a Cancel, none until the user does something in the page. - if (asking.has(page.id) || (refused.has(page.id) && !fresh)) { + // One question at a time; and once the page has asked, whatever the answer (Open or Cancel), + // none until the user does something in it (D77). + if (asking.has(page.id) || (spent.has(page.id) && !fresh)) { deps.note?.(url, 'dropped') return } - refused.delete(page.id) + spent.add(page.id) asking.add(page.id) deps.note?.(url, 'asked') void ask(profileId, page, url, scheme, origin).catch((err: unknown) => console.error('[pages] link to an app', err)) diff --git a/tests/e2e/pages-apps.spec.ts b/tests/e2e/pages-apps.spec.ts index bac60030..1eeb044a 100644 --- a/tests/e2e/pages-apps.spec.ts +++ b/tests/e2e/pages-apps.spec.ts @@ -240,3 +240,33 @@ test('[NV-19] after Cancel, the page asks again only with a new user gesture: a expect(await noDialogs(app)).toBe(true) expect(await launchedLinks(app)).toEqual([]) }) + +test('[NV-19] after Open too, the page asks again only with a new user gesture: a link it follows without one asks nothing, and a click asks again (D77)', async () => { + const { app } = orbit + const { tabId, page } = await openApps(app) + const CALL = 'tel:+15550100' + await page.locator('#mail').click() + const asked = await waitDialog(app, (d) => d.tabId === tabId && d.kind === 'app' && d.focused) + await (await dialogPage(app, asked.id)).getByTestId('page-dialog-ok').click() + await expect.poll(() => launchedLinks(app)).toEqual([MAIL]) + await expect.poll(() => noDialogs(app)).toBe(true) + // Open used the click up as Cancel does: a link the page follows without a new gesture (a timer, + // say) asks nothing. + await page.evaluate((call) => { + location.href = call + }, CALL) + await expect + .poll(() => appLinks(app)) + .toEqual([ + { url: MAIL, outcome: 'asked' }, + { url: CALL, outcome: 'dropped' }, + ]) + expect(await noDialogs(app)).toBe(true) + // A click is a new gesture: its link asks. + await page.locator('#call').click() + const again = await waitDialog(app, (d) => d.kind === 'app' && d.id > asked.id && d.focused) + expect(again.title).toBe('Open this link in another app?') + await (await dialogPage(app, again.id)).getByTestId('page-dialog-cancel').click() + await expect.poll(() => noDialogs(app)).toBe(true) + expect(await launchedLinks(app)).toEqual([MAIL]) +}) From b39cb16ce6ff43f1848f4c9613c3a5dc28a69116 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:43:32 +0000 Subject: [PATCH 27/29] test(pages): Orbit's Reduce Motion stops the page dialog's and the print dialog's animation With the setting off each card's computed animation-name is its fade-in; with it on, none (FL-28; fix check 1, follow-up 3). Co-Authored-By: Claude Opus 5.5 --- tests/e2e/pages-print.spec.ts | 48 +++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/e2e/pages-print.spec.ts b/tests/e2e/pages-print.spec.ts index 4374ac60..6f65819c 100644 --- a/tests/e2e/pages-print.spec.ts +++ b/tests/e2e/pages-print.spec.ts @@ -1,14 +1,17 @@ -import { type ElectronApplication, expect, test } from '@playwright/test' -import { isMac, launchOrbit, MOD, type Orbit, run, state, tabPage, viewPage } from './helpers' +import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { action, isMac, launchOrbit, MOD, type Orbit, run, state, tabPage, viewPage } from './helpers' import { choosePageMenu, clickInPage, focusedUrl, pageMenu, pressInPage } from './nav-support' import { + dialogPage, type PagesFixtures, PRINTERS_ENV, pageBounds, + pageDialogs, printDialogs, printedJobs, printPage, startPagesFixtures, + waitDialog, waitPrint, } from './pages-support' import { newTab, tab, waitTab } from './tabs-support' @@ -334,3 +337,44 @@ test('[NV-07] the page’s print() while a dialog ⌘P opened shows returns at o }) expect(await printedJobs(app)).toEqual([]) }) + +test('[NV-07][NV-24][FL-28] with Orbit’s Reduce Motion on, the print dialog and the page’s own dialogs show in place, with no animation', async () => { + const { app } = orbit + const { tabId } = await printTab(app) + /** Whether a dialog's view has reduced motion, and the animation its card runs as it shows. */ + const card = async (view: Page, testId: string) => ({ + reduced: await view.locator('html').evaluate((html) => html.classList.contains('reduce-motion')), + animation: await view.getByTestId(testId).evaluate((el) => getComputedStyle(el).animationName), + }) + /** Opens the page's confirm, then the print dialog, and checks each card while it shows. */ + const check = async (reduced: boolean) => { + await app.evaluate( + ({ webContents }, u) => + webContents + .getAllWebContents() + .find((w) => w.getURL() === u) + ?.executeJavaScript('setTimeout(() => confirm("Sure?"), 0); 0'), + fx.url('/print'), + ) + const asked = await waitDialog(app, (d) => d.tabId === tabId && d.onScreen) + const view = await dialogPage(app, asked.id) + await expect + .poll(() => card(view, 'page-dialog')) + .toEqual({ reduced, animation: reduced ? 'none' : 'page-dialog-in' }) + await view.getByTestId('page-dialog-cancel').click() + await expect.poll(() => pageDialogs(app)).toEqual([]) + await pressInPage(app, '/print', 'P', [MOD_KEY]) + const print = await waitPrint(app) + const printView = await printPage(app, print.dialog) + await expect + .poll(() => card(printView, 'print-dialog')) + .toEqual({ reduced, animation: reduced ? 'none' : 'page-print-in' }) + await pressEscape(app) + await expect.poll(() => printDialogs(app)).toEqual([]) + } + // Off, with a system that asks for nothing: each card fades and scales in. + await check(false) + await action(app, 'app.toggleReduceMotion') + await expect.poll(async () => (await state(app)).settings.reduceMotion).toBe(true) + await check(true) +}) From 6f02b9d452b2aeaceedb95599fc3c0c77e69791f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:44:59 +0000 Subject: [PATCH 28/29] docs(pages): NV-24 and NV-19 notes and ARCHITECTURE after D77; the two limits it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NV-24's note: a gesture in any frame counts, ⌘W and Orbit's other shortcuts are none; the engine's close leaves a page that auto-archive or sleep keeps with a user activation; ⌘W on a busy page that has a beforeunload listener waits for the engine, up to its 1 s limit. NV-19's note: after any answer, Open or Cancel, a page asks again only with a new gesture. ARCHITECTURE.md's leave-page and app-link paragraphs say what the shared gesture count is and who reads it. FEATURES.md regenerated. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 4 ++-- docs/ARCHITECTURE.md | 4 ++-- docs/feature-status.json | 4 ++-- src/main/pages/leave.ts | 13 ++++++++----- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 7586b641..6607ce9e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -246,12 +246,12 @@ Spec: `spec/navigation.md` | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | -| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once the user answers Cancel (or Esc) it asks again only after a new click or key in it, as in Chromium: a link it follows before then gets nothing (a click in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | +| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | | NV-20 | Full screen | P1 | ✅ | ✅ e2e+unit | – | ⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7) | | NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | -| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (" says"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | +| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (" says"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame (one from another site too), after a user gesture in any of its frames as in Chromium (a click, or a key that reaches the page: ⌘W and Orbit's other shortcuts are none, D77), goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; known limits (D77): the engine's close runs a page's beforeunload with a user activation, which a page that auto-archive or sleep keeps then holds (Orbit's pop-up rule and links to other apps go by Orbit's own count, not the engine's), and ⌘W on a page that has had a user gesture and has a beforeunload listener, while its script is busy, waits for the engine's answer, up to its 1 s limit, before the tab goes; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | | NV-25 | Share the current page | P2 | – | – | – | | ## Peek link preview (prefix PK) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e73f0f2..c5ee18b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -156,9 +156,9 @@ A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit U Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its `print()` opens the system's dialog, so Orbit shows its own dialog as a cover over the page. The preview is the engine's PDF of the page (`printToPDF`, which runs the page's `beforeprint` and `afterprint` and leaves the page as it was), shown in the engine's PDF viewer in a frame of the cover's view (a `blob:` URL: the renderer's CSP allows `blob:` frames for it). One function sends a job out (`deliverPrint`: `print()` to the printer chosen, without the system's dialog, or the PDF saved where the user says); test runs replace it with a recorder, and take their printers from `ORBIT_E2E_PRINTERS`. Callers outside the lane (the Print action for a tab, a Peek or a Mini Window, the page menu) open the dialog through `src/main/pages/entry.ts`, as they open a page's source (DT-04, `view-source.ts`: the page's own address as `view-source:` in a new tab; a Mini Window's in a main window). -Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. An always-allowed link opens the app once per user gesture in its page (a mouse press or a key in the page, as the tab runtime counts them, or a mouse press in any of its frames: a frame from another site, in its own process, reports its input only as `before-mouse-event`), as in Chromium: each link to another app uses up the page's gesture, so a page can't open the app again and again unasked. This gesture isn't the tab runtime's `consumeGesture`: that one a `target=_blank` link to the app has already used to open its tab, and the engine names the opener as the page that asks. After the user answers Cancel, the page asks again only with a new gesture, as in Chromium, so it can't keep asking. +Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. An always-allowed link opens the app once per user gesture in its page (a press or a key in any of its frames: the page's gesture count, below), as in Chromium: each link to another app uses up the page's gestures so far, so a page can't open the app again and again unasked. Links to other apps keep their own mark of what they used, not the tab runtime's `consumeGesture`: that one a `target=_blank` link to the app has already used to open its tab, and the engine names the opener as the page that asks. Once the page has asked, whatever the answer, Open or Cancel, it asks again only with a new gesture, as in Chromium, so it can't keep asking (D77). -Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change which of that page's own navigations "Leave site?" names and starts again. Whether a page warns is never the preload's word: closing a tab, a window or a Space, auto-archive and sleep close the page through the engine (`webContents.close({ waitForBeforeUnload: true })`, D73), which runs every frame's `beforeunload` only once the close has started and says when one warns (`will-prevent-unload`); the page then stays, and Orbit asks (auto-archive and sleep keep it without asking). +Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change which of that page's own navigations "Leave site?" names and starts again. Whether a page warns is never the preload's word: closing a tab, a window or a Space, auto-archive and sleep close a page that a user gesture has reached through the engine (`webContents.close({ waitForBeforeUnload: true })`, D73), which runs every frame's `beforeunload` only once the close has started and says when one warns (`will-prevent-unload`); the page then stays, and Orbit asks (auto-archive and sleep keep it without asking). A page no gesture has reached can't warn (Chromium's rule), and closes at once. A page's gestures are one count fed by all its frames (`src/main/pages/gestures.ts`, D77), which the leave-page warning, links to other apps (NV-19) and the tab runtime's pop-up rule (NV-09) read: a mouse press, from `before-mouse-event`, which sees every frame's (`input-event` sees only the main frame's widget, so never a press in a frame from another site, which has a process of its own), and a key that reaches the page, from `before-input-event`, but not one Orbit takes as its own shortcut, so ⌘W never counts for the page it closes. Two limits (D77): the engine's close notifies the main frame of a user activation before it runs `beforeunload`, so a page that auto-archive or sleep keeps holds one; and ⌘W on a page that has had a gesture and has a `beforeunload` listener, while its script is busy, waits for the engine's answer, up to the engine's 1 s limit, before its tab goes. Media in frames (embedded players, MD-01, MD-09; D67's trade-off): a frame that has media elements, in a tab that plays media, gets media's script (`src/main/media/agent.ts`) in the frame's own world, through `webFrameMain` (Electron runs scripts in a frame only there). It sits on a hidden, read-only property with a name drawn at random for each run. The frame's page can reach it, so its reports are the page's own word: main checks them (`parseSnapshot`) and they can only change that tab's media: what its controller shows and which of its videos floats. Media's script, in a frame or the tab's page, counts a `leavepictureinpicture` or `ended` only when the event is trusted (D71). A frame's "Back to tab" is a request main may refuse: it brings a window forward or switches Space only for a picture-in-picture Orbit opened in that document (the Picture in Picture action, the controller or the page menu), once per such picture-in-picture. Otherwise it selects the tab only in a window that already has focus, in the Space that window shows, and once for each frame document (D74). Orbit's mark lasts only as long as its picture-in-picture: main clears a tab's marks when it closes that picture-in-picture or hands it to its player, and every other document's mark when it opens one, since the engine shows one at a time (D74). In any document a leave counts as "Back to tab" only if no picture-in-picture is left there after the script's 300 ms wait, so a page that moves its picture-in-picture to another of its videos gets nothing (D74). Known limits: a page's own PiP button inside a frame returns to the tab without raising the window, and a page can end its own picture-in-picture once with `exitPictureInPicture()` and bring the window forward, as the engine's button does (D74). diff --git a/docs/feature-status.json b/docs/feature-status.json index f756d887..f1eb3e03 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -204,11 +204,11 @@ "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, - "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once the user answers Cancel (or Esc) it asks again only after a new click or key in it, as in Chromium: a link it follows before then gets nothing (a click in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, + "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, - "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame, after a user gesture as in Chromium, goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, + "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame (one from another site too), after a user gesture in any of its frames as in Chromium (a click, or a key that reaches the page: ⌘W and Orbit's other shortcuts are none, D77), goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; known limits (D77): the engine's close runs a page's beforeunload with a user activation, which a page that auto-archive or sleep keeps then holds (Orbit's pop-up rule and links to other apps go by Orbit's own count, not the engine's), and ⌘W on a page that has had a user gesture and has a beforeunload listener, while its script is busy, waits for the engine's answer, up to its 1 s limit, before the tab goes; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "yes", "note": "every icon button presses to 0.94 at once and springs back (pop), the Library button too; Back/Forward nudge, Reload's glyph turns once, the + scales to 0.92; restarts on repeat; none when disabled; no spring with reduced motion"}, diff --git a/src/main/pages/leave.ts b/src/main/pages/leave.ts index f956bc13..6f268ba2 100644 --- a/src/main/pages/leave.ts +++ b/src/main/pages/leave.ts @@ -91,11 +91,14 @@ export interface PageLeave { * - **Closing a tab, a window or a Space** closes each page through the engine (D73): every frame's * `beforeunload` runs, as when Chromium closes a tab, and a page that warns stays. Orbit shows its * tab and asks there; Leave closes it, Cancel keeps that tab (or the window, or the Space). A page - * without a user gesture in its document closes at once: it can't warn (the engine itself would - * let its main frame warn on a close). Quitting doesn't ask. + * whose document no user gesture has reached, in any of its frames (`gestures.ts`, D77), closes at + * once: it can't warn (the engine itself would let its main frame warn on a close, which is why + * ⌘W, Orbit's own key, is no gesture). A busy page that may warn holds its close until the engine + * answers, up to the engine's own limit (D77). Quitting doesn't ask. * - **A pop-up's window.** Closing it runs the page's `beforeunload` as a navigation does. * - **Auto-archive and sleep** let a page go the same way (D73), and keep it, without asking, when - * the engine says it warned; they leave it until it navigates or gets a new user gesture. + * the engine says it warned; they leave it until it navigates or gets a new user gesture. The + * engine's close leaves a page it keeps with a user activation (D77). * * Orbit runs no page's `beforeunload` of its own: only the engine does, once a close, an archive or * a sleep has started. A page's own alert, confirm or prompt goes as soon as Orbit navigates the @@ -123,8 +126,8 @@ export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialog const gesturesIn = (page: WebContents, p: LeavePage | undefined): number => (p ? gestureCount(page) - p.since : 0) /** - * Whether a page could warn now: Chromium's warning needs a user gesture in the page first, and a - * page waiting on its own alert, confirm or prompt goes with it. + * Whether a page could warn now: Chromium's warning needs a user gesture in one of the page's + * frames first, and a page waiting on its own alert, confirm or prompt goes with it. */ const mayWarn = (page: WebContents | undefined): page is WebContents => !!page && !page.isDestroyed() && gesturesIn(page, pages.get(page.id)) > 0 && !dialogs.pending(page, 'script') From 6a99a04eab20009b2758cfb1c0eadd7dcbf3cac4 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:55:05 +0000 Subject: [PATCH 29/29] docs: MD-01 names what a frame's page can notice while its video is in the player (D78) The hidden div the size of the frame's viewport under ; when the frame's own script puts a same-site frame's video back, one possible "ResizeObserver loop completed with undelivered notifications" error event, and with it the page's own observers of or get the final size a frame later. FEATURES.md regenerated. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 1c4c0b58..27a89c22 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -305,7 +305,7 @@ Spec: `spec/media.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward | +| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one "ResizeObserver loop completed with undelivered notifications" error event, and then its own observers of or get the final size a frame later, as they did before | | MD-02 | Open picture-in-picture manually | P1 | 🟡 partial | ✅ e2e | – | Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over | | MD-03 | The floating player's look and placement | P0 | ✅ | ✅ e2e+unit | – | frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform | | MD-04 | Floating player controls | P0 | ✅ | ✅ e2e+unit | – | on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab | diff --git a/docs/feature-status.json b/docs/feature-status.json index d8f59d14..63f768ea 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -216,7 +216,7 @@ "FL-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps; starts a frame along, so the frame that draws ⌘S already moves"}, "FL-03": {"built": "yes", "note": "0 ms dwell; card in 200 ms (snappy), out 160 ms (quick); hysteresis band; re-entering while it leaves brings it back"}, "AP-05": {"built": "yes", "note": "private windows are near-black whatever the theme (frame, sidebar, window background) with hover and selection tuned for black; Private label with icon in the sidebar, not over the page; no Space switcher or Favorites"}, - "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward"}, + "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one \"ResizeObserver loop completed with undelivered notifications\" error event, and then its own observers of or get the final size a frame later, as they did before"}, "MD-02": {"built": "partial", "note": "Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over"}, "MD-03": {"built": "yes", "note": "frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform"}, "MD-04": {"built": "yes", "note": "on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab"},