From 9eb3bd139cf3728c30457ace41bf9952552ca2f2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:44:47 +0000 Subject: [PATCH 01/69] feat(chrome): sidebar top row, address display and navigation keys SB-03: Back, Forward and Reload act on the active tab (focused pane), grey out when unavailable and turn Reload into Stop while loading. Long-press or right-click opens a native history menu (nearest first, favicons, SVG icons drawn by the shell) that closes on tab switch. Cmd/middle-click opens the entry in a background tab with its history; Cmd-click Reload duplicates. Esc in a loading page and double-click on Reload stop. The sidebar toggle gives way first at narrow widths. Press animations per FL-15. SB-04: the address shows the host only, edits the full URL in the command bar on click, warns on plain http to other machines (never a padlock), and has site-controls (placeholder until NV-16) and copy-link buttons. NV-01/NV-03/KS-08: Cmd+Left/Right go back/forward outside text fields on macOS; Shift+F5 hard reload; focus returns to the page after a reload. NV-17: one-time shortcut tip when the URL is copied from the command bar. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 24 +- docs/feature-status.json | 12 +- src/main/features.ts | 4 +- src/main/nav/focus.ts | 29 ++ src/main/nav/index.ts | 319 +++++++++++++++ src/main/nav/site-prefs.ts | 74 ++++ src/main/tabs/commands.ts | 26 +- src/main/windows.ts | 22 +- src/renderer/src/overlay/CommandBar.tsx | 8 + src/renderer/src/shell/chrome/Address.tsx | 82 ++++ src/renderer/src/shell/chrome/NavButtons.tsx | 185 +++++++++ .../src/shell/chrome/WindowControls.tsx | 33 ++ src/renderer/src/shell/chrome/chrome.css | 116 ++++++ src/renderer/src/shell/chrome/press.ts | 27 ++ src/renderer/src/shell/chrome/raster.ts | 45 +++ .../src/shell/chrome/shortcut-label.ts | 8 + src/renderer/src/shell/sidebar/TopBar.tsx | 117 ++---- src/renderer/src/styles/global.css | 47 --- src/shared/actions.ts | 45 ++- src/shared/commands/nav.ts | 46 +++ src/shared/commands/tabs.ts | 7 +- src/shared/nav.ts | 70 ++++ src/shared/rpc.ts | 4 +- tests/e2e/chrome.spec.ts | 380 ++++++++++++++++++ tests/e2e/helpers.ts | 11 +- tests/e2e/nav-support.ts | 269 +++++++++++++ tests/e2e/nav.spec.ts | 194 +++++++++ tests/unit/nav-history.test.ts | 48 +++ tests/unit/nav-keys.test.ts | 70 ++++ tests/unit/nav-url.test.ts | 40 ++ 30 files changed, 2173 insertions(+), 189 deletions(-) create mode 100644 src/main/nav/focus.ts create mode 100644 src/main/nav/index.ts create mode 100644 src/main/nav/site-prefs.ts create mode 100644 src/renderer/src/shell/chrome/Address.tsx create mode 100644 src/renderer/src/shell/chrome/NavButtons.tsx create mode 100644 src/renderer/src/shell/chrome/WindowControls.tsx create mode 100644 src/renderer/src/shell/chrome/chrome.css create mode 100644 src/renderer/src/shell/chrome/press.ts create mode 100644 src/renderer/src/shell/chrome/raster.ts create mode 100644 src/renderer/src/shell/chrome/shortcut-label.ts create mode 100644 src/shared/commands/nav.ts create mode 100644 src/shared/nav.ts create mode 100644 tests/e2e/chrome.spec.ts create mode 100644 tests/e2e/nav-support.ts create mode 100644 tests/e2e/nav.spec.ts create mode 100644 tests/unit/nav-history.test.ts create mode 100644 tests/unit/nav-keys.test.ts create mode 100644 tests/unit/nav-url.test.ts diff --git a/FEATURES.md b/FEATURES.md index 03751c6c..9da40940 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ 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:** 485 spec’d · 115 built (40 partial) · 158 tested · 0 feel right. +**Totals:** 485 spec’d · 118 built (43 partial) · 166 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 85 | 105 | -| P1 | 221 | 27 | 49 | +| P0 | 174 | 88 | 112 | +| P1 | 221 | 27 | 50 | | P2 | 83 | 3 | 4 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -23,8 +23,8 @@ Spec: `spec/sidebar.md` |---|---|---|---|---|---|---| | SB-01 | Sidebar-first window layout | P0 | 🟡 partial | – | – | scaffold; audit vs spec | | SB-02 | Sidebar anatomy (top to bottom) | P0 | 🟡 partial | – | – | scaffold; audit vs spec | -| SB-03 | Top row: window controls and navigation buttons | P0 | 🟡 partial | – | – | scaffold | -| SB-04 | Address display at the top of the sidebar | P0 | 🟡 partial | – | – | scaffold | +| SB-03 | Top row: window controls and navigation buttons | P0 | ✅ | ✅ e2e | – | traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow | +| SB-04 | Address display at the top of the sidebar | P0 | 🟡 partial | ✅ e2e+unit | – | host only (focused pane), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button; site-controls button shows a placeholder until NV-16 | | SB-05 | Favorites grid placement | P0 | 🟡 partial | – | – | scaffold | | SB-06 | Space title row placement | P0 | ✅ | ✅ e2e | – | title row: inline rename, hover profile badge and … menu (pinned caret is PN-09) | | SB-07 | "+ New Tab" row | P0 | ✅ | ✅ e2e | – | + New Tab row with press animation opens the command bar | @@ -229,14 +229,14 @@ Spec: `spec/navigation.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| NV-01 | Back and forward | P0 | – | – | – | | +| 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 | – | – | – | | -| NV-03 | Reload and hard reload | P0 | – | – | – | | -| NV-04 | Stop loading | P1 | – | – | – | | +| 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 | 🟡 partial | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet | | NV-05 | Find in page | P0 | – | – | – | | | NV-06 | Page zoom | P0 | – | – | – | | | NV-07 | Print | P1 | – | – | – | | -| NV-08 | Clicking links: plain and modified clicks | P0 | – | – | – | | +| NV-08 | Clicking links: plain and modified clicks | P0 | – | ✅ e2e | – | | | NV-09 | Links with target=_blank and script-opened windows | P0 | – | ✅ e2e | – | | | NV-10 | New window | P0 | – | – | – | | | NV-11 | Private (incognito) window | P0 | – | – | – | | @@ -245,7 +245,7 @@ Spec: `spec/navigation.md` | NV-14 | Crashed or unresponsive tab | P1 | – | ✅ e2e | – | | | NV-15 | Permission prompts | P0 | – | ✅ e2e | – | | | NV-16 | Site controls panel | P0 | – | – | – | | -| NV-17 | Copy the current URL | P0 | – | – | – | | +| NV-17 | Copy the current URL | P0 | 🟡 partial | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02 | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | – | ✅ e2e | – | | | NV-19 | Links that open other apps (external protocols) | P1 | – | – | – | | | NV-20 | Full screen | P1 | – | ✅ e2e | – | | @@ -354,7 +354,7 @@ Spec: `spec/shortcuts.md` | KS-05 | Tabs and windows | P0 | – | – | – | | | KS-06 | Switching tabs | P0 | – | – | – | | | KS-07 | Spaces | P0 | ✅ | ✅ e2e | – | Space shortcuts wired through the action table | -| KS-08 | Page navigation and page tools | P0 | – | – | – | | +| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload and Stop keys done; find, zoom indicator and the rest come with their NV items | | KS-09 | Split view | P0 | – | – | – | | | KS-10 | Peek and Mini Window | P1 | – | – | – | | | KS-11 | Media | P1 | – | – | – | | @@ -388,7 +388,7 @@ Spec: `spec/feel.md` | FL-12 | Folder and pinned-section expand/collapse | P1 | – | – | – | | | FL-13 | Space switching: swipe physics, settle and rubber-band | P0 | ✅ | ✅ e2e+unit | – | strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade | | FL-14 | Theme-colour transition between Spaces | P0 | ✅ | ✅ e2e+unit | – | theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade | -| FL-15 | Button press micro-animations | P1 | – | – | – | | +| FL-15 | Button press micro-animations | P1 | 🟡 partial | – | – | 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-16 | Command bar open and close | P0 | ✅ | ✅ e2e | – | motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window | | FL-17 | Command bar typing and result updates | P0 | ✅ | ✅ e2e+unit | – | per-keystroke local results, identity-keyed memoized rows, instant highlight; engine suggestions fill free rows without moving the highlight | | FL-18 | Split view create and resize | P1 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 13913b60..50271c55 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -1,8 +1,8 @@ { "SB-01": {"built": "partial", "note": "scaffold; audit vs spec"}, "SB-02": {"built": "partial", "note": "scaffold; audit vs spec"}, - "SB-03": {"built": "partial", "note": "scaffold"}, - "SB-04": {"built": "partial", "note": "scaffold"}, + "SB-03": {"built": "yes", "note": "traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow"}, + "SB-04": {"built": "partial", "note": "host only (focused pane), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button; site-controls button shows a placeholder until NV-16"}, "SB-05": {"built": "partial", "note": "scaffold"}, "SB-06": {"built": "yes", "note": "title row: inline rename, hover profile badge and … menu (pinned caret is PN-09)"}, "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, @@ -153,5 +153,11 @@ "PR-06": {"built": "yes", "note": "Favorites per profile; still during same-profile swipes, cross-fade between profiles"}, "PR-07": {"built": "yes", "note": "hover badge on the title row, 'Space · Profile' tooltip, check mark in the Profile submenu"}, "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"} + "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-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": "partial", "note": "Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet"}, + "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, + "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload and Stop keys done; find, zoom indicator and the rest come with their NV items"}, + "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 9418f812..fcfb1441 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -8,6 +8,7 @@ import { registerHistory } from './history' import { registerImport } from './import' import { registerMedia } from './media' import { registerMiniWindow } from './miniwindow' +import { registerNav } from './nav' import { registerPeek } from './peek' import { registerSettings } from './settings' import { registerSpaces } from './spaces/commands' @@ -19,7 +20,8 @@ import { registerUpdates } from './updates' /** Every feature module registers its commands, actions and hooks here, in dependency order. */ export function registerFeatures(ctx: MainContext): void { registerCore(ctx) - registerTabs(ctx) + const { ops } = registerTabs(ctx) + registerNav(ctx, ops) registerSpaces(ctx) const history = registerHistory(ctx) registerSearch(ctx, history) diff --git a/src/main/nav/focus.ts b/src/main/nav/focus.ts new file mode 100644 index 00000000..863e59e0 --- /dev/null +++ b/src/main/nav/focus.ts @@ -0,0 +1,29 @@ +import type { WebContents } from 'electron' + +/** Isolated world for the chrome lane's small page scripts: pages can't see or tamper with them. */ +export const NAV_WORLD = 1002 + +/** Is keyboard focus in something that edits text (or in a frame Orbit can't see into)? */ +const FOCUS_IS_EDITABLE = `(() => { + const el = document.activeElement + if (!el || el === document.body || el === document.documentElement) return false + if (el.isContentEditable) return true + const tag = el.tagName + if (tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'IFRAME' || tag === 'EMBED' || tag === 'OBJECT') return true + if (tag !== 'INPUT') return false + const type = (el.getAttribute('type') || 'text').toLowerCase() + return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'].includes(type) +})()` + +/** + * True when keyboard focus in `contents` is in a text field, where keys such as ⌘← keep their + * text-editing meaning (KS-16). Errs on the side of "editable" when the page can't be asked. + */ +export async function focusIsEditable(contents: WebContents): Promise { + if (contents.isDestroyed()) return true + try { + return !!(await contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code: FOCUS_IS_EDITABLE }])) + } catch { + return true + } +} diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts new file mode 100644 index 00000000..65cf9230 --- /dev/null +++ b/src/main/nav/index.ts @@ -0,0 +1,319 @@ +import { join } from 'node:path' +import { app, Menu, type MenuItemConstructorOptions, nativeImage } from 'electron' +import { ACTIONS_BY_ID, formatAccelerator, shortcutsFor } from '../../shared/actions' +import type { HistoryEntryInfo, NavButton, NavDirection } from '../../shared/commands/nav' +import { historyEntryLabel, historyMenuIndexes } from '../../shared/nav' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { CallContext } from '../registry' +import type { TabOps } from '../tabs/ops' +import { activeTabOf } from '../windows' +import { SitePrefs } from './site-prefs' + +/** Test-only handle (ORBIT_E2E=1): globalThis.__orbitNav. */ +export interface NavTestHooks { + /** The history menu on screen (labels top to bottom, and which items have an icon), or null once it closed. */ + menu: () => { windowId: WindowId; direction: NavDirection; labels: string[]; icons: boolean[] } | null + /** Chooses an item of the open history menu by label; `newTab` is a ⌘-click (Ctrl-click). */ + chooseMenu: (label: string, newTab?: boolean) => boolean +} + +interface ShownMenu { + windowId: WindowId + tabId: TabId + direction: NavDirection + entries: HistoryEntryInfo[] + /** Which entries have an icon in the menu. */ + icons: boolean[] + menu?: Menu +} + +const isMac = process.platform === 'darwin' + +/** + * Page navigation (NV-01, NV-03, NV-04) and the sidebar top row's buttons (SB-03): Back, Forward, + * Reload and Stop with their keys, history menus, ⌘-click/middle-click into new tabs and Esc to + * stop loading. + */ +export function registerNav(ctx: MainContext, ops: TabOps): void { + const { store, commands, actions, tabs, windows } = ctx + const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() + + /** Keyboard focus goes to the page (after Reload, or a top-row button press), unless an overlay holds it. */ + const focusPage = (windowId: WindowId | undefined, tabId: TabId) => { + if (!windowId || windows.windowOfTab(tabId) !== windowId) return + if (store.state.windows[windowId]?.overlay.kind !== 'none') return + if (!windows.get(windowId)?.isFocused()) return + tabs.contents(tabId)?.focus() + } + + /** A tab opened behind the current one: with the sidebar hidden it would appear unseen, so say so. */ + const openedInBackground = (windowId: WindowId | undefined, tabId: TabId) => { + tabs.ensure(tabId) + if (windowId && !store.state.windows[windowId]?.sidebar.visible) ctx.toast(windowId, 'Opened in a new tab') + } + + const go = (tabId: TabId, button: NavButton) => { + if (button === 'back') return commands.run('tabs.back', { tabId }) + if (button === 'forward') return commands.run('tabs.forward', { tabId }) + if (button === 'reload') return commands.run('tabs.reload', { tabId }) + return commands.run('tabs.stop', { tabId }) + } + + const historyEntries = (tabId: TabId, direction: NavDirection): HistoryEntryInfo[] => { + const history = ops.historyOf(tabId) + if (!history) return [] + return historyMenuIndexes(history.entries.length, history.index, direction).flatMap((index) => { + const entry = history.entries[index] + if (!entry) return [] + const favicon = tabs.faviconFor(entry.url) + return [{ index, url: entry.url, title: entry.title, ...(favicon ? { favicon } : {}) }] + }) + } + + /** Opens entry `index` of a tab's history in a new tab that keeps the whole history (NV-01). */ + const openEntryInNewTab = ( + windowId: WindowId | undefined, + tabId: TabId, + index: number, + foreground: boolean, + ): TabId | null => { + const history = ops.historyOf(tabId) + const entry = history?.entries[index] + if (!history || !entry) return null + const id = ops.createTab({ + url: entry.url, + title: entry.title || undefined, + windowId, + activate: foreground, + history: { entries: history.entries, index }, + }) + if (!foreground) openedInBackground(windowId, id) + return id + } + + commands.register('nav.press', async ({ button, newTab = false, foreground = false }, c) => { + const windowId = windowOf(c) + const tabId = ops.activeTab(windowId) + if (!windowId || !tabId) return null + if (!newTab) { + await go(tabId, button) + focusPage(windowId, tabId) + return null + } + if (button === 'stop') return null + if (button === 'reload') { + // ⌘-click Reload duplicates the tab with its history (NV-03). + const id = await commands.run('tabs.duplicate', { tabId, activate: foreground }, { windowId }) + if (!foreground) openedInBackground(windowId, id) + return id + } + const history = ops.historyOf(tabId) + if (!history) return null + return openEntryInNewTab(windowId, tabId, history.index + (button === 'back' ? -1 : 1), foreground) + }) + + commands.register('nav.history', ({ tabId, direction }, c) => { + const id = tabId ?? ops.activeTab(windowOf(c)) + return id ? historyEntries(id, direction) : [] + }) + + commands.register('nav.goToIndex', ({ tabId, index, newTab = false }, c) => { + const windowId = windowOf(c) + const id = tabId ?? ops.activeTab(windowId) + if (!id) return null + if (newTab) return openEntryInNewTab(windowId, id, index, false) + const contents = tabs.contents(id) + if (contents) { + if (index >= 0 && index < contents.navigationHistory.length()) contents.navigationHistory.goToIndex(index) + } else { + // A sleeping tab wakes up on the chosen entry. + const saved = tabs.savedHistory.get(id) + if (!saved || index < 0 || index >= saved.entries.length) return null + saved.index = index + tabs.ensure(id) + } + focusPage(windowId, id) + return null + }) + + // ---- History menu (long-press or right-click on Back/Forward) ---- + let shown: ShownMenu | null = null + const closeMenu = () => { + const current = shown + shown = null + if (!current?.menu) return + const win = windows.get(current.windowId) + try { + current.menu.closePopup(win) + } catch { + // The window is gone; so is its menu. + } + } + const choose = (menu: ShownMenu, index: number, newTab: boolean) => { + if (shown === menu) shown = null + void commands.run('nav.goToIndex', { tabId: menu.tabId, index, newTab }, { windowId: menu.windowId }) + } + const newTabClick = (event: Electron.KeyboardEvent) => !!(isMac ? event.metaKey : event.ctrlKey) + + commands.register('nav.historyMenu', ({ direction, x, y, icons }, c) => { + const windowId = windowOf(c) + const tabId = ops.activeTab(windowId) + closeMenu() + if (!windowId || !tabId) return + const entries = historyEntries(tabId, direction) + if (entries.length === 0) return + const items = entries.map((entry) => ({ entry, icon: menuIcon(entry.favicon, icons) })) + const record: ShownMenu = { windowId, tabId, direction, entries, icons: items.map((item) => !!item.icon) } + shown = record + // Under test the menu is recorded, not shown: a native menu would hold the main loop on macOS. + if (ctx.e2e) return + const win = windows.get(windowId) + if (!win) return + const template: MenuItemConstructorOptions[] = items.map(({ entry, icon }) => ({ + label: menuLabel(entry), + icon, + click: (_item, _window, event) => choose(record, entry.index, newTabClick(event)), + })) + const menu = Menu.buildFromTemplate(template) + record.menu = menu + menu.popup({ + window: win, + x: Math.round(x), + y: Math.round(y), + callback: () => { + if (shown === record) shown = null + }, + }) + }) + + // The menu belongs to the tab it was opened for: switching tab closes it (NV-01). + store.subscribe((next) => { + if (shown && activeTabOf(next, shown.windowId) !== shown.tabId) closeMenu() + }) + + // ---- Keys and mouse buttons ---- + const onActive = (fn: (tabId: TabId, windowId: WindowId) => void) => (c: CallContext) => { + const windowId = windowOf(c) + const tabId = ops.activeTab(windowId) + if (windowId && tabId) fn(tabId, windowId) + } + actions.register( + 'nav.back', + onActive((tabId) => void go(tabId, 'back')), + ) + actions.register( + 'nav.forward', + onActive((tabId) => void go(tabId, 'forward')), + ) + // After a reload the page has keyboard focus (NV-03). + actions.register( + 'nav.reload', + onActive((tabId, windowId) => { + void go(tabId, 'reload') + focusPage(windowId, tabId) + }), + ) + actions.register( + 'nav.hardReload', + onActive((tabId, windowId) => { + void commands.run('tabs.reload', { tabId, ignoreCache: true }) + focusPage(windowId, tabId) + }), + ) + actions.register( + 'nav.stop', + onActive((tabId) => void go(tabId, 'stop')), + ) + // Copying the address the long way (command bar, ⌘C) teaches the shortcut, once (NV-17). + const prefs = new SitePrefs(join(ctx.paths.userData, 'orbit-site-prefs.json')) + prefs.load() + app.on('before-quit', () => prefs.flush()) + commands.register('nav.copyHint', (_args, c) => { + if (prefs.hintShown('copy-url')) return + const action = ACTIONS_BY_ID['tab.copyUrl'] + const accel = action ? shortcutsFor(action, process.platform)[0] : undefined + if (!accel) return + prefs.markHint('copy-url') + ctx.toast(windowOf(c), `Tip: ${formatAccelerator(accel, process.platform)} copies the page's link`) + }) + + // Placeholder until the site controls panel (NV-16) lands. + actions.register('site.controls', (c) => ctx.toast(windowOf(c), 'Site controls are coming soon')) + + ctx.hooks.tabCreated.push((tabId, contents) => { + // Esc stops a loading page (NV-04). The page still gets the key; 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 + if (input.meta || input.control || input.alt || input.shift) return + if (!store.state.tabs[tabId]?.loading) return + const windowId = windows.windowOfTab(tabId) + if (windowId && store.state.windows[windowId]?.overlay.kind !== 'none') return + contents.stop() + }) + // The mouse's back/forward buttons over a page need nothing here: the page itself goes back or + // forward one entry on release (Chromium), unless it handles the buttons (NV-01). Over the + // sidebar they switch Spaces instead (SP-09). + }) + + if (ctx.e2e) { + const hooks: NavTestHooks = { + menu: () => + shown + ? { + windowId: shown.windowId, + direction: shown.direction, + labels: shown.entries.map((e) => historyEntryLabel(e.title, e.url)), + icons: shown.icons, + } + : null, + chooseMenu: (label, newTab = false) => { + const menu = shown + const entry = menu?.entries.find((e) => historyEntryLabel(e.title, e.url) === label) + if (!menu || !entry) return false + choose(menu, entry.index, newTab) + return true + }, + } + ;(globalThis as unknown as { __orbitNav: NavTestHooks }).__orbitNav = hooks + } +} + +/** A history menu item's label. On Windows and Linux a single & would mark a mnemonic. */ +function menuLabel(entry: HistoryEntryInfo): string { + const label = historyEntryLabel(entry.title, entry.url) + return isMac ? label : label.replace(/&/g, '&&') +} + +/** + * A history menu item's 16 px icon, with a 2× version when the favicon is big enough. SVG favicons + * come drawn as PNG by the shell (`rasters`, keyed by the SVG data URL): native menus can't draw SVG. + */ +function menuIcon( + favicon: string | undefined, + rasters: Record | undefined, +): Electron.NativeImage | undefined { + if (!favicon) return undefined + let source: string | undefined = favicon + if (favicon.startsWith('data:image/svg+xml')) { + const png = rasters?.[favicon] + source = + typeof png === 'string' && png.startsWith('data:image/png;base64,') && png.length <= 64 * 1024 ? png : undefined + } + if (!source) return undefined + const image = nativeImage.createFromDataURL(source) + if (image.isEmpty()) return undefined + const icon = nativeImage.createEmpty() + icon.addRepresentation({ + scaleFactor: 1, + dataURL: image.resize({ width: 16, height: 16, quality: 'best' }).toDataURL(), + }) + if (image.getSize().width >= 32) { + icon.addRepresentation({ + scaleFactor: 2, + dataURL: image.resize({ width: 32, height: 32, quality: 'best' }).toDataURL(), + }) + } + return icon +} diff --git a/src/main/nav/site-prefs.ts b/src/main/nav/site-prefs.ts new file mode 100644 index 00000000..96463a6a --- /dev/null +++ b/src/main/nav/site-prefs.ts @@ -0,0 +1,74 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { mkdir, rename, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +interface SitePrefsData { + /** One-time hints already shown (e.g. the ⌘⇧C tip, NV-17). */ + hints: Record +} + +/** + * The chrome lane's small per-user settings, kept out of OrbitState so they never travel to the + * renderers: one-time hints now, per-site choices later. Saved off the main thread shortly after + * a change, and synchronously on quit. Private windows never write here. + */ +export class SitePrefs { + private data: SitePrefsData = { hints: {} } + private timer: NodeJS.Timeout | undefined + private dirty = false + + constructor(private file: string) {} + + load(): void { + try { + const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Partial + const hints: Record = {} + for (const [id, shown] of Object.entries(raw.hints ?? {})) if (shown === true) hints[id] = true + this.data = { hints } + } catch { + // No file yet, or unreadable: start empty. + } + } + + hintShown(id: string): boolean { + return this.data.hints[id] === true + } + + markHint(id: string): void { + if (this.data.hints[id]) return + this.data.hints[id] = true + this.schedule() + } + + /** Writes pending changes now (quit). */ + flush(): void { + if (!this.dirty) return + clearTimeout(this.timer) + this.timer = undefined + this.dirty = false + try { + mkdirSync(dirname(this.file), { recursive: true }) + const tmp = `${this.file}.tmp` + writeFileSync(tmp, JSON.stringify(this.data)) + renameSync(tmp, this.file) + } catch (err) { + console.error('[site-prefs] could not save', err) + } + } + + private schedule(): void { + this.dirty = true + if (this.timer) return + this.timer = setTimeout(() => { + this.timer = undefined + if (!this.dirty) return + this.dirty = false + const tmp = `${this.file}.tmp` + void mkdir(dirname(this.file), { recursive: true }) + .then(() => writeFile(tmp, JSON.stringify(this.data))) + .then(() => rename(tmp, this.file)) + .catch((err) => console.error('[site-prefs] could not save', err)) + }, 500) + this.timer.unref?.() + } +} diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index c4a17f7d..c301ade6 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -295,7 +295,7 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM commands.register('tabs.archiveBelow', ({ tabId }) => ops.archiveBelow(tabId)) commands.register('tabs.copyLinks', ({ tabIds, markdown = false }, c) => copyLinks(windowOf(c), tabIds, markdown)) - commands.register('tabs.duplicate', ({ tabId, to, index }, c) => { + commands.register('tabs.duplicate', ({ tabId, to, index, activate }, c) => { const state = store.state const tab = state.tabs[tabId] if (!tab) throw new Error('No such tab') @@ -312,7 +312,7 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM container: to ?? loc?.container, index: to ? index : loc ? loc.index + 1 : undefined, // A copy dropped with ⌥ stays in the background; Duplicate from the menu selects it. - activate: !to, + activate: activate ?? !to, windowId: windowOf(c), history: targetProfile === tab.profileId ? ops.historyOf(tabId) : undefined, }) @@ -702,32 +702,12 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM send(windowId, 'sidebar:focus', {}) }) - // Navigation & page actions on the active tab. + // Page actions on the active tab. Back, Forward, Reload and Stop live in src/main/nav/. const onContents = (fn: (contents: Electron.WebContents) => void) => withActive((tabId) => { const contents = tabs.contents(tabId) if (contents) fn(contents) }) - actions.register( - 'nav.back', - withActive((tabId) => goBack(tabId)), - ) - actions.register( - 'nav.forward', - onContents((w) => w.navigationHistory.goForward()), - ) - actions.register( - 'nav.reload', - onContents((w) => w.reload()), - ) - actions.register( - 'nav.hardReload', - onContents((w) => w.reloadIgnoringCache()), - ) - actions.register( - 'nav.stop', - onContents((w) => w.stop()), - ) actions.register( 'page.zoomIn', onContents((w) => w.setZoomLevel(Math.min(w.getZoomLevel() + 0.5, 5))), diff --git a/src/main/windows.ts b/src/main/windows.ts index b3fadd19..6e93b4fd 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1,12 +1,13 @@ import { join } from 'node:path' import { BrowserWindow, nativeTheme, screen, type WebContents, WebContentsView, webContents } from 'electron' -import { actionForInput } from '../shared/actions' +import { actionForInput, outsideTextActionFor } from '../shared/actions' import { createWindowState } from '../shared/defaults' import type { Rect, RendererView } from '../shared/rpc' import type { ColorMode, OrbitState, SpaceId, TabId, WindowId, WindowState } from '../shared/state' import { paletteFor } from '../shared/theme' import { splitLeaves, splitOfTab } from '../shared/tree' import type { MainContext } from './context' +import { focusIsEditable } from './nav/focus' import { fastSwitchPanes } from './split/layout' interface Pane { @@ -542,7 +543,10 @@ export class WindowManager { contents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return const action = actionForInput(input, process.platform) - if (!action || !this.ctx.actions.has(action.id)) return + if (!action || !this.ctx.actions.has(action.id)) { + this.runOutsideText(contents, input) + return + } event.preventDefault() const windowId = this.windowIdFor(contents) ?? this.focusedWindowId() // Close Tab in a window that isn't an Orbit window (a page's popup) closes that window. @@ -554,6 +558,20 @@ export class WindowManager { }) } + /** + * Keys that edit text inside a field but are Orbit actions elsewhere (⌘← and ⌘→ go back and + * forward on macOS, KS-16, NV-01). The key is never taken from the page: the action runs only + * once it is clear that focus is not in a text field. + */ + private runOutsideText(contents: WebContents, input: Electron.Input): void { + const action = outsideTextActionFor(input, process.platform) + if (!action || !this.ctx.actions.has(action.id)) return + const windowId = this.windowIdFor(contents) ?? this.focusedWindowId() + void focusIsEditable(contents).then((editable) => { + if (!editable && !contents.isDestroyed()) void this.ctx.actions.run(action.id, { windowId }) + }) + } + focusActiveTab(windowId: WindowId): void { const tabId = activeTabOf(this.ctx.store.state, windowId) const contents = tabId ? this.liveContents(tabId) : undefined diff --git a/src/renderer/src/overlay/CommandBar.tsx b/src/renderer/src/overlay/CommandBar.tsx index 2474c0cd..b6e590fe 100644 --- a/src/renderer/src/overlay/CommandBar.tsx +++ b/src/renderer/src/overlay/CommandBar.tsx @@ -552,6 +552,14 @@ export function CommandBar({ aria-activedescendant={selected >= 0 && results.length ? `cmdbar-option-${selected}` : undefined} onInput={onInput} onKeyDown={onKeyDown} + onCopy={(e) => { + // The whole page address copied the long way: tip about ⌘⇧C, once (NV-17). + const el = e.currentTarget + const whole = el.selectionStart === 0 && el.selectionEnd === el.value.length + if (mode === 'edit-url' && initialQuery && whole && el.value === initialQuery) { + void invoke('nav.copyHint', {}) + } + }} onCompositionStart={() => { st.composing = true }} diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx new file mode 100644 index 00000000..dcd9affb --- /dev/null +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -0,0 +1,82 @@ +import { Check, Link2, SlidersHorizontal, TriangleAlert } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { connectionOf } from '../../../../shared/nav' +import { displayHost } from '../../../../shared/url' +import { runAction } from '../../api' +import { activeTabId } from '../../selectors' +import { useOrbit } from '../../store' +import { withShortcut } from './shortcut-label' + +/** How long the copy button shows its check mark. */ +const COPIED_MS = 1200 + +/** + * The address display under the top row (SB-04): the active tab's host only (the focused pane + * in split view), a site-controls button that turns into a warning on insecure pages (no padlock + * on secure ones), and a copy-link button. Clicking the host edits the URL in the command bar. + */ +export function Address({ windowId }: { windowId: string }) { + // Only the URL: the display re-renders on navigations, not on title, favicon or progress changes. + const url = useOrbit((s) => { + const id = activeTabId(s, windowId) + return id ? (s.tabs[id]?.url ?? '') : '' + }) + const page = url && url !== 'about:blank' ? url : '' + const connection = connectionOf(page) + const insecure = connection === 'insecure' + // The page whose link was just copied: the check mark shows for it only, not for the next page. + const [copiedFor, setCopiedFor] = useState('') + const copied = !!page && copiedFor === page + const copiedTimer = useRef(0) + useEffect(() => () => window.clearTimeout(copiedTimer.current), []) + + const copy = () => { + // Same as ⌘⇧C: the clean URL on the clipboard and a "Copied" toast (NV-17, TB-20). + runAction('tab.copyUrl') + setCopiedFor(page) + window.clearTimeout(copiedTimer.current) + copiedTimer.current = window.setTimeout(() => setCopiedFor(''), COPIED_MS) + } + + return ( +
+ {page && ( + + )} + + {page && ( + + )} +
+ ) +} diff --git a/src/renderer/src/shell/chrome/NavButtons.tsx b/src/renderer/src/shell/chrome/NavButtons.tsx new file mode 100644 index 00000000..913f3193 --- /dev/null +++ b/src/renderer/src/shell/chrome/NavButtons.tsx @@ -0,0 +1,185 @@ +import { ArrowLeft, ArrowRight, RotateCw, X } from 'lucide-react' +import { type MouseEvent, type PointerEvent, useEffect, useRef } from 'react' +import type { NavDirection } from '../../../../shared/commands/nav' +import { sectionOf } from '../../../../shared/tree' +import { invoke, isMac } from '../../api' +import { activeTabId } from '../../selectors' +import { useOrbitShallow } from '../../store' +import { playPress } from './press' +import { rasterizeSvgIcons } from './raster' +import { withShortcut } from './shortcut-label' + +/** Holding Back or Forward this long opens its history menu (NV-01). */ +export const LONG_PRESS_MS = 400 + +interface NavState { + hasTab: boolean + back: boolean + forward: boolean + loading: boolean +} + +/** ⌘-click on macOS, Ctrl-click elsewhere: the button's action goes to a new tab (SB-03). */ +const newTabClick = (e: MouseEvent) => (isMac ? e.metaKey : e.ctrlKey) + +/** + * Back, Forward and Reload for the window's active tab (the focused pane in split view), with + * their enabled, disabled and loading states (SB-03). + */ +export function NavButtons({ windowId }: { windowId: string }) { + // Only these four flags: title, favicon and progress updates don't re-render the buttons. + const nav = useOrbitShallow((s): NavState => { + const id = activeTabId(s, windowId) + const tab = id ? s.tabs[id] : undefined + if (!id || !tab) return { hasTab: false, back: false, forward: false, loading: false } + // Back on a fresh tab opened from a link closes it and returns to its opener (TB-19). + const backToOpener = !tab.canGoBack && !!tab.openerTabId && !!s.tabs[tab.openerTabId] + return { + hasTab: true, + back: tab.canGoBack || (backToOpener && sectionOf(s, id) === 'today'), + forward: tab.canGoForward, + loading: tab.loading, + } + }) + return ( + <> + + + + + ) +} + +/** + * Back or Forward. Click goes there; ⌘-click or middle-click opens that page in a new tab; + * long-press or right-click shows the tab's history in that direction (NV-01). + */ +function HistoryButton({ direction, enabled }: { direction: NavDirection; enabled: boolean }) { + const ref = useRef(null) + const hold = useRef<{ timer: number; opened: boolean } | null>(null) + const label = direction === 'back' ? 'Back' : 'Forward' + + const cancelHold = () => { + if (hold.current) window.clearTimeout(hold.current.timer) + } + useEffect( + () => () => { + if (hold.current) window.clearTimeout(hold.current.timer) + }, + [], + ) + + const showMenu = async () => { + const rect = ref.current?.getBoundingClientRect() + if (!rect) return + const entries = await invoke('nav.history', { direction }) + if (entries.length === 0) return + // Native menus can't draw SVG favicons: send PNG renderings along. + const icons = await rasterizeSvgIcons(entries.map((entry) => entry.favicon)) + await invoke('nav.historyMenu', { direction, x: rect.left, y: rect.bottom + 4, icons }) + } + const press = (newTab: boolean, foreground: boolean) => { + playPress(ref.current, direction) + void invoke('nav.press', { button: direction, newTab, foreground }) + } + + const onPointerDown = (e: PointerEvent) => { + cancelHold() + hold.current = null + if (e.button !== 0 || newTabClick(e) || !enabled) return + const current = { + opened: false, + timer: window.setTimeout(() => { + current.opened = true + void showMenu() + }, LONG_PRESS_MS), + } + hold.current = current + } + const onClick = (e: MouseEvent) => { + const longPress = hold.current?.opened + cancelHold() + hold.current = null + // The press became a long-press: the menu is the answer, not a navigation. + if (longPress) return + const newTab = newTabClick(e) + press(newTab, newTab && e.shiftKey) + } + const onAuxClick = (e: MouseEvent) => { + if (e.button !== 1) return + e.preventDefault() + press(true, e.shiftKey) + } + const onContextMenu = (e: MouseEvent) => { + e.preventDefault() + cancelHold() + if (enabled) void showMenu() + } + + return ( + + ) +} + +/** + * Reload, or Stop (×) while the page loads. Double-clicking Reload stops the load it started; + * ⌘-click or middle-click duplicates the tab (SB-03, NV-03, NV-04). + */ +function ReloadButton({ enabled, loading }: { enabled: boolean; loading: boolean }) { + const ref = useRef(null) + const duplicate = (foreground: boolean) => { + playPress(ref.current, 'reload') + void invoke('nav.press', { button: 'reload', newTab: true, foreground }) + } + const onClick = (e: MouseEvent) => { + if (newTabClick(e)) { + duplicate(e.shiftKey) + return + } + // The second click of a double-click stops, whether or not the loading state has arrived yet. + if (loading || e.detail >= 2) { + void invoke('nav.press', { button: 'stop' }) + return + } + playPress(ref.current, 'reload') + void invoke('nav.press', { button: 'reload' }) + } + const onAuxClick = (e: MouseEvent) => { + if (e.button !== 1) return + e.preventDefault() + duplicate(e.shiftKey) + } + const label = loading ? 'Stop' : 'Reload' + return ( + + ) +} diff --git a/src/renderer/src/shell/chrome/WindowControls.tsx b/src/renderer/src/shell/chrome/WindowControls.tsx new file mode 100644 index 00000000..c5e6042c --- /dev/null +++ b/src/renderer/src/shell/chrome/WindowControls.tsx @@ -0,0 +1,33 @@ +import { invoke } from '../../api' + +/** + * Client-side window controls at the top-left of the sidebar on Linux and Windows, mirroring the + * macOS traffic lights (SB-03, SB-26). + */ +export function WindowControls() { + return ( +
+
+ ) +} diff --git a/src/renderer/src/shell/chrome/chrome.css b/src/renderer/src/shell/chrome/chrome.css new file mode 100644 index 00000000..2fe3cb7a --- /dev/null +++ b/src/renderer/src/shell/chrome/chrome.css @@ -0,0 +1,116 @@ +/* Sidebar top row and address display (chrome lane: SB-03, SB-04, FL-15). */ + +.topbar { + container: topbar / inline-size; +} + +/* Top-row icon buttons press to 0.94 at once and release with the pop curve (FL-15). */ +.topbar .icon-button { + transition: + background var(--dur-fast) ease, + color var(--dur-fast) ease, + transform 240ms cubic-bezier(0.3, 1.3, 0.6, 1); +} + +.topbar .icon-button:active:not(:disabled) { + transform: scale(0.94); + transition-duration: var(--dur-fast), var(--dur-fast), 60ms; +} + +/* Narrow sidebar: the toggle goes first, Back and Forward stay (SB-03). ⌘S still toggles. */ +@container topbar (max-width: 177px) { + .topbar__toggle { + display: none; + } +} +@container topbar (max-width: 189px) { + .topbar--mac .topbar__toggle { + display: none; + } +} + +/* ---------- Address display ---------- */ + +.address { + -webkit-app-region: no-drag; + display: flex; + align-items: center; + gap: 2px; + width: 100%; + height: 34px; + padding: 0 4px; + border-radius: 9px; + background: var(--hover); + color: var(--text); + transition: box-shadow var(--dur-fast) ease; +} + +.address:hover { + box-shadow: inset 0 0 0 1px var(--divider); +} + +.address__host { + flex: 1; + min-width: 0; + align-self: stretch; + display: flex; + align-items: center; + padding: 0 6px; + text-align: left; + border-radius: 7px; +} + +.address__text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.address--empty .address__text { + color: var(--text-muted); +} + +.address__button { + flex: none; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 6px; + color: var(--text-muted); + transition: + background var(--dur-fast) ease, + color var(--dur-fast) ease, + opacity var(--dur-fast) ease, + transform 240ms cubic-bezier(0.3, 1.3, 0.6, 1); +} + +.address__button:hover { + background: var(--hover); + color: var(--text); +} + +.address__button:active { + transform: scale(0.94); + transition-duration: var(--dur-fast), var(--dur-fast), var(--dur-fast), 60ms; +} + +/* The copy button appears while the pointer is over the field or focus is in it. */ +.address__copy { + opacity: 0; +} + +.address:hover .address__copy, +.address:focus-within .address__copy, +.address__copy.is-copied { + opacity: 1; +} + +/* Insecure pages: a clear warning instead of the site-controls glyph (no padlock on https). */ +.address--insecure .address__site { + color: #c9372c; +} + +.shell[data-dark] .address--insecure .address__site { + color: #ff8a7d; +} diff --git a/src/renderer/src/shell/chrome/press.ts b/src/renderer/src/shell/chrome/press.ts new file mode 100644 index 00000000..3ccc6d06 --- /dev/null +++ b/src/renderer/src/shell/chrome/press.ts @@ -0,0 +1,27 @@ +import { prefersReducedMotion } from '../../theme' + +/** "snappy" curve from FL-01 (CSS fallback). */ +const SNAPPY_EASE = 'cubic-bezier(0.2, 0.8, 0.2, 1)' + +/** + * A button glyph's small press animation (FL-15): Back nudges about 3 px left, Forward right + * (180 ms); Reload's glyph turns once (260 ms). Runs alongside the action, never before it, and a + * repeated press restarts it instead of queueing. Decorative, so off with reduced motion. + */ +export function playPress(button: HTMLElement | null, kind: 'back' | 'forward' | 'reload'): void { + const glyph = button?.querySelector('svg') + if (!glyph || prefersReducedMotion()) return + for (const running of glyph.getAnimations()) running.cancel() + if (kind === 'reload') { + glyph.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { + duration: 260, + easing: SNAPPY_EASE, + }) + return + } + const dx = kind === 'back' ? -3 : 3 + glyph.animate( + [{ transform: 'translateX(0)' }, { transform: `translateX(${dx}px)`, offset: 0.4 }, { transform: 'translateX(0)' }], + { duration: 180, easing: SNAPPY_EASE }, + ) +} diff --git a/src/renderer/src/shell/chrome/raster.ts b/src/renderer/src/shell/chrome/raster.ts new file mode 100644 index 00000000..73feff39 --- /dev/null +++ b/src/renderer/src/shell/chrome/raster.ts @@ -0,0 +1,45 @@ +/** Size SVG icons are drawn at for native menus: crisp at 16 px on 2× screens. */ +const RASTER_PX = 32 + +const cache = new Map>() + +/** + * A PNG data URL of an SVG image (a site's favicon). Native menus can't draw SVG, so the history + * menu (NV-01) gets these instead. Cached per image; undefined when it can't be drawn. + */ +export function svgToPng(svg: string): Promise { + let drawn = cache.get(svg) + if (drawn) return drawn + drawn = new Promise((resolve) => { + const img = new Image() + img.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = RASTER_PX + canvas.height = RASTER_PX + canvas.getContext('2d')?.drawImage(img, 0, 0, RASTER_PX, RASTER_PX) + resolve(canvas.toDataURL('image/png')) + } catch { + resolve(undefined) + } + } + img.onerror = () => resolve(undefined) + img.src = svg + }) + if (cache.size >= 64) cache.clear() + cache.set(svg, drawn) + return drawn +} + +/** PNG renderings of the SVG favicons among `favicons`, keyed by the SVG data URL. */ +export async function rasterizeSvgIcons(favicons: (string | undefined)[]): Promise> { + const svgs = [...new Set(favicons.filter((f): f is string => !!f?.startsWith('data:image/svg+xml')))] + const out: Record = {} + await Promise.all( + svgs.map(async (svg) => { + const png = await svgToPng(svg) + if (png) out[svg] = png + }), + ) + return out +} diff --git a/src/renderer/src/shell/chrome/shortcut-label.ts b/src/renderer/src/shell/chrome/shortcut-label.ts new file mode 100644 index 00000000..1f5c380a --- /dev/null +++ b/src/renderer/src/shell/chrome/shortcut-label.ts @@ -0,0 +1,8 @@ +import { ACTIONS_BY_ID, formatAccelerator, shortcutsFor } from '../../../../shared/actions' + +/** "Back (⌘[)": a button's tooltip with the action's first shortcut on this platform. */ +export function withShortcut(label: string, actionId: string): string { + const action = ACTIONS_BY_ID[actionId] + const accel = action ? shortcutsFor(action, window.orbit.platform)[0] : undefined + return accel ? `${label} (${formatAccelerator(accel, window.orbit.platform)})` : label +} diff --git a/src/renderer/src/shell/sidebar/TopBar.tsx b/src/renderer/src/shell/sidebar/TopBar.tsx index 20332219..7eda84d0 100644 --- a/src/renderer/src/shell/sidebar/TopBar.tsx +++ b/src/renderer/src/shell/sidebar/TopBar.tsx @@ -1,103 +1,36 @@ -import { ArrowLeft, ArrowRight, Lock, PanelLeft, RotateCw, X } from 'lucide-react' +import { PanelLeft } from 'lucide-react' import type { WindowState } from '../../../../shared/state' -import { displayHost } from '../../../../shared/url' -import { invoke, isMac, runAction } from '../../api' -import { activeTabId } from '../../selectors' -import { useOrbit } from '../../store' +import { isMac, runAction } from '../../api' +import { Address } from '../chrome/Address' +import { NavButtons } from '../chrome/NavButtons' +import { withShortcut } from '../chrome/shortcut-label' +import { WindowControls } from '../chrome/WindowControls' +import '../chrome/chrome.css' -/** Window controls, navigation buttons and the address pill. */ +/** + * The top of the sidebar (SB-03, SB-04): window controls (traffic lights on macOS), the sidebar + * toggle, Back, Forward and Reload, then the address display. At narrow widths the toggle goes + * first; Back and Forward stay. + */ export function TopBar({ win }: { win: WindowState }) { - const tab = useOrbit((s) => { - const id = activeTabId(s, win.id) - return id ? s.tabs[id] : undefined - }) - const host = tab ? displayHost(tab.url) : '' - const secure = tab?.url.startsWith('https://') return ( -
+
- {isMac ?
: } + {isMac ?
: }
- runAction('sidebar.toggle')}> - - - runAction('nav.back')}> - - - runAction('nav.forward')} - > - - - runAction(tab?.loading ? 'nav.stop' : 'nav.reload')} + +
- -
- ) -} - -function IconButton(props: { - label: string - testId?: string - disabled?: boolean - onClick: () => void - children: React.ReactNode -}) { - return ( - - ) -} - -function WindowControls() { - return ( -
-
) } diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index bbb4092b..ad97bd3c 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -223,53 +223,6 @@ html.is-resizing * { background: #61c454; } -.address { - -webkit-app-region: no-drag; - display: flex; - align-items: center; - gap: 6px; - width: 100%; - height: 34px; - padding: 0 10px; - border-radius: 9px; - background: var(--hover); - color: var(--text); - text-align: left; - transition: background var(--dur-fast) ease; -} - -.address:hover { - background: color-mix(in srgb, var(--hover) 100%, var(--hover)); - box-shadow: inset 0 0 0 1px var(--divider); -} - -.address__lock { - flex: none; - color: var(--text-muted); -} - -.address__host { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.address--loading { - background-image: linear-gradient(90deg, transparent, var(--hover), transparent); - background-size: 50% 100%; - background-repeat: no-repeat; - animation: address-loading 1.1s linear infinite; -} - -@keyframes address-loading { - from { - background-position: -50% 0; - } - to { - background-position: 150% 0; - } -} - /* Favorites grid */ .favorites { flex: none; diff --git a/src/shared/actions.ts b/src/shared/actions.ts index bb84982c..1e7faf62 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -21,6 +21,13 @@ export interface ActionDef { hidden?: boolean /** Extra search words for the command bar. */ keywords?: string[] + /** + * Extra bindings that are text-editing keys inside a field (KS-16: ⌘← moves the caret to the + * line start). They run the action only when keyboard focus is not in an editable field, so + * they are matched separately (`outsideTextActionFor`), never by `actionForInput`. + */ + macOutsideText?: string[] + otherOutsideText?: string[] } const numbered = ( @@ -154,18 +161,38 @@ export const ACTIONS: ActionDef[] = [ // Navigation & page { id: 'url.edit', title: 'Edit URL', category: 'Navigation', mac: ['Cmd+L'], other: ['Ctrl+L', 'Alt+D'] }, - { id: 'nav.back', title: 'Back', category: 'Navigation', mac: ['Cmd+['], other: ['Alt+Left'] }, - { id: 'nav.forward', title: 'Forward', category: 'Navigation', mac: ['Cmd+]'], other: ['Alt+Right'] }, + { + id: 'nav.back', + title: 'Back', + category: 'Navigation', + mac: ['Cmd+['], + other: ['Alt+Left'], + macOutsideText: ['Cmd+Left'], + }, + { + id: 'nav.forward', + title: 'Forward', + category: 'Navigation', + mac: ['Cmd+]'], + other: ['Alt+Right'], + macOutsideText: ['Cmd+Right'], + }, { id: 'nav.reload', title: 'Reload Page', category: 'Navigation', mac: ['Cmd+R'], other: ['Ctrl+R', 'F5'] }, { id: 'nav.hardReload', title: 'Hard Reload', category: 'Navigation', mac: ['Cmd+Shift+R'], - other: ['Ctrl+Shift+R', 'Ctrl+F5'], + other: ['Ctrl+Shift+R', 'Shift+F5', 'Ctrl+F5'], }, { id: 'nav.stop', title: 'Stop Loading', category: 'Navigation', mac: ['Cmd+.'], other: [] }, { id: 'page.find', title: 'Find in Page', category: 'Page', mac: ['Cmd+F'], other: ['Ctrl+F'] }, + { + id: 'site.controls', + title: 'Site Controls', + category: 'Page', + keywords: ['permissions', 'site settings', 'connection', 'zoom'], + }, { id: 'page.zoomIn', title: 'Zoom In', @@ -422,6 +449,18 @@ export function actionForInput(input: KeyInput, platform: string): ActionDef | u return undefined } +/** + * The action bound to this key press only while focus is outside text fields (KS-16), if any. + * The caller checks where focus is before running it. + */ +export function outsideTextActionFor(input: KeyInput, platform: string): ActionDef | undefined { + for (const action of ACTIONS) { + const keys = (platform === 'darwin' ? action.macOutsideText : action.otherOutsideText) ?? [] + if (keys.some((accel) => matchesAccelerator(input, accel))) return action + } + return undefined +} + /** Display form of an accelerator, e.g. "⌘⇧T" on macOS or "Ctrl+Shift+T" elsewhere. */ export function formatAccelerator(accel: string, platform: string): string { const a = parseAccelerator(accel) diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts new file mode 100644 index 00000000..a84c9426 --- /dev/null +++ b/src/shared/commands/nav.ts @@ -0,0 +1,46 @@ +import type { TabId } from '../state' + +/** One entry of a tab's back/forward history, as the history menu lists it (NV-01). */ +export interface HistoryEntryInfo { + /** Position in the tab's navigation history (what `nav.goToIndex` takes). */ + index: number + url: string + title: string + /** Small data URL from the favicon store, when one is known for the entry's origin. */ + favicon?: string +} + +export type NavDirection = 'back' | 'forward' + +/** A button of the sidebar's top row (SB-03). */ +export type NavButton = NavDirection | 'reload' | 'stop' + +/** Owned by the chrome lane: page navigation, the sidebar's top row and page tools. */ +export interface NavCommands { + /** + * A press on Back, Forward, Reload or Stop in the top row: acts on the active tab (the focused + * pane in split view) and gives the page keyboard focus. With `newTab` (⌘-click or middle-click) + * Back/Forward open that history entry in a new background tab and Reload duplicates the tab, + * leaving the current tab as it is; `foreground` selects the new tab (SB-03, NV-01, NV-03). + * Returns the new tab, if one was made. + */ + 'nav.press': { args: { button: NavButton; newTab?: boolean; foreground?: boolean }; result: TabId | null } + /** + * Entries behind (`back`) or ahead of (`forward`) a tab's current page (default: the active + * tab), nearest the button first, as the history menu lists them (NV-01). + */ + 'nav.history': { args: { tabId?: TabId; direction: NavDirection }; result: HistoryEntryInfo[] } + /** + * Shows the history menu of Back or Forward under the button, at a point in window coordinates + * (long-press or right-click, NV-01). It closes when the window's tab changes. `icons` holds PNG + * renderings of SVG favicons keyed by the SVG data URL: native menus can't draw SVG. + */ + 'nav.historyMenu': { + args: { direction: NavDirection; x: number; y: number; icons?: Record } + result: void + } + /** Goes to a history entry of a tab (default: the active tab); `newTab` opens it in a new background tab. */ + 'nav.goToIndex': { args: { tabId?: TabId; index: number; newTab?: boolean }; result: TabId | null } + /** The page address was copied the long way (from the command bar): a one-time ⌘⇧C tip (NV-17). */ + 'nav.copyHint': { args: Record; result: void } +} diff --git a/src/shared/commands/tabs.ts b/src/shared/commands/tabs.ts index f1507be7..1312a480 100644 --- a/src/shared/commands/tabs.ts +++ b/src/shared/commands/tabs.ts @@ -54,8 +54,11 @@ export interface TabCommands { 'tabs.delete': { args: { id: ItemId }; result: void } 'tabs.archiveBelow': { args: { tabId: TabId }; result: void } 'tabs.copyLinks': { args: { tabIds: TabId[]; markdown?: boolean }; result: string } - /** Duplicate with history (TB-10): below the original, or at `to`/`index` (⌥-drag, TB-09). */ - 'tabs.duplicate': { args: { tabId: TabId; to?: ContainerRef; index?: number }; result: TabId } + /** + * Duplicate with history (TB-10): below the original, or at `to`/`index` (⌥-drag, TB-09). + * `activate` overrides whether the copy is selected (⌘-click Reload keeps it in the background, NV-03). + */ + 'tabs.duplicate': { args: { tabId: TabId; to?: ContainerRef; index?: number; activate?: boolean }; result: TabId } 'tabs.setMuted': { args: { tabId: TabId; muted: boolean }; result: void } 'tabs.clearToday': { args: { spaceId?: SpaceId }; result: void } /** Collapse or expand a Space's pinned section (PN-09). */ diff --git a/src/shared/nav.ts b/src/shared/nav.ts new file mode 100644 index 00000000..44ab33fc --- /dev/null +++ b/src/shared/nav.ts @@ -0,0 +1,70 @@ +/** + * Small pure helpers for page navigation and the sidebar's top row (chrome lane). Shared by the + * main process, the shell renderer and unit tests. + */ + +/** How the address display describes a page's connection (SB-04). */ +export type Connection = + /** https: no icon (Orbit shows no padlock for secure sites). */ + | 'secure' + /** Plain http to another machine: a clear warning icon. */ + | 'insecure' + /** http to this machine (localhost, 127.0.0.0/8, ::1): a secure context, no warning. */ + | 'local' + | 'file' + /** about:, data:, view-source of an internal page, Chromium pages… */ + | 'internal' + | 'none' + +const LOOPBACK_HOST = /^(?:localhost|[^.]+(?:\.[^.]+)*\.localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i + +export function connectionOf(url: string | undefined): Connection { + if (!url) return 'none' + if (url.startsWith('view-source:')) return connectionOf(url.slice('view-source:'.length)) + let parsed: URL + try { + parsed = new URL(url) + } catch { + return 'none' + } + switch (parsed.protocol) { + case 'https:': + case 'wss:': + return 'secure' + case 'http:': + case 'ws:': + return LOOPBACK_HOST.test(parsed.hostname) ? 'local' : 'insecure' + case 'file:': + return 'file' + default: + return 'internal' + } +} + +/** How many history entries the Back/Forward menu lists (the nearest ones). */ +export const HISTORY_MENU_MAX = 15 + +/** + * Indexes of the entries the Back or Forward history menu lists, nearest the button first: for + * Back the page just before the current one comes first (NV-01). + */ +export function historyMenuIndexes( + length: number, + current: number, + direction: 'back' | 'forward', + max = HISTORY_MENU_MAX, +): number[] { + const out: number[] = [] + if (direction === 'back') { + for (let i = Math.min(current, length) - 1; i >= 0 && out.length < max; i--) out.push(i) + } else { + for (let i = Math.max(current, -1) + 1; i < length && out.length < max; i++) out.push(i) + } + return out +} + +/** A history entry's menu label: its title, or its address without the scheme when it has none. */ +export function historyEntryLabel(title: string, url: string, max = 60): string { + const text = title.trim() || url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/$/, '') || url + return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index e43c936d..112cfea6 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -15,6 +15,7 @@ import type { DownloadCommands } from './commands/downloads' import type { ExtensionCommands } from './commands/extensions' import type { HistoryCommands } from './commands/history' import type { ImportCommands } from './commands/import' +import type { NavCommands } from './commands/nav' import type { PeekCommands } from './commands/peek' import type { SettingsCommands } from './commands/settings' import type { SpaceCommands } from './commands/spaces' @@ -35,7 +36,8 @@ export interface OrbitCommands ExtensionCommands, SettingsCommands, DownloadCommands, - ImportCommands {} + ImportCommands, + NavCommands {} export interface OrbitEvents extends CoreEvents {} diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts new file mode 100644 index 00000000..317734d1 --- /dev/null +++ b/tests/e2e/chrome.spec.ts @@ -0,0 +1,380 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' +import { isMac, launchOrbit, type Orbit, state, tabPage, viewPage } from './helpers' +import { + activeTab, + chooseHistory, + cmd, + HOST_RULES, + historyMenu, + INSECURE_HOST, + type NavFixtures, + NEW_TAB_CLICK, + openTab, + shellRoundTrip, + spyCommand, + startNavFixtures, + tabCount, + visit, + waitLoaded, + windowState, +} from './nav-support' + +let fx: NavFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit({ args: [HOST_RULES] }) + // macOS keeps the clipboard between tests (even after the app quits): start empty. + await orbit.app.evaluate(({ clipboard }) => clipboard.clear()) +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const page = (title: string) => fx.url(`/page/${title}`) + +/** Opens the first page in a new tab, then visits the others in that tab. Returns the tab. */ +async function tabWithHistory(...titles: [string, ...string[]]): Promise { + const [first, ...rest] = titles + const id = await openTab(orbit.app, page(first)) + for (const title of rest) await visit(orbit.app, id, page(title)) + return id +} + +/** The tab ids other than the given ones. */ +async function otherTabs(...known: string[]): Promise { + return Object.keys((await state(orbit.app)).tabs).filter((id) => !known.includes(id)) +} + +test('[SB-03][NV-01] Back and Forward grey out when there is nowhere to go and act on the active tab', async () => { + const { app, shell } = orbit + const back = shell.getByTestId('nav-back') + const forward = shell.getByTestId('nav-forward') + const reload = shell.getByTestId('nav-reload') + // No tab yet: nothing to act on. + await expect(back).toBeDisabled() + await expect(forward).toBeDisabled() + await expect(reload).toBeDisabled() + + const id = await tabWithHistory('Alpha') + await expect(reload).toBeEnabled() + await expect(back).toBeDisabled() + await visit(app, id, page('Beta')) + await expect(back).toBeEnabled() + await expect(forward).toBeDisabled() + + await back.click() + await waitLoaded(app, id, page('Alpha')) + await expect(forward).toBeEnabled() + await expect(back).toBeDisabled() + await forward.click() + await waitLoaded(app, id, page('Beta')) + await expect(forward).toBeDisabled() + // Tooltips name the keys (KS-08). + await expect(back).toHaveAttribute('title', isMac ? 'Back (⌘[)' : 'Back (Alt+←)') + await expect(forward).toHaveAttribute('title', isMac ? 'Forward (⌘])' : 'Forward (Alt+→)') + await expect(reload).toHaveAttribute('title', isMac ? 'Reload (⌘R)' : 'Reload (Ctrl+R)') +}) + +test('[SB-03][NV-01] long-press on Back lists the history nearest first, with icons; choosing one goes there', async () => { + const { app, shell } = orbit + const id = await tabWithHistory('One', 'Two', 'Three', 'Four') + const presses = await spyCommand(app, 'nav.press') + await shell.getByTestId('nav-back').hover() + await shell.mouse.down() + await expect + .poll(() => historyMenu(app)) + .toMatchObject({ direction: 'back', labels: ['Three', 'Two', 'One'], icons: [true, true, true] }) + await shell.mouse.up() + await shellRoundTrip(shell) + // The long-press opened the menu instead of going back. + expect(await presses()).toBe(0) + expect((await state(app)).tabs[id]?.url).toBe(page('Four')) + + expect(await chooseHistory(app, 'One')).toBe(true) + await waitLoaded(app, id, page('One')) + expect(await historyMenu(app)).toBeNull() + // Forward's menu lists the pages ahead, nearest first. + await shell.getByTestId('nav-forward').hover() + await shell.mouse.down() + await expect.poll(() => historyMenu(app)).toMatchObject({ direction: 'forward', labels: ['Two', 'Three', 'Four'] }) + await shell.mouse.up() + expect(await chooseHistory(app, 'Three')).toBe(true) + await waitLoaded(app, id, page('Three')) +}) + +test('[NV-01] right-click on Back opens the history menu at once; switching tab closes it', async () => { + const { app, shell } = orbit + const id = await tabWithHistory('Red', 'Green') + const other = await cmd(app, 'tabs.create', { url: page('Blue'), activate: false }) + expect((await activeTab(app)).id).toBe(id) + await shell.getByTestId('nav-back').click({ button: 'right' }) + await expect.poll(() => historyMenu(app)).toMatchObject({ direction: 'back', labels: ['Red'] }) + await cmd(app, 'tabs.activate', { tabId: other }) + await expect.poll(() => historyMenu(app)).toBeNull() + // Nothing left to choose: the menu of the old tab is gone. + expect(await chooseHistory(app, 'Red')).toBe(false) + expect((await state(app)).tabs[id]?.url).toBe(page('Green')) +}) + +test('[SB-03][NV-01] ⌘-click or middle-click on Back opens the previous page in a new tab; the tab stays', async () => { + const { app, shell } = orbit + const id = await tabWithHistory('Before', 'Current') + const back = shell.getByTestId('nav-back') + + await back.click({ modifiers: [NEW_TAB_CLICK] }) + await expect.poll(async () => (await otherTabs(id)).length).toBe(1) + const [first] = await otherTabs(id) + await waitLoaded(app, first as string, page('Before')) + // The original tab still shows its page and stays selected. + expect((await activeTab(app)).id).toBe(id) + expect((await state(app)).tabs[id]?.url).toBe(page('Current')) + // The new tab keeps the history: Forward leads to the page the click was made on. + await expect.poll(async () => (await state(app)).tabs[first as string]?.canGoForward).toBe(true) + + await back.click({ button: 'middle' }) + await expect.poll(async () => (await otherTabs(id)).length).toBe(2) + const [second] = await otherTabs(id, first as string) + await waitLoaded(app, second as string, page('Before')) + expect((await activeTab(app)).id).toBe(id) + + // With ⇧ the new tab comes to the front. + await back.click({ modifiers: [NEW_TAB_CLICK, 'Shift'] }) + await expect.poll(async () => (await otherTabs(id)).length).toBe(3) + const [third] = await otherTabs(id, first as string, second as string) + await expect.poll(async () => (await activeTab(app)).id).toBe(third) + expect((await state(app)).tabs[id]?.url).toBe(page('Current')) +}) + +test('[SB-03][NV-03] ⌘-click or middle-click on Reload duplicates the tab; the original stays selected', async () => { + const { app, shell } = orbit + const id = await tabWithHistory('Origin', 'Twin') + const reload = shell.getByTestId('nav-reload') + const before = await tabCount(app) + + await reload.click({ modifiers: [NEW_TAB_CLICK] }) + await expect.poll(() => tabCount(app)).toBe(before + 1) + const [copy] = await otherTabs(id) + await waitLoaded(app, copy as string, page('Twin')) + expect((await state(app)).tabs[id]?.url).toBe(page('Twin')) + expect((await activeTab(app)).id).toBe(id) + // The duplicate has the history too. + await expect.poll(async () => (await state(app)).tabs[copy as string]?.canGoBack).toBe(true) + + await reload.click({ button: 'middle' }) + await expect.poll(() => tabCount(app)).toBe(before + 2) + expect((await activeTab(app)).id).toBe(id) +}) + +test('[SB-03][NV-04] Reload turns into Stop while the page loads; clicking it stops the load', async () => { + const { app, shell } = orbit + const path = '/slow-reload/Slow' + const id = await openTab(app, fx.url(path)) + const reload = shell.getByTestId('nav-reload') + await reload.click() + // The reload is under way (the server holds it): the button is Stop now. + await expect.poll(() => fx.hits(path)).toBe(2) + await expect(reload).toHaveAttribute('data-loading', 'true') + await expect(reload).toHaveAttribute('title', 'Stop (Esc)') + await expect(reload).toHaveAttribute('aria-label', 'Stop') + await reload.click() + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + await expect(reload).not.toHaveAttribute('data-loading') + await expect(reload).toHaveAttribute('aria-label', 'Reload') + // The page that was showing stays. + const shown = await tabPage(app, path) + expect(await shown.evaluate(() => sessionStorage.getItem('loads'))).toBe('1') +}) + +test('[SB-03][NV-04] double-clicking Reload stops loading', async () => { + const { app, shell } = orbit + const reload = shell.getByTestId('nav-reload') + // While a navigation is under way. + const id = await openTab(app, page('Start')) + await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/Never') }) + await expect.poll(() => fx.hits('/slow/Never')).toBe(1) + await expect(reload).toHaveAttribute('data-loading', 'true') + await reload.dblclick() + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + expect((await state(app)).tabs[id]?.url).toBe(page('Start')) + + // On a loaded page: the first click reloads, the second stops that reload. + const path = '/slow-reload/Again' + const other = await openTab(app, fx.url(path)) + await reload.dblclick() + await expect.poll(async () => (await state(app)).tabs[other]?.loading).toBe(false) + await expect(reload).not.toHaveAttribute('data-loading') + const shown = await tabPage(app, path) + expect(await shown.evaluate(() => sessionStorage.getItem('loads'))).toBe('1') +}) + +test('[SB-03] the sidebar-toggle button hides the sidebar', async () => { + const { app, shell } = orbit + const toggle = shell.getByTestId('toggle-sidebar') + await expect(toggle).toHaveAttribute('title', isMac ? 'Hide sidebar (⌘S)' : 'Hide sidebar (Ctrl+S)') + await toggle.click() + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) +}) + +test('[SB-03] at the narrowest sidebar Back, Forward and Reload stay; the toggle gives way first', async () => { + const { app, shell } = orbit + await tabWithHistory('Narrow', 'Narrower') + const sidebar = shell.getByTestId('sidebar') + const toggle = shell.getByTestId('toggle-sidebar') + await expect(toggle).toBeVisible() + await cmd(app, 'window.setSidebar', { width: 180 }) + await expect.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0)).toBeLessThanOrEqual(180) + await expect(toggle).toBeHidden() + const box = await sidebar.boundingBox() + if (!box) throw new Error('no sidebar') + const lead = await shell.getByTestId(isMac ? 'traffic-space' : 'window-controls').boundingBox() + for (const testId of ['nav-back', 'nav-forward', 'nav-reload']) { + const button = shell.getByTestId(testId) + await expect(button).toBeVisible() + const b = await button.boundingBox() + if (!b || !lead) throw new Error(`no box for ${testId}`) + expect(b.width).toBeGreaterThanOrEqual(24) + // Inside the sidebar, clear of the window controls. + expect(b.x).toBeGreaterThanOrEqual(lead.x + lead.width) + expect(b.x + b.width).toBeLessThanOrEqual(box.x + box.width) + } +}) + +test('[SB-03] off macOS the window controls sit at the top left of the sidebar', async () => { + test.skip(isMac, 'macOS draws its own traffic lights there') + const { shell } = orbit + const controls = shell.getByTestId('window-controls') + await expect(controls).toBeVisible() + for (const title of ['Close', 'Minimize', 'Maximize']) await expect(controls.getByTitle(title)).toBeVisible() + const c = await controls.boundingBox() + const back = await shell.getByTestId('nav-back').boundingBox() + if (!c || !back) throw new Error('no boxes') + expect(c.x).toBeLessThan(back.x) +}) + +test('[SB-04] the address shows only the host; clicking it edits the full URL in the command bar', async () => { + const { app, shell } = orbit + const url = fx.url('/page/Deep?x=1') + await openTab(app, url) + const pill = shell.getByTestId('address-pill') + await expect(pill).toHaveText(`localhost:${fx.port}`) + await expect(pill).toHaveAttribute('title', url) + await pill.click() + const overlay = await viewPage(app, 'overlay') + const input = overlay.getByTestId('command-input') + await expect(input).toBeFocused() + await expect(input).toHaveValue(url) + expect((await windowState(app)).overlay).toMatchObject({ kind: 'commandbar', mode: 'edit-url' }) +}) + +test('[SB-04] no padlock on pages; plain http to another machine shows a warning', async () => { + const { app, shell } = orbit + const address = shell.getByTestId('address') + await expect(address).toHaveClass(/address--empty/) + await openTab(app, fx.urlOn(INSECURE_HOST, '/page/Plain')) + await expect(address).toHaveAttribute('data-connection', 'insecure') + await expect(shell.getByTestId('insecure-icon')).toBeVisible() + await expect(shell.getByTestId('address-pill')).toHaveText(`${INSECURE_HOST}:${fx.port}`) + // This machine over http is a secure context: no warning, and never a padlock. + await openTab(app, page('Local')) + await expect(address).toHaveAttribute('data-connection', 'local') + await expect(shell.getByTestId('insecure-icon')).toHaveCount(0) + await expect(shell.getByTestId('site-controls-button')).toBeVisible() + await expect(address.locator('.lucide-lock')).toHaveCount(0) +}) + +test('[SB-04][NV-17] the copy button copies the clean URL and confirms it', async () => { + const { app, shell } = orbit + await openTab(app, fx.url('/page/Shop?utm_source=x&id=7')) + await shell.getByTestId('address').hover() + const copy = shell.getByTestId('copy-url-button') + await expect(copy).toHaveCSS('opacity', '1') + await copy.click() + await expect.poll(() => app.evaluate(({ clipboard }) => clipboard.readText())).toBe(fx.url('/page/Shop?id=7')) + await expect(shell.getByTestId('toast').filter({ hasText: 'Copied' })).toBeVisible() + await expect(copy).toHaveClass(/is-copied/) +}) + +test('[NV-17] copying the address from the command bar shows a one-time tip about the shortcut', async () => { + const { app, shell } = orbit + const url = page('Hint') + await openTab(app, url) + const hints = await spyCommand(app, 'nav.copyHint') + const tip = shell.getByTestId('toast').filter({ hasText: 'Tip:' }) + + const copyFromBar = async () => { + await shell.getByTestId('address-pill').click() + const overlay = await viewPage(app, 'overlay') + const input = overlay.getByTestId('command-input') + await expect(input).toBeFocused() + await expect(input).toHaveValue(url) + // ⌘C reaches the field through the Edit menu on macOS, which calls copy() on the page. + if (isMac) { + await app.evaluate(({ webContents }) => + webContents + .getAllWebContents() + .find((w) => w.getURL().includes('view=overlay')) + ?.copy(), + ) + } else { + await overlay.keyboard.press('Control+C') + } + await expect.poll(() => app.evaluate(({ clipboard }) => clipboard.readText())).toBe(url) + await overlay.keyboard.press('Escape') + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') + } + + await copyFromBar() + await expect(tip).toBeVisible() + await expect(tip).toContainText(isMac ? '⇧⌘C' : 'Ctrl+Shift+C') + expect(await hints()).toBe(1) + // Remembered for good (a file in the profile directory). + const file = join(orbit.userData, 'orbit-site-prefs.json') + await expect + .poll(() => existsSync(file) && JSON.parse(readFileSync(file, 'utf8')).hints?.['copy-url'] === true) + .toBe(true) + await expect(tip).toHaveCount(0) + // The second time: no tip. + await copyFromBar() + await expect.poll(() => hints()).toBe(2) + await expect(tip).toHaveCount(0) +}) + +test('[SB-04][SB-03] in split view the address and buttons follow the focused pane', async () => { + const { app, shell } = orbit + const left = await tabWithHistory('LeftOne', 'LeftTwo') + const right = await openTab(app, fx.urlOn('127.0.0.1', '/page/Right')) + await cmd(app, 'split.create', { tabIds: [left, right], focus: right }) + const pill = shell.getByTestId('address-pill') + await expect(pill).toHaveText(`127.0.0.1:${fx.port}`) + await expect(shell.getByTestId('nav-back')).toBeDisabled() + await cmd(app, 'split.focusPane', { tabId: left }) + await expect(pill).toHaveText(`localhost:${fx.port}`) + await shell.getByTestId('nav-back').click() + await waitLoaded(app, left, page('LeftOne')) + expect((await state(app)).tabs[right]?.url).toBe(fx.urlOn('127.0.0.1', '/page/Right')) +}) + +test('[NV-01][SB-03] Back on a fresh tab opened from a link closes it and returns to the opener', async () => { + const { app, shell } = orbit + const opener = await openTab(app, page('Parent')) + const tab = await tabPage(app, '/page/Parent') + await tab.click('#child') + await expect.poll(async () => (await otherTabs(opener)).length).toBe(1) + const [child] = await otherTabs(opener) + await waitLoaded(app, child as string, page('Parent-child')) + await expect.poll(async () => (await activeTab(app)).id).toBe(child) + const back = shell.getByTestId('nav-back') + await expect(back).toBeEnabled() + await back.click() + await expect.poll(async () => (await state(app)).tabs[child as string]).toBeUndefined() + expect((await activeTab(app)).id).toBe(opener) +}) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index c8c41216..195e7d2a 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -22,10 +22,15 @@ export interface Orbit { userData: string } -/** Launches the built app (out/) with an isolated profile directory. */ -export async function launchOrbit(opts: { userData?: string; env?: Record } = {}): Promise { +/** + * Launches the built app (out/) with an isolated profile directory. `args` are extra Chromium + * switches (e.g. `--host-resolver-rules` to give the local fixture server another host name). + */ +export async function launchOrbit( + opts: { userData?: string; env?: Record; args?: string[] } = {}, +): Promise { const userData = opts.userData ?? mkdtempSync(join(tmpdir(), 'orbit-e2e-')) - const args = [resolve('out/main/index.js')] + const args = [...(opts.args ?? []), resolve('out/main/index.js')] // Linux CI/dev boxes often lack the setuid sandbox helper; macOS runs fully sandboxed. if (process.platform === 'linux') args.unshift('--no-sandbox') const app = await electron.launch({ diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts new file mode 100644 index 00000000..73b1bdcb --- /dev/null +++ b/tests/e2e/nav-support.ts @@ -0,0 +1,269 @@ +/** + * Helpers for the chrome lane's e2e specs (chrome.spec.ts, nav.spec.ts): a local fixture server + * with pages for navigation tests, and small wrappers around Orbit's test hooks. + */ +import { createServer, type ServerResponse } from 'node:http' +import type { AddressInfo, Socket } from 'node:net' +import { type ElectronApplication, expect, type Page } from '@playwright/test' +import type { OrbitState, Tab } from '../../src/shared/state' +import { isMac, state } from './helpers' + +/** Host name the fixture server also answers to, as a plain-http site that isn't this machine. */ +export const INSECURE_HOST = 'insecure.test' +/** Chromium switch mapping INSECURE_HOST to the local fixture server (no network involved). */ +export const HOST_RULES = `--host-resolver-rules=MAP ${INSECURE_HOST} 127.0.0.1` + +/** ⌘ on macOS, Ctrl elsewhere: the modifier that sends a click to a new tab (SB-03, NV-08). */ +export const NEW_TAB_CLICK = isMac ? 'Meta' : 'Control' + +export interface NavFixtures { + port: number + /** `http://localhost:` */ + url: (path: string) => string + /** The same server under another host name (`127.0.0.1`, or INSECURE_HOST with HOST_RULES). */ + urlOn: (host: string, path: string) => string + /** How many requests a path has had. */ + hits: (path: string) => number + /** The Cache-Control header of the latest request for a path (a hard reload sends no-cache). */ + cacheControl: (path: string) => string | undefined + close: () => Promise +} + +const ICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%2338a169'/%3E%3C/svg%3E" + +/** + * A page that records key presses (`window.__keys`) and counts its loads in sessionStorage + * (`loads`), with a text box (#box), a link to the next page (#next) and one that opens a new + * tab (#child). + */ +function page(title: string): string { + return ` +${title} + +

${title}

+

+

+

Child page

+
+ +` +} + +/** + * Routes: + * - `/page/`: a page (see `page()`). + * - `/slow/<title>?ms=N`: the page after N ms (default 30 s): keeps a tab loading. + * - `/slow-reload/<title>`: the page at once on the first request, then slowly (30 s) on reloads. + */ +export async function startNavFixtures(): Promise<NavFixtures> { + const hits = new Map<string, number>() + const cacheControl = new Map<string, string | undefined>() + const pending = new Set<ServerResponse>() + const sockets = new Set<Socket>() + const server = createServer((req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost') + const count = (hits.get(u.pathname) ?? 0) + 1 + hits.set(u.pathname, count) + cacheControl.set(u.pathname, req.headers['cache-control']) + const [, kind = '', raw = 'index'] = u.pathname.split('/') + const title = decodeURIComponent(raw) + const send = () => { + pending.delete(res) + if (res.writableEnded || res.destroyed) return + res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }) + res.end(page(title)) + } + const later = (ms: number) => { + pending.add(res) + const timer = setTimeout(send, ms) + res.on('close', () => { + clearTimeout(timer) + pending.delete(res) + }) + } + if (kind === 'page') return send() + if (kind === 'slow') return later(Number(u.searchParams.get('ms') ?? 30_000)) + if (kind === 'slow-reload') return count === 1 ? send() : later(30_000) + res.writeHead(404, { 'content-type': 'text/plain' }) + res.end('not found') + }) + server.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + await new Promise<void>((resolveListen, reject) => { + let port = 3340 + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE' && port < 3399) server.listen(++port, '127.0.0.1') + else reject(err) + }) + server.listen(port, '127.0.0.1', () => resolveListen()) + }) + const { port } = server.address() as AddressInfo + return { + port, + url: (path) => `http://localhost:${port}${path}`, + urlOn: (host, path) => `http://${host}:${port}${path}`, + hits: (path) => hits.get(path) ?? 0, + cacheControl: (path) => cacheControl.get(path), + close: () => + new Promise((r) => { + for (const socket of sockets) socket.destroy() + server.close(() => r()) + }), + } +} + +type Hooks = { + __orbit: { run: (n: string, a: unknown, w?: string) => Promise<unknown>; windowIds: () => string[] } + __orbitNav: { + menu: () => { windowId: string; direction: 'back' | 'forward'; labels: string[]; icons: boolean[] } | null + chooseMenu: (label: string, newTab?: boolean) => boolean + } +} + +/** Runs a command as if from the first window. */ +export function cmd<T = unknown>(app: ElectronApplication, name: string, args: unknown = {}): Promise<T> { + return app.evaluate( + async (_e, [n, a]) => { + const g = globalThis as unknown as Hooks + return g.__orbit.run(n, a, g.__orbit.windowIds()[0]) + }, + [name, args] as const, + ) as Promise<T> +} + +/** The history menu Orbit would show (labels top to bottom), or null when none is open. */ +export function historyMenu(app: ElectronApplication) { + return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.menu()) +} + +export function chooseHistory(app: ElectronApplication, label: string, newTab = false): Promise<boolean> { + return app.evaluate((_e, [l, n]) => (globalThis as unknown as Hooks).__orbitNav.chooseMenu(l, n), [ + label, + newTab, + ] as const) +} + +export async function windowState(app: ElectronApplication): Promise<OrbitState['windows'][string]> { + const s = await state(app) + const id = await app.evaluate(() => (globalThis as unknown as Hooks).__orbit.windowIds()[0]) + return s.windows[id as string] as OrbitState['windows'][string] +} + +export async function activeTab(app: ElectronApplication): Promise<Tab> { + const s = await state(app) + const win = await windowState(app) + const id = win.activeTab[win.spaceId] + const t = id ? s.tabs[id] : undefined + if (!t) throw new Error('no active tab') + return t +} + +export async function tabCount(app: ElectronApplication): Promise<number> { + return Object.keys((await state(app)).tabs).length +} + +/** Opens a URL in a new, selected tab and waits until it has loaded. */ +export async function openTab(app: ElectronApplication, url: string): Promise<string> { + const id = await cmd<string>(app, 'tabs.create', { url }) + await waitLoaded(app, id, url) + return id +} + +/** Waits until a tab shows `url` (exactly) and has stopped loading with its page title. */ +export async function waitLoaded(app: ElectronApplication, tabId: string, url: string): Promise<void> { + await expect + .poll(async () => { + const t = (await state(app)).tabs[tabId] + // Until the page's <title> arrives, Chromium titles the tab with its address. + return !!t && t.url === url && !t.loading && !t.asleep && !!t.title && !t.title.includes('/') + }) + .toBe(true) +} + +/** Navigates a tab like a click on a link would, and waits for the page. */ +export async function visit(app: ElectronApplication, tabId: string, url: string): Promise<void> { + await cmd(app, 'tabs.navigate', { tabId, url }) + await waitLoaded(app, tabId, url) +} + +/** The URL of the web contents that has keyboard focus. */ +export function focusedUrl(app: ElectronApplication): Promise<string | null> { + return app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL() ?? null) +} + +/** + * Presses a key in a tab's page the way the keyboard does: through the browser process, where + * Orbit's shortcuts see it. (Playwright's `page.keyboard` goes straight to the page.) `key` is an + * Electron key code ('Left', '[', 'R', 'F5', 'Escape'…); modifiers are 'shift', 'control', 'alt', 'meta'. + */ +export async function pressInPage( + app: ElectronApplication, + urlPart: string, + key: string, + modifiers: ('shift' | 'control' | 'alt' | 'meta')[] = [], +): Promise<void> { + await app.evaluate( + ({ webContents }, [part, keyCode, mods]) => { + const page = webContents.getAllWebContents().find((w) => w.getURL().includes(part)) + if (!page) throw new Error(`no page with ${part}`) + page.sendInputEvent({ type: 'keyDown', keyCode, modifiers: [...mods] }) + page.sendInputEvent({ type: 'keyUp', keyCode, modifiers: [...mods] }) + }, + [urlPart, key, modifiers] as const, + ) +} + +/** Presses a mouse side button ('back' or 'forward') over a page, as the hardware would send it. */ +export async function sideButton(page: Page, button: 'back' | 'forward'): Promise<void> { + const cdp = await page.context().newCDPSession(page) + try { + const at = { x: 120, y: 120, button, clickCount: 1 } + await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', buttons: button === 'back' ? 8 : 16, ...at }) + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', buttons: 0, ...at }) + } finally { + await cdp.detach() + } +} + +/** + * Counts calls to a main-process command from now on, by wrapping its handler: proves that + * something did not happen (e.g. a long-press did not also navigate). + */ +export async function spyCommand(app: ElectronApplication, name: string): Promise<() => Promise<number>> { + await app.evaluate((_e, n) => { + type Handler = (...args: unknown[]) => unknown + const g = globalThis as unknown as { + __orbit: { ctx: { commands: { handlers: Map<string, Handler> } } } + __navSpy?: Record<string, number> + } + const handlers = g.__orbit.ctx.commands.handlers + const original = handlers.get(n) + if (!original) throw new Error(`no command ${n}`) + g.__navSpy = g.__navSpy ?? {} + const spy = g.__navSpy + spy[n] = 0 + handlers.set(n, (...args: unknown[]) => { + spy[n] = (spy[n] ?? 0) + 1 + return original(...args) + }) + }, name) + return () => + app.evaluate((_e, n) => (globalThis as unknown as { __navSpy: Record<string, number> }).__navSpy[n] ?? 0, name) +} + +/** Resolves once every command the shell sent before has been handled (one IPC round trip). */ +export async function shellRoundTrip(shell: Page): Promise<void> { + await shell.evaluate(() => + (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise<unknown> } }).orbit.invoke( + 'app.info', + {}, + ), + ) +} diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts new file mode 100644 index 00000000..eb9fde54 --- /dev/null +++ b/tests/e2e/nav.spec.ts @@ -0,0 +1,194 @@ +import { expect, type Page, test } from '@playwright/test' +import { isMac, launchOrbit, MOD, type Orbit, state, tabPage, viewPage } from './helpers' +import { + cmd, + focusedUrl, + type NavFixtures, + openTab, + pressInPage, + sideButton, + startNavFixtures, + visit, + waitLoaded, + windowState, +} from './nav-support' + +let fx: NavFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const page = (title: string) => fx.url(`/page/${title}`) +/** ⌘ on macOS, Ctrl elsewhere, as a modifier for `pressInPage`. */ +const mod = isMac ? 'meta' : 'control' + +/** Opens the first page in a new tab, then visits the others in that tab. Returns the tab. */ +async function tabWithHistory(...titles: [string, ...string[]]): Promise<string> { + const [first, ...rest] = titles + const id = await openTab(orbit.app, page(first)) + for (const title of rest) await visit(orbit.app, id, page(title)) + return id +} + +const keysSeen = (tab: Page) => tab.evaluate(() => (window as unknown as { __keys: string[] }).__keys) +/** How many times the page has loaded in this tab (null while a reload swaps the document). */ +const loads = (tab: Page) => tab.evaluate(() => sessionStorage.getItem('loads')).catch(() => null) + +/** Gives keyboard focus to the web contents whose URL contains `part` (a tab, or `view=shell`). */ +async function focusContents(part: string): Promise<void> { + await orbit.app.evaluate(({ webContents }, p) => { + webContents + .getAllWebContents() + .find((w) => w.getURL().includes(p)) + ?.focus() + }, part) + await expect.poll(() => focusedUrl(orbit.app)).toContain(part) +} + +test('[NV-01][KS-08] the Back and Forward keys work from the page and from the sidebar', async () => { + const { app, shell } = orbit + const id = await tabWithHistory('KeyA', 'KeyB') + // In the page: ⌘[ ⌘] on macOS, Alt+← Alt+→ elsewhere. + await pressInPage(app, '/page/KeyB', isMac ? '[' : 'Left', [isMac ? 'meta' : 'alt']) + await waitLoaded(app, id, page('KeyA')) + await pressInPage(app, '/page/KeyA', isMac ? ']' : 'Right', [isMac ? 'meta' : 'alt']) + await waitLoaded(app, id, page('KeyB')) + // In the sidebar. + await shell.keyboard.press(isMac ? 'Meta+BracketLeft' : 'Alt+ArrowLeft') + await waitLoaded(app, id, page('KeyA')) + await shell.keyboard.press(isMac ? 'Meta+BracketRight' : 'Alt+ArrowRight') + await waitLoaded(app, id, page('KeyB')) +}) + +test('[NV-01][KS-08] ⌘← and ⌘→ go back and forward, but not while a text field has focus', async () => { + test.skip(!isMac, '⌘← and ⌘→ are macOS keys') + const { app } = orbit + const id = await tabWithHistory('ArrowA', 'ArrowB', 'ArrowC') + const tab = await tabPage(app, '/page/ArrowC') + await tab.click('#box') + await pressInPage(app, '/page/ArrowC', 'Left', ['meta']) + // In the field the key is the page's (it moves the caret there). + await expect.poll(() => keysSeen(tab)).toContain('ArrowLeft') + // Outside it, ⌘← goes back one page: had the first press gone back too, this would land on A. + await tab.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await pressInPage(app, '/page/ArrowC', 'Left', ['meta']) + await waitLoaded(app, id, page('ArrowB')) + await pressInPage(app, '/page/ArrowB', 'Right', ['meta']) + await waitLoaded(app, id, page('ArrowC')) +}) + +test('[NV-01] the mouse back and forward buttons over the page go back and forward one page', async () => { + const { app } = orbit + const id = await tabWithHistory('MouseA', 'MouseB', 'MouseC') + const tab = await tabPage(app, '/page/MouseC') + await sideButton(tab, 'back') + // One page, not two. + await waitLoaded(app, id, page('MouseB')) + await sideButton(tab, 'forward') + await waitLoaded(app, id, page('MouseC')) +}) + +test('[NV-01] Backspace does not go back', async () => { + const { app } = orbit + const id = await tabWithHistory('BsA', 'BsB') + const tab = await tabPage(app, '/page/BsB') + await pressInPage(app, '/page/BsB', 'Backspace') + await expect.poll(() => keysSeen(tab)).toContain('Backspace') + expect(await tab.evaluate(() => location.pathname)).toBe('/page/BsB') + expect((await state(app)).tabs[id]?.url).toBe(page('BsB')) +}) + +test('[NV-03][KS-08] after Reload the keyboard is in the page, from the page or the sidebar', async () => { + const { app, shell } = orbit + const url = page('Typing') + const id = await openTab(app, url) + const tab = await tabPage(app, '/page/Typing') + // Typing in the page's text box, then the Reload key. + await focusContents('/page/Typing') + await tab.click('#box') + await tab.keyboard.type('abc') + await pressInPage(app, '/page/Typing', 'R', [mod]) + await expect.poll(() => loads(tab)).toBe('2') + await waitLoaded(app, id, url) + expect(await focusedUrl(app)).toBe(url) + + // The Reload button in the sidebar hands the keyboard to the page. + await focusContents('view=shell') + await shell.getByTestId('nav-reload').click() + await expect.poll(() => loads(tab)).toBe('3') + await expect.poll(() => focusedUrl(app)).toBe(url) + + // So does the key pressed while the sidebar has focus. + await focusContents('view=shell') + await shell.keyboard.press(`${MOD}+R`) + await expect.poll(() => loads(tab)).toBe('4') + await expect.poll(() => focusedUrl(app)).toBe(url) +}) + +test('[NV-03][KS-08] Reload revalidates the page; hard reload bypasses the cache', async () => { + const { app } = orbit + const path = '/page/Fresh' + const url = fx.url(path) + const id = await openTab(app, url) + const tab = await tabPage(app, path) + let count = 1 + const press = async (key: string, modifiers: ('shift' | 'control' | 'meta')[], cacheControl: string) => { + await pressInPage(app, path, key, modifiers) + count++ + await expect.poll(() => loads(tab)).toBe(String(count)) + expect(fx.hits(path)).toBe(count) + expect(fx.cacheControl(path), `${modifiers.join('+')}+${key}`).toBe(cacheControl) + await waitLoaded(app, id, url) + } + await press('R', [mod], 'max-age=0') + await press('R', [mod, 'shift'], 'no-cache') + if (!isMac) { + await press('F5', [], 'max-age=0') + await press('F5', ['shift'], 'no-cache') + await press('F5', ['control'], 'no-cache') + } +}) + +test('[NV-04][KS-08] Esc in the page stops loading; with the command bar open, Esc closes the bar first', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Steady')) + const tab = await tabPage(app, '/page/Steady') + await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/Pending') }) + await expect.poll(() => fx.hits('/slow/Pending')).toBe(1) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) + + await shell.keyboard.press(`${MOD}+L`) + const overlay = await viewPage(app, 'overlay') + await expect(overlay.getByTestId('command-input')).toBeFocused() + await overlay.keyboard.press('Escape') + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') + expect((await state(app)).tabs[id]?.loading).toBe(true) + + // Esc in the page stops the load, and the page still gets the key. + await pressInPage(app, '/page/Steady', 'Escape') + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + expect(await keysSeen(tab)).toContain('Escape') + expect(await tab.evaluate(() => location.pathname)).toBe('/page/Steady') +}) + +test('[NV-04][KS-08] ⌘. stops loading on macOS', async () => { + test.skip(!isMac, '⌘. is a macOS key') + const { app } = orbit + const id = await openTab(app, page('Halt')) + await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/Halting') }) + await expect.poll(() => fx.hits('/slow/Halting')).toBe(1) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) + await pressInPage(app, '/page/Halt', '.', ['meta']) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) +}) diff --git a/tests/unit/nav-history.test.ts b/tests/unit/nav-history.test.ts new file mode 100644 index 00000000..5b8a55ef --- /dev/null +++ b/tests/unit/nav-history.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { HISTORY_MENU_MAX, historyEntryLabel, historyMenuIndexes } from '../../src/shared/nav' + +describe('historyMenuIndexes (NV-01)', () => { + it('lists the pages behind the current one, nearest first', () => { + expect(historyMenuIndexes(5, 3, 'back')).toEqual([2, 1, 0]) + expect(historyMenuIndexes(5, 0, 'back')).toEqual([]) + }) + + it('lists the pages ahead of the current one, nearest first', () => { + expect(historyMenuIndexes(5, 1, 'forward')).toEqual([2, 3, 4]) + expect(historyMenuIndexes(5, 4, 'forward')).toEqual([]) + }) + + it('caps the list at the nearest entries', () => { + const back = historyMenuIndexes(40, 30, 'back') + expect(back).toHaveLength(HISTORY_MENU_MAX) + expect(back[0]).toBe(29) + expect(back[back.length - 1]).toBe(30 - HISTORY_MENU_MAX) + const forward = historyMenuIndexes(40, 2, 'forward', 4) + expect(forward).toEqual([3, 4, 5, 6]) + }) + + it('stays inside the history when the current index is out of range', () => { + expect(historyMenuIndexes(3, 7, 'back')).toEqual([2, 1, 0]) + expect(historyMenuIndexes(3, -1, 'forward')).toEqual([0, 1, 2]) + expect(historyMenuIndexes(0, 0, 'back')).toEqual([]) + }) +}) + +describe('historyEntryLabel (NV-01)', () => { + it('uses the page title', () => { + expect(historyEntryLabel(' Docs home ', 'https://example.com/docs')).toBe('Docs home') + }) + + it('falls back to the address without its scheme', () => { + expect(historyEntryLabel('', 'https://example.com/')).toBe('example.com') + expect(historyEntryLabel(' ', 'http://localhost:3300/page/A')).toBe('localhost:3300/page/A') + expect(historyEntryLabel('', 'about:blank')).toBe('about:blank') + }) + + it('shortens long labels with an ellipsis', () => { + const label = historyEntryLabel('x'.repeat(100), 'https://example.com/') + expect(label).toHaveLength(60) + expect(label.endsWith('…')).toBe(true) + expect(historyEntryLabel('abcdefghij', 'https://example.com/', 5)).toBe('abcd…') + }) +}) diff --git a/tests/unit/nav-keys.test.ts b/tests/unit/nav-keys.test.ts new file mode 100644 index 00000000..eae74ff3 --- /dev/null +++ b/tests/unit/nav-keys.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + ACTIONS_BY_ID, + actionForInput, + type KeyInput, + outsideTextActionFor, + shortcutsFor, +} from '../../src/shared/actions' + +const key = (code: string, mods: Partial<KeyInput> = {}): KeyInput => ({ + key: code.replace(/^Key|^Digit|^Arrow/, ''), + code, + meta: false, + control: false, + alt: false, + shift: false, + ...mods, +}) + +describe('navigation keys (KS-08, NV-01, NV-03)', () => { + it('binds Back and Forward on each platform', () => { + expect(actionForInput(key('BracketLeft', { meta: true }), 'darwin')?.id).toBe('nav.back') + expect(actionForInput(key('BracketRight', { meta: true }), 'darwin')?.id).toBe('nav.forward') + expect(actionForInput(key('ArrowLeft', { alt: true }), 'linux')?.id).toBe('nav.back') + expect(actionForInput(key('ArrowRight', { alt: true }), 'win32')?.id).toBe('nav.forward') + }) + + it('binds Reload and hard reload, including the function keys off macOS', () => { + expect(actionForInput(key('KeyR', { meta: true }), 'darwin')?.id).toBe('nav.reload') + expect(actionForInput(key('KeyR', { meta: true, shift: true }), 'darwin')?.id).toBe('nav.hardReload') + expect(actionForInput(key('KeyR', { control: true }), 'linux')?.id).toBe('nav.reload') + expect(actionForInput(key('F5'), 'linux')?.id).toBe('nav.reload') + expect(actionForInput(key('F5', { shift: true }), 'linux')?.id).toBe('nav.hardReload') + expect(actionForInput(key('F5', { control: true }), 'win32')?.id).toBe('nav.hardReload') + expect(actionForInput(key('KeyR', { control: true, shift: true }), 'win32')?.id).toBe('nav.hardReload') + }) + + it('leaves Backspace alone: it never navigates', () => { + expect(actionForInput(key('Backspace'), 'linux')).toBeUndefined() + expect(actionForInput(key('Backspace'), 'darwin')).toBeUndefined() + expect(outsideTextActionFor(key('Backspace'), 'darwin')).toBeUndefined() + }) + + it('maps ⌘← and ⌘→ to Back and Forward on macOS outside text fields only', () => { + // Inside a field these keys move the caret, so the global keymap never claims them. + expect(actionForInput(key('ArrowLeft', { meta: true }), 'darwin')).toBeUndefined() + expect(actionForInput(key('ArrowRight', { meta: true }), 'darwin')).toBeUndefined() + expect(outsideTextActionFor(key('ArrowLeft', { meta: true }), 'darwin')?.id).toBe('nav.back') + expect(outsideTextActionFor(key('ArrowRight', { meta: true }), 'darwin')?.id).toBe('nav.forward') + expect(outsideTextActionFor(key('ArrowLeft', { meta: true, shift: true }), 'darwin')).toBeUndefined() + expect(outsideTextActionFor(key('ArrowLeft', { control: true }), 'linux')).toBeUndefined() + }) + + it('names the shortcuts the tooltips show', () => { + const back = ACTIONS_BY_ID['nav.back'] + const hard = ACTIONS_BY_ID['nav.hardReload'] + if (!back || !hard) throw new Error('missing actions') + expect(shortcutsFor(back, 'darwin')[0]).toBe('Cmd+[') + expect(shortcutsFor(back, 'linux')[0]).toBe('Alt+Left') + expect(shortcutsFor(hard, 'linux')).toContain('Shift+F5') + }) +}) + +describe('stop keys (NV-04)', () => { + it('binds ⌘. on macOS only; Esc is handled by the page hook', () => { + expect(actionForInput(key('Period', { key: '.', meta: true }), 'darwin')?.id).toBe('nav.stop') + expect(actionForInput(key('Period', { key: '.', control: true }), 'linux')).toBeUndefined() + expect(actionForInput(key('Escape', { key: 'Escape' }), 'linux')?.id).not.toBe('nav.stop') + }) +}) diff --git a/tests/unit/nav-url.test.ts b/tests/unit/nav-url.test.ts new file mode 100644 index 00000000..e426be13 --- /dev/null +++ b/tests/unit/nav-url.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { connectionOf } from '../../src/shared/nav' + +describe('connectionOf (SB-04)', () => { + it('treats https as secure: no padlock, no warning', () => { + expect(connectionOf('https://example.com/a?b=1')).toBe('secure') + expect(connectionOf('wss://example.com/socket')).toBe('secure') + }) + + it('warns for plain http to another machine', () => { + expect(connectionOf('http://example.com/')).toBe('insecure') + expect(connectionOf('http://192.168.1.20:8080/')).toBe('insecure') + expect(connectionOf('http://insecure.test:3340/page/A')).toBe('insecure') + expect(connectionOf('http://localhost.example.com/')).toBe('insecure') + }) + + it('does not warn for http to this machine', () => { + expect(connectionOf('http://localhost:3300/')).toBe('local') + expect(connectionOf('http://LOCALHOST/')).toBe('local') + expect(connectionOf('http://app.localhost:5173/')).toBe('local') + expect(connectionOf('http://127.0.0.1:8080/x')).toBe('local') + expect(connectionOf('http://127.10.0.3/')).toBe('local') + expect(connectionOf('http://[::1]:3000/')).toBe('local') + }) + + it('reads through view-source to the page underneath', () => { + expect(connectionOf('view-source:https://example.com/')).toBe('secure') + expect(connectionOf('view-source:http://example.com/')).toBe('insecure') + }) + + it('classifies files, internal pages and nothing', () => { + expect(connectionOf('file:///home/me/notes.html')).toBe('file') + expect(connectionOf('about:blank')).toBe('internal') + expect(connectionOf('data:text/html,hi')).toBe('internal') + expect(connectionOf('chrome://gpu')).toBe('internal') + expect(connectionOf('')).toBe('none') + expect(connectionOf(undefined)).toBe('none') + expect(connectionOf('not a url')).toBe('none') + }) +}) From 1bc2a98826c926682bbac5242b169fbeb7945e0c Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:31:23 +0000 Subject: [PATCH 02/69] chrome: hide, reveal and resize the sidebar (SB-11, SB-12, SB-14, FL-02, FL-03) - Cmd+S slides the sidebar out as the page card widens (one 260 ms curve on the column and the card margins), reversible mid-way; it returns at the same width, scroll and selected tab; state is per window and persisted. - With the sidebar hidden, the left edge brings it in as a floating card in its own view over the page (the page never moves). It stays while hovered, while a field in it is edited or the command bar is open, goes 400 ms after the pointer moves on and at once on a click into the page (the click is passed on). macOS traffic lights follow the card. - Dragging the sidebar's edge resizes it 1:1 within 180-420 px with no drift; double-click resets; 40 px past the minimum hides it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 18 +- docs/feature-status.json | 10 +- src/main/commandbar/search.ts | 3 +- src/main/nav/index.ts | 16 + src/main/nav/sidebar-reveal.ts | 181 ++++++++ src/main/windows.ts | 254 ++++++++++- src/renderer/src/main.tsx | 8 + src/renderer/src/shell/ShellApp.tsx | 5 +- .../src/shell/chrome/FloatingSidebarApp.tsx | 107 +++++ src/renderer/src/shell/chrome/SidebarEdge.tsx | 37 ++ src/renderer/src/shell/chrome/chrome.css | 87 ++++ src/renderer/src/shell/sidebar/Sidebar.tsx | 118 ++++- src/renderer/src/shell/sidebar/TopBar.tsx | 6 +- src/renderer/src/styles/global.css | 51 ++- src/shared/commands/nav.ts | 13 + src/shared/rpc.ts | 2 +- tests/e2e/chrome.spec.ts | 426 +++++++++++++++++- tests/e2e/helpers.ts | 3 +- tests/e2e/nav-support.ts | 57 ++- 19 files changed, 1352 insertions(+), 50 deletions(-) create mode 100644 src/main/nav/sidebar-reveal.ts create mode 100644 src/renderer/src/shell/chrome/FloatingSidebarApp.tsx create mode 100644 src/renderer/src/shell/chrome/SidebarEdge.tsx diff --git a/FEATURES.md b/FEATURES.md index 9da40940..7265e6ff 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:** 485 spec’d · 118 built (43 partial) · 166 tested · 0 feel right. +**Totals:** 485 spec’d · 123 built (42 partial) · 172 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 88 | 112 | +| P0 | 174 | 93 | 118 | | P1 | 221 | 27 | 50 | | P2 | 83 | 3 | 4 | @@ -31,16 +31,16 @@ Spec: `spec/sidebar.md` | SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | – | – | | | SB-09 | Bottom bar: Library, Space switcher, "+" menu | P0 | 🟡 partial | ✅ e2e | – | switcher and + menu done; Library button arrives with the archive lane | | SB-10 | Transient trays above the bottom bar | P1 | – | – | – | | -| SB-11 | Show / hide the sidebar | P0 | 🟡 partial | – | – | scaffold | -| SB-12 | Hover to reveal the hidden sidebar | P0 | – | – | – | | +| SB-11 | Show / hide the sidebar | P0 | ✅ | ✅ e2e | – | Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); back at the same width, scroll and selected tab; per window, persisted; new windows show it | +| SB-12 | Hover to reveal the hidden sidebar | P0 | ✅ | ✅ e2e | – | left 6 px edge (moving pointer, 0 ms dwell) brings a floating card in its own view over the page, which never moves; stays while hovered (16 px band), while a field in it is edited or the command bar is open; 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 | | SB-13 | Feedback when things happen while the sidebar is hidden | P1 | – | – | – | | -| SB-14 | Resize the sidebar width | P0 | 🟡 partial | – | – | scaffold | +| SB-14 | Resize the sidebar width | P0 | ✅ | ✅ e2e | – | 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-15 | Narrow ("compact") sidebar rendering | P1 | – | – | – | | | SB-16 | Sidebar position (left / right) | P2 | – | – | – | | | SB-17 | Optional top toolbar | P1 | – | – | – | | | SB-18 | Developer-mode tab chrome | P2 | – | – | – | | -| SB-19 | Inset rounded page frame ("card" look) | P0 | 🟡 partial | – | – | scaffold | -| SB-20 | Top-of-window behaviour and window dragging | P1 | – | – | – | | +| SB-19 | Inset rounded page frame ("card" look) | P0 | 🟡 partial | ✅ e2e | – | scaffold | +| 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-21 | Dim the sidebar when the window is inactive | P1 | – | – | – | | | 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 | @@ -375,8 +375,8 @@ Spec: `spec/feel.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | FL-01 | Motion principles and shared tokens | P0 | – | ✅ unit | – | | -| FL-02 | Sidebar show/hide (docked toggle) | P0 | – | – | – | | -| FL-03 | Sidebar hover-reveal overlay | P0 | – | – | – | | +| FL-02 | Sidebar show/hide (docked toggle) | P0 | ✅ | ✅ e2e | – | 260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps | +| FL-03 | Sidebar hover-reveal overlay | P0 | ✅ | ✅ e2e | – | 0 ms dwell; card in 200 ms (snappy), out 160 ms (quick); hysteresis band; re-entering while it leaves brings it back | | FL-04 | Tab row hover | P0 | – | – | – | | | FL-05 | Tab selection | P0 | – | – | – | | | FL-06 | New-tab insertion | P0 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 50271c55..93d50a4c 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -7,9 +7,11 @@ "SB-06": {"built": "yes", "note": "title row: inline rename, hover profile badge and … menu (pinned caret is PN-09)"}, "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, "SB-09": {"built": "partial", "note": "switcher and + menu done; Library button arrives with the archive lane"}, - "SB-11": {"built": "partial", "note": "scaffold"}, - "SB-14": {"built": "partial", "note": "scaffold"}, + "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); back at the same width, scroll and selected tab; per window, persisted; new windows show it"}, + "SB-12": {"built": "yes", "note": "left 6 px edge (moving pointer, 0 ms dwell) brings a floating card in its own view over the page, which never moves; stays while hovered (16 px band), while a field in it is edited or the command bar is open; 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"}, + "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": "partial", "note": "scaffold"}, + "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-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"}, "SB-24": {"built": "partial", "note": "theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet"}, @@ -159,5 +161,7 @@ "NV-04": {"built": "partial", "note": "Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet"}, "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload and Stop keys done; find, zoom indicator and the rest come with their NV items"}, - "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-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-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps"}, + "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"} } diff --git a/src/main/commandbar/search.ts b/src/main/commandbar/search.ts index 8fa35b52..24bb8f98 100644 --- a/src/main/commandbar/search.ts +++ b/src/main/commandbar/search.ts @@ -157,7 +157,8 @@ export function registerSearch(ctx: MainContext, history: HistoryService): void // CB-20: left clicks only (the renderer just closes the bar for other buttons, review M5). commands.register('search.forwardClick', ({ x, y }, c) => { - const contents = windows.get(windowOf(c) ?? '')?.webContents + // To the sidebar where it is drawn: the floating card while it is out (SB-12), else the shell. + const contents = windows.sidebarContents(windowOf(c) ?? '') if (!contents || contents.isDestroyed() || !Number.isFinite(x) || !Number.isFinite(y)) return const at = { x: Math.round(x), y: Math.round(y) } contents.sendInputEvent({ type: 'mouseMove', ...at }) diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 65cf9230..13ca3a3c 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -8,6 +8,7 @@ import type { MainContext } from '../context' import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' import { activeTabOf } from '../windows' +import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' import { SitePrefs } from './site-prefs' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitNav. */ @@ -16,6 +17,14 @@ export interface NavTestHooks { menu: () => { windowId: WindowId; direction: NavDirection; labels: string[]; icons: boolean[] } | null /** Chooses an item of the open history menu by label; `newTab` is a ⌘-click (Ctrl-click). */ chooseMenu: (label: string, newTab?: boolean) => boolean + /** The floating sidebar of a window (default: the first): its view, and what keeps it out (SB-12). */ + floating: (windowId?: WindowId) => { + exists: boolean + placed: boolean + shown: boolean + bounds?: { x: number; y: number; width: number; height: number } + hold: RevealHoldInfo | null + } } interface ShownMenu { @@ -238,6 +247,9 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { ctx.toast(windowOf(c), `Tip: ${formatAccelerator(accel, process.platform)} copies the page's link`) }) + // The hidden sidebar's hover reveal (SB-12, FL-03). + const reveal = registerSidebarReveal(ctx) + // Placeholder until the site controls panel (NV-16) lands. actions.register('site.controls', (c) => ctx.toast(windowOf(c), 'Site controls are coming soon')) @@ -275,6 +287,10 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { choose(menu, entry.index, newTab) return true }, + floating: (windowId) => { + const id = windowId ?? windows.ids()[0] ?? '' + return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } + }, } ;(globalThis as unknown as { __orbitNav: NavTestHooks }).__orbitNav = hooks } diff --git a/src/main/nav/sidebar-reveal.ts b/src/main/nav/sidebar-reveal.ts new file mode 100644 index 00000000..267eaf9d --- /dev/null +++ b/src/main/nav/sidebar-reveal.ts @@ -0,0 +1,181 @@ +import type { MouseInputEvent } from 'electron' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { CallContext } from '../registry' + +/** The pointer this close to the window's left edge reveals the hidden sidebar (FL-03: a 4–8 px band). */ +export const REVEAL_EDGE = 6 +/** The floating sidebar stays this long after the pointer has left it (FL-03: 300–500 ms). */ +export const HIDE_GRACE_MS = 400 + +/** What keeps the floating sidebar out. */ +export interface RevealHold { + /** The pointer is over the card, or just beside it. */ + over: boolean + /** A text field in the card has focus (a rename, the new-Space flow). */ + editing: boolean + /** Something opened over the window while the card was out (the command bar, a panel). */ + overlay: boolean +} + +interface Hold extends RevealHold { + timer?: NodeJS.Timeout +} + +/** A window's hold as tests see it: `hiding` while the card's grace delay runs. */ +export interface RevealHoldInfo extends RevealHold { + hiding: boolean +} + +const buttonDown = (mouse: MouseInputEvent) => (mouse.modifiers ?? []).some((m) => /buttondown$/i.test(m)) + +/** + * The hidden sidebar's hover reveal (SB-12, FL-03). When the pointer reaches the window's left edge + * the sidebar slides in over the page as a floating card (`sidebar.peeking`; the window manager + * places its view and the page never moves). It stays while the pointer is over it, a text field in + * it is being edited or something it opened floats over the window, and slides away shortly after + * the pointer moves on; a click into the page sends it away at once. + * + * Where the pointer is comes from the card itself (it enters, or leaves through the window's edge) + * and from the pages (it moves or clicks there). A native menu opened from the card holds the + * pointer, so nothing arrives from the pages and the card stays while the menu is up. + */ +export function registerSidebarReveal(ctx: MainContext): { + hold: (windowId: WindowId) => RevealHoldInfo | undefined +} { + const { store, commands, windows } = ctx + const holds = new Map<WindowId, Hold>() + const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() + + const holdOf = (windowId: WindowId): Hold => { + let hold = holds.get(windowId) + if (!hold) { + hold = { over: true, editing: false, overlay: false } + holds.set(windowId, hold) + } + return hold + } + + const setPeeking = (windowId: WindowId, peeking: boolean) => + store.update((d) => { + const w = d.windows[windowId] + if (w && !w.sidebar.visible && w.sidebar.peeking !== peeking) w.sidebar.peeking = peeking + }) + + const reveal = (windowId: WindowId) => { + const win = store.state.windows[windowId] + if (win?.kind !== 'main' || win.sidebar.visible) return + if (win.sidebar.peeking) { + const hold = holdOf(windowId) + hold.over = true + settle(windowId) + return + } + // Nothing summons it while something else floats over the window (the command bar, the site + // controls) or a video fills it. + if (win.overlay.kind !== 'none' || windows.inHtmlFullscreen(windowId)) return + clearTimeout(holds.get(windowId)?.timer) + holds.set(windowId, { over: true, editing: false, overlay: false }) + setPeeking(windowId, true) + } + + const hideNow = (windowId: WindowId) => { + clearTimeout(holds.get(windowId)?.timer) + holds.delete(windowId) + setPeeking(windowId, false) + } + + /** Once nothing holds the card, it slides away after the grace delay. */ + const settle = (windowId: WindowId) => { + const hold = holdOf(windowId) + clearTimeout(hold.timer) + hold.timer = undefined + if (!store.state.windows[windowId]?.sidebar.peeking || hold.over || hold.editing || hold.overlay) return + hold.timer = setTimeout(() => { + hold.timer = undefined + if (!hold.over && !hold.editing && !hold.overlay) setPeeking(windowId, false) + }, HIDE_GRACE_MS) + } + + store.subscribe((next) => { + for (const [windowId, win] of Object.entries(next.windows)) { + if (!win.sidebar.peeking || win.sidebar.visible) { + const hold = holds.get(windowId) + if (hold) { + clearTimeout(hold.timer) + holds.delete(windowId) + } + continue + } + // Opening the command bar or a panel from the card keeps it out until that closes (SB-12). + const hold = holdOf(windowId) + const floating = win.overlay.kind !== 'none' + if (floating !== hold.overlay) { + hold.overlay = floating + settle(windowId) + } + } + for (const windowId of holds.keys()) if (!next.windows[windowId]) holds.delete(windowId) + }) + + /** Pointer input over a page: it left the card, clicked into the page, or (full screen) reached the edge. */ + const onPageMouse = (tabId: TabId, mouse: MouseInputEvent) => { + if (mouse.type !== 'mouseMove' && mouse.type !== 'mouseDown') return + const windowId = windows.windowOfTab(tabId) + const win = windowId ? store.state.windows[windowId] : undefined + if (!windowId || !win || win.sidebar.visible) return + if (win.sidebar.peeking) { + if (mouse.type === 'mouseDown') { + hideNow(windowId) + return + } + const hold = holdOf(windowId) + if (hold.over) { + hold.over = false + settle(windowId) + } + return + } + // In full screen the page reaches the window's left edge, so the edge is in the page. + if (mouse.type !== 'mouseMove' || buttonDown(mouse)) return + const bounds = windows.tabViewBounds(tabId) + if (bounds && bounds.x + mouse.x < REVEAL_EDGE) reveal(windowId) + } + ctx.hooks.tabCreated.push((tabId, contents) => { + contents.on('before-mouse-event', (_event, mouse) => onPageMouse(tabId, mouse)) + }) + + commands.register('sidebar.reveal', (_args, c) => { + const windowId = windowOf(c) + if (windowId) reveal(windowId) + }) + commands.register('sidebar.peekState', ({ over, editing }, c) => { + const windowId = windowOf(c) + if (!windowId || !store.state.windows[windowId]?.sidebar.peeking) return + const hold = holdOf(windowId) + if (typeof over === 'boolean') hold.over = over + if (typeof editing === 'boolean') hold.editing = editing + settle(windowId) + }) + commands.register('sidebar.peekDismiss', ({ x, y, button }, c) => { + const windowId = windowOf(c) + if (!windowId) return + hideNow(windowId) + if (!Number.isFinite(x) || !Number.isFinite(y) || !['left', 'middle', 'right'].includes(button)) return + const page = windows.pageAt(windowId, x, y) + if (!page || page.contents.isDestroyed()) return + const at = { x: Math.round(page.x), y: Math.round(page.y) } + page.contents.sendInputEvent({ type: 'mouseMove', ...at }) + page.contents.sendInputEvent({ type: 'mouseDown', ...at, button, clickCount: 1 }) + page.contents.sendInputEvent({ type: 'mouseUp', ...at, button, clickCount: 1 }) + }) + + return { + hold: (windowId) => { + const hold = holds.get(windowId) + return hold + ? { over: hold.over, editing: hold.editing, overlay: hold.overlay, hiding: hold.timer !== undefined } + : undefined + }, + } +} diff --git a/src/main/windows.ts b/src/main/windows.ts index 6e93b4fd..0bc59f47 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1,9 +1,9 @@ import { join } from 'node:path' import { BrowserWindow, nativeTheme, screen, type WebContents, WebContentsView, webContents } from 'electron' import { actionForInput, outsideTextActionFor } from '../shared/actions' -import { createWindowState } from '../shared/defaults' +import { createWindowState, DEFAULT_SIDEBAR_WIDTH } from '../shared/defaults' import type { Rect, RendererView } from '../shared/rpc' -import type { ColorMode, OrbitState, SpaceId, TabId, WindowId, WindowState } from '../shared/state' +import type { ColorMode, OrbitState, SidebarState, SpaceId, TabId, WindowId, WindowState } from '../shared/state' import { paletteFor } from '../shared/theme' import { splitLeaves, splitOfTab } from '../shared/tree' import type { MainContext } from './context' @@ -37,6 +37,21 @@ interface WindowRecord { refocusPage?: boolean /** Tab whose page is in HTML fullscreen (video): its view fills the window. */ htmlFullscreen?: TabId + /** + * The hidden sidebar's floating card (SB-12): its own view, made once the sidebar is hidden and + * placed in the window only while the card is out (or sliding away). + */ + floating?: WebContentsView + /** The floating card is out (`sidebar.peeking`). */ + floatingShown: boolean + /** Pending removal of the floating view after its card slid away. */ + floatingTimer?: ReturnType<typeof setTimeout> + /** Pending start of the floating view's renderer, once the sidebar's hide animation is over. */ + floatingPrewarm?: ReturnType<typeof setTimeout> + /** Pending release of the floating view's renderer after the sidebar stayed docked a while. */ + floatingDispose?: ReturnType<typeof setTimeout> + /** macOS: pending return of the traffic lights once a docking sidebar has slid in. */ + buttonsTimer?: ReturnType<typeof setTimeout> } /** A tab view placed in a window. */ @@ -50,6 +65,23 @@ interface Attachment { /** How long the overlay view stays up after closing so its exit animation can play (FL-16). */ const OVERLAY_EXIT_MS = 110 +/** The floating sidebar's card sits this far from the window's edges (SB-12, SB-19). */ +export const FLOAT_INSET = 6 +/** macOS: where the traffic lights sit in the docked sidebar's top row (SB-03, SB-20). */ +const TRAFFIC_LIGHTS = { x: 16, y: 17 } +/** Room right of the floating card for its shadow; the pointer there still counts as over it (FL-03). */ +export const FLOAT_BAND = 16 +/** The docked sidebar's slide (FL-02): the shell's `--sidebar-dur`. */ +const SIDEBAR_SLIDE_MS = 260 +/** The floating view stays up this long while its card slides away (FL-03: quick timing, 160 ms)… */ +const FLOAT_EXIT_MS = 200 +/** …and this long when the sidebar docks, while the docked one slides in beneath it. */ +const FLOAT_DOCK_MS = SIDEBAR_SLIDE_MS + 20 +/** The floating view's renderer starts once the docked sidebar has slid away (not during the slide). */ +const FLOAT_PREWARM_MS = SIDEBAR_SLIDE_MS +/** …and is let go after the sidebar has stayed docked this long. */ +const FLOAT_DISPOSE_MS = 60_000 + const isMac = process.platform === 'darwin' /** Isolated world for Orbit's own small page scripts (the page can't tamper with them). */ const EXIT_FULLSCREEN_WORLD = 1001 @@ -249,7 +281,7 @@ export class WindowManager { show: false, title: 'Orbit', backgroundColor: windowBackground(this.ctx.store.state, winState.spaceId) ?? '#e9eaf5', - ...(isMac ? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 17 } } : { frame: false }), + ...(isMac ? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: TRAFFIC_LIGHTS } : { frame: false }), webPreferences: { ...uiWebPreferences(id, 'shell'), backgroundThrottling: false }, }) if (winState.maximized) win.maximize() @@ -266,8 +298,11 @@ export class WindowManager { radius: 10, minPaneSeq: 0, overlayShown: false, + floatingShown: false, } this.records.set(id, rec) + // macOS: the traffic lights live in the sidebar's top row (SB-20). + if (isMac && !winState.sidebar.visible) win.setWindowButtonVisibility(false) win.once('ready-to-show', () => { this.ctx.perf.mark(`window:${id}:ready-to-show`) @@ -276,6 +311,7 @@ export class WindowManager { setTimeout(() => { if (!win.isDestroyed()) this.ensureOverlay(rec) }, 120) + if (!this.ctx.store.state.windows[id]?.sidebar.visible) this.prewarmFloating(rec, 600) }) win.on('focus', () => { this.lastFocused = id @@ -296,6 +332,7 @@ export class WindowManager { }) win.on('resize', () => { if (rec.overlayShown) this.fitOverlay(rec) + if (rec.floatingShown) this.fitFloating(rec) if (rec.htmlFullscreen) this.applyPanes(id, rec.panes, rec.radius) }) const saveBounds = () => { @@ -327,6 +364,11 @@ export class WindowManager { } win.on('closed', () => { clearTimeout(rec.overlayHideTimer) + clearTimeout(rec.floatingTimer) + clearTimeout(rec.floatingPrewarm) + clearTimeout(rec.floatingDispose) + clearTimeout(rec.buttonsTimer) + if (rec.floating && !rec.floating.webContents.isDestroyed()) rec.floating.webContents.close() this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { if (entry.windowId === id) { @@ -394,10 +436,67 @@ export class WindowManager { sendToWindow(windowId: WindowId, channel: string, payload: unknown): void { const rec = this.records.get(windowId) if (!rec) return - if (!rec.win.isDestroyed()) rec.win.webContents.send(`orbit:${channel}`, payload) + // While the floating sidebar is out, what the sidebar shows (a rename field, the new-Space + // flow, an undo toast) happens there and not in the hidden, docked one. + const floating = this.floatingContents(windowId) + const forSidebar = channel.startsWith('sidebar:') || channel.startsWith('spaces:') + if (floating && forSidebar) floating.send(`orbit:${channel}`, payload) + else if (!rec.win.isDestroyed()) rec.win.webContents.send(`orbit:${channel}`, payload) if (rec.overlay && !rec.overlay.webContents.isDestroyed()) rec.overlay.webContents.send(`orbit:${channel}`, payload) } + /** The floating sidebar's web contents while its card is out (SB-12). */ + floatingContents(windowId: WindowId): WebContents | undefined { + const rec = this.records.get(windowId) + const contents = rec?.floatingShown ? rec.floating?.webContents : undefined + return contents && !contents.isDestroyed() ? contents : undefined + } + + /** Where Orbit's sidebar is drawn: the floating card while it is out, else the window's shell. */ + sidebarContents(windowId: WindowId): WebContents | undefined { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed()) return undefined + return this.floatingContents(windowId) ?? rec.win.webContents + } + + /** True while a page of the window is in HTML full screen (a video). */ + inHtmlFullscreen(windowId: WindowId): boolean { + return !!this.records.get(windowId)?.htmlFullscreen + } + + /** A tab view's place in its window (window coordinates), while attached. */ + tabViewBounds(tabId: TabId): Rect | undefined { + const entry = this.attached.get(tabId) + if (!entry || !this.records.get(entry.windowId)?.visible.has(tabId)) return undefined + return entry.view.getBounds() + } + + /** The page under a point of a window (window coordinates), with the point in the page's coordinates. */ + pageAt(windowId: WindowId, x: number, y: number): { contents: WebContents; x: number; y: number } | undefined { + const rec = this.records.get(windowId) + if (!rec) return undefined + for (const tabId of rec.visible) { + const b = this.tabViewBounds(tabId) + const contents = this.liveContents(tabId) + if (!b || !contents) continue + if (x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height) return { contents, x: x - b.x, y: y - b.y } + } + return undefined + } + + /** Test/debug view of a window's floating sidebar. */ + floatingInfo(windowId: WindowId): { exists: boolean; placed: boolean; shown: boolean; bounds?: Rect } { + const rec = this.records.get(windowId) + const view = rec?.floating + if (!rec || !view || view.webContents.isDestroyed()) return { exists: false, placed: false, shown: false } + return { + exists: true, + placed: rec.win.contentView.children.includes(view), + shown: rec.floatingShown, + bounds: view.getBounds(), + } + } + /** Positions tab views where the shell says panes are; hides tabs no longer shown. */ applyPanes(windowId: WindowId, panes: Pane[], radius: number): void { const rec = this.records.get(windowId) @@ -469,6 +568,13 @@ export class WindowManager { const rec = entry?.contentsId === contentsId ? this.records.get(entry.windowId) : undefined if (!rec) return rec.htmlFullscreen = tabId + // A floating sidebar never covers a full-screen video (SB-12). + if (this.ctx.store.state.windows[rec.id]?.sidebar.peeking) { + this.ctx.store.update((d) => { + const w = d.windows[rec.id] + if (w) w.sidebar.peeking = false + }) + } this.applyPanes(rec.id, rec.panes, rec.radius) return } @@ -639,7 +745,9 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - const overlayAt = rec.overlay ? children.indexOf(rec.overlay) : -1 + // The overlay and the floating sidebar (SB-12) stay above every page. + const above = [rec.overlay, rec.floating].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const overlayAt = above.length > 0 ? Math.min(...above) : -1 if (overlayAt < 0) { if (at < 0 || at !== children.length - 1) parent.addChildView(view) return @@ -695,6 +803,7 @@ export class WindowManager { if (now.overlay.kind === 'none') this.hideOverlay(rec) else this.showOverlay(rec) } + if (now.sidebar !== before?.sidebar) this.onSidebar(rec, now.sidebar, before?.sidebar) const tabNow = activeTabOf(next, rec.id) const tabBefore = before ? activeTabOf(prev, rec.id) : null if (tabNow !== tabBefore) { @@ -751,6 +860,141 @@ export class WindowManager { if (tabId && !rec.overlayShown) this.liveContents(tabId)?.focus() } + /** + * The sidebar was hidden, docked, revealed floating or resized (SB-11, SB-12, SB-14). The floating + * card's renderer starts once the sidebar is hidden, so the first reveal is quick, and goes again + * after the sidebar has stayed docked for a while. + */ + private onSidebar(rec: WindowRecord, now: SidebarState, before: SidebarState | undefined): void { + if (now.visible !== before?.visible || now.peeking !== before?.peeking) this.applyWindowButtons(rec, now, before) + if (now.visible) { + clearTimeout(rec.floatingPrewarm) + rec.floatingPrewarm = undefined + if (rec.floating && !rec.floatingDispose) { + rec.floatingDispose = setTimeout(() => this.disposeFloating(rec), FLOAT_DISPOSE_MS) + } + } else { + clearTimeout(rec.floatingDispose) + rec.floatingDispose = undefined + if (!now.peeking) this.prewarmFloating(rec, FLOAT_PREWARM_MS) + } + if (now.peeking && !before?.peeking) this.showFloating(rec) + else if (!now.peeking && before?.peeking) this.hideFloating(rec, now.visible ? FLOAT_DOCK_MS : FLOAT_EXIT_MS) + else if (now.peeking && now.width !== before?.width) this.fitFloating(rec) + } + + /** + * macOS: the traffic lights belong to the sidebar's top row. While it is hidden they show only + * with the floating card, moved in with it (the card sits inset from the window's corner) (SB-20). + */ + private applyWindowButtons(rec: WindowRecord, now: SidebarState, before: SidebarState | undefined): void { + if (!isMac || rec.win.isDestroyed()) return + clearTimeout(rec.buttonsTimer) + rec.buttonsTimer = undefined + const later = (ms: number, fn: () => void) => { + rec.buttonsTimer = setTimeout(() => { + rec.buttonsTimer = undefined + if (!rec.win.isDestroyed()) fn() + }, ms) + } + const docked = () => rec.win.setWindowButtonPosition(TRAFFIC_LIGHTS) + if (now.peeking) { + rec.win.setWindowButtonPosition({ x: TRAFFIC_LIGHTS.x + FLOAT_INSET, y: TRAFFIC_LIGHTS.y + FLOAT_INSET }) + rec.win.setWindowButtonVisibility(true) + } else if (!now.visible) { + rec.win.setWindowButtonVisibility(false) + docked() + } else if (before?.peeking) { + // Docked from the floating card: they move over once the card has gone. + later(FLOAT_DOCK_MS, docked) + } else if (before && !before.visible) { + // Shown: they appear once the sidebar has slid in under them. + docked() + later(SIDEBAR_SLIDE_MS, () => rec.win.setWindowButtonVisibility(true)) + } else { + docked() + rec.win.setWindowButtonVisibility(true) + } + } + + private prewarmFloating(rec: WindowRecord, delay: number): void { + if (rec.floating || rec.floatingPrewarm) return + rec.floatingPrewarm = setTimeout(() => { + rec.floatingPrewarm = undefined + const sidebar = this.ctx.store.state.windows[rec.id]?.sidebar + if (!rec.win.isDestroyed() && sidebar && !sidebar.visible) this.ensureFloating(rec) + }, delay) + } + + /** The floating sidebar's view (SB-12): a transparent view at the window's left edge. */ + private ensureFloating(rec: WindowRecord): WebContentsView { + if (rec.floating && !rec.floating.webContents.isDestroyed()) return rec.floating + clearTimeout(rec.floatingPrewarm) + rec.floatingPrewarm = undefined + const view = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'sidebar') }) + view.setBackgroundColor('#00000000') + this.registerUi(view.webContents, rec.id) + this.reloadUiOnCrash(view.webContents, 'sidebar') + this.attachShortcuts(view.webContents) + this.hardenUi(view.webContents) + loadRenderer(view.webContents, 'sidebar') + rec.floating = view + return view + } + + /** Puts the floating sidebar above the pages (below the overlay); its card slides in (FL-03). */ + private showFloating(rec: WindowRecord): void { + if (rec.win.isDestroyed()) return + clearTimeout(rec.floatingTimer) + rec.floatingTimer = undefined + const view = this.ensureFloating(rec) + rec.floatingShown = true + this.fitFloating(rec) + const parent = rec.win.contentView + const children = parent.children + const at = children.indexOf(view) + const overlayAt = rec.overlay ? children.indexOf(rec.overlay) : -1 + if (overlayAt < 0) { + if (at !== children.length - 1) parent.addChildView(view) + } else if (at !== overlayAt - 1) { + parent.addChildView(view, at >= 0 && at < overlayAt ? overlayAt - 1 : overlayAt) + } + view.setVisible(true) + } + + /** The card slides away (or stays while the docked sidebar slides in), then the view leaves the window. */ + private hideFloating(rec: WindowRecord, afterMs: number): void { + rec.floatingShown = false + const view = rec.floating + if (!view || view.webContents.isDestroyed()) return + // Keyboard focus in the card goes back to the page. + if (webContents.getFocusedWebContents() === view.webContents && !rec.overlayShown) this.focusActiveTab(rec.id) + clearTimeout(rec.floatingTimer) + rec.floatingTimer = setTimeout(() => { + rec.floatingTimer = undefined + if (!rec.floatingShown && !rec.win.isDestroyed()) this.removeChild(rec, view) + }, afterMs) + } + + private fitFloating(rec: WindowRecord): void { + const view = rec.floating + if (!view || rec.win.isDestroyed()) return + const [width = 0, height = 0] = rec.win.getContentSize() + const sidebar = this.ctx.store.state.windows[rec.id]?.sidebar.width ?? DEFAULT_SIDEBAR_WIDTH + view.setBounds({ x: 0, y: 0, width: Math.min(width, FLOAT_INSET + sidebar + FLOAT_BAND), height }) + } + + private disposeFloating(rec: WindowRecord): void { + rec.floatingDispose = undefined + const view = rec.floating + if (!view || rec.floatingShown || !this.ctx.store.state.windows[rec.id]?.sidebar.visible) return + clearTimeout(rec.floatingTimer) + rec.floatingTimer = undefined + rec.floating = undefined + if (!rec.win.isDestroyed()) this.removeChild(rec, view) + if (!view.webContents.isDestroyed()) view.webContents.close() + } + /** The transparent overlay view (command bar, Peek chrome, menus), created on first need. */ private ensureOverlay(rec: WindowRecord): WebContentsView { if (rec.overlay) return rec.overlay diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6009a72a..82ab6f58 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -21,6 +21,14 @@ async function boot(): Promise<void> { <OverlayApp /> </StrictMode>, ) + } else if (view === 'sidebar') { + // The hidden sidebar's floating card (chrome lane, SB-12): a transparent view at the window's left edge. + const { FloatingSidebarApp } = await import('./shell/chrome/FloatingSidebarApp') + root.render( + <StrictMode> + <FloatingSidebarApp /> + </StrictMode>, + ) } else if (view === 'strip') { // Split view pane strip (split lane, SV-12): a tiny transparent view over a pane's top edge. const { PaneStripApp } = await import('./shell/split/PaneStripApp') diff --git a/src/renderer/src/shell/ShellApp.tsx b/src/renderer/src/shell/ShellApp.tsx index 125b34ec..4578c07a 100644 --- a/src/renderer/src/shell/ShellApp.tsx +++ b/src/renderer/src/shell/ShellApp.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from 'react' import { useWindow } from '../store' import { useShellTheme } from '../theme' import { ContentArea } from './ContentArea' +import { SidebarEdge } from './chrome/SidebarEdge' import { Sidebar } from './sidebar/Sidebar' import { Toasts } from './Toasts' @@ -11,10 +12,11 @@ export function ShellApp() { // The Space theme is painted as CSS variables by the theme driver (no re-render per frame). const theme = useShellTheme(win?.spaceId) if (!win) return null + const hidden = !win.sidebar.visible const style = { '--sidebar-w': `${win.sidebar.width}px` } as CSSProperties return ( <div - className={`shell${win.sidebar.visible ? '' : ' shell--sidebar-hidden'}${win.focused ? '' : ' shell--blurred'}`} + className={`shell${hidden ? ' shell--sidebar-hidden' : ''}${win.focused ? '' : ' shell--blurred'}`} style={style} ref={theme.shellRef} data-testid="shell" @@ -24,6 +26,7 @@ export function ShellApp() { </div> <Sidebar win={win} /> <ContentArea win={win} /> + {hidden && win.kind === 'main' && <SidebarEdge />} <Toasts /> </div> ) diff --git a/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx b/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx new file mode 100644 index 00000000..bc6e47e6 --- /dev/null +++ b/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx @@ -0,0 +1,107 @@ +import { type CSSProperties, useEffect } from 'react' +import { invoke } from '../../api' +import { useWindow } from '../../store' +import { useShellTheme } from '../../theme' +import { Sidebar } from '../sidebar/Sidebar' +import './chrome.css' + +/** + * The hidden sidebar's floating card (SB-12, FL-03). Pages are native views above the shell, so the + * card lives in a transparent view of its own at the window's left edge, which main places while the + * card is out. It holds everything the docked sidebar has, in the Space's theme, and slides in and + * out on `sidebar.peeking`. + */ +export function FloatingSidebarApp() { + const win = useWindow() + const theme = useShellTheme(win?.spaceId) + usePointerReports(!!win?.sidebar.peeking) + if (!win) return null + // Docking from here (⌘S): the card stays put while the docked sidebar slides in beneath it. + const out = win.sidebar.peeking || win.sidebar.visible + const style = { '--sidebar-w': `${win.sidebar.width}px` } as CSSProperties + return ( + <div + className={`shell float-sidebar${out ? ' is-out' : ''}${win.focused ? '' : ' shell--blurred'}`} + style={style} + ref={theme.shellRef} + data-testid="floating-sidebar" + data-out={out ? 'true' : 'false'} + onMouseDown={(e) => { + // Beside the card is the page: the card goes at once and the click goes on to the page. + if (e.target !== e.currentTarget) return + e.preventDefault() + const button = e.button === 1 ? 'middle' : e.button === 2 ? 'right' : 'left' + void invoke('sidebar.peekDismiss', { x: e.clientX, y: e.clientY, button }) + }} + > + <div className="float-sidebar__slide"> + <div className="float-sidebar__shadow" aria-hidden /> + <div className="float-sidebar__card"> + <div className="shell-bg" ref={theme.backgroundRef} aria-hidden> + <div className="shell-bg__grain" /> + </div> + <Sidebar win={win} floating /> + </div> + </div> + </div> + ) +} + +/** How often a pointer moving over the card says so again (well inside main's grace delay). */ +const REASSERT_MS = 250 + +const isEditable = (el: Element | null) => + el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement | null)?.isContentEditable + +/** + * Tells main whether the pointer is over the card (or just beside it) and whether a text field in it + * has focus. The view reaches the window's top, left and bottom edges and ends beside the card, so + * the pointer leaves it through an edge; a "leave" inside it means a menu or the command bar came up + * over it, which says nothing about where the pointer is (main hears from the page once it moves on). + */ +function usePointerReports(peeking: boolean) { + useEffect(() => { + if (!peeking) return + let over: boolean | null = null + let editing: boolean | null = null + let said = 0 + const setOver = (value: boolean) => { + // While the pointer moves over the card, say so now and then: main may have heard otherwise. + const now = performance.now() + if (value === over && (!value || now - said < REASSERT_MS)) return + over = value + said = now + void invoke('sidebar.peekState', { over: value }) + } + const setEditing = (value: boolean) => { + if (value === editing) return + editing = value + void invoke('sidebar.peekState', { editing: value }) + } + const onMove = () => setOver(true) + const onOut = (e: MouseEvent) => { + if (e.relatedTarget || e.buttons) return + const atEdge = e.clientX <= 0 || e.clientY <= 0 || e.clientX >= innerWidth - 1 || e.clientY >= innerHeight - 1 + if (atEdge) setOver(false) + else over = null + } + let focusTimer = 0 + const onFocus = () => { + clearTimeout(focusTimer) + // After a blur, focus lands on the next element a moment later. + focusTimer = window.setTimeout(() => setEditing(!!isEditable(document.activeElement)), 0) + } + document.addEventListener('mousemove', onMove, { passive: true }) + document.addEventListener('mouseout', onOut) + document.addEventListener('focusin', onFocus) + document.addEventListener('focusout', onFocus) + onFocus() + return () => { + clearTimeout(focusTimer) + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseout', onOut) + document.removeEventListener('focusin', onFocus) + document.removeEventListener('focusout', onFocus) + } + }, [peeking]) +} diff --git a/src/renderer/src/shell/chrome/SidebarEdge.tsx b/src/renderer/src/shell/chrome/SidebarEdge.tsx new file mode 100644 index 00000000..a9cccfe3 --- /dev/null +++ b/src/renderer/src/shell/chrome/SidebarEdge.tsx @@ -0,0 +1,37 @@ +import { type MouseEvent, useEffect, useRef } from 'react' +import { invoke } from '../../api' + +/** + * The window's left edge while the sidebar is hidden (SB-12, FL-03): the pointer reaching it brings + * the sidebar in as a floating card, at once. Only a pointer that moves counts: one resting there + * when the sidebar is hidden (⌘S with the pointer at the edge) doesn't bring it straight back, and + * neither does a drag passing by (a text selection, a tab). + */ +export function SidebarEdge() { + const last = useRef(0) + useLeavingWindow() + const onMove = (e: MouseEvent) => { + if (e.buttons || (e.movementX === 0 && e.movementY === 0)) return + const now = performance.now() + if (now - last.current < 100) return + last.current = now + void invoke('sidebar.reveal', {}) + } + return <div className="sidebar-edge" data-testid="sidebar-edge" aria-hidden onMouseMove={onMove} /> +} + +/** + * A pointer passing through the edge on its way out of the window (to a screen on the left) brings + * the card in after it has gone, so the card never hears of it: the shell tells main it left. + */ +function useLeavingWindow() { + useEffect(() => { + const onOut = (e: globalThis.MouseEvent) => { + if (e.relatedTarget || e.buttons) return + const outside = e.clientX < 0 || e.clientY < 0 || e.clientX >= innerWidth || e.clientY >= innerHeight + if (outside) void invoke('sidebar.peekState', { over: false }) + } + document.addEventListener('mouseout', onOut) + return () => document.removeEventListener('mouseout', onOut) + }, []) +} diff --git a/src/renderer/src/shell/chrome/chrome.css b/src/renderer/src/shell/chrome/chrome.css index 2fe3cb7a..40745556 100644 --- a/src/renderer/src/shell/chrome/chrome.css +++ b/src/renderer/src/shell/chrome/chrome.css @@ -114,3 +114,90 @@ .shell[data-dark] .address--insecure .address__site { color: #ff8a7d; } + +/* ---------- Hidden sidebar: the left edge and the floating card (SB-12, FL-03) ---------- */ + +/* The window's left edge while the sidebar is hidden: the page card's margin there. */ +.sidebar-edge { + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: var(--gutter-hidden); + z-index: 30; + -webkit-app-region: no-drag; +} + +/* + * The floating card's own view (view=sidebar): transparent, at the window's left edge, as wide as + * the card plus its margin and a 16 px band beside it for the shadow (the pointer there still + * counts as over the card). + */ +html[data-view="sidebar"] body { + background: transparent; +} + +.shell.float-sidebar { + position: fixed; + inset: 0; + display: block; + background: none; + transition: none; +} + +.float-sidebar__slide { + position: absolute; + top: var(--gutter-hidden); + bottom: var(--gutter-hidden); + left: var(--gutter-hidden); + width: var(--sidebar-w); + /* Out: the quick timing (FL-01, FL-03). */ + transform: translateX(calc(-100% - var(--gutter-hidden) - 16px)); + transition: transform 160ms cubic-bezier(0.2, 0, 0, 1); + will-change: transform; +} + +.float-sidebar.is-out .float-sidebar__slide { + transform: none; + /* In: the snappy timing, no overshoot (structure doesn't bounce). */ + transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +/* The shadow fades in with the slide. */ +.float-sidebar__shadow { + position: absolute; + inset: 0; + border-radius: var(--radius-frame); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.14), + 0 6px 14px -2px rgba(0, 0, 0, 0.22); + opacity: 0; + transition: opacity 160ms cubic-bezier(0.2, 0, 0, 1); +} + +.float-sidebar.is-out .float-sidebar__shadow { + opacity: 1; + transition: opacity 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.float-sidebar__card { + position: absolute; + inset: 0; + isolation: isolate; + overflow: hidden; + border-radius: var(--radius-frame); +} + +.sidebar.sidebar--floating { + width: 100%; + height: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .float-sidebar__slide, + .float-sidebar__shadow, + .float-sidebar.is-out .float-sidebar__slide, + .float-sidebar.is-out .float-sidebar__shadow { + transition: none; + } +} diff --git a/src/renderer/src/shell/sidebar/Sidebar.tsx b/src/renderer/src/shell/sidebar/Sidebar.tsx index 729ac432..58f650cf 100644 --- a/src/renderer/src/shell/sidebar/Sidebar.tsx +++ b/src/renderer/src/shell/sidebar/Sidebar.tsx @@ -1,4 +1,10 @@ -import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, useRef } from 'react' +import { + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + useLayoutEffect, + useRef, +} from 'react' +import { DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from '../../../../shared/defaults' import type { WindowState } from '../../../../shared/state' import { invoke } from '../../api' import { useOrbit } from '../../store' @@ -10,24 +16,25 @@ import { TopBar } from './TopBar' /** * The vertical sidebar: top bar and address, then the per-Space strip * (Favorites, title row, pinned, Today; swipeable between Spaces), then the - * Space switcher. Space content lives in ../spaces/. + * Space switcher. Space content lives in ../spaces/. `floating` is the hidden + * sidebar's card shown over the page (SB-12), which has no resize edge. */ -export function Sidebar({ win }: { win: WindowState }) { +export function Sidebar({ win, floating = false }: { win: WindowState; floating?: boolean }) { const exists = useOrbit((s) => !!s.spaces[win.spaceId]) if (!exists) return null return ( <aside - className="sidebar" + className={`sidebar${floating ? ' sidebar--floating' : ''}`} data-testid="sidebar" - aria-hidden={!win.sidebar.visible} + aria-hidden={!floating && !win.sidebar.visible} onMouseUp={onSideButtons} onAuxClick={preventSideButtons} > - <TopBar win={win} /> + <TopBar win={win} floating={floating} /> <SpaceStrip windowId={win.id} spaceId={win.spaceId} /> <UndoToast /> <SpaceBar win={win} /> - <ResizeHandle width={win.sidebar.width} /> + {!floating && <ResizeHandle win={win} />} </aside> ) } @@ -43,20 +50,92 @@ function preventSideButtons(e: ReactMouseEvent) { if (e.button === 3 || e.button === 4) e.preventDefault() } -function ResizeHandle({ width }: { width: number }) { - const start = useRef<{ x: number; width: number } | null>(null) +/** Dragging this far left past the minimum width hides the sidebar (SB-14). */ +export const COLLAPSE_PAST = 40 + +const clampWidth = (w: number) => Math.round(Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, w))) + +interface Drag { + x: number + /** The width when the drag began: the width follows the pointer's distance from there (no drift). */ + width: number + live: number + collapsed: boolean + shell: HTMLElement | null +} + +/** + * The sidebar's right edge (SB-14). Dragging it sets the width 1:1 with the pointer (the shell + * shows the drag's width at once, `--sidebar-live-w`, with no animation), clamped to the limits; + * dragging on 40 px past the minimum hides the sidebar as ⌘S does, and back out shows it again. + * The window's width is saved when the drag ends; a drag that hid the sidebar keeps the width it + * had, so it comes back the same. Double-click resets the default width. + */ +function ResizeHandle({ win }: { win: WindowState }) { + const drag = useRef<Drag | null>(null) + /** The width a finished drag sent; the live width goes once the window's state has it. */ + const pending = useRef<{ width: number; shell: HTMLElement | null } | null>(null) + const storeWidth = win.sidebar.width + + useLayoutEffect(() => { + const p = pending.current + if (p && p.width === storeWidth) { + pending.current = null + endLive(p.shell) + } + }, [storeWidth]) + const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => { - start.current = { x: e.clientX, width } + if (e.button !== 0) return + e.preventDefault() + const shell = e.currentTarget.closest<HTMLElement>('.shell') + pending.current = null + drag.current = { x: e.clientX, width: storeWidth, live: storeWidth, collapsed: false, shell } e.currentTarget.setPointerCapture(e.pointerId) document.documentElement.classList.add('is-resizing') } const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => { - if (!start.current) return - void invoke('window.setSidebar', { width: start.current.width + e.clientX - start.current.x }) + const d = drag.current + if (!d) return + const raw = d.width + e.clientX - d.x + if (raw < MIN_SIDEBAR_WIDTH - COLLAPSE_PAST) { + if (d.collapsed) return + d.collapsed = true + // The hide animates (FL-02); the width it had stays the window's. + d.shell?.classList.remove('shell--resizing') + void invoke('window.setSidebar', { visible: false }) + return + } + d.live = clampWidth(raw) + setLive(d.shell, d.live) + if (d.collapsed) { + d.collapsed = false + void invoke('window.setSidebar', { visible: true }) + } } const onPointerUp = () => { - start.current = null + const d = drag.current + drag.current = null document.documentElement.classList.remove('is-resizing') + if (!d) return + if (d.collapsed) { + // Hidden by the drag: back to the width it had once the slide is over. + window.setTimeout(() => endLive(d.shell), 320) + return + } + if (d.live === storeWidth) { + endLive(d.shell) + return + } + pending.current = { width: d.live, shell: d.shell } + void invoke('window.setSidebar', { width: d.live }) + // Should the state never echo the width back (window closing), don't keep the drag's width forever. + window.setTimeout(() => { + if (pending.current?.width === d.live) { + pending.current = null + endLive(d.shell) + } + }, 1000) } return ( <div @@ -66,6 +145,19 @@ function ResizeHandle({ width }: { width: number }) { onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} + onDoubleClick={() => void invoke('window.setSidebar', { width: DEFAULT_SIDEBAR_WIDTH })} /> ) } + +function setLive(shell: HTMLElement | null, width: number): void { + if (!shell) return + shell.classList.add('shell--resizing') + shell.style.setProperty('--sidebar-live-w', `${width}px`) +} + +function endLive(shell: HTMLElement | null): void { + if (!shell) return + shell.classList.remove('shell--resizing') + shell.style.removeProperty('--sidebar-live-w') +} diff --git a/src/renderer/src/shell/sidebar/TopBar.tsx b/src/renderer/src/shell/sidebar/TopBar.tsx index 7eda84d0..e0031086 100644 --- a/src/renderer/src/shell/sidebar/TopBar.tsx +++ b/src/renderer/src/shell/sidebar/TopBar.tsx @@ -10,9 +10,9 @@ import '../chrome/chrome.css' /** * The top of the sidebar (SB-03, SB-04): window controls (traffic lights on macOS), the sidebar * toggle, Back, Forward and Reload, then the address display. At narrow widths the toggle goes - * first; Back and Forward stay. + * first; Back and Forward stay. In the floating card (SB-12) the toggle docks the sidebar again. */ -export function TopBar({ win }: { win: WindowState }) { +export function TopBar({ win, floating = false }: { win: WindowState; floating?: boolean }) { return ( <div className={`topbar${isMac ? ' topbar--mac' : ''}`} data-testid="topbar"> <div className="topbar__row"> @@ -22,7 +22,7 @@ export function TopBar({ win }: { win: WindowState }) { type="button" className="icon-button topbar__toggle" aria-label="Toggle sidebar" - title={withShortcut('Hide sidebar', 'sidebar.toggle')} + title={withShortcut(floating ? 'Show sidebar' : 'Hide sidebar', 'sidebar.toggle')} data-testid="toggle-sidebar" onClick={() => runAction('sidebar.toggle')} > diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index ad97bd3c..ab99c1a5 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -18,6 +18,11 @@ --radius-row: 8px; --row-h: 32px; --gutter: 8px; + /* With the sidebar hidden the page card keeps this margin on all four sides (SB-19). */ + --gutter-hidden: 6px; + /* The sidebar's show/hide (FL-02): the standard token. */ + --sidebar-dur: 260ms; + --sidebar-ease: cubic-bezier(0.2, 0.9, 0.1, 1); --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); --ease-spring: cubic-bezier(0.25, 1.2, 0.4, 1); --dur-fast: 120ms; @@ -83,25 +88,47 @@ html.is-resizing * { position: relative; isolation: isolate; display: grid; - grid-template-columns: var(--sidebar-w) minmax(0, 1fr); + /* --sidebar-live-w: the width while the sidebar's edge is dragged (SB-14), ahead of the window's state. */ + grid-template-columns: var(--sidebar-live-w, var(--sidebar-w)) minmax(0, 1fr); height: 100%; /* Space theme: three stops painted by the theme driver (src/renderer/src/theme.ts). */ background: linear-gradient(180deg, var(--frame-top), var(--frame-mid, var(--frame-top)), var(--frame-bottom)); - transition: grid-template-columns var(--dur) var(--ease-out); + /* + * Show/hide (SB-11, FL-02): the sidebar column, the card's margins and the sidebar's slide share + * one duration and curve, so the sidebar's right edge and the card's left edge move together. + * A reversal mid-way starts from where they are. + */ + transition: grid-template-columns var(--sidebar-dur) var(--sidebar-ease); } .shell--sidebar-hidden { - grid-template-columns: 0 minmax(0, 1fr); + grid-template-columns: 0px minmax(0, 1fr); } .content { min-width: 0; padding: var(--gutter) var(--gutter) var(--gutter) 0; - transition: padding var(--dur) var(--ease-out); + transition: padding var(--sidebar-dur) var(--sidebar-ease); } .shell--sidebar-hidden .content { - padding-left: var(--gutter); + padding: var(--gutter-hidden); +} + +/* Dragging the sidebar's edge is 1:1 with the pointer (SB-14, FL-01). */ +.shell--resizing, +.shell--resizing .content, +.shell--resizing .sidebar { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .shell, + .content, + .sidebar, + .shell--sidebar-hidden .sidebar { + transition: none; + } } .frame { @@ -135,15 +162,25 @@ html.is-resizing * { position: relative; display: flex; flex-direction: column; + /* + * The sidebar keeps its width and slides out with its column's right edge (FL-02). A new width + * (double-click on the edge) grows it with its column, its left edge staying put. + */ + width: var(--sidebar-live-w, var(--sidebar-w)); + justify-self: end; min-width: 0; overflow: hidden; padding: 0 4px 6px 8px; - transition: opacity var(--dur) var(--ease-out); + transition: width var(--sidebar-dur) var(--sidebar-ease); } .shell--sidebar-hidden .sidebar { - opacity: 0; + visibility: hidden; pointer-events: none; + /* Out of reach (and of Tab focus) once it has slid out. */ + transition: + width var(--sidebar-dur) var(--sidebar-ease), + visibility 0s linear var(--sidebar-dur); } .shell--blurred .sidebar { diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index a84c9426..74c27fc4 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -43,4 +43,17 @@ export interface NavCommands { 'nav.goToIndex': { args: { tabId?: TabId; index: number; newTab?: boolean }; result: TabId | null } /** The page address was copied the long way (from the command bar): a one-time ⌘⇧C tip (NV-17). */ 'nav.copyHint': { args: Record<string, never>; result: void } + /** + * The pointer reached the left edge of a window whose sidebar is hidden: the sidebar slides in + * over the page as a floating card (SB-12, FL-03). Ignored while something else floats over the + * window (command bar, a panel) or a page is in full screen. + */ + 'sidebar.reveal': { args: Record<string, never>; result: void } + /** + * From the floating sidebar: whether the pointer is over it (`over`) and whether a text field in it + * has focus (`editing`). It slides away shortly after neither holds and nothing it opened is up. + */ + 'sidebar.peekState': { args: { over?: boolean; editing?: boolean }; result: void } + /** A click beside the floating sidebar's card: the card hides at once and the click goes on to the page. */ + 'sidebar.peekDismiss': { args: { x: number; y: number; button: 'left' | 'middle' | 'right' }; result: void } } diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 112cfea6..7616f4f1 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -61,7 +61,7 @@ export interface Rect { } /** Which UI a renderer instance is showing. */ -export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' +export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' | 'sidebar' /** The API the preload exposes on `window.orbit` to Orbit's own UI renderers. */ export interface OrbitBridge { diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 317734d1..5e2f470c 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -1,17 +1,22 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, test } from '@playwright/test' -import { isMac, launchOrbit, type Orbit, state, tabPage, viewPage } from './helpers' +import { expect, type Page, test } from '@playwright/test' +import { DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from '../../src/shared/defaults' +import { action, FRAME_SLACK, isMac, launchOrbit, MOD, type Orbit, state, tabPage, tabView, viewPage } from './helpers' import { activeTab, chooseHistory, cmd, + floatingInfo, + floatingPage, HOST_RULES, historyMenu, INSECURE_HOST, type NavFixtures, NEW_TAB_CLICK, openTab, + pageMouse, shellRoundTrip, spyCommand, startNavFixtures, @@ -378,3 +383,418 @@ test('[NV-01][SB-03] Back on a fresh tab opened from a link closes it and return await expect.poll(async () => (await state(app)).tabs[child as string]).toBeUndefined() expect((await activeTab(app)).id).toBe(opener) }) + +// ---------- Hiding, revealing and resizing the sidebar (SB-11, SB-12, SB-14, FL-02, FL-03) ---------- + +/** The card's margin with the sidebar hidden, and the band beside the floating card (SB-12, SB-19). */ +const MARGIN_HIDDEN = 6 +const FLOAT_BAND = 16 + +async function sidebarState() { + return (await windowState(orbit.app)).sidebar +} + +/** The first window's content size. */ +function contentSize(): Promise<{ width: number; height: number }> { + return orbit.app.evaluate(() => { + const hooks = ( + globalThis as unknown as { + __orbit: { + ctx: { windows: { get: (id: string) => Electron.BrowserWindow | undefined } } + windowIds: () => string[] + } + } + ).__orbit + const [width = 0, height = 0] = hooks.ctx.windows.get(hooks.windowIds()[0] as string)?.getContentSize() ?? [] + return { width, height } + }) +} + +/** Hides the sidebar and waits until the docked one has slid away. */ +async function hideSidebar(): Promise<void> { + await cmd(orbit.app, 'window.setSidebar', { visible: false }) + await expect(orbit.shell.getByTestId('sidebar')).toBeHidden() +} + +/** Brings the floating sidebar in from the window's left edge and returns its page. */ +async function revealFloating(): Promise<Page> { + // Into the edge from the page: a pointer resting there doesn't count. + await orbit.shell.mouse.move(40, 300) + await orbit.shell.mouse.move(2, 300) + await expect.poll(async () => (await sidebarState()).peeking).toBe(true) + const floating = await floatingPage(orbit.app) + await expect(floating.getByTestId('floating-sidebar')).toHaveAttribute('data-out', 'true') + return floating +} + +/** macOS: where the window's traffic lights are. */ +const windowButtons = () => + orbit.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition() ?? null) + +const clicksSeen = (tab: Page) => + tab.evaluate(() => (window as unknown as { __clicks: { x: number; y: number; button: number }[] }).__clicks) + +test('[SB-11] ⌘S hides the sidebar and the page card takes the window; again brings it back as it was', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Wide')) + for (let n = 0; n < 30; n++) await cmd(app, 'tabs.create', { url: `http://127.0.0.1:9/t${n}`, activate: false }) + await cmd(app, 'window.setSidebar', { width: 300 }) + const scroll = shell.getByTestId('sidebar-scroll') + await expect.poll(() => scroll.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(200) + await scroll.evaluate((el) => { + el.scrollTop = 120 + }) + const size = await contentSize() + + await shell.keyboard.press(`${MOD}+S`) + await expect.poll(async () => (await sidebarState()).visible).toBe(false) + // The card spans the window but for a thin margin on every side. + await expect + .poll(async () => (await tabView(app, id))?.bounds) + .toEqual({ + x: MARGIN_HIDDEN, + y: MARGIN_HIDDEN, + width: size.width - 2 * MARGIN_HIDDEN, + height: size.height - 2 * MARGIN_HIDDEN, + }) + await expect(shell.getByTestId('sidebar')).toBeHidden() + await expect(shell.getByTestId('nav-back')).toBeHidden() + + // Back at the same width, scroll position and selected tab. + await shell.keyboard.press(`${MOD}+S`) + await expect.poll(async () => (await sidebarState()).visible).toBe(true) + await expect(shell.getByTestId('sidebar')).toBeVisible() + await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(300) + expect((await shell.getByTestId('sidebar').boundingBox())?.width).toBe(300) + expect(await scroll.evaluate((el) => el.scrollTop)).toBe(120) + expect((await activeTab(app)).id).toBe(id) +}) + +test('[SB-11] Toggle Sidebar is an action in the command bar (and the View menu on macOS)', async () => { + const { app, shell } = orbit + await openTab(app, page('Menus')) + await shell.keyboard.press(`${MOD}+T`) + const overlay = await viewPage(app, 'overlay') + await expect(overlay.getByTestId('command-input')).toBeFocused() + await overlay.keyboard.type('Toggle Sidebar') + await expect(overlay.getByTestId('command-result').first()).toContainText('Toggle Sidebar') + await overlay.keyboard.press('Enter') + await expect.poll(async () => (await sidebarState()).visible).toBe(false) + if (isMac) { + await app.evaluate(({ Menu }) => { + const view = Menu.getApplicationMenu()?.items.find((i) => i.label === 'View') + const item = view?.submenu?.items.find((i) => i.label === 'Toggle Sidebar') + item?.click() + }) + await expect.poll(async () => (await sidebarState()).visible).toBe(true) + } +}) + +test('[SB-11][SB-14] each window keeps its own sidebar state and width, across a restart', async () => { + const userData = mkdtempSync(join(tmpdir(), 'orbit-e2e-sidebar-')) + await orbit.app.close() + orbit = await launchOrbit({ userData, args: [HOST_RULES] }) + await openTab(orbit.app, page('Kept')) + await cmd(orbit.app, 'window.setSidebar', { width: 320 }) + await hideSidebar() + // A new window opens with its sidebar shown, whatever the others do. + await action(orbit.app, 'window.new') + await expect.poll(async () => Object.keys((await state(orbit.app)).windows).length).toBe(2) + const firstId = (await windowState(orbit.app)).id + const second = Object.values((await state(orbit.app)).windows).find((w) => w.id !== firstId) + expect(second?.sidebar.visible).toBe(true) + await orbit.app.close() + + orbit = await launchOrbit({ userData, args: [HOST_RULES] }) + const windows = Object.values((await state(orbit.app)).windows) + const first = windows.find((w) => !w.sidebar.visible) + expect(first?.sidebar.width).toBe(320) + expect(windows.filter((w) => w.sidebar.visible)).toHaveLength(windows.length - 1) +}) + +test('[FL-02] the sidebar slides out as the card widens, edge to edge in every frame; ⌘S again reverses it', async () => { + const { app, shell } = orbit + await openTab(app, page('Slide')) + const width = (await sidebarState()).width + /** The sidebar's right edge and the card's left edge, every frame for 700 ms after a toggle (and another at `reverseAt`). */ + const record = (reverseAt: number | null) => + shell.evaluate(async (at) => { + const sidebar = document.querySelector('[data-testid="sidebar"]') as HTMLElement + const frame = document.querySelector('[data-testid="content-frame"]') as HTMLElement + const orbitApi = (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise<unknown> } }).orbit + const out: { t: number; right: number; left: number }[] = [] + const t0 = performance.now() + void orbitApi.invoke('action.run', { id: 'sidebar.toggle' }) + if (at !== null) setTimeout(() => void orbitApi.invoke('action.run', { id: 'sidebar.toggle' }), at) + await new Promise<void>((resolve) => { + const tick = () => { + const t = performance.now() - t0 + out.push({ t, right: sidebar.getBoundingClientRect().right, left: frame.getBoundingClientRect().left }) + if (t < 700) requestAnimationFrame(tick) + else resolve() + } + requestAnimationFrame(tick) + }) + return out + }, reverseAt) + + const hide = await record(null) + // One value drives both: the card's left edge stays at the sidebar's right edge plus the margin. + for (const f of hide) { + expect(f.left - f.right, `at ${Math.round(f.t)} ms`).toBeGreaterThanOrEqual(-1) + expect(f.left - f.right, `at ${Math.round(f.t)} ms`).toBeLessThanOrEqual(MARGIN_HIDDEN + 1) + } + expect(hide.some((f) => f.right > 0 && f.right < width)).toBe(true) + const gone = hide.find((f) => f.right <= 0.5) + expect(gone?.t ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(300 * FRAME_SLACK + 50) + expect(hide[hide.length - 1]?.left).toBe(MARGIN_HIDDEN) + await expect.poll(async () => (await sidebarState()).visible).toBe(false) + + // Show, then ⌘S again part way: it turns back from where it is. + const shown = await record(null) + expect(shown[shown.length - 1]?.right).toBe(width) + const reversed = await record(90) + for (const f of reversed) { + expect(f.left - f.right).toBeGreaterThanOrEqual(-1) + expect(f.left - f.right).toBeLessThanOrEqual(MARGIN_HIDDEN + 1) + } + if (FRAME_SLACK === 1) expect(Math.min(...reversed.map((f) => f.right))).toBeGreaterThan(0) + expect(reversed[reversed.length - 1]?.right).toBe(width) + await expect.poll(async () => (await sidebarState()).visible).toBe(true) +}) + +test('[SB-12][FL-03] with the sidebar hidden, the left edge brings it in over the page; it goes once the pointer moves on', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Under')) + const tab = await tabPage(app, '/page/Under') + await hideSidebar() + await tab.evaluate(() => window.scrollTo(0, 300)) + const before = { bounds: (await tabView(app, id))?.bounds, width: await tab.evaluate(() => innerWidth) } + const { height } = await contentSize() + const { width } = await sidebarState() + + const floating = await revealFloating() + const info = await floatingInfo(app) + expect(info.placed).toBe(true) + expect(info.bounds).toEqual({ x: 0, y: 0, width: MARGIN_HIDDEN + width + FLOAT_BAND, height }) + // A card inset from the window's edges, sliding in with the snappy timing. + const slide = floating.locator('.float-sidebar__slide') + await expect + .poll(() => slide.evaluate((el) => getComputedStyle(el).transform)) + .toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/) + const box = await slide.boundingBox() + expect(box).toEqual({ x: MARGIN_HIDDEN, y: MARGIN_HIDDEN, width, height: height - 2 * MARGIN_HIDDEN }) + const duration = await slide.evaluate((el) => Number.parseFloat(getComputedStyle(el).transitionDuration) * 1000) + expect(duration).toBeGreaterThanOrEqual(180) + expect(duration).toBeLessThanOrEqual(220) + // Everything a docked sidebar has. + await expect(floating.getByTestId('nav-reload')).toBeVisible() + await expect(floating.getByTestId('address')).toContainText('localhost') + await expect(floating.getByTestId('toggle-sidebar')).toHaveAttribute('title', /^Show sidebar/) + // macOS: the traffic lights come in with the card, in its top row. + if (isMac) expect(await windowButtons()).toEqual({ x: 16 + MARGIN_HIDDEN, y: 17 + MARGIN_HIDDEN }) + // The page neither moved, resized nor scrolled. + expect((await tabView(app, id))?.bounds).toEqual(before.bounds) + expect(await tab.evaluate(() => innerWidth)).toBe(before.width) + expect(await tab.evaluate(() => scrollY)).toBe(300) + void shell + + // The pointer moves on over the page: it stays a moment, then slides away and leaves the window. + const left = Date.now() + await pageMouse(app, '/page/Under', 'mouseMove', 700, 300) + await expect.poll(async () => (await floatingInfo(app)).hold?.over ?? false).toBe(false) + expect((await sidebarState()).peeking).toBe(true) + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + expect(Date.now() - left).toBeGreaterThanOrEqual(300) + await expect(floating.getByTestId('floating-sidebar')).toHaveAttribute('data-out', 'false') + await expect.poll(async () => (await floatingInfo(app)).placed).toBe(false) + expect(Date.now() - left).toBeLessThan(1000 * FRAME_SLACK) + expect((await tabView(app, id))?.bounds).toEqual(before.bounds) +}) + +test('[SB-12][FL-03] back over the card it stays; re-entering the edge as it leaves brings it back', async () => { + const { app } = orbit + await openTab(app, page('Return')) + await hideSidebar() + const floating = await revealFloating() + // Over the page, then back over the card before the grace delay ends. + await pageMouse(app, '/page/Return', 'mouseMove', 700, 300) + await expect.poll(async () => (await floatingInfo(app)).hold?.over ?? true).toBe(false) + await floating.mouse.move(100, 400) + await expect.poll(async () => (await floatingInfo(app)).hold?.over ?? false).toBe(true) + expect((await floatingInfo(app)).hold?.hiding).toBe(false) + expect((await sidebarState()).peeking).toBe(true) + // Leaving through the window's left edge counts as leaving. + await floating.mouse.move(-5, 400) + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + // The edge again while it slides away: the same view turns back. + const id = await floating.evaluate(() => performance.timeOrigin) + await revealFloating() + expect((await floatingInfo(app)).placed).toBe(true) + expect(await (await floatingPage(app)).evaluate(() => performance.timeOrigin)).toBe(id) +}) + +test('[SB-12] a pointer passing through the edge on its way out of the window brings the sidebar in, and it goes again', async () => { + const { app, shell } = orbit + await openTab(app, page('Passing')) + await hideSidebar() + await revealFloating() + await shell.mouse.move(-30, 300) + await expect.poll(async () => (await floatingInfo(app)).hold?.over).toBe(false) + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + if (isMac) expect(await windowButtons()).toEqual({ x: 16, y: 17 }) +}) + +test('[SB-12] a click into the page sends the floating sidebar away at once; a click beside it reaches the page', async () => { + const { app } = orbit + await openTab(app, page('Clicks')) + const tab = await tabPage(app, '/page/Clicks') + await hideSidebar() + await revealFloating() + const at = Date.now() + await pageMouse(app, '/page/Clicks', 'mouseDown', 600, 200) + await pageMouse(app, '/page/Clicks', 'mouseUp', 600, 200) + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + expect(Date.now() - at).toBeLessThan(400 * FRAME_SLACK) + + // In the band beside the card: the card goes and the page gets the click where it was made. + const floating = await revealFloating() + const { width } = await sidebarState() + const x = MARGIN_HIDDEN + width + 8 + await floating.mouse.click(x, 250) + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + await expect.poll(() => clicksSeen(tab)).toContainEqual({ x: x - MARGIN_HIDDEN, y: 250 - MARGIN_HIDDEN, button: 0 }) +}) + +test('[SB-12] the floating sidebar works like the docked one and stays while the command bar is open; ⌘S docks it', async () => { + const { app, shell } = orbit + const first = await openTab(app, page('One')) + const second = await openTab(app, page('Two')) + await hideSidebar() + const floating = await revealFloating() + // Switch tabs from it. + await floating.locator(`[data-testid="tab-row"][data-tab-id="${first}"]`).click() + await expect.poll(async () => (await activeTab(app)).id).toBe(first) + expect((await sidebarState()).peeking).toBe(true) + // New Tab opens the command bar over it; it stays, even once the pointer has gone over the page. + await floating.getByTestId('new-tab-button').click() + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('commandbar') + await pageMouse(app, '/page/One', 'mouseMove', 700, 300) + await expect.poll(async () => (await floatingInfo(app)).hold?.over).toBe(false) + expect((await floatingInfo(app)).hold).toMatchObject({ overlay: true, hiding: false }) + expect((await sidebarState()).peeking).toBe(true) + expect((await floatingInfo(app)).placed).toBe(true) + // Closing the bar lets it go. + const overlay = await viewPage(app, 'overlay') + await overlay.keyboard.press('Escape') + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + + // ⌘S while it is out docks it. + await revealFloating() + await floating.keyboard.press(`${MOD}+S`) + await expect.poll(async () => (await sidebarState()).visible).toBe(true) + await expect(shell.getByTestId('sidebar')).toBeVisible() + await expect.poll(async () => (await floatingInfo(app)).placed).toBe(false) + expect((await activeTab(app)).id).toBe(first) + void second +}) + +test('[SB-12] renaming a tab in the floating sidebar keeps it out until done', async () => { + const { app } = orbit + const id = await openTab(app, page('Rename')) + await hideSidebar() + const floating = await revealFloating() + await floating.locator(`[data-testid="tab-row"][data-tab-id="${id}"]`).dblclick() + const input = floating.getByTestId('rename-field') + await expect(input).toBeFocused() + await expect.poll(async () => (await floatingInfo(app)).hold?.editing).toBe(true) + // The pointer goes over the page: the field holds the card. + await pageMouse(app, '/page/Rename', 'mouseMove', 700, 300) + await expect.poll(async () => (await floatingInfo(app)).hold?.over).toBe(false) + expect((await floatingInfo(app)).hold).toMatchObject({ editing: true, hiding: false }) + expect((await sidebarState()).peeking).toBe(true) + await input.fill('Studio') + await input.press('Enter') + await expect.poll(async () => (await state(app)).tabs[id]?.customTitle).toBe('Studio') + // Done: it slides away. + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) +}) + +test('[SB-12] the edge does nothing while the sidebar is docked or the command bar is open, nor for a pointer resting there', async () => { + const { app, shell } = orbit + await openTab(app, page('Quiet')) + await shell.mouse.move(40, 300) + await shell.mouse.move(2, 300) + await shellRoundTrip(shell) + expect((await sidebarState()).peeking).toBe(false) + await expect(shell.getByTestId('sidebar-edge')).toHaveCount(0) + // Hidden with the pointer at the edge: it doesn't come straight back. + await hideSidebar() + await expect(shell.getByTestId('sidebar-edge')).toHaveCount(1) + await shellRoundTrip(shell) + expect((await sidebarState()).peeking).toBe(false) + await shell.keyboard.press(`${MOD}+T`) + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('commandbar') + await cmd(app, 'sidebar.reveal') + await shell.mouse.move(2, 320) + await shellRoundTrip(shell) + expect((await sidebarState()).peeking).toBe(false) +}) + +test('[SB-14] dragging the edge sets the width 1:1 within limits; double-click resets; far left hides', async () => { + const { app, shell } = orbit + await openTab(app, page('Resize')) + const handle = shell.getByTestId('sidebar-resize') + const sidebar = shell.getByTestId('sidebar') + const start = (await sidebarState()).width + const box = await handle.boundingBox() + if (!box) throw new Error('no resize edge') + const x0 = box.x + box.width / 2 + const y0 = box.y + 200 + await expect(handle).toHaveCSS('cursor', 'col-resize') + + await shell.mouse.move(x0, y0) + await shell.mouse.down() + await shell.mouse.move(x0 + 52, y0, { steps: 4 }) + // Live, before the drag ends. + await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(start + 52) + // Past the maximum it stops; coming back it picks the pointer up again with no drift. + await shell.mouse.move(x0 + 400, y0, { steps: 4 }) + await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(MAX_SIDEBAR_WIDTH) + await shell.mouse.move(x0 + 52, y0, { steps: 4 }) + await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(start + 52) + await shell.mouse.up() + await expect.poll(async () => (await sidebarState()).width).toBe(start + 52) + const id = (await activeTab(app)).id + await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(start + 52) + + // Below the minimum it stops too. + const edge = x0 + 52 + await shell.mouse.move(edge, y0) + await shell.mouse.down() + await shell.mouse.move(edge - (start + 52 - MIN_SIDEBAR_WIDTH) - 30, y0, { steps: 4 }) + await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(MIN_SIDEBAR_WIDTH) + expect((await sidebarState()).visible).toBe(true) + await shell.mouse.up() + await expect.poll(async () => (await sidebarState()).width).toBe(MIN_SIDEBAR_WIDTH) + + // Double-click: back to the default width (the edge glides there). + await handle.dblclick() + await expect.poll(async () => (await sidebarState()).width).toBe(DEFAULT_SIDEBAR_WIDTH) + await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(DEFAULT_SIDEBAR_WIDTH) + + // Far left (40 px past the minimum): hidden, as ⌘S does, keeping the width it had. + const back = await handle.boundingBox() + if (!back) throw new Error('no resize edge') + const bx = back.x + back.width / 2 + await shell.mouse.move(bx, y0) + await shell.mouse.down() + await shell.mouse.move(bx - (DEFAULT_SIDEBAR_WIDTH - MIN_SIDEBAR_WIDTH) - 45, y0, { steps: 6 }) + await expect.poll(async () => (await sidebarState()).visible).toBe(false) + await shell.mouse.up() + await expect(sidebar).toBeHidden() + expect((await sidebarState()).width).toBe(DEFAULT_SIDEBAR_WIDTH) + await shell.keyboard.press(`${MOD}+S`) + await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(DEFAULT_SIDEBAR_WIDTH) +}) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 195e7d2a..b560a424 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -38,7 +38,8 @@ export async function launchOrbit( env: { ...process.env, ORBIT_E2E: '1', ORBIT_USER_DATA: userData, ...opts.env } as Record<string, string>, }) const shell = await viewPage(app, 'shell') - await shell.getByTestId('sidebar').waitFor() + // Attached rather than visible: a window restored with its sidebar hidden (SB-11) has one too. + await shell.getByTestId('sidebar').waitFor({ state: 'attached' }) return { app, shell, userData } } diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 73b1bdcb..da96e813 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -33,9 +33,9 @@ const ICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%2338a169'/%3E%3C/svg%3E" /** - * A page that records key presses (`window.__keys`) and counts its loads in sessionStorage - * (`loads`), with a text box (#box), a link to the next page (#next) and one that opens a new - * tab (#child). + * A page that records key presses (`window.__keys`) and mouse presses (`window.__clicks`: x, y, + * button) and counts its loads in sessionStorage (`loads`), with a text box (#box), a link to the + * next page (#next) and one that opens a new tab (#child). */ function page(title: string): string { return `<!doctype html> @@ -49,6 +49,8 @@ function page(title: string): string { <script> window.__keys = [] addEventListener('keydown', (e) => window.__keys.push(e.key)) +window.__clicks = [] +addEventListener('mousedown', (e) => window.__clicks.push({ x: e.clientX, y: e.clientY, button: e.button })) sessionStorage.setItem('loads', String(Number(sessionStorage.getItem('loads') || 0) + 1)) </script> </body></html>` @@ -124,9 +126,36 @@ type Hooks = { __orbitNav: { menu: () => { windowId: string; direction: 'back' | 'forward'; labels: string[]; icons: boolean[] } | null chooseMenu: (label: string, newTab?: boolean) => boolean + floating: (windowId?: string) => FloatingInfo } } +/** A window's floating sidebar (SB-12): whether its view exists, is placed in the window and is out. */ +export interface FloatingInfo { + exists: boolean + placed: boolean + shown: boolean + bounds?: { x: number; y: number; width: number; height: number } + /** What keeps it out; `hiding` while it waits out the grace delay before sliding away. */ + hold: { over: boolean; editing: boolean; overlay: boolean; hiding: boolean } | null +} + +/** The floating sidebar's page (its own renderer, `view=sidebar`), once it has been made. */ +export async function floatingPage(app: ElectronApplication): Promise<Page> { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + const found = app.windows().find((p) => p.url().includes('view=sidebar')) + if (found) return found + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error('No floating sidebar page') +} + +/** The first window's floating sidebar. */ +export function floatingInfo(app: ElectronApplication): Promise<FloatingInfo> { + return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.floating()) +} + /** Runs a command as if from the first window. */ export function cmd<T = unknown>(app: ElectronApplication, name: string, args: unknown = {}): Promise<T> { return app.evaluate( @@ -220,6 +249,28 @@ export async function pressInPage( ) } +/** + * Moves or presses the mouse over a tab's page the way the hardware does: through the browser + * process, where Orbit sees it. `x`, `y` are in the page's coordinates. + */ +export async function pageMouse( + app: ElectronApplication, + urlPart: string, + type: 'mouseMove' | 'mouseDown' | 'mouseUp', + x: number, + y: number, +): Promise<void> { + await app.evaluate( + ({ webContents }, [part, t, px, py]) => { + const page = webContents.getAllWebContents().find((w) => w.getURL().includes(part)) + if (!page) throw new Error(`no page with ${part}`) + if (t === 'mouseMove') page.sendInputEvent({ type: t, x: px, y: py }) + else page.sendInputEvent({ type: t, x: px, y: py, button: 'left', clickCount: 1 }) + }, + [urlPart, type, x, y] as const, + ) +} + /** Presses a mouse side button ('back' or 'forward') over a page, as the hardware would send it. */ export async function sideButton(page: Page, button: 'back' | 'forward'): Promise<void> { const cdp = await page.context().newCDPSession(page) From e0ffac37ddc7e0acd077d1d6aabc84c81bddb19d Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:42:47 +0000 Subject: [PATCH 03/69] chrome: the page card in every state; audit the window layout (SB-19, SB-01, SB-02, SB-05) - Native full screen drops the frame: no margins, square corners; leaving brings it back. Corners stay at 10 px while loading, with the sidebar hidden or floating. - Each page's background colour is kept on its tab and fills the page's view and the shell's card under it, so a window resize uncovers no white on dark pages; a tab that wakes starts from its last colour. - Tests for the layout, the sidebar's regions in order (the middle scrolls, the rest stays) and Favorites staying put across Spaces of one profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 12 +- docs/feature-status.json | 8 +- src/main/nav/index.ts | 5 + src/main/nav/page-color.ts | 102 +++++++++++++++ src/main/persist.ts | 1 + src/main/windows.ts | 18 ++- src/renderer/src/shell/ContentArea.tsx | 3 + src/renderer/src/shell/ShellApp.tsx | 2 +- src/renderer/src/styles/global.css | 11 ++ src/shared/state.ts | 2 + tests/e2e/chrome.spec.ts | 166 +++++++++++++++++++++++++ tests/e2e/nav-support.ts | 31 ++++- 12 files changed, 343 insertions(+), 18 deletions(-) create mode 100644 src/main/nav/page-color.ts diff --git a/FEATURES.md b/FEATURES.md index 7265e6ff..447a0dab 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:** 485 spec’d · 123 built (42 partial) · 172 tested · 0 feel right. +**Totals:** 485 spec’d · 127 built (38 partial) · 175 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 93 | 118 | +| P0 | 174 | 97 | 121 | | P1 | 221 | 27 | 50 | | P2 | 83 | 3 | 4 | @@ -21,11 +21,11 @@ Spec: `spec/sidebar.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| SB-01 | Sidebar-first window layout | P0 | 🟡 partial | – | – | scaffold; audit vs spec | -| SB-02 | Sidebar anatomy (top to bottom) | P0 | 🟡 partial | – | – | scaffold; audit vs spec | +| SB-01 | Sidebar-first window layout | P0 | ✅ | ✅ e2e | – | sidebar on the left with controls, navigation, address and every tab; the page an inset rounded card beside it in the Space theme; no tab strip | +| SB-02 | Sidebar anatomy (top to bottom) | P0 | ✅ | ✅ e2e | – | top row, address, Favorites, title row, pinned, divider, + New Tab, Today, empty area, bottom bar in that order; title row to empty area scroll together, the rest stays; trays come with SB-10, the Library button with SB-09 | | SB-03 | Top row: window controls and navigation buttons | P0 | ✅ | ✅ e2e | – | traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow | | SB-04 | Address display at the top of the sidebar | P0 | 🟡 partial | ✅ e2e+unit | – | host only (focused pane), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button; site-controls button shows a placeholder until NV-16 | -| SB-05 | Favorites grid placement | P0 | 🟡 partial | – | – | scaffold | +| SB-05 | Favorites grid placement | P0 | ✅ | ✅ e2e | – | Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile | | SB-06 | Space title row placement | P0 | ✅ | ✅ e2e | – | title row: inline rename, hover profile badge and … menu (pinned caret is PN-09) | | SB-07 | "+ New Tab" row | P0 | ✅ | ✅ e2e | – | + New Tab row with press animation opens the command bar | | SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | – | – | | @@ -39,7 +39,7 @@ Spec: `spec/sidebar.md` | SB-16 | Sidebar position (left / right) | P2 | – | – | – | | | SB-17 | Optional top toolbar | P1 | – | – | – | | | SB-18 | Developer-mode tab chrome | P2 | – | – | – | | -| SB-19 | Inset rounded page frame ("card" look) | P0 | 🟡 partial | ✅ e2e | – | scaffold | +| 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-21 | Dim the sidebar when the window is inactive | P1 | – | – | – | | | SB-22 | Keyboard focus and navigation inside the sidebar | P2 | ✅ | ✅ e2e | – | Cmd/Ctrl+Shift+E focuses the sidebar; arrows, Enter, Escape, F2 | diff --git a/docs/feature-status.json b/docs/feature-status.json index 93d50a4c..73f2e0df 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -1,16 +1,16 @@ { - "SB-01": {"built": "partial", "note": "scaffold; audit vs spec"}, - "SB-02": {"built": "partial", "note": "scaffold; audit vs spec"}, + "SB-01": {"built": "yes", "note": "sidebar on the left with controls, navigation, address and every tab; the page an inset rounded card beside it in the Space theme; no tab strip"}, + "SB-02": {"built": "yes", "note": "top row, address, Favorites, title row, pinned, divider, + New Tab, Today, empty area, bottom bar in that order; title row to empty area scroll together, the rest stays; trays come with SB-10, the Library button with SB-09"}, "SB-03": {"built": "yes", "note": "traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow"}, "SB-04": {"built": "partial", "note": "host only (focused pane), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button; site-controls button shows a placeholder until NV-16"}, - "SB-05": {"built": "partial", "note": "scaffold"}, + "SB-05": {"built": "yes", "note": "Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile"}, "SB-06": {"built": "yes", "note": "title row: inline rename, hover profile badge and … menu (pinned caret is PN-09)"}, "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, "SB-09": {"built": "partial", "note": "switcher and + menu done; Library button arrives with the archive lane"}, "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); back at the same width, scroll and selected tab; per window, persisted; new windows show it"}, "SB-12": {"built": "yes", "note": "left 6 px edge (moving pointer, 0 ms dwell) brings a floating card in its own view over the page, which never moves; stays while hovered (16 px band), while a field in it is edited or the command bar is open; 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"}, "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": "partial", "note": "scaffold"}, + "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-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"}, diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 13ca3a3c..b3766290 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -8,6 +8,7 @@ import type { MainContext } from '../context' import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' import { activeTabOf } from '../windows' +import { registerPageColor } from './page-color' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' import { SitePrefs } from './site-prefs' @@ -17,6 +18,8 @@ export interface NavTestHooks { menu: () => { windowId: WindowId; direction: NavDirection; labels: string[]; icons: boolean[] } | null /** Chooses an item of the open history menu by label; `newTab` is a ⌘-click (Ctrl-click). */ chooseMenu: (label: string, newTab?: boolean) => boolean + /** The corner radius of a shown page's card (0 without a frame: full screen) (SB-19). */ + corner: (tabId: TabId) => number | null /** The floating sidebar of a window (default: the first): its view, and what keeps it out (SB-12). */ floating: (windowId?: WindowId) => { exists: boolean @@ -249,6 +252,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // The hidden sidebar's hover reveal (SB-12, FL-03). const reveal = registerSidebarReveal(ctx) + registerPageColor(ctx) // Placeholder until the site controls panel (NV-16) lands. actions.register('site.controls', (c) => ctx.toast(windowOf(c), 'Site controls are coming soon')) @@ -287,6 +291,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { choose(menu, entry.index, newTab) return true }, + corner: (tabId) => windows.cornerOf(tabId) ?? null, floating: (windowId) => { const id = windowId ?? windows.ids()[0] ?? '' return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } diff --git a/src/main/nav/page-color.ts b/src/main/nav/page-color.ts new file mode 100644 index 00000000..36d0ee3b --- /dev/null +++ b/src/main/nav/page-color.ts @@ -0,0 +1,102 @@ +import { nativeTheme, type WebContents } from 'electron' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import { NAV_WORLD } from './focus' + +/** + * The page's own background as an opaque #rrggbb: the root's background, else the body's (which + * then paints the canvas), over the default canvas (white, or Chromium's dark canvas for a page in + * a dark colour scheme). Any CSS colour format works: a 1-pixel canvas does the conversion. + */ +const PAGE_COLOR = `(() => { + const root = document.documentElement + if (!root) return null + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + const g = canvas.getContext('2d', { willReadFrequently: true }) + if (!g) return null + const paint = (color) => { + g.fillStyle = 'rgba(0, 0, 0, 0)' + g.fillStyle = color + g.fillRect(0, 0, 1, 1) + } + const style = getComputedStyle(root) + let color = style.backgroundColor + g.clearRect(0, 0, 1, 1) + paint(color) + if (g.getImageData(0, 0, 1, 1).data[3] === 0 && document.body) color = getComputedStyle(document.body).backgroundColor + const scheme = style.colorScheme || '' + const dark = /\\bdark\\b/.test(scheme) && (!/\\blight\\b/.test(scheme) || matchMedia('(prefers-color-scheme: dark)').matches) + g.clearRect(0, 0, 1, 1) + g.fillStyle = dark ? '#121212' : '#ffffff' + g.fillRect(0, 0, 1, 1) + paint(color) + const [r, gr, b] = g.getImageData(0, 0, 1, 1).data + return '#' + [r, gr, b].map((n) => n.toString(16).padStart(2, '0')).join('') +})()` + +const HEX = /^#[0-9a-f]{6}$/ + +/** + * Each page's background colour (SB-19), kept on its tab (`pageColor`). The page's view is filled + * with it, so the part of the card a window resize uncovers before the page redraws matches the + * page instead of flashing white, and the shell's card under the page (seen while the view catches + * up, and for a sleeping tab) takes it too. A tab that wakes starts from the colour it last had. + */ +export function registerPageColor(ctx: MainContext): void { + const { store, tabs } = ctx + const timers = new Map<TabId, NodeJS.Timeout>() + + const apply = (tabId: TabId, contents: WebContents, color: string) => { + const view = tabs.get(tabId) + if (view?.webContents === contents) view.setBackgroundColor(color) + if (!store.state.tabs[tabId] || store.state.tabs[tabId]?.pageColor === color) return + store.update((d) => { + const tab = d.tabs[tabId] + if (tab) tab.pageColor = color + }) + } + + const probe = async (tabId: TabId, contents: WebContents) => { + if (contents.isDestroyed()) return + try { + const color: unknown = await contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code: PAGE_COLOR }]) + if (typeof color === 'string' && HEX.test(color) && !contents.isDestroyed()) apply(tabId, contents, color) + } catch { + // The page went away or can't be scripted (an error page, a PDF): keep the last colour. + } + } + + /** Pages often set their background from script just after loading: look once things settle. */ + const soon = (tabId: TabId, contents: WebContents, ms: number) => { + clearTimeout(timers.get(tabId)) + timers.set( + tabId, + setTimeout(() => { + timers.delete(tabId) + void probe(tabId, contents) + }, ms), + ) + } + + ctx.hooks.tabCreated.push((tabId, contents) => { + const known = store.state.tabs[tabId]?.pageColor + const view = tabs.get(tabId) + if (known && view?.webContents === contents) view.setBackgroundColor(known) + contents.on('did-finish-load', () => soon(tabId, contents, 150)) + contents.on('did-navigate-in-page', () => soon(tabId, contents, 300)) + contents.once('destroyed', () => { + clearTimeout(timers.get(tabId)) + timers.delete(tabId) + }) + }) + + // Pages that follow the system's light/dark setting change colour with it. + nativeTheme.on('updated', () => { + for (const tabId of tabs.liveTabIds()) { + const contents = tabs.get(tabId)?.webContents + if (contents && !contents.isDestroyed()) soon(tabId, contents, 200) + } + }) +} diff --git a/src/main/persist.ts b/src/main/persist.ts index 57063e1f..ccce11ba 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -228,6 +228,7 @@ function normalizeEntities(state: OrbitState, base: OrbitState): void { t.title = text(t.title, fallbackTitle(t.url)) t.createdAt = time(t.createdAt) t.lastActiveAt = time(t.lastActiveAt) + if (typeof t.pageColor !== 'string' || !/^#[0-9a-f]{6}$/.test(t.pageColor)) delete t.pageColor } for (const [id, w] of Object.entries(state.windows)) { w.id = id diff --git a/src/main/windows.ts b/src/main/windows.ts index 0bc59f47..15c07a5c 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -24,6 +24,8 @@ interface WindowRecord { visible: Set<TabId> panes: Pane[] radius: number + /** The corner radius each shown page was given (0 in full screen) (SB-19). */ + corners: Map<TabId, number> /** Last single-pane content rect: lets tab switches swap views without a renderer round trip. */ lastSingleRect?: Rect /** Last reported content frame (all panes together); used to lay out split views on the fast path. */ @@ -296,6 +298,7 @@ export class WindowManager { visible: new Set(), panes: [], radius: 10, + corners: new Map(), minPaneSeq: 0, overlayShown: false, floatingShown: false, @@ -325,10 +328,13 @@ export class WindowManager { win.on('enter-full-screen', () => { this.setRuntime(id, { fullscreen: true }) this.settleHtmlFullscreen(rec) + // The frame goes (SB-19): square corners now, the margins as the shell lays out again. + if (!rec.htmlFullscreen) this.applyPanes(id, rec.panes, rec.radius) }) win.on('leave-full-screen', () => { this.setRuntime(id, { fullscreen: false }) this.settleHtmlFullscreen(rec) + if (!rec.htmlFullscreen) this.applyPanes(id, rec.panes, rec.radius) }) win.on('resize', () => { if (rec.overlayShown) this.fitOverlay(rec) @@ -445,6 +451,12 @@ export class WindowManager { if (rec.overlay && !rec.overlay.webContents.isDestroyed()) rec.overlay.webContents.send(`orbit:${channel}`, payload) } + /** The corner radius a shown page was last given (SB-19), for tests. */ + cornerOf(tabId: TabId): number | undefined { + const entry = this.attached.get(tabId) + return entry ? this.records.get(entry.windowId)?.corners.get(tabId) : undefined + } + /** The floating sidebar's web contents while its card is out (SB-12). */ floatingContents(windowId: WindowId): WebContents | undefined { const rec = this.records.get(windowId) @@ -503,6 +515,8 @@ export class WindowManager { if (!rec || rec.win.isDestroyed()) return rec.panes = panes rec.radius = radius + // Native full screen has no frame: square corners (SB-19). + const corner = this.ctx.store.state.windows[windowId]?.fullscreen ? 0 : radius const wanted = new Set(panes.map((p) => p.tabId)) for (const tabId of rec.visible) { if (wanted.has(tabId)) continue @@ -518,7 +532,8 @@ export class WindowManager { if (!view) continue this.attach(rec, pane.tabId, view) view.setBounds(roundRect(pane.rect)) - view.setBorderRadius(radius) + 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() @@ -560,6 +575,7 @@ export class WindowManager { this.placeOnTop(rec, entry.view) entry.view.setBounds({ x: 0, y: 0, width, height }) entry.view.setBorderRadius(0) + rec.corners.set(tabId, 0) } private setHtmlFullscreen(tabId: TabId, contentsId: number, on: boolean): void { diff --git a/src/renderer/src/shell/ContentArea.tsx b/src/renderer/src/shell/ContentArea.tsx index d11add85..1cace8c9 100644 --- a/src/renderer/src/shell/ContentArea.tsx +++ b/src/renderer/src/shell/ContentArea.tsx @@ -126,10 +126,13 @@ const Pane = memo(function Pane(props: { single: boolean }) { const { tabId, engine, blank, focused, single } = props + // The page's own background under it, for the moments the page hasn't drawn (SB-19). + const pageColor = useOrbit((s) => (blank ? undefined : s.tabs[tabId]?.pageColor)) return ( <div ref={(el) => engine.registerPane(tabId, el)} className={`pane${single ? ' pane--single' : ''}${blank ? ' pane--blank' : ''}${focused ? ' is-focused' : ''}`} + style={pageColor ? { backgroundColor: pageColor } : undefined} data-testid="split-pane" data-tab-id={tabId} data-focused={focused ? 'true' : undefined} diff --git a/src/renderer/src/shell/ShellApp.tsx b/src/renderer/src/shell/ShellApp.tsx index 4578c07a..c964386e 100644 --- a/src/renderer/src/shell/ShellApp.tsx +++ b/src/renderer/src/shell/ShellApp.tsx @@ -16,7 +16,7 @@ export function ShellApp() { const style = { '--sidebar-w': `${win.sidebar.width}px` } as CSSProperties return ( <div - className={`shell${hidden ? ' shell--sidebar-hidden' : ''}${win.focused ? '' : ' shell--blurred'}`} + className={`shell${hidden ? ' shell--sidebar-hidden' : ''}${win.fullscreen ? ' shell--fullscreen' : ''}${win.focused ? '' : ' shell--blurred'}`} style={style} ref={theme.shellRef} data-testid="shell" diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index ab99c1a5..59265f75 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -115,6 +115,17 @@ html.is-resizing * { padding: var(--gutter-hidden); } +/* Native full screen has no frame: the page meets the screen's edges, square (SB-19). */ +.shell--fullscreen .content, +.shell--fullscreen.shell--sidebar-hidden .content { + padding: 0; +} + +.shell--fullscreen .pane { + border-radius: 0; + box-shadow: none; +} + /* Dragging the sidebar's edge is 1:1 with the pointer (SB-14, FL-01). */ .shell--resizing, .shell--resizing .content, diff --git a/src/shared/state.ts b/src/shared/state.ts index e0247c8b..d8b80b4f 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -47,6 +47,8 @@ export interface Tab { icon?: string /** Tab whose link opened this tab (TB-19). */ openerTabId?: TabId + /** The page's background colour (#rrggbb) when last seen: fills its card while it draws (SB-19). */ + pageColor?: string createdAt: number lastActiveAt: number /** Runtime-only fields (not persisted): */ diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 5e2f470c..3c6279c7 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -16,7 +16,9 @@ import { type NavFixtures, NEW_TAB_CLICK, openTab, + pageCorner, pageMouse, + setNativeFullScreen, shellRoundTrip, spyCommand, startNavFixtures, @@ -798,3 +800,167 @@ test('[SB-14] dragging the edge sets the width 1:1 within limits; double-click r await shell.keyboard.press(`${MOD}+S`) await expect.poll(async () => (await sidebar.boundingBox())?.width).toBe(DEFAULT_SIDEBAR_WIDTH) }) + +// ---------- The page card (SB-19) ---------- + +/** The card's margin with the sidebar shown: top, right and bottom (the sidebar's edge is the left). */ +const MARGIN = 8 +const RADIUS = 10 + +test('[SB-19] the page is a rounded card inset from the window in every state: loading, sidebar hidden or floating', async () => { + const { app } = orbit + const id = await openTab(app, page('Card')) + const size = await contentSize() + const { width } = await sidebarState() + const docked = { x: width, y: MARGIN, width: size.width - width - MARGIN, height: size.height - 2 * MARGIN } + await expect.poll(async () => (await tabView(app, id))?.bounds).toEqual(docked) + expect(await pageCorner(app, id)).toBe(RADIUS) + // Loading. + await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/CardLoading') }) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) + expect(await pageCorner(app, id)).toBe(RADIUS) + expect((await tabView(app, id))?.bounds).toEqual(docked) + // Sidebar hidden: a thin margin on all four sides. + await hideSidebar() + const hidden = { + x: MARGIN_HIDDEN, + y: MARGIN_HIDDEN, + width: size.width - 2 * MARGIN_HIDDEN, + height: size.height - 2 * MARGIN_HIDDEN, + } + await expect.poll(async () => (await tabView(app, id))?.bounds).toEqual(hidden) + expect(await pageCorner(app, id)).toBe(RADIUS) + // The floating sidebar over it changes nothing. + await revealFloating() + expect((await tabView(app, id))?.bounds).toEqual(hidden) + expect(await pageCorner(app, id)).toBe(RADIUS) + // The shell's card under the page has the same corners and a fine edge. + const pane = orbit.shell.locator(`[data-testid="split-pane"][data-tab-id="${id}"]`) + await expect(pane).toHaveCSS('border-radius', `${RADIUS}px`) + expect(await pane.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none') +}) + +test('[SB-19] native full screen takes the frame away; leaving brings it back', async () => { + const { app } = orbit + const id = await openTab(app, page('Screen')) + const { width } = await sidebarState() + await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(width) + await setNativeFullScreen(app, true) + 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 }) + ) + }) + .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 pageCorner(app, id)).toBe(RADIUS) +}) + +test('[SB-19] the card takes the page’s own background, so a resize uncovers no white', async () => { + const { app, shell } = orbit + const id = await openTab(app, fx.url('/page/Dark?bg=%23202124')) + await expect.poll(async () => (await state(app)).tabs[id]?.pageColor).toBe('#202124') + const pane = shell.locator(`[data-testid="split-pane"][data-tab-id="${id}"]`) + await expect(pane).toHaveCSS('background-color', 'rgb(32, 33, 36)') + // Another page, another colour: the card follows. + await visit(app, id, page('Light')) + await expect.poll(async () => (await state(app)).tabs[id]?.pageColor).toBe('#f2fbf5') + await expect(pane).toHaveCSS('background-color', 'rgb(242, 251, 245)') +}) + +// ---------- Window layout and the sidebar's regions (SB-01, SB-02, SB-05) ---------- + +test('[SB-01] a window is the sidebar on the left and the page card beside it, with no tab strip across the top', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Layout')) + const size = await contentSize() + const sidebar = await shell.getByTestId('sidebar').boundingBox() + expect(sidebar).toMatchObject({ x: 0, y: 0, height: size.height }) + const view = (await tabView(app, id))?.bounds + expect(view?.x).toBe(sidebar?.width) + // Above the card there is only the thin themed margin, nothing that holds tabs. + expect(view?.y).toBe(MARGIN) + const across = await shell.evaluate(() => { + const el = document.elementFromPoint(innerWidth / 2, 3) + return el ? !!el.closest('[data-testid="tab-row"], [role="tablist"], [data-testid="sidebar"]') : false + }) + expect(across).toBe(false) + // The tab is in the sidebar. + await expect(shell.locator(`[data-testid="sidebar"] [data-testid="tab-row"][data-tab-id="${id}"]`)).toBeVisible() +}) + +test('[SB-02][SB-05] the sidebar’s regions run top to bottom in order; the middle scrolls, the top and bottom stay', async () => { + const { app, shell } = orbit + const favorite = await openTab(app, page('Fav')) + expect(await cmd(app, 'tabs.favorite', { tabId: favorite })).toBe(true) + for (const title of ['PinA', 'PinB']) await cmd(app, 'tabs.pin', { tabId: await openTab(app, page(title)) }) + for (const title of ['TodayA', 'TodayB', 'TodayC']) await openTab(app, page(title)) + const regions = [ + '[data-testid="topbar"] .topbar__row', + '[data-testid="address"]', + '[data-testid="favorites"]', + '[data-testid="space-title-row"]', + '[data-testid="pinned-list"]', + '[data-testid="today-divider"]', + '[data-testid="new-tab-button"]', + '[data-testid="today-list"]', + '[data-testid="sidebar-empty-area"]', + '[data-testid="spacebar"]', + ] + const sidebar = shell.getByTestId('sidebar') + const tops = async () => { + const out: number[] = [] + for (const r of regions) out.push((await sidebar.locator(r).first().boundingBox())?.y ?? Number.NaN) + return out + } + await expect(sidebar.locator('[data-testid="favorite-tile"]')).toHaveCount(1) + await expect(sidebar.locator('[data-testid="pinned-list"] [data-testid="tab-row"]')).toHaveCount(2) + await expect(sidebar.locator('[data-testid="today-list"] [data-testid="tab-row"]')).toHaveCount(3) + const before = await tops() + for (let i = 1; i < before.length; i++) { + expect(before[i], `${regions[i]} below ${regions[i - 1]}`).toBeGreaterThan(before[i - 1] as number) + } + // Title row to the empty area scroll together; the top rows, Favorites and the bottom bar don't. + const scroll = shell.getByTestId('sidebar-scroll') + for (const r of regions.slice(3, 9)) await expect(scroll.locator(r)).toHaveCount(1) + for (const r of [...regions.slice(0, 3), regions[9] as string]) await expect(scroll.locator(r)).toHaveCount(0) + for (let n = 0; n < 30; n++) await cmd(app, 'tabs.create', { url: `http://127.0.0.1:9/r${n}`, activate: false }) + await expect.poll(() => scroll.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(100) + await scroll.evaluate((el) => { + el.scrollTop = 80 + }) + await expect.poll(async () => (await tops())[3]).toBe((before[3] as number) - 80) + const after = await tops() + for (const i of [0, 1, 2, 9]) expect(after[i], regions[i]).toBe(before[i]) +}) + +test('[SB-05] Favorites stay in place across a switch to another Space of the same profile', async () => { + const { app, shell } = orbit + const favorite = await openTab(app, page('Shared')) + expect(await cmd(app, 'tabs.favorite', { tabId: favorite })).toBe(true) + const tile = shell.locator(`[data-testid="favorite-tile"][data-tab-id="${favorite}"]`) + await expect(tile).toBeVisible() + // Where the tile rests once its arrival animation is over. + const rest = async () => { + let last = '' + await expect + .poll(async () => { + const now = JSON.stringify(await tile.boundingBox()) + const same = now === last + last = now + return same + }) + .toBe(true) + return JSON.parse(last) as { x: number; y: number; width: number; height: number } + } + const where = await rest() + const other = (await cmd(app, 'spaces.create', { name: 'Elsewhere' })) as string + await expect.poll(async () => (await windowState(app)).spaceId).toBe(other) + await expect(tile).toBeVisible() + expect(await rest()).toEqual(where) +}) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index da96e813..1b6ea8dd 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -35,12 +35,12 @@ const ICON = /** * A page that records key presses (`window.__keys`) and mouse presses (`window.__clicks`: x, y, * button) and counts its loads in sessionStorage (`loads`), with a text box (#box), a link to the - * next page (#next) and one that opens a new tab (#child). + * next page (#next) and one that opens a new tab (#child). `background` is its CSS background. */ -function page(title: string): string { +function page(title: string, background = '#f2fbf5'): string { return `<!doctype html> <html><head><meta charset="utf-8"><title>${title} - +

${title}

@@ -58,7 +58,7 @@ sessionStorage.setItem('loads', String(Number(sessionStorage.getItem('loads') || /** * Routes: - * - `/page/`: a page (see `page()`). + * - `/page/<title>?bg=<css colour>`: a page (see `page()`), optionally in another background. * - `/slow/<title>?ms=N`: the page after N ms (default 30 s): keeps a tab loading. * - `/slow-reload/<title>`: the page at once on the first request, then slowly (30 s) on reloads. */ @@ -78,7 +78,7 @@ export async function startNavFixtures(): Promise<NavFixtures> { pending.delete(res) if (res.writableEnded || res.destroyed) return res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }) - res.end(page(title)) + res.end(page(title, u.searchParams.get('bg') ?? undefined)) } const later = (ms: number) => { pending.add(res) @@ -122,11 +122,16 @@ export async function startNavFixtures(): Promise<NavFixtures> { } type Hooks = { - __orbit: { run: (n: string, a: unknown, w?: string) => Promise<unknown>; windowIds: () => string[] } + __orbit: { + run: (n: string, a: unknown, w?: string) => Promise<unknown> + windowIds: () => string[] + ctx: { windows: { get: (id: string) => Electron.BrowserWindow | undefined } } + } __orbitNav: { menu: () => { windowId: string; direction: 'back' | 'forward'; labels: string[]; icons: boolean[] } | null chooseMenu: (label: string, newTab?: boolean) => boolean floating: (windowId?: string) => FloatingInfo + corner: (tabId: string) => number | null } } @@ -156,6 +161,20 @@ export function floatingInfo(app: ElectronApplication): Promise<FloatingInfo> { return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.floating()) } +/** The corner radius a shown page's card was given (SB-19). */ +export function pageCorner(app: ElectronApplication, tabId: string): Promise<number | null> { + return app.evaluate((_e, id) => (globalThis as unknown as Hooks).__orbitNav.corner(id), tabId) +} + +/** Puts the first window into or out of native full screen and waits until it is there (macOS animates). */ +export async function setNativeFullScreen(app: ElectronApplication, on: boolean): Promise<void> { + await app.evaluate((_e, value) => { + const { __orbit } = globalThis as unknown as Hooks + __orbit.ctx.windows.get(__orbit.windowIds()[0] as string)?.setFullScreen(value) + }, on) + await expect.poll(async () => (await windowState(app)).fullscreen, { timeout: 15_000 }).toBe(on) +} + /** Runs a command as if from the first window. */ export function cmd<T = unknown>(app: ElectronApplication, name: string, args: unknown = {}): Promise<T> { return app.evaluate( From 227be402f302e62aa87edc30e2ff0e3328695110 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:09:25 +0000 Subject: [PATCH 04/69] chrome: find in page and page zoom (NV-05, NV-06) - Find bar in a small view over the active page's top-right corner: finds as you type with an n/m count or No matches, Enter/Cmd+G/F3 and Shift+Enter/Shift+Cmd+G/Shift+F3 move between matches, Esc closes and keeps the match selected. Per tab: another tab hides it, coming back brings it with its text and place. Starts from the page's selection, else the tab's last search; input methods search once they commit. Cmd+J jumps to the selection (macOS); Edit > Find menu. - Zoom steps through Chromium's levels (25-500%) on Cmd+=/-/0 and Ctrl+wheel; Chromium zooms every tab of the host in the profile and Orbit remembers it per profile across launches. A zoom indicator with Reset shows for 1.5 s (longer while hovered). Pinch is visual zoom; Cmd+0 undoes it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 10 +- docs/feature-status.json | 4 +- src/main/core.ts | 5 +- src/main/menu.ts | 28 +- src/main/nav/index.ts | 6 + src/main/nav/page-tools.ts | 439 ++++++++++++++++++ src/main/nav/site-prefs.ts | 37 +- src/main/windows.ts | 94 +++- src/renderer/src/main.tsx | 8 + .../src/overlay/find/PageToolsApp.tsx | 201 ++++++++ src/renderer/src/overlay/find/find.css | 169 +++++++ src/shared/actions.ts | 10 + src/shared/commands/nav.ts | 13 + src/shared/nav.ts | 80 ++++ src/shared/rpc.ts | 2 +- tests/e2e/nav-support.ts | 58 ++- tests/e2e/nav.spec.ts | 314 ++++++++++++- tests/unit/nav-zoom.test.ts | 47 ++ 18 files changed, 1504 insertions(+), 21 deletions(-) create mode 100644 src/main/nav/page-tools.ts create mode 100644 src/renderer/src/overlay/find/PageToolsApp.tsx create mode 100644 src/renderer/src/overlay/find/find.css create mode 100644 tests/unit/nav-zoom.test.ts diff --git a/FEATURES.md b/FEATURES.md index 447a0dab..55f64754 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:** 485 spec’d · 127 built (38 partial) · 175 tested · 0 feel right. +**Totals:** 485 spec’d · 129 built (38 partial) · 177 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 97 | 121 | +| P0 | 174 | 99 | 123 | | P1 | 221 | 27 | 50 | | P2 | 83 | 3 | 4 | @@ -233,8 +233,8 @@ Spec: `spec/navigation.md` | NV-02 | Trackpad two-finger swipe back/forward | P1 | – | – | – | | | 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 | 🟡 partial | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet | -| NV-05 | Find in page | P0 | – | – | – | | -| NV-06 | Page zoom | P0 | – | – | – | | +| 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; Cmd+J jumps to the selection (macOS); Edit > Find menu | +| NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; 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-08 | Clicking links: plain and modified clicks | P0 | – | ✅ e2e | – | | | NV-09 | Links with target=_blank and script-opened windows | P0 | – | ✅ e2e | – | | @@ -354,7 +354,7 @@ Spec: `spec/shortcuts.md` | KS-05 | Tabs and windows | P0 | – | – | – | | | KS-06 | Switching tabs | P0 | – | – | – | | | KS-07 | Spaces | P0 | ✅ | ✅ e2e | – | Space shortcuts wired through the action table | -| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload and Stop keys done; find, zoom indicator 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; print, save page, reader mode and the rest come with their NV items | | KS-09 | Split view | P0 | – | – | – | | | KS-10 | Peek and Mini Window | P1 | – | – | – | | | KS-11 | Media | P1 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 73f2e0df..d1d99218 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -159,8 +159,10 @@ "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-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": "partial", "note": "Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet"}, + "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; Cmd+J jumps to the selection (macOS); Edit > Find menu"}, + "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, - "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload and Stop keys done; find, zoom indicator 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; print, save page, reader mode and the rest come with their NV items"}, "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-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps"}, "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"} diff --git a/src/main/core.ts b/src/main/core.ts index 265db8f7..737e53fb 100644 --- a/src/main/core.ts +++ b/src/main/core.ts @@ -78,10 +78,7 @@ export function registerCore(ctx: MainContext): void { } actions.register('tab.new', openCommandBar('new-tab')) actions.register('url.edit', openCommandBar('edit-url')) - actions.register('page.find', (c) => { - // Placeholder until the find-in-page bar lands: forward to the command bar lane's find UI when present. - ctx.toast(windowOf(c), 'Find in page is coming soon') - }) + // page.find (the find bar, NV-05) is registered by src/main/nav/page-tools.ts. actions.register('sidebar.toggle', (c) => { const windowId = windowOf(c) diff --git a/src/main/menu.ts b/src/main/menu.ts index 63ea9430..eb3e3be3 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -60,7 +60,33 @@ export function installMenu(ctx: MainContext): void { item('page.print'), ], }, - { role: 'editMenu' }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteAndMatchStyle' }, + { role: 'delete' }, + { role: 'selectAll' }, + { type: 'separator' }, + // Find in page (NV-05). + { + label: 'Find', + submenu: [ + item('page.find', 'Find…'), + item('page.findNext'), + item('page.findPrevious'), + item('page.jumpToSelection'), + ], + }, + { type: 'separator' }, + { label: 'Speech', submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }] }, + ], + }, { label: 'View', submenu: [ diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index b3766290..8b66694d 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -9,6 +9,7 @@ import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' import { activeTabOf } from '../windows' import { registerPageColor } from './page-color' +import { type PageToolsInfo, registerPageTools } from './page-tools' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' import { SitePrefs } from './site-prefs' @@ -18,6 +19,8 @@ export interface NavTestHooks { menu: () => { windowId: WindowId; direction: NavDirection; labels: string[]; icons: boolean[] } | null /** Chooses an item of the open history menu by label; `newTab` is a ⌘-click (Ctrl-click). */ chooseMenu: (label: string, newTab?: boolean) => boolean + /** A window's page tools (default: the first): what the find bar and zoom indicator show, and where (NV-05, NV-06). */ + tools: (windowId?: WindowId) => PageToolsInfo /** The corner radius of a shown page's card (0 without a frame: full screen) (SB-19). */ corner: (tabId: TabId) => number | null /** The floating sidebar of a window (default: the first): its view, and what keeps it out (SB-12). */ @@ -253,6 +256,8 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // The hidden sidebar's hover reveal (SB-12, FL-03). const reveal = registerSidebarReveal(ctx) registerPageColor(ctx) + // Find in page and page zoom (NV-05, NV-06). Registered after the tabs lane, so its zoom actions are replaced. + const pageTools = registerPageTools(ctx, prefs) // Placeholder until the site controls panel (NV-16) lands. actions.register('site.controls', (c) => ctx.toast(windowOf(c), 'Site controls are coming soon')) @@ -292,6 +297,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { return true }, corner: (tabId) => windows.cornerOf(tabId) ?? null, + tools: (windowId) => pageTools.info(windowId ?? windows.ids()[0] ?? ''), floating: (windowId) => { const id = windowId ?? windows.ids()[0] ?? '' return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts new file mode 100644 index 00000000..45264890 --- /dev/null +++ b/src/main/nav/page-tools.ts @@ -0,0 +1,439 @@ +import type { Rectangle, WebContents } from 'electron' +import { + type FindState, + findTextFromSelection, + PAGE_TOOLS, + sameZoom, + type ZoomIndicator, + zoomPercent, + zoomStep, +} from '../../shared/nav' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { CallContext } from '../registry' +import { activeTabOf } from '../windows' +import { NAV_WORLD } from './focus' +import type { SitePrefs } from './site-prefs' + +/** The zoom indicator stays this long after the last change, and while the pointer is on it (NV-06). */ +export const ZOOM_INDICATOR_MS = 1500 +/** Pinch-to-zoom reaches Chromium's own limit (NV-06). */ +const PINCH_MAX = 5 + +const SELECTION = `String(getSelection() || '')` + +/** Scrolls the page so its selection (or the focused text field) is in the middle (⌘J). */ +const JUMP_TO_SELECTION = `(() => { + const active = document.activeElement + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { + active.scrollIntoView({ block: 'center', inline: 'nearest' }) + return true + } + const s = getSelection() + if (!s || s.rangeCount === 0) return false + const node = s.getRangeAt(0).startContainer + const el = node instanceof Element ? node : node.parentElement + if (!el) return false + el.scrollIntoView({ block: 'center', inline: 'nearest' }) + return true +})()` + +interface FindSession { + open: boolean + query: string + active: number + matches: number + searched: boolean + focus: number + /** The latest find request: results of older ones are ignored. */ + request: number + /** Counts the bar's edits, so a late selection never replaces what was typed. */ + edits: number +} + +interface WindowTools { + zoom: ZoomIndicator | null + zoomTimer?: NodeJS.Timeout + hovered: boolean + /** What the view was last told, so a layout pass doesn't repeat it. */ + sent?: string +} + +export interface PageToolsInfo { + find: FindState + zoom: ZoomIndicator | null + /** The pointer is on the zoom indicator (it stays while it is). */ + hovered: boolean + placed: boolean + bounds?: Rectangle +} + +const CLOSED: FindState = { + tabId: null, + open: false, + query: '', + active: 0, + matches: 0, + searched: false, + focus: 0, +} + +/** The host whose zoom Orbit remembers (web pages only). */ +function zoomHost(url: string): string | undefined { + try { + const u = new URL(url) + return u.protocol === 'http:' || u.protocol === 'https:' ? u.hostname : undefined + } catch { + return undefined + } +} + +/** Where the page tools go: their cards' top-right corner `inset` px inside the page's, plus the shadow's margin. */ +function toolsBounds(page: Rectangle, find: boolean, zoom: boolean): Rectangle { + const { margin, inset, gap, findWidth, findHeight, zoomWidth, zoomHeight } = PAGE_TOOLS + const room = Math.max(120, page.width - 2 * inset) + const width = Math.min(find ? findWidth : zoomWidth, room) + const height = (find ? findHeight : 0) + (find && zoom ? gap : 0) + (zoom ? zoomHeight : 0) + return { + x: page.x + page.width - inset - width - margin, + y: page.y + inset - margin, + width: width + 2 * margin, + height: height + 2 * margin, + } +} + +/** + * Find in page (NV-05) and page zoom (NV-06). Both show in the page tools view over the top-right + * corner of the active page: the find bar while it is open for that tab, and a short-lived zoom + * indicator after each zoom change. Find is per tab (switching tabs hides the bar, returning brings + * it back with its matches); zoom is Chromium's per-host zoom, remembered per profile across launches. + */ +export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: (windowId: WindowId) => PageToolsInfo } { + const { store, commands, actions, tabs, windows } = ctx + const finds = new Map<TabId, FindSession>() + const tools = new Map<WindowId, WindowTools>() + let focusNonce = 0 + let zoomNonce = 0 + + const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() + const toolsOf = (windowId: WindowId): WindowTools => { + let t = tools.get(windowId) + if (!t) { + t = { zoom: null, hovered: false } + tools.set(windowId, t) + } + return t + } + const pageOf = (tabId: TabId | null | undefined): WebContents | undefined => { + const contents = tabId ? tabs.contents(tabId) : undefined + return contents && !contents.isDestroyed() ? contents : undefined + } + const activeOf = (windowId: WindowId | undefined): TabId | null => + windowId ? activeTabOf(store.state, windowId) : null + + const findState = (windowId: WindowId): FindState => { + const tabId = activeOf(windowId) + const s = tabId ? finds.get(tabId) : undefined + if (!tabId || !s) return { ...CLOSED, tabId } + const { open, query, active, matches, searched, focus } = s + return { tabId, open, query, active, matches, searched, focus } + } + + /** Shows, moves or hides a window's page tools to match what they hold. */ + const refresh = (windowId: WindowId) => { + if (!windows.get(windowId)) { + clearTimeout(tools.get(windowId)?.zoomTimer) + tools.delete(windowId) + return + } + const t = tools.get(windowId) + const find = findState(windowId) + const zoom = t?.zoom ?? null + const page = find.tabId ? windows.tabViewBounds(find.tabId) : undefined + if (!(find.open || zoom) || !page || windows.inHtmlFullscreen(windowId)) { + // Nothing is under the pointer once the view has gone. + if (t) t.hovered = false + windows.hideTools(windowId) + return + } + const contents = windows.toolsContents(windowId) + if (!contents) return + const state = toolsOf(windowId) + const payload = { find, zoom } + const json = JSON.stringify(payload) + if (state.sent !== json) { + state.sent = json + contents.send('orbit:pageTools', payload) + } + windows.showTools(windowId, toolsBounds(page, find.open, !!zoom)) + } + + const refreshTab = (tabId: TabId) => { + const windowId = windows.windowOfTab(tabId) + if (windowId && activeOf(windowId) === tabId) refresh(windowId) + } + + // ---------- Find in page (NV-05) ---------- + + const sessionOf = (tabId: TabId): FindSession => { + let s = finds.get(tabId) + if (!s) { + s = { open: false, query: '', active: 0, matches: 0, searched: false, focus: 0, request: 0, edits: 0 } + finds.set(tabId, s) + } + return s + } + + const clearMatches = (s: FindSession) => { + s.request = 0 + s.active = 0 + s.matches = 0 + s.searched = false + } + + /** Highlights the session's text: a new search (`fresh`) or the next or previous match. */ + const search = (contents: WebContents, s: FindSession, fresh: boolean, forward = true) => { + if (!s.query) { + clearMatches(s) + contents.stopFindInPage('clearSelection') + return + } + try { + s.request = contents.findInPage(s.query, { forward, findNext: fresh }) + } catch { + s.request = 0 + } + } + + const focusBar = (windowId: WindowId, s: FindSession) => { + s.focus = ++focusNonce + refresh(windowId) + windows.toolsContents(windowId)?.focus() + } + + /** ⌘F: the bar opens (or its text is selected again), starting from the page's selection or the tab's last search. */ + const openFind = (windowId: WindowId | undefined) => { + const tabId = activeOf(windowId) + const contents = pageOf(tabId) + if (!windowId || !tabId || !contents) return + const s = sessionOf(tabId) + if (s.open) { + focusBar(windowId, s) + return + } + s.open = true + clearMatches(s) + if (s.query) search(contents, s, true) + focusBar(windowId, s) + // Selected text on the page starts a new search instead (asked after the bar is up, so no key is lost). + const edits = s.edits + contents + .executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code: SELECTION }]) + .then((selected: unknown) => { + const text = typeof selected === 'string' ? findTextFromSelection(selected) : '' + if (!text || text === s.query || !s.open || s.edits !== edits || finds.get(tabId) !== s) return + s.query = text + search(contents, s, true) + if (activeOf(windowId) === tabId) focusBar(windowId, s) + }) + .catch(() => {}) + } + + const step = (windowId: WindowId | undefined, forward: boolean) => { + const tabId = activeOf(windowId) + const contents = pageOf(tabId) + if (!windowId || !tabId || !contents) return + const s = finds.get(tabId) + if (!s?.query) { + openFind(windowId) + return + } + if (!s.open) { + // ⌘G with the bar closed opens it on the last search. + s.open = true + clearMatches(s) + search(contents, s, true) + focusBar(windowId, s) + return + } + search(contents, s, !s.searched && s.request === 0, forward) + } + + const closeFind = (windowId: WindowId | undefined) => { + const tabId = activeOf(windowId) + const s = tabId ? finds.get(tabId) : undefined + if (!windowId || !tabId || !s?.open) return + s.open = false + clearMatches(s) + pageOf(tabId)?.stopFindInPage('keepSelection') + refresh(windowId) + windows.focusActiveTab(windowId) + } + + actions.register('page.find', (c) => openFind(windowOf(c))) + actions.register('page.jumpToSelection', (c) => { + void pageOf(activeOf(windowOf(c))) + ?.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code: JUMP_TO_SELECTION }]) + .catch(() => {}) + }) + actions.register('page.findNext', (c) => step(windowOf(c), true)) + actions.register('page.findPrevious', (c) => step(windowOf(c), false)) + + commands.register('find.update', ({ query }, c) => { + const windowId = windowOf(c) + const tabId = activeOf(windowId) + const contents = pageOf(tabId) + if (!windowId || !tabId || !contents) return + const s = sessionOf(tabId) + if (!s.open) return + s.edits++ + if (s.query === query) return + s.query = query + search(contents, s, true) + if (!query) refresh(windowId) + }) + commands.register('find.step', ({ forward }, c) => step(windowOf(c), forward)) + commands.register('find.close', (_args, c) => closeFind(windowOf(c))) + + // ---------- Page zoom (NV-06) ---------- + + const hideZoom = (windowId: WindowId) => { + const t = tools.get(windowId) + if (!t?.zoom) return + clearTimeout(t.zoomTimer) + t.zoomTimer = undefined + t.zoom = null + refresh(windowId) + } + + const showZoom = (windowId: WindowId, factor: number) => { + const t = toolsOf(windowId) + t.zoom = { percent: zoomPercent(factor), nonce: ++zoomNonce } + clearTimeout(t.zoomTimer) + t.zoomTimer = t.hovered ? undefined : setTimeout(() => hideZoom(windowId), ZOOM_INDICATOR_MS) + refresh(windowId) + } + + /** Chromium zooms every tab of the host in the profile's session; Orbit also keeps it for next time. */ + const remember = (tabId: TabId, url: string, factor: number) => { + const tab = store.state.tabs[tabId] + const host = zoomHost(url) + if (tab && host) prefs.setZoom(tab.profileId, host, factor) + } + + const allowPinch = (contents: WebContents) => { + void contents.setVisualZoomLevelLimits(1, PINCH_MAX).catch(() => {}) + } + + /** ⌘0 also undoes a pinch: the scale limits close in on 1 and open again. */ + const resetPinch = (contents: WebContents) => { + void contents + .setVisualZoomLevelLimits(1, 1) + .then(() => (contents.isDestroyed() ? undefined : contents.setVisualZoomLevelLimits(1, PINCH_MAX))) + .catch(() => {}) + } + + /** Zooms a tab one step in (1) or out (-1), or back to 100% (0), and shows the indicator in its window. */ + const zoom = (windowId: WindowId, tabId: TabId, direction: 1 | -1 | 0) => { + const contents = pageOf(tabId) + if (!contents) return + const before = contents.getZoomFactor() + const next = direction === 0 ? 1 : zoomStep(before, direction) + if (!sameZoom(before, next)) contents.setZoomFactor(next) + if (direction === 0) resetPinch(contents) + remember(tabId, contents.getURL(), next) + showZoom(windowId, next) + } + + const zoomAction = (direction: 1 | -1 | 0) => (c: CallContext) => { + const windowId = windowOf(c) + const tabId = activeOf(windowId) + if (windowId && tabId) zoom(windowId, tabId, direction) + } + actions.register('page.zoomIn', zoomAction(1)) + actions.register('page.zoomOut', zoomAction(-1)) + actions.register('page.zoomReset', zoomAction(0)) + + commands.register('zoom.get', ({ tabId }, c) => { + const contents = pageOf(tabId ?? activeOf(windowOf(c))) + return { percent: contents ? zoomPercent(contents.getZoomFactor()) : 100 } + }) + + // ---------- The view ---------- + + commands.register('pageTools.state', (_args, c) => { + const windowId = windowOf(c) + return { + find: windowId ? findState(windowId) : CLOSED, + zoom: windowId ? (tools.get(windowId)?.zoom ?? null) : null, + } + }) + commands.register('pageTools.hover', ({ over }, c) => { + const windowId = windowOf(c) + if (!windowId) return + const t = toolsOf(windowId) + t.hovered = over + clearTimeout(t.zoomTimer) + t.zoomTimer = !over && t.zoom ? setTimeout(() => hideZoom(windowId), ZOOM_INDICATOR_MS) : undefined + }) + + // The tools follow the active page: tab switches, resizes, splits and full screen. + windows.onLayout((windowId) => { + const t = tools.get(windowId) + const tabId = activeOf(windowId) + if (t?.zoom || (tabId && finds.get(tabId)?.open) || windows.toolsInfo(windowId).placed) refresh(windowId) + }) + + ctx.hooks.tabCreated.push((tabId, contents) => { + allowPinch(contents) + contents.on('found-in-page', (_event, result) => { + const s = finds.get(tabId) + if (!s?.open || result.requestId !== s.request) return + s.matches = result.matches + s.active = result.activeMatchOrdinal + s.searched = true + refreshTab(tabId) + }) + contents.on('did-navigate', () => { + // A new page: the old matches are gone (the bar stays open with its text). + allowPinch(contents) + const s = finds.get(tabId) + if (s?.open && (s.request || s.searched)) { + clearMatches(s) + refreshTab(tabId) + } + }) + // Ctrl+wheel (Windows, Linux) zooms the page under the pointer. + contents.on('zoom-changed', (_event, direction) => { + const windowId = windows.windowOfTab(tabId) + if (windowId) zoom(windowId, tabId, direction === 'in' ? 1 : -1) + }) + contents.once('destroyed', () => { + const s = finds.get(tabId) + finds.delete(tabId) + if (s?.open) for (const windowId of windows.ids()) if (activeOf(windowId) === tabId) refresh(windowId) + }) + }) + + // A page opens at the zoom its host had in this profile. + ctx.hooks.tabNavigated.push((tabId, contents, url) => { + const tab = store.state.tabs[tabId] + const host = zoomHost(url) + if (!tab || !host || contents.isDestroyed()) return + const want = prefs.zoomFor(tab.profileId, host) ?? 1 + if (!sameZoom(contents.getZoomFactor(), want)) contents.setZoomFactor(want) + }) + + return { + info: (windowId) => { + const placed = windows.toolsInfo(windowId) + return { + find: findState(windowId), + zoom: tools.get(windowId)?.zoom ?? null, + hovered: tools.get(windowId)?.hovered ?? false, + placed: placed.placed, + bounds: placed.placed ? placed.bounds : undefined, + } + }, + } +} diff --git a/src/main/nav/site-prefs.ts b/src/main/nav/site-prefs.ts index 96463a6a..0b1c0ded 100644 --- a/src/main/nav/site-prefs.ts +++ b/src/main/nav/site-prefs.ts @@ -1,19 +1,25 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { mkdir, rename, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { sameZoom, ZOOM_STEPS } from '../../shared/nav' interface SitePrefsData { /** One-time hints already shown (e.g. the ⌘⇧C tip, NV-17). */ hints: Record<string, true> + /** Page zoom per profile and host, when not 100% (NV-06). */ + zoom: Record<string, Record<string, number>> } +const MIN_ZOOM = ZOOM_STEPS[0] as number +const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1] as number + /** * The chrome lane's small per-user settings, kept out of OrbitState so they never travel to the * renderers: one-time hints now, per-site choices later. Saved off the main thread shortly after * a change, and synchronously on quit. Private windows never write here. */ export class SitePrefs { - private data: SitePrefsData = { hints: {} } + private data: SitePrefsData = { hints: {}, zoom: {} } private timer: NodeJS.Timeout | undefined private dirty = false @@ -24,7 +30,16 @@ export class SitePrefs { const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<SitePrefsData> const hints: Record<string, true> = {} for (const [id, shown] of Object.entries(raw.hints ?? {})) if (shown === true) hints[id] = true - this.data = { hints } + const zoom: Record<string, Record<string, number>> = {} + for (const [profile, hosts] of Object.entries(raw.zoom ?? {})) { + if (!hosts || typeof hosts !== 'object') continue + for (const [host, factor] of Object.entries(hosts)) { + if (typeof factor !== 'number' || !(factor >= MIN_ZOOM && factor <= MAX_ZOOM) || sameZoom(factor, 1)) continue + zoom[profile] ??= {} + ;(zoom[profile] as Record<string, number>)[host] = factor + } + } + this.data = { hints, zoom } } catch { // No file yet, or unreadable: start empty. } @@ -40,6 +55,24 @@ export class SitePrefs { this.schedule() } + /** The page zoom remembered for a host in a profile (NV-06); undefined means 100%. */ + zoomFor(profileId: string, host: string): number | undefined { + return this.data.zoom[profileId]?.[host] + } + + setZoom(profileId: string, host: string, factor: number): void { + const hosts = this.data.zoom[profileId] + if (sameZoom(factor, 1)) { + if (!hosts || hosts[host] === undefined) return + delete hosts[host] + if (Object.keys(hosts).length === 0) delete this.data.zoom[profileId] + } else { + if (hosts?.[host] !== undefined && sameZoom(hosts[host] as number, factor)) return + this.data.zoom[profileId] = { ...hosts, [host]: factor } + } + this.schedule() + } + /** Writes pending changes now (quit). */ flush(): void { if (!this.dirty) return diff --git a/src/main/windows.ts b/src/main/windows.ts index 15c07a5c..cec9e2d6 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -54,6 +54,13 @@ interface WindowRecord { floatingDispose?: ReturnType<typeof setTimeout> /** macOS: pending return of the traffic lights once a docking sidebar has slid in. */ buttonsTimer?: ReturnType<typeof setTimeout> + /** + * The page tools (find bar, zoom indicator; NV-05, NV-06): a small view over the active page's + * top-right corner, started a little after the window opens and placed only while it shows something. + */ + tools?: WebContentsView + toolsShown: boolean + toolsPrewarm?: ReturnType<typeof setTimeout> } /** A tab view placed in a window. */ @@ -66,6 +73,8 @@ interface Attachment { /** How long the overlay view stays up after closing so its exit animation can play (FL-16). */ const OVERLAY_EXIT_MS = 110 +/** The page tools' renderer starts this long after the window shows (NV-05: ⌘F finds the bar ready). */ +const TOOLS_PREWARM_MS = 1500 /** The floating sidebar's card sits this far from the window's edges (SB-12, SB-19). */ export const FLOAT_INSET = 6 @@ -146,6 +155,7 @@ export class WindowManager { private uiContents = new Map<number, WindowId>() /** Each tab's view that is placed in a window, and which window. Compared by view, not tab id. */ private attached = new Map<TabId, Attachment>() + private layoutListeners: ((windowId: WindowId) => void)[] = [] /** Tabs whose page closed its own web contents (window.close()): not reopened until selected again. */ private selfClosed = new Set<TabId>() /** Main window that most recently had focus (fallback when no window has focus). */ @@ -302,6 +312,7 @@ export class WindowManager { minPaneSeq: 0, overlayShown: false, floatingShown: false, + toolsShown: false, } this.records.set(id, rec) // macOS: the traffic lights live in the sidebar's top row (SB-20). @@ -315,6 +326,11 @@ export class WindowManager { if (!win.isDestroyed()) this.ensureOverlay(rec) }, 120) if (!this.ctx.store.state.windows[id]?.sidebar.visible) this.prewarmFloating(rec, 600) + // The page tools start once the window has settled, so ⌘F finds a ready bar. + rec.toolsPrewarm = setTimeout(() => { + rec.toolsPrewarm = undefined + if (!win.isDestroyed()) this.ensureTools(rec) + }, TOOLS_PREWARM_MS) }) win.on('focus', () => { this.lastFocused = id @@ -374,7 +390,9 @@ export class WindowManager { clearTimeout(rec.floatingPrewarm) clearTimeout(rec.floatingDispose) clearTimeout(rec.buttonsTimer) + clearTimeout(rec.toolsPrewarm) if (rec.floating && !rec.floating.webContents.isDestroyed()) rec.floating.webContents.close() + if (rec.tools && !rec.tools.webContents.isDestroyed()) rec.tools.webContents.close() this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { if (entry.windowId === id) { @@ -542,6 +560,59 @@ export class WindowManager { if (panes.length === 1) rec.lastSingleRect = panes[0]?.rect if (rec.htmlFullscreen) this.layoutHtmlFullscreen(rec, wanted) this.focusIfNewlyShown(rec, shownBefore) + for (const listener of this.layoutListeners) listener(windowId) + } + + /** Runs after a window's pages were laid out again (tab switch, resize, split, full screen). */ + onLayout(listener: (windowId: WindowId) => void): void { + this.layoutListeners.push(listener) + } + + /** The page tools' web contents (NV-05, NV-06), started if it wasn't yet. */ + toolsContents(windowId: WindowId): WebContents | undefined { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed()) return undefined + const contents = this.ensureTools(rec).webContents + return contents.isDestroyed() ? undefined : contents + } + + /** Places the page tools at `bounds` (window coordinates): above the pages, under the floating sidebar and the overlay. */ + showTools(windowId: WindowId, bounds: Rect): void { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed()) return + const view = this.ensureTools(rec) + view.setBounds(roundRect(bounds)) + rec.toolsShown = true + const parent = rec.win.contentView + const children = parent.children + const at = children.indexOf(view) + const above = [rec.overlay, rec.floating].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const ceiling = above.length > 0 ? Math.min(...above) : -1 + if (ceiling < 0) { + if (at !== children.length - 1) parent.addChildView(view) + } else if (at !== ceiling - 1) { + parent.addChildView(view, at >= 0 && at < ceiling ? ceiling - 1 : ceiling) + } + view.setVisible(true) + } + + /** Takes the page tools out of the window; keyboard focus in them goes back to the page. */ + hideTools(windowId: WindowId): void { + const rec = this.records.get(windowId) + const view = rec?.tools + if (!rec || !view || !rec.toolsShown) return + rec.toolsShown = false + const focused = !view.webContents.isDestroyed() && webContents.getFocusedWebContents() === view.webContents + if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) + this.removeChild(rec, view) + } + + /** Test/debug view of a window's page tools. */ + toolsInfo(windowId: WindowId): { exists: boolean; placed: boolean; bounds?: Rect } { + const rec = this.records.get(windowId) + const view = rec?.tools + if (!rec || !view || view.webContents.isDestroyed()) return { exists: false, placed: false } + return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } } /** A page that just appeared for the focused tab or pane (e.g. a split pane filled late) takes keyboard focus. */ @@ -761,8 +832,10 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - // The overlay and the floating sidebar (SB-12) stay above every page. - const above = [rec.overlay, rec.floating].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + // The overlay, the floating sidebar (SB-12) and the page tools (NV-05) stay above every page. + const above = [rec.overlay, rec.floating, rec.tools] + .map((v) => (v ? children.indexOf(v) : -1)) + .filter((i) => i >= 0) const overlayAt = above.length > 0 ? Math.min(...above) : -1 if (overlayAt < 0) { if (at < 0 || at !== children.length - 1) parent.addChildView(view) @@ -1011,6 +1084,23 @@ export class WindowManager { if (!view.webContents.isDestroyed()) view.webContents.close() } + /** The page tools' view (NV-05, NV-06): transparent, sized by the nav module to what it shows. */ + private ensureTools(rec: WindowRecord): WebContentsView { + if (rec.tools && !rec.tools.webContents.isDestroyed()) return rec.tools + clearTimeout(rec.toolsPrewarm) + rec.toolsPrewarm = undefined + const view = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'tools') }) + view.setBackgroundColor('#00000000') + this.registerUi(view.webContents, rec.id) + this.reloadUiOnCrash(view.webContents, 'tools') + this.attachShortcuts(view.webContents) + this.hardenUi(view.webContents) + loadRenderer(view.webContents, 'tools') + rec.tools = view + rec.toolsShown = false + return view + } + /** The transparent overlay view (command bar, Peek chrome, menus), created on first need. */ private ensureOverlay(rec: WindowRecord): WebContentsView { if (rec.overlay) return rec.overlay diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 82ab6f58..05063e98 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -29,6 +29,14 @@ async function boot(): Promise<void> { <FloatingSidebarApp /> </StrictMode>, ) + } else if (view === 'tools') { + // The page tools over the active page's corner (chrome lane, NV-05, NV-06): find bar and zoom indicator. + const { PageToolsApp } = await import('./overlay/find/PageToolsApp') + root.render( + <StrictMode> + <PageToolsApp /> + </StrictMode>, + ) } else if (view === 'strip') { // Split view pane strip (split lane, SV-12): a tiny transparent view over a pane's top edge. const { PaneStripApp } = await import('./shell/split/PaneStripApp') diff --git a/src/renderer/src/overlay/find/PageToolsApp.tsx b/src/renderer/src/overlay/find/PageToolsApp.tsx new file mode 100644 index 00000000..266de737 --- /dev/null +++ b/src/renderer/src/overlay/find/PageToolsApp.tsx @@ -0,0 +1,201 @@ +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { type CSSProperties, useEffect, useRef, useState } from 'react' +import type { FindState, ZoomIndicator } from '../../../../shared/nav' +import { invoke, runAction } from '../../api' +import { withShortcut } from '../../shell/chrome/shortcut-label' +import { useWindow } from '../../store' +import { paletteVars, usePalette } from '../../theme' +import './find.css' + +interface ToolsState { + find: FindState + zoom: ZoomIndicator | null +} + +/** The last focus request the bar acted on: returning to a tab shows its bar without taking focus. */ +let focusTaken = 0 + +/** + * The page tools (NV-05, NV-06): a small transparent view main keeps over the top-right corner of + * the active page. It holds the find bar while it is open for that tab and the zoom indicator for a + * moment after each zoom change; main sizes and places the view to fit what shows. + */ +export function PageToolsApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [state, setState] = useState<ToolsState | null>(null) + useEffect(() => { + let alive = true + let pushed = false + const off = window.orbit.on('pageTools', (payload) => { + pushed = true + setState(payload as ToolsState) + }) + // Main may have opened the bar before this renderer was ready to hear it. + void invoke('pageTools.state', {}).then((s) => { + if (alive && !pushed) setState(s) + }) + return () => { + alive = false + off() + } + }, []) + const find = state?.find + const zoom = state?.zoom + return ( + <div + className={`page-tools${palette?.dark ? ' page-tools--dark' : ''}`} + style={paletteVars(palette) as CSSProperties} + data-testid="page-tools" + > + {find?.open && <FindBar key={find.tabId ?? ''} state={find} />} + {zoom && <ZoomPill key={zoom.nonce} zoom={zoom} />} + </div> + ) +} + +function FindBar({ state }: { state: FindState }) { + const input = useRef<HTMLInputElement>(null) + const composing = useRef(false) + const [empty, setEmpty] = useState(state.query === '') + + // ⌘F (or a selection to search for): the field takes main's text, selected, and keyboard focus. + useEffect(() => { + const el = input.current + if (!el) return + if (state.focus > focusTaken) { + focusTaken = state.focus + el.value = state.query + setEmpty(state.query === '') + el.focus() + el.select() + } else if (document.activeElement !== el && el.value !== state.query) { + el.value = state.query + setEmpty(state.query === '') + } + }, [state.focus, state.query]) + + const send = () => { + const el = input.current + if (!el) return + setEmpty(el.value === '') + void invoke('find.update', { query: el.value }) + } + + const count = !state.query + ? '' + : !state.searched + ? '' + : state.matches === 0 + ? 'No matches' + : `${Math.max(state.active, 1)}/${state.matches}` + const none = state.searched && !!state.query && state.matches === 0 + + return ( + <div className={`find-bar${none ? ' find-bar--none' : ''}`} data-testid="find-bar" role="search"> + <input + ref={input} + className="find-bar__input" + data-testid="find-input" + type="text" + defaultValue={state.query} + placeholder="Find in page" + aria-label="Find in page" + spellCheck={false} + autoComplete="off" + onInput={() => { + // An input method composes first: search for what it commits, not each step. + if (!composing.current) send() + }} + onCompositionStart={() => { + composing.current = true + }} + onCompositionEnd={() => { + composing.current = false + send() + }} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing || composing.current) return + if (e.key === 'Enter') { + e.preventDefault() + void invoke('find.step', { forward: !e.shiftKey }) + } else if (e.key === 'Escape') { + e.preventDefault() + void invoke('find.close', {}) + } + }} + /> + <span + className="find-bar__count" + data-testid="find-count" + data-empty={none ? 'true' : 'false'} + aria-live="polite" + hidden={empty && !count} + > + {count} + </span> + <span className="find-bar__sep" aria-hidden /> + <button + type="button" + className="find-bar__button" + data-testid="find-prev" + aria-label="Previous match" + title={withShortcut('Previous match', 'page.findPrevious')} + disabled={state.matches === 0} + onMouseDown={(e) => e.preventDefault()} + onClick={() => void invoke('find.step', { forward: false })} + > + <ChevronUp size={16} /> + </button> + <button + type="button" + className="find-bar__button" + data-testid="find-next" + aria-label="Next match" + title={withShortcut('Next match', 'page.findNext')} + disabled={state.matches === 0} + onMouseDown={(e) => e.preventDefault()} + onClick={() => void invoke('find.step', { forward: true })} + > + <ChevronDown size={16} /> + </button> + <button + type="button" + className="find-bar__button" + data-testid="find-close" + aria-label="Close find bar" + title="Close (Esc)" + onClick={() => void invoke('find.close', {})} + > + <X size={15} /> + </button> + </div> + ) +} + +function ZoomPill({ zoom }: { zoom: ZoomIndicator }) { + return ( + <div + className="zoom-pill" + data-testid="zoom-indicator" + role="status" + onMouseEnter={() => void invoke('pageTools.hover', { over: true })} + onMouseLeave={() => void invoke('pageTools.hover', { over: false })} + > + <span className="zoom-pill__percent" data-testid="zoom-percent"> + {zoom.percent}% + </span> + {zoom.percent !== 100 && ( + <button + type="button" + className="zoom-pill__reset" + data-testid="zoom-reset" + title={withShortcut('Reset zoom', 'page.zoomReset')} + onClick={() => runAction('page.zoomReset')} + > + Reset + </button> + )} + </div> + ) +} diff --git a/src/renderer/src/overlay/find/find.css b/src/renderer/src/overlay/find/find.css new file mode 100644 index 00000000..566b353e --- /dev/null +++ b/src/renderer/src/overlay/find/find.css @@ -0,0 +1,169 @@ +/* + * The page tools' own view (view=tools, NV-05, NV-06): transparent, over the active page's top-right + * corner, as big as what shows plus an 8 px margin for the shadow (PAGE_TOOLS in shared/nav.ts). + */ +html[data-view="tools"] body { + background: transparent; + user-select: none; +} + +.page-tools { + --tools-bg: color-mix(in srgb, var(--frame-top, #e9eaf5) 12%, rgba(255, 255, 255, 0.98)); + --tools-text: #1c1c1f; + --tools-muted: rgba(28, 28, 31, 0.52); + --tools-line: rgba(0, 0, 0, 0.08); + --tools-hover: rgba(0, 0, 0, 0.06); + --tools-none: #c4314b; + --tools-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.16), 0 4px 14px rgba(0, 0, 0, 0.14), 0 1px 3px rgba(0, 0, 0, 0.08); + position: fixed; + inset: 0; + padding: 8px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + color: var(--tools-text); +} + +.page-tools--dark { + --tools-bg: color-mix(in srgb, var(--frame-top, #2a2b33) 22%, rgba(32, 32, 36, 0.98)); + --tools-text: #f2f2f4; + --tools-muted: rgba(242, 242, 244, 0.55); + --tools-line: rgba(255, 255, 255, 0.1); + --tools-hover: rgba(255, 255, 255, 0.08); + --tools-none: #ff8095; + --tools-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.12), 0 4px 14px rgba(0, 0, 0, 0.42), 0 1px 3px rgba(0, 0, 0, 0.3); +} + +@keyframes page-tools-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } +} + +/* ---------- Find bar (NV-05) ---------- */ + +.find-bar { + box-sizing: border-box; + width: 100%; + height: 44px; + flex: none; + display: flex; + align-items: center; + gap: 2px; + padding: 0 6px 0 12px; + border-radius: 10px; + background: var(--tools-bg); + box-shadow: var(--tools-shadow); + animation: page-tools-in 120ms var(--ease-out); + transform-origin: 100% 0; +} + +.find-bar__input { + flex: 1; + min-width: 0; + height: 30px; + border: none; + outline: none; + background: none; + color: inherit; + font: inherit; + font-size: 13px; + user-select: text; +} + +.find-bar__input::placeholder { + color: var(--tools-muted); +} + +.find-bar__count { + flex: none; + padding: 0 6px; + color: var(--tools-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.find-bar--none .find-bar__count { + color: var(--tools-none); +} + +.find-bar__sep { + flex: none; + width: 1px; + height: 18px; + margin: 0 4px; + background: var(--tools-line); +} + +.find-bar__button { + flex: none; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 7px; + background: none; + color: inherit; +} + +.find-bar__button:hover:not(:disabled) { + background: var(--tools-hover); +} + +.find-bar__button:disabled { + opacity: 0.35; +} + +/* ---------- Zoom indicator (NV-06) ---------- */ + +.zoom-pill { + box-sizing: border-box; + height: 36px; + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 0 6px 0 14px; + border-radius: 18px; + background: var(--tools-bg); + box-shadow: var(--tools-shadow); + animation: page-tools-in 120ms var(--ease-out); + transform-origin: 100% 0; +} + +.zoom-pill:not(:has(.zoom-pill__reset)) { + padding-right: 14px; +} + +.zoom-pill__percent { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.zoom-pill__reset { + height: 26px; + padding: 0 10px; + border: none; + border-radius: 13px; + background: var(--tools-hover); + color: inherit; + font: inherit; + font-size: 12px; +} + +.zoom-pill__reset:hover { + background: var(--tools-line); +} + +@media (prefers-reduced-motion: reduce) { + .find-bar, + .zoom-pill { + animation-name: none; + } +} diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 1e7faf62..062c1e13 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -187,6 +187,16 @@ export const ACTIONS: ActionDef[] = [ }, { id: 'nav.stop', title: 'Stop Loading', category: 'Navigation', mac: ['Cmd+.'], other: [] }, { id: 'page.find', title: 'Find in Page', category: 'Page', mac: ['Cmd+F'], other: ['Ctrl+F'] }, + { id: 'page.findNext', title: 'Find Next', category: 'Page', mac: ['Cmd+G'], other: ['F3', 'Ctrl+G'] }, + { + id: 'page.findPrevious', + title: 'Find Previous', + category: 'Page', + mac: ['Cmd+Shift+G'], + other: ['Shift+F3', 'Ctrl+Shift+G'], + }, + // Scrolls the page to its selection (KS-08: macOS only, the Edit menu convention). + { id: 'page.jumpToSelection', title: 'Jump to Selection', category: 'Page', mac: ['Cmd+J'], other: [] }, { id: 'site.controls', title: 'Site Controls', diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index 74c27fc4..e4575901 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -1,3 +1,4 @@ +import type { FindState, ZoomIndicator } from '../nav' import type { TabId } from '../state' /** One entry of a tab's back/forward history, as the history menu lists it (NV-01). */ @@ -56,4 +57,16 @@ export interface NavCommands { 'sidebar.peekState': { args: { over?: boolean; editing?: boolean }; result: void } /** A click beside the floating sidebar's card: the card hides at once and the click goes on to the page. */ 'sidebar.peekDismiss': { args: { x: number; y: number; button: 'left' | 'middle' | 'right' }; result: void } + /** What the page tools view shows for its window: the find bar and the zoom indicator (NV-05, NV-06). */ + 'pageTools.state': { args: Record<string, never>; result: { find: FindState; zoom: ZoomIndicator | null } } + /** The pointer is over the page tools (the zoom indicator stays while it is). */ + 'pageTools.hover': { args: { over: boolean }; result: void } + /** The find bar's text changed: highlight its matches in the active tab's page (NV-05). */ + 'find.update': { args: { query: string }; result: void } + /** Next (`forward`) or previous match (Enter, ⇧Enter, ⌘G, ⇧⌘G). */ + 'find.step': { args: { forward: boolean }; result: void } + /** Closes the find bar: highlights go, the page keeps the last match selected and gets focus. */ + 'find.close': { args: Record<string, never>; result: void } + /** The active tab's page zoom as a percentage, and whether it differs from the default (NV-06, NV-16). */ + 'zoom.get': { args: { tabId?: TabId }; result: { percent: number } } } diff --git a/src/shared/nav.ts b/src/shared/nav.ts index 44ab33fc..d95b6d6f 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -68,3 +68,83 @@ export function historyEntryLabel(title: string, url: string, max = 60): string const text = title.trim() || url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/$/, '') || url return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text } + +/** Chromium's page zoom steps (NV-06), as zoom factors. */ +export const ZOOM_STEPS: readonly number[] = [ + 0.25, + 1 / 3, + 0.5, + 2 / 3, + 0.75, + 0.8, + 0.9, + 1, + 1.1, + 1.25, + 1.5, + 1.75, + 2, + 2.5, + 3, + 4, + 5, +] + +const ZOOM_EPSILON = 0.001 + +/** The next zoom step in (`+1`) or out (`-1`) from a factor; stays put at either end. */ +export function zoomStep(factor: number, direction: 1 | -1): number { + if (direction > 0) return ZOOM_STEPS.find((s) => s > factor + ZOOM_EPSILON) ?? factor + return [...ZOOM_STEPS].reverse().find((s) => s < factor - ZOOM_EPSILON) ?? factor +} + +/** A zoom factor as the whole percentage the indicator shows (1/3 → 33). */ +export function zoomPercent(factor: number): number { + return Math.round(factor * 100) +} + +export function sameZoom(a: number, b: number): boolean { + return Math.abs(a - b) < ZOOM_EPSILON +} + +/** What the find bar shows for the window's active tab (NV-05). */ +export interface FindState { + tabId: string | null + open: boolean + query: string + /** 1-based position of the current match; 0 when there is none. */ + active: number + matches: number + /** The count belongs to `query` on this page (false while typing starts, or after the page changed). */ + searched: boolean + /** Changes each time the field should take focus with its text selected (⌘F). */ + focus: number +} + +/** The transient zoom indicator (NV-06); `null` when hidden. */ +export interface ZoomIndicator { + percent: number + /** Changes on every zoom change, so the indicator can pulse. */ + nonce: number +} + +/** + * The page tools view: a small transparent view over the top-right corner of the active page that + * holds the find bar and the zoom indicator. Sizes in px; `margin` leaves room for the shadow. + */ +export const PAGE_TOOLS = { + margin: 8, + /** The bar's distance from the page's top and right edges. */ + inset: 10, + gap: 6, + findWidth: 380, + findHeight: 44, + zoomWidth: 184, + zoomHeight: 36, +} as const + +/** The text ⌘F starts from when the page has a selection: its first non-empty line, trimmed and capped. */ +export function findTextFromSelection(selection: string, max = 200): string { + const line = selection.split(/\r?\n/).find((l) => l.trim() !== '') ?? '' + return line.trim().slice(0, max) +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 7616f4f1..d03cb6ee 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -61,7 +61,7 @@ export interface Rect { } /** Which UI a renderer instance is showing. */ -export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' | 'sidebar' +export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' | 'sidebar' | 'tools' /** The API the preload exposes on `window.orbit` to Orbit's own UI renderers. */ export interface OrbitBridge { diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 1b6ea8dd..2fa419df 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -32,16 +32,20 @@ export interface NavFixtures { const ICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%2338a169'/%3E%3C/svg%3E" +const escapeHtml = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`) + /** * A page that records key presses (`window.__keys`) and mouse presses (`window.__clicks`: x, y, * button) and counts its loads in sessionStorage (`loads`), with a text box (#box), a link to the - * next page (#next) and one that opens a new tab (#child). `background` is its CSS background. + * next page (#next) and one that opens a new tab (#child). `background` is its CSS background; + * `text` goes in a paragraph (#text). */ -function page(title: string, background = '#f2fbf5'): string { +function page(title: string, background = '#f2fbf5', text = ''): string { return `<!doctype html> <html><head><meta charset="utf-8"><title>${title}

${title}

+

${escapeHtml(text)}

Child page

@@ -58,7 +62,8 @@ sessionStorage.setItem('loads', String(Number(sessionStorage.getItem('loads') || /** * Routes: - * - `/page/?bg=<css colour>`: a page (see `page()`), optionally in another background. + * - `/page/<title>?bg=<css colour>&text=<words>`: a page (see `page()`), optionally in another + * background and with some text. * - `/slow/<title>?ms=N`: the page after N ms (default 30 s): keeps a tab loading. * - `/slow-reload/<title>`: the page at once on the first request, then slowly (30 s) on reloads. */ @@ -78,7 +83,7 @@ export async function startNavFixtures(): Promise<NavFixtures> { pending.delete(res) if (res.writableEnded || res.destroyed) return res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }) - res.end(page(title, u.searchParams.get('bg') ?? undefined)) + res.end(page(title, u.searchParams.get('bg') ?? undefined, u.searchParams.get('text') ?? '')) } const later = (ms: number) => { pending.add(res) @@ -132,9 +137,54 @@ type Hooks = { chooseMenu: (label: string, newTab?: boolean) => boolean floating: (windowId?: string) => FloatingInfo corner: (tabId: string) => number | null + tools: (windowId?: string) => ToolsInfo } } +/** A window's page tools (NV-05, NV-06): what the find bar and the zoom indicator show, and where. */ +export interface ToolsInfo { + find: { + tabId: string | null + open: boolean + query: string + active: number + matches: number + searched: boolean + } + zoom: { percent: number } | null + hovered: boolean + placed: boolean + bounds?: { x: number; y: number; width: number; height: number } +} + +/** The first window's page tools. */ +export function toolsInfo(app: ElectronApplication): Promise<ToolsInfo> { + return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.tools()) +} + +/** The page tools' page (their own renderer, `view=tools`), once it has started. */ +export async function toolsPage(app: ElectronApplication): Promise<Page> { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + const found = app.windows().find((p) => p.url().includes('view=tools')) + if (found) return found + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error('No page tools page') +} + +/** The zoom factor of the web contents whose URL contains `part`. */ +export function zoomOf(app: ElectronApplication, part: string): Promise<number | undefined> { + return app.evaluate( + ({ webContents }, p) => + webContents + .getAllWebContents() + .find((w) => w.getURL().includes(p)) + ?.getZoomFactor(), + part, + ) +} + /** A window's floating sidebar (SB-12): whether its view exists, is placed in the window and is out. */ export interface FloatingInfo { exists: boolean diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index eb9fde54..f4e69085 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -1,16 +1,23 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { expect, type Page, test } from '@playwright/test' -import { isMac, launchOrbit, MOD, type Orbit, state, tabPage, viewPage } from './helpers' +import { isMac, launchOrbit, MOD, type Orbit, state, tabPage, tabView, viewPage } from './helpers' import { cmd, focusedUrl, type NavFixtures, openTab, pressInPage, + shellRoundTrip, sideButton, startNavFixtures, + toolsInfo, + toolsPage, visit, waitLoaded, windowState, + zoomOf, } from './nav-support' let fx: NavFixtures @@ -192,3 +199,308 @@ test('[NV-04][KS-08] ⌘. stops loading on macOS', async () => { await pressInPage(app, '/page/Halt', '.', ['meta']) await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) }) + +// ---------- Find in page (NV-05) ---------- + +const WORDS = 'orbit one. orbit two. orbit three.' +/** A page with "orbit" in it three times. */ +const findPage = (title: string) => fx.url(`/page/${title}?text=${encodeURIComponent(WORDS)}`) + +/** ⌘F in a tab's page; resolves with the page tools once the find bar has keyboard focus. */ +async function openFind(part: string): Promise<Page> { + await pressInPage(orbit.app, part, 'F', [mod]) + const tools = await toolsPage(orbit.app) + await expect(tools.getByTestId('find-bar')).toBeVisible() + await expect(tools.getByTestId('find-input')).toBeFocused() + return tools +} + +/** The page tools sit over the top-right corner of a tab's page: 10 px in, plus their 8 px shadow margin. */ +async function expectToolsOver(tabId: string): Promise<void> { + await expect + .poll(async () => { + const [info, view] = [await toolsInfo(orbit.app), await tabView(orbit.app, tabId)] + const b = info.bounds + const v = view?.bounds + if (!info.placed || !b || !v) return null + return { right: b.x + b.width - (v.x + v.width), top: b.y - v.y } + }) + .toEqual({ right: -2, top: 2 }) +} + +test('[NV-05][KS-08] ⌘F finds as you type; Enter and ⌘G go on, ⇧Enter and ⇧⌘G go back; Esc closes and keeps the match selected', async () => { + const { app } = orbit + const id = await openTab(app, findPage('Find')) + const tab = await tabPage(app, '/page/Find') + const tools = await openFind('/page/Find') + await expectToolsOver(id) + const count = tools.getByTestId('find-count') + await tools.keyboard.type('orbit') + await expect(count).toHaveText('1/3') + await tools.keyboard.press('Enter') + await expect(count).toHaveText('2/3') + await tools.keyboard.press(`${MOD}+G`) + await expect(count).toHaveText('3/3') + // Past the last match it starts again from the first. + await tools.keyboard.press(`${MOD}+G`) + await expect(count).toHaveText('1/3') + await tools.keyboard.press('Shift+Enter') + await expect(count).toHaveText('3/3') + await tools.keyboard.press(`${MOD}+Shift+G`) + await expect(count).toHaveText('2/3') + if (!isMac) { + await tools.keyboard.press('F3') + await expect(count).toHaveText('3/3') + await tools.keyboard.press('Shift+F3') + await expect(count).toHaveText('2/3') + } + // The buttons do the same. + await tools.getByTestId('find-next').click() + await expect(count).toHaveText('3/3') + await tools.getByTestId('find-prev').click() + await expect(count).toHaveText('2/3') + // No match says so. + const input = tools.getByTestId('find-input') + await input.fill('orbits') + await expect(count).toHaveText('No matches') + await expect(count).toHaveAttribute('data-empty', 'true') + await expect(tools.getByTestId('find-next')).toBeDisabled() + await input.fill('orbit') + await expect(count).toHaveText('1/3') + await tools.keyboard.press('Enter') + await expect(count).toHaveText('2/3') + await tools.keyboard.press('Escape') + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) + // The highlights are gone, the second match stays selected and the page has the keyboard again. + await expect + .poll(() => + tab.evaluate(() => { + const s = getSelection() + return s && s.rangeCount > 0 ? [String(s), s.getRangeAt(0).startOffset] : null + }), + ) + .toEqual(['orbit', 11]) + await expect.poll(() => focusedUrl(app)).toContain('/page/Find') + expect((await toolsInfo(app)).find).toMatchObject({ open: false, matches: 0 }) +}) + +test('[NV-05] find is per tab: another tab hides the bar, coming back brings it with its text and place', async () => { + const { app } = orbit + const first = await openTab(app, findPage('FindA')) + const tools = await openFind('/page/FindA') + const input = tools.getByTestId('find-input') + const count = tools.getByTestId('find-count') + await tools.keyboard.type('orbit') + await expect(count).toHaveText('1/3') + await tools.keyboard.press('Enter') + await expect(count).toHaveText('2/3') + const second = await openTab(app, page('Plain')) + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) + await cmd(app, 'tabs.activate', { tabId: first }) + await expectToolsOver(first) + await expect(input).toHaveValue('orbit') + await expect(count).toHaveText('2/3') + // The other tab has a bar of its own. + await cmd(app, 'tabs.activate', { tabId: second }) + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) + await openFind('/page/Plain') + await expect(input).toHaveValue('') + await expect(count).toBeHidden() + // Closing a tab with its bar open is fine; the other tab's bar comes back with it. + await cmd(app, 'tabs.close', { tabId: second }) + await expect.poll(async () => (await windowState(app)).activeTab[(await windowState(app)).spaceId]).toBe(first) + await expectToolsOver(first) + await expect(input).toHaveValue('orbit') + // The bar follows the page when the sidebar hides. + await cmd(app, 'action.run', { id: 'sidebar.toggle' }) + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + await expectToolsOver(first) +}) + +test('[NV-05] ⌘F starts from the selected text, else the tab’s last search; an input method’s text is searched once committed', async () => { + const { app } = orbit + await openTab(app, findPage('FindSel')) + const tab = await tabPage(app, '/page/FindSel') + // Select "two" on the page. + await tab.evaluate(() => { + const text = document.getElementById('text')?.firstChild as Text + const range = document.createRange() + range.setStart(text, 17) + range.setEnd(text, 20) + getSelection()?.removeAllRanges() + getSelection()?.addRange(range) + }) + const tools = await openFind('/page/FindSel') + const input = tools.getByTestId('find-input') + const count = tools.getByTestId('find-count') + await expect(input).toHaveValue('two') + await expect(count).toHaveText('1/1') + // Selected, so typing replaces it. + await expect + .poll(() => input.evaluate((el: HTMLInputElement) => [el.selectionStart, el.selectionEnd])) + .toEqual([0, 3]) + await tools.keyboard.press('Escape') + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) + await tab.evaluate(() => getSelection()?.removeAllRanges()) + const again = await openFind('/page/FindSel') + expect(again).toBe(tools) + await expect(input).toHaveValue('two') + await expect(count).toHaveText('1/1') + await expect + .poll(() => input.evaluate((el: HTMLInputElement) => [el.selectionStart, el.selectionEnd])) + .toEqual([0, 3]) + // An input method composes "orb" first (nothing is searched), then commits "orbit". + const cdp = await tools.context().newCDPSession(tools) + try { + await cdp.send('Input.imeSetComposition', { text: 'orb', selectionStart: 3, selectionEnd: 3 }) + await expect(input).toHaveValue('orb') + await shellRoundTrip(tools) + expect((await toolsInfo(app)).find.query).toBe('two') + await cdp.send('Input.insertText', { text: 'orbit' }) + } finally { + await cdp.detach() + } + await expect(input).toHaveValue('orbit') + // A new search goes on from where the last one was (Chromium): past "two" comes the third. + await expect(count).toHaveText('3/3') +}) + +test('[NV-05][KS-08] Jump to Selection (⌘J on macOS) scrolls the page back to the selected text', async () => { + const { app } = orbit + await openTab(app, findPage('Jump')) + const tab = await tabPage(app, '/page/Jump') + await tab.evaluate(() => { + const text = document.getElementById('text')?.firstChild as Text + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, 5) + getSelection()?.removeAllRanges() + getSelection()?.addRange(range) + window.scrollTo(0, 1500) + }) + await expect.poll(() => tab.evaluate(() => window.scrollY)).toBeGreaterThan(1000) + if (isMac) await pressInPage(app, '/page/Jump', 'J', ['meta']) + else await cmd(app, 'action.run', { id: 'page.jumpToSelection' }) + await expect.poll(() => tab.evaluate(() => window.scrollY)).toBeLessThan(200) + expect(await tab.evaluate(() => String(getSelection()))).toBe('orbit') +}) + +// ---------- Page zoom (NV-06) ---------- + +test('[NV-06][KS-08] ⌘+ and ⌘− step through the zoom levels with an indicator; the site’s other tabs follow; ⌘0 resets', async () => { + const { app } = orbit + const first = await openTab(app, page('ZoomA')) + await pressInPage(app, '/page/ZoomA', '=', [mod]) + await pressInPage(app, '/page/ZoomA', '=', [mod]) + const tools = await toolsPage(app) + const percent = tools.getByTestId('zoom-percent') + await expect(percent).toHaveText('125%') + await expect.poll(() => zoomOf(app, '/page/ZoomA')).toBeCloseTo(1.25) + await expectToolsOver(first) + // A new tab on the same site opens zoomed; another site doesn't. + const second = await openTab(app, page('ZoomB')) + expect(await zoomOf(app, '/page/ZoomB')).toBeCloseTo(1.25) + await openTab(app, fx.urlOn('127.0.0.1', '/page/ZoomC')) + expect(await zoomOf(app, '/page/ZoomC')).toBeCloseTo(1) + await pressInPage(app, '/page/ZoomC', '-', [mod]) + await expect(percent).toHaveText('90%') + await expect.poll(() => zoomOf(app, '/page/ZoomC')).toBeCloseTo(0.9) + // The indicator's Reset button goes back to 100%. It stays while the pointer is on it. + await tools.getByTestId('zoom-reset').click() + await expect(percent).toHaveText('100%') + await expect(tools.getByTestId('zoom-reset')).toHaveCount(0) + await expect.poll(() => zoomOf(app, '/page/ZoomC')).toBeCloseTo(1) + await expect.poll(async () => (await toolsInfo(app)).hovered).toBe(true) + await tools.mouse.move(1, 1) + await expect.poll(async () => (await toolsInfo(app)).hovered).toBe(false) + // ⌘0 resets the site in all its tabs. + await cmd(app, 'tabs.activate', { tabId: second }) + await pressInPage(app, '/page/ZoomB', '0', [mod]) + await expect.poll(() => zoomOf(app, '/page/ZoomB')).toBeCloseTo(1) + await expect.poll(() => zoomOf(app, '/page/ZoomA')).toBeCloseTo(1) + // The indicator goes on its own after about a second and a half. + const shown = Date.now() + await expect.poll(async () => (await toolsInfo(app)).zoom, { timeout: 8000 }).toBeNull() + expect(Date.now() - shown).toBeGreaterThan(900) + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) +}) + +test('[NV-06] zoom is remembered per site and profile across a restart', async () => { + const userData = mkdtempSync(join(tmpdir(), 'orbit-e2e-zoom-')) + await orbit.app.close() + orbit = await launchOrbit({ userData }) + let { app } = orbit + const personal = (await windowState(app)).spaceId + await openTab(app, page('Keep')) + await pressInPage(app, '/page/Keep', '=', [mod]) + await expect.poll(() => zoomOf(app, '/page/Keep')).toBeCloseTo(1.1) + // Another profile has its own zoom for the same site. + const profileId = await cmd<string>(app, 'profiles.create', { name: 'Work' }) + const work = await cmd<string>(app, 'spaces.create', { name: 'Work', profileId }) + await expect.poll(async () => (await windowState(app)).spaceId).toBe(work) + await openTab(app, page('KeepWork')) + expect(await zoomOf(app, '/page/KeepWork')).toBeCloseTo(1) + await orbit.app.close() + orbit = await launchOrbit({ userData }) + app = orbit.app + await cmd(app, 'spaces.switch', { spaceId: personal }) + await expect.poll(async () => (await windowState(app)).spaceId).toBe(personal) + await openTab(app, page('KeepAgain')) + await expect.poll(() => zoomOf(app, '/page/KeepAgain')).toBeCloseTo(1.1) + await cmd(app, 'spaces.switch', { spaceId: work }) + await expect.poll(async () => (await windowState(app)).spaceId).toBe(work) + await openTab(app, page('KeepWorkAgain')) + expect(await zoomOf(app, '/page/KeepWorkAgain')).toBeCloseTo(1) +}) + +test('[NV-06] ⌘0 also undoes a pinch zoom', async () => { + const { app } = orbit + await openTab(app, page('Pinch')) + const tab = await tabPage(app, '/page/Pinch') + const scale = () => tab.evaluate(() => window.visualViewport?.scale ?? 1) + const cdp = await tab.context().newCDPSession(tab) + try { + await cdp.send('Input.synthesizePinchGesture', { + x: 200, + y: 200, + scaleFactor: 2, + relativeSpeed: 800, + // A trackpad's pinch. + gestureSourceType: 'mouse', + }) + } finally { + await cdp.detach() + } + await expect.poll(scale).toBeGreaterThan(1.3) + await pressInPage(app, '/page/Pinch', '0', [mod]) + await expect.poll(scale).toBe(1) +}) + +test('[NV-06] Ctrl and the mouse wheel zoom the page under the pointer (Windows, Linux)', async () => { + test.skip(isMac, 'macOS zooms with a pinch') + const { app } = orbit + await openTab(app, page('Wheel')) + // One notch of the wheel. Electron sends a synthetic wheel event twice (as the scroll begins and + // ends), each with the ticks given, so half a tick each makes the one notch. + const wheel = (deltaY: number) => + app.evaluate(({ webContents }, dy) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL().includes('/page/Wheel')) + contents?.sendInputEvent({ + type: 'mouseWheel', + x: 200, + y: 200, + deltaX: 0, + deltaY: dy, + wheelTicksX: 0, + wheelTicksY: Math.sign(dy) / 2, + canScroll: true, + modifiers: ['control'], + }) + }, deltaY) + await wheel(120) + const tools = await toolsPage(app) + await expect(tools.getByTestId('zoom-percent')).toHaveText('110%') + await expect.poll(() => zoomOf(app, '/page/Wheel')).toBeCloseTo(1.1) + await wheel(-120) + await expect.poll(() => zoomOf(app, '/page/Wheel')).toBeCloseTo(1) +}) diff --git a/tests/unit/nav-zoom.test.ts b/tests/unit/nav-zoom.test.ts new file mode 100644 index 00000000..33773420 --- /dev/null +++ b/tests/unit/nav-zoom.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { findTextFromSelection, sameZoom, ZOOM_STEPS, zoomPercent, zoomStep } from '../../src/shared/nav' + +describe('page zoom steps (NV-06)', () => { + it('lists Chromium’s steps from 25% to 500%', () => { + expect(ZOOM_STEPS.map(zoomPercent)).toEqual([ + 25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500, + ]) + }) + + it('steps in and out from 100%', () => { + expect(zoomStep(1, 1)).toBe(1.1) + expect(zoomStep(1.1, 1)).toBe(1.25) + expect(zoomStep(1, -1)).toBe(0.9) + expect(zoomStep(0.9, -1)).toBe(0.8) + }) + + it('stays put at either end', () => { + expect(zoomStep(5, 1)).toBe(5) + expect(zoomStep(0.25, -1)).toBe(0.25) + }) + + it('snaps a factor between steps to the next one in that direction', () => { + expect(zoomStep(1.2, 1)).toBe(1.25) + expect(zoomStep(1.2, -1)).toBe(1.1) + // A factor read back through Chromium's zoom levels is a hair off the step. + expect(zoomStep(1.25 + 1e-9, 1)).toBe(1.5) + expect(zoomStep(1.25 - 1e-9, -1)).toBe(1.1) + }) + + it('compares factors with a little slack', () => { + expect(sameZoom(1.1, 1.1000000001)).toBe(true) + expect(sameZoom(1.1, 1.25)).toBe(false) + }) +}) + +describe('find text from a selection (NV-05)', () => { + it('takes the first non-empty line, trimmed', () => { + expect(findTextFromSelection(' orbit ')).toBe('orbit') + expect(findTextFromSelection('\n\n first line \nsecond')).toBe('first line') + expect(findTextFromSelection('')).toBe('') + }) + + it('caps long selections', () => { + expect(findTextFromSelection('x'.repeat(500))).toHaveLength(200) + }) +}) From 442766d721a637cbf0d78d92f1760eba5661795f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:28:31 +0000 Subject: [PATCH 05/69] test(split): the views-over-panes poll waits for a pane that is still growing in A pane added a moment ago springs open from zero width (SV-26), so its first bounding box can be empty. The poll now retries instead of failing at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/split.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/split.spec.ts b/tests/e2e/split.spec.ts index 62c341ea..d3d3e035 100644 --- a/tests/e2e/split.spec.ts +++ b/tests/e2e/split.spec.ts @@ -73,7 +73,8 @@ async function expectViewsOverPanes(o: Orbit, tabIds: string[]): Promise<void> { .poll( async () => { const view = await tabView(o.app, tabId) - const pane = await paneBox(o.shell, tabId) + // A pane that has only just been added grows in from nothing (SV-26): no box yet, poll on. + const pane = await paneBox(o.shell, tabId).catch(() => null) return view?.visible === true && near(view.bounds, pane) }, { message: `view of ${tabId} over its pane` }, From 7768a9ff53c26c598d4dc82fd5c94a03384a5c0e Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:14:47 +0000 Subject: [PATCH 06/69] chrome: modified link clicks, pop-ups, page menu and private windows (in progress) Shift-click opens a new window, Alt-click opens split view instead of the engine's download, Shift-Cmd-click no longer hangs (no-guest foreground tab). Pop-up windows keep the host as their title; pop-ups blocked for want of a click show in the address display and can be opened or allowed per site. Tabs opened only to download close again. The page menu gains New Window, Private Window, Save Link/Page As, Print, View Page Source, Search [engine], Look Up and Add to Dictionary. Private windows: an in-memory profile shared by all private windows, a Space of their own per window, a near-black look, and nothing written to the state file, history, favicon cache or site prefs. Tests follow in the next commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/index.ts | 27 +- src/main/nav/links.ts | 340 ++++++++++++++++++ src/main/nav/new-window.ts | 40 +++ src/main/nav/page-menu.ts | 163 +++++++++ src/main/nav/private.ts | 212 +++++++++++ src/main/nav/sidebar-reveal.ts | 2 +- src/main/nav/site-prefs.ts | 64 +++- src/main/persist.ts | 11 +- src/main/sessions.ts | 13 +- src/main/spaces/model.ts | 3 + src/main/tabs/commands.ts | 11 +- src/main/tabs/context-menu.ts | 25 +- src/main/tabs/model.ts | 3 + src/main/tabs/ops.ts | 18 +- src/main/tabs/runtime.ts | 10 +- src/main/windows.ts | 26 +- src/renderer/src/shell/ShellApp.tsx | 4 +- src/renderer/src/shell/chrome/Address.tsx | 24 +- .../src/shell/chrome/PrivateSidebar.tsx | 24 ++ src/renderer/src/shell/chrome/chrome.css | 53 +++ src/renderer/src/shell/sidebar/Sidebar.tsx | 19 +- .../src/shell/sidebar/SidebarItems.tsx | 15 +- .../src/shell/spaces/NewSpaceFlow.tsx | 6 +- src/renderer/src/shell/spaces/SpacePanels.tsx | 3 + src/renderer/src/theme.ts | 8 +- src/shared/commands/nav.ts | 5 + src/shared/private.ts | 213 +++++++++++ src/shared/state.ts | 5 +- tests/e2e/nav-support.ts | 32 +- 29 files changed, 1329 insertions(+), 50 deletions(-) create mode 100644 src/main/nav/links.ts create mode 100644 src/main/nav/new-window.ts create mode 100644 src/main/nav/page-menu.ts create mode 100644 src/main/nav/private.ts create mode 100644 src/renderer/src/shell/chrome/PrivateSidebar.tsx create mode 100644 src/shared/private.ts diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 8b66694d..b3d2fb7c 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -3,18 +3,23 @@ import { app, Menu, type MenuItemConstructorOptions, nativeImage } from 'electro import { ACTIONS_BY_ID, formatAccelerator, shortcutsFor } from '../../shared/actions' import type { HistoryEntryInfo, NavButton, NavDirection } from '../../shared/commands/nav' import { historyEntryLabel, historyMenuIndexes } from '../../shared/nav' +import { isPrivateTab, isPrivateWindow } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' import { activeTabOf } from '../windows' +import { type LinksTestHooks, registerLinks } from './links' +import { openUrlInNewWindow } from './new-window' +import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { registerPageColor } from './page-color' +import { type PrivateWindows, registerPrivate } from './private' import { type PageToolsInfo, registerPageTools } from './page-tools' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' import { SitePrefs } from './site-prefs' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitNav. */ -export interface NavTestHooks { +export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { /** The history menu on screen (labels top to bottom, and which items have an icon), or null once it closed. */ menu: () => { windowId: WindowId; direction: NavDirection; labels: string[]; icons: boolean[] } | null /** Chooses an item of the open history menu by label; `newTab` is a ⌘-click (Ctrl-click). */ @@ -258,6 +263,24 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { registerPageColor(ctx) // Find in page and page zoom (NV-05, NV-06). Registered after the tabs lane, so its zoom actions are replaced. const pageTools = registerPageTools(ctx, prefs) + // Private windows (NV-11). + const privateWindows: PrivateWindows = registerPrivate(ctx, ops, prefs) + /** A link in a new window: a private one when it comes from a private window (NV-08, NV-12). */ + const openLinkWindow = (url: string, from: { tabId?: TabId; windowId?: WindowId }) => { + const state = store.state + if ((from.tabId && isPrivateTab(state, from.tabId)) || isPrivateWindow(state, from.windowId)) { + privateWindows.open({ url, fromWindowId: from.windowId }) + } else { + openUrlInNewWindow(ctx, ops, url, from.windowId) + } + } + // Modified clicks on links, pop-ups and downloads (NV-08, NV-09). + const links = registerLinks(ctx, { ops, prefs, openWindow: openLinkWindow }) + // The page menu's Open Link in New Window / Private Window (NV-12). + setLinkOpeners({ + window: openLinkWindow, + private: (url, from) => void privateWindows.open({ url, fromWindowId: from.windowId }), + }) // Placeholder until the site controls panel (NV-16) lands. actions.register('site.controls', (c) => ctx.toast(windowOf(c), 'Site controls are coming soon')) @@ -280,6 +303,8 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { if (ctx.e2e) { const hooks: NavTestHooks = { + ...links, + ...pageMenuTestHooks, menu: () => shown ? { diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts new file mode 100644 index 00000000..cab772ed --- /dev/null +++ b/src/main/nav/links.ts @@ -0,0 +1,340 @@ +import { app, type BrowserWindow, Menu, type MenuItemConstructorOptions, type WebContents, webContents } from 'electron' +import { connectionOf } from '../../shared/nav' +import type { TabId, WindowId } from '../../shared/state' +import { sectionOf } from '../../shared/tree' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' +import { activeTabOf, shownTabs } from '../windows' +import { NAV_WORLD } from './focus' +import type { SitePrefs } from './site-prefs' + +/** The engine starts its "download link" for an ⌥-click about half a second later; it is caught for this long. */ +const ALT_CLICK_DOWNLOAD_MS = 15_000 +/** Blocked pop-ups offered for opening from the address display, newest first. */ +const BLOCKED_SHOWN = 5 + +/** + * The link under the pointer in the page's main frame (through open shadow roots), as + * `{ href }`; `{ frame: true }` when the pointer is over a frame or something Orbit can't look + * into (the link may be inside); null over anything else. + */ +const HOVERED_LINK = `(() => { + let root = document, el = null + for (let i = 0; i < 16; i++) { + const list = root.querySelectorAll(':hover') + const last = list[list.length - 1] + if (!last) break + el = last + if (!last.shadowRoot) break + root = last.shadowRoot + } + if (!el) return null + for (let n = el; n; n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null) { + if (n.localName !== 'a' && n.localName !== 'area') continue + const raw = n.href + const href = typeof raw === 'string' ? raw : raw && raw.baseVal ? new URL(raw.baseVal, document.baseURI).href : '' + if (href) return { href } + } + const tag = el.localName + return tag === 'iframe' || tag === 'frame' || tag === 'object' || tag === 'embed' || el.shadowRoot ? { frame: true } : null +})()` + +interface Hovered { + href?: string + frame?: boolean +} + +/** An ⌥-click Orbit took over: the engine's download for the same link is cancelled when it comes. */ +interface AltClick { + url: string + until: number + /** Not opened yet (a link inside a frame): the download, when it comes, opens the split. */ + split: boolean +} + +/** Links Orbit opens: web and file pages (not script, data or blob URLs). */ +const openable = (url: string | undefined): url is string => !!url && /^(https?|file):/i.test(url) + +const withoutHash = (url: string) => url.split('#')[0] ?? url + +function hostOf(url: string | undefined): string { + try { + return url ? new URL(url).host : '' + } catch { + return '' + } +} + +/** A pop-up's title: the host it shows, marked when the connection isn't secure (NV-09). */ +export function popupTitle(url: string): string { + const host = hostOf(url) + if (!host) return url === 'about:blank' ? '' : url + return connectionOf(url) === 'insecure' ? `Not secure — ${host}` : host +} + +export interface LinksDeps { + ops: TabOps + prefs: SitePrefs + /** A new window showing `url`: private when the link came from a private tab (NV-08, NV-12). */ + openWindow: (url: string, from: { tabId?: TabId; windowId?: WindowId }) => void +} + +export interface LinksTestHooks { + /** The blocked pop-ups menu last shown (labels; disabled items in brackets), or null. */ + blockedMenu: () => string[] | null + /** Chooses an item of the blocked pop-ups menu by label. */ + chooseBlocked: (label: string) => boolean +} + +/** + * Clicks on links and the windows pages open, beyond what the tabs lane does (NV-08, NV-09): + * - ⇧-click (when Peek doesn't take it, `peek.md`): the link in a new window, Chromium-style. + * - ⌥-click: the link in split view beside the page, instead of the engine's "download link". + * - A tab opened only to download something closes again and its opener is selected. + * - Pop-up windows show the host they're on as their title, the page can't change it. + * - Pop-ups blocked for want of a click show in the address display, where they can be opened or + * always allowed for the site. + */ +export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks { + const { store, commands, tabs, windows } = ctx + const { ops, prefs } = deps + + // ---- Modifier keys: mouse events don't carry them, key events in any Orbit contents do ---- + const held = { alt: false, meta: false, control: false, shift: false } + const track = (input: Electron.Input) => { + const down = input.type !== 'keyUp' + if (input.key === 'Alt') held.alt = down + else if (input.key === 'Meta') held.meta = down + else if (input.key === 'Control') held.control = down + else if (input.key === 'Shift') held.shift = down + else { + held.alt = input.alt + held.meta = input.meta + held.control = input.control + held.shift = input.shift + } + } + const watchKeys = (contents: WebContents) => contents.on('before-input-event', (_e, input) => track(input)) + for (const contents of webContents.getAllWebContents()) watchKeys(contents) + app.on('web-contents-created', (_event, contents) => watchKeys(contents)) + // A key released while another app had focus is never seen: start over on every focus change. + app.on('browser-window-blur', () => { + held.alt = false + held.meta = false + held.control = false + held.shift = false + }) + + // ---- ⇧-click: a new window (NV-08). Peek's own hook comes first and takes it when it's on. ---- + ctx.hooks.windowOpen.push((openerTabId, details) => { + if (details.disposition !== 'new-window' || details.features) return undefined + if (!openable(details.url)) return undefined + deps.openWindow(details.url, { tabId: openerTabId, windowId: windows.windowOfTab(openerTabId) }) + return { action: 'deny' } + }) + + // ---- ⌥-click: split view (NV-08, SV-08) ---- + const hovered = new Map<TabId, string>() + const altClicks = new Map<TabId, AltClick>() + + const openInSplit = (tabId: TabId, url: string) => { + const windowId = windows.windowOfTab(tabId) + if (!windowId || !store.state.tabs[tabId]) return + void commands.run('split.openUrl', { url, target: tabId, side: 'right' }, { windowId }).catch(() => {}) + } + + const onAltClick = async (tabId: TabId, contents: WebContents) => { + const windowId = windows.windowOfTab(tabId) + // Only a page that is one of a browser window's panes (not Peek, a Mini Window or a pop-up). + if (!windowId || store.state.windows[windowId]?.kind === 'mini') return + if (!shownTabs(store.state, windowId).has(tabId)) return + const fallback = hovered.get(tabId) + let found: Hovered | null + try { + found = (await contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code: HOVERED_LINK }])) as Hovered | null + } catch { + found = { frame: true } + } + if (contents.isDestroyed()) return + const until = Date.now() + ALT_CLICK_DOWNLOAD_MS + if (found?.href) { + if (!openable(found.href)) return + altClicks.set(tabId, { url: found.href, until, split: false }) + openInSplit(tabId, found.href) + } else if (found?.frame && openable(fallback)) { + // A link in a frame: the engine's download of it says which, and opens the split. + altClicks.set(tabId, { url: fallback, until, split: true }) + } + } + + ctx.hooks.tabCreated.push((tabId, contents) => { + contents.on('update-target-url', (_e, url) => { + if (url) hovered.set(tabId, url) + else hovered.delete(tabId) + }) + contents.on('before-mouse-event', (_e, mouse) => { + if (mouse.type !== 'mouseDown' || mouse.button !== 'left') return + if (!held.alt || held.meta || held.control || held.shift) return + void onAltClick(tabId, contents) + }) + contents.on('did-create-window', (popup, details) => keepHostTitle(popup, details.url)) + contents.once('destroyed', () => { + if (tabs.contents(tabId)) return + hovered.delete(tabId) + altClicks.delete(tabId) + }) + }) + + // ---- Downloads: an ⌥-clicked link's, and tabs opened only to download (NV-08, NV-09) ---- + /** A tab that never showed a page: its first navigation turned into a download. */ + const neverShowedPage = (contents: WebContents) => { + const url = contents.getURL() + return contents.navigationHistory.length() <= 1 && (!url || url === 'about:blank') + } + const closeDownloadTab = (tabId: TabId) => { + const state = store.state + const tab = state.tabs[tabId] + // Pinned tabs and Favorites keep their place in the sidebar. + if (!tab || sectionOf(state, tabId) !== 'today') return + const windowId = windows.windowOfTab(tabId) + const shown = !!windowId && activeTabOf(state, windowId) === tabId + const opener = tab.openerTabId && state.tabs[tab.openerTabId] ? tab.openerTabId : undefined + // Nothing to keep: no Archive entry, no "reopen closed tab", no undo. + store.update((d) => { + const t = d.tabs[tabId] + if (t) t.url = 'about:blank' + }) + ops.closeTabs([tabId], 'auto', { undoLabel: false }) + if (shown && windowId && opener) ops.activate(windowId, opener) + } + ctx.sessions.onSessionCreated((ses) => { + ses.on('will-download', (event, item, source) => { + const tabId = source && !source.isDestroyed() ? tabs.tabIdFor(source) : undefined + if (!source || !tabId) return + const url = item.getURLChain()[0] ?? item.getURL() + const alt = altClicks.get(tabId) + if (alt && Date.now() <= alt.until && withoutHash(alt.url) === withoutHash(url)) { + altClicks.delete(tabId) + event.preventDefault() + if (alt.split) openInSplit(tabId, url) + return + } + if (neverShowedPage(source)) setTimeout(() => closeDownloadTab(tabId), 0) + }) + }) + + // ---- Pop-up windows: the host as the title (NV-09) ---- + const keepHostTitle = (popup: BrowserWindow, url: string) => { + const show = (next: string) => { + if (!popup.isDestroyed() && next) popup.setTitle(popupTitle(next)) + } + // The window re-emits its page's title change and takes the title unless this prevents it. + popup.on('page-title-updated', (event) => event.preventDefault()) + popup.webContents.on('did-navigate', (_e, next) => show(next)) + show(url) + } + + // ---- Blocked pop-ups (NV-09) ---- + const blocked = new Map<TabId, string[]>() + // Private profiles' choices stay in memory (SitePrefs). + const popupsAllowed = (profileId: string, host: string) => prefs.popupsAllowed(profileId, host) + const setPopupsAllowed = (profileId: string, host: string, allowed: boolean) => + prefs.setPopupsAllowed(profileId, host, allowed) + const setBlocked = (tabId: TabId, urls: string[]) => { + if (urls.length > 0) blocked.set(tabId, urls) + else blocked.delete(tabId) + const count = urls.length || undefined + if (store.state.tabs[tabId]?.blockedPopups === count) return + store.update((d) => { + const t = d.tabs[tabId] + if (!t) return + if (count) t.blockedPopups = count + else delete t.blockedPopups + }) + } + + tabs.allowsPopups = (tabId) => { + const tab = store.state.tabs[tabId] + const host = hostOf(tabs.contents(tabId)?.getURL() || tab?.url) + return !!tab && !!host && popupsAllowed(tab.profileId, host) + } + const previousBlocked = tabs.onPopupBlocked + tabs.onPopupBlocked = (tabId, url) => { + previousBlocked?.(tabId, url) + const list = (blocked.get(tabId) ?? []).filter((u) => u !== url) + list.push(url) + setBlocked(tabId, list.slice(-20)) + } + // A new page starts with nothing blocked. + ctx.hooks.tabNavigated.push((tabId) => { + if (blocked.has(tabId)) setBlocked(tabId, []) + }) + + let lastBlockedMenu: { items: MenuItemConstructorOptions[] } | null = null + commands.register('nav.blockedPopupsMenu', ({ x, y }, c) => { + const windowId = c.windowId ?? windows.focusedWindowId() + const tabId = windowId ? activeTabOf(store.state, windowId) : null + const tab = tabId ? store.state.tabs[tabId] : undefined + if (!windowId || !tabId || !tab) return + const host = hostOf(tabs.contents(tabId)?.getURL() || tab.url) + const urls = blocked.get(tabId) ?? [] + const allowed = !!host && popupsAllowed(tab.profileId, host) + const items: MenuItemConstructorOptions[] = [ + { label: `Pop-ups blocked on ${host || 'this page'}`, enabled: false }, + ...urls + .slice(-BLOCKED_SHOWN) + .reverse() + .map( + (url): MenuItemConstructorOptions => ({ + label: `Open ${url.length > 60 ? `${url.slice(0, 59)}…` : url}`, + click: () => { + setBlocked( + tabId, + (blocked.get(tabId) ?? []).filter((u) => u !== url), + ) + ops.createTab({ url, spaceId: tab.spaceId, windowId, activate: true, openerTabId: tabId }) + }, + }), + ), + { type: 'separator' }, + { + label: `Always Allow Pop-ups from ${host || 'This Site'}`, + type: 'checkbox', + checked: allowed, + enabled: !!host, + click: () => { + setPopupsAllowed(tab.profileId, host, !allowed) + if (!allowed) setBlocked(tabId, []) + }, + }, + ] + lastBlockedMenu = { items } + if (ctx.e2e) return + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return + Menu.buildFromTemplate(items).popup({ + window: win, + x: Math.round(x), + y: Math.round(y), + callback: () => { + lastBlockedMenu = null + }, + }) + }) + + return { + blockedMenu: () => + lastBlockedMenu + ? lastBlockedMenu.items + .filter((i) => i.type !== 'separator') + .map((i) => (i.enabled === false ? `[${i.label}]` : String(i.label))) + : null, + chooseBlocked: (label) => { + const item = lastBlockedMenu?.items.find((i) => i.label === label) + if (!item?.click) return false + ;(item.click as () => void)() + lastBlockedMenu = null + return true + }, + } +} diff --git a/src/main/nav/new-window.ts b/src/main/nav/new-window.ts new file mode 100644 index 00000000..e1777479 --- /dev/null +++ b/src/main/nav/new-window.ts @@ -0,0 +1,40 @@ +import { createWindowState } from '../../shared/defaults' +import { isPrivateWindow } from '../../shared/private' +import type { SpaceId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' + +/** + * A normal window showing `url` in a new tab at the top of Today (⇧-click, "Open Link in New + * Window"; NV-08, NV-12). It shows the Space of the window the link came from, a step down and + * right of it, in full screen when that one is (NV-10). Every normal window lists the same tabs + * (TB-17), so the tab is in the other windows' sidebars too, shown only here. + */ +export function openUrlInNewWindow( + ctx: MainContext, + ops: TabOps, + url: string, + fromWindowId: WindowId | undefined, +): WindowId | null { + const { store, windows } = ctx + const state = store.state + const from = fromWindowId ? state.windows[fromWindowId] : undefined + const spaceId: SpaceId | undefined = + from && from.kind === 'main' && !isPrivateWindow(state, from.id) && state.spaces[from.spaceId] + ? from.spaceId + : (Object.values(state.windows).find((w) => w.kind === 'main' && !isPrivateWindow(state, w.id))?.spaceId ?? + state.spaceOrder[0]) + if (!spaceId) return null + const winState = createWindowState(spaceId) + winState.bounds = windows.cascadeFrom(fromWindowId) + store.update((d) => { + d.windows[winState.id] = winState + }) + ops.createTab({ url, spaceId, windowId: winState.id, activate: true }) + const created = store.state.windows[winState.id] + if (!created) return null + const id = windows.open(created) + const source = fromWindowId ? windows.get(fromWindowId) : undefined + if (source && !source.isDestroyed() && source.isFullScreen()) windows.get(id)?.setFullScreen(true) + return id +} diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts new file mode 100644 index 00000000..75d178b6 --- /dev/null +++ b/src/main/nav/page-menu.ts @@ -0,0 +1,163 @@ +import { join } from 'node:path' +import { + app, + type ContextMenuParams, + dialog, + type MenuItemConstructorOptions, + type WebContents, +} from 'electron' +import { isPrivateTab } from '../../shared/private' +import type { TabId, WindowId } from '../../shared/state' +import { SEARCH_ENGINES } from '../../shared/url' +import type { MainContext } from '../context' + +/** + * The page context menu's items from the chrome lane (NV-12), added to the tabs lane's menu + * (`tabs/context-menu.ts`): opening links in new and private windows, saving links and pages, + * printing, page source, spelling and Look Up. In tests the menu is kept instead of shown. + */ + +type From = { tabId?: TabId; windowId?: WindowId } + +interface LinkOpeners { + /** A link in a new window (a private one from a private window). */ + window: (url: string, from: From) => void + /** A link in a new private window. */ + private: (url: string, from: From) => void +} + +let openers: LinkOpeners | undefined +let shown: { items: MenuItemConstructorOptions[]; tabId: TabId } | null = null + +/** Set once by the nav module (it owns the windows these open). */ +export function setLinkOpeners(next: LinkOpeners): void { + openers = next +} + +/** + * Open Link in New Window / in Private Window. A private window opens both as private windows, + * so it shows only the first (NV-11). + */ +export function linkWindowItems(ctx: MainContext, tabId: TabId, url: string): MenuItemConstructorOptions[] { + if (!openers) return [] + const from = (): From => ({ tabId, windowId: ctx.windows.windowOfTab(tabId) }) + const items: MenuItemConstructorOptions[] = [ + { label: 'Open Link in New Window', click: () => openers?.window(url, from()) }, + ] + if (!isPrivateTab(ctx.store.state, tabId)) { + items.push({ label: 'Open Link in Private Window', click: () => openers?.private(url, from()) }) + } + return items +} + +/** Save Link As…: the link as a download, which asks where to save it. */ +export function saveLinkItem(contents: WebContents, url: string): MenuItemConstructorOptions { + return { label: 'Save Link As…', click: () => contents.downloadURL(url) } +} + +/** Search [engine] for “…” (the search engine named, NV-12). */ +export function searchLabel(ctx: MainContext, selection: string): string { + const engine = SEARCH_ENGINES[ctx.store.state.settings.searchEngine] ?? SEARCH_ENGINES.google + const text = selection.trim() + return `Search ${engine.name} for “${text.slice(0, 40)}${text.length > 40 ? '…' : ''}”` +} + +/** Look Up “…” (macOS: the system dictionary panel for the selection). */ +export function lookUpItems(contents: WebContents, selection: string): MenuItemConstructorOptions[] { + if (process.platform !== 'darwin') return [] + const text = selection.trim() + return [ + { + label: `Look Up “${text.slice(0, 24)}${text.length > 24 ? '…' : ''}”`, + click: () => contents.showDefinitionForSelection(), + }, + ] +} + +/** Add to Dictionary for a misspelled word (not in private windows: their dictionary isn't kept). */ +export function dictionaryItems(contents: WebContents, params: ContextMenuParams): MenuItemConstructorOptions[] { + const word = params.misspelledWord + if (!word || !contents.session.isPersistent()) return [] + return [{ label: 'Add to Dictionary', click: () => contents.session.addWordToSpellCheckerDictionary(word) }] +} + +/** A file name from a page title, without characters file systems refuse. */ +function fileName(title: string): string { + const clean = title + .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + return clean || 'Page' +} + +/** Save Page As… (the page with its resources), Print… and View Page Source. */ +export function pageItems(ctx: MainContext, tabId: TabId, contents: WebContents): MenuItemConstructorOptions[] { + return [ + { + label: 'Save Page As…', + click: () => { + const win = ctx.windows.get(ctx.windows.windowOfTab(tabId) ?? '') + const defaultPath = join(app.getPath('downloads'), `${fileName(contents.getTitle())}.html`) + const options = { defaultPath, filters: [{ name: 'Web Page, Complete', extensions: ['html', 'htm'] }] } + void (win ? dialog.showSaveDialog(win, options) : dialog.showSaveDialog(options)).then( + ({ canceled, filePath }) => { + if (canceled || !filePath || contents.isDestroyed()) return + contents.savePage(filePath, 'HTMLComplete').catch((err) => console.error('[nav] save page', err)) + }, + ) + }, + }, + { label: 'Print…', click: () => contents.print() }, + { type: 'separator' }, + { + label: 'View Page Source', + click: () => { + const url = contents.getURL() + if (!url || url.startsWith('view-source:')) return + const tab = ctx.store.state.tabs[tabId] + const windowId = ctx.windows.windowOfTab(tabId) + void ctx.commands + .run('tabs.create', { url: `view-source:${url}`, spaceId: tab?.spaceId, activate: true }, { windowId }) + .catch(() => {}) + }, + }, + ] +} + +/** In tests, keeps the menu for the test hooks instead of showing it. True when kept. */ +export function keepPageMenu(ctx: MainContext, tabId: TabId, items: MenuItemConstructorOptions[]): boolean { + if (!ctx.e2e) return false + shown = { items, tabId } + return true +} + +const labelOf = (item: MenuItemConstructorOptions) => + item.label ?? (item.role ? `role:${item.role}` : '') + +export interface PageMenuTestHooks { + /** The page menu last opened: its items' labels (`role:copy` for built-in ones; `-` for separators), or null. */ + pageMenu: () => { tabId: TabId; labels: string[]; enabled: Record<string, boolean> } | null + /** Chooses an item of the page menu by label. */ + choosePageMenu: (label: string) => boolean +} + +export const pageMenuTestHooks: PageMenuTestHooks = { + pageMenu: () => { + if (!shown) return null + const enabled: Record<string, boolean> = {} + for (const item of shown.items) if (item.type !== 'separator') enabled[labelOf(item)] = item.enabled !== false + return { + tabId: shown.tabId, + labels: shown.items.map((i) => (i.type === 'separator' ? '-' : labelOf(i))), + enabled, + } + }, + choosePageMenu: (label) => { + const item = shown?.items.find((i) => i.type !== 'separator' && labelOf(i) === label) + if (!item?.click) return false + shown = null + ;(item.click as () => void)() + return true + }, +} diff --git a/src/main/nav/private.ts b/src/main/nav/private.ts new file mode 100644 index 00000000..9a373b0a --- /dev/null +++ b/src/main/nav/private.ts @@ -0,0 +1,212 @@ +import { randomUUID } from 'node:crypto' +import { app, Menu, session } from 'electron' +import { createSpace, createWindowState, THEME_PRESETS } from '../../shared/defaults' +import { isFolderId } from '../../shared/ids' +import { isPrivateProfileId, isPrivateWindow, PRIVATE_PARTITION_PREFIX, PRIVATE_PROFILE_PREFIX } from '../../shared/private' +import type { ItemId, OrbitState, Profile, ProfileId, SpaceId, SpaceTheme, WindowId } from '../../shared/state' +import { tabsInItem } from '../../shared/tree' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' +import type { SitePrefs } from './site-prefs' + +/** Actions for what a private window doesn't have (SB-25). */ +const NOT_IN_PRIVATE = + /^(space\.|tab\.togglePin$|tab\.favorite$|tab\.resetToPinned$|pinned\.toggleCollapsed$|folder\.new$|app\.archive$)/ + +/** The command line switch that opens a private window (Windows taskbar task, NV-11). */ +export const PRIVATE_WINDOW_SWITCH = '--private-window' + +export interface PrivateWindows { + /** + * Opens a private window, a step down and right of `fromWindowId`: with `url` in its first tab, + * else with the command bar open for one (SB-25). + */ + open(opts?: { url?: string; fromWindowId?: WindowId }): WindowId | null + /** The current private session's profile, while a private window is open. */ + profileId(): ProfileId | undefined +} + +/** + * Private windows (NV-11, SB-25, AP-05). They share one private profile, whose session lives in + * memory only (a partition without "persist:"), so nothing of it reaches the disk: no cookies, + * cache, site data or form entries. History, the favicon cache, the Archive, site choices and the + * state file leave private tabs out (`withoutPrivate`). Each window shows a Space of its own, kept + * out of the Space order: it lists no other Space, pinned tab or Favorite, and no other window + * lists its tabs. When the last private window closes, the profile and its session go, and the + * next private window starts a new one. + */ +export function registerPrivate(ctx: MainContext, ops: TabOps, prefs: SitePrefs): PrivateWindows { + const { store, actions, commands, windows } = ctx + let current: ProfileId | undefined + + const newProfile = (now: number): Profile => { + const key = randomUUID().replace(/-/g, '').slice(0, 12) + return { + id: `${PRIVATE_PROFILE_PREFIX}${key}`, + name: 'Private', + partition: `${PRIVATE_PARTITION_PREFIX}${key}`, + favorites: [], + createdAt: now, + } + } + + const open: PrivateWindows['open'] = ({ url, fromWindowId } = {}) => { + const now = Date.now() + const from = fromWindowId ?? windows.focusedWindowId() + let windowId: WindowId | undefined + let spaceId: SpaceId | undefined + store.update((d) => { + let profile = current ? d.profiles[current] : undefined + if (!profile) { + profile = newProfile(now) + d.profiles[profile.id] = profile + current = profile.id + } + // Its own Space, outside the Space order: no switcher, no other window lists it. + const space = createSpace('Private', profile.id, THEME_PRESETS[0] as SpaceTheme, '', now) + d.spaces[space.id] = space + const win = createWindowState(space.id, 'private') + win.bounds = windows.cascadeFrom(from) + d.windows[win.id] = win + windowId = win.id + spaceId = space.id + }) + if (!windowId || !spaceId) return null + if (url) ops.createTab({ url, spaceId, windowId, activate: true }) + const created = store.state.windows[windowId] + if (!created) return null + const id = windows.open(created) + const win = windows.get(id) + const source = from ? windows.get(from) : undefined + if (win && source && !source.isDestroyed() && source.isFullScreen()) win.setFullScreen(true) + if (win && !url) { + // "+" and ⌘T open the command bar there too; a new private window starts with it (SB-25). + win.once('show', () => { + void commands + .run('overlay.open', { overlay: { kind: 'commandbar', mode: 'new-tab', query: '' } }, { windowId: id }) + .catch(() => {}) + }) + } + return id + } + + /** Items of a Space and everything inside its folders. */ + const itemsOf = (state: OrbitState, spaceId: SpaceId): ItemId[] => { + const out: ItemId[] = [] + const visit = (ids: ItemId[]) => { + for (const id of ids) { + out.push(id) + const folder = isFolderId(id) ? state.folders[id] : undefined + if (folder) visit(folder.children) + } + } + const space = state.spaces[spaceId] + if (space) visit([...space.pinned, ...space.today]) + return out + } + + /** A private window closed: its Space and tabs go once no window shows them (none is kept anywhere). */ + const dropSpaces = (spaceIds: SpaceId[]) => { + for (const spaceId of spaceIds) { + const state = store.state + if (!state.spaces[spaceId]) continue + if (Object.values(state.windows).some((w) => w.spaceId === spaceId)) continue + const tabIds = itemsOf(state, spaceId).flatMap((id) => tabsInItem(state, id)) + if (tabIds.length > 0) ops.closeTabs(tabIds, 'auto', { remove: true, undoLabel: false }) + store.update((d) => { + for (const id of itemsOf(d, spaceId)) { + if (d.folders[id]) delete d.folders[id] + if (d.splits[id]) delete d.splits[id] + } + delete d.spaces[spaceId] + }) + } + if (!Object.keys(store.state.windows).some((id) => isPrivateWindow(store.state, id))) endSession() + } + + /** The last private window closed: forget everything of the private session. */ + const endSession = () => { + const profiles = Object.values(store.state.profiles).filter((p) => isPrivateProfileId(p.id)) + if (profiles.length === 0 && !current) return + current = undefined + const spaces = new Set( + Object.values(store.state.spaces) + .filter((s) => isPrivateProfileId(s.profileId)) + .map((s) => s.id), + ) + const leftover = Object.values(store.state.tabs) + .filter((t) => isPrivateProfileId(t.profileId) || spaces.has(t.spaceId)) + .map((t) => t.id) + if (leftover.length > 0) ops.closeTabs(leftover, 'auto', { remove: true, undoLabel: false }) + store.update((d) => { + for (const id of spaces) { + for (const item of itemsOf(d, id)) { + if (d.folders[item]) delete d.folders[item] + if (d.splits[item]) delete d.splits[item] + } + delete d.spaces[id] + } + for (const p of profiles) delete d.profiles[p.id] + }) + // Reopen Closed Tab and undo never bring a private tab back once its window is gone. + for (let i = ops.recentlyClosed.length - 1; i >= 0; i--) { + if (isPrivateProfileId(ops.recentlyClosed[i]?.tab.profileId)) ops.recentlyClosed.splice(i, 1) + } + prefs.forgetPrivate() + for (const p of profiles) { + ctx.sessions.forget(p.partition) + const ses = session.fromPartition(p.partition) + void ses.clearStorageData().catch(() => {}) + void ses.clearCache().catch(() => {}) + void ses.clearAuthCache().catch(() => {}) + void ses.clearHostResolverCache().catch(() => {}) + } + } + + store.subscribe((next, prev) => { + if (next.windows === prev.windows) return + const gone: SpaceId[] = [] + for (const [id, w] of Object.entries(prev.windows)) { + if (!next.windows[id] && isPrivateWindow(prev, id)) gone.push(w.spaceId) + } + if (gone.length > 0) setImmediate(() => dropSpaces(gone)) + }) + + actions.register('window.newPrivate', (c) => { + open({ fromWindowId: c.windowId }) + }) + // What a private window doesn't have does nothing there (SB-25): other Spaces, pinned tabs, + // Favorites, folders and the Archive. + actions.intercept((id, c) => { + if (!NOT_IN_PRIVATE.test(id)) return false + const windowId = c.windowId ?? windows.focusedWindowId() + return !!windowId && isPrivateWindow(store.state, windowId) + }) + + // macOS: the Dock menu; Windows: the taskbar's jump list (NV-11). + if (process.platform === 'darwin') { + app.dock?.setMenu( + Menu.buildFromTemplate([ + { label: 'New Window', click: () => void actions.run('window.new', {}) }, + { label: 'New Private Window', click: () => open() }, + ]), + ) + } else if (process.platform === 'win32' && !ctx.e2e) { + app.setUserTasks([ + { + program: process.execPath, + arguments: PRIVATE_WINDOW_SWITCH, + iconPath: process.execPath, + iconIndex: 0, + title: 'New Private Window', + description: 'Open a private window', + }, + ]) + } + app.on('second-instance', (_event, argv) => { + if (argv.includes(PRIVATE_WINDOW_SWITCH)) open() + }) + if (process.argv.includes(PRIVATE_WINDOW_SWITCH)) setImmediate(() => open()) + + return { open, profileId: () => current } +} diff --git a/src/main/nav/sidebar-reveal.ts b/src/main/nav/sidebar-reveal.ts index 267eaf9d..ce8921cb 100644 --- a/src/main/nav/sidebar-reveal.ts +++ b/src/main/nav/sidebar-reveal.ts @@ -64,7 +64,7 @@ export function registerSidebarReveal(ctx: MainContext): { const reveal = (windowId: WindowId) => { const win = store.state.windows[windowId] - if (win?.kind !== 'main' || win.sidebar.visible) return + if (!win || win.kind === 'mini' || win.sidebar.visible) return if (win.sidebar.peeking) { const hold = holdOf(windowId) hold.over = true diff --git a/src/main/nav/site-prefs.ts b/src/main/nav/site-prefs.ts index 0b1c0ded..5abe2c27 100644 --- a/src/main/nav/site-prefs.ts +++ b/src/main/nav/site-prefs.ts @@ -2,24 +2,31 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { mkdir, rename, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { sameZoom, ZOOM_STEPS } from '../../shared/nav' +import { isPrivateProfileId } from '../../shared/private' interface SitePrefsData { /** One-time hints already shown (e.g. the ⌘⇧C tip, NV-17). */ hints: Record<string, true> /** Page zoom per profile and host, when not 100% (NV-06). */ zoom: Record<string, Record<string, number>> + /** Hosts allowed to open pop-ups without a click, per profile (NV-09, NV-16). */ + popups: Record<string, Record<string, true>> } const MIN_ZOOM = ZOOM_STEPS[0] as number const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1] as number +type SiteChoices = Pick<SitePrefsData, 'zoom' | 'popups'> + /** * The chrome lane's small per-user settings, kept out of OrbitState so they never travel to the - * renderers: one-time hints now, per-site choices later. Saved off the main thread shortly after - * a change, and synchronously on quit. Private windows never write here. + * renderers: one-time hints and per-site choices. Saved off the main thread shortly after a + * change, and synchronously on quit. Private profiles' choices stay in memory (NV-11). */ export class SitePrefs { - private data: SitePrefsData = { hints: {}, zoom: {} } + private data: SitePrefsData = { hints: {}, zoom: {}, popups: {} } + /** Choices made in private windows: never written, gone with the private session. */ + private ephemeral: SiteChoices = { zoom: {}, popups: {} } private timer: NodeJS.Timeout | undefined private dirty = false @@ -39,7 +46,16 @@ export class SitePrefs { ;(zoom[profile] as Record<string, number>)[host] = factor } } - this.data = { hints, zoom } + const popups: Record<string, Record<string, true>> = {} + for (const [profile, hosts] of Object.entries(raw.popups ?? {})) { + if (!hosts || typeof hosts !== 'object') continue + for (const [host, allowed] of Object.entries(hosts)) { + if (allowed !== true) continue + popups[profile] ??= {} + ;(popups[profile] as Record<string, true>)[host] = true + } + } + this.data = { hints, zoom, popups } } catch { // No file yet, or unreadable: start empty. } @@ -57,20 +73,50 @@ export class SitePrefs { /** The page zoom remembered for a host in a profile (NV-06); undefined means 100%. */ zoomFor(profileId: string, host: string): number | undefined { - return this.data.zoom[profileId]?.[host] + return this.choices(profileId).zoom[profileId]?.[host] } setZoom(profileId: string, host: string, factor: number): void { - const hosts = this.data.zoom[profileId] + const choices = this.choices(profileId) + const hosts = choices.zoom[profileId] if (sameZoom(factor, 1)) { if (!hosts || hosts[host] === undefined) return delete hosts[host] - if (Object.keys(hosts).length === 0) delete this.data.zoom[profileId] + if (Object.keys(hosts).length === 0) delete choices.zoom[profileId] } else { if (hosts?.[host] !== undefined && sameZoom(hosts[host] as number, factor)) return - this.data.zoom[profileId] = { ...hosts, [host]: factor } + choices.zoom[profileId] = { ...hosts, [host]: factor } } - this.schedule() + this.changed(profileId) + } + + /** Whether a host may open pop-ups without a click in a profile (NV-09). */ + popupsAllowed(profileId: string, host: string): boolean { + return this.choices(profileId).popups[profileId]?.[host] === true + } + + setPopupsAllowed(profileId: string, host: string, allowed: boolean): void { + if (this.popupsAllowed(profileId, host) === allowed) return + const choices = this.choices(profileId) + const hosts = { ...choices.popups[profileId] } + if (allowed) hosts[host] = true + else delete hosts[host] + if (Object.keys(hosts).length > 0) choices.popups[profileId] = hosts + else delete choices.popups[profileId] + this.changed(profileId) + } + + /** Forgets every choice made in private windows (the last one closed). */ + forgetPrivate(): void { + this.ephemeral = { zoom: {}, popups: {} } + } + + private choices(profileId: string): SiteChoices { + return isPrivateProfileId(profileId) ? this.ephemeral : this.data + } + + private changed(profileId: string): void { + if (!isPrivateProfileId(profileId)) this.schedule() } /** Writes pending changes now (quit). */ diff --git a/src/main/persist.ts b/src/main/persist.ts index ccce11ba..017aefa0 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -13,6 +13,7 @@ import { MIN_SIDEBAR_WIDTH, } from '../shared/defaults' import { isFolderId, isSplitId, isTabId } from '../shared/ids' +import { withoutPrivate } from '../shared/private' import type { ItemId, OrbitState, ProfileId, SpaceId, SplitLayout, TabId } from '../shared/state' import { STATE_SCHEMA } from '../shared/state' import { normalizeSpaceTheme } from '../shared/theme' @@ -129,6 +130,7 @@ export function normalizeState(input: Partial<OrbitState>): OrbitState { tab.asleep = true tab.progress = undefined tab.error = undefined + tab.blockedPopups = undefined tab.canGoBack = tab.canGoBack ?? false tab.canGoForward = tab.canGoForward ?? false tab.muted = tab.muted ?? false @@ -371,7 +373,7 @@ function repairReferences(state: OrbitState): void { * touch nothing else (loading flips, focus, audio, download progress…) don't cause a write. */ const RUNTIME_ONLY: Record<string, ReadonlySet<string>> = { - tabs: new Set(['loading', 'audible', 'asleep', 'progress', 'error', 'canGoBack', 'canGoForward']), + tabs: new Set(['loading', 'audible', 'asleep', 'progress', 'error', 'canGoBack', 'canGoForward', 'blockedPopups']), windows: new Set(['focused', 'fullscreen', 'overlay']), downloads: new Set(['receivedBytes']), } @@ -384,9 +386,12 @@ export function isRuntimeOnlyPatch(patch: Patch): boolean { return RUNTIME_ONLY[String(root)]?.has(String(field)) ?? false } -/** Runtime-only fields are written too (they are small) and reset on load. */ +/** + * Runtime-only fields are written too (they are small) and reset on load. Private windows and + * everything in them never are (NV-11). + */ function serialize(state: OrbitState): string { - return JSON.stringify(state) + return JSON.stringify(withoutPrivate(state)) } export const backupOf = (file: string): string => `${file}.bak` diff --git a/src/main/sessions.ts b/src/main/sessions.ts index 9a2ef3bd..b43805d5 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -1,5 +1,6 @@ import { dialog, type Session, session } from 'electron' import { DEFAULT_PROFILE_ID } from '../shared/defaults' +import { isPrivateProfileId, PRIVATE_PARTITION_PREFIX } from '../shared/private' import type { ProfileId } from '../shared/state' import type { Store } from './store' @@ -67,7 +68,12 @@ export class Sessions { get(profileId: ProfileId): Session { const profiles = this.store.state.profiles - const profile = profiles[profileId] ?? profiles[DEFAULT_PROFILE_ID] ?? Object.values(profiles)[0] + // A private profile that is gone (its last window closed) never falls back to a normal one: + // whatever still asks for it gets a session that keeps nothing (NV-11). + const fallback = isPrivateProfileId(profileId) + ? { id: profileId, partition: `${PRIVATE_PARTITION_PREFIX}gone` } + : (profiles[DEFAULT_PROFILE_ID] ?? Object.values(profiles)[0]) + const profile = profiles[profileId] ?? fallback if (!profile) throw new Error('No profiles') const ses = session.fromPartition(profile.partition) if (!this.configured.has(profile.partition)) { @@ -83,6 +89,11 @@ export class Sessions { return this.decisions.get(permissionKey(partition, originOf(origin), permission)) } + /** Forgets every decision made in a profile (a private session ended, NV-11). */ + forget(partition: string): void { + for (const key of [...this.decisions.keys()]) if (key.startsWith(`${partition}|`)) this.decisions.delete(key) + } + /** Records a decision (from the prompt; later from site controls). */ decide(partition: string, origin: string, permission: string, allowed: boolean): void { this.decisions.set(permissionKey(partition, originOf(origin), permission), allowed) diff --git a/src/main/spaces/model.ts b/src/main/spaces/model.ts index 3d846238..8bced5f9 100644 --- a/src/main/spaces/model.ts +++ b/src/main/spaces/model.ts @@ -6,6 +6,7 @@ import { current, type Draft, isDraft } from 'immer' import { createProfile, createSpace, DEFAULT_PROFILE_ID, NEW_SPACE_PRESETS, THEME_PRESETS } from '../../shared/defaults' import { isFolderId, isSplitId, isTabId, newId } from '../../shared/ids' +import { isPrivateSpace, isPrivateWindow } from '../../shared/private' import { placeholderName } from '../../shared/space-names' import type { ArchivedTab, @@ -99,6 +100,8 @@ export function reorderSpaceInDraft(d: OrbitState, spaceId: SpaceId, index: numb export function switchSpaceInDraft(d: OrbitState, windowId: WindowId, spaceId: SpaceId): boolean { const win = d.windows[windowId] if (!win || !d.spaces[spaceId] || win.spaceId === spaceId) return false + // A private window keeps its own Space, and no other window shows one (NV-11). + if (isPrivateWindow(d, windowId) || isPrivateSpace(d, spaceId)) return false win.spaceId = spaceId // The Space's remembered tab may be on screen in another window: a page is live in one // window at a time, so the window that switched keeps it and the other picks again (TB-17). diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index c301ade6..ac17f3ce 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -8,6 +8,7 @@ import { type WebContentsViewConstructorOptions, } from 'electron' import { isFolderId, isTabId, newId } from '../../shared/ids' +import { isPrivateTab, privateTabMenu } from '../../shared/private' import type { ContainerRef, Folder, FolderId, ItemId, TabId, WindowId } from '../../shared/state' import { containerItems, @@ -369,7 +370,9 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const windowId = windowOf(c) const state = store.state const mo = menuOps(windowId) - const items = isFolderId(id) ? folderMenu(state, id, mo) : isTabId(id) ? tabMenu(state, id, selection, mo) : [] + const all = isFolderId(id) ? folderMenu(state, id, mo) : isTabId(id) ? tabMenu(state, id, selection, mo) : [] + // Private windows have no pinned tabs, Favorites, folders or other Spaces (NV-11, SB-25). + const items = isTabId(id) && isPrivateTab(state, id) ? privateTabMenu(all) : all if (items.length === 0) return lastMenu = items if (ctx.e2e) return @@ -448,6 +451,12 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM createWindow: (options) => { const windowId = windows.windowOfTab(openerTabId) ?? windows.focusedWindowId() const spaceId = windowId ? ops.spaceOfWindow(windowId) : opener.spaceId + if (!(options as { webContents?: unknown }).webContents) { + // ⇧⌘-click: the engine brings no page of its own (a view built from these options + // would throw), so an ordinary new tab loads the link (NV-08). + const id = ops.createTab({ url: details.url, spaceId, windowId, activate: true, openerTabId }) + return (tabs.ensure(id) as WebContentsView).webContents + } const tab = makeTab({ spaceId, profileId: opener.profileId, url: details.url, openerTabId }) // Add the tab, give it the real (opener-linked) view, and only then select it: selecting // first would make the layout pass create a second, orphan view (review M1). diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index 999c0bc2..2c6aeb3b 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -1,6 +1,15 @@ import { type ContextMenuParams, clipboard, Menu, type MenuItemConstructorOptions, type WebContents } from 'electron' import type { TabId } from '../../shared/state' import type { MainContext } from '../context' +import { + dictionaryItems, + keepPageMenu, + linkWindowItems, + lookUpItems, + pageItems, + saveLinkItem, + searchLabel, +} from '../nav/page-menu' import { linkSplitMenuItem } from '../split/menu' /** Native right-click menu for web pages. */ @@ -16,16 +25,18 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We { label: 'Open Link in New Tab', click: () => openInNewTab(params.linkURL, false) }, // A new pane beside the page's pane (SV-08). linkSplitMenuItem(ctx, tabId, params.linkURL), + ...linkWindowItems(ctx, tabId, params.linkURL), { label: 'Copy Link Address', click: () => clipboard.writeText(params.linkURL) }, + saveLinkItem(contents, params.linkURL), { type: 'separator' }, ) } if (params.mediaType === 'image' && params.srcURL) { items.push( { label: 'Open Image in New Tab', click: () => openInNewTab(params.srcURL, false) }, + { label: 'Save Image As…', click: () => contents.downloadURL(params.srcURL) }, { label: 'Copy Image', click: () => contents.copyImageAt(params.x, params.y) }, { label: 'Copy Image Address', click: () => clipboard.writeText(params.srcURL) }, - { label: 'Save Image As…', click: () => contents.downloadURL(params.srcURL) }, { type: 'separator' }, ) } @@ -33,7 +44,9 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We for (const suggestion of params.dictionarySuggestions.slice(0, 4)) { items.push({ label: suggestion, click: () => contents.replaceMisspelling(suggestion) }) } - if (params.dictionarySuggestions.length) items.push({ type: 'separator' }) + const dictionary = dictionaryItems(contents, params) + items.push(...dictionary) + if (params.dictionarySuggestions.length || dictionary.length) items.push({ type: 'separator' }) items.push( { role: 'undo', enabled: params.editFlags.canUndo }, { role: 'redo', enabled: params.editFlags.canRedo }, @@ -41,15 +54,15 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We { role: 'cut', enabled: params.editFlags.canCut }, { role: 'copy', enabled: params.editFlags.canCopy }, { role: 'paste', enabled: params.editFlags.canPaste }, + { role: 'pasteAndMatchStyle', enabled: params.editFlags.canPaste }, { role: 'selectAll' }, { type: 'separator' }, ) } else if (params.selectionText) { - const text = params.selectionText.trim().slice(0, 40) items.push( { role: 'copy' }, { - label: `Search for “${text}${params.selectionText.length > 40 ? '…' : ''}”`, + label: searchLabel(ctx, params.selectionText), click: () => { const engine = ctx.store.state.settings.searchEngine void import('../../shared/url').then(({ searchUrl }) => @@ -57,6 +70,7 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We ) }, }, + ...lookUpItems(contents, params.selectionText), { type: 'separator' }, ) } @@ -74,10 +88,13 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We }, { label: 'Reload', click: () => contents.reload() }, { type: 'separator' }, + ...pageItems(ctx, tabId, contents), + { type: 'separator' }, { label: 'Copy Page URL', click: () => clipboard.writeText(contents.getURL()) }, { type: 'separator' }, ) } items.push({ label: 'Inspect', click: () => contents.inspectElement(params.x, params.y) }) + if (keepPageMenu(ctx, tabId, items)) return Menu.buildFromTemplate(items).popup() } diff --git a/src/main/tabs/model.ts b/src/main/tabs/model.ts index f875def1..df90799a 100644 --- a/src/main/tabs/model.ts +++ b/src/main/tabs/model.ts @@ -3,6 +3,7 @@ */ import { DEFAULT_PROFILE_ID } from '../../shared/defaults' import { newId } from '../../shared/ids' +import { windowMayShow } from '../../shared/private' import { noteSplitFocus, removePane } from '../../shared/split' import type { ItemId, OrbitState, Space, SpaceId, Tab, TabId, WindowId } from '../../shared/state' import { @@ -50,6 +51,8 @@ export function activateInDraft(d: OrbitState, windowId: WindowId, tabId: TabId, const win = d.windows[windowId] const tab = d.tabs[tabId] if (!win || !tab) return + // A private window shows only its own tabs, and no other window shows them (NV-11). + if (!windowMayShow(d, windowId, tabId)) return const isFavorite = sectionOf(d, tabId) === 'favorite' const currentSpace = d.spaces[win.spaceId] // Favorites are shared by all Spaces of a profile: stay in the current Space when it shows them. diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index a0e1be81..0d82b7f8 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -5,6 +5,7 @@ */ import { createWindowState, MAX_FAVORITES } from '../../shared/defaults' import { isFolderId, isTabId, newId } from '../../shared/ids' +import { isLostPrivateTab, isPrivateProfileId, isPrivateSpace, mayReopenIn, privateMoveAllowed } from '../../shared/private' import { isPendingPane, layoutLeaves, paneAfterClose, SPLIT_FULL_MESSAGE } from '../../shared/split' import type { ArchivedTab, @@ -197,6 +198,8 @@ export class TabOps { section: 'today' | 'pinned', ): string | undefined { if (!tab.url || tab.url === 'about:blank') return undefined + // Private tabs leave nothing behind (NV-11). + if (isPrivateProfileId(tab.profileId)) return undefined const id = newId('a') // Appended (newest last): an unshift would rewrite every entry's patch path. d.archive.push({ @@ -316,7 +319,8 @@ export class TabOps { /** Puts closed tabs back where they were (or at the top of Today), with their history (TB-07). */ restoreClosed(records: ClosedTab[], opts: { windowId?: WindowId; select: boolean }): TabId[] { - const live = records.filter((r) => !this.state.tabs[r.tab.id]) + // A private tab whose window is gone never comes back (NV-11). + const live = records.filter((r) => !this.state.tabs[r.tab.id] && !isLostPrivateTab(this.state, r.tab)) if (live.length === 0) return [] const ids = new Set(live.map((r) => r.tab.id)) for (let i = this.recentlyClosed.length - 1; i >= 0; i--) { @@ -405,7 +409,7 @@ export class TabOps { reopen(tabId: TabId, windowId: WindowId | undefined): TabId | null { const i = this.recentlyClosed.findIndex((r) => r.tab.id === tabId) const rec = i >= 0 ? this.recentlyClosed[i] : undefined - if (!rec) return null + if (!rec || !mayReopenIn(this.state, rec.tab, windowId)) return null this.recentlyClosed.splice(i, 1) const [id] = this.restoreClosed(this.withUnit(rec), { windowId, select: true }) return id ?? null @@ -427,8 +431,11 @@ export class TabOps { /** ⌘⇧T: reopens the most recently closed tab (a stack). */ reopenLast(windowId: WindowId | undefined): TabId | null { - while (this.recentlyClosed.length > 0) { - const rec = this.recentlyClosed.pop() as ClosedTab + // Private windows reopen their own tabs, normal windows never a private one (NV-11). + const fits = (r: ClosedTab) => mayReopenIn(this.state, r.tab, windowId) + while (this.recentlyClosed.some(fits)) { + const i = this.recentlyClosed.findLastIndex(fits) + const rec = this.recentlyClosed.splice(i, 1)[0] as ClosedTab if (this.state.tabs[rec.tab.id]) continue const restored = this.restoreClosed(this.withUnit(rec), { windowId, select: true }) return restored[restored.length - 1] ?? null @@ -763,6 +770,7 @@ export class TabOps { ) if (items.length === 0) return false if (!items.every((id) => canMoveTo(state, id, to))) return false + if (!privateMoveAllowed(state, items, to)) return false // Never move a folder into one of the other moving folders' subtrees. if (to.kind === 'folder' && items.includes(to.folderId)) return false if (to.kind === 'favorites') { @@ -972,7 +980,7 @@ export class TabOps { const tab = state.tabs[tabId] if (!tab) return null const spaceId = fromWindowId ? this.spaceOfWindow(fromWindowId) : tab.spaceId - const win = createWindowState(spaceId) + const win = createWindowState(spaceId, isPrivateSpace(state, spaceId) ? 'private' : 'main') win.activeTab[spaceId] = tabId const source = fromWindowId ? this.ctx.windows.get(fromWindowId) : undefined const size = source && !source.isDestroyed() ? source.getBounds() : { width: 1100, height: 760 } diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 82c8da19..7dcdd080 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import { type NavigationEntry, nativeImage, type WebContents, WebContentsView } from 'electron' +import { isPrivateProfileId } from '../../shared/private' import type { Tab, TabId } from '../../shared/state' import { isAwayFromPinned, sameDocUrl } from '../../shared/tabs' import type { MainContext } from '../context' @@ -117,6 +118,8 @@ export class TabRuntime { onControlUp?: () => void /** A page tried to open a tab or pop-up without a user gesture. */ onPopupBlocked?: (tabId: TabId, url: string) => void + /** True when the tab's site may open pop-ups without a click (allowed in site controls, NV-09). */ + allowsPopups?: (tabId: TabId) => boolean /** Last user gesture per tab, for the pop-up rule (NV-09). */ private gestures = new Map<TabId, { at: number; used: boolean }>() /** Favicon URL → data URL, fetched in the tab's own session (no fetches from Orbit's UI). */ @@ -265,7 +268,7 @@ export class TabRuntime { contents.on('context-menu', (_e, params) => showPageContextMenu(this.ctx, tabId, contents, params)) contents.setWindowOpenHandler((details) => { // No tabs or pop-ups without a user gesture, and one per gesture (NV-09). - if (!this.consumeGesture(tabId)) { + if (!this.consumeGesture(tabId) && !this.allowsPopups?.(tabId)) { this.onPopupBlocked?.(tabId, details.url) return { action: 'deny' } } @@ -365,8 +368,9 @@ export class TabRuntime { /** A tab's icon, also remembered by origin (outside OrbitState) for archived pages and lists. */ private setFavicon(tabId: TabId, data: string): void { this.update(tabId, { favicon: data }) - const url = this.ctx.store.state.tabs[tabId]?.url - if (url) this.faviconStore?.set(url, data) + const tab = this.ctx.store.state.tabs[tabId] + // Private tabs' icons are not remembered (NV-11). + if (tab?.url && !isPrivateProfileId(tab.profileId)) this.faviconStore?.set(tab.url, data) } /** The remembered icon for a page's origin (archive, history, command bar). */ diff --git a/src/main/windows.ts b/src/main/windows.ts index cec9e2d6..5abf055a 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -4,6 +4,7 @@ import { actionForInput, outsideTextActionFor } from '../shared/actions' import { createWindowState, DEFAULT_SIDEBAR_WIDTH } from '../shared/defaults' import type { Rect, RendererView } from '../shared/rpc' import type { ColorMode, OrbitState, SidebarState, SpaceId, TabId, WindowId, WindowState } from '../shared/state' +import { isPrivateSpace, PRIVATE_PALETTE } from '../shared/private' import { paletteFor } from '../shared/theme' import { splitLeaves, splitOfTab } from '../shared/tree' import type { MainContext } from './context' @@ -94,6 +95,8 @@ const FLOAT_PREWARM_MS = SIDEBAR_SLIDE_MS const FLOAT_DISPOSE_MS = 60_000 const isMac = process.platform === 'darwin' +/** How far a new window sits down and right of the one it was opened from (NV-10, SP-24). */ +const CASCADE = 28 /** Isolated world for Orbit's own small page scripts (the page can't tamper with them). */ const EXIT_FULLSCREEN_WORLD = 1001 @@ -262,6 +265,23 @@ export class WindowManager { return false } + /** + * Where a window opened from `fromWindowId` goes: the same size, a step down and right of it, + * back at the screen's corner when that would leave the screen (NV-10, SP-24). Undefined when + * the source is in full screen or gone. + */ + cascadeFrom(fromWindowId: WindowId | undefined): WindowState['bounds'] { + const source = fromWindowId ? this.get(fromWindowId) : undefined + if (!source || source.isDestroyed() || source.isFullScreen()) return undefined + const b = source.getNormalBounds() + const area = screen.getDisplayMatching(b).workArea + let x = b.x + CASCADE + let y = b.y + CASCADE + if (x + b.width > area.x + area.width) x = area.x + CASCADE + if (y + b.height > area.y + area.height) y = area.y + CASCADE + return { x, y, width: b.width, height: b.height } + } + /** * Opens a window for an existing window state, or a new one showing `spaceId`. With no main * window open, the last closed main window comes back (its bounds, Space and tabs). @@ -270,7 +290,9 @@ export class WindowManager { const state = this.ctx.store.state let winState = existing ?? this.takeClosedMain(spaceId) if (!winState) { - const space = spaceId ?? state.windows[this.focusedWindowId() ?? '']?.spaceId ?? state.spaceOrder[0] + // A window opened from a private one shows a normal Space (NV-11). + const focused = state.windows[this.focusedWindowId() ?? '']?.spaceId + const space = spaceId ?? (focused && !isPrivateSpace(state, focused) ? focused : undefined) ?? state.spaceOrder[0] if (!space) throw new Error('No spaces') winState = createWindowState(space) const created = winState @@ -1192,6 +1214,8 @@ function syncAppearance(appearance: ColorMode): void { export function windowBackground(state: OrbitState, spaceId: SpaceId | undefined): string | undefined { const space = spaceId ? state.spaces[spaceId] : undefined if (!space) return undefined + // Private windows are near-black whatever the theme (AP-05). + if (isPrivateSpace(state, space.id)) return PRIVATE_PALETTE.windowBackground // The theme source follows settings.appearance, so this is the global light/dark choice. return paletteFor(space.theme, nativeTheme.shouldUseDarkColors).windowBackground } diff --git a/src/renderer/src/shell/ShellApp.tsx b/src/renderer/src/shell/ShellApp.tsx index c964386e..ae4ffe1b 100644 --- a/src/renderer/src/shell/ShellApp.tsx +++ b/src/renderer/src/shell/ShellApp.tsx @@ -16,7 +16,7 @@ export function ShellApp() { const style = { '--sidebar-w': `${win.sidebar.width}px` } as CSSProperties return ( <div - className={`shell${hidden ? ' shell--sidebar-hidden' : ''}${win.fullscreen ? ' shell--fullscreen' : ''}${win.focused ? '' : ' shell--blurred'}`} + className={`shell${hidden ? ' shell--sidebar-hidden' : ''}${win.fullscreen ? ' shell--fullscreen' : ''}${win.focused ? '' : ' shell--blurred'}${win.kind === 'private' ? ' shell--private' : ''}`} style={style} ref={theme.shellRef} data-testid="shell" @@ -26,7 +26,7 @@ export function ShellApp() { </div> <Sidebar win={win} /> <ContentArea win={win} /> - {hidden && win.kind === 'main' && <SidebarEdge />} + {hidden && win.kind !== 'mini' && <SidebarEdge />} <Toasts /> </div> ) diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx index dcd9affb..ef63ad57 100644 --- a/src/renderer/src/shell/chrome/Address.tsx +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -1,8 +1,8 @@ -import { Check, Link2, SlidersHorizontal, TriangleAlert } from 'lucide-react' +import { Check, Link2, ShieldBan, SlidersHorizontal, TriangleAlert } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { connectionOf } from '../../../../shared/nav' import { displayHost } from '../../../../shared/url' -import { runAction } from '../../api' +import { invoke, runAction } from '../../api' import { activeTabId } from '../../selectors' import { useOrbit } from '../../store' import { withShortcut } from './shortcut-label' @@ -14,6 +14,7 @@ const COPIED_MS = 1200 * The address display under the top row (SB-04): the active tab's host only (the focused pane * in split view), a site-controls button that turns into a warning on insecure pages (no padlock * on secure ones), and a copy-link button. Clicking the host edits the URL in the command bar. + * When the page tried to open pop-ups without a click, a button says so and offers them (NV-09). */ export function Address({ windowId }: { windowId: string }) { // Only the URL: the display re-renders on navigations, not on title, favicon or progress changes. @@ -21,6 +22,10 @@ export function Address({ windowId }: { windowId: string }) { const id = activeTabId(s, windowId) return id ? (s.tabs[id]?.url ?? '') : '' }) + const blockedPopups = useOrbit((s) => { + const id = activeTabId(s, windowId) + return id ? (s.tabs[id]?.blockedPopups ?? 0) : 0 + }) const page = url && url !== 'about:blank' ? url : '' const connection = connectionOf(page) const insecure = connection === 'insecure' @@ -65,6 +70,21 @@ export function Address({ windowId }: { windowId: string }) { > <span className="address__text">{page ? displayHost(page) : 'Search or enter address'}</span> </button> + {page && blockedPopups > 0 && ( + <button + type="button" + className="address__button address__blocked" + data-testid="popups-blocked" + aria-label={blockedPopups === 1 ? 'Pop-up blocked' : `${blockedPopups} pop-ups blocked`} + title="Pop-ups blocked on this page" + onClick={(e) => { + const r = e.currentTarget.getBoundingClientRect() + void invoke('nav.blockedPopupsMenu', { x: r.left, y: r.bottom + 4 }) + }} + > + <ShieldBan size={13} /> + </button> + )} {page && ( <button type="button" diff --git a/src/renderer/src/shell/chrome/PrivateSidebar.tsx b/src/renderer/src/shell/chrome/PrivateSidebar.tsx new file mode 100644 index 00000000..e859793d --- /dev/null +++ b/src/renderer/src/shell/chrome/PrivateSidebar.tsx @@ -0,0 +1,24 @@ +import { HatGlasses } from 'lucide-react' +import { PRIVATE_NOTE } from '../../../../shared/private' +import type { SpaceId } from '../../../../shared/state' +import { SidebarItems } from '../sidebar/SidebarItems' + +/** + * A private window's sidebar below the top bar (SB-25, AP-05): a "Private" label with what it does + * and doesn't hide, then the window's own tabs. No Spaces, pinned tabs or Favorites. + */ +export function PrivateSidebar({ spaceId }: { spaceId: SpaceId }) { + return <SidebarItems spaceId={spaceId} simple header={<PrivateHeader />} /> +} + +function PrivateHeader() { + return ( + <div className="private-header" data-testid="private-label"> + <div className="private-header__label"> + <HatGlasses size={15} aria-hidden /> + <span>Private</span> + </div> + <p className="private-header__note">{PRIVATE_NOTE}</p> + </div> + ) +} diff --git a/src/renderer/src/shell/chrome/chrome.css b/src/renderer/src/shell/chrome/chrome.css index 40745556..23379360 100644 --- a/src/renderer/src/shell/chrome/chrome.css +++ b/src/renderer/src/shell/chrome/chrome.css @@ -106,6 +106,25 @@ opacity: 1; } +/* Pop-ups the page tried to open without a click (NV-09): shown until the next page. */ +.address__blocked { + color: var(--text); + animation: address-blocked-in 220ms cubic-bezier(0.3, 1.3, 0.6, 1); +} + +@keyframes address-blocked-in { + from { + opacity: 0; + transform: scale(0.6); + } +} + +@media (prefers-reduced-motion: reduce) { + .address__blocked { + animation: none; + } +} + /* Insecure pages: a clear warning instead of the site-controls glyph (no padlock on https). */ .address--insecure .address__site { color: #c9372c; @@ -201,3 +220,37 @@ html[data-view="sidebar"] body { transition: none; } } + +/* ---------- Private window (SB-25, AP-05) ---------- */ + +.private-header { + display: flex; + flex-direction: column; + gap: 6px; + margin: 6px 4px 10px; + padding: 10px 10px 11px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07); +} + +.private-header__label { + display: flex; + align-items: center; + gap: 7px; + font-weight: 600; + font-size: 13px; + letter-spacing: 0.01em; + color: var(--text); +} + +.private-header__label svg { + color: var(--accent); +} + +.private-header__note { + margin: 0; + font-size: 11.5px; + line-height: 1.4; + color: var(--text-muted); +} diff --git a/src/renderer/src/shell/sidebar/Sidebar.tsx b/src/renderer/src/shell/sidebar/Sidebar.tsx index 58f650cf..07ef4566 100644 --- a/src/renderer/src/shell/sidebar/Sidebar.tsx +++ b/src/renderer/src/shell/sidebar/Sidebar.tsx @@ -8,6 +8,7 @@ import { DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from '../ import type { WindowState } from '../../../../shared/state' import { invoke } from '../../api' import { useOrbit } from '../../store' +import { PrivateSidebar } from '../chrome/PrivateSidebar' import { SpaceStrip } from '../spaces/SpaceStrip' import { UndoToast } from '../spaces/UndoToast' import { SpaceBar } from './SpaceBar' @@ -18,22 +19,30 @@ import { TopBar } from './TopBar' * (Favorites, title row, pinned, Today; swipeable between Spaces), then the * Space switcher. Space content lives in ../spaces/. `floating` is the hidden * sidebar's card shown over the page (SB-12), which has no resize edge. + * A private window's sidebar has only its own tabs under a "Private" label (SB-25). */ export function Sidebar({ win, floating = false }: { win: WindowState; floating?: boolean }) { const exists = useOrbit((s) => !!s.spaces[win.spaceId]) if (!exists) return null + const isPrivate = win.kind === 'private' return ( <aside - className={`sidebar${floating ? ' sidebar--floating' : ''}`} + className={`sidebar${floating ? ' sidebar--floating' : ''}${isPrivate ? ' sidebar--private' : ''}`} data-testid="sidebar" aria-hidden={!floating && !win.sidebar.visible} - onMouseUp={onSideButtons} + onMouseUp={isPrivate ? undefined : onSideButtons} onAuxClick={preventSideButtons} > <TopBar win={win} floating={floating} /> - <SpaceStrip windowId={win.id} spaceId={win.spaceId} /> - <UndoToast /> - <SpaceBar win={win} /> + {isPrivate ? ( + <PrivateSidebar spaceId={win.spaceId} /> + ) : ( + <> + <SpaceStrip windowId={win.id} spaceId={win.spaceId} /> + <UndoToast /> + <SpaceBar win={win} /> + </> + )} {!floating && <ResizeHandle win={win} />} </aside> ) diff --git a/src/renderer/src/shell/sidebar/SidebarItems.tsx b/src/renderer/src/shell/sidebar/SidebarItems.tsx index 97748a2e..3cba31e0 100644 --- a/src/renderer/src/shell/sidebar/SidebarItems.tsx +++ b/src/renderer/src/shell/sidebar/SidebarItems.tsx @@ -35,12 +35,15 @@ export const SidebarItems = memo(function SidebarItems({ spaceId, header, onContextMenu, + simple = false, }: { spaceId: SpaceId /** The Space title row (owned by the Spaces lane), drawn at the top of the scrolling list. */ header?: ReactNode /** Right-click on empty list space (rows handle their own menus first). */ onContextMenu?: (e: ReactMouseEvent) => void + /** A private window's list (SB-25): Today only, no Favorites or pinned section. */ + simple?: boolean }) { const space = useOrbit((s) => s.spaces[spaceId]) const active = useOrbit((s) => activeTabId(s)) @@ -81,9 +84,11 @@ export const SidebarItems = memo(function SidebarItems({ return ( <MotionConfig reducedMotion="user"> <div className="sidebar-items" ref={rootRef}> - <div className="sidebar-items__favorites" data-favorites-layer> - <Favorites ref={favRef} profileId={space.profileId} /> - </div> + {!simple && ( + <div className="sidebar-items__favorites" data-favorites-layer> + <Favorites ref={favRef} profileId={space.profileId} /> + </div> + )} <div className="sidebar__scroll" ref={scrollRef} @@ -98,8 +103,8 @@ export const SidebarItems = memo(function SidebarItems({ if (!e.defaultPrevented) onContextMenu?.(e) }} > - <SpaceHeader space={space}>{header}</SpaceHeader> - <PinnedSection space={space} /> + {simple ? header : <SpaceHeader space={space}>{header}</SpaceHeader>} + {!simple && <PinnedSection space={space} />} <TodayBlock space={space} /> <ItemList key={`today:${space.id}`} diff --git a/src/renderer/src/shell/spaces/NewSpaceFlow.tsx b/src/renderer/src/shell/spaces/NewSpaceFlow.tsx index 516776d7..8b02cacb 100644 --- a/src/renderer/src/shell/spaces/NewSpaceFlow.tsx +++ b/src/renderer/src/shell/spaces/NewSpaceFlow.tsx @@ -1,6 +1,7 @@ import { Check, Smile } from 'lucide-react' import { type KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from 'react' import { DEFAULT_PROFILE_ID, NEW_SPACE_PRESETS, THEME_PRESETS } from '../../../../shared/defaults' +import { isPrivateProfile } from '../../../../shared/private' import { placeholderName } from '../../../../shared/space-names' import type { SpaceTheme } from '../../../../shared/state' import { invoke } from '../../api' @@ -41,7 +42,10 @@ export function NewSpaceFlow({ windowId }: { windowId: string }) { }, []) useEffect(() => switcher.setFlowTheme(theme), [theme]) - const profileList = Object.values(profiles).sort((a, b) => + // The private windows' profile is not one to pick (NV-11). + const profileList = Object.values(profiles) + .filter((p) => !isPrivateProfile(p)) + .sort((a, b) => a.id === DEFAULT_PROFILE_ID ? -1 : b.id === DEFAULT_PROFILE_ID ? 1 : a.createdAt - b.createdAt, ) diff --git a/src/renderer/src/shell/spaces/SpacePanels.tsx b/src/renderer/src/shell/spaces/SpacePanels.tsx index e63f73be..8cfb4063 100644 --- a/src/renderer/src/shell/spaces/SpacePanels.tsx +++ b/src/renderer/src/shell/spaces/SpacePanels.tsx @@ -24,6 +24,7 @@ import { } from 'react' import { SPACE_PANELS, type SpacePanel } from '../../../../shared/commands/spaces' import { DEFAULT_PROFILE_ID } from '../../../../shared/defaults' +import { isPrivateProfile } from '../../../../shared/private' import type { ColorMode, OverlayState, SpaceId, SpaceTheme } from '../../../../shared/state' import { invoke, runAction } from '../../api' import { useOrbit } from '../../store' @@ -249,6 +250,8 @@ function SpaceMenu({ spaceId, x, y, sidebar }: { spaceId: SpaceId; x: number; y: const count = useOrbit((s) => s.spaceOrder.length) if (!space) return null const profileItems: MenuItem[] = Object.values(profiles) + // The private windows' profile is not one to pick (NV-11). + .filter((p) => !isPrivateProfile(p)) .sort((a, b) => (a.id === DEFAULT_PROFILE_ID ? -1 : b.id === DEFAULT_PROFILE_ID ? 1 : a.createdAt - b.createdAt)) .map((p) => ({ id: `profile-${p.id}`, diff --git a/src/renderer/src/theme.ts b/src/renderer/src/theme.ts index 0aa5d958..3c4ebca3 100644 --- a/src/renderer/src/theme.ts +++ b/src/renderer/src/theme.ts @@ -1,4 +1,5 @@ import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { isPrivateSpace, PRIVATE_PALETTE } from '../../shared/private' import type { SpaceTheme } from '../../shared/state' import { mixPalettes, type Palette, paletteFor, resolveDark } from '../../shared/theme' import { getOrbitState, useOrbit } from './store' @@ -51,8 +52,13 @@ export function cachedPalette(theme: SpaceTheme, dark: boolean): Palette { export function usePalette(spaceId: string | undefined): Palette | undefined { const theme = useOrbit((s) => (spaceId ? s.spaces[spaceId]?.theme : undefined)) + // Private windows are near-black whatever the theme or light/dark setting (AP-05). + const isPrivate = useOrbit((s) => isPrivateSpace(s, spaceId)) const dark = useDark() - return useMemo(() => (theme ? cachedPalette(theme, dark) : undefined), [theme, dark]) + return useMemo( + () => (isPrivate ? PRIVATE_PALETTE : theme ? cachedPalette(theme, dark) : undefined), + [isPrivate, theme, dark], + ) } /** CSS custom properties for a palette (shared by the shell driver and the overlay). */ diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index e4575901..3cf544f8 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -67,6 +67,11 @@ export interface NavCommands { 'find.step': { args: { forward: boolean }; result: void } /** Closes the find bar: highlights go, the page keeps the last match selected and gets focus. */ 'find.close': { args: Record<string, never>; result: void } + /** + * The address display's blocked pop-ups button: a menu at a point in window coordinates to open + * the active tab's blocked pop-ups or always allow them on its site (NV-09). + */ + 'nav.blockedPopupsMenu': { args: { x: number; y: number }; result: void } /** The active tab's page zoom as a percentage, and whether it differs from the default (NV-06, NV-16). */ 'zoom.get': { args: { tabId?: TabId }; result: { percent: number } } } diff --git a/src/shared/private.ts b/src/shared/private.ts new file mode 100644 index 00000000..9f5a5c06 --- /dev/null +++ b/src/shared/private.ts @@ -0,0 +1,213 @@ +/** + * Private windows (NV-11, SB-25, AP-05). Each private window shows a Space of its own, kept out of + * the Space order, so no other window lists it. Its profile is "private": an in-memory session + * partition that every open private window shares and that is wiped when the last one closes. + * Nothing private is ever written to disk: `withoutPrivate` is what the state file gets. + */ +import type { + ContainerRef, + ItemId, + OrbitState, + Profile, + ProfileId, + SpaceId, + SplitLayout, + Tab, + TabId, + WindowId, +} from './state' +import type { Palette } from './theme' +import { locate, spaceOfContainer } from './tree' + +/** Ids of private profiles start with this, so a tab still says it was private after its profile went. */ +export const PRIVATE_PROFILE_PREFIX = 'private-' +/** Session partitions of private profiles: no "persist:" prefix, so Chromium keeps them in memory. */ +export const PRIVATE_PARTITION_PREFIX = 'orbit-private-' + +/** What the private window's sidebar says about it (NV-11: plain about what it does and doesn't hide). */ +export const PRIVATE_NOTE = + 'Hides your browsing from other people using this device, not from websites or your network. History, cookies and site data are deleted when the last private window closes.' + +export function isPrivateProfileId(id: string | undefined): boolean { + return !!id && id.startsWith(PRIVATE_PROFILE_PREFIX) +} + +export function isPrivateProfile(profile: Profile | undefined): boolean { + return !!profile && (isPrivateProfileId(profile.id) || !profile.partition.startsWith('persist:')) +} + +export function isPrivateSpace(state: OrbitState, spaceId: SpaceId | undefined): boolean { + const space = spaceId ? state.spaces[spaceId] : undefined + return !!space && (isPrivateProfileId(space.profileId) || isPrivateProfile(state.profiles[space.profileId])) +} + +export function isPrivateWindow(state: OrbitState, windowId: WindowId | undefined): boolean { + const win = windowId ? state.windows[windowId] : undefined + return !!win && (win.kind === 'private' || isPrivateSpace(state, win.spaceId)) +} + +export function isPrivateTab(state: OrbitState, tabId: TabId | undefined): boolean { + const tab = tabId ? state.tabs[tabId] : undefined + return !!tab && (isPrivateProfileId(tab.profileId) || isPrivateProfile(state.profiles[tab.profileId])) +} + +/** + * Whether a window may show a tab: a private window shows only its own Space's tabs, and no other + * window ever shows a private tab. + */ +export function windowMayShow(state: OrbitState, windowId: WindowId, tabId: TabId): boolean { + const win = state.windows[windowId] + const tab = state.tabs[tabId] + if (!win || !tab) return false + if (isPrivateWindow(state, windowId)) return tab.spaceId === win.spaceId + return !isPrivateTab(state, tabId) +} + +const isPrivateContainer = (state: OrbitState, ref: ContainerRef) => + ref.kind === 'favorites' ? isPrivateProfileId(ref.profileId) : isPrivateSpace(state, spaceOfContainer(state, ref)) + +/** + * Whether items may move into a container (NV-11, SB-25): a private window's tabs stay in its + * Today (it has no pinned tabs, Favorites or folders), and nothing moves in from elsewhere. + */ +export function privateMoveAllowed(state: OrbitState, items: ItemId[], to: ContainerRef): boolean { + const toPrivate = isPrivateContainer(state, to) + return items.every((id) => { + const at = locate(state, id) + const fromPrivate = !!at && isPrivateContainer(state, at.container) + if (!fromPrivate && !toPrivate) return true + return ( + fromPrivate && + toPrivate && + to.kind === 'today' && + !!at && + spaceOfContainer(state, at.container) === to.spaceId + ) + }) +} + +/** Tab menu items for what a private window doesn't have: pinned tabs, Favorites, folders, other Spaces. */ +const NOT_PRIVATE = + /^(Pin |Unpin |Add to Favorites|Remove from Favorites|Reset to Pinned URL|Pinned URL|Move to|New Folder with Tabs|Delete from Sidebar|Delete Favorite)/ + +/** A private tab's menu (SB-25): the tab menu without what a private window doesn't have. */ +export function privateTabMenu<T extends { label?: string; type?: string }>(items: T[]): T[] { + const out: T[] = [] + for (const item of items) { + if (item.label && NOT_PRIVATE.test(item.label)) continue + if (item.type === 'separator' && (out.length === 0 || out[out.length - 1]?.type === 'separator')) continue + out.push(item) + } + while (out[out.length - 1]?.type === 'separator') out.pop() + return out +} + +/** A closed private tab whose window (Space) is gone: it never comes back (NV-11). */ +export function isLostPrivateTab(state: OrbitState, tab: Pick<Tab, 'profileId' | 'spaceId'>): boolean { + return isPrivateProfileId(tab.profileId) && (!state.spaces[tab.spaceId] || !state.profiles[tab.profileId]) +} + +/** + * Whether a closed tab may come back in a window (Reopen Closed Tab): a private tab only in its + * own private window, a normal tab only in a normal window (NV-11). + */ +export function mayReopenIn(state: OrbitState, tab: Pick<Tab, 'profileId' | 'spaceId'>, windowId: WindowId | undefined): boolean { + const privateTab = isPrivateProfileId(tab.profileId) || isPrivateSpace(state, tab.spaceId) + const win = windowId ? state.windows[windowId] : undefined + if (!privateTab) return !isPrivateWindow(state, windowId) + return !!win && win.spaceId === tab.spaceId && !isLostPrivateTab(state, tab) +} + +/** + * The private window's look (AP-05): near-black whatever the theme or light/dark setting, with + * hover and selection tuned to read on black. + */ +export const PRIVATE_PALETTE: Palette = { + dark: true, + frameTop: '#141417', + frameMid: '#111114', + frameBottom: '#0e0e10', + windowBackground: '#141417', + accent: '#a78bfa', + selection: '#a78bfa55', + surface: '#202025', + text: '#f2f2f5', + textMuted: 'rgba(242,242,245,0.62)', + hover: 'rgba(255,255,255,0.09)', + active: 'rgba(255,255,255,0.16)', + activeShadow: '0 1px 2px rgba(0,0,0,0.5)', + divider: 'rgba(255,255,255,0.1)', + grain: 0, +} + +function layoutLeaves(layout: SplitLayout): TabId[] { + return layout.type === 'leaf' ? [layout.tabId] : layout.children.flatMap(layoutLeaves) +} + +/** + * The state with every private window, Space, profile, tab, folder and split left out (and any + * Archive entry that names a private profile): what may be written to disk. Returns `state` itself + * when there is nothing private. + */ +export function withoutPrivate(state: OrbitState): OrbitState { + const profiles = new Set(Object.values(state.profiles).filter(isPrivateProfile).map((p) => p.id)) + const isPrivate = (profileId: ProfileId) => profiles.has(profileId) || isPrivateProfileId(profileId) + const spaces = new Set(Object.values(state.spaces).filter((s) => isPrivate(s.profileId)).map((s) => s.id)) + const tabs = new Set( + Object.values(state.tabs) + .filter((t) => isPrivate(t.profileId) || spaces.has(t.spaceId)) + .map((t) => t.id), + ) + const windows = new Set( + Object.values(state.windows) + .filter((w) => w.kind === 'private' || spaces.has(w.spaceId)) + .map((w) => w.id), + ) + const archive = state.archive.some((a) => isPrivate(a.profileId) || spaces.has(a.spaceId)) + if (profiles.size + spaces.size + tabs.size + windows.size === 0 && !archive) return state + + // Folders and splits have no Space of their own: they go with the private Spaces holding them. + const items = new Set<ItemId>() + const visit = (ids: ItemId[]) => { + for (const id of ids) { + if (items.has(id)) continue + items.add(id) + const folder = state.folders[id] + if (folder) visit(folder.children) + } + } + for (const id of spaces) { + const space = state.spaces[id] + if (space) visit([...space.pinned, ...space.today]) + } + for (const id of profiles) visit(state.profiles[id]?.favorites ?? []) + + const keep = <T>(record: Record<string, T>, drop: (id: string, value: T) => boolean): Record<string, T> => { + const out: Record<string, T> = {} + for (const [id, value] of Object.entries(record)) if (!drop(id, value)) out[id] = value + return out + } + const out: OrbitState = { + ...state, + profiles: keep(state.profiles, (id) => profiles.has(id)), + spaces: keep(state.spaces, (id) => spaces.has(id)), + spaceOrder: state.spaceOrder.filter((id) => !spaces.has(id)), + tabs: keep(state.tabs, (id) => tabs.has(id)), + folders: keep(state.folders, (id) => items.has(id)), + splits: keep( + state.splits, + (id, split) => items.has(id) || layoutLeaves(split.layout).some((tabId) => tabs.has(tabId)), + ), + windows: keep(state.windows, (id) => windows.has(id)), + archive: archive ? state.archive.filter((a) => !isPrivate(a.profileId) && !spaces.has(a.spaceId)) : state.archive, + } + // A normal window never shows a private tab, but its remembered tabs per Space must not name one. + for (const [id, win] of Object.entries(out.windows)) { + const stale = Object.entries(win.activeTab).filter(([spaceId, tabId]) => spaces.has(spaceId) || (tabId && tabs.has(tabId))) + if (stale.length === 0) continue + const activeTab = { ...win.activeTab } + for (const [spaceId] of stale) delete activeTab[spaceId] + out.windows[id] = { ...win, activeTab } + } + return out +} diff --git a/src/shared/state.ts b/src/shared/state.ts index d8b80b4f..8cce32ec 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -63,6 +63,8 @@ export interface Tab { progress?: number /** Set when the page crashed or failed to load. */ error?: { code: number; description: string } + /** Runtime-only: pop-ups the page tried to open without a click since it loaded (NV-09). */ + blockedPopups?: number } export interface Folder { @@ -154,7 +156,8 @@ export interface SidebarState { export interface WindowState { id: WindowId - kind: 'main' | 'mini' + /** `private`: a private window (NV-11), showing a Space of its own with an in-memory profile. */ + kind: 'main' | 'mini' | 'private' spaceId: SpaceId /** Active (focused) tab per space, remembered across space switches. */ activeTab: Record<SpaceId, TabId | null> diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 2fa419df..afeaea00 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -37,10 +37,13 @@ const escapeHtml = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charC /** * A page that records key presses (`window.__keys`) and mouse presses (`window.__clicks`: x, y, * button) and counts its loads in sessionStorage (`loads`), with a text box (#box), a link to the - * next page (#next) and one that opens a new tab (#child). `background` is its CSS background; - * `text` goes in a paragraph (#text). + * next page (#next), one that opens a new tab (#child) and one to a download (#download, in a new + * tab), and buttons that open a small pop-up window (#popup) and close it again (#closepopup). + * `background` is its CSS background; `text` goes in a paragraph (#text); `open` is a page it + * opens in a pop-up as soon as it loads, without a click. */ -function page(title: string, background = '#f2fbf5', text = ''): string { +function page(title: string, background = '#f2fbf5', text = '', open = ''): string { + const name = encodeURIComponent(title) return `<!doctype html> <html><head><meta charset="utf-8"><title>${title} @@ -49,6 +52,9 @@ function page(title: string, background = '#f2fbf5', text = ''): string {

Child page

+

Download + +

` } @@ -66,6 +73,8 @@ sessionStorage.setItem('loads', String(Number(sessionStorage.getItem('loads') || * background and with some text. * - `/slow/?ms=N`: the page after N ms (default 30 s): keeps a tab loading. * - `/slow-reload/<title>`: the page at once on the first request, then slowly (30 s) on reloads. + * - `/page/<title>?open=<path>`: the page, opening `<path>` in a pop-up as it loads (no click). + * - `/download/<name>`: a small text file sent as a download (Content-Disposition: attachment). */ export async function startNavFixtures(): Promise<NavFixtures> { const hits = new Map<string, number>() @@ -83,7 +92,14 @@ export async function startNavFixtures(): Promise<NavFixtures> { pending.delete(res) if (res.writableEnded || res.destroyed) return res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }) - res.end(page(title, u.searchParams.get('bg') ?? undefined, u.searchParams.get('text') ?? '')) + res.end( + page( + title, + u.searchParams.get('bg') ?? undefined, + u.searchParams.get('text') ?? '', + u.searchParams.get('open') ?? '', + ), + ) } const later = (ms: number) => { pending.add(res) @@ -94,6 +110,14 @@ export async function startNavFixtures(): Promise<NavFixtures> { }) } if (kind === 'page') return send() + if (kind === 'download') { + res.writeHead(200, { + 'content-type': 'text/plain', + 'content-disposition': `attachment; filename="${title.replace(/[^\w.-]/g, '_')}"`, + 'cache-control': 'no-store', + }) + return res.end(`Downloaded ${title}\n`) + } if (kind === 'slow') return later(Number(u.searchParams.get('ms') ?? 30_000)) if (kind === 'slow-reload') return count === 1 ? send() : later(30_000) res.writeHead(404, { 'content-type': 'text/plain' }) From f86385a04adccec3b4113b42cefa26dee4e390d2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:35:51 +0000 Subject: [PATCH 07/69] chrome: tests for links, pop-ups, page menu and private windows; Writing Direction e2e: Cmd, Shift+Cmd, Shift and Alt clicks; target=_blank; pop-up windows titled with the host and closed by their page; blocked pop-ups offered and allowed from the address display; download-only tabs leave nothing; the page, link and text field menus; Cmd+N; private windows (look, own tabs, shared in-memory session wiped after the last closes, and a scan of every file in the profile folder for the private page after quitting). Unit: withoutPrivate, moves, reopening, the private tab menu, pop-up titles. The text field menu gains Writing Direction (sets the field's dir). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 22 +- docs/feature-status.json | 11 +- src/main/nav/index.ts | 4 +- src/main/nav/links.ts | 9 +- src/main/nav/page-menu.ts | 53 ++- src/main/nav/private.ts | 7 +- src/main/tabs/context-menu.ts | 2 + src/main/tabs/ops.ts | 8 +- src/main/windows.ts | 2 +- .../src/shell/spaces/NewSpaceFlow.tsx | 4 +- src/shared/nav.ts | 15 + src/shared/private.ts | 28 +- tests/e2e/nav-links.spec.ts | 334 ++++++++++++++++++ tests/e2e/nav-support.ts | 186 +++++++++- tests/e2e/nav-windows.spec.ts | 293 +++++++++++++++ tests/unit/nav-private.test.ts | 195 ++++++++++ 16 files changed, 1119 insertions(+), 54 deletions(-) create mode 100644 tests/e2e/nav-links.spec.ts create mode 100644 tests/e2e/nav-windows.spec.ts create mode 100644 tests/unit/nav-private.test.ts diff --git a/FEATURES.md b/FEATURES.md index 88fa823b..957627ab 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ 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:** 485 spec’d · 129 built (38 partial) · 178 tested · 0 feel right. +**Totals:** 485 spec’d · 134 built (40 partial) · 183 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 99 | 124 | -| P1 | 221 | 27 | 50 | +| P0 | 174 | 102 | 127 | +| P1 | 221 | 29 | 52 | | P2 | 83 | 3 | 4 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -45,7 +45,7 @@ Spec: `spec/sidebar.md` | 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 | | SB-24 | Sidebar colouring follows the Space theme | P0 | 🟡 partial | ✅ e2e+unit | – | theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet | -| SB-25 | Incognito window sidebar | P1 | – | – | – | | +| SB-25 | Incognito window sidebar | P1 | ✅ | ✅ e2e | – | private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with | | SB-26 | Platform notes (Windows / Linux) | P2 | – | – | – | | | SB-27 | Link status pill | P1 | – | – | – | | | SB-28 | Page loading indicator | P1 | – | – | – | | @@ -203,7 +203,7 @@ Spec: `spec/split-view.md` | SV-05 | Create by dropping a tab onto another tab row | P0 | ✅ | ✅ e2e+unit | – | resting a dragged tab on a tab or split row's middle band (~400 ms) highlights that half; dropping makes or extends a split there, undoable with Cmd/Ctrl+Z; quick drags reorder as usual | | SV-06 | Create from a multi-selection | P0 | ✅ | ✅ e2e+unit | – | Open in Split View in the tab menu for a 2-4 tab multi-selection (sidebar order); more than 4 is disabled with the hint | | SV-07 | Create from the "+" menu or the window's split button | P1 | 🟡 partial | ✅ unit | – | New Split View and Add Split Right/Left/Above/Below actions plus newSplitMenuItems(); the '+' menu and title-bar button belong to the sidebar chrome | -| SV-08 | Open a link into a split | P1 | 🟡 partial | ✅ e2e+unit | – | Open Link in Split View in the page's link menu; Option-click and dragging links not built | +| SV-08 | Open a link into a split | P1 | 🟡 partial | ✅ e2e+unit | – | Open Link in Split View in the page's link menu; Option/Alt-click a link opens it beside the page (the engine download is cancelled); dragging links not built | | SV-09 | Option-click a tab row | P1 | ✅ | ✅ e2e+unit | – | Alt-click a tab row joins it (the selected tab duplicates beside itself); Alt-click a split row separates | | SV-10 | Notes and canvases in a split | P2 | – | – | – | | | SV-11 | Layouts and flipping orientation | P0 | ✅ | ✅ e2e+unit | – | row/column trees, 3-4 pane arrangements from where panes are added, flip transposes keeping ratios | @@ -236,11 +236,11 @@ Spec: `spec/navigation.md` | 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; Cmd+J jumps to the selection (macOS); Edit > Find menu | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; 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-08 | Clicking links: plain and modified clicks | P0 | – | ✅ e2e | – | | -| NV-09 | Links with target=_blank and script-opened windows | P0 | – | ✅ e2e | – | | -| NV-10 | New window | P0 | – | – | – | | -| NV-11 | Private (incognito) window | P0 | – | – | – | | -| NV-12 | Page context menu | P0 | – | – | – | | +| NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e | – | Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Peek, Mini Window and pinned-tab links come with the peek lane; hover pill is SB-27; dragging links not built | +| 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 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 | +| NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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, Print, View Page Source), link (New Tab, Split View, New Window, Private Window, Copy, Save Link As), selection (Copy, Search engine, Look Up on macOS), image, editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek, Mini Window and media items come from their lanes; P2 Translate and Share Quote not built | | NV-13 | Load-error pages | P1 | – | – | – | | | NV-14 | Crashed or unresponsive tab | P1 | – | ✅ e2e | – | | | NV-15 | Permission prompts | P0 | – | ✅ e2e | – | | @@ -417,7 +417,7 @@ Spec: `spec/appearance.md` | AP-02 | Theme colours carry through the whole interface | P1 | – | – | – | | | AP-03 | Restore the default (neutral) theme | P1 | – | ✅ unit | – | | | AP-04 | Dark mode quality | P1 | – | ✅ unit | – | | -| AP-05 | Private-window appearance | P1 | – | – | – | | +| AP-05 | Private-window appearance | P1 | ✅ | ✅ e2e | – | 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 | | AP-06 | Inactive-window dimming | P1 | – | – | – | | | AP-07 | Optional top toolbar (look and placement) | P1 | – | – | – | | | AP-08 | Page-colour-aware chrome | P2 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index d1d99218..438cf4d7 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -15,6 +15,7 @@ "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"}, "SB-24": {"built": "partial", "note": "theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet"}, + "SB-25": {"built": "yes", "note": "private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with"}, "TB-01": {"built": "yes", "note": "row: favicon/emoji, title or breadcrumb, hover close, audio, selected pill, dimmed when asleep"}, "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop / Cmd+Alt+V paste; background link tabs gesture-gated"}, "TB-03": {"built": "yes", "note": "single rule todayInsertIndex(): top of Today"}, @@ -76,7 +77,7 @@ "SV-05": {"built": "yes", "note": "resting a dragged tab on a tab or split row's middle band (~400 ms) highlights that half; dropping makes or extends a split there, undoable with Cmd/Ctrl+Z; quick drags reorder as usual"}, "SV-06": {"built": "yes", "note": "Open in Split View in the tab menu for a 2-4 tab multi-selection (sidebar order); more than 4 is disabled with the hint"}, "SV-07": {"built": "partial", "note": "New Split View and Add Split Right/Left/Above/Below actions plus newSplitMenuItems(); the '+' menu and title-bar button belong to the sidebar chrome"}, - "SV-08": {"built": "partial", "note": "Open Link in Split View in the page's link menu; Option-click and dragging links not built"}, + "SV-08": {"built": "partial", "note": "Open Link in Split View in the page's link menu; Option/Alt-click a link opens it beside the page (the engine download is cancelled); dragging links not built"}, "SV-09": {"built": "yes", "note": "Alt-click a tab row joins it (the selected tab duplicates beside itself); Alt-click a split row separates"}, "SV-11": {"built": "yes", "note": "row/column trees, 3-4 pane arrangements from where panes are added, flip transposes keeping ratios"}, "SV-12": {"built": "yes", "note": "hover strip view with close and split-controls menu (flip, add split x4, move pane, separate); no per-pane toolbar yet"}, @@ -161,9 +162,15 @@ "NV-04": {"built": "partial", "note": "Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet"}, "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; Cmd+J jumps to the selection (macOS); Edit > Find menu"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, + "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Peek, Mini Window and pinned-tab links come with the peek lane; hover pill is SB-27; dragging links not built"}, + "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 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"}, + "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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, Print, View Page Source), link (New Tab, Split View, New Window, Private Window, Copy, Save Link As), selection (Copy, Search engine, Look Up on macOS), image, editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek, Mini Window and media items come from their lanes; P2 Translate and Share Quote not built"}, "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, "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"}, "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-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps"}, - "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"} + "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"} } diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index b3d2fb7c..2be97fb2 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -11,10 +11,10 @@ import type { TabOps } from '../tabs/ops' import { activeTabOf } from '../windows' import { type LinksTestHooks, registerLinks } from './links' import { openUrlInNewWindow } from './new-window' -import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { registerPageColor } from './page-color' -import { type PrivateWindows, registerPrivate } from './private' +import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { type PageToolsInfo, registerPageTools } from './page-tools' +import { type PrivateWindows, registerPrivate } from './private' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' import { SitePrefs } from './site-prefs' diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index cab772ed..31324fc9 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -1,5 +1,5 @@ import { app, type BrowserWindow, Menu, type MenuItemConstructorOptions, type WebContents, webContents } from 'electron' -import { connectionOf } from '../../shared/nav' +import { popupTitle } from '../../shared/nav' import type { TabId, WindowId } from '../../shared/state' import { sectionOf } from '../../shared/tree' import type { MainContext } from '../context' @@ -65,13 +65,6 @@ function hostOf(url: string | undefined): string { } } -/** A pop-up's title: the host it shows, marked when the connection isn't secure (NV-09). */ -export function popupTitle(url: string): string { - const host = hostOf(url) - if (!host) return url === 'about:blank' ? '' : url - return connectionOf(url) === 'insecure' ? `Not secure — ${host}` : host -} - export interface LinksDeps { ops: TabOps prefs: SitePrefs diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index 75d178b6..f1d93258 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -1,15 +1,10 @@ import { join } from 'node:path' -import { - app, - type ContextMenuParams, - dialog, - type MenuItemConstructorOptions, - type WebContents, -} from 'electron' +import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, type WebContents } from 'electron' import { isPrivateTab } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' import { SEARCH_ENGINES } from '../../shared/url' import type { MainContext } from '../context' +import { NAV_WORLD } from './focus' /** * The page context menu's items from the chrome lane (NV-12), added to the tabs lane's menu @@ -81,10 +76,37 @@ export function dictionaryItems(contents: WebContents, params: ContextMenuParams return [{ label: 'Add to Dictionary', click: () => contents.session.addWordToSpellCheckerDictionary(word) }] } +/** + * Writing Direction for a text field (NV-12): Default, Left to Right or Right to Left, set on the + * field itself (its `dir`), so the page's own text and layout don't change. + */ +export function writingDirectionItem(contents: WebContents): MenuItemConstructorOptions { + const set = (dir: '' | 'ltr' | 'rtl') => () => { + if (contents.isDestroyed()) return + const code = `(() => { + let el = document.activeElement + while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement + if (!el || !(el.isContentEditable || el.localName === 'input' || el.localName === 'textarea')) return + if (${JSON.stringify(dir)}) el.setAttribute('dir', ${JSON.stringify(dir)}) + else el.removeAttribute('dir') + })()` + contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code }]).catch(() => {}) + } + return { + label: 'Writing Direction', + submenu: [ + { label: 'Default', click: set('') }, + { label: 'Left to Right', click: set('ltr') }, + { label: 'Right to Left', click: set('rtl') }, + ], + } +} + /** A file name from a page title, without characters file systems refuse. */ function fileName(title: string): string { - const clean = title - .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ') + const printable = Array.from(title, (c) => (c.charCodeAt(0) < 32 ? ' ' : c)).join('') + const clean = printable + .replace(/[\\/:*?"<>|]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 120) @@ -132,13 +154,12 @@ export function keepPageMenu(ctx: MainContext, tabId: TabId, items: MenuItemCons return true } -const labelOf = (item: MenuItemConstructorOptions) => - item.label ?? (item.role ? `role:${item.role}` : '') +const labelOf = (item: MenuItemConstructorOptions) => item.label ?? (item.role ? `role:${item.role}` : '') export interface PageMenuTestHooks { /** The page menu last opened: its items' labels (`role:copy` for built-in ones; `-` for separators), or null. */ pageMenu: () => { tabId: TabId; labels: string[]; enabled: Record<string, boolean> } | null - /** Chooses an item of the page menu by label. */ + /** Chooses an item of the page menu by label (`Menu > Item` for one in a submenu). */ choosePageMenu: (label: string) => boolean } @@ -154,7 +175,13 @@ export const pageMenuTestHooks: PageMenuTestHooks = { } }, choosePageMenu: (label) => { - const item = shown?.items.find((i) => i.type !== 'separator' && labelOf(i) === label) + let items = shown?.items ?? [] + const path = label.split(' > ') + let item: MenuItemConstructorOptions | undefined + for (const part of path) { + item = items.find((i) => i.type !== 'separator' && labelOf(i) === part) + items = Array.isArray(item?.submenu) ? item.submenu : [] + } if (!item?.click) return false shown = null ;(item.click as () => void)() diff --git a/src/main/nav/private.ts b/src/main/nav/private.ts index 9a373b0a..bcc959b5 100644 --- a/src/main/nav/private.ts +++ b/src/main/nav/private.ts @@ -2,7 +2,12 @@ import { randomUUID } from 'node:crypto' import { app, Menu, session } from 'electron' import { createSpace, createWindowState, THEME_PRESETS } from '../../shared/defaults' import { isFolderId } from '../../shared/ids' -import { isPrivateProfileId, isPrivateWindow, PRIVATE_PARTITION_PREFIX, PRIVATE_PROFILE_PREFIX } from '../../shared/private' +import { + isPrivateProfileId, + isPrivateWindow, + PRIVATE_PARTITION_PREFIX, + PRIVATE_PROFILE_PREFIX, +} from '../../shared/private' import type { ItemId, OrbitState, Profile, ProfileId, SpaceId, SpaceTheme, WindowId } from '../../shared/state' import { tabsInItem } from '../../shared/tree' import type { MainContext } from '../context' diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index 2c6aeb3b..59497755 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -9,6 +9,7 @@ import { pageItems, saveLinkItem, searchLabel, + writingDirectionItem, } from '../nav/page-menu' import { linkSplitMenuItem } from '../split/menu' @@ -56,6 +57,7 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We { role: 'paste', enabled: params.editFlags.canPaste }, { role: 'pasteAndMatchStyle', enabled: params.editFlags.canPaste }, { role: 'selectAll' }, + writingDirectionItem(contents), { type: 'separator' }, ) } else if (params.selectionText) { diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index f4e44195..566a6a75 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -5,7 +5,13 @@ */ import { createWindowState, MAX_FAVORITES } from '../../shared/defaults' import { isFolderId, isTabId, newId } from '../../shared/ids' -import { isLostPrivateTab, isPrivateProfileId, isPrivateSpace, mayReopenIn, privateMoveAllowed } from '../../shared/private' +import { + isLostPrivateTab, + isPrivateProfileId, + isPrivateSpace, + mayReopenIn, + privateMoveAllowed, +} from '../../shared/private' import { isPendingPane, layoutLeaves, paneAfterClose, SPLIT_FULL_MESSAGE } from '../../shared/split' import type { ArchivedTab, diff --git a/src/main/windows.ts b/src/main/windows.ts index 5abf055a..5aa07bf9 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -2,9 +2,9 @@ import { join } from 'node:path' import { BrowserWindow, nativeTheme, screen, type WebContents, WebContentsView, webContents } from 'electron' import { actionForInput, outsideTextActionFor } from '../shared/actions' import { createWindowState, DEFAULT_SIDEBAR_WIDTH } from '../shared/defaults' +import { isPrivateSpace, PRIVATE_PALETTE } from '../shared/private' import type { Rect, RendererView } from '../shared/rpc' import type { ColorMode, OrbitState, SidebarState, SpaceId, TabId, WindowId, WindowState } from '../shared/state' -import { isPrivateSpace, PRIVATE_PALETTE } from '../shared/private' import { paletteFor } from '../shared/theme' import { splitLeaves, splitOfTab } from '../shared/tree' import type { MainContext } from './context' diff --git a/src/renderer/src/shell/spaces/NewSpaceFlow.tsx b/src/renderer/src/shell/spaces/NewSpaceFlow.tsx index 8b02cacb..4bd51b74 100644 --- a/src/renderer/src/shell/spaces/NewSpaceFlow.tsx +++ b/src/renderer/src/shell/spaces/NewSpaceFlow.tsx @@ -45,9 +45,7 @@ export function NewSpaceFlow({ windowId }: { windowId: string }) { // The private windows' profile is not one to pick (NV-11). const profileList = Object.values(profiles) .filter((p) => !isPrivateProfile(p)) - .sort((a, b) => - a.id === DEFAULT_PROFILE_ID ? -1 : b.id === DEFAULT_PROFILE_ID ? 1 : a.createdAt - b.createdAt, - ) + .sort((a, b) => (a.id === DEFAULT_PROFILE_ID ? -1 : b.id === DEFAULT_PROFILE_ID ? 1 : a.createdAt - b.createdAt)) const create = () => { if (busy) return diff --git a/src/shared/nav.ts b/src/shared/nav.ts index d95b6d6f..32005545 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -41,6 +41,21 @@ export function connectionOf(url: string | undefined): Connection { } } +/** + * A pop-up window's title (NV-09): the host it shows, whatever the page calls itself, marked when + * the connection isn't secure. Empty for a blank page; other addresses as they are. + */ +export function popupTitle(url: string): string { + let host = '' + try { + host = new URL(url).host + } catch { + // Not an address: shown as it is. + } + if (!host) return url === 'about:blank' ? '' : url + return connectionOf(url) === 'insecure' ? `Not secure — ${host}` : host +} + /** How many history entries the Back/Forward menu lists (the nearest ones). */ export const HISTORY_MENU_MAX = 15 diff --git a/src/shared/private.ts b/src/shared/private.ts index 9f5a5c06..6dd1861d 100644 --- a/src/shared/private.ts +++ b/src/shared/private.ts @@ -77,11 +77,7 @@ export function privateMoveAllowed(state: OrbitState, items: ItemId[], to: Conta const fromPrivate = !!at && isPrivateContainer(state, at.container) if (!fromPrivate && !toPrivate) return true return ( - fromPrivate && - toPrivate && - to.kind === 'today' && - !!at && - spaceOfContainer(state, at.container) === to.spaceId + fromPrivate && toPrivate && to.kind === 'today' && !!at && spaceOfContainer(state, at.container) === to.spaceId ) }) } @@ -111,7 +107,11 @@ export function isLostPrivateTab(state: OrbitState, tab: Pick<Tab, 'profileId' | * Whether a closed tab may come back in a window (Reopen Closed Tab): a private tab only in its * own private window, a normal tab only in a normal window (NV-11). */ -export function mayReopenIn(state: OrbitState, tab: Pick<Tab, 'profileId' | 'spaceId'>, windowId: WindowId | undefined): boolean { +export function mayReopenIn( + state: OrbitState, + tab: Pick<Tab, 'profileId' | 'spaceId'>, + windowId: WindowId | undefined, +): boolean { const privateTab = isPrivateProfileId(tab.profileId) || isPrivateSpace(state, tab.spaceId) const win = windowId ? state.windows[windowId] : undefined if (!privateTab) return !isPrivateWindow(state, windowId) @@ -150,9 +150,17 @@ function layoutLeaves(layout: SplitLayout): TabId[] { * when there is nothing private. */ export function withoutPrivate(state: OrbitState): OrbitState { - const profiles = new Set(Object.values(state.profiles).filter(isPrivateProfile).map((p) => p.id)) + const profiles = new Set( + Object.values(state.profiles) + .filter(isPrivateProfile) + .map((p) => p.id), + ) const isPrivate = (profileId: ProfileId) => profiles.has(profileId) || isPrivateProfileId(profileId) - const spaces = new Set(Object.values(state.spaces).filter((s) => isPrivate(s.profileId)).map((s) => s.id)) + const spaces = new Set( + Object.values(state.spaces) + .filter((s) => isPrivate(s.profileId)) + .map((s) => s.id), + ) const tabs = new Set( Object.values(state.tabs) .filter((t) => isPrivate(t.profileId) || spaces.has(t.spaceId)) @@ -203,7 +211,9 @@ export function withoutPrivate(state: OrbitState): OrbitState { } // A normal window never shows a private tab, but its remembered tabs per Space must not name one. for (const [id, win] of Object.entries(out.windows)) { - const stale = Object.entries(win.activeTab).filter(([spaceId, tabId]) => spaces.has(spaceId) || (tabId && tabs.has(tabId))) + const stale = Object.entries(win.activeTab).filter( + ([spaceId, tabId]) => spaces.has(spaceId) || (tabId && tabs.has(tabId)), + ) if (stale.length === 0) continue const activeTab = { ...win.activeTab } for (const [spaceId] of stale) delete activeTab[spaceId] diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts new file mode 100644 index 00000000..fd6eeaf4 --- /dev/null +++ b/tests/e2e/nav-links.spec.ts @@ -0,0 +1,334 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { type ElectronApplication, expect, test } from '@playwright/test' +import type { SplitLayout } from '../../src/shared/state' +import { isMac, launchOrbit, type Orbit, state, tabPage } from './helpers' +import { + blockedMenu, + chooseBlocked, + choosePageMenu, + clickInPage, + cmd, + foreignWindowTitles, + type NavFixtures, + openTab, + pageMenu, + startNavFixtures, + visit, + waitLoaded, + windowList, + windowState, +} from './nav-support' + +/** Clicking links and the windows pages open (NV-08, NV-09), and the page's context menu (NV-12). */ + +let fx: NavFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const page = (title: string) => fx.url(`/page/${title}`) +/** ⌘ on macOS, Ctrl elsewhere. */ +const mod = isMac ? 'meta' : 'control' + +const leaves = (layout: SplitLayout): string[] => + layout.type === 'leaf' ? [layout.tabId] : layout.children.flatMap(leaves) + +/** The tabs of the split view showing `tabId`, in order; empty when it isn't in one. */ +async function splitTabs(app: ElectronApplication, tabId: string): Promise<string[]> { + for (const split of Object.values((await state(app)).splits)) { + const ids = leaves(split.layout) + if (ids.includes(tabId)) return ids + } + return [] +} + +/** The first window's Today tabs, top first. */ +async function today(app: ElectronApplication): Promise<string[]> { + const win = await windowState(app) + return [...((await state(app)).spaces[win.spaceId]?.today ?? [])] +} + +async function selectedTab(app: ElectronApplication): Promise<string | undefined> { + const win = await windowState(app) + return win.activeTab[win.spaceId] ?? undefined +} + +const urlOf = async (app: ElectronApplication, tabId: string | undefined) => + tabId ? (await state(app)).tabs[tabId]?.url : undefined + +type Download = { url: string; cancelled: boolean } + +/** + * Records the downloads of a page's session from now on: the address, and whether Orbit cancelled + * it. One that goes ahead is saved in a temporary folder instead of asking where. + */ +async function watchDownloads(app: ElectronApplication, pageUrl: string): Promise<() => Promise<Download[]>> { + const folder = mkdtempSync(join(tmpdir(), 'orbit-e2e-downloads-')) + await app.evaluate( + ({ webContents }, [url, dir]) => { + const g = globalThis as unknown as { __downloads: Download[] } + g.__downloads = [] + const contents = webContents.getAllWebContents().find((w) => w.getURL() === url) + if (!contents) throw new Error(`no page ${url}`) + // Added after Orbit's own listener, so it sees what Orbit decided. + contents.session.on('will-download', (event, item) => { + g.__downloads.push({ url: item.getURL(), cancelled: event.defaultPrevented }) + if (!event.defaultPrevented) item.setSavePath(`${dir}/${item.getFilename()}`) + }) + }, + [pageUrl, folder] as const, + ) + return () => app.evaluate(() => (globalThis as unknown as { __downloads: Download[] }).__downloads) +} + +/** Title of the page with this exact address, once it has loaded. */ +const loadedTitle = (app: ElectronApplication, url: string) => + app.evaluate(({ webContents }, u) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL() === u) + return contents && !contents.isLoading() ? contents.getTitle() : null + }, url) + +/** `labels` without the items not in `wanted`: checks those come in this order. */ +const inOrder = (labels: string[] | undefined, wanted: string[]) => (labels ?? []).filter((l) => wanted.includes(l)) + +test('[NV-08] ⌘-click (Ctrl-click) opens the link in a background tab at the top of Today', async () => { + const { app } = orbit + const opener = await openTab(app, page('Behind')) + const before = await today(app) + await clickInPage(app, page('Behind'), '#next', [mod]) + await expect.poll(async () => (await today(app)).length).toBe(before.length + 1) + expect(await urlOf(app, (await today(app))[0])).toBe(page('Behind-next')) + expect(await selectedTab(app)).toBe(opener) +}) + +test('[NV-08] ⇧⌘-click (Ctrl+Shift-click) opens the link in a new tab at the top of Today and selects it', async () => { + const { app } = orbit + const opener = await openTab(app, page('Front')) + await clickInPage(app, page('Front'), '#next', [mod, 'shift']) + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(page('Front-next')) + const [id] = await today(app) + await expect.poll(() => selectedTab(app)).toBe(id) + await waitLoaded(app, id as string, page('Front-next')) + expect(await urlOf(app, opener)).toBe(page('Front')) +}) + +test('[NV-08] ⇧-click opens the link in a new window, a step down and right, when Peek doesn’t take it', async () => { + const { app } = orbit + // ⇧-click shows Peek unless that's turned off in Settings (peek.md); then it opens a new window. + await app.evaluate(() => { + const g = globalThis as unknown as { + __orbit: { ctx: { store: { update: (f: (d: { settings: Record<string, unknown> }) => void) => void } } } + } + g.__orbit.ctx.store.update((d) => { + d.settings.peekShiftClick = false + }) + }) + const opener = await openTab(app, page('Shifted')) + await clickInPage(app, page('Shifted'), '#next', ['shift']) + await expect.poll(async () => (await windowList(app)).length).toBe(2) + const [first, second] = await windowList(app) + if (!first || !second) throw new Error('no second window') + expect(second.kind).toBe('main') + expect(second.spaceId).toBe(first.spaceId) + const s = await state(app) + const shown = s.windows[second.id]?.activeTab[second.spaceId] + expect(await urlOf(app, shown ?? undefined)).toBe(page('Shifted-next')) + // The first window still shows the page with the link. + expect(s.windows[first.id]?.activeTab[first.spaceId]).toBe(opener) + expect([second.bounds?.x, second.bounds?.y]).not.toEqual([first.bounds?.x, first.bounds?.y]) +}) + +test('[NV-08][SV-08] ⌥-click (Alt-click) opens the link in split view beside the page instead of downloading it', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Alted')) + const downloads = await watchDownloads(app, page('Alted')) + await clickInPage(app, page('Alted'), '#next', ['alt']) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [left, right] = await splitTabs(app, id) + expect(left).toBe(id) + await waitLoaded(app, right as string, page('Alted-next')) + await expect(shell.getByTestId('split-pane')).toHaveCount(2) + // The engine's own download of the link comes a moment later, and is cancelled. + await expect.poll(downloads, { timeout: 10_000 }).toEqual([{ url: page('Alted-next'), cancelled: true }]) + expect(await urlOf(app, id)).toBe(page('Alted')) +}) + +test('[NV-09] a target=_blank link opens a new tab at the top of Today and selects it', async () => { + const { app } = orbit + const opener = await openTab(app, page('Blank')) + await clickInPage(app, page('Blank'), '#child') + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(page('Blank-child')) + const [id] = await today(app) + await expect.poll(() => selectedTab(app)).toBe(id) + // Orbit remembers where it came from (TB-19: Back with no history goes back there). + expect((await state(app)).tabs[id as string]?.openerTabId).toBe(opener) +}) + +test('[NV-09] a page’s pop-up (window.open with a size) is a small window of its own, titled with the site, that the page can close', async () => { + const { app } = orbit + await openTab(app, page('Opener')) + const tabs = Object.keys((await state(app)).tabs).length + await clickInPage(app, page('Opener'), '#popup') + const host = `localhost:${fx.port}` + await expect.poll(() => foreignWindowTitles(app)).toEqual([host]) + // The page calls itself "Popup-Opener"; the window keeps showing where it is. + await expect.poll(() => loadedTitle(app, page('Popup-Opener'))).toBe('Popup-Opener') + expect(await foreignWindowTitles(app)).toEqual([host]) + // Not a tab, nor an Orbit window with a sidebar. + expect(Object.keys((await state(app)).tabs)).toHaveLength(tabs) + expect(await windowList(app)).toHaveLength(1) + await clickInPage(app, page('Opener'), '#closepopup') + await expect.poll(() => foreignWindowTitles(app)).toEqual([]) +}) + +test('[NV-09] a pop-up opened without a click is blocked and offered in the address display; Always Allow lets the site open them', async () => { + const { app, shell } = orbit + const url = fx.url('/page/Eager?open=/page/Surprise') + const id = await openTab(app, url) + const indicator = shell.getByTestId('popups-blocked') + await expect(indicator).toBeVisible() + expect(await foreignWindowTitles(app)).toEqual([]) + const host = `localhost:${fx.port}` + await indicator.click() + await expect + .poll(() => blockedMenu(app)) + .toEqual([`[Pop-ups blocked on ${host}]`, `Open ${page('Surprise')}`, `Always Allow Pop-ups from ${host}`]) + // Opened from there, it's a tab. + expect(await chooseBlocked(app, `Open ${page('Surprise')}`)).toBe(true) + await expect.poll(async () => urlOf(app, await selectedTab(app))).toBe(page('Surprise')) + await expect(indicator).toBeHidden() + + // Back on the page, loading it again blocks it again; this time the site is allowed. + await cmd(app, 'tabs.activate', { tabId: id }) + await visit(app, id, url) + await expect(indicator).toBeVisible() + await indicator.click() + await expect.poll(() => blockedMenu(app)).toContain(`Always Allow Pop-ups from ${host}`) + expect(await chooseBlocked(app, `Always Allow Pop-ups from ${host}`)).toBe(true) + await expect(indicator).toBeHidden() + await visit(app, id, url) + await expect.poll(() => foreignWindowTitles(app)).toEqual([host]) + await expect(indicator).toBeHidden() +}) + +test('[NV-09] a link that downloads in a new tab leaves no tab behind', async () => { + const { app } = orbit + const opener = await openTab(app, page('Files')) + const downloads = await watchDownloads(app, page('Files')) + const before = await today(app) + await clickInPage(app, page('Files'), '#download') + await expect.poll(downloads).toEqual([{ url: fx.url('/download/Files.txt'), cancelled: false }]) + await expect.poll(() => today(app)).toEqual(before) + expect(await selectedTab(app)).toBe(opener) + // Nothing to bring back either. + expect((await state(app)).archive).toEqual([]) +}) + +test('[NV-12] right-clicking a link offers new tab, split view, new and private windows, copy and save; Split View shows two panes', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Menu')) + await clickInPage(app, page('Menu'), '#next', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + const labels = (await pageMenu(app))?.labels + const link = [ + 'Open Link in New Tab', + 'Open Link in Split View', + 'Open Link in New Window', + 'Open Link in Private Window', + 'Copy Link Address', + 'Save Link As…', + ] + expect(inOrder(labels, link)).toEqual(link) + expect(labels?.[0]).toBe('Open Link in New Tab') + expect(labels?.at(-1)).toBe('Inspect') + // Nothing for the page itself when a link was clicked. + expect(labels).not.toContain('View Page Source') + expect(await choosePageMenu(app, 'Open Link in Split View')).toBe(true) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + await expect(shell.getByTestId('split-pane')).toHaveCount(2) +}) + +test('[NV-12] right-clicking the page offers Back, Forward, Reload, Save Page As, Print and View Page Source', async () => { + const { app } = orbit + const id = await openTab(app, page('Plain')) + await visit(app, id, page('Plain-next')) + await clickInPage(app, page('Plain-next'), '#title', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + const menu = await pageMenu(app) + const wanted = ['Back', 'Forward', 'Reload', 'Save Page As…', 'Print…', 'View Page Source', 'Inspect'] + expect(inOrder(menu?.labels, wanted)).toEqual(wanted) + expect(menu?.labels.at(-1)).toBe('Inspect') + expect(menu?.enabled.Back).toBe(true) + expect(menu?.enabled.Forward).toBe(false) + expect(menu?.labels).not.toContain('Open Link in New Tab') + // View Page Source opens the source in a new tab. + expect(await choosePageMenu(app, 'View Page Source')).toBe(true) + await expect.poll(async () => urlOf(app, await selectedTab(app))).toBe(`view-source:${page('Plain-next')}`) +}) + +test('[NV-12] right-clicking a text field offers the editing items and Writing Direction', async () => { + const { app } = orbit + await openTab(app, page('Field')) + await clickInPage(app, page('Field'), '#box', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain('Writing Direction') + const wanted = [ + 'role:undo', + 'role:redo', + 'role:cut', + 'role:copy', + 'role:paste', + 'role:pasteAndMatchStyle', + 'role:selectAll', + 'Writing Direction', + 'Inspect', + ] + expect(inOrder((await pageMenu(app))?.labels, wanted)).toEqual(wanted) + expect(await choosePageMenu(app, 'Writing Direction > Right to Left')).toBe(true) + const tab = await tabPage(app, page('Field')) + await expect.poll(() => tab.evaluate(() => document.querySelector('#box')?.getAttribute('dir'))).toBe('rtl') +}) + +test('[NV-12] Open Link in New Window and in Private Window', async () => { + const { app } = orbit + await openTab(app, page('Links')) + await clickInPage(app, page('Links'), '#next', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain('Open Link in New Window') + expect(await choosePageMenu(app, 'Open Link in New Window')).toBe(true) + await expect.poll(async () => (await windowList(app)).map((w) => w.kind)).toEqual(['main', 'main']) + await clickInPage(app, page('Links'), '#next', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain('Open Link in Private Window') + expect(await choosePageMenu(app, 'Open Link in Private Window')).toBe(true) + await expect.poll(async () => (await windowList(app)).map((w) => w.kind)).toEqual(['main', 'main', 'private']) + const s = await state(app) + const [second, third] = (await windowList(app)).slice(1) + for (const w of [second, third]) { + const shown = w ? s.windows[w.id]?.activeTab[w.spaceId] : undefined + expect(shown && s.tabs[shown]?.url).toBe(page('Links-next')) + } + // The private window's copy of the page lives in a session kept in memory only. + const privateTab = third ? s.windows[third.id]?.activeTab[third.spaceId] : undefined + await waitLoaded(app, privateTab as string, page('Links-next')) + const sessions = await app.evaluate( + ({ webContents }, url) => + webContents + .getAllWebContents() + .filter((w) => w.getURL() === url) + .map((w) => w.session.isPersistent()) + .sort(), + page('Links-next'), + ) + expect(sessions).toEqual([false, true]) +}) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index afeaea00..51b2eca7 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -1,5 +1,5 @@ /** - * Helpers for the chrome lane's e2e specs (chrome.spec.ts, nav.spec.ts): a local fixture server + * Helpers for the chrome lane's e2e specs (chrome.spec.ts, nav*.spec.ts): a local fixture server * with pages for navigation tests, and small wrappers around Orbit's test hooks. */ import { createServer, type ServerResponse } from 'node:http' @@ -38,7 +38,8 @@ const escapeHtml = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charC * A page that records key presses (`window.__keys`) and mouse presses (`window.__clicks`: x, y, * button) and counts its loads in sessionStorage (`loads`), with a text box (#box), a link to the * next page (#next), one that opens a new tab (#child) and one to a download (#download, in a new - * tab), and buttons that open a small pop-up window (#popup) and close it again (#closepopup). + * tab), and buttons that open a small pop-up window (`/page/Popup-<title>`, #popup) and close it + * again (#closepopup). * `background` is its CSS background; `text` goes in a paragraph (#text); `open` is a page it * opens in a pop-up as soon as it loads, without a click. */ @@ -53,7 +54,7 @@ function page(title: string, background = '#f2fbf5', text = '', open = ''): stri <p><a id="next" href="/page/${encodeURIComponent(`${title}-next`)}">Next page</a></p> <p><a id="child" href="/page/${encodeURIComponent(`${title}-child`)}" target="_blank">Child page</a></p> <p><a id="download" href="/download/${name}.txt" target="_blank">Download</a> -<button id="popup" onclick="window.__popup = window.open('/page/${name}-popup', 'pop', 'width=420,height=360')">Pop-up</button> +<button id="popup" onclick="window.__popup = window.open('/page/Popup-${name}', 'pop', 'width=420,height=360')">Pop-up</button> <button id="closepopup" onclick="window.__popup && window.__popup.close()">Close pop-up</button></p> <div style="height:2000px"></div> <script> @@ -154,6 +155,7 @@ type Hooks = { __orbit: { run: (n: string, a: unknown, w?: string) => Promise<unknown> windowIds: () => string[] + flush: () => unknown ctx: { windows: { get: (id: string) => Electron.BrowserWindow | undefined } } } __orbitNav: { @@ -162,9 +164,187 @@ type Hooks = { floating: (windowId?: string) => FloatingInfo corner: (tabId: string) => number | null tools: (windowId?: string) => ToolsInfo + blockedMenu: () => string[] | null + chooseBlocked: (label: string) => boolean + pageMenu: () => PageMenu | null + choosePageMenu: (label: string) => boolean } } +/** The page context menu Orbit last built (NV-12): labels top to bottom, `-` for separators. */ +export interface PageMenu { + tabId: string + labels: string[] + enabled: Record<string, boolean> +} + +/** The page context menu last opened (kept instead of shown in tests), or null. */ +export function pageMenu(app: ElectronApplication): Promise<PageMenu | null> { + return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.pageMenu()) +} + +export function choosePageMenu(app: ElectronApplication, label: string): Promise<boolean> { + return app.evaluate((_e, l) => (globalThis as unknown as Hooks).__orbitNav.choosePageMenu(l), label) +} + +/** The blocked pop-ups menu last opened from the address display (NV-09), or null. */ +export function blockedMenu(app: ElectronApplication): Promise<string[] | null> { + return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.blockedMenu()) +} + +export function chooseBlocked(app: ElectronApplication, label: string): Promise<boolean> { + return app.evaluate((_e, l) => (globalThis as unknown as Hooks).__orbitNav.chooseBlocked(l), label) +} + +type Modifier = 'shift' | 'control' | 'alt' | 'meta' +type InputEventInit = { type: string } & Record<string, unknown> + +/** Runs a script in the page with this address (or a part of it); null while there's none or it loads. */ +function inPage<T>(app: ElectronApplication, page: string, code: string): Promise<T | null> { + return app.evaluate( + async ({ webContents }, [part, js]) => { + const matches = (url: string) => (part.startsWith('http') ? url === part : url.includes(part)) + const contents = webContents.getAllWebContents().find((w) => matches(w.getURL())) + if (!contents || contents.isLoading()) return null + return await contents.executeJavaScript(js) + }, + [page, code] as const, + ) as Promise<T | null> +} + +/** Sends input events to the page with this address (or a part of it) as the hardware would. */ +async function sendToPage(app: ElectronApplication, page: string, events: InputEventInit[]): Promise<void> { + await app.evaluate( + ({ webContents }, [part, list]) => { + const matches = (url: string) => (part.startsWith('http') ? url === part : url.includes(part)) + const contents = webContents.getAllWebContents().find((w) => matches(w.getURL())) + if (!contents) throw new Error(`no page ${part}`) + for (const event of list) contents.sendInputEvent(event as unknown as Electron.MouseInputEvent) + }, + [page, events] as const, + ) +} + +/** + * Clicks an element of a page the way a person does, through the browser process: the pointer + * goes onto it first (until the page has it hovered), then the modifier keys go down, the button + * is pressed and released, and the keys come up, so Orbit sees the keys as well as the page. + * `page` is the page's full address (matched exactly), or a part of it. + */ +export async function clickInPage( + app: ElectronApplication, + page: string, + selector: string, + modifiers: Modifier[] = [], + button: 'left' | 'middle' | 'right' = 'left', +): Promise<void> { + const sel = JSON.stringify(selector) + const deadline = Date.now() + 10_000 + let at: { x: number; y: number } | null = null + while (!at && Date.now() < deadline) { + at = await inPage<{ x: number; y: number }>( + app, + page, + `(() => { const r = document.querySelector(${sel})?.getBoundingClientRect(); return r ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } : null })()`, + ) + if (!at) await new Promise((r) => setTimeout(r, 100)) + } + if (!at) throw new Error(`no ${selector} in ${page}`) + const { x, y } = at + await sendToPage(app, page, [{ type: 'mouseMove', x, y }]) + await expect.poll(() => inPage<boolean>(app, page, `!!document.querySelector(${sel})?.matches(':hover')`)).toBe(true) + const keys: Record<Modifier, string> = { shift: 'Shift', control: 'Control', alt: 'Alt', meta: 'Meta' } + const events: InputEventInit[] = [] + const held: Modifier[] = [] + for (const m of modifiers) { + held.push(m) + events.push({ type: 'keyDown', keyCode: keys[m], modifiers: [...held] }) + } + events.push({ type: 'mouseDown', x, y, button, clickCount: 1, modifiers: [...modifiers] }) + events.push({ type: 'mouseUp', x, y, button, clickCount: 1, modifiers: [...modifiers] }) + for (const m of [...modifiers].reverse()) { + held.pop() + events.push({ type: 'keyUp', keyCode: keys[m], modifiers: [...held] }) + } + await sendToPage(app, page, events) +} + +/** Orbit's windows: id, kind and bounds, in the order they opened. */ +export async function windowList( + app: ElectronApplication, +): Promise<{ id: string; kind: string; spaceId: string; bounds: Electron.Rectangle | null }[]> { + const s = await state(app) + const ids = await app.evaluate(() => (globalThis as unknown as Hooks).__orbit.windowIds()) + const bounds = await app.evaluate((_e, list) => { + const { __orbit } = globalThis as unknown as Hooks + return list.map((id) => { + const win = __orbit.ctx.windows.get(id) + return win && !win.isDestroyed() ? win.getBounds() : null + }) + }, ids) + return ids.map((id, i) => ({ + id, + kind: s.windows[id]?.kind ?? '', + spaceId: s.windows[id]?.spaceId ?? '', + bounds: bounds[i] ?? null, + })) +} + +/** Windows that are not Orbit's own (pop-ups pages opened): their titles. */ +export function foreignWindowTitles(app: ElectronApplication): Promise<string[]> { + return app.evaluate(({ BrowserWindow }) => { + const { __orbit } = globalThis as unknown as Hooks + const own = new Set(__orbit.windowIds().map((id) => __orbit.ctx.windows.get(id))) + return BrowserWindow.getAllWindows() + .filter((w) => !w.isDestroyed() && !own.has(w)) + .map((w) => w.getTitle()) + }) +} + +/** Runs a command as if from the given window. */ +export function cmdIn<T = unknown>( + app: ElectronApplication, + windowId: string, + name: string, + args: unknown = {}, +): Promise<T> { + return app.evaluate(async (_e, [w, n, a]) => (globalThis as unknown as Hooks).__orbit.run(n, a, w), [ + windowId, + name, + args, + ] as const) as Promise<T> +} + +/** A window's sidebar (`shell`) or overlay page. */ +export async function uiPageOf(app: ElectronApplication, view: 'shell' | 'overlay', windowId: string): Promise<Page> { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + for (const p of app.windows()) { + if (!p.url().includes(`view=${view}`)) continue + const id = await p + .evaluate(() => (window as unknown as { orbit?: { windowId?: string } }).orbit?.windowId) + .catch(() => undefined) + if (id === windowId) return p + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error(`no ${view} page for window ${windowId}`) +} + +/** Closes a window as its close button would. */ +export async function closeWindow(app: ElectronApplication, windowId: string): Promise<void> { + await app.evaluate((_e, id) => { + ;(globalThis as unknown as Hooks).__orbit.ctx.windows.get(id)?.close() + }, windowId) +} + +/** Writes the state file now (it is otherwise written a moment after each change). */ +export async function flushState(app: ElectronApplication): Promise<void> { + await app.evaluate(async () => { + await (globalThis as unknown as Hooks).__orbit.flush() + }) +} + /** A window's page tools (NV-05, NV-06): what the find bar and the zoom indicator show, and where. */ export interface ToolsInfo { find: { diff --git a/tests/e2e/nav-windows.spec.ts b/tests/e2e/nav-windows.spec.ts new file mode 100644 index 00000000..2a9c85a4 --- /dev/null +++ b/tests/e2e/nav-windows.spec.ts @@ -0,0 +1,293 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { type ElectronApplication, expect, test } from '@playwright/test' +import type { OrbitState } from '../../src/shared/state' +import { action, isMac, launchOrbit, MOD, type Orbit, state } from './helpers' +import { + closeWindow, + cmd, + cmdIn, + flushState, + type NavFixtures, + openTab, + pressInPage, + startNavFixtures, + uiPageOf, + waitLoaded, + windowList, + zoomOf, +} from './nav-support' + +/** New windows (NV-10) and private windows (NV-11, SB-25, AP-05). */ + +let fx: NavFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const page = (title: string) => fx.url(`/page/${title}`) +/** ⌘ on macOS, Ctrl elsewhere. */ +const mod = isMac ? 'meta' : 'control' + +type WindowInfo = Awaited<ReturnType<typeof windowList>>[number] + +/** Opens a private window from the first window (⌘⇧N there) and returns it. */ +async function openPrivate(app: ElectronApplication): Promise<WindowInfo> { + const before = new Set((await windowList(app)).map((w) => w.id)) + await action(app, 'window.newPrivate') + await expect.poll(async () => (await windowList(app)).filter((w) => !before.has(w.id)).length).toBe(1) + const win = (await windowList(app)).find((w) => !before.has(w.id)) + if (!win) throw new Error('no private window') + expect(win.kind).toBe('private') + return win +} + +/** Opens a page in a new tab of the given window and waits until it has loaded. */ +async function openTabIn(app: ElectronApplication, windowId: string, url: string): Promise<string> { + const id = await cmdIn<string>(app, windowId, 'tabs.create', { url }) + await waitLoaded(app, id, url) + return id +} + +/** Runs a script in the page with this exact address in a private (or else a normal) session. */ +function inSession<T>(app: ElectronApplication, url: string, persistent: boolean, code: string): Promise<T> { + return app.evaluate( + async ({ webContents }, [u, keep, js]) => { + const contents = webContents + .getAllWebContents() + .find((w) => w.getURL() === u && w.session.isPersistent() === keep) + if (!contents) throw new Error(`no page ${u}`) + return await contents.executeJavaScript(js) + }, + [url, persistent, code] as const, + ) as Promise<T> +} + +const privateLeft = (s: OrbitState) => ({ + profiles: Object.keys(s.profiles).filter((id) => id.startsWith('private-')), + spaces: Object.values(s.spaces).filter((sp) => sp.profileId.startsWith('private-')).length, + tabs: Object.values(s.tabs).filter((t) => t.profileId.startsWith('private-')).length, + windows: Object.values(s.windows).filter((w) => w.kind === 'private').length, +}) +const NOTHING_PRIVATE = { profiles: [], spaces: 0, tabs: 0, windows: 0 } + +/** Every file under `dir` whose bytes contain `text` (as UTF-8 or UTF-16, as Chromium stores some strings). */ +function filesContaining(dir: string, text: string): string[] { + const needles = [Buffer.from(text, 'utf8'), Buffer.from(text, 'utf16le')] + const found: string[] = [] + const visit = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const path = join(d, entry.name) + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) { + let bytes: Buffer + try { + bytes = readFileSync(path) + } catch { + continue + } + if (needles.some((n) => bytes.includes(n))) found.push(path) + } + } + } + visit(dir) + return found +} + +const read = (path: string) => (existsSync(path) ? readFileSync(path, 'utf8') : '') +const readDir = (dir: string) => + existsSync(dir) + ? readdirSync(dir) + .map((f) => read(join(dir, f))) + .join('\n') + : '' + +test('[NV-10] ⌘N (Ctrl+N) opens another window with the same pinned tabs, a step down and right', async () => { + const { app, shell } = orbit + const pinned = await openTab(app, page('Kept')) + await cmd(app, 'tabs.pin', { tabId: pinned }) + await shell.keyboard.press(`${MOD}+N`) + await expect.poll(async () => (await windowList(app)).length).toBe(2) + const [first, second] = await windowList(app) + if (!first || !second) throw new Error('no second window') + expect(second.kind).toBe('main') + const sidebar = await uiPageOf(app, 'shell', second.id) + await expect(sidebar.locator(`[data-testid="tab-row"][data-tab-id="${pinned}"]`)).toBeVisible() + expect([second.bounds?.x, second.bounds?.y]).not.toEqual([first.bounds?.x, first.bounds?.y]) +}) + +test('[NV-11][SB-25][AP-05] ⌘⇧N (Ctrl+Shift+N) opens a private window: dark, labelled Private, with only its own tabs', async () => { + const { app, shell } = orbit + const pinned = await openTab(app, page('Mine')) + await cmd(app, 'tabs.pin', { tabId: pinned }) + const normal = await openTab(app, page('Ours')) + await shell.keyboard.press(`${MOD}+Shift+N`) + await expect.poll(async () => (await windowList(app)).map((w) => w.kind)).toEqual(['main', 'private']) + const priv = (await windowList(app))[1] as WindowInfo + const sidebar = await uiPageOf(app, 'shell', priv.id) + const label = sidebar.getByTestId('private-label') + await expect(label).toContainText('Private') + // Plain about what it hides and what it doesn't. + await expect(label).toContainText('not from websites or your network') + // Near-black, whatever the theme. + await expect + .poll(() => + sidebar.evaluate(() => + getComputedStyle(document.querySelector('[data-testid="sidebar"]') as Element) + .getPropertyValue('--frame-top') + .trim(), + ), + ) + .toMatch(/^(#141417|rgb\(20, 20, 23\))$/) + // No Spaces, pinned tabs or Favorites; its Space isn't one of the user's. + await expect(sidebar.getByTestId('spacebar')).toHaveCount(0) + await expect(sidebar.getByTestId('space-strip')).toHaveCount(0) + await expect(sidebar.locator(`[data-tab-id="${pinned}"]`)).toHaveCount(0) + await expect(sidebar.locator(`[data-tab-id="${normal}"]`)).toHaveCount(0) + expect((await state(app)).spaceOrder).not.toContain(priv.spaceId) + // It starts with the command bar open for a first tab, which opens there. + await expect.poll(async () => (await state(app)).windows[priv.id]?.overlay.kind).toBe('commandbar') + const overlay = await uiPageOf(app, 'overlay', priv.id) + const input = overlay.getByTestId('command-input') + const secret = fx.urlOn('127.0.0.1', '/page/Hidden') + await input.fill(secret) + await expect(overlay.getByTestId('command-result').filter({ hasText: '127.0.0.1' }).first()).toBeVisible() + await input.press('Enter') + await expect + .poll(async () => { + const s = await state(app) + const shown = s.windows[priv.id]?.activeTab[priv.spaceId] + return shown ? s.tabs[shown]?.url : undefined + }) + .toBe(secret) + const s = await state(app) + const tabId = s.windows[priv.id]?.activeTab[priv.spaceId] as string + expect(s.tabs[tabId]?.profileId).toMatch(/^private-/) + await expect(sidebar.locator(`[data-testid="tab-row"][data-tab-id="${tabId}"]`)).toBeVisible() + // The normal window doesn't list it, can't show it, and its command bar doesn't offer it. + await expect(shell.locator(`[data-tab-id="${tabId}"]`)).toHaveCount(0) + await cmd(app, 'tabs.activate', { tabId }) + const first = (await windowList(app))[0] as WindowInfo + expect((await state(app)).windows[first.id]?.activeTab[first.spaceId]).not.toBe(tabId) + const found = await cmd<{ results: { url?: string }[] }>(app, 'search.query', { query: 'Hidden', mode: 'new-tab' }) + expect(found.results.filter((r) => r.url === secret)).toEqual([]) + // Nor does the private window's command bar offer the normal window's tabs or history. + const there = await cmdIn<{ results: { url?: string }[] }>(app, priv.id, 'search.query', { + query: 'Ours', + mode: 'new-tab', + }) + expect(there.results.filter((r) => r.url === page('Ours'))).toEqual([]) + // Pinning and other Spaces aren't there. + await app.evaluate( + async (_e, [w, t]) => { + const g = globalThis as unknown as { __orbit: { run: (n: string, a: unknown, w?: string) => Promise<unknown> } } + await g.__orbit.run('tabs.activate', { tabId: t }, w) + }, + [priv.id, tabId] as const, + ) + await app.evaluate(async (_e, w) => { + const g = globalThis as unknown as { __orbit: { action: (id: string, w?: string) => Promise<void> } } + await g.__orbit.action('tab.togglePin', w) + }, priv.id) + expect((await state(app)).spaces[priv.spaceId]?.pinned).toEqual([]) +}) + +test('[NV-11] private windows share one session while any is open; it is wiped when the last one closes', async () => { + const { app } = orbit + const site = fx.urlOn('127.0.0.1', '/page/Jar') + const normal = await openTab(app, site) + const a = await openPrivate(app) + await openTabIn(app, a.id, site) + await inSession(app, site, false, `document.cookie = 'jar=private; path=/'; localStorage.setItem('jar', 'private')`) + // The normal window's copy of the site sees none of it. + expect(await inSession(app, site, true, 'document.cookie')).toBe('') + expect(await inSession(app, site, true, `localStorage.getItem('jar')`)).toBeNull() + // A second private window shares the first one's session. + const b = await openPrivate(app) + expect(b.spaceId).not.toBe(a.spaceId) + const second = await openTabIn(app, b.id, fx.urlOn('127.0.0.1', '/page/Jar2')) + expect(await inSession(app, fx.urlOn('127.0.0.1', '/page/Jar2'), false, 'document.cookie')).toBe('jar=private') + // Closing one keeps the session; closing the last forgets everything. + await closeWindow(app, a.id) + await expect.poll(async () => (await windowList(app)).length).toBe(2) + expect((await state(app)).tabs[second]).toBeDefined() + expect(await inSession(app, fx.urlOn('127.0.0.1', '/page/Jar2'), false, 'document.cookie')).toBe('jar=private') + await closeWindow(app, b.id) + await expect.poll(async () => privateLeft(await state(app))).toEqual(NOTHING_PRIVATE) + expect((await state(app)).tabs[normal]).toBeDefined() + // Reopen Closed Tab doesn't bring a private tab back into a normal window. + await action(app, 'tab.reopenClosed') + expect(privateLeft(await state(app))).toEqual(NOTHING_PRIVATE) + // The next private window starts with nothing. + const c = await openPrivate(app) + await openTabIn(app, c.id, fx.urlOn('127.0.0.1', '/page/Jar3')) + expect(await inSession(app, fx.urlOn('127.0.0.1', '/page/Jar3'), false, 'document.cookie')).toBe('') + expect(await inSession(app, fx.urlOn('127.0.0.1', '/page/Jar3'), false, `localStorage.getItem('jar')`)).toBeNull() +}) + +test('[NV-11] nothing of a private window reaches the disk: no state, history, favicon cache or site choices', async () => { + const { app, userData } = orbit + const token = `Secret${Date.now().toString(36)}` + /** What a normal page stores the same way: the search below must find it (it can see the engine's files). */ + const control = `Kept${Date.now().toString(36)}` + const secret = fx.urlOn('127.0.0.1', `/page/${token}`) + const priv = await openPrivate(app) + await openTabIn(app, priv.id, secret) + // A site choice (zoom), a cookie and site data. + await pressInPage(app, secret, '=', [mod]) + await expect.poll(() => zoomOf(app, secret)).toBeCloseTo(1.1) + await inSession( + app, + secret, + false, + `document.cookie = '${token}=1; path=/; max-age=3600'; localStorage.setItem('${token}', '1')`, + ) + // While it's open, the state file has none of it. + await flushState(app) + const saved = read(join(userData, 'orbit-state.json')) + expect(saved).toContain('"spaceOrder"') + expect(saved).not.toContain(token) + expect(saved).not.toContain('private-') + // A normal page meanwhile, so each store has something to write. + await openTab(app, page('Plain')) + await inSession( + app, + page('Plain'), + true, + `document.cookie = '${control}=1; path=/; max-age=3600'; localStorage.setItem('${control}', '1')`, + ) + await pressInPage(app, page('Plain'), '=', [mod]) + await expect.poll(() => zoomOf(app, page('Plain'))).toBeCloseTo(1.1) + await expect.poll(() => read(join(userData, 'orbit-favicons.json'))).toContain(`http://localhost:${fx.port}`) + await closeWindow(app, priv.id) + await expect.poll(async () => privateLeft(await state(app))).toEqual(NOTHING_PRIVATE) + await orbit.app.close() + + // What Orbit keeps has the normal page and not the private one… + expect(readDir(join(userData, 'history'))).toContain(page('Plain')) + expect(read(join(userData, 'orbit-site-prefs.json'))).toContain('localhost') + // …and no file at all (Orbit's or the engine's) names the private page or its site, while the + // normal page's cookie and site data are in the engine's files. + expect(filesContaining(userData, control).length).toBeGreaterThan(0) + expect(filesContaining(userData, token)).toEqual([]) + expect(filesContaining(userData, '127.0.0.1')).toEqual([]) + expect(existsSync(join(userData, 'Partitions'))).toBe(true) + expect(readdirSync(join(userData, 'Partitions')).filter((p) => p.includes('private'))).toEqual([]) + + // Back in Orbit, the command bar and history don't know it. + orbit = await launchOrbit({ userData }) + expect(privateLeft(await state(orbit.app))).toEqual(NOTHING_PRIVATE) + const found = await cmd<{ results: { url?: string }[] }>(orbit.app, 'search.query', { query: token, mode: 'new-tab' }) + expect(found.results.filter((r) => r.url?.includes(`/page/${token}`))).toEqual([]) +}) diff --git a/tests/unit/nav-private.test.ts b/tests/unit/nav-private.test.ts new file mode 100644 index 00000000..ab9f8489 --- /dev/null +++ b/tests/unit/nav-private.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { makeTab } from '../../src/main/tabs/model' +import { createInitialState, createSpace, createWindowState, DEFAULT_PROFILE_ID } from '../../src/shared/defaults' +import { newId } from '../../src/shared/ids' +import { popupTitle } from '../../src/shared/nav' +import { + isLostPrivateTab, + isPrivateTab, + isPrivateWindow, + mayReopenIn, + privateMoveAllowed, + privateTabMenu, + windowMayShow, + withoutPrivate, +} from '../../src/shared/private' +import type { OrbitState, Profile } from '../../src/shared/state' + +/** A normal window with one tab, and a private window (profile, Space, tab) as Orbit makes them. */ +function setup() { + const state: OrbitState = createInitialState(0) + const spaceId = state.spaceOrder[0] as string + const normal = makeTab({ spaceId, profileId: DEFAULT_PROFILE_ID, url: 'https://a.test/' }, 0) + state.tabs[normal.id] = normal + state.spaces[spaceId]?.today.push(normal.id) + const main = createWindowState(spaceId) + main.activeTab[spaceId] = normal.id + state.windows[main.id] = main + + const profile: Profile = { + id: 'private-abc', + name: 'Private', + partition: 'orbit-private-abc', + favorites: [], + createdAt: 0, + } + state.profiles[profile.id] = profile + const space = createSpace('Private', profile.id, undefined, '', 0) + state.spaces[space.id] = space + const secret = makeTab({ spaceId: space.id, profileId: profile.id, url: 'https://secret.test/' }, 0) + state.tabs[secret.id] = secret + space.today.push(secret.id) + const win = createWindowState(space.id, 'private') + win.activeTab[space.id] = secret.id + state.windows[win.id] = win + return { + state, + spaceId, + normal: normal.id, + main: main.id, + privateSpace: space.id, + secret: secret.id, + privateWindow: win.id, + } +} + +describe('private windows (NV-11)', () => { + it('knows private tabs and windows', () => { + const { state, normal, secret, main, privateWindow } = setup() + expect(isPrivateTab(state, secret)).toBe(true) + expect(isPrivateTab(state, normal)).toBe(false) + expect(isPrivateWindow(state, privateWindow)).toBe(true) + expect(isPrivateWindow(state, main)).toBe(false) + }) + + it('leaves everything private out of what is written to disk', () => { + const { state, spaceId, normal, secret, main, privateSpace, privateWindow } = setup() + // A folder and a split view in the private Space, and an Archive entry naming it. + const folderId = newId('f') + const inFolder = makeTab({ spaceId: privateSpace, profileId: 'private-abc', url: 'https://in.test/' }, 0) + state.tabs[inFolder.id] = inFolder + state.folders[folderId] = { id: folderId, name: 'F', children: [inFolder.id], collapsed: false, createdAt: 0 } + state.spaces[privateSpace]?.today.push(folderId) + const pane = makeTab({ spaceId: privateSpace, profileId: 'private-abc', url: 'https://pane.test/' }, 0) + state.tabs[pane.id] = pane + const splitId = newId('v') + state.splits[splitId] = { + id: splitId, + createdAt: 0, + layout: { + type: 'row', + sizes: [0.5, 0.5], + children: [ + { type: 'leaf', tabId: secret }, + { type: 'leaf', tabId: pane.id }, + ], + }, + } + state.archive.push( + { + id: newId('a'), + url: 'https://gone.test/', + title: 'Gone', + spaceId: privateSpace, + profileId: 'private-abc', + archivedAt: 0, + reason: 'closed', + }, + { + id: newId('a'), + url: 'https://kept.test/', + title: 'Kept', + spaceId, + profileId: DEFAULT_PROFILE_ID, + archivedAt: 0, + reason: 'closed', + }, + ) + // A normal window that once showed the private Space must not name it. + const mainWin = state.windows[main] + if (mainWin) mainWin.activeTab[privateSpace] = secret + + const out = withoutPrivate(state) + const text = JSON.stringify(out) + for (const word of ['private-abc', 'orbit-private-abc', 'secret.test', 'in.test', 'pane.test', 'gone.test']) { + expect(text).not.toContain(word) + } + expect(Object.keys(out.windows)).toEqual([main]) + expect(out.windows[main]?.activeTab).toEqual({ [spaceId]: normal }) + expect(Object.keys(out.tabs)).toEqual([normal]) + expect(out.spaces[privateSpace]).toBeUndefined() + expect(out.folders[folderId]).toBeUndefined() + expect(out.splits[splitId]).toBeUndefined() + expect(out.archive.map((a) => a.url)).toEqual(['https://kept.test/']) + expect(out.windows[privateWindow]).toBeUndefined() + // The live state is untouched. + expect(state.tabs[secret]).toBeDefined() + }) + + it('returns the state itself when nothing is private', () => { + const { state, privateWindow, privateSpace, secret } = setup() + delete state.windows[privateWindow] + delete state.spaces[privateSpace] + delete state.tabs[secret] + delete state.profiles['private-abc'] + expect(withoutPrivate(state)).toBe(state) + }) + + it('a private window shows only its own tabs; no other window shows them', () => { + const { state, normal, secret, main, privateWindow } = setup() + expect(windowMayShow(state, privateWindow, secret)).toBe(true) + expect(windowMayShow(state, privateWindow, normal)).toBe(false) + expect(windowMayShow(state, main, normal)).toBe(true) + expect(windowMayShow(state, main, secret)).toBe(false) + }) + + it('private tabs only move within their own Today, and nothing moves in', () => { + const { state, spaceId, normal, secret, privateSpace } = setup() + expect(privateMoveAllowed(state, [secret], { kind: 'today', spaceId: privateSpace })).toBe(true) + expect(privateMoveAllowed(state, [secret], { kind: 'pinned', spaceId: privateSpace })).toBe(false) + expect(privateMoveAllowed(state, [secret], { kind: 'today', spaceId })).toBe(false) + expect(privateMoveAllowed(state, [secret], { kind: 'favorites', profileId: DEFAULT_PROFILE_ID })).toBe(false) + expect(privateMoveAllowed(state, [normal], { kind: 'today', spaceId: privateSpace })).toBe(false) + expect(privateMoveAllowed(state, [normal], { kind: 'pinned', spaceId })).toBe(true) + }) + + it('Reopen Closed Tab: a private tab only in its own window, a normal tab never in a private one', () => { + const { state, spaceId, privateSpace, main, privateWindow } = setup() + const closedSecret = { profileId: 'private-abc', spaceId: privateSpace } + const closedNormal = { profileId: DEFAULT_PROFILE_ID, spaceId } + expect(mayReopenIn(state, closedSecret, privateWindow)).toBe(true) + expect(mayReopenIn(state, closedSecret, main)).toBe(false) + expect(mayReopenIn(state, closedNormal, main)).toBe(true) + expect(mayReopenIn(state, closedNormal, privateWindow)).toBe(false) + // Once its window (Space) is gone, a private tab never comes back. + delete state.spaces[privateSpace] + expect(isLostPrivateTab(state, closedSecret)).toBe(true) + expect(mayReopenIn(state, closedSecret, privateWindow)).toBe(false) + }) + + it('a private tab’s menu has nothing for pinned tabs, Favorites, folders or other Spaces', () => { + const menu = [ + { label: 'Copy Link' }, + { label: 'Pin Tab' }, + { label: 'Add to Favorites' }, + { type: 'separator' }, + { label: 'Move to' }, + { label: 'New Folder with Tabs' }, + { type: 'separator' }, + { label: 'Duplicate' }, + { type: 'separator' }, + { label: 'Delete from Sidebar' }, + ] + expect(privateTabMenu(menu)).toEqual([{ label: 'Copy Link' }, { type: 'separator' }, { label: 'Duplicate' }]) + }) +}) + +describe('pop-up window titles (NV-09)', () => { + it('shows the host, marked when the connection is not secure', () => { + expect(popupTitle('https://accounts.example.com/signin?x=1')).toBe('accounts.example.com') + expect(popupTitle('http://pay.example.com/')).toBe('Not secure — pay.example.com') + expect(popupTitle('http://localhost:3000/cb')).toBe('localhost:3000') + expect(popupTitle('about:blank')).toBe('') + expect(popupTitle('not a url')).toBe('not a url') + }) +}) From e60b6c67fc929b0c17224ddc004c68e1af74a63c Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:48:21 +0000 Subject: [PATCH 08/69] chrome: windows keep apart by private window profiles only A profile on an in-memory session that isn't a private window's (the command bar's history test makes one) shows in normal windows again, and the state file keeps it. Private windows' own profiles stay apart and out of the state file as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/shared/private.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/shared/private.ts b/src/shared/private.ts index 6dd1861d..8f2f779a 100644 --- a/src/shared/private.ts +++ b/src/shared/private.ts @@ -32,13 +32,18 @@ export function isPrivateProfileId(id: string | undefined): boolean { return !!id && id.startsWith(PRIVATE_PROFILE_PREFIX) } +/** A profile whose data stays in memory: a private window's, or any profile without a persistent session. */ export function isPrivateProfile(profile: Profile | undefined): boolean { return !!profile && (isPrivateProfileId(profile.id) || !profile.partition.startsWith('persist:')) } +/** + * A private window's Space. Windows keep apart by this (and `isPrivateTab`): only private windows' + * own profiles count, so another profile on an in-memory session still shows in normal windows. + */ export function isPrivateSpace(state: OrbitState, spaceId: SpaceId | undefined): boolean { const space = spaceId ? state.spaces[spaceId] : undefined - return !!space && (isPrivateProfileId(space.profileId) || isPrivateProfile(state.profiles[space.profileId])) + return !!space && isPrivateProfileId(space.profileId) } export function isPrivateWindow(state: OrbitState, windowId: WindowId | undefined): boolean { @@ -46,9 +51,10 @@ export function isPrivateWindow(state: OrbitState, windowId: WindowId | undefine return !!win && (win.kind === 'private' || isPrivateSpace(state, win.spaceId)) } +/** A private window's tab. */ export function isPrivateTab(state: OrbitState, tabId: TabId | undefined): boolean { const tab = tabId ? state.tabs[tabId] : undefined - return !!tab && (isPrivateProfileId(tab.profileId) || isPrivateProfile(state.profiles[tab.profileId])) + return !!tab && isPrivateProfileId(tab.profileId) } /** @@ -150,12 +156,8 @@ function layoutLeaves(layout: SplitLayout): TabId[] { * when there is nothing private. */ export function withoutPrivate(state: OrbitState): OrbitState { - const profiles = new Set( - Object.values(state.profiles) - .filter(isPrivateProfile) - .map((p) => p.id), - ) - const isPrivate = (profileId: ProfileId) => profiles.has(profileId) || isPrivateProfileId(profileId) + const profiles = new Set(Object.keys(state.profiles).filter(isPrivateProfileId)) + const isPrivate = (profileId: ProfileId) => isPrivateProfileId(profileId) const spaces = new Set( Object.values(state.spaces) .filter((s) => isPrivate(s.profileId)) From 12cc0ff084614a94a9fb5e40304ffda04e7e6e80 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:09:47 +0000 Subject: [PATCH 09/69] WIP: chrome: permission prompts and the site controls panel (in progress) NV-15: a site's permission requests (camera, microphone, location, notifications, clipboard, MIDI, device use, window management, fonts, local network), device choosers (serial, USB, HID), screen sharing and file saving wait as a prompt under the address display (in a view over the page's corner while the sidebar is hidden). Choices are kept per profile and site; private windows keep them in memory. NV-16: the site controls panel in the overlay, opened from the button at the address display's right end or the Site Controls action, with the site's connection, permission switches, pop-ups, devices, zoom, reset and clearing cookies and site data. Not tested yet; committed before merging main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/index.ts | 10 +- src/main/nav/permissions.ts | 659 ++++++++++++++++++ src/main/nav/site-controls.ts | 200 ++++++ src/main/nav/site-prefs.ts | 116 ++- src/main/persist.ts | 13 +- src/main/sessions.ts | 55 +- src/main/windows.ts | 83 ++- src/renderer/src/main.tsx | 8 + src/renderer/src/overlay/OverlayApp.tsx | 4 + .../src/overlay/site/SiteControls.tsx | 360 ++++++++++ src/renderer/src/overlay/site/site.css | 288 ++++++++ src/renderer/src/shell/chrome/Address.tsx | 52 +- .../src/shell/chrome/PermissionPrompt.tsx | 413 +++++++++++ .../src/shell/chrome/PromptViewApp.tsx | 29 + src/renderer/src/shell/chrome/chrome.css | 14 +- src/renderer/src/shell/chrome/prompt.css | 254 +++++++ src/renderer/src/shell/sidebar/TopBar.tsx | 10 +- src/shared/commands/nav.ts | 19 + src/shared/rpc.ts | 2 +- src/shared/site.ts | 268 +++++++ src/shared/state.ts | 4 + 21 files changed, 2832 insertions(+), 29 deletions(-) create mode 100644 src/main/nav/permissions.ts create mode 100644 src/main/nav/site-controls.ts create mode 100644 src/renderer/src/overlay/site/SiteControls.tsx create mode 100644 src/renderer/src/overlay/site/site.css create mode 100644 src/renderer/src/shell/chrome/PermissionPrompt.tsx create mode 100644 src/renderer/src/shell/chrome/PromptViewApp.tsx create mode 100644 src/renderer/src/shell/chrome/prompt.css create mode 100644 src/shared/site.ts diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 2be97fb2..c75ebff5 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -14,8 +14,10 @@ import { openUrlInNewWindow } from './new-window' import { registerPageColor } from './page-color' import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { type PageToolsInfo, registerPageTools } from './page-tools' +import { registerPermissions } from './permissions' import { type PrivateWindows, registerPrivate } from './private' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' +import { registerSiteControls } from './site-controls' import { SitePrefs } from './site-prefs' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitNav. */ @@ -26,6 +28,8 @@ export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { chooseMenu: (label: string, newTab?: boolean) => boolean /** A window's page tools (default: the first): what the find bar and zoom indicator show, and where (NV-05, NV-06). */ tools: (windowId?: WindowId) => PageToolsInfo + /** A window's permission prompt view (default: the first): placed while the sidebar is hidden (NV-15). */ + prompt: (windowId?: WindowId) => { placed: boolean; bounds?: { x: number; y: number; width: number; height: number } } /** The corner radius of a shown page's card (0 without a frame: full screen) (SB-19). */ corner: (tabId: TabId) => number | null /** The floating sidebar of a window (default: the first): its view, and what keeps it out (SB-12). */ @@ -282,8 +286,9 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { private: (url, from) => void privateWindows.open({ url, fromWindowId: from.windowId }), }) - // Placeholder until the site controls panel (NV-16) lands. - actions.register('site.controls', (c) => ctx.toast(windowOf(c), 'Site controls are coming soon')) + // Permission prompts and the site controls panel (NV-15, NV-16). + const permissions = registerPermissions(ctx, prefs) + registerSiteControls(ctx, prefs, permissions) ctx.hooks.tabCreated.push((tabId, contents) => { // Esc stops a loading page (NV-04). The page still gets the key; an open overlay (command bar, @@ -323,6 +328,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { }, corner: (tabId) => windows.cornerOf(tabId) ?? null, tools: (windowId) => pageTools.info(windowId ?? windows.ids()[0] ?? ''), + prompt: (windowId) => permissions.info(windowId ?? windows.ids()[0] ?? ''), floating: (windowId) => { const id = windowId ?? windows.ids()[0] ?? '' return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts new file mode 100644 index 00000000..f642bea5 --- /dev/null +++ b/src/main/nav/permissions.ts @@ -0,0 +1,659 @@ +import { basename } from 'node:path' +import { + webContents as allContents, + BrowserWindow, + type DesktopCapturerSource, + desktopCapturer, + dialog, + type Rectangle, + type WebContents, +} from 'electron' +import { + type DeviceKind, + deviceKey, + deviceLabel, + type EngineDevice, + kindsOf, + ONE_TIME_KINDS, + PERMISSION_ASK, + type PermissionKind, + type PermissionPrompt, + type PromptChoice, + permissionOrigin, + type SitePermissionRow, + siteLabel, + sortKinds, +} from '../../shared/site' +import type { ProfileId, TabId, WindowId } from '../../shared/state' +import { locate } from '../../shared/tree' +import type { MainContext } from '../context' +import type { PermissionHandler, PermissionRequestDetails } from '../sessions' +import { activeTabOf } from '../windows' +import type { SitePrefs } from './site-prefs' + +/** + * Granted without asking, as in Chrome: harmless, or started by the user (full screen, writing the + * clipboard…). Everything else that isn't a permission Orbit asks about (`kindsOf`) is refused. + */ +const ALWAYS_ALLOW = new Set([ + 'fullscreen', + 'clipboard-sanitized-write', + 'pointerLock', + 'keyboardLock', + 'speaker-selection', + 'mediaKeySystem', + 'screen-wake-lock', + 'sensors', + 'background-sync', + 'background-fetch', + 'periodic-background-sync', +]) + +/** Device APIs: a site may ask, and the chooser asks the user for each device. */ +const DEVICE_APIS = new Set(['serial', 'usb', 'hid']) + +/** 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 } + +interface Pending { + prompt: PermissionPrompt + tabId: TabId + profileId: ProfileId + /** The tab's navigation count when asked: a request from a page that was since left is dropped. */ + nav: number + /** Ends the request with the user's answer (dismiss when the page went away). */ + settle: (choice: PromptChoice, pick?: string) => void + /** Identical permission requests while one waits share its prompt (permission prompts only). */ + key?: string + callbacks?: ((granted: boolean) => void)[] + /** Updates a chooser's list as devices come and go. */ + updateDevices?: (change: (devices: EngineDevice[]) => EngineDevice[]) => void +} + +export interface Permissions { + /** What the site controls panel lists for a site: permissions it was given, refused or asked for. */ + rows(profileId: ProfileId, origin: string): SitePermissionRow[] + /** A switch in the site controls panel: allow or block a permission (NV-16). */ + set(profileId: ProfileId, origin: string, kind: PermissionKind, allowed: boolean): void + /** Forgets every permission choice of a site (it asks again). */ + reset(profileId: ProfileId, origin: string): void + /** Test hooks. */ + info(windowId: WindowId): { placed: boolean; bounds?: Rectangle } +} + +/** + * Permission prompts (NV-15). A site asking for the camera, microphone, location, notifications, + * clipboard, MIDI, device use, a device (serial, USB, HID) or a screen to share gets a small prompt + * under the address display, at the top of the sidebar (in a view over the page's top-left corner + * while the sidebar is hidden). Choices are kept per profile and site (`SitePrefs`; private + * windows' in memory only) and the site controls panel shows and changes them (NV-16). "Allow this + * time" lasts while a page of the site stays open. Leaving the page or closing the tab drops its + * prompts; switching tabs hides them until the tab is back. Requests from pages without a sidebar + * (pop-up windows, Mini Windows, Peek) get a plain dialog. + */ +export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permissions { + const { store, commands, tabs, windows } = ctx + const queues = new Map<TabId, Pending[]>() + const navCount = new Map<TabId, number>() + /** "Allow this time": `profile|origin|kind` → pages of that site that keep it. */ + const once = new Map<string, Set<number>>() + /** Which pages hold which one-time grants (dropped as they leave the site or close). */ + const onceByContents = new Map<number, Set<string>>() + /** Permissions sites asked for since launch, so the panel lists them even without a choice. */ + const asked = new Map<string, Set<PermissionKind>>() + /** Screen pickers' previews, by prompt. */ + const thumbnails = new Map<number, Record<string, string>>() + /** Heights the prompt view's card reported, per window. */ + const promptHeights = new Map<WindowId, number>() + let nextId = 0 + + const siteKey = (profileId: ProfileId, origin: string) => `${profileId}|${origin}` + const onceKey = (profileId: ProfileId, origin: string, kind: PermissionKind) => `${profileId}|${origin}|${kind}` + + const noteAsked = (profileId: ProfileId, origin: string, kinds: PermissionKind[]) => { + const key = siteKey(profileId, origin) + let set = asked.get(key) + if (!set) { + set = new Set() + asked.set(key, set) + } + for (const k of kinds) set.add(k) + } + + const hasOnce = (profileId: ProfileId, origin: string, kind: PermissionKind) => + (once.get(onceKey(profileId, origin, kind))?.size ?? 0) > 0 + + const grantOnce = (profileId: ProfileId, origin: string, kind: PermissionKind, contents: WebContents) => { + const key = onceKey(profileId, origin, kind) + let holders = once.get(key) + if (!holders) { + holders = new Set() + once.set(key, holders) + } + holders.add(contents.id) + let keys = onceByContents.get(contents.id) + if (!keys) { + keys = new Set() + onceByContents.set(contents.id, keys) + } + keys.add(key) + } + + /** A page left its site or closed: the one-time grants it held go (`keep` names the site it is on now). */ + const releaseOnce = (contentsId: number, keepOrigin?: string | null) => { + const keys = onceByContents.get(contentsId) + if (!keys) return + for (const key of [...keys]) { + const origin = key.split('|')[1] + if (keepOrigin && origin === keepOrigin) continue + keys.delete(key) + const holders = once.get(key) + holders?.delete(contentsId) + if (holders && holders.size === 0) once.delete(key) + } + if (keys.size === 0) onceByContents.delete(contentsId) + } + + const clearOnce = (profileId: ProfileId, origin: string, kind?: PermissionKind) => { + const prefix = kind ? onceKey(profileId, origin, kind) : `${profileId}|${origin}|` + for (const key of [...once.keys()]) { + if (kind ? key !== prefix : !key.startsWith(prefix)) continue + for (const id of once.get(key) ?? []) onceByContents.get(id)?.delete(key) + once.delete(key) + } + } + + /** Whether a permission is granted right now: allowed, or allowed this time. */ + const allowedNow = (profileId: ProfileId, origin: string, kind: PermissionKind) => + prefs.permission(profileId, origin, kind) === 'allow' || hasOnce(profileId, origin, kind) + + /** The site a request belongs to: the top page's, as in Chromium (a frame uses what its page was given). */ + const requestOrigin = (contents: WebContents, details: { isMainFrame?: boolean; requestingUrl?: string }) => { + const top = contents.isDestroyed() ? '' : contents.getURL() + return permissionOrigin( + details.isMainFrame && details.requestingUrl ? details.requestingUrl : top || details.requestingUrl, + ) + } + + /** A tab that shows its prompt in a window's sidebar (or the page's corner): one in a Space, not Peek or a Mini Window. */ + const promptable = (tabId: TabId | undefined): tabId is TabId => { + if (!tabId) return false + const state = store.state + return !!state.tabs[tabId] && !!locate(state, tabId) + } + + // ---------- The queue on each tab ---------- + + const syncTab = (tabId: TabId) => { + const head = queues.get(tabId)?.[0] + const tab = store.state.tabs[tabId] + if (!tab) return + const current = tab.permissionPrompt + if (head ? JSON.stringify(current) === JSON.stringify(head.prompt) : current === undefined) return + store.update((d) => { + const t = d.tabs[tabId] + if (!t) return + if (head) t.permissionPrompt = head.prompt + else delete t.permissionPrompt + }) + } + + const enqueue = (pending: Pending) => { + let queue = queues.get(pending.tabId) + if (!queue) { + queue = [] + queues.set(pending.tabId, queue) + } + queue.push(pending) + syncTab(pending.tabId) + } + + /** Ends a request: out of its queue, then the next one shows. */ + const finish = (pending: Pending, choice: PromptChoice, pick?: string) => { + const queue = queues.get(pending.tabId) + const at = queue ? queue.indexOf(pending) : -1 + if (at < 0) return + queue?.splice(at, 1) + if (queue && queue.length === 0) queues.delete(pending.tabId) + thumbnails.delete(pending.prompt.id) + try { + pending.settle(choice, pick) + } catch (err) { + console.error('[permissions] answer failed', err) + } + syncTab(pending.tabId) + } + + /** Drops a tab's prompts (its page went away), or only those asked before navigation `before`. */ + const dropTab = (tabId: TabId, before?: number) => { + const queue = queues.get(tabId) + if (!queue) return + for (const pending of [...queue]) if (before === undefined || pending.nav < before) finish(pending, 'dismiss') + } + + // ---------- Permission requests ---------- + + const askPermission = ( + profileId: ProfileId, + contents: WebContents, + tabId: TabId, + origin: string, + kinds: PermissionKind[], + callback: (granted: boolean) => void, + ) => { + const key = `${origin}|${kinds.join(',')}` + const waiting = queues.get(tabId)?.find((p) => p.key === key && p.prompt.type === 'permission') + if (waiting) { + waiting.callbacks?.push(callback) + return + } + const callbacks = [callback] + const pending: Pending = { + prompt: { + id: ++nextId, + type: 'permission', + origin, + kinds, + oneTime: kinds.every((k) => ONE_TIME_KINDS.has(k)), + }, + tabId, + profileId, + nav: navCount.get(tabId) ?? 0, + key, + callbacks, + settle: (choice) => { + if (choice === 'allow' || choice === 'block') { + for (const k of kinds) prefs.setPermission(profileId, origin, k, choice) + } else if (choice === 'once' && !contents.isDestroyed()) { + for (const k of kinds) grantOnce(profileId, origin, k, contents) + } + const granted = choice === 'allow' || choice === 'once' + for (const cb of callbacks) cb(granted) + }, + } + enqueue(pending) + } + + /** Pages without a sidebar (pop-up windows, Mini Windows, Peek): a plain dialog asks instead. */ + const askInDialog = ( + profileId: ProfileId, + contents: WebContents, + origin: string, + kinds: PermissionKind[], + callback: (granted: boolean) => void, + ) => { + if (ctx.e2e) return callback(false) + const parent = BrowserWindow.getFocusedWindow() + const options = { + type: 'question' as const, + buttons: ['Allow', 'Block'], + defaultId: 1, + cancelId: 1, + message: `${siteLabel(origin)} wants to:`, + detail: kinds.map((k) => PERMISSION_ASK[k]).join('\n'), + } + void (parent ? dialog.showMessageBox(parent, options) : dialog.showMessageBox(options)).then(({ response }) => { + const allowed = response === 0 + for (const k of kinds) prefs.setPermission(profileId, origin, k, allowed ? 'allow' : 'block') + if (!contents.isDestroyed()) callback(allowed) + }) + } + + /** Saving to (or reading) a file the page was given: asked each time, never remembered. */ + const askFile = ( + profileId: ProfileId, + contents: WebContents, + details: PermissionRequestDetails, + callback: (granted: boolean) => void, + ) => { + const tabId = tabs.tabIdFor(contents) + const origin = requestOrigin(contents, details) + const file = details as { filePath?: string; isDirectory?: boolean; fileAccessType?: string } + if (!origin || !promptable(tabId)) return callback(false) + enqueue({ + prompt: { + id: ++nextId, + type: 'file', + origin, + name: file.filePath ? basename(file.filePath) : '', + directory: !!file.isDirectory, + write: file.fileAccessType === 'writable', + }, + tabId, + profileId, + nav: navCount.get(tabId) ?? 0, + settle: (choice) => callback(choice === 'allow' || choice === 'once'), + }) + } + + const handler: PermissionHandler = { + check: (profileId, _contents, permission, requestingOrigin, details) => { + if (ALWAYS_ALLOW.has(permission)) return true + const origin = permissionOrigin(details.embeddingOrigin || requestingOrigin) + if (!origin) return false + if (DEVICE_APIS.has(permission)) return true + const kinds = kindsOf(permission, details.mediaType ? [details.mediaType] : undefined) + if (!kinds) return false + return kinds.every((k) => allowedNow(profileId, origin, k)) + }, + request: (profileId, contents, permission, callback, details) => { + 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 + const kinds = kindsOf(permission, media) + const origin = requestOrigin(contents, details) + if (!kinds || !origin) return callback(false) + noteAsked(profileId, origin, kinds) + if (kinds.some((k) => prefs.permission(profileId, origin, k) === 'block')) return callback(false) + const undecided = sortKinds(kinds.filter((k) => !allowedNow(profileId, origin, k))) + if (undecided.length === 0) return callback(true) + const tabId = tabs.tabIdFor(contents) + if (!promptable(tabId)) return askInDialog(profileId, contents, origin, undecided, callback) + askPermission(profileId, contents, tabId, origin, undecided, callback) + }, + decision: (profileId, origin, permission) => { + const kinds = kindsOf(permission) + const site = permissionOrigin(origin) + if (!kinds || !site) return undefined + const choices = kinds.map((k) => prefs.permission(profileId, site, k)) + if (choices.some((c) => c === 'block')) return false + return choices.every((c) => c === 'allow') ? true : undefined + }, + decide: (profileId, origin, permission, allowed) => { + const kinds = kindsOf(permission) + const site = permissionOrigin(origin) + if (!kinds || !site) return + for (const k of kinds) prefs.setPermission(profileId, site, k, allowed ? 'allow' : 'block') + }, + } + ctx.sessions.setPermissionHandler(handler) + + // ---------- Device choosers (Web Serial, WebUSB, WebHID) ---------- + + const askDevice = ( + profileId: ProfileId, + contents: WebContents | undefined, + type: DeviceKind, + list: EngineDevice[], + idOf: (d: EngineDevice) => string | undefined, + choose: (id: string | undefined) => void, + ): Pending | undefined => { + const tabId = contents ? tabs.tabIdFor(contents) : undefined + const origin = contents && !contents.isDestroyed() ? permissionOrigin(contents.getURL()) : null + if (!origin || !promptable(tabId)) { + choose(undefined) + return undefined + } + let devices = list + const options = () => + devices.flatMap((d) => { + const id = idOf(d) + return id ? [{ id, name: deviceLabel(type, d) }] : [] + }) + const prompt: PermissionPrompt = { id: ++nextId, type: 'device', origin, device: type, devices: options() } + const pending: Pending = { + prompt, + tabId, + profileId, + nav: navCount.get(tabId) ?? 0, + settle: (choice, pick) => { + const device = choice === 'allow' && pick ? devices.find((d) => idOf(d) === pick) : undefined + if (device) { + prefs.grantDevice(profileId, origin, { type, key: deviceKey(type, device), name: deviceLabel(type, device) }) + } + choose(device ? pick : undefined) + }, + updateDevices: (change) => { + devices = change(devices) + pending.prompt = { ...prompt, devices: options() } + syncTab(tabId) + }, + } + enqueue(pending) + return pending + } + + /** The chooser waiting for a page, to update its list as devices come and go. */ + const chooserFor = (contents: WebContents | undefined, type: DeviceKind): Pending | undefined => { + const tabId = contents ? tabs.tabIdFor(contents) : undefined + return tabId + ? queues.get(tabId)?.find((p) => p.prompt.type === 'device' && p.prompt.device === type && !!p.updateDevices) + : undefined + } + + const contentsOfFrame = (frame: Electron.WebFrameMain | null | undefined): WebContents | undefined => { + if (!frame) return undefined + try { + return allContents.fromFrame(frame) + } catch { + return undefined + } + } + + // ---------- Screen sharing (getDisplayMedia) ---------- + + const askScreen = ( + profileId: ProfileId, + contents: WebContents | undefined, + origin: string | null, + audio: boolean, + callback: (streams: { video: DesktopCapturerSource; audio?: 'loopback' } | null) => void, + ) => { + const tabId = contents ? tabs.tabIdFor(contents) : undefined + if (!origin || !promptable(tabId)) return callback(null) + const navAtAsk = navCount.get(tabId) ?? 0 + desktopCapturer + .getSources({ types: ['screen', 'window'], thumbnailSize: { width: 240, height: 150 }, fetchWindowIcons: false }) + .then((sources) => { + if (!contents || contents.isDestroyed() || (navCount.get(tabId) ?? 0) !== navAtAsk) return callback(null) + const prompt: PermissionPrompt = { + id: ++nextId, + type: 'screen', + origin, + sources: sources.map((s) => ({ id: s.id, name: s.name, screen: s.id.startsWith('screen:') })), + } + const previews: Record<string, string> = {} + for (const s of sources) if (!s.thumbnail.isEmpty()) previews[s.id] = s.thumbnail.toDataURL() + thumbnails.set(prompt.id, previews) + enqueue({ + prompt, + tabId, + profileId, + nav: navAtAsk, + settle: (choice, pick) => { + const source = choice === 'allow' && pick ? sources.find((s) => s.id === pick) : undefined + if (!source) return callback(null) + // Sound comes along only where the system can share it (Windows). + callback(audio && process.platform === 'win32' ? { video: source, audio: 'loopback' } : { video: source }) + }, + }) + }) + .catch(() => callback(null)) + } + + ctx.sessions.onSessionCreated((ses, profileId) => { + ses.on('select-serial-port', (event, portList, contents, callback) => { + event.preventDefault() + askDevice( + profileId, + contents, + 'serial', + portList as EngineDevice[], + (d) => d.portId, + (id) => callback(id ?? ''), + ) + }) + ses.on('serial-port-added', (_event, port, contents) => { + chooserFor(contents, 'serial')?.updateDevices?.((list) => [ + ...list.filter((d) => d.portId !== port.portId), + port as EngineDevice, + ]) + }) + ses.on('serial-port-removed', (_event, port, contents) => { + chooserFor(contents, 'serial')?.updateDevices?.((list) => list.filter((d) => d.portId !== port.portId)) + }) + ses.on('select-usb-device', (event, details, callback) => { + event.preventDefault() + askDevice( + profileId, + contentsOfFrame(details.frame), + 'usb', + details.deviceList as EngineDevice[], + (d) => d.deviceId, + (id) => callback(id), + ) + }) + ses.on('usb-device-added', (_event, device, contents) => { + chooserFor(contents, 'usb')?.updateDevices?.((list) => [ + ...list.filter((d) => d.deviceId !== device.deviceId), + device as EngineDevice, + ]) + }) + ses.on('usb-device-removed', (_event, device, contents) => { + chooserFor(contents, 'usb')?.updateDevices?.((list) => list.filter((d) => d.deviceId !== device.deviceId)) + }) + ses.on('select-hid-device', (event, details, callback) => { + event.preventDefault() + askDevice( + profileId, + contentsOfFrame(details.frame), + 'hid', + details.deviceList as EngineDevice[], + (d) => d.deviceId, + (id) => callback(id ?? null), + ) + }) + ses.setDevicePermissionHandler((details) => { + const origin = permissionOrigin(details.origin) + const type = details.deviceType as DeviceKind + if (!origin || !DEVICE_APIS.has(type)) return false + const key = deviceKey(type, details.device as EngineDevice) + return prefs.devicesFor(profileId, origin).some((g) => g.key === key) + }) + ses.setDisplayMediaRequestHandler( + (request, callback) => { + const contents = contentsOfFrame(request.frame) + const origin = contents && !contents.isDestroyed() ? permissionOrigin(contents.getURL()) : null + if (!request.videoRequested) return callback(null) + askScreen(profileId, contents, origin, request.audioRequested, callback) + }, + // macOS 15 and later show the system's own picker. + { useSystemPicker: true }, + ) + }) + + // ---------- Answers from the prompt ---------- + + const findPending = (id: number): Pending | undefined => { + for (const queue of queues.values()) { + const found = queue.find((p) => p.prompt.id === id) + if (found) return found + } + return undefined + } + + commands.register('permissions.answer', ({ id, choice, pick }) => { + const pending = findPending(id) + if (pending) finish(pending, choice, pick) + }) + commands.register('permissions.thumbnails', ({ id }) => thumbnails.get(id) ?? {}) + commands.register('permissions.promptSize', ({ height }, c) => { + const windowId = c.windowId + if (!windowId || !(height > 0)) return + const rounded = Math.ceil(height) + if (promptHeights.get(windowId) === rounded) return + promptHeights.set(windowId, rounded) + place(windowId) + }) + + // ---------- The prompt over the page while the sidebar is hidden ---------- + + const place = (windowId: WindowId) => { + const state = store.state + const win = state.windows[windowId] + const tabId = activeTabOf(state, windowId) + const prompt = tabId ? state.tabs[tabId]?.permissionPrompt : undefined + const page = tabId ? windows.tabViewBounds(tabId) : undefined + // The sidebar shows it (docked, or the floating card while that is out over the corner). + const inSidebar = win?.sidebar.visible || win?.sidebar.peeking + if (!win || !prompt || !page || inSidebar || windows.inHtmlFullscreen(windowId)) { + windows.hidePrompt(windowId) + return + } + const { width, margin, inset, estimate } = PROMPT_VIEW + const height = Math.min(promptHeights.get(windowId) ?? estimate, Math.max(80, page.height - 2 * inset)) + windows.showPrompt(windowId, { + x: page.x + inset - margin, + y: page.y + inset - margin, + width: Math.min(width, Math.max(160, page.width - 2 * inset)) + 2 * margin, + height: height + 2 * margin, + }) + } + + const placeAll = () => { + for (const windowId of windows.ids()) place(windowId) + } + store.subscribe((next, prev) => { + if (next.windows === prev.windows && next.tabs === prev.tabs) return + placeAll() + }) + windows.onLayout((windowId) => place(windowId)) + + // ---------- Pages come and go ---------- + + ctx.hooks.tabCreated.push((tabId, contents) => { + const contentsId = contents.id + contents.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) navCount.set(tabId, (navCount.get(tabId) ?? 0) + 1) + }) + contents.on('did-navigate', (_event, url) => { + // Prompts of the page that was left go; "Allow this time" goes with the site. + dropTab(tabId, navCount.get(tabId) ?? 0) + releaseOnce(contentsId, permissionOrigin(url)) + }) + contents.once('destroyed', () => { + dropTab(tabId) + releaseOnce(contentsId) + navCount.delete(tabId) + }) + }) + // A tab that went away entirely. + store.subscribe((next, prev) => { + if (next.tabs === prev.tabs) return + for (const tabId of queues.keys()) if (!next.tabs[tabId]) dropTab(tabId) + }) + + return { + rows: (profileId, origin) => { + const choices = prefs.permissionsFor(profileId, origin) + const kinds = new Set<PermissionKind>(Object.keys(choices) as PermissionKind[]) + for (const k of asked.get(siteKey(profileId, origin)) ?? []) kinds.add(k) + for (const key of once.keys()) { + const [p, o, k] = key.split('|') + if (p === profileId && o === origin) kinds.add(k as PermissionKind) + } + kinds.delete('popups') + return sortKinds(kinds).map((kind) => { + const choice = choices[kind] + const state = choice ?? (hasOnce(profileId, origin, kind) ? 'once' : 'ask') + return { kind, state } + }) + }, + set: (profileId, origin, kind, allowed) => { + clearOnce(profileId, origin, kind) + noteAsked(profileId, origin, [kind]) + prefs.setPermission(profileId, origin, kind, allowed ? 'allow' : 'block') + }, + reset: (profileId, origin) => { + clearOnce(profileId, origin) + for (const kind of Object.keys(prefs.permissionsFor(profileId, origin)) as PermissionKind[]) { + prefs.setPermission(profileId, origin, kind, undefined) + } + for (const grant of prefs.devicesFor(profileId, origin)) prefs.revokeDevice(profileId, origin, grant.key) + }, + info: (windowId) => { + const placed = windows.promptInfo(windowId) + return { placed: placed.placed, bounds: placed.placed ? placed.bounds : undefined } + }, + } +} diff --git a/src/main/nav/site-controls.ts b/src/main/nav/site-controls.ts new file mode 100644 index 00000000..af91b4a3 --- /dev/null +++ b/src/main/nav/site-controls.ts @@ -0,0 +1,200 @@ +import { type Cookie, session } from 'electron' +import { connectionOf, zoomPercent } from '../../shared/nav' +import { isPrivateTab } from '../../shared/private' +import { permissionOrigin, SITE_PANEL, type SiteControlsInfo } from '../../shared/site' +import type { ProfileId, TabId, WindowId } from '../../shared/state' +import { displayHost } from '../../shared/url' +import type { MainContext } from '../context' +import type { CallContext } from '../registry' +import { activeTabOf } from '../windows' +import type { Permissions } from './permissions' +import type { SitePrefs } from './site-prefs' + +/** What a tab's page is, for the panel: its address, permission origin and host. */ +interface Site { + tabId: TabId + profileId: ProfileId + url: string + /** Where permissions are kept (null for pages that have none: internal pages, blank). */ + origin: string | null + /** host:port, how pop-up choices are kept (NV-09). */ + host: string + web: boolean +} + +/** The URL a cookie is sent to, for removing it. */ +function cookieUrl(cookie: Cookie): string | null { + const domain = (cookie.domain ?? '').replace(/^\./, '') + if (!domain) return null + return `${cookie.secure ? 'https' : 'http'}://${domain}${cookie.path || '/'}` +} + +/** + * The site controls panel (NV-16): opened from the address display's button (or the Site Controls + * action), it shows the active tab's site and connection, the permissions it was given, refused + * or asked for (with switches), pop-ups, devices it may use, zoom with a reset, and clearing its + * cookies and data. Changes apply at once; the panel offers a reload when the page only sees them + * after one. It is drawn in the overlay, so the hidden sidebar stays hidden. + */ +export function registerSiteControls(ctx: MainContext, prefs: SitePrefs, permissions: Permissions): void { + const { store, commands, actions, tabs, windows } = ctx + /** Tabs whose page sees a change from the panel only after a reload. */ + const reloadNeeded = new Set<TabId>() + + const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() + + const siteOf = (tabId: TabId | null | undefined): Site | null => { + const tab = tabId ? store.state.tabs[tabId] : undefined + if (!tabId || !tab) return null + const contents = tabs.contents(tabId) + const url = (contents && !contents.isDestroyed() ? contents.getURL() : '') || tab.url + let host = '' + let web = false + try { + const u = new URL(url) + web = u.protocol === 'http:' || u.protocol === 'https:' + host = web ? u.host : '' + } catch { + // Not an address. + } + return { tabId, profileId: tab.profileId, url, origin: web ? permissionOrigin(url) : null, host, web } + } + + const info = (tabId: TabId | null | undefined): SiteControlsInfo | null => { + const site = siteOf(tabId) + if (!site) return null + const tab = store.state.tabs[site.tabId] + const rows = site.origin ? permissions.rows(site.profileId, site.origin) : [] + if (site.host) { + // Pop-ups show once the page had one blocked, or the site may open them. + const allowed = prefs.popupsAllowed(site.profileId, site.host) + if (allowed || (tab?.blockedPopups ?? 0) > 0) rows.push({ kind: 'popups', state: allowed ? 'allow' : 'block' }) + } + const devices = site.origin + ? prefs.devicesFor(site.profileId, site.origin).map((g) => ({ key: g.key, type: g.type, name: g.name })) + : [] + const contents = tabs.contents(site.tabId) + return { + tabId: site.tabId, + url: site.url, + host: site.host || displayHost(site.url) || site.url, + connection: connectionOf(site.url), + web: site.web, + private: isPrivateTab(store.state, site.tabId), + permissions: rows, + devices, + zoom: contents && !contents.isDestroyed() ? zoomPercent(contents.getZoomFactor()) : 100, + reload: reloadNeeded.has(site.tabId), + } + } + + const changed = (tabId: TabId) => { + reloadNeeded.add(tabId) + } + + /** Opens the panel in a window's overlay with its top-left corner at a point (window coordinates). */ + const open = (windowId: WindowId, at: { x: number; y: number }) => { + const tabId = activeTabOf(store.state, windowId) + if (!tabId) return + void commands + .run( + 'overlay.open', + { + overlay: { + kind: 'panel', + panel: 'site-controls', + params: { tabId, x: Math.round(at.x), y: Math.round(at.y) }, + }, + }, + { windowId }, + ) + .catch(() => {}) + } + + /** Where the panel goes without a button to hang from: the page's top-left corner. */ + const pageCorner = (windowId: WindowId) => { + const tabId = activeTabOf(store.state, windowId) + const page = tabId ? windows.tabViewBounds(tabId) : undefined + return page ? { x: page.x + SITE_PANEL.margin, y: page.y + SITE_PANEL.margin } : { x: 16, y: 16 } + } + + commands.register('site.openControls', ({ x, y }, c) => { + const windowId = windowOf(c) + if (!windowId) return + open(windowId, x !== undefined && y !== undefined ? { x, y } : pageCorner(windowId)) + }) + + // The Site Controls action (command bar, a shortcut the user sets): under the address display's + // button while the sidebar shows (the sidebar places it), else at the page's corner. It never + // brings the hidden sidebar out (NV-16). + actions.register('site.controls', (c) => { + const windowId = windowOf(c) + const win = windowId ? store.state.windows[windowId] : undefined + if (!windowId || !win) return + if (win.overlay.kind === 'panel' && win.overlay.panel === 'site-controls') { + void commands.run('overlay.close', {}, { windowId }).catch(() => {}) + return + } + if (win.sidebar.visible || windows.floatingContents(windowId)) { + windows.sendToWindow(windowId, 'sidebar:siteControls', {}) + } else { + open(windowId, pageCorner(windowId)) + } + }) + + commands.register('site.info', ({ tabId }, c) => info(tabId ?? activeTabOf(store.state, windowOf(c) ?? ''))) + + commands.register('site.setPermission', ({ tabId, kind, allowed }) => { + const site = siteOf(tabId) + if (!site) return + if (kind === 'popups') { + if (!site.host) return + prefs.setPopupsAllowed(site.profileId, site.host, allowed) + } else { + if (!site.origin) return + permissions.set(site.profileId, site.origin, kind, allowed) + } + changed(tabId) + }) + + commands.register('site.resetPermissions', ({ tabId }) => { + const site = siteOf(tabId) + if (!site?.origin) return + permissions.reset(site.profileId, site.origin) + if (site.host) prefs.setPopupsAllowed(site.profileId, site.host, false) + changed(tabId) + }) + + commands.register('site.revokeDevice', ({ tabId, key }) => { + const site = siteOf(tabId) + if (!site?.origin) return + prefs.revokeDevice(site.profileId, site.origin, key) + changed(tabId) + }) + + commands.register('site.clearData', async ({ tabId }) => { + const site = siteOf(tabId) + if (!site?.origin || !site.web) return + const ses = + tabs.contents(tabId)?.session ?? session.fromPartition(store.state.profiles[site.profileId]?.partition ?? '') + // Cookies the site gets (its host's and its parent domains'), and everything stored for it. + const cookies = await ses.cookies.get({ url: site.url }).catch(() => [] as Cookie[]) + await Promise.all( + cookies.map((cookie) => { + const url = cookieUrl(cookie) + return url ? ses.cookies.remove(url, cookie.name).catch(() => {}) : Promise.resolve() + }), + ) + await ses.clearStorageData({ origin: site.origin }).catch(() => {}) + changed(tabId) + ctx.toast(windows.windowOfTab(tabId), `Cleared cookies and site data for ${site.host}`) + }) + + // A page that loads again sees every change. + ctx.hooks.tabNavigated.push((tabId) => { + reloadNeeded.delete(tabId) + }) + ctx.hooks.tabCreated.push((tabId, contents) => { + contents.once('destroyed', () => reloadNeeded.delete(tabId)) + }) +} diff --git a/src/main/nav/site-prefs.ts b/src/main/nav/site-prefs.ts index 5abe2c27..fb164a3f 100644 --- a/src/main/nav/site-prefs.ts +++ b/src/main/nav/site-prefs.ts @@ -3,6 +3,7 @@ import { mkdir, rename, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { sameZoom, ZOOM_STEPS } from '../../shared/nav' import { isPrivateProfileId } from '../../shared/private' +import { type DeviceKind, isPermissionKind, type PermissionChoice, type PermissionKind } from '../../shared/site' interface SitePrefsData { /** One-time hints already shown (e.g. the ⌘⇧C tip, NV-17). */ @@ -11,12 +12,29 @@ interface SitePrefsData { zoom: Record<string, Record<string, number>> /** Hosts allowed to open pop-ups without a click, per profile (NV-09, NV-16). */ popups: Record<string, Record<string, true>> + /** Permission choices per profile and origin (NV-15, NV-16). */ + permissions: Record<string, Record<string, SitePermissions>> + /** Devices a site may use again without asking, per profile and origin (NV-15). */ + devices: Record<string, Record<string, DeviceGrant[]>> } +export type SitePermissions = Partial<Record<PermissionKind, PermissionChoice>> + +/** A device a site was allowed to connect to: its lasting identity and its name. */ +export interface DeviceGrant { + type: DeviceKind + key: string + name: string +} + +const DEVICE_KINDS: readonly DeviceKind[] = ['serial', 'usb', 'hid'] + const MIN_ZOOM = ZOOM_STEPS[0] as number const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1] as number -type SiteChoices = Pick<SitePrefsData, 'zoom' | 'popups'> +type SiteChoices = Pick<SitePrefsData, 'zoom' | 'popups' | 'permissions' | 'devices'> + +const emptyChoices = (): SiteChoices => ({ zoom: {}, popups: {}, permissions: {}, devices: {} }) /** * The chrome lane's small per-user settings, kept out of OrbitState so they never travel to the @@ -24,9 +42,9 @@ type SiteChoices = Pick<SitePrefsData, 'zoom' | 'popups'> * change, and synchronously on quit. Private profiles' choices stay in memory (NV-11). */ export class SitePrefs { - private data: SitePrefsData = { hints: {}, zoom: {}, popups: {} } + private data: SitePrefsData = { hints: {}, ...emptyChoices() } /** Choices made in private windows: never written, gone with the private session. */ - private ephemeral: SiteChoices = { zoom: {}, popups: {} } + private ephemeral: SiteChoices = emptyChoices() private timer: NodeJS.Timeout | undefined private dirty = false @@ -55,7 +73,43 @@ export class SitePrefs { ;(popups[profile] as Record<string, true>)[host] = true } } - this.data = { hints, zoom, popups } + const permissions: Record<string, Record<string, SitePermissions>> = {} + for (const [profile, origins] of Object.entries(raw.permissions ?? {})) { + if (!origins || typeof origins !== 'object') continue + for (const [origin, kinds] of Object.entries(origins)) { + if (!kinds || typeof kinds !== 'object') continue + const kept: SitePermissions = {} + for (const [kind, choice] of Object.entries(kinds)) { + if (isPermissionKind(kind) && (choice === 'allow' || choice === 'block')) kept[kind] = choice + } + if (Object.keys(kept).length === 0) continue + permissions[profile] ??= {} + ;(permissions[profile] as Record<string, SitePermissions>)[origin] = kept + } + } + const devices: Record<string, Record<string, DeviceGrant[]>> = {} + for (const [profile, origins] of Object.entries(raw.devices ?? {})) { + if (!origins || typeof origins !== 'object') continue + for (const [origin, grants] of Object.entries(origins)) { + if (!Array.isArray(grants)) continue + const kept = grants.filter( + (g): g is DeviceGrant => + !!g && + DEVICE_KINDS.includes(g.type) && + typeof g.key === 'string' && + typeof g.name === 'string' && + g.key.length > 0, + ) + if (kept.length === 0) continue + devices[profile] ??= {} + ;(devices[profile] as Record<string, DeviceGrant[]>)[origin] = kept.map(({ type, key, name }) => ({ + type, + key, + name, + })) + } + } + this.data = { hints, zoom, popups, permissions, devices } } catch { // No file yet, or unreadable: start empty. } @@ -106,9 +160,61 @@ export class SitePrefs { this.changed(profileId) } + /** The user's choice for a permission on an origin in a profile (NV-15), if they made one. */ + permission(profileId: string, origin: string, kind: PermissionKind): PermissionChoice | undefined { + return this.choices(profileId).permissions[profileId]?.[origin]?.[kind] + } + + /** Every permission choice made on an origin in a profile. */ + permissionsFor(profileId: string, origin: string): SitePermissions { + return { ...this.choices(profileId).permissions[profileId]?.[origin] } + } + + /** Records (or with undefined, forgets) a permission choice. */ + setPermission(profileId: string, origin: string, kind: PermissionKind, choice: PermissionChoice | undefined): void { + if (this.permission(profileId, origin, kind) === choice) return + const choices = this.choices(profileId) + const origins = { ...choices.permissions[profileId] } + const kinds: SitePermissions = { ...origins[origin] } + if (choice) kinds[kind] = choice + else delete kinds[kind] + if (Object.keys(kinds).length > 0) origins[origin] = kinds + else delete origins[origin] + if (Object.keys(origins).length > 0) choices.permissions[profileId] = origins + else delete choices.permissions[profileId] + this.changed(profileId) + } + + /** Devices an origin may use again in a profile (NV-15). */ + devicesFor(profileId: string, origin: string): DeviceGrant[] { + return [...(this.choices(profileId).devices[profileId]?.[origin] ?? [])] + } + + grantDevice(profileId: string, origin: string, grant: DeviceGrant): void { + const current = this.devicesFor(profileId, origin) + if (current.some((g) => g.key === grant.key)) return + this.setDevices(profileId, origin, [...current, { type: grant.type, key: grant.key, name: grant.name }]) + } + + revokeDevice(profileId: string, origin: string, key: string): void { + const current = this.devicesFor(profileId, origin) + const next = current.filter((g) => g.key !== key) + if (next.length !== current.length) this.setDevices(profileId, origin, next) + } + + private setDevices(profileId: string, origin: string, grants: DeviceGrant[]): void { + const choices = this.choices(profileId) + const origins = { ...choices.devices[profileId] } + if (grants.length > 0) origins[origin] = grants + else delete origins[origin] + if (Object.keys(origins).length > 0) choices.devices[profileId] = origins + else delete choices.devices[profileId] + this.changed(profileId) + } + /** Forgets every choice made in private windows (the last one closed). */ forgetPrivate(): void { - this.ephemeral = { zoom: {}, popups: {} } + this.ephemeral = emptyChoices() } private choices(profileId: string): SiteChoices { diff --git a/src/main/persist.ts b/src/main/persist.ts index 017aefa0..aaa91928 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -131,6 +131,7 @@ export function normalizeState(input: Partial<OrbitState>): OrbitState { tab.progress = undefined tab.error = undefined tab.blockedPopups = undefined + tab.permissionPrompt = undefined tab.canGoBack = tab.canGoBack ?? false tab.canGoForward = tab.canGoForward ?? false tab.muted = tab.muted ?? false @@ -373,7 +374,17 @@ function repairReferences(state: OrbitState): void { * touch nothing else (loading flips, focus, audio, download progress…) don't cause a write. */ const RUNTIME_ONLY: Record<string, ReadonlySet<string>> = { - tabs: new Set(['loading', 'audible', 'asleep', 'progress', 'error', 'canGoBack', 'canGoForward', 'blockedPopups']), + tabs: new Set([ + 'loading', + 'audible', + 'asleep', + 'progress', + 'error', + 'canGoBack', + 'canGoForward', + 'blockedPopups', + 'permissionPrompt', + ]), windows: new Set(['focused', 'fullscreen', 'overlay']), downloads: new Set(['receivedBytes']), } diff --git a/src/main/sessions.ts b/src/main/sessions.ts index b43805d5..59700fee 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -1,4 +1,4 @@ -import { dialog, type Session, session } from 'electron' +import { dialog, type Session, session, type WebContents } from 'electron' import { DEFAULT_PROFILE_ID } from '../shared/defaults' import { isPrivateProfileId, PRIVATE_PARTITION_PREFIX } from '../shared/private' import type { ProfileId } from '../shared/state' @@ -31,6 +31,33 @@ const ASK = new Set([ 'window-management', ]) +/** What a permission request comes with (Electron's union of request details). */ +export type PermissionRequestDetails = Parameters<Parameters<Session['setPermissionRequestHandler']>[0] & object>[3] +type PermissionCheckDetails = Parameters<Parameters<Session['setPermissionCheckHandler']>[0] & object>[3] + +/** + * Answers permission checks and requests for every profile once set (the site permissions module: + * prompts under the address display and choices kept per site, NV-15); until then, the rules here. + */ +export interface PermissionHandler { + check( + profileId: ProfileId, + contents: WebContents | null, + permission: string, + origin: string, + details: PermissionCheckDetails, + ): boolean + request( + profileId: ProfileId, + contents: WebContents, + permission: string, + callback: (granted: boolean) => void, + details: PermissionRequestDetails, + ): void + decision(profileId: ProfileId, origin: string, permission: string): boolean | undefined + decide(profileId: ProfileId, origin: string, permission: string, allowed: boolean): void +} + /** Origin of a URL or origin string ("this site" when it has none). */ export function originOf(urlOrOrigin: string | undefined): string { if (!urlOrOrigin) return 'this site' @@ -55,6 +82,7 @@ export class Sessions { private configured = new Set<string>() private decisions = new Map<string, boolean>() private listeners: ((ses: Session, profileId: ProfileId) => void)[] = [] + private permissions: PermissionHandler | undefined constructor(private store: Store) {} @@ -78,14 +106,26 @@ export class Sessions { const ses = session.fromPartition(profile.partition) if (!this.configured.has(profile.partition)) { this.configured.add(profile.partition) - this.configure(ses, profile.partition) + this.configure(ses, profile.partition, profile.id) for (const listener of this.listeners) listener(ses, profile.id) } return ses } + /** Hands permission checks and requests of every profile to `handler` (NV-15). */ + setPermissionHandler(handler: PermissionHandler): void { + this.permissions = handler + } + + /** The profile whose session uses a partition. */ + private profileOf(partition: string): ProfileId | undefined { + return Object.values(this.store.state.profiles).find((p) => p.partition === partition)?.id + } + /** The user's decision for a permission on an origin in a profile, if they made one. */ decision(partition: string, origin: string, permission: string): boolean | undefined { + const profileId = this.permissions ? this.profileOf(partition) : undefined + if (this.permissions && profileId) return this.permissions.decision(profileId, originOf(origin), permission) return this.decisions.get(permissionKey(partition, originOf(origin), permission)) } @@ -96,19 +136,26 @@ export class Sessions { /** Records a decision (from the prompt; later from site controls). */ decide(partition: string, origin: string, permission: string, allowed: boolean): void { + const profileId = this.permissions ? this.profileOf(partition) : undefined + if (this.permissions && profileId) { + this.permissions.decide(profileId, originOf(origin), permission, allowed) + return + } this.decisions.set(permissionKey(partition, originOf(origin), permission), allowed) } - private configure(ses: Session, partition: string): void { + private configure(ses: Session, partition: string, profileId: ProfileId): void { // Present as plain Chrome: some sites block "embedded" browsers by user agent. ses.setUserAgent(cleanUserAgent(ses.getUserAgent())) - ses.setPermissionCheckHandler((_wc, permission, requestingOrigin) => { + ses.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => { + if (this.permissions) return this.permissions.check(profileId, wc, permission, requestingOrigin, details) if (ALWAYS_ALLOW.has(permission)) return true if (!ASK.has(permission)) return false // Granted only when the user allowed it for this origin in this profile. return this.decision(partition, requestingOrigin, permission) === true }) ses.setPermissionRequestHandler((wc, permission, callback, details) => { + if (this.permissions) return this.permissions.request(profileId, wc, permission, callback, details) if (ALWAYS_ALLOW.has(permission)) return callback(true) if (!ASK.has(permission)) return callback(false) const origin = originOf(details.requestingUrl || wc.getURL()) diff --git a/src/main/windows.ts b/src/main/windows.ts index 5aa07bf9..1fc4673e 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -62,6 +62,12 @@ interface WindowRecord { tools?: WebContentsView toolsShown: boolean toolsPrewarm?: ReturnType<typeof setTimeout> + /** + * A permission prompt over the active page's top-left corner while the sidebar is hidden (NV-15; + * with the sidebar showing, the prompt is part of it). Started when first needed. + */ + prompt?: WebContentsView + promptShown: boolean } /** A tab view placed in a window. */ @@ -335,6 +341,7 @@ export class WindowManager { overlayShown: false, floatingShown: false, toolsShown: false, + promptShown: false, } this.records.set(id, rec) // macOS: the traffic lights live in the sidebar's top row (SB-20). @@ -415,6 +422,7 @@ export class WindowManager { clearTimeout(rec.toolsPrewarm) if (rec.floating && !rec.floating.webContents.isDestroyed()) rec.floating.webContents.close() if (rec.tools && !rec.tools.webContents.isDestroyed()) rec.tools.webContents.close() + if (rec.prompt && !rec.prompt.webContents.isDestroyed()) rec.prompt.webContents.close() this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { if (entry.windowId === id) { @@ -637,6 +645,61 @@ export class WindowManager { return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } } + /** Places the permission prompt's view at `bounds` (window coordinates): above the pages, under the floating sidebar and the overlay (NV-15). */ + showPrompt(windowId: WindowId, bounds: Rect): void { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed()) return + const view = this.ensurePrompt(rec) + const next = roundRect(bounds) + const current = view.getBounds() + if ( + current.x !== next.x || + current.y !== next.y || + current.width !== next.width || + current.height !== next.height + ) { + view.setBounds(next) + } + rec.promptShown = true + this.placeAbovePages(rec, view) + view.setVisible(true) + } + + /** Takes the permission prompt's view out of the window; keyboard focus in it goes back to the page. */ + hidePrompt(windowId: WindowId): void { + const rec = this.records.get(windowId) + const view = rec?.prompt + if (!rec || !view || !rec.promptShown) return + rec.promptShown = false + const focused = !view.webContents.isDestroyed() && webContents.getFocusedWebContents() === view.webContents + if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) + this.removeChild(rec, view) + } + + /** Test/debug view of a window's permission prompt view. */ + promptInfo(windowId: WindowId): { exists: boolean; placed: boolean; bounds?: Rect } { + const rec = this.records.get(windowId) + const view = rec?.prompt + if (!rec || !view || view.webContents.isDestroyed()) return { exists: false, placed: false } + return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } + } + + /** Puts a small view above every page, under the overlay and the floating sidebar; leaves it be when it already is. */ + private placeAbovePages(rec: WindowRecord, view: WebContentsView): void { + const parent = rec.win.contentView + const children = parent.children + const at = children.indexOf(view) + const ceilings = [rec.overlay, rec.floating].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : children.length + let topPage = -1 + for (const entry of this.attached.values()) { + if (entry.windowId === rec.id) topPage = Math.max(topPage, children.indexOf(entry.view)) + } + if (at > topPage && at < ceiling) return + if (ceiling >= children.length) parent.addChildView(view) + else parent.addChildView(view, at >= 0 && at < ceiling ? ceiling - 1 : ceiling) + } + /** A page that just appeared for the focused tab or pane (e.g. a split pane filled late) takes keyboard focus. */ private focusIfNewlyShown(rec: WindowRecord, shownBefore: Set<TabId>): void { const active = activeTabOf(this.ctx.store.state, rec.id) @@ -854,8 +917,9 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - // The overlay, the floating sidebar (SB-12) and the page tools (NV-05) stay above every page. - const above = [rec.overlay, rec.floating, rec.tools] + // The overlay, the floating sidebar (SB-12), the page tools (NV-05) and a permission prompt + // (NV-15) stay above every page. + const above = [rec.overlay, rec.floating, rec.tools, rec.prompt] .map((v) => (v ? children.indexOf(v) : -1)) .filter((i) => i >= 0) const overlayAt = above.length > 0 ? Math.min(...above) : -1 @@ -1123,6 +1187,21 @@ export class WindowManager { return view } + /** The permission prompt's view (NV-15): transparent, sized by the permissions module to its card. */ + private ensurePrompt(rec: WindowRecord): WebContentsView { + if (rec.prompt && !rec.prompt.webContents.isDestroyed()) return rec.prompt + const view = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'prompt') }) + view.setBackgroundColor('#00000000') + this.registerUi(view.webContents, rec.id) + this.reloadUiOnCrash(view.webContents, 'prompt') + this.attachShortcuts(view.webContents) + this.hardenUi(view.webContents) + loadRenderer(view.webContents, 'prompt') + rec.prompt = view + rec.promptShown = false + return view + } + /** The transparent overlay view (command bar, Peek chrome, menus), created on first need. */ private ensureOverlay(rec: WindowRecord): WebContentsView { if (rec.overlay) return rec.overlay diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 05063e98..f6f5b5b3 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -37,6 +37,14 @@ async function boot(): Promise<void> { <PageToolsApp /> </StrictMode>, ) + } else if (view === 'prompt') { + // A site's permission prompt over the page's corner while the sidebar is hidden (chrome lane, NV-15). + const { PromptViewApp } = await import('./shell/chrome/PromptViewApp') + root.render( + <StrictMode> + <PromptViewApp /> + </StrictMode>, + ) } else if (view === 'strip') { // Split view pane strip (split lane, SV-12): a tiny transparent view over a pane's top edge. const { PaneStripApp } = await import('./shell/split/PaneStripApp') diff --git a/src/renderer/src/overlay/OverlayApp.tsx b/src/renderer/src/overlay/OverlayApp.tsx index 0f5cabfb..1ef8b974 100644 --- a/src/renderer/src/overlay/OverlayApp.tsx +++ b/src/renderer/src/overlay/OverlayApp.tsx @@ -5,6 +5,7 @@ import { isSpacePanel, SpacePanels } from '../shell/spaces/SpacePanels' import { useWindow } from '../store' import { paletteVars, usePalette } from '../theme' import { CommandBar } from './CommandBar' +import { SiteControls } from './site/SiteControls' /** * Transparent full-window layer above the tab views. It is only visible while @@ -54,6 +55,9 @@ export function OverlayApp() { {overlay?.kind === 'panel' && isSpacePanel(overlay.panel) && ( <SpacePanels key={overlay.nonce} overlay={overlay} /> )} + {overlay?.kind === 'panel' && overlay.panel === 'site-controls' && ( + <SiteControls key={overlay.nonce} overlay={overlay} dark={palette?.dark} /> + )} </div> </MotionConfig> </LazyMotion> diff --git a/src/renderer/src/overlay/site/SiteControls.tsx b/src/renderer/src/overlay/site/SiteControls.tsx new file mode 100644 index 00000000..334095e9 --- /dev/null +++ b/src/renderer/src/overlay/site/SiteControls.tsx @@ -0,0 +1,360 @@ +import { CircleAlert, FileText, Globe, Laptop, Minus, Plus, RotateCw, TriangleAlert, X } from 'lucide-react' +import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { Connection } from '../../../../shared/nav' +import { + DEVICE_NAME, + PERMISSION_NAME, + type PermissionKind, + SITE_PANEL, + type SiteControlsInfo, + type SitePermissionRow, +} from '../../../../shared/site' +import type { OverlayState } from '../../../../shared/state' +import { invoke } from '../../api' +import { DEVICE_ICON, PERMISSION_ICON } from '../../shell/chrome/PermissionPrompt' +import { useOrbit } from '../../store' +import './site.css' + +type PanelOverlay = Extract<OverlayState, { kind: 'panel' }> + +const close = () => invoke('overlay.close', {}) + +const STATE_LABEL: Record<SitePermissionRow['state'], string> = { + allow: 'Allowed', + block: 'Blocked', + once: 'Allowed this time', + ask: 'Ask', +} + +function connectionLine(connection: Connection): { icon: ReactNode; text: string; warn?: boolean } { + switch (connection) { + case 'secure': + return { icon: <Globe size={14} />, text: 'Connection is secure' } + case 'insecure': + return { + icon: <TriangleAlert size={14} />, + text: 'Not secure: what you send to this site could be seen by others', + warn: true, + } + case 'local': + return { icon: <Laptop size={14} />, text: 'A page on this device' } + case 'file': + return { icon: <FileText size={14} />, text: 'A file on this device' } + default: + return { icon: <CircleAlert size={14} />, text: 'An Orbit page' } + } +} + +/** + * The site controls panel (NV-16), drawn in the overlay under the address display (or at the + * page's corner while the sidebar is hidden). It shows the active tab's site and connection, the + * permissions the site was given, refused or asked for with a switch each (pop-ups too), devices + * it may use, zoom with a reset, and clearing its cookies and data. Changes apply at once; when the + * page only sees them after a reload, a Reload button says so. Esc or a click outside closes it; + * arrows move between its controls. + */ +export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; dark?: boolean }) { + const params = overlay.params ?? {} + const tabId = typeof params.tabId === 'string' ? params.tabId : '' + const x = typeof params.x === 'number' ? params.x : SITE_PANEL.margin + const y = typeof params.y === 'number' ? params.y : SITE_PANEL.margin + const [info, setInfo] = useState<SiteControlsInfo | null | undefined>(undefined) + const [confirmClear, setConfirmClear] = useState(false) + const panel = useRef<HTMLDivElement>(null) + const focused = useRef(false) + + // What the page is and what it was given: again after each change, and when the tab moves on. + const url = useOrbit((s) => s.tabs[tabId]?.url) + const blockedPopups = useOrbit((s) => s.tabs[tabId]?.blockedPopups) + const refresh = useCallback(() => { + void invoke('site.info', { tabId }).then(setInfo) + }, [tabId]) + // biome-ignore lint/correctness/useExhaustiveDependencies: asks again when the page or its blocked pop-ups change + useEffect(() => { + refresh() + }, [refresh, url, blockedPopups]) + // The tab went away: nothing to show. + const exists = useOrbit((s) => !!s.tabs[tabId]) + useEffect(() => { + if (!exists) void close() + }, [exists]) + + // Placed under the point main gave, kept inside the window. + const [pos, setPos] = useState<{ left: number; top: number }>({ left: x, top: y }) + useLayoutEffect(() => { + const el = panel.current + if (!el) return + const place = () => { + const { margin } = SITE_PANEL + const w = el.offsetWidth + const h = el.offsetHeight + const left = Math.max(margin, Math.min(x, window.innerWidth - margin - w)) + const top = Math.max(margin, Math.min(y, window.innerHeight - margin - h)) + setPos((p) => (p.left === left && p.top === top ? p : { left, top })) + } + place() + const observer = new ResizeObserver(place) + observer.observe(el) + window.addEventListener('resize', place) + return () => { + observer.disconnect() + window.removeEventListener('resize', place) + } + }, [x, y]) + + // Keyboard focus goes to the panel once it has something in it (KS-19). + useEffect(() => { + if (info === undefined || focused.current) return + focused.current = true + panel.current?.focus() + }, [info]) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + if (confirmClear) setConfirmClear(false) + else void close() + return + } + const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0 + if (!step || !panel.current) return + const controls = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled)')] + if (controls.length === 0) return + e.preventDefault() + const at = controls.indexOf(document.activeElement as HTMLElement) + const next = at < 0 ? (step > 0 ? 0 : controls.length - 1) : (at + step + controls.length) % controls.length + controls[next]?.focus() + } + + const setPermission = async (kind: PermissionKind, allowed: boolean) => { + await invoke('site.setPermission', { tabId, kind, allowed }) + refresh() + } + const zoom = async (id: 'page.zoomIn' | 'page.zoomOut' | 'page.zoomReset') => { + await invoke('action.run', { id }) + refresh() + } + + const conn = info ? connectionLine(info.connection) : null + return ( + <div + ref={panel} + className={`site-panel${dark ? ' is-dark' : ''}`} + role="dialog" + aria-label="Site controls" + tabIndex={-1} + style={{ left: pos.left, top: pos.top, width: SITE_PANEL.width }} + data-testid="site-controls" + onKeyDown={onKeyDown} + > + {info === null && <p className="site-panel__note">Nothing to show for this tab.</p>} + {info && conn && ( + <> + <header className="site-panel__head"> + <div className="site-panel__host" data-testid="site-controls-host" title={info.url}> + {info.host} + </div> + <div + className={`site-panel__connection${conn.warn ? ' is-warning' : ''}`} + data-testid="site-controls-connection" + data-connection={info.connection} + > + {conn.icon} + <span>{conn.text}</span> + </div> + <button + type="button" + className="site-panel__close" + aria-label="Close" + title="Close" + onClick={() => void close()} + > + <X size={13} /> + </button> + </header> + + {info.reload && ( + <div className="site-panel__reload" data-testid="site-controls-reload"> + <span>Reload this page to apply your changes.</span> + <button + type="button" + className="site-panel__button is-primary" + onClick={async () => { + await close() + await invoke('action.run', { id: 'nav.reload' }) + }} + > + <RotateCw size={12} /> Reload + </button> + </div> + )} + + {info.web && ( + <section className="site-panel__section" aria-label="Permissions"> + <h3 className="site-panel__title">Permissions</h3> + {info.permissions.length === 0 ? ( + <p className="site-panel__note" data-testid="site-controls-no-permissions"> + This site hasn’t asked for any permissions. + </p> + ) : ( + <ul className="site-panel__rows"> + {info.permissions.map((row) => ( + <PermissionRow key={row.kind} row={row} onChange={(on) => void setPermission(row.kind, on)} /> + ))} + </ul> + )} + </section> + )} + + {info.devices.length > 0 && ( + <section className="site-panel__section" aria-label="Devices"> + <h3 className="site-panel__title">Devices</h3> + <ul className="site-panel__rows"> + {info.devices.map((device) => { + const Icon = DEVICE_ICON[device.type] + return ( + <li key={device.key} className="site-panel__row" data-testid="site-controls-device"> + <Icon size={14} aria-hidden /> + <span className="site-panel__name" title={DEVICE_NAME[device.type]}> + {device.name} + </span> + <button + type="button" + className="site-panel__icon-button" + aria-label={`Stop letting this site use ${device.name}`} + title="Remove" + onClick={async () => { + await invoke('site.revokeDevice', { tabId, key: device.key }) + refresh() + }} + > + <X size={13} /> + </button> + </li> + ) + })} + </ul> + </section> + )} + + <section className="site-panel__section" aria-label="Zoom"> + <div className="site-panel__row site-panel__zoom"> + <span className="site-panel__name">Zoom</span> + <button + type="button" + className="site-panel__icon-button" + aria-label="Zoom out" + onClick={() => void zoom('page.zoomOut')} + > + <Minus size={13} /> + </button> + <span className="site-panel__zoom-value" data-testid="site-controls-zoom"> + {info.zoom}% + </span> + <button + type="button" + className="site-panel__icon-button" + aria-label="Zoom in" + onClick={() => void zoom('page.zoomIn')} + > + <Plus size={13} /> + </button> + {info.zoom !== 100 && ( + <button + type="button" + className="site-panel__button" + data-testid="site-controls-zoom-reset" + onClick={() => void zoom('page.zoomReset')} + > + Reset + </button> + )} + </div> + </section> + + {info.web && ( + <footer className="site-panel__foot"> + {info.permissions.some((r) => r.state !== 'ask') || info.devices.length > 0 ? ( + <button + type="button" + className="site-panel__link" + data-testid="site-controls-reset" + onClick={async () => { + await invoke('site.resetPermissions', { tabId }) + refresh() + }} + > + Reset permissions + </button> + ) : null} + {confirmClear ? ( + <div className="site-panel__confirm" data-testid="site-controls-clear-confirm"> + <span> + Clear cookies and site data for {info.host}? You may be signed out of this site + {info.private ? '' : ' in this profile'}. + </span> + <div className="site-panel__confirm-buttons"> + <button type="button" className="site-panel__button" onClick={() => setConfirmClear(false)}> + Cancel + </button> + <button + type="button" + className="site-panel__button is-danger" + data-testid="site-controls-clear-ok" + onClick={async () => { + setConfirmClear(false) + await invoke('site.clearData', { tabId }) + refresh() + }} + > + Clear + </button> + </div> + </div> + ) : ( + <button + type="button" + className="site-panel__link" + data-testid="site-controls-clear" + onClick={() => setConfirmClear(true)} + > + Clear cookies and site data… + </button> + )} + {info.private && ( + <p className="site-panel__note">Choices here last until the last private window closes.</p> + )} + </footer> + )} + </> + )} + </div> + ) +} + +function PermissionRow({ row, onChange }: { row: SitePermissionRow; onChange: (on: boolean) => void }) { + const Icon = PERMISSION_ICON[row.kind] + const on = row.state === 'allow' || row.state === 'once' + const name = PERMISSION_NAME[row.kind] + return ( + <li className="site-panel__row" data-testid="site-controls-permission" data-kind={row.kind} data-state={row.state}> + <Icon size={14} aria-hidden /> + <span className="site-panel__name"> + {name} + <span className="site-panel__state">{STATE_LABEL[row.state]}</span> + </span> + <button + type="button" + role="switch" + aria-checked={on} + aria-label={name} + className={`site-switch${on ? ' is-on' : ''}`} + data-testid={`site-switch-${row.kind}`} + onClick={() => onChange(!on)} + > + <span className="site-switch__knob" /> + </button> + </li> + ) +} diff --git a/src/renderer/src/overlay/site/site.css b/src/renderer/src/overlay/site/site.css new file mode 100644 index 00000000..2ca4d7e3 --- /dev/null +++ b/src/renderer/src/overlay/site/site.css @@ -0,0 +1,288 @@ +/* + * The site controls panel (NV-16) in the overlay: a popover under the address display, in the + * Space's colours. Opens with the snappy popover timing (FL: fade and 0.97 → 1, about 150 ms). + */ + +.site-panel { + --site-line: color-mix(in srgb, var(--text, #1c1c1f) 9%, transparent); + --site-fill: color-mix(in srgb, var(--text, #1c1c1f) 6%, transparent); + --site-fill-hover: color-mix(in srgb, var(--text, #1c1c1f) 11%, transparent); + position: absolute; + box-sizing: border-box; + display: flex; + flex-direction: column; + max-height: calc(100vh - 16px); + overflow: auto; + padding: 6px; + border-radius: 13px; + background: var(--surface, #fff); + color: var(--text, #1c1c1f); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.16), + 0 14px 36px rgba(0, 0, 0, 0.22), + 0 2px 8px rgba(0, 0, 0, 0.1); + font-size: 13px; + line-height: 1.35; + outline: none; + user-select: none; + animation: site-panel-in 150ms var(--ease-out, ease-out); + transform-origin: 0 0; +} + +@keyframes site-panel-in { + from { + opacity: 0; + transform: scale(0.97) translateY(-3px); + } +} + +.site-panel__head { + position: relative; + padding: 8px 34px 10px 8px; + border-bottom: 1px solid var(--site-line); +} + +.site-panel__host { + font-size: 14px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.site-panel__connection { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 4px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 12px; +} + +.site-panel__connection svg { + flex: none; + margin-top: 1px; +} + +.site-panel__connection.is-warning { + color: #c9372c; +} + +.site-panel.is-dark .site-panel__connection.is-warning { + color: #ff8a7d; +} + +.site-panel__close { + position: absolute; + top: 6px; + right: 4px; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 7px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); +} + +.site-panel__close:hover { + background: var(--site-fill); + color: inherit; +} + +.site-panel__reload { + display: flex; + align-items: center; + gap: 8px; + margin: 8px 2px 2px; + padding: 8px 8px 8px 10px; + border-radius: 9px; + background: color-mix(in srgb, var(--accent, #5865d8) 12%, transparent); + font-size: 12px; +} + +.site-panel__reload span { + flex: 1; +} + +.site-panel__section { + padding: 6px 0 4px; + border-bottom: 1px solid var(--site-line); +} + +.site-panel__title { + margin: 4px 8px 4px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.site-panel__rows { + margin: 0; + padding: 0; + list-style: none; +} + +.site-panel__row { + display: flex; + align-items: center; + gap: 9px; + min-height: 34px; + padding: 2px 8px; + border-radius: 8px; +} + +.site-panel__row > svg { + flex: none; + opacity: 0.75; +} + +.site-panel__name { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow-wrap: anywhere; +} + +.site-panel__state { + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 11px; +} + +.site-panel__note { + margin: 4px 8px 6px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 12px; +} + +.site-panel__zoom-value { + min-width: 40px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.site-panel__icon-button { + flex: none; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 7px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); +} + +.site-panel__icon-button:hover { + background: var(--site-fill); + color: inherit; +} + +.site-panel__button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 3px 10px; + border-radius: 7px; + background: var(--site-fill); + color: inherit; + font-weight: 600; + font-size: 12px; + white-space: nowrap; +} + +.site-panel__button:hover { + background: var(--site-fill-hover); +} + +.site-panel__button.is-primary { + background: var(--accent, #5865d8); + color: #fff; +} + +.site-panel__button.is-danger { + background: #c9372c; + color: #fff; +} + +.site-panel__foot { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 0 2px; +} + +.site-panel__link { + display: flex; + align-items: center; + min-height: 30px; + padding: 0 8px; + border-radius: 8px; + text-align: left; +} + +.site-panel__link:hover { + background: var(--site-fill); +} + +.site-panel__confirm { + display: flex; + flex-direction: column; + gap: 8px; + margin: 2px; + padding: 10px; + border-radius: 9px; + background: var(--site-fill); + font-size: 12px; +} + +.site-panel__confirm-buttons { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.site-panel button:focus-visible { + outline: 2px solid var(--accent, #5865d8); + outline-offset: 1px; +} + +/* A permission's switch. */ +.site-switch { + flex: none; + position: relative; + width: 30px; + height: 18px; + border-radius: 9px; + background: color-mix(in srgb, var(--text, #1c1c1f) 20%, transparent); + transition: background var(--dur-fast, 120ms) ease; +} + +.site-switch.is-on { + background: var(--accent, #5865d8); +} + +.site-switch__knob { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transition: transform 180ms cubic-bezier(0.3, 1.3, 0.6, 1); +} + +.site-switch.is-on .site-switch__knob { + transform: translateX(12px); +} + +@media (prefers-reduced-motion: reduce) { + .site-panel { + animation: none; + } + .site-switch, + .site-switch__knob { + transition: none; + } +} diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx index ef63ad57..941ce2c2 100644 --- a/src/renderer/src/shell/chrome/Address.tsx +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -1,6 +1,7 @@ import { Check, Link2, ShieldBan, SlidersHorizontal, TriangleAlert } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { connectionOf } from '../../../../shared/nav' +import { SITE_PANEL } from '../../../../shared/site' import { displayHost } from '../../../../shared/url' import { invoke, runAction } from '../../api' import { activeTabId } from '../../selectors' @@ -12,9 +13,10 @@ const COPIED_MS = 1200 /** * The address display under the top row (SB-04): the active tab's host only (the focused pane - * in split view), a site-controls button that turns into a warning on insecure pages (no padlock - * on secure ones), and a copy-link button. Clicking the host edits the URL in the command bar. - * When the page tried to open pop-ups without a click, a button says so and offers them (NV-09). + * in split view), a warning before it on insecure pages (no padlock on secure ones), a copy-link + * button and, at the right end, the site controls button (NV-16), which opens the site controls + * panel under the field. Clicking the host edits the URL in the command bar. When the page tried + * to open pop-ups without a click, a button says so and offers them (NV-09). */ export function Address({ windowId }: { windowId: string }) { // Only the URL: the display re-renders on navigations, not on title, favicon or progress changes. @@ -34,6 +36,22 @@ export function Address({ windowId }: { windowId: string }) { const copied = !!page && copiedFor === page const copiedTimer = useRef(0) useEffect(() => () => window.clearTimeout(copiedTimer.current), []) + const field = useRef<HTMLDivElement>(null) + const panelOpen = useOrbit((s) => { + const overlay = s.windows[windowId]?.overlay + return overlay?.kind === 'panel' && overlay.panel === 'site-controls' + }) + + // The site controls panel hangs under the field (window coordinates: the sidebar's view starts + // at the window's corner, docked or floating). + const openControls = () => { + const r = field.current?.getBoundingClientRect() + void invoke('site.openControls', r ? { x: r.left, y: r.bottom + SITE_PANEL.gap } : {}) + } + const openRef = useRef(openControls) + openRef.current = openControls + // The Site Controls action (command bar, a shortcut) while this sidebar shows. + useEffect(() => window.orbit.on('sidebar:siteControls', () => openRef.current()), []) const copy = () => { // Same as ⌘⇧C: the clean URL on the clipboard and a "Copied" toast (NV-17, TB-20). @@ -45,20 +63,21 @@ export function Address({ windowId }: { windowId: string }) { return ( <div + ref={field} className={`address${page ? '' : ' address--empty'}${insecure ? ' address--insecure' : ''}`} data-testid="address" data-connection={connection} > - {page && ( + {page && insecure && ( <button type="button" - className="address__button address__site" - data-testid="site-controls-button" - aria-label={insecure ? 'Not secure: site controls' : 'Site controls'} - title={insecure ? 'Not secure. This connection is not encrypted.' : 'Site controls'} - onClick={() => runAction('site.controls')} + className="address__button address__warning" + data-testid="insecure-button" + aria-label="Not secure: site controls" + title="Not secure. This connection is not encrypted." + onClick={openControls} > - {insecure ? <TriangleAlert size={13} data-testid="insecure-icon" /> : <SlidersHorizontal size={13} />} + <TriangleAlert size={13} data-testid="insecure-icon" /> </button> )} <button @@ -97,6 +116,19 @@ export function Address({ windowId }: { windowId: string }) { {copied ? <Check size={13} /> : <Link2 size={14} />} </button> )} + {page && ( + <button + type="button" + className={`address__button address__site${panelOpen ? ' is-open' : ''}`} + data-testid="site-controls-button" + aria-label="Site controls" + aria-expanded={panelOpen} + title={withShortcut('Site controls', 'site.controls')} + onClick={openControls} + > + <SlidersHorizontal size={13} /> + </button> + )} </div> ) } diff --git a/src/renderer/src/shell/chrome/PermissionPrompt.tsx b/src/renderer/src/shell/chrome/PermissionPrompt.tsx new file mode 100644 index 00000000..d7ea8641 --- /dev/null +++ b/src/renderer/src/shell/chrome/PermissionPrompt.tsx @@ -0,0 +1,413 @@ +import { + AppWindow, + Bell, + Cable, + ClipboardPaste, + Gamepad2, + Hourglass, + type LucideIcon, + MapPin, + Mic, + Network, + Piano, + SquareArrowOutUpRight, + Type, + Usb, + Video, + X, +} from 'lucide-react' +import { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + DEVICE_ASK, + type DeviceKind, + PERMISSION_ASK, + type PermissionKind, + type PermissionPrompt as Prompt, + type PromptChoice, + siteLabel, +} from '../../../../shared/site' +import { invoke } from '../../api' +import { activeTabId } from '../../selectors' +import { useOrbit } from '../../store' +import './prompt.css' + +export const PERMISSION_ICON: Record<PermissionKind, LucideIcon> = { + camera: Video, + microphone: Mic, + location: MapPin, + notifications: Bell, + clipboard: ClipboardPaste, + midi: Piano, + idle: Hourglass, + windows: AppWindow, + fonts: Type, + network: Network, + popups: SquareArrowOutUpRight, +} + +export const DEVICE_ICON: Record<DeviceKind, LucideIcon> = { serial: Cable, usb: Usb, hid: Gamepad2 } + +/** The permission prompt waiting on a window's active tab (NV-15), if any. */ +export function useActivePrompt(windowId: string): Prompt | undefined { + return useOrbit((s) => { + const id = activeTabId(s, windowId) + return id ? s.tabs[id]?.permissionPrompt : undefined + }) +} + +/** + * The permission prompt (NV-15): a small card anchored under the address display at the top of + * the sidebar (or over the page's top-left corner while the sidebar is hidden, in a view of its + * own). It names the site and what it wants, with Chromium's choices: "Allow while visiting the + * site", "Allow this time" and "Never allow" for the camera, microphone and location, "Allow" and + * "Block" for the rest, or a list to pick a device or a screen from. The text wraps to the + * sidebar's width. Esc (or the close button) dismisses it: the site may ask again later. Arrow + * keys move between the choices; Enter takes one. + */ +export function PermissionPrompt({ prompt, onSize }: { prompt: Prompt; onSize?: (height: number) => void }) { + const card = useRef<HTMLDivElement>(null) + const [busy, setBusy] = useState(false) + + useLayoutEffect(() => { + const el = card.current + if (!el || !onSize) return + onSize(el.offsetHeight) + const observer = new ResizeObserver(() => onSize(el.offsetHeight)) + observer.observe(el) + return () => observer.disconnect() + }, [onSize]) + + const answer = (choice: PromptChoice, pick?: string) => { + if (busy) return + setBusy(true) + void invoke('permissions.answer', { id: prompt.id, choice, pick }).finally(() => setBusy(false)) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + answer('dismiss') + return + } + const step = + e.key === 'ArrowDown' || e.key === 'ArrowRight' ? 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 0 + if (!step || !card.current) return + // Inside a list, arrows pick the next item (the list's own handler); elsewhere they move between buttons. + if ((e.target as HTMLElement).closest('[role="listbox"]')) return + const buttons = [...card.current.querySelectorAll<HTMLButtonElement>('button:not(:disabled):not([role="option"])')] + if (buttons.length === 0) return + e.preventDefault() + const at = buttons.indexOf(document.activeElement as HTMLButtonElement) + const next = at < 0 ? (step > 0 ? 0 : buttons.length - 1) : (at + step + buttons.length) % buttons.length + buttons[next]?.focus() + } + + const site = siteLabel(prompt.origin) + return ( + <div + ref={card} + className="perm-prompt" + role="alertdialog" + aria-label={`${site} permission request`} + data-testid="permission-prompt" + data-type={prompt.type} + data-kinds={prompt.type === 'permission' ? prompt.kinds.join(' ') : undefined} + onKeyDown={onKeyDown} + > + <div className="perm-prompt__head"> + <p className="perm-prompt__title"> + <span className="perm-prompt__site" data-testid="permission-site"> + {site} + </span>{' '} + {title(prompt)} + </p> + <button + type="button" + className="perm-prompt__close" + aria-label="Close" + title="Close" + data-testid="permission-dismiss" + onClick={() => answer('dismiss')} + > + <X size={13} /> + </button> + </div> + {prompt.type === 'permission' && <PermissionBody prompt={prompt} busy={busy} answer={answer} />} + {prompt.type === 'device' && <DeviceBody prompt={prompt} busy={busy} answer={answer} />} + {prompt.type === 'screen' && <ScreenBody prompt={prompt} busy={busy} answer={answer} />} + {prompt.type === 'file' && ( + <Actions> + <Choice testId="permission-block" disabled={busy} onClick={() => answer('block')}> + Block + </Choice> + <Choice testId="permission-allow" primary disabled={busy} onClick={() => answer('allow')}> + Allow + </Choice> + </Actions> + )} + </div> + ) +} + +function title(prompt: Prompt): string { + switch (prompt.type) { + case 'permission': + return 'wants to' + case 'device': + return `wants to ${DEVICE_ASK[prompt.device].toLowerCase()}` + case 'screen': + return 'wants to share your screen' + case 'file': { + const name = prompt.name ? `“${prompt.name}”` : prompt.directory ? 'a folder' : 'a file' + if (prompt.write) return `wants to save changes to ${prompt.directory ? `files in ${name}` : name}` + return `wants to see ${prompt.directory ? `the files in ${name}` : name}` + } + } +} + +type Answer = (choice: PromptChoice, pick?: string) => void + +function Actions({ children, stacked = false }: { children: ReactNode; stacked?: boolean }) { + return <div className={`perm-prompt__actions${stacked ? ' is-stacked' : ''}`}>{children}</div> +} + +function Choice({ + children, + onClick, + primary = false, + disabled = false, + testId, +}: { + children: ReactNode + onClick: () => void + primary?: boolean + disabled?: boolean + testId: string +}) { + return ( + <button + type="button" + className={`perm-prompt__choice${primary ? ' is-primary' : ''}`} + disabled={disabled} + data-testid={testId} + onClick={onClick} + > + {children} + </button> + ) +} + +function PermissionBody({ + prompt, + busy, + answer, +}: { + prompt: Extract<Prompt, { type: 'permission' }> + busy: boolean + answer: Answer +}) { + return ( + <> + <ul className="perm-prompt__list"> + {prompt.kinds.map((kind) => { + const Icon = PERMISSION_ICON[kind] + return ( + <li key={kind} data-kind={kind}> + <Icon size={14} aria-hidden /> + <span>{PERMISSION_ASK[kind]}</span> + </li> + ) + })} + </ul> + {prompt.oneTime ? ( + <Actions stacked> + <Choice testId="permission-allow" disabled={busy} onClick={() => answer('allow')}> + Allow while visiting the site + </Choice> + <Choice testId="permission-once" disabled={busy} onClick={() => answer('once')}> + Allow this time + </Choice> + <Choice testId="permission-block" disabled={busy} onClick={() => answer('block')}> + Never allow + </Choice> + </Actions> + ) : ( + <Actions> + <Choice testId="permission-block" disabled={busy} onClick={() => answer('block')}> + Block + </Choice> + <Choice testId="permission-allow" primary disabled={busy} onClick={() => answer('allow')}> + Allow + </Choice> + </Actions> + )} + </> + ) +} + +/** A list to pick one item from: arrows move the pick (and focus), Enter takes it. */ +function PickList({ + label, + items, + picked, + onPick, + onEnter, + empty, + testId, +}: { + label: string + items: { id: string; name: string; icon?: ReactNode; preview?: string }[] + picked: string | undefined + onPick: (id: string) => void + onEnter: () => void + empty: string + testId: string +}) { + const list = useRef<HTMLDivElement>(null) + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && picked) { + e.preventDefault() + onEnter() + return + } + const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0 + if (!step || items.length === 0) return + e.preventDefault() + const at = items.findIndex((i) => i.id === picked) + const next = at < 0 ? 0 : Math.min(items.length - 1, Math.max(0, at + step)) + const item = items[next] + if (!item) return + onPick(item.id) + list.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')[next]?.focus() + } + if (items.length === 0) { + return ( + <p className="perm-prompt__empty" data-testid={`${testId}-empty`}> + {empty} + </p> + ) + } + const focusable = items.some((i) => i.id === picked) ? picked : items[0]?.id + return ( + <div + ref={list} + className="perm-prompt__picks" + role="listbox" + aria-label={label} + data-testid={testId} + onKeyDown={onKeyDown} + > + {items.map((item) => ( + <button + key={item.id} + type="button" + role="option" + aria-selected={item.id === picked} + tabIndex={item.id === focusable ? 0 : -1} + className={`perm-prompt__pick${item.id === picked ? ' is-picked' : ''}`} + data-testid={`${testId}-item`} + onClick={() => onPick(item.id)} + onDoubleClick={() => { + onPick(item.id) + onEnter() + }} + > + {item.preview !== undefined ? ( + <span + className="perm-prompt__preview" + style={item.preview ? { backgroundImage: `url(${item.preview})` } : undefined} + /> + ) : ( + item.icon + )} + <span className="perm-prompt__pick-name">{item.name}</span> + </button> + ))} + </div> + ) +} + +function DeviceBody({ + prompt, + busy, + answer, +}: { + prompt: Extract<Prompt, { type: 'device' }> + busy: boolean + answer: Answer +}) { + const [picked, setPicked] = useState<string | undefined>(undefined) + // A device that went away can't be picked. + const current = prompt.devices.some((d) => d.id === picked) ? picked : undefined + const Icon = DEVICE_ICON[prompt.device] + return ( + <> + <PickList + label="Devices" + items={prompt.devices.map((d) => ({ id: d.id, name: d.name, icon: <Icon size={14} aria-hidden /> }))} + picked={current} + onPick={setPicked} + onEnter={() => current && answer('allow', current)} + empty="No compatible devices found." + testId="permission-devices" + /> + <Actions> + <Choice testId="permission-block" disabled={busy} onClick={() => answer('dismiss')}> + Cancel + </Choice> + <Choice testId="permission-allow" primary disabled={busy || !current} onClick={() => answer('allow', current)}> + Connect + </Choice> + </Actions> + </> + ) +} + +function ScreenBody({ + prompt, + busy, + answer, +}: { + prompt: Extract<Prompt, { type: 'screen' }> + busy: boolean + answer: Answer +}) { + const [picked, setPicked] = useState<string | undefined>(prompt.sources.find((s) => s.screen)?.id) + const [previews, setPreviews] = useState<Record<string, string>>({}) + useEffect(() => { + let alive = true + void invoke('permissions.thumbnails', { id: prompt.id }).then((p) => { + if (alive) setPreviews(p) + }) + return () => { + alive = false + } + }, [prompt.id]) + const items = prompt.sources.map((s) => ({ + id: s.id, + name: s.screen && prompt.sources.filter((x) => x.screen).length === 1 ? 'Entire screen' : s.name, + preview: previews[s.id] ?? '', + })) + return ( + <> + <PickList + label="Screens and windows" + items={items} + picked={picked} + onPick={setPicked} + onEnter={() => picked && answer('allow', picked)} + empty="Nothing to share." + testId="permission-sources" + /> + <Actions> + <Choice testId="permission-block" disabled={busy} onClick={() => answer('dismiss')}> + Cancel + </Choice> + <Choice testId="permission-allow" primary disabled={busy || !picked} onClick={() => answer('allow', picked)}> + Share + </Choice> + </Actions> + </> + ) +} diff --git a/src/renderer/src/shell/chrome/PromptViewApp.tsx b/src/renderer/src/shell/chrome/PromptViewApp.tsx new file mode 100644 index 00000000..ec39e51e --- /dev/null +++ b/src/renderer/src/shell/chrome/PromptViewApp.tsx @@ -0,0 +1,29 @@ +import { type CSSProperties, useCallback, useRef } from 'react' +import { invoke } from '../../api' +import { useWindow } from '../../store' +import { paletteVars, usePalette } from '../../theme' +import { PermissionPrompt, useActivePrompt } from './PermissionPrompt' +import './chrome.css' + +/** + * The permission prompt while the sidebar is hidden (NV-15): a small transparent view main keeps + * over the active page's top-left corner. It holds the active tab's prompt and tells main how tall + * the card is, so the view fits it. + */ +export function PromptViewApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const prompt = useActivePrompt(window.orbit.windowId) + const said = useRef(0) + const onSize = useCallback((height: number) => { + const rounded = Math.ceil(height) + if (rounded <= 0 || rounded === said.current) return + said.current = rounded + void invoke('permissions.promptSize', { height: rounded }) + }, []) + return ( + <div className="prompt-view" style={paletteVars(palette) as CSSProperties} data-testid="prompt-view"> + {prompt && <PermissionPrompt key={prompt.id} prompt={prompt} onSize={onSize} />} + </div> + ) +} diff --git a/src/renderer/src/shell/chrome/chrome.css b/src/renderer/src/shell/chrome/chrome.css index 23379360..bfcdf187 100644 --- a/src/renderer/src/shell/chrome/chrome.css +++ b/src/renderer/src/shell/chrome/chrome.css @@ -125,15 +125,23 @@ } } -/* Insecure pages: a clear warning instead of the site-controls glyph (no padlock on https). */ -.address--insecure .address__site { +/* Insecure pages: a clear warning before the host (no padlock on https). */ +.address__warning, +.address__warning:hover { color: #c9372c; } -.shell[data-dark] .address--insecure .address__site { +.shell[data-dark] .address__warning, +.shell[data-dark] .address__warning:hover { color: #ff8a7d; } +/* The site controls button while its panel is open (NV-16). */ +.address__site.is-open { + background: var(--hover); + color: var(--text); +} + /* ---------- Hidden sidebar: the left edge and the floating card (SB-12, FL-03) ---------- */ /* The window's left edge while the sidebar is hidden: the page card's margin there. */ diff --git a/src/renderer/src/shell/chrome/prompt.css b/src/renderer/src/shell/chrome/prompt.css new file mode 100644 index 00000000..3863a709 --- /dev/null +++ b/src/renderer/src/shell/chrome/prompt.css @@ -0,0 +1,254 @@ +/* + * The permission prompt (NV-15): a card under the address display at the top of the sidebar, or + * in its own transparent view (view=prompt) over the page's top-left corner while the sidebar is + * hidden. It takes the Space's colours; text wraps to the sidebar's width. + */ + +/* Under the address display: over the sidebar's content, as wide as the address. */ +.topbar.has-prompt { + position: relative; + z-index: 40; +} + +.perm-prompt-anchor { + position: absolute; + top: calc(100% - 2px); + left: 0; + right: 0; + -webkit-app-region: no-drag; +} + +.perm-prompt { + --prompt-line: color-mix(in srgb, var(--text, #1c1c1f) 10%, transparent); + --prompt-fill: color-mix(in srgb, var(--text, #1c1c1f) 6%, transparent); + --prompt-fill-hover: color-mix(in srgb, var(--text, #1c1c1f) 11%, transparent); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 10px; + max-height: calc(100vh - 96px); + padding: 12px; + border-radius: 12px; + overflow: auto; + background: var(--surface, #fff); + color: var(--text, #1c1c1f); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.16), + 0 10px 28px rgba(0, 0, 0, 0.2), + 0 2px 6px rgba(0, 0, 0, 0.08); + font-size: 13px; + line-height: 1.35; + cursor: default; + user-select: none; + -webkit-app-region: no-drag; + animation: perm-prompt-in 150ms var(--ease-out, ease-out); + transform-origin: 50% 0; +} + +@keyframes perm-prompt-in { + from { + opacity: 0; + transform: scale(0.97) translateY(-3px); + } +} + +.perm-prompt__head { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.perm-prompt__title { + flex: 1; + min-width: 0; + margin: 0; + font-weight: 500; + overflow-wrap: anywhere; +} + +.perm-prompt__site { + font-weight: 700; +} + +.perm-prompt__close { + flex: none; + display: grid; + place-items: center; + width: 22px; + height: 22px; + margin: -3px -4px 0 0; + border-radius: 6px; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); +} + +.perm-prompt__close:hover { + background: var(--prompt-fill); + color: inherit; +} + +.perm-prompt__list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.perm-prompt__list li { + display: flex; + align-items: flex-start; + gap: 8px; + overflow-wrap: anywhere; +} + +.perm-prompt__list svg { + flex: none; + margin-top: 1px; + opacity: 0.75; +} + +.perm-prompt__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.perm-prompt__actions.is-stacked { + flex-direction: column; + align-items: stretch; +} + +.perm-prompt__choice { + flex: 1 1 auto; + min-height: 30px; + padding: 5px 12px; + border-radius: 8px; + background: var(--prompt-fill); + color: inherit; + font-weight: 600; + text-align: center; + overflow-wrap: anywhere; + transition: + background var(--dur-fast, 120ms) ease, + transform 240ms cubic-bezier(0.3, 1.3, 0.6, 1); +} + +.perm-prompt__actions:not(.is-stacked) .perm-prompt__choice { + flex: 0 1 auto; +} + +.perm-prompt__choice:hover:not(:disabled) { + background: var(--prompt-fill-hover); +} + +.perm-prompt__choice:active:not(:disabled) { + transform: scale(0.97); + transition-duration: var(--dur-fast, 120ms), 60ms; +} + +.perm-prompt__choice.is-primary { + background: var(--accent, #5865d8); + color: #fff; +} + +.perm-prompt__choice.is-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent, #5865d8) 88%, #000); +} + +.perm-prompt__choice:disabled { + opacity: 0.5; +} + +.perm-prompt button:focus-visible, +.perm-prompt [role="option"]:focus-visible { + outline: 2px solid var(--accent, #5865d8); + outline-offset: 1px; +} + +/* A device or screen to pick. */ +.perm-prompt__picks { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow: auto; + padding: 3px; + border-radius: 9px; + box-shadow: inset 0 0 0 1px var(--prompt-line); +} + +.perm-prompt__pick { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 4px 8px; + border-radius: 6px; + text-align: left; +} + +.perm-prompt__pick:hover { + background: var(--prompt-fill); +} + +.perm-prompt__pick.is-picked { + background: color-mix(in srgb, var(--accent, #5865d8) 18%, transparent); +} + +.perm-prompt__pick svg { + flex: none; + opacity: 0.75; +} + +.perm-prompt__pick-name { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.perm-prompt__preview { + flex: none; + width: 56px; + height: 35px; + border-radius: 4px; + background: var(--prompt-fill) center / contain no-repeat; +} + +.perm-prompt__empty { + margin: 0; + padding: 10px; + border-radius: 9px; + box-shadow: inset 0 0 0 1px var(--prompt-line); + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + text-align: center; +} + +/* The prompt's own view while the sidebar is hidden (PROMPT_VIEW: a 12 px margin for the shadow). */ +html[data-view="prompt"] body { + background: transparent; + overflow: hidden; +} + +.prompt-view { + position: fixed; + top: 12px; + left: 12px; + right: 12px; +} + +.prompt-view .perm-prompt { + max-height: none; + transform-origin: 0 0; +} + +@media (prefers-reduced-motion: reduce) { + .perm-prompt { + animation: none; + } + .perm-prompt__choice { + transition: none; + } +} diff --git a/src/renderer/src/shell/sidebar/TopBar.tsx b/src/renderer/src/shell/sidebar/TopBar.tsx index e0031086..d779fe88 100644 --- a/src/renderer/src/shell/sidebar/TopBar.tsx +++ b/src/renderer/src/shell/sidebar/TopBar.tsx @@ -3,6 +3,7 @@ import type { WindowState } from '../../../../shared/state' import { isMac, runAction } from '../../api' import { Address } from '../chrome/Address' import { NavButtons } from '../chrome/NavButtons' +import { PermissionPrompt, useActivePrompt } from '../chrome/PermissionPrompt' import { withShortcut } from '../chrome/shortcut-label' import { WindowControls } from '../chrome/WindowControls' import '../chrome/chrome.css' @@ -11,10 +12,12 @@ import '../chrome/chrome.css' * The top of the sidebar (SB-03, SB-04): window controls (traffic lights on macOS), the sidebar * toggle, Back, Forward and Reload, then the address display. At narrow widths the toggle goes * first; Back and Forward stay. In the floating card (SB-12) the toggle docks the sidebar again. + * A site's permission prompt hangs under the address display (NV-15). */ export function TopBar({ win, floating = false }: { win: WindowState; floating?: boolean }) { + const prompt = useActivePrompt(win.id) return ( - <div className={`topbar${isMac ? ' topbar--mac' : ''}`} data-testid="topbar"> + <div className={`topbar${isMac ? ' topbar--mac' : ''}${prompt ? ' has-prompt' : ''}`} data-testid="topbar"> <div className="topbar__row"> {isMac ? <div className="topbar__traffic-space" data-testid="traffic-space" /> : <WindowControls />} <div className="topbar__spacer" /> @@ -31,6 +34,11 @@ export function TopBar({ win, floating = false }: { win: WindowState; floating?: <NavButtons windowId={win.id} /> </div> <Address windowId={win.id} /> + {prompt && ( + <div className="perm-prompt-anchor"> + <PermissionPrompt key={prompt.id} prompt={prompt} /> + </div> + )} </div> ) } diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index 3cf544f8..d2d32bb5 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -1,4 +1,5 @@ import type { FindState, ZoomIndicator } from '../nav' +import type { PermissionKind, PromptChoice, SiteControlsInfo } from '../site' import type { TabId } from '../state' /** One entry of a tab's back/forward history, as the history menu lists it (NV-01). */ @@ -74,4 +75,22 @@ export interface NavCommands { 'nav.blockedPopupsMenu': { args: { x: number; y: number }; result: void } /** The active tab's page zoom as a percentage, and whether it differs from the default (NV-06, NV-16). */ 'zoom.get': { args: { tabId?: TabId }; result: { percent: number } } + /** Answers a permission prompt (NV-15); `pick` is the device or screen chosen in a chooser. */ + 'permissions.answer': { args: { id: number; choice: PromptChoice; pick?: string }; result: void } + /** A screen picker's previews: data URLs by source id. */ + 'permissions.thumbnails': { args: { id: number }; result: Record<string, string> } + /** The prompt view's card height, so its view fits it (sidebar hidden). */ + 'permissions.promptSize': { args: { height: number }; result: void } + /** Opens the site controls panel for the active tab, under a point in window coordinates (NV-16). */ + 'site.openControls': { args: { x?: number; y?: number }; result: void } + /** What the site controls panel shows for a tab (default: the active one). */ + 'site.info': { args: { tabId?: TabId }; result: SiteControlsInfo | null } + /** A switch in the site controls panel: allow or block a permission, or pop-ups, on the tab's site. */ + 'site.setPermission': { args: { tabId: TabId; kind: PermissionKind; allowed: boolean }; result: void } + /** Forgets every permission choice of the tab's site. */ + 'site.resetPermissions': { args: { tabId: TabId }; result: void } + /** Stops letting the tab's site use a device it was given. */ + 'site.revokeDevice': { args: { tabId: TabId; key: string }; result: void } + /** Deletes the tab's site's cookies and stored data. */ + 'site.clearData': { args: { tabId: TabId }; result: void } } diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index d03cb6ee..6b942615 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -61,7 +61,7 @@ export interface Rect { } /** Which UI a renderer instance is showing. */ -export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' | 'sidebar' | 'tools' +export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' | 'sidebar' | 'tools' | 'prompt' /** 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 new file mode 100644 index 00000000..316fc5ac --- /dev/null +++ b/src/shared/site.ts @@ -0,0 +1,268 @@ +/** + * What sites may ask for and what Orbit shows about a site (NV-15, NV-16): the permission prompt + * under the address display and the site controls panel. Shared by main and the renderers. + */ +import type { Connection } from './nav' +import type { TabId } from './state' + +/** Permissions a site asks for. Camera and microphone are separate choices, as in Chromium. */ +export type PermissionKind = + | 'camera' + | 'microphone' + | 'location' + | 'notifications' + | 'clipboard' + | 'midi' + | 'idle' + | 'windows' + | 'fonts' + | 'network' + | 'popups' + +/** The user's lasting choice for a permission on a site. */ +export type PermissionChoice = 'allow' | 'block' + +/** Devices a site connects to through a chooser (Web Serial, WebUSB, WebHID). */ +export type DeviceKind = 'serial' | 'usb' | 'hid' + +/** The order permissions are listed in, in the prompt and the panel. */ +export const PERMISSION_ORDER: readonly PermissionKind[] = [ + 'camera', + 'microphone', + 'location', + 'notifications', + 'clipboard', + 'midi', + 'idle', + 'windows', + 'fonts', + 'network', + 'popups', +] + +/** The prompt's line for each permission: "<site> wants to" … (NV-15). */ +export const PERMISSION_ASK: Record<PermissionKind, string> = { + camera: 'Use your camera', + microphone: 'Use your microphone', + location: 'Know your location', + notifications: 'Show notifications', + clipboard: 'See text and images copied to the clipboard', + midi: 'Use your MIDI devices', + idle: 'Know when you’re actively using this device', + windows: 'Manage windows on all your displays', + fonts: 'Use the fonts on your computer', + network: 'Look for and connect to any device on your local network', + popups: 'Open pop-ups', +} + +/** The site controls panel's name for each permission (NV-16). */ +export const PERMISSION_NAME: Record<PermissionKind, string> = { + camera: 'Camera', + microphone: 'Microphone', + location: 'Location', + notifications: 'Notifications', + clipboard: 'Clipboard', + midi: 'MIDI devices', + idle: 'Device use', + windows: 'Window management', + fonts: 'Fonts', + network: 'Local network access', + popups: 'Pop-ups', +} + +/** What a device chooser asks: "<site> wants to" … */ +export const DEVICE_ASK: Record<DeviceKind, string> = { + serial: 'Connect to a serial port', + usb: 'Connect to a USB device', + hid: 'Connect to a HID device', +} + +export const DEVICE_NAME: Record<DeviceKind, string> = { + serial: 'Serial port', + usb: 'USB device', + hid: 'HID device', +} + +/** Permissions whose prompt offers "Allow this time", as Chromium's does. */ +export const ONE_TIME_KINDS: ReadonlySet<PermissionKind> = new Set(['camera', 'microphone', 'location']) + +export function isPermissionKind(value: unknown): value is PermissionKind { + return typeof value === 'string' && (PERMISSION_ORDER as readonly string[]).includes(value) +} + +/** + * The permissions an engine permission request or check is about, or null for one Orbit doesn't + * ask the user about. `media` names the camera and/or the microphone; when the engine doesn't say + * which, it is both. + */ +export function kindsOf(permission: string, mediaTypes?: readonly string[]): PermissionKind[] | null { + switch (permission) { + case 'media': { + const known = (mediaTypes ?? []).filter((t) => t === 'video' || t === 'audio') + if (known.length === 0) return ['camera', 'microphone'] + const kinds: PermissionKind[] = [] + if (known.includes('video')) kinds.push('camera') + if (known.includes('audio')) kinds.push('microphone') + return kinds + } + case 'geolocation': + case 'geolocation-approximate': + return ['location'] + case 'notifications': + return ['notifications'] + case 'clipboard-read': + return ['clipboard'] + case 'midi': + case 'midiSysex': + return ['midi'] + case 'idle-detection': + return ['idle'] + case 'window-management': + return ['windows'] + case 'local-fonts': + return ['fonts'] + case 'local-network-access': + case 'local-network': + case 'loopback-network': + return ['network'] + default: + return null + } +} + +/** Sorts permissions into PERMISSION_ORDER, without repeats. */ +export function sortKinds(kinds: Iterable<PermissionKind>): PermissionKind[] { + const set = new Set(kinds) + return PERMISSION_ORDER.filter((k) => set.has(k)) +} + +/** + * The origin a permission belongs to: the page's own (the top frame's), as in Chromium, where a + * frame inside the page uses what the page was given. + */ +export function permissionOrigin(url: string | undefined): string | null { + if (!url) return null + try { + const u = new URL(url) + if (u.protocol === 'file:') return 'file://' + const origin = u.origin + return origin && origin !== 'null' ? origin : null + } catch { + return null + } +} + +/** How a prompt names a site: its host (with the port), "This file" for local files. */ +export function siteLabel(origin: string): string { + if (origin === 'file://') return 'This file' + try { + return new URL(origin).host || origin + } catch { + return origin + } +} + +/** A device as the engine describes it (serial port, USB or HID device). */ +export interface EngineDevice { + deviceId?: string + portId?: string + portName?: string + displayName?: string + name?: string + productName?: string + manufacturerName?: string + vendorId?: number | string + productId?: number | string + serialNumber?: string +} + +/** A device's lasting identity for a site's grant: vendor, product and serial number where known. */ +export function deviceKey(type: DeviceKind, device: EngineDevice): string { + const id = (v: number | string | undefined) => (v === undefined || v === '' ? '' : String(v)) + if (device.vendorId !== undefined || device.productId !== undefined) { + return `${type}:${id(device.vendorId)}:${id(device.productId)}:${device.serialNumber ?? ''}` + } + return `${type}:port:${device.portName ?? device.portId ?? device.deviceId ?? ''}` +} + +/** A device's name for people: the product's name, else the port's, else its kind. */ +export function deviceLabel(type: DeviceKind, device: EngineDevice): string { + return device.displayName || device.productName || device.name || device.portName || DEVICE_NAME[type] +} + +export interface DeviceOption { + id: string + name: string +} + +export interface ScreenSource { + id: string + name: string + /** A whole screen (else a window). */ + screen: boolean +} + +/** A permission prompt on a tab (NV-15): the first of its pending requests. */ +export type PermissionPrompt = + | { + id: number + type: 'permission' + origin: string + kinds: PermissionKind[] + /** Offers "Allow this time" (camera, microphone, location). */ + oneTime: boolean + } + | { id: number; type: 'device'; origin: string; device: DeviceKind; devices: DeviceOption[] } + | { id: number; type: 'screen'; origin: string; sources: ScreenSource[] } + | { + id: number + type: 'file' + origin: string + /** The file's (or folder's) name. */ + name: string + directory: boolean + /** Saving changes to it (else seeing it). */ + write: boolean + } + +/** + * How the user answered a prompt: allow (and remember), allow this time, block (and remember), or + * dismiss (no answer: asked again next time). A chooser's allow comes with the device or screen picked. + */ +export type PromptChoice = 'allow' | 'once' | 'block' | 'dismiss' + +/** A row of the site controls panel's permissions. */ +export interface SitePermissionRow { + kind: PermissionKind + /** allow / block: the user's choice; once: allowed this time; ask: asked, no choice yet. */ + state: 'allow' | 'block' | 'once' | 'ask' +} + +/** A device the site may use again. */ +export interface SiteDeviceRow { + key: string + type: DeviceKind + name: string +} + +/** What the site controls panel shows for the active tab's site (NV-16). */ +export interface SiteControlsInfo { + tabId: TabId + url: string + /** The host (with the port), or the address for pages that have none. */ + host: string + connection: Connection + /** A web page (http or https): permissions, pop-ups and site data apply. */ + web: boolean + /** The tab is in a private window: choices last until the last private window closes. */ + private: boolean + permissions: SitePermissionRow[] + devices: SiteDeviceRow[] + /** Page zoom in percent (NV-06). */ + zoom: number + /** A change the page only sees once it reloads. */ + reload: boolean +} + +/** The site controls panel's size and its gap from what it is anchored to. */ +export const SITE_PANEL = { width: 300, gap: 6, margin: 8 } diff --git a/src/shared/state.ts b/src/shared/state.ts index 8cce32ec..d5de8fbe 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -7,6 +7,8 @@ * s- space, p- profile, w- window. Sidebar containers hold ordered ItemId lists. */ +import type { PermissionPrompt } from './site' + export type TabId = string export type FolderId = string export type SplitId = string @@ -65,6 +67,8 @@ export interface Tab { error?: { code: number; description: string } /** Runtime-only: pop-ups the page tried to open without a click since it loaded (NV-09). */ blockedPopups?: number + /** Runtime-only: the permission prompt waiting for an answer on this tab (NV-15). */ + permissionPrompt?: PermissionPrompt } export interface Folder { From 565745758038465256d228871c5fc8b637692a10 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:25:17 +0000 Subject: [PATCH 10/69] nav: one modifier tracker with Peek; private windows keep their links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Peek's HeldModifiers becomes the app's one tracker (heldModifiers() in peek/input.ts). ⌥-click split view reads it too, and also the mouse event's own modifiers: only ⌥ alone splits, so ⌘⌥ / Ctrl+Alt stays the Mini Window click (MW-07). - Private windows (NV-11): Peek's link hook and page navigation handling skip private tabs, the link menu has no Peek or Mini Window items there, and mini.fromTab refuses a private tab. A Mini Window is a normal-profile window, so it would carry the private page's link out of the private session. - Link menu in the order NV-12 lists: New Tab, Split View, Peek, Mini Window, New Window, Private Window, Copy, Save. - e2e: ⇧-click with Peek's Shift-click setting off opens a new window and no Peek; ⌥-click and ⌘⌥-click on page links and sidebar rows don't take each other's click; a private window keeps its links and links from other apps never open in it (NV-18). The nav fixture server takes any free port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/miniwindow/index.ts | 4 +- src/main/nav/links.ts | 41 +++++++------------ src/main/peek/index.ts | 15 +++---- src/main/peek/input.ts | 19 ++++++++- src/main/peek/menu.ts | 3 ++ src/main/tabs/context-menu.ts | 3 +- tests/e2e/nav-links.spec.ts | 55 ++++++++++++++++++++++++- tests/e2e/nav-support.ts | 9 ++-- tests/e2e/nav-windows.spec.ts | 77 +++++++++++++++++++++++++++++++++++ 9 files changed, 181 insertions(+), 45 deletions(-) diff --git a/src/main/miniwindow/index.ts b/src/main/miniwindow/index.ts index 8ff2fe2d..98a2e572 100644 --- a/src/main/miniwindow/index.ts +++ b/src/main/miniwindow/index.ts @@ -12,6 +12,7 @@ import { toElectronAccelerator } from '../../shared/actions' import { createWindowState } from '../../shared/defaults' import { newId } from '../../shared/ids' import { routeFor } from '../../shared/link-routing' +import { isPrivateTab } from '../../shared/private' import type { Rect } from '../../shared/rpc' import type { OrbitState, ProfileId, SpaceId, TabId, WindowId } from '../../shared/state' import { NEUTRAL_THEME, paletteFor } from '../../shared/theme' @@ -489,7 +490,8 @@ export function registerMiniWindow(ctx: MainContext): void { const fromTab = (tabId: TabId): WindowId | undefined => { const state = store.state const tab = state.tabs[tabId] - if (!tab || tab.floating) return undefined + // A private window's tab stays in private windows: a Mini Window is a normal-profile window (NV-11). + if (!tab || tab.floating || isPrivateTab(state, tabId)) return undefined const section = sectionOf(state, tabId) if (section === 'today') return open(undefined, { adopt: tabId }) const url = tabs.contents(tabId)?.getURL() || tab.url diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index df837b51..d3d23e77 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -1,8 +1,9 @@ -import { app, type BrowserWindow, Menu, type MenuItemConstructorOptions, type WebContents, webContents } from 'electron' +import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type WebContents } from 'electron' import { popupTitle, shownTabId } from '../../shared/nav' import type { TabId, WindowId } from '../../shared/state' import { sectionOf } from '../../shared/tree' import type { MainContext } from '../context' +import { heldModifiers } from '../peek/input' import type { TabOps } from '../tabs/ops' import { activeTabOf, shownTabs } from '../windows' import { NAV_WORLD } from './focus' @@ -92,31 +93,18 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks const { store, commands, tabs, windows } = ctx const { ops, prefs } = deps - // ---- Modifier keys: mouse events don't carry them, key events in any Orbit contents do ---- - const held = { alt: false, meta: false, control: false, shift: false } - const track = (input: Electron.Input) => { - const down = input.type !== 'keyUp' - if (input.key === 'Alt') held.alt = down - else if (input.key === 'Meta') held.meta = down - else if (input.key === 'Control') held.control = down - else if (input.key === 'Shift') held.shift = down - else { - held.alt = input.alt - held.meta = input.meta - held.control = input.control - held.shift = input.shift - } + // ---- Modifier keys: the app's one tracker (Peek's), from the key events of every contents ---- + const held = heldModifiers() + /** Only ⌥ (Alt) held: ⌘⌥ / Ctrl+Alt is a Mini Window click (MW-07), ⇧⌥ and ⌘-clicks are not ours. */ + const altOnly = (mouse: Electron.MouseInputEvent) => { + const mods = new Set<string>(mouse.modifiers ?? []) + const keys = held.get() + const alt = keys.alt || mods.has('alt') + const meta = keys.meta || mods.has('meta') || mods.has('command') || mods.has('cmd') + const control = keys.control || mods.has('control') || mods.has('ctrl') + const shift = keys.shift || mods.has('shift') + return alt && !meta && !control && !shift } - const watchKeys = (contents: WebContents) => contents.on('before-input-event', (_e, input) => track(input)) - for (const contents of webContents.getAllWebContents()) watchKeys(contents) - app.on('web-contents-created', (_event, contents) => watchKeys(contents)) - // A key released while another app had focus is never seen: start over on every focus change. - app.on('browser-window-blur', () => { - held.alt = false - held.meta = false - held.control = false - held.shift = false - }) // ---- ⇧-click: a new window (NV-08). Peek's own hook comes first and takes it when it's on. ---- ctx.hooks.windowOpen.push((openerTabId, details) => { @@ -166,8 +154,7 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks else hovered.delete(tabId) }) contents.on('before-mouse-event', (_e, mouse) => { - if (mouse.type !== 'mouseDown' || mouse.button !== 'left') return - if (!held.alt || held.meta || held.control || held.shift) return + if (mouse.type !== 'mouseDown' || mouse.button !== 'left' || !altOnly(mouse)) return void onAltClick(tabId, contents) }) contents.on('did-create-window', (popup, details) => keepHostTitle(popup, details.url)) diff --git a/src/main/peek/index.ts b/src/main/peek/index.ts index 484ac47f..39c3f991 100644 --- a/src/main/peek/index.ts +++ b/src/main/peek/index.ts @@ -6,9 +6,10 @@ import type { WebContentsWillNavigateEventParams, WindowOpenHandlerResponse, } from 'electron' -import { app, WebContentsView } from 'electron' +import { WebContentsView } from 'electron' import { decideLink, type LinkHow, type LinkSettings, type LinkSource, routeFor } from '../../shared/link-routing' import { PEEK_RADIUS, peekCard, peekStart } from '../../shared/peek-geometry' +import { isPrivateTab } from '../../shared/private' import type { Rect } from '../../shared/rpc' import { layoutLeaves, MAX_PANES, SPLIT_FULL_MESSAGE } from '../../shared/split' import type { OrbitState, SpaceId, Tab, TabId, WindowId } from '../../shared/state' @@ -20,7 +21,7 @@ import { activateInDraft, todayInsertIndex } from '../tabs/model' import type { SavedHistory } from '../tabs/runtime' import { activeTabOf } from '../windows' import { archiveFloating, floatingTab, keepFloatingPagesOutOfTabClose } from './floating' -import { ClickTracker, HeldModifiers, isMiniClick, PAGE_WORLD, type PageClick } from './input' +import { ClickTracker, heldModifiers, isMiniClick, PAGE_WORLD, type PageClick } from './input' import { runPageAction } from './page-actions' /** A Peek card open in a window: a floating tab whose view sits above the overlay, in the card. */ @@ -75,10 +76,8 @@ export function registerPeek(ctx: MainContext): void { const { store, commands, actions, windows, tabs } = ctx const sessions = new Map<WindowId, Session>() const closed: ClosedPeek[] = [] - const held = new HeldModifiers() - const clicks = new ClickTracker(held) - app.on('web-contents-created', (_event, contents) => held.watch(contents)) - app.on('browser-window-blur', () => held.reset()) + // One tracker for the app: ⌥-click split view reads the same keys (nav lane). + const clicks = new ClickTracker(heldModifiers()) /** When a sidebar tab last closed: ⌘⇧T reopens a Peek only if it closed after that. */ let lastTabCloseAt = 0 let undo: { windowId: WindowId; until: number } | undefined @@ -584,7 +583,9 @@ export function registerPeek(ctx: MainContext): void { const sourceOf = (tabId: TabId, contents: WebContents): LinkSource | undefined => { const tab = store.state.tabs[tabId] - if (!tab) return undefined + // A private window's links stay in private windows: no Peek there, and a Mini Window would + // carry them into a normal profile (NV-11). The usual new-tab and new-window handling applies. + if (!tab || isPrivateTab(store.state, tabId)) return undefined const url = contents.getURL() || tab.url if (tab.floating === 'peek') return { kind: 'peek', url } if (tab.floating === 'mini') return { kind: 'mini', url } diff --git a/src/main/peek/input.ts b/src/main/peek/input.ts index 18d2dee7..3b8a082e 100644 --- a/src/main/peek/input.ts +++ b/src/main/peek/input.ts @@ -1,4 +1,4 @@ -import type { InputEvent, WebContents } from 'electron' +import { app, type InputEvent, type WebContents, webContents } from 'electron' /** The last click (or Enter press) in a page: when, where, on which link, with which modifiers. */ export interface PageClick { @@ -63,6 +63,23 @@ export class HeldModifiers { } } +let shared: HeldModifiers | undefined + +/** + * The app's one modifier tracker, watching every web contents from the first call on. Peek and + * Mini Window clicks (⇧, ⌘⌥) and ⌥-click split view (nav) read the same keys. + */ +export function heldModifiers(): HeldModifiers { + if (shared) return shared + const held = new HeldModifiers() + shared = held + for (const contents of webContents.getAllWebContents()) held.watch(contents) + app.on('web-contents-created', (_event, contents) => held.watch(contents)) + // A key released while another app had focus is never seen: start over. + app.on('browser-window-blur', () => held.reset()) + return held +} + /** The link (a or area) at a point of the page, in CSS pixels: its URL, or null. */ const linkAt = (x: number, y: number) => `(() => { const a = document.elementFromPoint(${x}, ${y})?.closest('a[href], area[href]'); return a && typeof a.href === 'string' ? a.href : null })()` diff --git a/src/main/peek/menu.ts b/src/main/peek/menu.ts index 7aff0e77..39fbc9e3 100644 --- a/src/main/peek/menu.ts +++ b/src/main/peek/menu.ts @@ -1,4 +1,5 @@ import type { MenuItemConstructorOptions } from 'electron' +import { isPrivateTab } from '../../shared/private' import type { TabId } from '../../shared/state' import type { MainContext } from '../context' import { openMiniWindow } from '../miniwindow' @@ -10,6 +11,8 @@ import { openLinkInPeek } from '.' */ export function linkPeekMenuItems(ctx: MainContext, tabId: TabId, url: string): MenuItemConstructorOptions[] { if (!/^https?:/i.test(url)) return [] + // A private window has no Peek, and a Mini Window would take the link into a normal profile (NV-11). + if (isPrivateTab(ctx.store.state, tabId)) return [] const floating = ctx.store.state.tabs[tabId]?.floating const items: MenuItemConstructorOptions[] = [] if (!floating) items.push({ label: 'Open Link in Peek', click: () => openLinkInPeek(tabId, url) }) diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index 3574451f..2afb2a13 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -25,9 +25,10 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We if (params.linkURL) { items.push( { label: 'Open Link in New Tab', click: () => openInNewTab(params.linkURL, false) }, - ...linkPeekMenuItems(ctx, tabId, params.linkURL), // A new pane beside the page's pane (SV-08). linkSplitMenuItem(ctx, tabId, params.linkURL), + // Then Peek and Mini Window, new and private windows: the order NV-12 lists. + ...linkPeekMenuItems(ctx, tabId, params.linkURL), ...linkWindowItems(ctx, tabId, params.linkURL), { label: 'Copy Link Address', click: () => clipboard.writeText(params.linkURL) }, saveLinkItem(contents, params.linkURL), diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index fd6eeaf4..0266a580 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -69,6 +69,17 @@ async function selectedTab(app: ElectronApplication): Promise<string | undefined const urlOf = async (app: ElectronApplication, tabId: string | undefined) => tabId ? (await state(app)).tabs[tabId]?.url : undefined +/** The pages of the open Mini Windows, oldest first. */ +async function miniUrls(app: ElectronApplication): Promise<(string | null)[]> { + const s = await state(app) + return Object.values(s.windows) + .filter((w) => w.kind === 'mini') + .map((w) => { + const id = w.activeTab[w.spaceId] + return id ? (s.tabs[id]?.url ?? null) : null + }) +} + type Download = { url: string; cancelled: boolean } /** @@ -146,11 +157,49 @@ test('[NV-08] ⇧-click opens the link in a new window, a step down and right, w const s = await state(app) const shown = s.windows[second.id]?.activeTab[second.spaceId] expect(await urlOf(app, shown ?? undefined)).toBe(page('Shifted-next')) - // The first window still shows the page with the link. + // The first window still shows the page with the link, and no Peek took the click. expect(s.windows[first.id]?.activeTab[first.spaceId]).toBe(opener) + expect(s.windows[first.id]?.overlay.kind).toBe('none') + expect(Object.values(s.tabs).filter((t) => t.floating)).toHaveLength(0) expect([second.bounds?.x, second.bounds?.y]).not.toEqual([first.bounds?.x, first.bounds?.y]) }) +test('[NV-08][MW-07] ⌥-click opens split view and ⌘⌥-click (Ctrl+Alt-click) a Mini Window: neither takes the other’s click', async () => { + const { app } = orbit + const id = await openTab(app, page('Both')) + const downloads = await watchDownloads(app, page('Both')) + // ⌘⌥-click: the link in a Mini Window; no split view, no download. + await clickInPage(app, page('Both'), '#next', [mod, 'alt']) + await expect.poll(() => miniUrls(app)).toEqual([page('Both-next')]) + expect(await splitTabs(app, id)).toEqual([]) + expect(await urlOf(app, id)).toBe(page('Both')) + // ⌥-click: split view beside the page; no second Mini Window. + await clickInPage(app, page('Both'), '#next', ['alt']) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [, right] = await splitTabs(app, id) + await waitLoaded(app, right as string, page('Both-next')) + expect(await miniUrls(app)).toEqual([page('Both-next')]) + // Only the ⌥-click's own download began, and Orbit cancelled it. + await expect.poll(downloads, { timeout: 10_000 }).toEqual([{ url: page('Both-next'), cancelled: true }]) +}) + +test('[SV-09][MW-07] on sidebar rows too: ⌥-click splits with the current tab, ⌘⌥-click (Ctrl+Alt-click) opens a Mini Window', async () => { + const { app, shell } = orbit + const current = await openTab(app, page('RowA')) + const other = await openTab(app, page('RowB')) + const third = await openTab(app, page('RowC')) + await cmd(app, 'tabs.activate', { tabId: current }) + await expect.poll(() => selectedTab(app)).toBe(current) + const row = (id: string) => shell.locator(`[data-testid="tab-row"][data-tab-id="${id}"]`) + await row(other).click({ modifiers: ['Alt'] }) + await expect.poll(() => splitTabs(app, current)).toEqual([current, other]) + expect(await miniUrls(app)).toEqual([]) + await row(third).click({ modifiers: [isMac ? 'Meta' : 'Control', 'Alt'] }) + await expect.poll(() => miniUrls(app)).toEqual([page('RowC')]) + expect((await state(app)).tabs[third]?.floating).toBe('mini') + expect(await splitTabs(app, current)).toEqual([current, other]) +}) + test('[NV-08][SV-08] ⌥-click (Alt-click) opens the link in split view beside the page instead of downloading it', async () => { const { app, shell } = orbit const id = await openTab(app, page('Alted')) @@ -237,7 +286,7 @@ test('[NV-09] a link that downloads in a new tab leaves no tab behind', async () expect((await state(app)).archive).toEqual([]) }) -test('[NV-12] right-clicking a link offers new tab, split view, new and private windows, copy and save; Split View shows two panes', async () => { +test('[NV-12] right-clicking a link offers new tab, split view, Peek, Mini Window, new and private windows, copy and save; Split View shows two panes', async () => { const { app, shell } = orbit const id = await openTab(app, page('Menu')) await clickInPage(app, page('Menu'), '#next', [], 'right') @@ -246,6 +295,8 @@ test('[NV-12] right-clicking a link offers new tab, split view, new and private const link = [ 'Open Link in New Tab', 'Open Link in Split View', + 'Open Link in Peek', + 'Open Link in Mini Window', 'Open Link in New Window', 'Open Link in Private Window', 'Copy Link Address', diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 51b2eca7..8e23d7ea 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -128,13 +128,10 @@ export async function startNavFixtures(): Promise<NavFixtures> { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) }) + // Any free port the system gives: other agents' and suites' servers keep to fixed ranges. await new Promise<void>((resolveListen, reject) => { - let port = 3340 - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE' && port < 3399) server.listen(++port, '127.0.0.1') - else reject(err) - }) - server.listen(port, '127.0.0.1', () => resolveListen()) + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolveListen()) }) const { port } = server.address() as AddressInfo return { diff --git a/tests/e2e/nav-windows.spec.ts b/tests/e2e/nav-windows.spec.ts index 2a9c85a4..a40ac442 100644 --- a/tests/e2e/nav-windows.spec.ts +++ b/tests/e2e/nav-windows.spec.ts @@ -4,12 +4,14 @@ import { type ElectronApplication, expect, test } from '@playwright/test' import type { OrbitState } from '../../src/shared/state' import { action, isMac, launchOrbit, MOD, type Orbit, state } from './helpers' import { + clickInPage, closeWindow, cmd, cmdIn, flushState, type NavFixtures, openTab, + pageMenu, pressInPage, startNavFixtures, uiPageOf, @@ -291,3 +293,78 @@ test('[NV-11] nothing of a private window reaches the disk: no state, history, f const found = await cmd<{ results: { url?: string }[] }>(orbit.app, 'search.query', { query: token, mode: 'new-tab' }) expect(found.results.filter((r) => r.url?.includes(`/page/${token}`))).toEqual([]) }) + +test('[NV-11][NV-18][MW-07] a private window keeps its links: no Peek or Mini Window from it, and links from other apps never open in it', async () => { + const { app } = orbit + const main = (await windowList(app))[0] + if (!main) throw new Error('no window') + const priv = await openPrivate(app) + const id = await openTabIn(app, priv.id, page('Kept')) + const isPrivateTab = (s: OrbitState, tabId: string | undefined) => + !!tabId && !!s.tabs[tabId]?.profileId.startsWith('private-') + const minis = async () => Object.values((await state(app)).windows).filter((w) => w.kind === 'mini') + + // The link menu has no Peek or Mini Window there. + await clickInPage(app, page('Kept'), '#next', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + const labels = (await pageMenu(app))?.labels ?? [] + expect(labels).toContain('Open Link in New Tab') + expect(labels).not.toContain('Open Link in Peek') + expect(labels).not.toContain('Open Link in Mini Window') + + // ⌘⌥-click (Ctrl+Alt-click) opens the link as a tab of the private window, not a Mini Window. + await clickInPage(app, page('Kept'), '#next', [mod, 'alt']) + await expect + .poll(async () => { + const s = await state(app) + return (s.spaces[priv.spaceId]?.today ?? []).map((t) => s.tabs[t]?.url) + }) + .toContain(page('Kept-next')) + expect(await minis()).toHaveLength(0) + const s1 = await state(app) + const opened = (s1.spaces[priv.spaceId]?.today ?? []).find((t) => s1.tabs[t]?.url === page('Kept-next')) + expect(isPrivateTab(s1, opened)).toBe(true) + // ⌘⌥-click on a row of its sidebar: the tab stays in the private window. + const shell = await uiPageOf(app, 'shell', priv.id) + await shell.locator(`[data-testid="tab-row"][data-tab-id="${id}"]`).click({ modifiers: [MOD, 'Alt'] }) + // A later command from the same sidebar is handled after the click's own. + await shell.evaluate(() => + (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise<unknown> } }).orbit.invoke( + 'site.info', + {}, + ), + ) + expect(await minis()).toHaveLength(0) + expect((await state(app)).tabs[id]?.floating).toBeUndefined() + expect((await state(app)).spaces[priv.spaceId]?.today).toContain(id) + + // A link from another app, while the private window is in front: a Mini Window in a normal profile. + const fromApp = (url: string) => + app.evaluate(({ app: a }, u) => a.emit('second-instance', {}, ['orbit', u], '/'), url) + await fromApp(page('FromMail')) + await expect.poll(async () => (await minis()).length).toBe(1) + const s2 = await state(app) + const mini = (await minis())[0] + const miniTab = mini?.activeTab[mini.spaceId] ?? undefined + expect(s2.tabs[miniTab ?? '']?.url).toBe(page('FromMail')) + expect(isPrivateTab(s2, miniTab)).toBe(false) + // With Mini Windows off for such links: a tab in the normal window, never the private one. + await app.evaluate(() => { + const g = globalThis as unknown as { + __orbit: { ctx: { store: { update: (f: (d: { settings: Record<string, unknown> }) => void) => void } } } + } + g.__orbit.ctx.store.update((d) => { + d.settings.miniWindowForExternalLinks = false + }) + }) + await fromApp(page('FromChat')) + await expect + .poll(async () => { + const s = await state(app) + return (s.spaces[main.spaceId]?.today ?? []).map((t) => s.tabs[t]?.url) + }) + .toContain(page('FromChat')) + const s3 = await state(app) + expect((s3.spaces[priv.spaceId]?.today ?? []).map((t) => s3.tabs[t]?.url)).not.toContain(page('FromChat')) + expect(Object.values(s3.tabs).filter((t) => t.url === page('FromChat') && isPrivateTab(s3, t.id))).toEqual([]) +}) From ef4acbb43f45dee89f8cf3acbda1ba8d3c7834f2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:01:24 +0000 Subject: [PATCH 11/69] nav: page preload keeps undecided permissions and notification clicks; nav-site e2e; fixture ports 3330-3399 Pages read a permission the user hasn't decided as "default"/"prompt" (Electron's check can only say yes or no), and a click on a page's notification brings its tab forward. The site controls buttons toggle the panel and Esc closes it wherever focus is. Nav fixtures walk up from 3330 within 3300-3399. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 14 +- docs/feature-status.json | 5 +- electron.vite.config.ts | 3 +- src/main/nav/index.ts | 3 + src/main/nav/page-bridge.ts | 105 ++++ src/main/nav/permissions.ts | 7 + src/preload/page.ts | 121 ++++ .../src/overlay/site/SiteControls.tsx | 18 +- src/renderer/src/shell/chrome/Address.tsx | 9 +- src/shared/site.ts | 49 ++ tests/e2e/chrome.spec.ts | 8 +- tests/e2e/nav-site.spec.ts | 534 ++++++++++++++++++ tests/e2e/nav-support.ts | 19 +- tests/unit/nav-site.test.ts | 238 ++++++++ 14 files changed, 1110 insertions(+), 23 deletions(-) create mode 100644 src/main/nav/page-bridge.ts create mode 100644 src/preload/page.ts create mode 100644 tests/e2e/nav-site.spec.ts create mode 100644 tests/unit/nav-site.test.ts diff --git a/FEATURES.md b/FEATURES.md index 784e3190..b4e4f81b 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:** 485 spec’d · 159 built (45 partial) · 213 tested · 0 feel right. +**Totals:** 485 spec’d · 162 built (45 partial) · 214 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | -| P0 | 174 | 114 | 139 | +| P0 | 174 | 117 | 140 | | P1 | 221 | 41 | 69 | | P2 | 83 | 4 | 5 | @@ -24,7 +24,7 @@ Spec: `spec/sidebar.md` | SB-01 | Sidebar-first window layout | P0 | ✅ | ✅ e2e | – | sidebar on the left with controls, navigation, address and every tab; the page an inset rounded card beside it in the Space theme; no tab strip | | SB-02 | Sidebar anatomy (top to bottom) | P0 | ✅ | ✅ e2e | – | top row, address, Favorites, title row, pinned, divider, + New Tab, Today, empty area, bottom bar in that order; title row to empty area scroll together, the rest stays; trays come with SB-10, the Library button with SB-09 | | SB-03 | Top row: window controls and navigation buttons | P0 | ✅ | ✅ e2e | – | traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow | -| SB-04 | Address display at the top of the sidebar | P0 | 🟡 partial | ✅ e2e+unit | – | host only (focused pane), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button; site-controls button shows a placeholder until NV-16 | +| SB-04 | Address display at the top of the sidebar | P0 | ✅ | ✅ e2e+unit | – | host only (focused pane, or the Peek's page while one shows), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button, site controls button (NV-16) at the right end | | SB-05 | Favorites grid placement | P0 | ✅ | ✅ e2e | – | Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile | | SB-06 | Space title row placement | P0 | ✅ | ✅ e2e | – | title row: inline rename, hover profile badge and … menu (pinned caret is PN-09) | | SB-07 | "+ New Tab" row | P0 | ✅ | ✅ e2e | – | + New Tab row with press animation opens the command bar | @@ -243,10 +243,10 @@ Spec: `spec/navigation.md` | NV-12 | Page context menu | P0 | ✅ | ✅ e2e | – | native menu: page (Back, Forward, Reload, Save Page As, Print, View Page Source), link (New Tab, Split View, New Window, Private Window, Copy, Save Link As), selection (Copy, Search engine, Look Up on macOS), image, editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek, Mini Window and media items come from their lanes; P2 Translate and Share Quote not built | | NV-13 | Load-error pages | P1 | – | – | – | | | NV-14 | Crashed or unresponsive tab | P1 | – | ✅ e2e | – | | -| NV-15 | Permission prompts | P0 | – | ✅ e2e | – | | -| NV-16 | Site controls panel | P0 | – | – | – | | +| 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): 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; Esc dismisses; kept per profile and site (private in memory); pages read undecided as default/prompt; a notification click brings its tab forward (page notifications, not service-worker ones); Peek, Mini Window and pop-ups ask in a dialog; 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 | 🟡 partial | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02 | -| NV-18 | Links from other apps (Orbit as the default browser) | P0 | – | ✅ e2e | – | | +| 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); rules UI is ST-09, Archive restores AR-05 | | NV-19 | Links that open other apps (external protocols) | P1 | – | – | – | | | NV-20 | Full screen | P1 | – | ✅ e2e | – | | | NV-21 | Save page and view source | P2 | – | – | – | | @@ -271,7 +271,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 | – | links, back/forward, reload, zoom, copy URL, devtools act on the Peek; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; find in page waits for the find bar (core placeholder) | +| PK-11 | Navigating and acting inside a Peek | P1 | 🟡 partial | ✅ e2e+unit | – | links, back/forward, reload, zoom, copy URL, devtools act on the Peek; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; find in page waits for the find bar (core placeholder) | | PK-12 | Peek settings | P1 | 🟡 partial | ✅ e2e+unit | – | settings.peekExternalLinks / peekShiftClick honoured; toggles UI belongs to the settings lane | | 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 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index fea6b3ab..010bf436 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -2,7 +2,7 @@ "SB-01": {"built": "yes", "note": "sidebar on the left with controls, navigation, address and every tab; the page an inset rounded card beside it in the Space theme; no tab strip"}, "SB-02": {"built": "yes", "note": "top row, address, Favorites, title row, pinned, divider, + New Tab, Today, empty area, bottom bar in that order; title row to empty area scroll together, the rest stays; trays come with SB-10, the Library button with SB-09"}, "SB-03": {"built": "yes", "note": "traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow"}, - "SB-04": {"built": "partial", "note": "host only (focused pane), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button; site-controls button shows a placeholder until NV-16"}, + "SB-04": {"built": "yes", "note": "host only (focused pane, or the Peek's page while one shows), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button, site controls button (NV-16) at the right end"}, "SB-05": {"built": "yes", "note": "Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile"}, "SB-06": {"built": "yes", "note": "title row: inline rename, hover profile badge and … menu (pinned caret is PN-09)"}, "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, @@ -167,6 +167,9 @@ "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: 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, Print, View Page Source), link (New Tab, Split View, New Window, Private Window, Copy, Save Link As), selection (Copy, Search engine, Look Up on macOS), image, editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek, Mini Window and media items come from their lanes; P2 Translate and Share Quote not built"}, + "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): 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; Esc dismisses; kept per profile and site (private in memory); pages read undecided as default/prompt; a notification click brings its tab forward (page notifications, not service-worker ones); Peek, Mini Window and pop-ups ask in a dialog; 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); rules UI is ST-09, Archive restores AR-05"}, "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, "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"}, "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/electron.vite.config.ts b/electron.vite.config.ts index ffe6cd2e..fa7ecb9d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -15,7 +15,8 @@ export default defineConfig({ // Sandboxed preloads must be a single CommonJS file with no node_modules imports. externalizeDeps: false, rollupOptions: { - input: { shell: resolve('src/preload/shell.ts') }, + // shell: Orbit's own views. page: every web page (src/main/nav/page-bridge.ts registers it). + input: { shell: resolve('src/preload/shell.ts'), page: resolve('src/preload/page.ts') }, output: { format: 'cjs', entryFileNames: '[name].cjs' }, }, }, diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 2c9b57e0..c6b02ceb 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -10,6 +10,7 @@ import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' import { type LinksTestHooks, registerLinks } from './links' import { openUrlInNewWindow } from './new-window' +import { registerPageBridge } from './page-bridge' import { registerPageColor } from './page-color' import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { type PageToolsInfo, registerPageTools } from './page-tools' @@ -291,6 +292,8 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // Permission prompts and the site controls panel (NV-15, NV-16). const permissions = registerPermissions(ctx, prefs) registerSiteControls(ctx, prefs, permissions) + // The web page preload: permission states as Chromium reads them, notification clicks (NV-15). + registerPageBridge(ctx, permissions) ctx.hooks.tabCreated.push((tabId, contents) => { // Esc stops a loading page (NV-04). The page still gets the key; an open overlay (command bar, diff --git a/src/main/nav/page-bridge.ts b/src/main/nav/page-bridge.ts new file mode 100644 index 00000000..15741730 --- /dev/null +++ b/src/main/nav/page-bridge.ts @@ -0,0 +1,105 @@ +import { join } from 'node:path' +import { BrowserWindow, type IpcMainEvent, ipcMain, type Session, type WebContents } from 'electron' +import { windowMayShow } from '../../shared/private' +import { PAGE_CHANNEL, type PagePermissionState, pagePermissionKinds, permissionOrigin } from '../../shared/site' +import type { ProfileId, TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { Permissions } from './permissions' + +/** The web page preload, built next to the shell's (electron.vite.config.ts). */ +export function pagePreloadPath(): string { + return join(import.meta.dirname, '../preload/page.cjs') +} + +/** + * The main side of the web page preload (src/preload/page.ts, NV-15). Every profile session runs it + * in its pages; it asks here how a permission should read to the page ("prompt" until the user + * decides, which Electron's yes/no check can't say) and says when the user clicked one of the + * page's notifications, which brings the tab that sent it forward: in the window that shows it, or + * else selected in the last window the user worked in that may show it, on its Space. + */ +export function registerPageBridge(ctx: MainContext, permissions: Permissions): void { + const { store, sessions, tabs, windows, commands } = ctx + const profiles = new WeakMap<Session, ProfileId>() + + sessions.onSessionCreated((ses, profileId) => { + profiles.set(ses, profileId) + try { + ses.registerPreloadScript({ type: 'frame', id: 'orbit-page', filePath: pagePreloadPath() }) + } catch (err) { + console.error('[page-bridge] preload not registered', err) + } + }) + + const profileOf = (contents: WebContents): ProfileId | undefined => { + const tabId = tabs.tabIdFor(contents) + return (tabId ? store.state.tabs[tabId]?.profileId : undefined) ?? profiles.get(contents.session) + } + + // The reply goes out as soon as returnValue is set, so it is set once. + ipcMain.on(PAGE_CHANNEL.permission, (event: IpcMainEvent, name: unknown) => { + let answer: PagePermissionState | null = null + try { + const kinds = typeof name === 'string' ? pagePermissionKinds(name) : null + const frame = event.senderFrame + const origin = frame ? permissionOrigin(frame.url) : null + const profileId = profileOf(event.sender) + if (kinds && origin && profileId) answer = permissions.pageState(profileId, origin, kinds) + } catch (err) { + console.error('[page-bridge] permission state', err) + } + event.returnValue = answer + }) + + const raise = (win: BrowserWindow | undefined) => { + if (!win || win.isDestroyed()) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() + } + + /** The window to select a tab in that no window shows: the one the user worked in last, if it may. */ + const windowFor = (tabId: TabId): WindowId | undefined => { + const state = store.state + const tab = state.tabs[tabId] + if (!tab) return undefined + const candidates = Object.values(state.windows).filter( + (w) => w.kind !== 'mini' && windows.get(w.id) && windowMayShow(state, w.id, tabId), + ) + // One already on the tab's Space first (the focused one if it is), else the focused one. + const focused = windows.focusedWindowId() + const onSpace = candidates.filter((w) => w.spaceId === tab.spaceId) + const ranked = [ + ...onSpace.filter((w) => w.id === focused), + ...onSpace, + ...candidates.filter((w) => w.id === focused), + ...candidates, + ] + return ranked[0]?.id + } + + const bringForward = (tabId: TabId) => { + const tab = store.state.tabs[tabId] + if (!tab) return + const showing = windows.windowOfTab(tabId) + if (showing) { + // A tab in a window's layout gets selected there (its pane, in a split); a Peek or Mini Window + // just comes forward. + if (!tab.floating) void commands.run('tabs.activate', { tabId }, { windowId: showing }).catch(() => {}) + raise(windows.get(showing)) + return + } + const windowId = windowFor(tabId) + if (!windowId) return + void commands.run('tabs.activate', { tabId }, { windowId }).catch(() => {}) + raise(windows.get(windowId)) + } + + ipcMain.on(PAGE_CHANNEL.notificationClick, (event: IpcMainEvent) => { + const contents = event.sender + const tabId = tabs.tabIdFor(contents) + if (tabId) bringForward(tabId) + // A pop-up window's page: its window comes forward. + else raise(BrowserWindow.fromWebContents(contents) ?? undefined) + }) +} diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index a3f5812e..7ba585af 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -16,6 +16,7 @@ import { type EngineDevice, kindsOf, ONE_TIME_KINDS, + type PagePermissionState, PERMISSION_ASK, type PermissionKind, type PermissionPrompt, @@ -78,6 +79,8 @@ export interface Permissions { set(profileId: ProfileId, origin: string, kind: PermissionKind, allowed: boolean): void /** Forgets every permission choice of a site (it asks again). */ reset(profileId: ProfileId, origin: string): void + /** A permission's state as the site's pages should read it: "prompt" until the user decides. */ + pageState(profileId: ProfileId, origin: string, kinds: PermissionKind[]): PagePermissionState /** Test hooks. */ info(windowId: WindowId): { placed: boolean; bounds?: Rectangle } } @@ -654,6 +657,10 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss } for (const grant of prefs.devicesFor(profileId, origin)) prefs.revokeDevice(profileId, origin, grant.key) }, + pageState: (profileId, origin, kinds) => { + if (kinds.some((k) => prefs.permission(profileId, origin, k) === 'block')) return 'denied' + return kinds.every((k) => allowedNow(profileId, origin, k)) ? 'granted' : 'prompt' + }, info: (windowId) => { const placed = windows.promptInfo(windowId) return { placed: placed.placed, bounds: placed.placed ? placed.bounds : undefined } diff --git a/src/preload/page.ts b/src/preload/page.ts new file mode 100644 index 00000000..d27d9dfb --- /dev/null +++ b/src/preload/page.ts @@ -0,0 +1,121 @@ +/** + * Preload for web pages: registered on every profile session (src/main/nav/page-bridge.ts), so it + * runs in the main frame of each page, Peek and Mini Window included, and never in Orbit's own views. + * + * Electron's permission check can only say yes or no, so a permission the user hasn't decided would + * read as "denied", and sites that look before they ask would never ask. This keeps Chromium's + * answers without adding anything to the page's globals (NV-15): + * - `Notification.permission` is "default", and a PermissionStatus's `state` "prompt", until the + * user decides; + * - clicking one of the page's notifications brings its tab forward. + */ +import { contextBridge, ipcRenderer } from 'electron' +import { PAGE_CHANNEL } from '../shared/site' + +type StateOf = (name: string) => string | null + +const permissionState: StateOf = (name) => { + try { + const value: unknown = ipcRenderer.sendSync(PAGE_CHANNEL.permission, String(name)) + return typeof value === 'string' ? value : null + } catch { + return null + } +} + +const notificationClicked = () => { + ipcRenderer.send(PAGE_CHANNEL.notificationClick) +} + +/** + * Runs in the page's own world before its scripts (serialized: it may use only its arguments and + * 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) { + const define = Object.defineProperty + const describe = Object.getOwnPropertyDescriptor + const construct = Reflect.construct + const listen = EventTarget.prototype.addEventListener + const then = Promise.prototype.then + + const NativeNotification = window.Notification + if (typeof NativeNotification === 'function') { + /** "denied" from the engine is "default" while the user hasn't decided (or dismissed the prompt). */ + const undecided = (value: NotificationPermission): NotificationPermission => + value === 'denied' && stateOf('notifications') === 'prompt' ? 'default' : value + const permission = describe(NativeNotification, 'permission') + const read = permission?.get + if (permission?.configurable && read) { + define(NativeNotification, 'permission', { + configurable: true, + enumerable: permission.enumerable, + get() { + return undecided(read.call(NativeNotification)) + }, + }) + } + // Notification.requestPermission(): a dismissed prompt answers "default", as in Chromium. + const request = describe(NativeNotification, 'requestPermission') + const ask = request?.value + if (request?.configurable && typeof ask === 'function') { + define(NativeNotification, 'requestPermission', { + configurable: true, + enumerable: request.enumerable, + writable: true, + value: function requestPermission(callback?: (value: NotificationPermission) => void) { + return then.call(ask.call(NativeNotification), (value: NotificationPermission) => { + const answer = undecided(value) + if (typeof callback === 'function') callback(answer) + return answer + }) + }, + }) + } + // A click on one of the page's notifications (only a real one) brings its tab forward. + const onClick = (event: Event) => { + if (event.isTrusted) clicked() + } + const Notification = new Proxy(NativeNotification, { + construct(target, args, newTarget) { + const notification = construct(target, args, newTarget) + listen.call(notification, 'click', onClick) + return notification + }, + }) + const own = describe(window, 'Notification') + if (own?.configurable) { + define(window, 'Notification', { + configurable: true, + enumerable: own.enumerable, + writable: true, + value: Notification, + }) + } + } + + // navigator.permissions.query(): a PermissionStatus says "prompt" while the user hasn't decided. + const Status = window.PermissionStatus + if (typeof Status === 'function') { + const state = describe(Status.prototype, 'state') + const name = describe(Status.prototype, 'name') + const readState = state?.get + const readName = name?.get + if (state?.configurable && readState && readName) { + define(Status.prototype, 'state', { + configurable: true, + enumerable: state.enumerable, + get() { + const value = readState.call(this) + return value === 'denied' && stateOf(String(readName.call(this))) === 'prompt' ? 'prompt' : value + }, + }) + } + } +} + +try { + contextBridge.executeInMainWorld({ func: patchPage, args: [permissionState, notificationClicked] }) +} catch { + // A page without its own world to patch keeps the engine's answers. +} diff --git a/src/renderer/src/overlay/site/SiteControls.tsx b/src/renderer/src/overlay/site/SiteControls.tsx index 334095e9..dd3461fd 100644 --- a/src/renderer/src/overlay/site/SiteControls.tsx +++ b/src/renderer/src/overlay/site/SiteControls.tsx @@ -109,14 +109,22 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; panel.current?.focus() }, [info]) - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + // Esc closes it (or backs out of clearing data) wherever focus is: a control that went away (Reset + // permissions once there is nothing left to reset) leaves it on the page's body. + const confirming = useRef(confirmClear) + confirming.current = confirmClear + useEffect(() => { + const onEscape = (e: globalThis.KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented) return e.preventDefault() - e.stopPropagation() - if (confirmClear) setConfirmClear(false) + if (confirming.current) setConfirmClear(false) else void close() - return } + window.addEventListener('keydown', onEscape) + return () => window.removeEventListener('keydown', onEscape) + }, []) + + const onKeyDown = (e: KeyboardEvent) => { const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0 if (!step || !panel.current) return const controls = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled)')] diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx index bd1332be..7f2f469f 100644 --- a/src/renderer/src/shell/chrome/Address.tsx +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -50,6 +50,11 @@ export function Address({ windowId }: { windowId: string }) { const r = field.current?.getBoundingClientRect() void invoke('site.openControls', r ? { x: r.left, y: r.bottom + SITE_PANEL.gap } : {}) } + // The buttons toggle it (the overlay over them usually takes that click first and closes it). + const toggleControls = () => { + if (panelOpen) void invoke('overlay.close', {}) + else openControls() + } const openRef = useRef(openControls) openRef.current = openControls // The Site Controls action (command bar, a shortcut) while this sidebar shows. @@ -77,7 +82,7 @@ export function Address({ windowId }: { windowId: string }) { data-testid="insecure-button" aria-label="Not secure: site controls" title="Not secure. This connection is not encrypted." - onClick={openControls} + onClick={toggleControls} > <TriangleAlert size={13} data-testid="insecure-icon" /> </button> @@ -131,7 +136,7 @@ export function Address({ windowId }: { windowId: string }) { aria-label="Site controls" aria-expanded={panelOpen} title={withShortcut('Site controls', 'site.controls')} - onClick={openControls} + onClick={toggleControls} > <SlidersHorizontal size={13} /> </button> diff --git a/src/shared/site.ts b/src/shared/site.ts index 316fc5ac..a93cedbf 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -130,6 +130,55 @@ export function kindsOf(permission: string, mediaTypes?: readonly string[]): Per } } +/** + * The permissions a page reads through `navigator.permissions.query()` (PermissionStatus.name) and + * `Notification.permission`, as Orbit's kinds; null for one Orbit doesn't ask about. Chromium names + * some differently on PermissionStatus ("video_capture" for the camera, underscores for hyphens). + */ +export function pagePermissionKinds(name: string): PermissionKind[] | null { + switch (name.replaceAll('_', '-')) { + case 'geolocation': + return ['location'] + case 'notifications': + case 'push': + return ['notifications'] + case 'camera': + case 'video-capture': + return ['camera'] + case 'microphone': + case 'audio-capture': + return ['microphone'] + case 'midi': + case 'midi-sysex': + return ['midi'] + case 'clipboard-read': + return ['clipboard'] + case 'idle-detection': + return ['idle'] + case 'window-management': + return ['windows'] + case 'local-fonts': + return ['fonts'] + case 'local-network-access': + case 'local-network': + case 'loopback-network': + return ['network'] + default: + return null + } +} + +/** A permission's state as a page sees it: "prompt" until the user decides, as in Chromium. */ +export type PagePermissionState = 'granted' | 'denied' | 'prompt' + +/** Messages from the web page preload (src/preload/page.ts) to main (src/main/nav/page-bridge.ts). */ +export const PAGE_CHANNEL = { + /** sendSync(name): the PagePermissionState for a permission name, or null for the engine's answer. */ + permission: 'orbit-page:permission', + /** send(): the user clicked one of the page's notifications (NV-15). */ + notificationClick: 'orbit-page:notification-click', +} as const + /** Sorts permissions into PERMISSION_ORDER, without repeats. */ export function sortKinds(kinds: Iterable<PermissionKind>): PermissionKind[] { const set = new Set(kinds) diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 3c6279c7..d7c13983 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -10,7 +10,7 @@ import { cmd, floatingInfo, floatingPage, - HOST_RULES, + HOST_ARGS, historyMenu, INSECURE_HOST, type NavFixtures, @@ -38,7 +38,7 @@ test.afterAll(async () => { await fx.close() }) test.beforeEach(async () => { - orbit = await launchOrbit({ args: [HOST_RULES] }) + orbit = await launchOrbit({ args: HOST_ARGS }) // macOS keeps the clipboard between tests (even after the app quits): start empty. await orbit.app.evaluate(({ clipboard }) => clipboard.clear()) }) @@ -495,7 +495,7 @@ test('[SB-11] Toggle Sidebar is an action in the command bar (and the View menu test('[SB-11][SB-14] each window keeps its own sidebar state and width, across a restart', async () => { const userData = mkdtempSync(join(tmpdir(), 'orbit-e2e-sidebar-')) await orbit.app.close() - orbit = await launchOrbit({ userData, args: [HOST_RULES] }) + orbit = await launchOrbit({ userData, args: HOST_ARGS }) await openTab(orbit.app, page('Kept')) await cmd(orbit.app, 'window.setSidebar', { width: 320 }) await hideSidebar() @@ -507,7 +507,7 @@ test('[SB-11][SB-14] each window keeps its own sidebar state and width, across a expect(second?.sidebar.visible).toBe(true) await orbit.app.close() - orbit = await launchOrbit({ userData, args: [HOST_RULES] }) + orbit = await launchOrbit({ userData, args: HOST_ARGS }) const windows = Object.values((await state(orbit.app)).windows) const first = windows.find((w) => !w.sidebar.visible) expect(first?.sidebar.width).toBe(320) diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts new file mode 100644 index 00000000..e09a0f49 --- /dev/null +++ b/tests/e2e/nav-site.spec.ts @@ -0,0 +1,534 @@ +import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { action, launchOrbit, type Orbit, state, tabPage } from './helpers' +import { + closeWindow, + cmd, + cmdIn, + type NavFixtures, + openTab, + shellRoundTrip, + startNavFixtures, + uiPageOf, + visit, + waitLoaded, + windowList, + windowState, +} from './nav-support' + +/** Permission prompts (NV-15) and the site controls panel (NV-16). */ + +let fx: NavFixtures +let orbit: Orbit + +// A fake camera and microphone, so getUserMedia has devices once it is allowed. +const MEDIA_ARGS = ['--use-fake-device-for-media-stream'] + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit({ args: MEDIA_ARGS }) +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const page = (title: string) => fx.url(`/page/${title}`) +/** Another site (another origin) on the same server. */ +const elsewhere = (title: string) => fx.urlOn('127.0.0.1', `/page/${title}`) +const host = () => `localhost:${fx.port}` + +type Ask = 'location' | 'notifications' | 'camera' | 'microphone' | 'camera+microphone' +type Answers = { __answers?: Record<string, string> } + +/** + * Starts a permission request in a page without waiting for it: its outcome lands in + * `window.__answers[what]` ('granted', 'denied', 'default' or 'error:<code or name>'). + */ +async function ask(p: Page, what: Ask): Promise<void> { + await p.evaluate((w) => { + const win = window as unknown as Answers + win.__answers ??= {} + const answers = win.__answers + delete answers[w] + const done = (value: string) => { + answers[w] = value + } + if (w === 'location') { + navigator.geolocation.getCurrentPosition( + () => done('granted'), + (e) => done(`error:${e.code}`), + ) + } else if (w === 'notifications') { + void Notification.requestPermission().then(done) + } else { + navigator.mediaDevices.getUserMedia({ video: w.includes('camera'), audio: w.includes('microphone') }).then( + (stream) => { + for (const track of stream.getTracks()) track.stop() + done('granted') + }, + (e: Error) => done(`error:${e.name}`), + ) + } + }, what) +} + +const answerOf = (p: Page, what: Ask) => p.evaluate((w) => (window as unknown as Answers).__answers?.[w] ?? null, what) + +/** What the page sees for a permission without asking (the permission check). */ +const permissionState = (p: Page, name: 'geolocation' | 'notifications' | 'camera') => + p.evaluate(async (n) => (await navigator.permissions.query({ name: n as PermissionName })).state, name) + +/** The shown page of a tab: the view's bounds in the window. */ +const pageBounds = (app: ElectronApplication, tabId: string) => + app.evaluate((_e, id) => { + const g = globalThis as unknown as { + __orbit: { ctx: { windows: { tabViewBounds: (t: string) => Electron.Rectangle | undefined } } } + } + return g.__orbit.ctx.windows.tabViewBounds(id) ?? null + }, tabId) + +/** A tab's page bounds once the layout has stopped moving (the sidebar slides in 260 ms). */ +async function settledPage(app: ElectronApplication, tabId: string): Promise<Electron.Rectangle> { + let last = '' + await expect + .poll( + async () => { + const now = JSON.stringify(await pageBounds(app, tabId)) + const same = now === last + last = now + return same + }, + { intervals: [100] }, + ) + .toBe(true) + const bounds = await pageBounds(app, tabId) + if (!bounds) throw new Error('no page') + return bounds +} + +/** The first window's permission prompt view (shown while the sidebar is hidden). */ +const promptView = (app: ElectronApplication) => + app.evaluate(() => { + const g = globalThis as unknown as { + __orbitNav: { prompt: () => { placed: boolean; bounds?: Electron.Rectangle } } + } + return g.__orbitNav.prompt() + }) + +/** The prompt view's own page (view=prompt), once it has started. */ +async function promptPage(app: ElectronApplication): Promise<Page> { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + const found = app.windows().find((p) => p.url().includes('view=prompt')) + if (found) return found + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error('No prompt view page') +} + +/** Opens a page in a new selected tab and returns it with its Playwright page. */ +async function open(app: ElectronApplication, url: string): Promise<{ id: string; p: Page }> { + const id = await openTab(app, url) + return { id, p: await tabPage(app, url) } +} + +test('[NV-15] the camera prompt hangs under the address; Allow this time lasts while the site stays; Never allow sticks', async () => { + const { app, shell } = orbit + const { id, p } = await open(app, page('Camera')) + await ask(p, 'camera') + const prompt = shell.getByTestId('permission-prompt') + await expect(prompt).toBeVisible() + await expect(prompt.getByTestId('permission-site')).toHaveText(host()) + await expect(prompt).toHaveAttribute('data-kinds', 'camera') + await expect(prompt).toContainText('Use your camera') + await expect(prompt.getByTestId('permission-allow')).toHaveText('Allow while visiting the site') + await expect(prompt.getByTestId('permission-once')).toHaveText('Allow this time') + await expect(prompt.getByTestId('permission-block')).toHaveText('Never allow') + // Under the address display, as wide as it. + const address = await shell.getByTestId('address').boundingBox() + const card = await prompt.boundingBox() + if (!address || !card) throw new Error('no boxes') + expect(card.y).toBeGreaterThanOrEqual(address.y + address.height - 4) + expect(Math.abs(card.x - address.x)).toBeLessThanOrEqual(1) + expect(Math.abs(card.width - address.width)).toBeLessThanOrEqual(1) + + await prompt.getByTestId('permission-once').click() + await expect.poll(() => answerOf(p, 'camera')).toBe('granted') + await expect(prompt).toBeHidden() + // Asked again on the site: no prompt. Another page of the same site keeps it too. + await ask(p, 'camera') + await expect.poll(() => answerOf(p, 'camera')).toBe('granted') + await visit(app, id, page('Camera-2')) + await ask(p, 'camera') + await expect.poll(() => answerOf(p, 'camera')).toBe('granted') + await expect(prompt).toBeHidden() + + // Once the tab has left the site, it asks again; Never allow is remembered. + await visit(app, id, elsewhere('Away')) + await visit(app, id, page('Camera')) + const back = await tabPage(app, page('Camera')) + expect(await permissionState(back, 'camera')).toBe('prompt') + await ask(back, 'camera') + await expect(prompt).toBeVisible() + await prompt.getByTestId('permission-block').click() + await expect.poll(() => answerOf(back, 'camera')).toBe('error:NotAllowedError') + await ask(back, 'camera') + await expect.poll(() => answerOf(back, 'camera')).toBe('error:NotAllowedError') + await expect(prompt).toBeHidden() + expect(await permissionState(back, 'camera')).toBe('denied') +}) + +test('[NV-15] camera and microphone ask once together; a blocked location fails with code 1', async () => { + const { app, shell } = orbit + const { p } = await open(app, page('Call')) + const prompt = shell.getByTestId('permission-prompt') + await ask(p, 'camera+microphone') + await expect(prompt).toHaveAttribute('data-kinds', 'camera microphone') + await expect(prompt.locator('li')).toHaveText(['Use your camera', 'Use your microphone']) + await prompt.getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'camera+microphone')).toBe('granted') + // Remembered for each: asking for one of them doesn't ask again. + await ask(p, 'microphone') + await expect.poll(() => answerOf(p, 'microphone')).toBe('granted') + + await ask(p, 'location') + await expect(prompt).toHaveAttribute('data-kinds', 'location') + await prompt.getByTestId('permission-block').click() + await expect.poll(() => answerOf(p, 'location')).toBe('error:1') + expect(await permissionState(p, 'geolocation')).toBe('denied') + expect(await permissionState(p, 'camera')).toBe('granted') +}) + +test('[NV-15] notifications ask with Block and Allow; Esc dismisses and the site may ask again', async () => { + const { app, shell } = orbit + const { p } = await open(app, page('News')) + const prompt = shell.getByTestId('permission-prompt') + await ask(p, 'notifications') + await expect(prompt).toContainText('Show notifications') + await expect(prompt.getByTestId('permission-once')).toHaveCount(0) + await expect(prompt.getByTestId('permission-block')).toHaveText('Block') + // Keyboard: the buttons take the arrows, Esc dismisses. + await prompt.getByTestId('permission-block').focus() + await shell.keyboard.press('ArrowRight') + await expect(prompt.getByTestId('permission-allow')).toBeFocused() + await shell.keyboard.press('Escape') + await expect(prompt).toBeHidden() + await expect.poll(() => answerOf(p, 'notifications')).toBe('default') + expect(await p.evaluate(() => Notification.permission)).toBe('default') + // Dismissed is not decided: asked again, it prompts again. + await ask(p, 'notifications') + await expect(prompt).toBeVisible() + await prompt.getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + expect(await p.evaluate(() => Notification.permission)).toBe('granted') +}) + +test('[NV-15] a prompt belongs to its tab: hidden on another tab, back with it, gone when the page is left', async () => { + const { app, shell } = orbit + const { id, p } = await open(app, page('Asker')) + const prompt = shell.getByTestId('permission-prompt') + await ask(p, 'notifications') + await expect(prompt).toBeVisible() + await openTab(app, page('Other')) + await expect(prompt).toBeHidden() + await cmd(app, 'tabs.activate', { tabId: id }) + await expect(prompt).toBeVisible() + // Leaving the page answers for it: the request ends undecided. + await visit(app, id, elsewhere('Left')) + await expect(prompt).toBeHidden() + expect((await state(app)).tabs[id]?.permissionPrompt).toBeUndefined() +}) + +test('[NV-15] pages read an undecided permission as not decided yet, as in Chromium', async () => { + const { app, shell } = orbit + const { id, p } = await open(app, page('Reader')) + // Electron's check can only say yes or no: the page preload keeps "default" and "prompt". + expect(await p.evaluate(() => Notification.permission)).toBe('default') + expect(await permissionState(p, 'notifications')).toBe('prompt') + expect(await permissionState(p, 'geolocation')).toBe('prompt') + expect(await permissionState(p, 'camera')).toBe('prompt') + // The page's Notification is still the one it knows. + expect( + await p.evaluate(() => ({ + name: Notification.name, + request: typeof Notification.requestPermission, + maxActions: typeof (Notification as unknown as { maxActions: number }).maxActions, + own: Object.keys(window).filter((k) => /orbit/i.test(k)), + })), + ).toEqual({ name: 'Notification', request: 'function', maxActions: 'number', own: [] }) + + const prompt = shell.getByTestId('permission-prompt') + await ask(p, 'notifications') + await prompt.getByTestId('permission-block').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('denied') + expect(await p.evaluate(() => Notification.permission)).toBe('denied') + expect(await permissionState(p, 'notifications')).toBe('denied') + // Reset in the site controls: undecided again. + await cmd(app, 'site.resetPermissions', { tabId: id }) + expect(await p.evaluate(() => Notification.permission)).toBe('default') + await ask(p, 'notifications') + await prompt.getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + expect(await permissionState(p, 'notifications')).toBe('granted') + expect(await p.evaluate(() => new Notification('Hello') instanceof Notification)).toBe(true) +}) + +test('[NV-15] clicking a notification brings its tab forward, on its Space; a click the page makes up does nothing', async () => { + const { app, shell } = orbit + const { id, p } = await open(app, page('Sender')) + await ask(p, 'notifications') + await shell.getByTestId('permission-prompt').getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + await p.evaluate(() => { + ;(window as unknown as { __note: Notification }).__note = new Notification('Ping', { body: 'From the sender' }) + }) + // The user moves on: another tab, then another Space. + const other = await openTab(app, page('Elsewhere')) + const work = await cmd<string>(app, 'spaces.create', { name: 'Work' }) + await expect.poll(async () => (await windowState(app)).spaceId).toBe(work) + + // A click event the page dispatches itself isn't the user's. + await p.evaluate(() => (window as unknown as { __note: Notification }).__note.dispatchEvent(new Event('click'))) + await shellRoundTrip(shell) + expect((await windowState(app)).spaceId).toBe(work) + + // The user's click (the engine's trusted event reaches main as this message). + const url = page('Sender') + await app.evaluate(({ ipcMain, webContents }, u) => { + const sender = webContents.getAllWebContents().find((c) => c.getURL() === u) + ipcMain.emit('orbit-page:notification-click', { sender }) + }, url) + await expect + .poll(async () => { + const win = await windowState(app) + return { space: win.spaceId === work ? 'work' : 'first', tab: win.activeTab[win.spaceId] } + }) + .toEqual({ space: 'first', tab: id }) + expect(other).not.toBe(id) +}) + +test('[NV-15] with the sidebar hidden the prompt shows over the page’s top-left corner', async () => { + const { app } = orbit + const { id, p } = await open(app, page('Corner')) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + const shown = await settledPage(app, id) + await ask(p, 'notifications') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + const bounds = (await promptView(app)).bounds + if (!bounds) throw new Error('no bounds') + // The card sits 10 px in from the page's corner (its view has a 12 px margin for the shadow). + expect(bounds.x).toBe(shown.x - 2) + expect(bounds.y).toBe(shown.y - 2) + const view = await promptPage(app) + const card = view.getByTestId('permission-prompt') + await expect(card).toBeVisible() + await expect(card.getByTestId('permission-site')).toHaveText(host()) + // The view fits the card: its height follows what the card reports. + await expect + .poll(async () => { + const box = await card.boundingBox() + const placed = (await promptView(app)).bounds + return !!box && !!placed && Math.abs(placed.height - (box.height + 24)) <= 2 + }) + .toBe(true) + await card.getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + await expect.poll(async () => (await promptView(app)).placed).toBe(false) + // Showing the sidebar moves a waiting prompt back under the address. + await ask(p, 'location') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await promptView(app)).placed).toBe(false) + await expect(orbit.shell.getByTestId('permission-prompt')).toBeVisible() +}) + +test('[NV-15] a site asking for a serial port gets a device chooser; Connect waits for a pick, Cancel refuses', async () => { + const { app, shell } = orbit + const { p } = await open(app, page('Ports')) + // requestPort() needs a click: a button in the page, clicked as a person would. + await p.evaluate(() => { + const button = document.createElement('button') + button.id = 'connect' + button.textContent = 'Connect' + button.addEventListener('click', () => { + const win = window as unknown as Answers + win.__answers ??= {} + const answers = win.__answers + ;(navigator as unknown as { serial: { requestPort: () => Promise<unknown> } }).serial.requestPort().then( + () => { + answers.serial = 'granted' + }, + (e: Error) => { + answers.serial = `error:${e.name}` + }, + ) + }) + document.body.prepend(button) + }) + await p.click('#connect') + const prompt = shell.getByTestId('permission-prompt') + await expect(prompt).toHaveAttribute('data-type', 'device') + await expect(prompt).toContainText('wants to connect to a serial port') + // The machine's own ports, or a note that there are none: nothing is picked for the user. + await expect( + prompt.getByTestId('permission-devices-empty').or(prompt.getByTestId('permission-devices')), + ).toBeVisible() + await expect(prompt.getByTestId('permission-allow')).toHaveText('Connect') + await expect(prompt.getByTestId('permission-allow')).toBeDisabled() + await prompt.getByTestId('permission-block').click() + await expect + .poll(() => p.evaluate(() => (window as unknown as Answers).__answers?.serial ?? null)) + .toBe('error:NotFoundError') + await expect(prompt).toBeHidden() +}) + +test('[NV-15][NV-11] choices last across launches, per profile; private windows forget theirs', async () => { + const { app, userData } = orbit + const { p } = await open(app, page('Kept')) + await ask(p, 'notifications') + await orbit.shell.getByTestId('permission-prompt').getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + + // In a private window the same site starts undecided, and its answer stays there. + const before = new Set((await windowList(app)).map((w) => w.id)) + await action(app, 'window.newPrivate') + await expect.poll(async () => (await windowList(app)).filter((w) => !before.has(w.id)).length).toBe(1) + const priv = (await windowList(app)).find((w) => !before.has(w.id)) + if (!priv) throw new Error('no private window') + const privUrl = page('Kept?private') + const privTab = await cmdIn<string>(app, priv.id, 'tabs.create', { url: privUrl }) + await waitLoaded(app, privTab, privUrl) + const pp = await tabPage(app, privUrl) + expect(await pp.evaluate(() => Notification.permission)).toBe('default') + await ask(pp, 'notifications') + const privShell = await uiPageOf(app, 'shell', priv.id) + await privShell.getByTestId('permission-prompt').getByTestId('permission-block').click() + await expect.poll(() => answerOf(pp, 'notifications')).toBe('denied') + // The normal window's choice is untouched. + expect(await p.evaluate(() => Notification.permission)).toBe('granted') + await closeWindow(app, priv.id) + await expect.poll(async () => (await windowList(app)).length).toBe(1) + + // After a relaunch the choice is still there, without asking. + await app.close() + orbit = await launchOrbit({ userData, args: MEDIA_ARGS }) + const again = await open(orbit.app, page('Kept-again')) + expect(await again.p.evaluate(() => Notification.permission)).toBe('granted') + await ask(again.p, 'notifications') + await expect.poll(() => answerOf(again.p, 'notifications')).toBe('granted') + await expect(orbit.shell.getByTestId('permission-prompt')).toBeHidden() +}) + +test('[NV-16] the site controls panel shows the site, its permissions and zoom; switches apply at once', async () => { + const { app, shell } = orbit + const win = await windowState(app) + const { p } = await open(app, page('Controls')) + // Something to show: location refused. + await ask(p, 'location') + await shell.getByTestId('permission-prompt').getByTestId('permission-block').click() + await expect.poll(() => answerOf(p, 'location')).toBe('error:1') + + const button = shell.getByTestId('site-controls-button') + await button.click() + const overlay = await uiPageOf(app, 'overlay', win.id) + const panel = overlay.getByTestId('site-controls') + await expect(panel).toBeVisible() + await expect(button).toHaveAttribute('aria-expanded', 'true') + await expect(panel.getByTestId('site-controls-host')).toHaveText(host()) + await expect(panel.getByTestId('site-controls-connection')).toHaveAttribute('data-connection', 'local') + // Under the address display's field. + const field = await shell.getByTestId('address').boundingBox() + const box = await panel.boundingBox() + if (!field || !box) throw new Error('no boxes') + expect(box.y).toBeGreaterThanOrEqual(field.y + field.height) + expect(box.y).toBeLessThan(field.y + field.height + 20) + const location = panel.locator('[data-testid="site-controls-permission"][data-kind="location"]') + await expect(location).toHaveAttribute('data-state', 'block') + // Turning it on applies at once; the page sees it after a reload. + await panel.getByTestId('site-switch-location').click() + await expect(location).toHaveAttribute('data-state', 'allow') + await expect(panel.getByTestId('site-controls-reload')).toBeVisible() + expect(await permissionState(p, 'geolocation')).toBe('granted') + // Zoom, with a reset once it isn't 100%. + await expect(panel.getByTestId('site-controls-zoom')).toHaveText('100%') + await expect(panel.getByTestId('site-controls-zoom-reset')).toHaveCount(0) + await panel.getByRole('button', { name: 'Zoom in' }).click() + await expect(panel.getByTestId('site-controls-zoom')).toHaveText('110%') + await panel.getByTestId('site-controls-zoom-reset').click() + await expect(panel.getByTestId('site-controls-zoom')).toHaveText('100%') + // Reset permissions: the site asks again. + await panel.getByTestId('site-controls-reset').click() + await expect(location).toHaveAttribute('data-state', 'ask') + expect(await permissionState(p, 'geolocation')).toBe('prompt') + // Esc closes it; the button opens it again and closes it. + await overlay.keyboard.press('Escape') + await expect(panel).toBeHidden() + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') + await button.click() + await expect(panel).toBeVisible() + await button.click() + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') +}) + +test('[NV-16] clearing cookies and site data asks first; a click outside closes the panel', async () => { + const { app, shell } = orbit + const win = await windowState(app) + const { p } = await open(app, page('Jar')) + await p.evaluate(async () => { + const jar = (window as unknown as { cookieStore: { set: (name: string, value: string) => Promise<void> } }) + .cookieStore + await jar.set('kept', '1') + localStorage.setItem('kept', '1') + }) + expect(await p.evaluate(() => document.cookie)).toBe('kept=1') + await shell.getByTestId('site-controls-button').click() + const overlay = await uiPageOf(app, 'overlay', win.id) + const panel = overlay.getByTestId('site-controls') + await expect(panel).toBeVisible() + await panel.getByTestId('site-controls-clear').click() + await expect(panel.getByTestId('site-controls-clear-confirm')).toContainText( + `Clear cookies and site data for ${host()}`, + ) + await panel.getByTestId('site-controls-clear-ok').click() + await expect.poll(() => p.evaluate(() => document.cookie)).toBe('') + await visit(app, (await windowState(app)).activeTab[win.spaceId] as string, page('Jar-2')) + expect(await p.evaluate(() => localStorage.getItem('kept'))).toBeNull() + // The panel stays open while the tab moves on; a click outside it closes it. + await expect(panel).toBeVisible() + await expect(panel.getByTestId('site-controls-clear')).toBeVisible() + const box = await panel.boundingBox() + if (!box) throw new Error('no panel') + await overlay.mouse.click(box.x + box.width + 120, box.y + box.height + 120) + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') +}) + +test('[NV-16] with the sidebar hidden, Site Controls opens at the page’s corner and the sidebar stays hidden', async () => { + const { app } = orbit + const win = await windowState(app) + const { id } = await open(app, page('Hidden')) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + const shown = await settledPage(app, id) + await action(app, 'site.controls') + const overlay = await uiPageOf(app, 'overlay', win.id) + const panel = overlay.getByTestId('site-controls') + await expect(panel).toBeVisible() + // 8 px in from the page's corner, once its opening animation is over. + await expect + .poll(async () => { + const box = await panel.boundingBox() + return !!box && Math.abs(box.x - (shown.x + 8)) <= 1 && Math.abs(box.y - (shown.y + 8)) <= 1 + }) + .toBe(true) + const s = await windowState(app) + expect(s.sidebar.visible).toBe(false) + expect(s.sidebar.peeking).toBe(false) + // The action again closes it. + await action(app, 'site.controls') + await expect.poll(async () => (await windowState(app)).overlay.kind).toBe('none') +}) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 8e23d7ea..aa2c386c 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -12,10 +12,18 @@ import { isMac, state } from './helpers' export const INSECURE_HOST = 'insecure.test' /** Chromium switch mapping INSECURE_HOST to the local fixture server (no network involved). */ export const HOST_RULES = `--host-resolver-rules=MAP ${INSECURE_HOST} 127.0.0.1` +/** + * Launch switches for tests that visit INSECURE_HOST: the mapping, and a way past the dead proxy the + * e2e apps may run behind (it only lets loopback names through, and this one isn't one). + */ +export const HOST_ARGS = [HOST_RULES, `--proxy-bypass-list=${INSECURE_HOST}`] /** ⌘ on macOS, Ctrl elsewhere: the modifier that sends a click to a new tab (SB-03, NV-08). */ export const NEW_TAB_CLICK = isMac ? 'Meta' : 'Control' +/** Where the nav fixture servers start looking for a free port (this lane's range is 3330–3349). */ +const NAV_PORT_BASE = 3330 + export interface NavFixtures { port: number /** `http://localhost:<port><path>` */ @@ -128,10 +136,15 @@ export async function startNavFixtures(): Promise<NavFixtures> { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) }) - // Any free port the system gives: other agents' and suites' servers keep to fixed ranges. + // Everything the tests run stays within 3300–3399: the first free port from this suite's base. + const base = NAV_PORT_BASE + (Number(process.env.TEST_PARALLEL_INDEX ?? 0) % 10) await new Promise<void>((resolveListen, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => resolveListen()) + let port = base + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE' && port < 3399) server.listen(++port, '127.0.0.1') + else reject(err) + }) + server.listen(port, '127.0.0.1', () => resolveListen()) }) const { port } = server.address() as AddressInfo return { diff --git a/tests/unit/nav-site.test.ts b/tests/unit/nav-site.test.ts new file mode 100644 index 00000000..3445ece2 --- /dev/null +++ b/tests/unit/nav-site.test.ts @@ -0,0 +1,238 @@ +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { SitePrefs } from '../../src/main/nav/site-prefs' +import { makeTab } from '../../src/main/tabs/model' +import { createInitialState, createWindowState, DEFAULT_PROFILE_ID } from '../../src/shared/defaults' +import { peekShown, shownTabId } from '../../src/shared/nav' +import { + deviceKey, + deviceLabel, + isPermissionKind, + kindsOf, + ONE_TIME_KINDS, + PERMISSION_ASK, + PERMISSION_NAME, + PERMISSION_ORDER, + pagePermissionKinds, + permissionOrigin, + siteLabel, + sortKinds, +} from '../../src/shared/site' +import type { OrbitState } from '../../src/shared/state' + +describe('engine permissions as the prompt and panel name them (NV-15)', () => { + it('splits a media request into camera and microphone', () => { + expect(kindsOf('media', ['video'])).toEqual(['camera']) + expect(kindsOf('media', ['audio'])).toEqual(['microphone']) + expect(kindsOf('media', ['audio', 'video'])).toEqual(['camera', 'microphone']) + // A check that doesn't say which is about both. + expect(kindsOf('media')).toEqual(['camera', 'microphone']) + expect(kindsOf('media', ['unknown'])).toEqual(['camera', 'microphone']) + }) + + it('maps the other permissions it asks about', () => { + expect(kindsOf('geolocation')).toEqual(['location']) + expect(kindsOf('geolocation-approximate')).toEqual(['location']) + expect(kindsOf('notifications')).toEqual(['notifications']) + expect(kindsOf('clipboard-read')).toEqual(['clipboard']) + expect(kindsOf('midi')).toEqual(['midi']) + expect(kindsOf('midiSysex')).toEqual(['midi']) + expect(kindsOf('idle-detection')).toEqual(['idle']) + expect(kindsOf('window-management')).toEqual(['windows']) + expect(kindsOf('local-fonts')).toEqual(['fonts']) + expect(kindsOf('local-network-access')).toEqual(['network']) + expect(kindsOf('loopback-network')).toEqual(['network']) + }) + + it('asks about nothing else', () => { + for (const quiet of ['fullscreen', 'pointerLock', 'clipboard-sanitized-write', 'openExternal', 'unknown']) { + expect(kindsOf(quiet)).toBeNull() + } + }) + + it('maps the names pages read permissions by', () => { + expect(pagePermissionKinds('geolocation')).toEqual(['location']) + expect(pagePermissionKinds('notifications')).toEqual(['notifications']) + expect(pagePermissionKinds('push')).toEqual(['notifications']) + expect(pagePermissionKinds('camera')).toEqual(['camera']) + expect(pagePermissionKinds('microphone')).toEqual(['microphone']) + expect(pagePermissionKinds('clipboard-read')).toEqual(['clipboard']) + expect(pagePermissionKinds('local-fonts')).toEqual(['fonts']) + // As Chromium's PermissionStatus names them. + expect(pagePermissionKinds('video_capture')).toEqual(['camera']) + expect(pagePermissionKinds('audio_capture')).toEqual(['microphone']) + expect(pagePermissionKinds('clipboard_read')).toEqual(['clipboard']) + expect(pagePermissionKinds('window_management')).toEqual(['windows']) + // The engine answers the rest itself. + for (const other of ['clipboard-write', 'persistent-storage', 'storage-access', 'accelerometer', '']) { + expect(pagePermissionKinds(other)).toBeNull() + } + }) + + it('sorts into one order without repeats', () => { + expect(sortKinds(['microphone', 'camera', 'microphone', 'location'])).toEqual(['camera', 'microphone', 'location']) + expect(sortKinds([])).toEqual([]) + }) + + it('has a question and a name for every permission, and one-time grants only for camera, microphone and location', () => { + for (const kind of PERMISSION_ORDER) { + expect(PERMISSION_ASK[kind]).toBeTruthy() + expect(PERMISSION_NAME[kind]).toBeTruthy() + expect(isPermissionKind(kind)).toBe(true) + } + expect(isPermissionKind('speaker')).toBe(false) + expect(isPermissionKind(3)).toBe(false) + expect([...ONE_TIME_KINDS].sort()).toEqual(['camera', 'location', 'microphone']) + }) +}) + +describe('the site a permission belongs to', () => { + it('is the page’s origin, port included', () => { + expect(permissionOrigin('https://maps.example.com/route?a=1#x')).toBe('https://maps.example.com') + expect(permissionOrigin('http://localhost:3341/page/A')).toBe('http://localhost:3341') + }) + + it('is one entry for every local file, and nothing for pages without an origin', () => { + expect(permissionOrigin('file:///home/me/a.html')).toBe('file://') + expect(permissionOrigin('about:blank')).toBeNull() + expect(permissionOrigin('data:text/html,hi')).toBeNull() + expect(permissionOrigin('not a url')).toBeNull() + expect(permissionOrigin(undefined)).toBeNull() + }) + + it('is named by its host, or "This file"', () => { + expect(siteLabel('https://maps.example.com')).toBe('maps.example.com') + expect(siteLabel('http://localhost:3341')).toBe('localhost:3341') + expect(siteLabel('file://')).toBe('This file') + }) +}) + +describe('devices a site may use', () => { + it('are known by vendor, product and serial number, or by port', () => { + expect(deviceKey('usb', { vendorId: 0x2341, productId: 0x43, serialNumber: 'A1' })).toBe('usb:9025:67:A1') + expect(deviceKey('hid', { vendorId: 1, productId: 2 })).toBe('hid:1:2:') + expect(deviceKey('serial', { portName: '/dev/ttyUSB0', portId: 'p1' })).toBe('serial:port:/dev/ttyUSB0') + expect(deviceKey('serial', { portId: 'p1' })).toBe('serial:port:p1') + }) + + it('are named for people', () => { + expect(deviceLabel('usb', { productName: 'Board', name: 'x' })).toBe('Board') + expect(deviceLabel('serial', { portName: 'COM3' })).toBe('COM3') + expect(deviceLabel('hid', {})).toBeTruthy() + }) +}) + +describe('site choices on disk (NV-15, NV-16)', () => { + const fileIn = () => join(mkdtempSync(join(tmpdir(), 'orbit-site-prefs-')), 'site-prefs.json') + + it('keeps permission choices per profile and origin, and writes them', () => { + const file = fileIn() + const prefs = new SitePrefs(file) + prefs.setPermission(DEFAULT_PROFILE_ID, 'https://a.test', 'camera', 'allow') + prefs.setPermission(DEFAULT_PROFILE_ID, 'https://a.test', 'location', 'block') + prefs.setPermission('work', 'https://a.test', 'camera', 'block') + expect(prefs.permission(DEFAULT_PROFILE_ID, 'https://a.test', 'camera')).toBe('allow') + expect(prefs.permission('work', 'https://a.test', 'camera')).toBe('block') + expect(prefs.permission(DEFAULT_PROFILE_ID, 'https://b.test', 'camera')).toBeUndefined() + prefs.setPermission(DEFAULT_PROFILE_ID, 'https://a.test', 'location', undefined) + expect(prefs.permissionsFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual({ camera: 'allow' }) + prefs.flush() + const again = new SitePrefs(file) + again.load() + expect(again.permissionsFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual({ camera: 'allow' }) + expect(again.permissionsFor('work', 'https://a.test')).toEqual({ camera: 'block' }) + }) + + it('keeps a private window’s choices in memory only, until the last private window closes', () => { + const file = fileIn() + const prefs = new SitePrefs(file) + prefs.setPermission('private-1', 'https://a.test', 'camera', 'allow') + prefs.grantDevice('private-1', 'https://a.test', { type: 'usb', key: 'usb:1:2:', name: 'Board' }) + expect(prefs.permission('private-1', 'https://a.test', 'camera')).toBe('allow') + prefs.flush() + expect(existsSync(file) ? readFileSync(file, 'utf8') : '').not.toContain('a.test') + prefs.forgetPrivate() + expect(prefs.permission('private-1', 'https://a.test', 'camera')).toBeUndefined() + expect(prefs.devicesFor('private-1', 'https://a.test')).toEqual([]) + }) + + it('remembers each device once and forgets it on request', () => { + const prefs = new SitePrefs(fileIn()) + const board = { type: 'usb' as const, key: 'usb:1:2:A', name: 'Board' } + prefs.grantDevice(DEFAULT_PROFILE_ID, 'https://a.test', board) + prefs.grantDevice(DEFAULT_PROFILE_ID, 'https://a.test', { ...board, name: 'Board again' }) + expect(prefs.devicesFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual([board]) + prefs.revokeDevice(DEFAULT_PROFILE_ID, 'https://a.test', board.key) + expect(prefs.devicesFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual([]) + }) + + it('drops what it doesn’t understand when loading', () => { + const file = fileIn() + writeFileSync( + file, + JSON.stringify({ + permissions: { + default: { 'https://a.test': { camera: 'allow', speaker: 'allow', location: 'maybe' }, 'https://b.test': 5 }, + }, + devices: { + default: { + 'https://a.test': [ + { type: 'usb', key: 'k', name: 'n' }, + { type: 'bluetooth', key: 'x' }, + ], + }, + }, + }), + ) + const prefs = new SitePrefs(file) + prefs.load() + expect(prefs.permissionsFor('default', 'https://a.test')).toEqual({ camera: 'allow' }) + expect(prefs.permissionsFor('default', 'https://b.test')).toEqual({}) + expect(prefs.devicesFor('default', 'https://a.test')).toEqual([{ type: 'usb', key: 'k', name: 'n' }]) + }) +}) + +describe('the tab the top row is about (PK-11)', () => { + function setup() { + const state: OrbitState = createInitialState(0) + const spaceId = state.spaceOrder[0] as string + const page = makeTab({ spaceId, profileId: DEFAULT_PROFILE_ID, url: 'https://a.test/' }, 0) + const peek = makeTab({ spaceId, profileId: DEFAULT_PROFILE_ID, url: 'https://b.test/' }, 0) + peek.floating = 'peek' + state.tabs[page.id] = page + state.tabs[peek.id] = peek + state.spaces[spaceId]?.today.push(page.id) + const win = createWindowState(spaceId) + win.activeTab[spaceId] = page.id + state.windows[win.id] = win + return { state, win, page, peek } + } + + it('is the active tab, or nothing for an unknown window', () => { + const { state, win, page } = setup() + expect(shownTabId(state, win.id)).toBe(page.id) + expect(peekShown(state, win.id)).toBe(false) + expect(shownTabId(state, 'w-gone')).toBeNull() + }) + + it('is the Peek’s page while one shows, and the active tab again while it closes', () => { + const { state, win, page, peek } = setup() + win.overlay = { kind: 'peek', tabId: peek.id, nonce: 1, sourceTabId: page.id } + expect(shownTabId(state, win.id)).toBe(peek.id) + expect(peekShown(state, win.id)).toBe(true) + win.overlay = { kind: 'peek', tabId: peek.id, nonce: 1, sourceTabId: page.id, phase: 'expanding' } + expect(shownTabId(state, win.id)).toBe(peek.id) + win.overlay = { kind: 'peek', tabId: peek.id, nonce: 1, sourceTabId: page.id, phase: 'closing' } + expect(shownTabId(state, win.id)).toBe(page.id) + expect(peekShown(state, win.id)).toBe(false) + }) + + it('ignores a Peek whose page is already gone', () => { + const { state, win, page } = setup() + win.overlay = { kind: 'peek', tabId: 't-gone', nonce: 1 } + expect(shownTabId(state, win.id)).toBe(page.id) + expect(peekShown(state, win.id)).toBe(false) + }) +}) From 579c3b8e86e45a3065451660b039fa0ed84b48c3 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:29:20 +0000 Subject: [PATCH 12/69] nav: loading indicator over the page card, pending address while a page is on its way, dimmed sidebar when inactive SB-28/FL-23: a small theme-tinted pill at the top centre of the shown page card, in its own view above the pages (windows.createUiView), after 150 ms; fills and fades on load end. NV-04: a navigation Orbit started shows its address at once and keeps it when stopped; an error page shows the address that failed. SB-21: the sidebar fades to 84% on blur. waitLoaded also checks the tab's live page (a just-woken tab passed too early). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 12 +- docs/feature-status.json | 5 +- src/main/nav/index.ts | 11 + src/main/nav/loading.ts | 204 +++++++++++++++++++ src/main/nav/pending-url.ts | 74 +++++++ src/main/persist.ts | 2 + src/main/windows.ts | 63 +++++- src/renderer/src/main.tsx | 8 + src/renderer/src/shell/chrome/Address.tsx | 6 +- src/renderer/src/shell/chrome/LoadingApp.tsx | 43 ++++ src/renderer/src/shell/chrome/loading.css | 73 +++++++ src/renderer/src/styles/global.css | 12 +- src/shared/rpc.ts | 13 +- src/shared/state.ts | 5 + tests/e2e/nav-page.spec.ts | 203 ++++++++++++++++++ tests/e2e/nav-support.ts | 16 +- 16 files changed, 733 insertions(+), 17 deletions(-) create mode 100644 src/main/nav/loading.ts create mode 100644 src/main/nav/pending-url.ts create mode 100644 src/renderer/src/shell/chrome/LoadingApp.tsx create mode 100644 src/renderer/src/shell/chrome/loading.css create mode 100644 tests/e2e/nav-page.spec.ts diff --git a/FEATURES.md b/FEATURES.md index b4e4f81b..66f09f18 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ 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:** 485 spec’d · 162 built (45 partial) · 214 tested · 0 feel right. +**Totals:** 485 spec’d · 166 built (44 partial) · 217 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | | P0 | 174 | 117 | 140 | -| P1 | 221 | 41 | 69 | +| P1 | 221 | 45 | 72 | | P2 | 83 | 4 | 5 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -41,14 +41,14 @@ Spec: `spec/sidebar.md` | 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-21 | Dim the sidebar when the window is inactive | P1 | – | – | – | | +| 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 | | SB-24 | Sidebar colouring follows the Space theme | P0 | 🟡 partial | ✅ e2e+unit | – | theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet | | SB-25 | Incognito window sidebar | P1 | ✅ | ✅ e2e | – | private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with | | SB-26 | Platform notes (Windows / Linux) | P2 | – | – | – | | | SB-27 | Link status pill | P1 | – | – | – | | -| SB-28 | Page loading indicator | P1 | – | – | – | | +| SB-28 | Page loading indicator | P1 | ✅ | ✅ e2e | – | loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; shows in full screen; tab rows keep their favicon loading ring | ## Tabs, Pinned tabs, Favorites, Today tabs & auto-archive, Folders @@ -232,7 +232,7 @@ Spec: `spec/navigation.md` | 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 | – | – | – | | | 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 | 🟡 partial | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet | +| NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first), 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; Cmd+J jumps to the selection (macOS); Edit > Find menu | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; 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 | – | – | – | | @@ -396,7 +396,7 @@ Spec: `spec/feel.md` | FL-20 | Favorites tiles | P1 | – | – | – | | | FL-21 | Sidebar scrolling and large-sidebar performance | P0 | – | ✅ e2e | – | | | FL-22 | Toasts | P1 | – | – | – | | -| FL-23 | Page loading indicator | P1 | – | – | – | | +| FL-23 | Page loading indicator | P1 | ✅ | ✅ e2e | – | 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 | | FL-24 | Library and Archive motion | P2 | – | – | – | | | FL-25 | Menus, popovers and tooltips | P1 | – | – | – | | | FL-26 | Recent-tab switcher (Ctrl+Tab) | P1 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 010bf436..5982a6af 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -12,10 +12,12 @@ "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-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"}, "SB-24": {"built": "partial", "note": "theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet"}, "SB-25": {"built": "yes", "note": "private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with"}, + "SB-28": {"built": "yes", "note": "loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; shows in full screen; tab rows keep their favicon loading ring"}, "TB-01": {"built": "yes", "note": "row: favicon/emoji, title or breadcrumb, hover close, audio, selected pill, dimmed when asleep"}, "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop / Cmd+Alt+V paste; background link tabs gesture-gated"}, "TB-03": {"built": "yes", "note": "single rule todayInsertIndex(): top of Today"}, @@ -159,7 +161,7 @@ "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-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": "partial", "note": "Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; the address does not show a pending URL yet"}, + "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), 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; Cmd+J jumps to the selection (macOS); Edit > Find menu"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Peek, Mini Window and pinned-tab links come with the peek lane; hover pill is SB-27; dragging links not built"}, @@ -173,6 +175,7 @@ "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, "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"}, "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"}, "FL-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps"}, "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"}, diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index c6b02ceb..c0024e7f 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -9,11 +9,13 @@ import type { MainContext } from '../context' import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' import { type LinksTestHooks, registerLinks } from './links' +import { type LoadingInfo, registerLoading } from './loading' import { openUrlInNewWindow } from './new-window' import { registerPageBridge } from './page-bridge' import { registerPageColor } from './page-color' import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { type PageToolsInfo, registerPageTools } from './page-tools' +import { registerPendingUrl } from './pending-url' import { registerPermissions } from './permissions' import { type PrivateWindows, registerPrivate } from './private' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' @@ -40,6 +42,8 @@ export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { bounds?: { x: number; y: number; width: number; height: number } hold: RevealHoldInfo | null } + /** A window's page loading indicator (default: the first) (SB-28). */ + loading: (windowId?: WindowId) => LoadingInfo } interface ShownMenu { @@ -80,10 +84,15 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { if (windowId && !store.state.windows[windowId]?.sidebar.visible) ctx.toast(windowId, 'Opened in a new tab') } + // The address while a page is on its way or failed (NV-04, NV-13), and the loading indicator (SB-28). + const pending = registerPendingUrl(ctx) + const loading = registerLoading(ctx) + const go = (tabId: TabId, button: NavButton) => { if (button === 'back') return commands.run('tabs.back', { tabId }) if (button === 'forward') return commands.run('tabs.forward', { tabId }) if (button === 'reload') return commands.run('tabs.reload', { tabId }) + pending.markStopped(tabId) return commands.run('tabs.stop', { tabId }) } @@ -304,6 +313,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { if (!store.state.tabs[tabId]?.loading) return const windowId = windows.windowOfTab(tabId) if (windowId && store.state.windows[windowId]?.overlay.kind !== 'none') return + pending.markStopped(tabId) contents.stop() }) // The mouse's back/forward buttons over a page need nothing here: the page itself goes back or @@ -338,6 +348,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { const id = windowId ?? windows.ids()[0] ?? '' return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } }, + loading: (windowId) => loading.info(windowId ?? windows.ids()[0] ?? ''), } ;(globalThis as unknown as { __orbitNav: NavTestHooks }).__orbitNav = hooks } diff --git a/src/main/nav/loading.ts b/src/main/nav/loading.ts new file mode 100644 index 00000000..cbb6ef84 --- /dev/null +++ b/src/main/nav/loading.ts @@ -0,0 +1,204 @@ +import type { WebContentsView } from 'electron' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { activeTabOf } from '../windows' + +/** A page has to be loading this long before the indicator shows, so quick loads never flash it (FL-23). */ +export const LOADING_SHOW_AFTER_MS = 150 +/** The indicator's finish (a short fill, then a fade; loading.css) before its view goes: within 200 ms (FL-23). */ +const LOADING_FINISH_MS = 180 +/** + * The indicator's view: a small pill centred just inside the page card's top edge, with room for its + * shadow. Kept small, as it sits over the page (a view takes the clicks under it). + */ +export const LOADING_VIEW = { width: 60, height: 14, top: 2 } as const + +export type LoadingState = 'loading' | 'done' + +export interface LoadingInfo { + tabId: TabId | null + visible: boolean + state: LoadingState | null + bounds?: Electron.Rectangle + /** How many times it has been shown in this window (tests: a quick load never shows it). */ + shown: number + /** Waiting out the delay before it shows. */ + pending: boolean +} + +interface WindowLoading { + view?: WebContentsView + /** The tab the indicator is out for. */ + tabId: TabId | null + state: LoadingState | null + showTimer?: ReturnType<typeof setTimeout> + hideTimer?: ReturnType<typeof setTimeout> + shown: number +} + +/** + * The page loading indicator (SB-28, FL-23): a small theme-tinted pill at the top centre of the + * page card of the window's shown tab (the focused pane in split view), drawn in a view of its own + * over the page, so it never moves page content and shows in full screen too. It appears once the + * page has been loading for 150 ms; when the load ends (or is stopped) it fills and fades out. + */ +export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) => LoadingInfo } { + const { store, windows } = ctx + /** When each loading tab started loading. */ + const since = new Map<TabId, number>() + const perWindow = new Map<WindowId, WindowLoading>() + + const stateOf = (windowId: WindowId): WindowLoading | undefined => { + let s = perWindow.get(windowId) + if (s) return s + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return undefined + s = { tabId: null, state: null, shown: 0 } + perWindow.set(windowId, s) + const record = s + win.once('closed', () => { + clearTimeout(record.showTimer) + clearTimeout(record.hideTimer) + perWindow.delete(windowId) + }) + return s + } + + const live = (view: WebContentsView | undefined): view is WebContentsView => !!view && !view.webContents.isDestroyed() + + const send = (s: WindowLoading, state: LoadingState) => { + s.state = state + if (live(s.view)) s.view.webContents.send('orbit:loading:state', { state }) + } + + /** Where the pill goes over a tab's page, or undefined when the page isn't on screen. */ + const boundsFor = (tabId: TabId): Electron.Rectangle | undefined => { + const page = windows.tabViewBounds(tabId) + if (!page || page.width < LOADING_VIEW.width || page.height < LOADING_VIEW.height) return undefined + return { + x: Math.round(page.x + page.width / 2 - LOADING_VIEW.width / 2), + y: Math.round(page.y + LOADING_VIEW.top), + width: LOADING_VIEW.width, + height: LOADING_VIEW.height, + } + } + + const hide = (windowId: WindowId, s: WindowLoading) => { + clearTimeout(s.hideTimer) + s.hideTimer = undefined + s.tabId = null + s.state = null + if (live(s.view)) windows.hideUiView(windowId, s.view) + } + + const show = (windowId: WindowId, s: WindowLoading, tabId: TabId) => { + const bounds = boundsFor(tabId) + if (!bounds) { + hide(windowId, s) + return + } + clearTimeout(s.hideTimer) + s.hideTimer = undefined + if (!live(s.view)) s.view = windows.createUiView(windowId, 'loading') + if (!s.view) return + const fresh = s.tabId !== tabId || s.state !== 'loading' + s.tabId = tabId + send(s, 'loading') + windows.showUiView(windowId, s.view, bounds) + if (fresh) s.shown++ + } + + /** The load the indicator was out for ended: it finishes, then goes. */ + const finish = (windowId: WindowId, s: WindowLoading) => { + if (s.state === 'done') return + send(s, 'done') + clearTimeout(s.hideTimer) + s.hideTimer = setTimeout(() => { + s.hideTimer = undefined + if (s.state === 'done') hide(windowId, s) + }, LOADING_FINISH_MS) + } + + /** Brings a window's indicator in line with its shown tab and that tab's loading. */ + const evaluate = (windowId: WindowId) => { + const s = stateOf(windowId) + if (!s) return + clearTimeout(s.showTimer) + s.showTimer = undefined + const target = activeTabOf(store.state, windowId) + const started = target ? since.get(target) : undefined + if (!target || started === undefined) { + // The page finished (or was stopped): the pill fills and fades. Another tab took its place: it goes. + if (s.tabId && s.tabId === target && s.state === 'loading') finish(windowId, s) + else if (s.tabId && s.tabId !== target) hide(windowId, s) + return + } + if (s.tabId === target && s.state === 'loading') { + // Still loading: follow the page card if it moved. + show(windowId, s, target) + return + } + const wait = started + LOADING_SHOW_AFTER_MS - Date.now() + if (wait <= 0) { + show(windowId, s, target) + return + } + // Not long enough yet; a tab that was out for another page goes at once. + if (s.tabId && s.tabId !== target) hide(windowId, s) + s.showTimer = setTimeout(() => { + s.showTimer = undefined + if (perWindow.get(windowId) === s) evaluate(windowId) + }, wait) + } + + const evaluateTab = (tabId: TabId) => { + const windowId = windows.windowOfTab(tabId) + if (windowId) evaluate(windowId) + } + + ctx.hooks.tabCreated.push((tabId, contents) => { + contents.on('did-start-loading', () => { + since.set(tabId, Date.now()) + evaluateTab(tabId) + }) + contents.on('did-stop-loading', () => { + since.delete(tabId) + evaluateTab(tabId) + }) + contents.once('destroyed', () => { + since.delete(tabId) + for (const [windowId, s] of perWindow) if (s.tabId === tabId) evaluate(windowId) + }) + }) + + // Another tab shown (tab switch, Space switch, split focus), or the page card moved. + store.subscribe((next, prev) => { + for (const windowId of perWindow.keys()) { + if (activeTabOf(next, windowId) !== activeTabOf(prev, windowId)) evaluate(windowId) + } + for (const windowId of Object.keys(next.windows)) { + if (perWindow.has(windowId)) continue + const tabId = activeTabOf(next, windowId) + if (tabId && since.has(tabId)) evaluate(windowId) + } + }) + windows.onLayout((windowId) => { + const s = perWindow.get(windowId) + if (s?.tabId) evaluate(windowId) + }) + + return { + info: (windowId) => { + const s = perWindow.get(windowId) + const view = live(s?.view) ? s?.view : undefined + return { + tabId: s?.tabId ?? null, + visible: !!view?.getVisible(), + state: s?.state ?? null, + bounds: view?.getBounds(), + shown: s?.shown ?? 0, + pending: !!s?.showTimer, + } + }, + } +} diff --git a/src/main/nav/pending-url.ts b/src/main/nav/pending-url.ts new file mode 100644 index 00000000..6ae9a1ec --- /dev/null +++ b/src/main/nav/pending-url.ts @@ -0,0 +1,74 @@ +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' + +/** Chromium's net::ERR_ABORTED: a navigation that was replaced, or turned into a download. */ +const ERR_ABORTED = -3 + +/** Addresses that load in the tab; others (mailto:, a custom app's scheme…) go to another app. */ +const LOADS_IN_TAB = /^(https?|file):/i + +/** + * The address while a page is on its way (NV-04) or failed to load (NV-13). + * + * Where a navigation Orbit started is going, before it commits (an address typed in the command + * bar, Back or Forward, Reload, a link Orbit opened in a new tab): the address display shows it at + * once, as Chromium's address bar does, and keeps it when the user stops the load (Esc, ⌘., the + * Stop button), so it can be tried again. A load that ends some other way without a page (a + * download, an empty response, another app) puts the address back. A page's own navigations (its + * links, its scripts) show only once they commit, so a page can never put an address it doesn't + * have over its own content. + * + * An error page commits without `did-navigate`; the address then shows the address that failed, + * so Reload tries it again. + */ +export function registerPendingUrl(ctx: MainContext): { markStopped: (tabId: TabId) => void } { + const { store } = ctx + /** Tabs whose pending load the user stopped: their address stays where it was going. */ + const stopped = new Set<TabId>() + + const setPending = (tabId: TabId, url: string | undefined) => { + if (store.state.tabs[tabId]?.pendingUrl === url) return + store.update((d) => { + const tab = d.tabs[tabId] + if (!tab) return + if (url === undefined) delete tab.pendingUrl + else tab.pendingUrl = url + }) + } + + ctx.hooks.tabCreated.push((tabId, contents) => { + contents.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument) return + stopped.delete(tabId) + // No initiating frame: Orbit started it (loadURL, history, reload), not the page. + const fromOrbit = !details.initiator && LOADS_IN_TAB.test(details.url) + setPending(tabId, fromOrbit ? details.url : undefined) + }) + contents.on('did-navigate', () => setPending(tabId, undefined)) + contents.on('did-fail-provisional-load', (_event, code, _description, url, isMainFrame) => { + if (!isMainFrame || code === ERR_ABORTED) return + // An error page took the page's place. + stopped.delete(tabId) + store.update((d) => { + const tab = d.tabs[tabId] + if (!tab) return + delete tab.pendingUrl + if (url && tab.url !== url) tab.url = url + }) + }) + contents.on('did-stop-loading', () => { + // Stopped by the user: the address stays. Ended without a page any other way: it goes back. + if (!stopped.has(tabId)) setPending(tabId, undefined) + }) + contents.once('destroyed', () => { + stopped.delete(tabId) + setPending(tabId, undefined) + }) + }) + + return { + markStopped: (tabId) => { + if (store.state.tabs[tabId]?.pendingUrl) stopped.add(tabId) + }, + } +} diff --git a/src/main/persist.ts b/src/main/persist.ts index 8543bebe..9b606c82 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -133,6 +133,7 @@ export function normalizeState(input: Partial<OrbitState>): OrbitState { tab.error = undefined tab.blockedPopups = undefined tab.permissionPrompt = undefined + tab.pendingUrl = undefined tab.canGoBack = tab.canGoBack ?? false tab.canGoForward = tab.canGoForward ?? false tab.muted = tab.muted ?? false @@ -423,6 +424,7 @@ const RUNTIME_ONLY: Record<string, ReadonlySet<string>> = { 'canGoForward', 'blockedPopups', 'permissionPrompt', + 'pendingUrl', ]), windows: new Set(['focused', 'fullscreen', 'overlay']), downloads: new Set(['receivedBytes']), diff --git a/src/main/windows.ts b/src/main/windows.ts index 4cfe0b71..df8741fd 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -77,6 +77,11 @@ interface WindowRecord { */ prompt?: WebContentsView promptShown: boolean + /** + * Other modules' small views over the pages (createUiView): the loading indicator (SB-28), the + * link pill (SB-27), page dialogs (NV-24). Kept above the pages, closed with the window. + */ + uiViews: Set<WebContentsView> } /** A tab view placed in a window. */ @@ -369,6 +374,7 @@ export class WindowManager { floatingShown: false, toolsShown: false, promptShown: false, + uiViews: new Set(), } this.records.set(id, rec) // macOS: the traffic lights live in the sidebar's top row (SB-20). @@ -450,6 +456,8 @@ export class WindowManager { if (rec.floating && !rec.floating.webContents.isDestroyed()) rec.floating.webContents.close() if (rec.tools && !rec.tools.webContents.isDestroyed()) rec.tools.webContents.close() if (rec.prompt && !rec.prompt.webContents.isDestroyed()) rec.prompt.webContents.close() + for (const view of rec.uiViews) if (!view.webContents.isDestroyed()) view.webContents.close() + rec.uiViews.clear() this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { if (entry.windowId === id) { @@ -713,6 +721,55 @@ export class WindowManager { return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } } + /** + * A small transparent Orbit UI view for another module (the loading indicator, SB-28; the link + * pill, SB-27; page dialogs, NV-24): hidden until showUiView(), kept above the window's pages + * (under the overlay and the floating sidebar) and closed with the window. + */ + createUiView(windowId: WindowId, view: RendererView): WebContentsView | undefined { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed()) return undefined + const created = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, view) }) + created.setBackgroundColor('#00000000') + created.setVisible(false) + this.registerUi(created.webContents, rec.id) + this.reloadUiOnCrash(created.webContents, view) + this.attachShortcuts(created.webContents) + this.hardenUi(created.webContents) + loadRenderer(created.webContents, view) + rec.uiViews.add(created) + return created + } + + /** Shows a createUiView() view at `bounds` (window coordinates); `focus` gives it the keyboard. */ + showUiView(windowId: WindowId, view: WebContentsView, bounds: Rect, focus = false): void { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || view.webContents.isDestroyed()) return + const next = roundRect(bounds) + const current = view.getBounds() + if ( + current.x !== next.x || + current.y !== next.y || + current.width !== next.width || + current.height !== next.height + ) { + view.setBounds(next) + } + this.placeAbovePages(rec, view) + if (!view.getVisible()) view.setVisible(true) + if (focus) view.webContents.focus() + } + + /** Hides a createUiView() view; keyboard focus in it goes back to the page. */ + hideUiView(windowId: WindowId, view: WebContentsView): void { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || view.webContents.isDestroyed()) return + if (!view.getVisible()) return + const focused = webContents.getFocusedWebContents() === view.webContents + view.setVisible(false) + if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) + } + /** Puts a small view above every page, under the overlay and the floating sidebar; leaves it be when it already is. */ private placeAbovePages(rec: WindowRecord, view: WebContentsView): void { const parent = rec.win.contentView @@ -959,9 +1016,9 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - // The overlay, the floating sidebar (SB-12), the page tools (NV-05) and a permission prompt - // (NV-15) stay above every page. - const above = [rec.overlay, rec.floating, rec.tools, rec.prompt] + // The overlay, the floating sidebar (SB-12), the page tools (NV-05), a permission prompt + // (NV-15) and other modules' small views (createUiView) stay above every page. + const above = [rec.overlay, rec.floating, rec.tools, rec.prompt, ...rec.uiViews] .map((v) => (v ? children.indexOf(v) : -1)) .filter((i) => i >= 0) const overlayAt = above.length > 0 ? Math.min(...above) : -1 diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 228cfaab..a56f5a61 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -57,6 +57,14 @@ async function boot(): Promise<void> { <PromptViewApp /> </StrictMode>, ) + } else if (view === 'loading') { + // The page loading indicator at the top of the page card (chrome lane, SB-28). + const { LoadingApp } = await import('./shell/chrome/LoadingApp') + root.render( + <StrictMode> + <LoadingApp /> + </StrictMode>, + ) } else if (view === 'strip') { // Split view pane strip (split lane, SV-12): a tiny transparent view over a pane's top edge. const { PaneStripApp } = await import('./shell/split/PaneStripApp') diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx index 7f2f469f..8383f7d6 100644 --- a/src/renderer/src/shell/chrome/Address.tsx +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -19,10 +19,12 @@ const COPIED_MS = 1200 */ export function Address({ windowId }: { windowId: string }) { // Only the URL: the display re-renders on navigations, not on title, favicon or progress changes. - // While a Peek shows, it is the Peek's page (PK-11). + // While a Peek shows, it is the Peek's page (PK-11). A navigation Orbit started shows where it is + // going before it commits, and after the user stopped it (NV-04). const url = useOrbit((s) => { const id = shownTabId(s, windowId) - return id ? (s.tabs[id]?.url ?? '') : '' + const tab = id ? s.tabs[id] : undefined + return tab ? (tab.pendingUrl ?? tab.url ?? '') : '' }) const blockedPopups = useOrbit((s) => { const id = shownTabId(s, windowId) diff --git a/src/renderer/src/shell/chrome/LoadingApp.tsx b/src/renderer/src/shell/chrome/LoadingApp.tsx new file mode 100644 index 00000000..9b5b103b --- /dev/null +++ b/src/renderer/src/shell/chrome/LoadingApp.tsx @@ -0,0 +1,43 @@ +import { type CSSProperties, useEffect, useState } from 'react' +import { useWindow } from '../../store' +import { paletteVars, usePalette } from '../../theme' +import './loading.css' + +type LoadingState = 'loading' | 'done' + +/** + * The page loading indicator (SB-28, FL-23): a small view main places at the top centre of the + * shown page card while the page loads. A short theme-tinted sweep runs on a steady 1.2 s cycle; + * when the load ends it fills the pill and fades out (main hides the view after that). + */ +export function LoadingApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + // The view only shows while a page loads: it starts out loading. + const [state, setState] = useState<LoadingState>('loading') + // A new load restarts the sweep from the left. + const [run, setRun] = useState(0) + useEffect( + () => + window.orbit.on('loading:state', (payload) => { + const next = (payload as { state?: LoadingState }).state === 'done' ? 'done' : 'loading' + setState((current) => { + if (next === 'loading' && current !== 'loading') setRun((n) => n + 1) + return next + }) + }), + [], + ) + return ( + <div + className={`loading-view is-${state}`} + style={paletteVars(palette) as CSSProperties} + data-testid="loading-indicator" + data-state={state} + > + <div className="loading-view__pill" role="progressbar" aria-label="Loading"> + <div key={run} className="loading-view__sweep" /> + </div> + </div> + ) +} diff --git a/src/renderer/src/shell/chrome/loading.css b/src/renderer/src/shell/chrome/loading.css new file mode 100644 index 00000000..c7334de4 --- /dev/null +++ b/src/renderer/src/shell/chrome/loading.css @@ -0,0 +1,73 @@ +/* The page loading indicator (SB-28, FL-23): its own small transparent view over the page card. */ +html[data-view="loading"] body { + background: transparent; + overflow: hidden; +} + +.loading-view { + display: flex; + justify-content: center; + padding-top: 4px; + height: 100%; + pointer-events: none; +} + +.loading-view__pill { + position: relative; + width: 48px; + height: 4px; + border-radius: 2px; + overflow: hidden; + background: color-mix(in srgb, var(--accent, #6b7cff) 24%, var(--surface, #fff)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--surface, #fff) 70%, transparent), + 0 1px 4px rgba(0, 0, 0, 0.22); +} + +.loading-view__sweep { + position: absolute; + inset: 0 auto 0 0; + width: 40%; + border-radius: inherit; + background: var(--accent, #6b7cff); + animation: loading-sweep 1.2s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} + +/* The load ended: the sweep fills the pill (80 ms), then the pill fades (150 ms); main takes the + view away 180 ms after the load ended (LOADING_FINISH_MS). A new load shows it again at once. */ +.loading-view.is-done .loading-view__sweep { + animation: none; + width: 100%; + transition: width 80ms ease-out; +} + +.loading-view.is-done .loading-view__pill { + opacity: 0; + transition: opacity 150ms ease-out 30ms; +} + +@keyframes loading-sweep { + from { + transform: translateX(-100%); + } + to { + transform: translateX(250%); + } +} + +/* Reduced motion: no sweep, a slow pulse of the whole pill (FL-28). */ +@media (prefers-reduced-motion: reduce) { + .loading-view__sweep { + width: 100%; + animation: loading-pulse 1.2s ease-in-out infinite alternate; + } + + @keyframes loading-pulse { + from { + opacity: 0.35; + } + to { + opacity: 1; + } + } +} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 23245a4d..6c561eaf 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -194,8 +194,16 @@ html.is-resizing * { visibility 0s linear var(--sidebar-dur); } -.shell--blurred .sidebar { - --text: color-mix(in srgb, currentColor 100%, transparent); +/* An inactive window's sidebar dims a little (SB-21): over 150 ms on blur, back at once on focus. */ +.shell--blurred .sidebar > * { + opacity: 0.84; + transition: opacity 150ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .shell--blurred .sidebar > * { + transition: none; + } } .topbar { diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 3936cf98..3f7a258a 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -61,7 +61,18 @@ export interface Rect { } /** Which UI a renderer instance is showing. */ -export type RendererView = 'shell' | 'overlay' | 'mini' | 'settings' | 'strip' | 'sidebar' | 'tools' | 'prompt' +export type RendererView = + | 'shell' + | 'overlay' + | 'mini' + | 'settings' + | 'strip' + | 'sidebar' + | 'tools' + | 'prompt' + /** The page loading indicator (SB-28) and the link status pill (SB-27), chrome lane. */ + | 'loading' + | 'link' /** The API the preload exposes on `window.orbit` to Orbit's own UI renderers. */ export interface OrbitBridge { diff --git a/src/shared/state.ts b/src/shared/state.ts index a6aafd8b..f9fbc5f7 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -69,6 +69,11 @@ export interface Tab { blockedPopups?: number /** Runtime-only: the permission prompt waiting for an answer on this tab (NV-15). */ permissionPrompt?: PermissionPrompt + /** + * Runtime-only: where a navigation Orbit started (typed, Back, Reload…) is going before it + * commits; the address display shows it (NV-04). + */ + pendingUrl?: string /** * Set while the tab lives outside the sidebar: the page of a Peek card or of a Mini Window. * Such a tab sits in no container (and is not persisted) until it is expanded or moved into a Space. diff --git a/tests/e2e/nav-page.spec.ts b/tests/e2e/nav-page.spec.ts new file mode 100644 index 00000000..bc8de2bb --- /dev/null +++ b/tests/e2e/nav-page.spec.ts @@ -0,0 +1,203 @@ +import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { launchOrbit, type Orbit, state } from './helpers' +import { cmd, type NavFixtures, openTab, pressInPage, startNavFixtures, waitLoaded, windowState } from './nav-support' + +/** + * A page's life in its card: the inactive window's sidebar (SB-21), the loading indicator (SB-28) + * and the address while a page is on its way (NV-04). + */ + +let fx: NavFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +const page = (title: string) => fx.url(`/page/${title}`) + +interface LoadingInfo { + tabId: string | null + visible: boolean + state: 'loading' | 'done' | null + bounds?: { x: number; y: number; width: number; height: number } + shown: number + pending: boolean +} + +type NavHooks = { __orbitNav: { loading: (windowId?: string) => LoadingInfo } } + +function loading(app: ElectronApplication): Promise<LoadingInfo> { + return app.evaluate(() => (globalThis as unknown as NavHooks).__orbitNav.loading()) +} + +/** The loading indicator's own page (`view=loading`), once it has started. */ +async function loadingPage(app: ElectronApplication): Promise<Page> { + let found: Page | undefined + await expect + .poll(() => { + found = app.windows().find((p) => p.url().includes('view=loading')) + return !!found + }) + .toBe(true) + return found as Page +} + +/** A tab's page bounds in its window (window coordinates). */ +function pageBounds(app: ElectronApplication, tabId: string) { + return app.evaluate( + (_e, id) => + ( + globalThis as unknown as { + __orbit: { ctx: { windows: { tabViewBounds: (t: string) => Electron.Rectangle | undefined } } } + } + ).__orbit.ctx.windows.tabViewBounds(id), + tabId, + ) +} + +/** Tells the first window it lost (or got) the focus, as the OS does when another app comes forward. */ +function focusEvent(app: ElectronApplication, kind: 'blur' | 'focus') { + return app.evaluate(({ BrowserWindow }, k) => { + for (const w of BrowserWindow.getAllWindows()) if (w.webContents.getURL().includes('view=shell')) w.emit(k) + }, kind) +} + +test('[SB-21] an inactive window dims its sidebar over 150 ms and brightens at once on focus', async () => { + const { app, shell } = orbit + await openTab(app, page('Dim')) + const topbar = shell.getByTestId('topbar') + const opacity = () => topbar.evaluate((el) => Number(getComputedStyle(el).opacity)) + await focusEvent(app, 'focus') + await expect.poll(async () => (await windowState(app)).focused).toBe(true) + await expect.poll(opacity).toBe(1) + await focusEvent(app, 'blur') + await expect.poll(async () => (await windowState(app)).focused).toBe(false) + // Dimmed by about 16 %, with a short fade (not at once). + expect( + await topbar.evaluate((el) => { + const style = getComputedStyle(el) + return { duration: style.transitionDuration, property: style.transitionProperty } + }), + ).toEqual({ duration: '0.15s', property: 'opacity' }) + await expect.poll(opacity).toBeCloseTo(0.84, 2) + // The whole sidebar dims: the Space's list too. + expect(await shell.getByTestId('space-strip').evaluate((el) => Number(getComputedStyle(el).opacity))).toBeCloseTo( + 0.84, + 2, + ) + await focusEvent(app, 'focus') + await expect.poll(async () => (await windowState(app)).focused).toBe(true) + // Back at once: no transition on the way back. + expect(await topbar.evaluate((el) => getComputedStyle(el).transitionDuration)).not.toBe('0.15s') + await expect.poll(opacity).toBe(1) +}) + +test('[SB-28][FL-23] a slow page shows the loading indicator at the top centre of its card; it finishes and goes when the page is in', async () => { + const { app } = orbit + const id = await openTab(app, page('Start')) + const before = (await loading(app)).shown + await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/Slowly?ms=1500') }) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) + // Out after the page has been loading a little while, for this tab. + await expect.poll(async () => (await loading(app)).visible).toBe(true) + const info = await loading(app) + expect(info.tabId).toBe(id) + expect(info.state).toBe('loading') + expect(info.shown).toBe(before + 1) + const card = await pageBounds(app, id) + if (!card || !info.bounds) throw new Error('no bounds') + // Centred on the card, just inside its top edge. + expect(Math.abs(info.bounds.x + info.bounds.width / 2 - (card.x + card.width / 2))).toBeLessThanOrEqual(1) + expect(info.bounds.y - card.y).toBe(2) + const view = await loadingPage(app) + const indicator = view.getByTestId('loading-indicator') + await expect(indicator).toHaveAttribute('data-state', 'loading') + // The sweep moves (an indeterminate animation). + expect(await view.locator('.loading-view__sweep').evaluate((el) => getComputedStyle(el).animationName)).toBe( + 'loading-sweep', + ) + // The page arrives: the pill finishes, then its view goes. + await waitLoaded(app, id, fx.url('/slow/Slowly?ms=1500')) + await expect.poll(async () => (await loading(app)).visible, { timeout: 2000 }).toBe(false) +}) + +test('[SB-28][FL-23] a load shorter than the delay never shows the indicator; switching tabs takes it away', async () => { + const { app } = orbit + const id = await openTab(app, page('Quick')) + const before = (await loading(app)).shown + // A load that starts and ends within the delay (a cached page), whatever the machine's speed: the + // page's own start and stop, back to back. The indicator waits out the delay after a start… + const afterStart = await app.evaluate(({ webContents }, part) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL().includes(part)) + if (!contents) throw new Error('no page') + contents.emit('did-start-loading') + const g = globalThis as unknown as { __orbitNav: { loading: () => { pending: boolean; visible: boolean } } } + const waiting = g.__orbitNav.loading() + contents.emit('did-stop-loading') + return waiting + }, '/page/Quick') + expect(afterStart).toMatchObject({ pending: true, visible: false }) + // …and a stop within it means it never shows. + expect(await loading(app)).toMatchObject({ visible: false, pending: false, shown: before }) + // A slow page in one tab, then another tab: the indicator goes with its tab and comes back with it. + await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/Long?ms=30000') }) + await expect.poll(async () => (await loading(app)).visible).toBe(true) + await openTab(app, page('Other')) + await expect.poll(async () => (await loading(app)).visible).toBe(false) + await cmd(app, 'tabs.activate', { tabId: id }) + await expect.poll(async () => (await loading(app)).tabId).toBe(id) + await expect.poll(async () => (await loading(app)).visible).toBe(true) +}) + +test('[NV-04][SB-28] the address shows where a typed address is going; Esc stops it, the indicator goes and the address stays', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Steady')) + const pill = shell.getByTestId('address-pill') + await expect(pill).toHaveText(`localhost:${fx.port}`) + // Another host, so the address visibly changes before the page answers. + const pending = fx.urlOn('127.0.0.1', '/slow/Pending') + await cmd(app, 'tabs.navigate', { tabId: id, url: pending }) + await expect(pill).toHaveText(`127.0.0.1:${fx.port}`) + await expect(pill).toHaveAttribute('title', pending) + await expect.poll(async () => (await loading(app)).visible).toBe(true) + // Esc in the page stops the load: the indicator goes, the address keeps where it was going. + await pressInPage(app, '/page/Steady', 'Escape') + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + await expect.poll(async () => (await loading(app)).visible, { timeout: 2000 }).toBe(false) + await expect(pill).toHaveText(`127.0.0.1:${fx.port}`) + expect((await state(app)).tabs[id]?.url).toBe(page('Steady')) + // The next navigation that commits takes over. + await cmd(app, 'tabs.navigate', { tabId: id, url: page('Next') }) + await waitLoaded(app, id, page('Next')) + await expect(pill).toHaveText(`localhost:${fx.port}`) + expect((await state(app)).tabs[id]?.pendingUrl).toBeUndefined() +}) + +test('[NV-04] a page’s own navigation shows only once it commits', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Own')) + const pill = shell.getByTestId('address-pill') + const target = fx.urlOn('127.0.0.1', '/slow/Script?ms=30000') + await app.evaluate( + ({ webContents }, [part, url]) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL().includes(part)) + void contents?.executeJavaScript(`location.href = ${JSON.stringify(url)}`) + }, + ['/page/Own', target] as const, + ) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) + await expect.poll(() => fx.hits('/slow/Script')).toBe(1) + // The page is still the old one: so is the address. + await expect(pill).toHaveText(`localhost:${fx.port}`) + expect((await state(app)).tabs[id]?.pendingUrl).toBeUndefined() +}) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index aa2c386c..410ad475 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -166,7 +166,10 @@ type Hooks = { run: (n: string, a: unknown, w?: string) => Promise<unknown> windowIds: () => string[] flush: () => unknown - ctx: { windows: { get: (id: string) => Electron.BrowserWindow | undefined } } + ctx: { + windows: { get: (id: string) => Electron.BrowserWindow | undefined } + tabs: { contents: (id: string) => Electron.WebContents | undefined } + } } __orbitNav: { menu: () => { windowId: string; direction: 'back' | 'forward'; labels: string[]; icons: boolean[] } | null @@ -494,7 +497,16 @@ export async function waitLoaded(app: ElectronApplication, tabId: string, url: s .poll(async () => { const t = (await state(app)).tabs[tabId] // Until the page's <title> arrives, Chromium titles the tab with its address. - return !!t && t.url === url && !t.loading && !t.asleep && !!t.title && !t.title.includes('/') + if (!t || t.url !== url || t.loading || t.asleep || !t.title || t.title.includes('/')) return false + // A tab that has just woken still has its stored address and a host-name title before its load + // starts: the page itself has to be there too. + return app.evaluate( + (_e, [id, u]) => { + const page = (globalThis as unknown as Hooks).__orbit.ctx.tabs.contents(id) + return !!page && !page.isDestroyed() && page.getURL() === u && !page.isLoading() + }, + [tabId, url] as const, + ) }) .toBe(true) } From 534ab545c46e83114d95862d7638dbe5ad2881a3 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:41:34 +0000 Subject: [PATCH 13/69] windows: never call into a view's page once it is gone (D28) A view's webContents is undefined once its page is gone, so a timer or handler that touched view.webContents.isDestroyed() after its window closed threw, and the error box blocked quitting. One guarded accessor, viewContents(view), now stands in for every such access in the window manager and the loading indicator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/loading.ts | 7 +++-- src/main/windows.ts | 63 ++++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/main/nav/loading.ts b/src/main/nav/loading.ts index cbb6ef84..6e4f61a9 100644 --- a/src/main/nav/loading.ts +++ b/src/main/nav/loading.ts @@ -1,7 +1,7 @@ import type { WebContentsView } from 'electron' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' -import { activeTabOf } from '../windows' +import { activeTabOf, viewContents } from '../windows' /** A page has to be loading this long before the indicator shows, so quick loads never flash it (FL-23). */ export const LOADING_SHOW_AFTER_MS = 150 @@ -64,11 +64,12 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) return s } - const live = (view: WebContentsView | undefined): view is WebContentsView => !!view && !view.webContents.isDestroyed() + /** A view whose page is alive (its `webContents` is undefined once the page is gone). */ + const live = (view: WebContentsView | undefined): view is WebContentsView => !!viewContents(view) const send = (s: WindowLoading, state: LoadingState) => { s.state = state - if (live(s.view)) s.view.webContents.send('orbit:loading:state', { state }) + viewContents(s.view)?.send('orbit:loading:state', { state }) } /** Where the pill goes over a tab's page, or undefined when the page isn't on screen. */ diff --git a/src/main/windows.ts b/src/main/windows.ts index df8741fd..aeadd92e 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -158,6 +158,16 @@ export function shownTabs(state: OrbitState, windowId: WindowId): Set<TabId> { return new Set(split ? splitLeaves(split.layout) : [active]) } +/** + * A view's web contents while its page is alive. Once the page is gone `view.webContents` is + * undefined, so it is never dereferenced blindly: an exception in a timer or handler would block + * quitting (D28). + */ +export function viewContents(view: WebContentsView | undefined): WebContents | undefined { + const contents = view?.webContents as WebContents | undefined + return contents && !contents.isDestroyed() ? contents : undefined +} + /** The active tab of a window's current space. */ export function activeTabOf(state: OrbitState, windowId: WindowId): TabId | null { const win = state.windows[windowId] @@ -453,10 +463,10 @@ export class WindowManager { clearTimeout(rec.floatingDispose) clearTimeout(rec.buttonsTimer) clearTimeout(rec.toolsPrewarm) - if (rec.floating && !rec.floating.webContents.isDestroyed()) rec.floating.webContents.close() - if (rec.tools && !rec.tools.webContents.isDestroyed()) rec.tools.webContents.close() - if (rec.prompt && !rec.prompt.webContents.isDestroyed()) rec.prompt.webContents.close() - for (const view of rec.uiViews) if (!view.webContents.isDestroyed()) view.webContents.close() + viewContents(rec.floating)?.close() + viewContents(rec.tools)?.close() + viewContents(rec.prompt)?.close() + for (const view of rec.uiViews) viewContents(view)?.close() rec.uiViews.clear() this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { @@ -465,7 +475,7 @@ export class WindowManager { this.ctx.tabs.sleep(tabId) } } - if (rec.overlay && !rec.overlay.webContents.isDestroyed()) rec.overlay.webContents.close() + viewContents(rec.overlay)?.close() if (this.quitting) return if (this.ctx.store.state.windows[id]?.kind === 'main' && !this.hasOpenMain()) { // The last main window: keep its state. Linux and Windows quit right after this and @@ -531,7 +541,7 @@ export class WindowManager { const forSidebar = channel.startsWith('sidebar:') || channel.startsWith('spaces:') if (floating && forSidebar) floating.send(`orbit:${channel}`, payload) else if (!rec.win.isDestroyed()) rec.win.webContents.send(`orbit:${channel}`, payload) - if (rec.overlay && !rec.overlay.webContents.isDestroyed()) rec.overlay.webContents.send(`orbit:${channel}`, payload) + viewContents(rec.overlay)?.send(`orbit:${channel}`, payload) } /** The corner radius a shown page was last given (SB-19), for tests. */ @@ -543,8 +553,7 @@ export class WindowManager { /** The floating sidebar's web contents while its card is out (SB-12). */ floatingContents(windowId: WindowId): WebContents | undefined { const rec = this.records.get(windowId) - const contents = rec?.floatingShown ? rec.floating?.webContents : undefined - return contents && !contents.isDestroyed() ? contents : undefined + return rec?.floatingShown ? viewContents(rec.floating) : undefined } /** Where Orbit's sidebar is drawn: the floating card while it is out, else the window's shell. */ @@ -583,7 +592,7 @@ export class WindowManager { floatingInfo(windowId: WindowId): { exists: boolean; placed: boolean; shown: boolean; bounds?: Rect } { const rec = this.records.get(windowId) const view = rec?.floating - if (!rec || !view || view.webContents.isDestroyed()) return { exists: false, placed: false, shown: false } + if (!rec || !view || !viewContents(view)) return { exists: false, placed: false, shown: false } return { exists: true, placed: rec.win.contentView.children.includes(view), @@ -639,8 +648,7 @@ export class WindowManager { toolsContents(windowId: WindowId): WebContents | undefined { const rec = this.records.get(windowId) if (!rec || rec.win.isDestroyed()) return undefined - const contents = this.ensureTools(rec).webContents - return contents.isDestroyed() ? undefined : contents + return viewContents(this.ensureTools(rec)) } /** Places the page tools at `bounds` (window coordinates): above the pages, under the floating sidebar and the overlay. */ @@ -669,7 +677,8 @@ export class WindowManager { const view = rec?.tools if (!rec || !view || !rec.toolsShown) return rec.toolsShown = false - const focused = !view.webContents.isDestroyed() && webContents.getFocusedWebContents() === view.webContents + const contents = viewContents(view) + const focused = !!contents && webContents.getFocusedWebContents() === contents if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) this.removeChild(rec, view) } @@ -678,7 +687,7 @@ export class WindowManager { toolsInfo(windowId: WindowId): { exists: boolean; placed: boolean; bounds?: Rect } { const rec = this.records.get(windowId) const view = rec?.tools - if (!rec || !view || view.webContents.isDestroyed()) return { exists: false, placed: false } + if (!rec || !view || !viewContents(view)) return { exists: false, placed: false } return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } } @@ -708,7 +717,8 @@ export class WindowManager { const view = rec?.prompt if (!rec || !view || !rec.promptShown) return rec.promptShown = false - const focused = !view.webContents.isDestroyed() && webContents.getFocusedWebContents() === view.webContents + const contents = viewContents(view) + const focused = !!contents && webContents.getFocusedWebContents() === contents if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) this.removeChild(rec, view) } @@ -717,7 +727,7 @@ export class WindowManager { promptInfo(windowId: WindowId): { exists: boolean; placed: boolean; bounds?: Rect } { const rec = this.records.get(windowId) const view = rec?.prompt - if (!rec || !view || view.webContents.isDestroyed()) return { exists: false, placed: false } + if (!rec || !view || !viewContents(view)) return { exists: false, placed: false } return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } } @@ -744,7 +754,7 @@ export class WindowManager { /** Shows a createUiView() view at `bounds` (window coordinates); `focus` gives it the keyboard. */ showUiView(windowId: WindowId, view: WebContentsView, bounds: Rect, focus = false): void { const rec = this.records.get(windowId) - if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || view.webContents.isDestroyed()) return + if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || !viewContents(view)) return const next = roundRect(bounds) const current = view.getBounds() if ( @@ -757,15 +767,15 @@ export class WindowManager { } this.placeAbovePages(rec, view) if (!view.getVisible()) view.setVisible(true) - if (focus) view.webContents.focus() + if (focus) viewContents(view)?.focus() } /** Hides a createUiView() view; keyboard focus in it goes back to the page. */ hideUiView(windowId: WindowId, view: WebContentsView): void { const rec = this.records.get(windowId) - if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || view.webContents.isDestroyed()) return + if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || !viewContents(view)) return if (!view.getVisible()) return - const focused = webContents.getFocusedWebContents() === view.webContents + const focused = webContents.getFocusedWebContents() === viewContents(view) view.setVisible(false) if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) } @@ -1206,7 +1216,7 @@ export class WindowManager { /** The floating sidebar's view (SB-12): a transparent view at the window's left edge. */ private ensureFloating(rec: WindowRecord): WebContentsView { - if (rec.floating && !rec.floating.webContents.isDestroyed()) return rec.floating + if (rec.floating && viewContents(rec.floating)) return rec.floating clearTimeout(rec.floatingPrewarm) rec.floatingPrewarm = undefined const view = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'sidebar') }) @@ -1244,9 +1254,10 @@ export class WindowManager { private hideFloating(rec: WindowRecord, afterMs: number): void { rec.floatingShown = false const view = rec.floating - if (!view || view.webContents.isDestroyed()) return + const contents = viewContents(view) + if (!view || !contents) return // Keyboard focus in the card goes back to the page. - if (webContents.getFocusedWebContents() === view.webContents && !rec.overlayShown) this.focusActiveTab(rec.id) + if (webContents.getFocusedWebContents() === contents && !rec.overlayShown) this.focusActiveTab(rec.id) clearTimeout(rec.floatingTimer) rec.floatingTimer = setTimeout(() => { rec.floatingTimer = undefined @@ -1270,12 +1281,12 @@ export class WindowManager { rec.floatingTimer = undefined rec.floating = undefined if (!rec.win.isDestroyed()) this.removeChild(rec, view) - if (!view.webContents.isDestroyed()) view.webContents.close() + viewContents(view)?.close() } /** The page tools' view (NV-05, NV-06): transparent, sized by the nav module to what it shows. */ private ensureTools(rec: WindowRecord): WebContentsView { - if (rec.tools && !rec.tools.webContents.isDestroyed()) return rec.tools + if (rec.tools && viewContents(rec.tools)) return rec.tools clearTimeout(rec.toolsPrewarm) rec.toolsPrewarm = undefined const view = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'tools') }) @@ -1292,7 +1303,7 @@ export class WindowManager { /** The permission prompt's view (NV-15): transparent, sized by the permissions module to its card. */ private ensurePrompt(rec: WindowRecord): WebContentsView { - if (rec.prompt && !rec.prompt.webContents.isDestroyed()) return rec.prompt + if (rec.prompt && viewContents(rec.prompt)) return rec.prompt const view = new WebContentsView({ webPreferences: uiWebPreferences(rec.id, 'prompt') }) view.setBackgroundColor('#00000000') this.registerUi(view.webContents, rec.id) @@ -1347,7 +1358,7 @@ export class WindowManager { } rec.overlayHideTimer = setTimeout(() => { rec.overlayHideTimer = undefined - if (rec.overlayShown || rec.win.isDestroyed() || overlay.webContents.isDestroyed()) return + if (rec.overlayShown || rec.win.isDestroyed() || !viewContents(overlay)) return try { overlay.setVisible(false) } catch { From decf3bf95abf755ad5fae8a67962111efd3e7025 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:47:19 +0000 Subject: [PATCH 14/69] nav: link status pill at the bottom of the page card (SB-27) Hovering or focusing a link shows where it goes in a small theme-styled pill in the card's bottom-left corner, drawn in a view of its own sized to the pill. After 1.5 s on one link it shows the full URL, and keeps doing so for the next links while it stays up. It moves to the other corner when the pointer comes within 24 px, steps aside when near both, and goes 120 ms after the link is left. The quick-load indicator test now waits for the first load's indicator to go before it starts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 6 +- docs/feature-status.json | 1 + src/main/nav/index.ts | 5 + src/main/nav/link-pill.ts | 304 ++++++++++++++++++ src/renderer/src/main.tsx | 8 + src/renderer/src/shell/chrome/LinkPillApp.tsx | 66 ++++ src/renderer/src/shell/chrome/link-pill.css | 60 ++++ src/shared/commands/nav.ts | 6 +- src/shared/nav.ts | 35 ++ tests/e2e/nav-page.spec.ts | 112 ++++++- tests/unit/nav-url.test.ts | 29 +- 11 files changed, 618 insertions(+), 14 deletions(-) create mode 100644 src/main/nav/link-pill.ts create mode 100644 src/renderer/src/shell/chrome/LinkPillApp.tsx create mode 100644 src/renderer/src/shell/chrome/link-pill.css diff --git a/FEATURES.md b/FEATURES.md index 66f09f18..96a300bd 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ 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:** 485 spec’d · 166 built (44 partial) · 217 tested · 0 feel right. +**Totals:** 485 spec’d · 167 built (44 partial) · 218 tested · 0 feel right. | Priority | Spec’d | Built | Tested | |---|---|---|---| | ? | 7 | 0 | 0 | | P0 | 174 | 117 | 140 | -| P1 | 221 | 45 | 72 | +| P1 | 221 | 46 | 73 | | P2 | 83 | 4 | 5 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -47,7 +47,7 @@ Spec: `spec/sidebar.md` | SB-24 | Sidebar colouring follows the Space theme | P0 | 🟡 partial | ✅ e2e+unit | – | theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet | | SB-25 | Incognito window sidebar | P1 | ✅ | ✅ e2e | – | private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with | | SB-26 | Platform notes (Windows / Linux) | P2 | – | – | – | | -| SB-27 | Link status pill | P1 | – | – | – | | +| SB-27 | Link status pill | P1 | ✅ | ✅ e2e+unit | – | hovering (or focusing) a link shows a theme-styled pill with where it goes (no scheme) at the bottom-left of that page card, in its own small view; the full URL after 1.5 s on the link, kept for the next links while it stays up; moves to the other corner when the pointer comes within 24 px, out of the way if near both; goes 120 ms after the link is left; not on Peek pages | | SB-28 | Page loading indicator | P1 | ✅ | ✅ e2e | – | loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; shows in full screen; tab rows keep their favicon loading ring | ## Tabs, Pinned tabs, Favorites, Today tabs & auto-archive, Folders diff --git a/docs/feature-status.json b/docs/feature-status.json index 5982a6af..940a9362 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -17,6 +17,7 @@ "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"}, "SB-24": {"built": "partial", "note": "theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet"}, "SB-25": {"built": "yes", "note": "private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with"}, + "SB-27": {"built": "yes", "note": "hovering (or focusing) a link shows a theme-styled pill with where it goes (no scheme) at the bottom-left of that page card, in its own small view; the full URL after 1.5 s on the link, kept for the next links while it stays up; moves to the other corner when the pointer comes within 24 px, out of the way if near both; goes 120 ms after the link is left; not on Peek pages"}, "SB-28": {"built": "yes", "note": "loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; shows in full screen; tab rows keep their favicon loading ring"}, "TB-01": {"built": "yes", "note": "row: favicon/emoji, title or breadcrumb, hover close, audio, selected pill, dimmed when asleep"}, "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop / Cmd+Alt+V paste; background link tabs gesture-gated"}, diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index c0024e7f..5edcc0e0 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -8,6 +8,7 @@ import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { CallContext } from '../registry' import type { TabOps } from '../tabs/ops' +import { type LinkPillInfo, registerLinkPill } from './link-pill' import { type LinksTestHooks, registerLinks } from './links' import { type LoadingInfo, registerLoading } from './loading' import { openUrlInNewWindow } from './new-window' @@ -44,6 +45,8 @@ export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { } /** A window's page loading indicator (default: the first) (SB-28). */ loading: (windowId?: WindowId) => LoadingInfo + /** A window's link status pill (default: the first) (SB-27). */ + linkPill: (windowId?: WindowId) => LinkPillInfo } interface ShownMenu { @@ -87,6 +90,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // The address while a page is on its way or failed (NV-04, NV-13), and the loading indicator (SB-28). const pending = registerPendingUrl(ctx) const loading = registerLoading(ctx) + const linkPill = registerLinkPill(ctx) const go = (tabId: TabId, button: NavButton) => { if (button === 'back') return commands.run('tabs.back', { tabId }) @@ -349,6 +353,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } }, loading: (windowId) => loading.info(windowId ?? windows.ids()[0] ?? ''), + linkPill: (windowId) => linkPill.info(windowId ?? windows.ids()[0] ?? ''), } ;(globalThis as unknown as { __orbitNav: NavTestHooks }).__orbitNav = hooks } diff --git a/src/main/nav/link-pill.ts b/src/main/nav/link-pill.ts new file mode 100644 index 00000000..062315fb --- /dev/null +++ b/src/main/nav/link-pill.ts @@ -0,0 +1,304 @@ +import type { WebContentsView } from 'electron' +import { type LinkPillContent, linkPillText } from '../../shared/nav' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { shownTabs, viewContents } from '../windows' + +/** The link status pill's geometry and timing (SB-27). */ +export const LINK_PILL = { + /** The pill's height. */ + height: 22, + /** Room around the pill in its view, for the shadow. */ + margin: 6, + /** Gap between the pill and the page card's edges. */ + inset: 6, + /** The pointer this close to the pill moves it to the card's other corner. */ + near: 24, + /** Hovering a link this long shows the full URL. */ + expandAfterMs: 1500, + /** The pill stays this long after the pointer leaves a link, so moving between links doesn't flicker. */ + hideAfterMs: 120, + /** The short pill's widest, as a share of the card's width, and at least this many px. */ + compactShare: 1 / 3, + compactMin: 180, +} as const + +type Side = 'left' | 'right' +type Point = { x: number; y: number } + +export interface LinkPillInfo { + visible: boolean + tabId: TabId | null + url: string | null + text: string | null + expanded: boolean + side: Side + bounds?: Electron.Rectangle + /** How long the link had been hovered when the full URL showed (ms), once it has. */ + expandedAfter: number | null +} + +interface WindowPill { + view?: WebContentsView + /** The tab whose hovered link it shows, and the link. */ + tabId: TabId | null + url: string | null + /** When the pointer came onto this link. */ + urlSince: number + expanded: boolean + expandedAfter: number | null + side: Side + /** Numbers each content sent to the view; the view's size report names the one it measured. */ + key: number + maxWidth: number + /** The pill's measured width for `key`, once the view said. */ + width: number | null + expandTimer?: ReturnType<typeof setTimeout> + hideTimer?: ReturnType<typeof setTimeout> +} + +/** + * The link status pill (SB-27): hovering a link (or giving one keyboard focus) shows where it goes + * in a small theme-styled pill in the bottom-left corner of that page's card; after about 1.5 s on + * the link it shows the full URL, and links after it too while it stays up. When the pointer comes + * near it, it moves to the other corner (and out of the way if it is near both). It is drawn in a + * small view of its own over the page, sized to the pill, so it covers as little as it can. + */ +export function registerLinkPill(ctx: MainContext): { info: (windowId: WindowId) => LinkPillInfo } { + const { store, windows, commands } = ctx + const perWindow = new Map<WindowId, WindowPill>() + /** Where the pointer is over each page (page coordinates), while it is. */ + const pointers = new Map<TabId, Point>() + + /** A view whose page is alive (its `webContents` is undefined once the page is gone). */ + const live = (view: WebContentsView | undefined): view is WebContentsView => !!viewContents(view) + + const stateOf = (windowId: WindowId): WindowPill | undefined => { + const existing = perWindow.get(windowId) + if (existing) return existing + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return undefined + const s: WindowPill = { + tabId: null, + url: null, + urlSince: 0, + expanded: false, + expandedAfter: null, + side: 'left', + key: 0, + maxWidth: LINK_PILL.compactMin, + width: null, + } + perWindow.set(windowId, s) + win.once('closed', () => { + clearTimeout(s.expandTimer) + clearTimeout(s.hideTimer) + perWindow.delete(windowId) + }) + return s + } + + const contentOf = (s: WindowPill): LinkPillContent | null => + s.url + ? { key: s.key, url: s.url, text: linkPillText(s.url, s.expanded), expanded: s.expanded, maxWidth: s.maxWidth } + : null + + const maxWidthFor = (expanded: boolean, card: Electron.Rectangle): number => { + const room = Math.max(40, card.width - 2 * LINK_PILL.inset) + if (expanded) return room + return Math.min(room, Math.max(LINK_PILL.compactMin, Math.round(card.width * LINK_PILL.compactShare))) + } + + /** Sends the view what to show, numbered anew: it measures the pill and says how wide it came out. */ + const send = (s: WindowPill) => { + s.key++ + s.width = null + const content = contentOf(s) + if (content) viewContents(s.view)?.send('orbit:link:show', content) + } + + /** The pill's view in one of the card's bottom corners. */ + const rectFor = (card: Electron.Rectangle, width: number, side: Side): Electron.Rectangle => { + const { height, margin, inset } = LINK_PILL + const w = Math.ceil(width) + return { + x: Math.round(side === 'left' ? card.x + inset - margin : card.x + card.width - inset - w - margin), + y: Math.round(card.y + card.height - inset - height - margin), + width: w + 2 * margin, + height: height + 2 * margin, + } + } + + /** The pointer (window coordinates) within `near` of the pill drawn in `rect`. */ + const isNear = (rect: Electron.Rectangle, p: Point | undefined): boolean => { + if (!p) return false + const { margin, near } = LINK_PILL + return ( + p.x >= rect.x + margin - near && + p.x <= rect.x + rect.width - margin + near && + p.y >= rect.y + margin - near && + p.y <= rect.y + rect.height - margin + near + ) + } + + const hideNow = (windowId: WindowId, s: WindowPill) => { + clearTimeout(s.expandTimer) + clearTimeout(s.hideTimer) + s.expandTimer = undefined + s.hideTimer = undefined + s.tabId = null + s.url = null + s.expanded = false + s.side = 'left' + s.width = null + if (live(s.view)) windows.hideUiView(windowId, s.view) + } + + /** Puts the pill's view where it goes, or out of the way, once the view has measured the pill. */ + const place = (windowId: WindowId, s: WindowPill) => { + if (!s.tabId || !s.url || !live(s.view)) return + const card = windows.tabViewBounds(s.tabId) + if (!card) { + hideNow(windowId, s) + return + } + const maxWidth = maxWidthFor(s.expanded, card) + if (maxWidth !== s.maxWidth) { + s.maxWidth = maxWidth + send(s) + } + if (s.width === null) return + const at = pointers.get(s.tabId) + const pointer = at ? { x: card.x + at.x, y: card.y + at.y } : undefined + if (isNear(rectFor(card, s.width, s.side), pointer)) s.side = s.side === 'left' ? 'right' : 'left' + if (isNear(rectFor(card, s.width, s.side), pointer)) { + // Near both corners (a wide pill): out of the way until the pointer moves off. + windows.hideUiView(windowId, s.view) + return + } + windows.showUiView(windowId, s.view, rectFor(card, s.width, s.side)) + } + + const startExpandTimer = (windowId: WindowId, s: WindowPill) => { + clearTimeout(s.expandTimer) + s.expandTimer = setTimeout(() => { + s.expandTimer = undefined + if (perWindow.get(windowId) !== s || !s.url || s.expanded) return + s.expanded = true + s.expandedAfter = Date.now() - s.urlSince + const card = s.tabId ? windows.tabViewBounds(s.tabId) : undefined + if (card) s.maxWidth = maxWidthFor(true, card) + send(s) + }, LINK_PILL.expandAfterMs) + } + + const onTarget = (tabId: TabId, url: string) => { + const windowId = windows.windowOfTab(tabId) + if (!windowId) return + const s = stateOf(windowId) + if (!s) return + if (!url) { + // Off the link: the pill fades, then goes, unless another link comes first. + if (s.tabId !== tabId || !s.url || s.hideTimer) return + viewContents(s.view)?.send('orbit:link:hide', {}) + s.hideTimer = setTimeout(() => { + s.hideTimer = undefined + if (perWindow.get(windowId) === s) hideNow(windowId, s) + }, LINK_PILL.hideAfterMs) + return + } + // Only a page in one of the window's page cards (not a Peek's, which has a card of its own). + if (!shownTabs(store.state, windowId).has(tabId)) return + const card = windows.tabViewBounds(tabId) + if (!card) return + clearTimeout(s.hideTimer) + s.hideTimer = undefined + if (!live(s.view)) s.view = windows.createUiView(windowId, 'link') + if (s.tabId === tabId && s.url === url) { + // The same link again, before the pill went: it stays as it was. + send(s) + place(windowId, s) + return + } + if (!s.url) { + s.expanded = false + s.expandedAfter = null + s.side = 'left' + } + s.tabId = tabId + s.url = url + s.urlSince = Date.now() + // The full URL after a continued hover of one link; once it shows, the next links show in full too. + if (!s.expanded) startExpandTimer(windowId, s) + s.maxWidth = maxWidthFor(s.expanded, card) + send(s) + place(windowId, s) + } + + commands.register('nav.linkPill', (_args, c) => { + const s = c.windowId ? perWindow.get(c.windowId) : undefined + if (!s?.url) return null + // The view just loaded: it measures this content and says. + s.key++ + s.width = null + return contentOf(s) + }) + commands.register('nav.linkPillSize', ({ key, width }, c) => { + const windowId = c.windowId + const s = windowId ? perWindow.get(windowId) : undefined + if (!windowId || !s || key !== s.key || !(width > 0)) return + s.width = width + place(windowId, s) + }) + + ctx.hooks.tabCreated.push((tabId, contents) => { + contents.on('update-target-url', (_event, url) => onTarget(tabId, url)) + contents.on('before-mouse-event', (_event, mouse) => { + if (mouse.type === 'mouseLeave') { + pointers.delete(tabId) + return + } + if (mouse.type !== 'mouseMove') return + pointers.set(tabId, { x: mouse.x, y: mouse.y }) + const windowId = windows.windowOfTab(tabId) + const s = windowId ? perWindow.get(windowId) : undefined + if (windowId && s?.url && s.tabId === tabId) place(windowId, s) + }) + // A new page: no link of it is hovered yet. + contents.on('did-navigate', () => { + for (const [windowId, s] of perWindow) if (s.tabId === tabId) hideNow(windowId, s) + }) + contents.once('destroyed', () => { + pointers.delete(tabId) + for (const [windowId, s] of perWindow) if (s.tabId === tabId) hideNow(windowId, s) + }) + }) + + // Another tab shown: the pill goes with its page. The page card moved: the pill follows. + store.subscribe((next) => { + for (const [windowId, s] of perWindow) { + if (s.tabId && !shownTabs(next, windowId).has(s.tabId)) hideNow(windowId, s) + } + }) + windows.onLayout((windowId) => { + const s = perWindow.get(windowId) + if (s?.url) place(windowId, s) + }) + + return { + info: (windowId) => { + const s = perWindow.get(windowId) + const view = live(s?.view) ? s?.view : undefined + return { + visible: !!view?.getVisible(), + tabId: s?.tabId ?? null, + url: s?.url ?? null, + text: s?.url ? linkPillText(s.url, s.expanded) : null, + expanded: !!s?.expanded, + side: s?.side ?? 'left', + bounds: view?.getBounds(), + expandedAfter: s?.expandedAfter ?? null, + } + }, + } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a56f5a61..92777298 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -65,6 +65,14 @@ async function boot(): Promise<void> { <LoadingApp /> </StrictMode>, ) + } else if (view === 'link') { + // The link status pill at the bottom of the page card (chrome lane, SB-27). + const { LinkPillApp } = await import('./shell/chrome/LinkPillApp') + root.render( + <StrictMode> + <LinkPillApp /> + </StrictMode>, + ) } else if (view === 'strip') { // Split view pane strip (split lane, SV-12): a tiny transparent view over a pane's top edge. const { PaneStripApp } = await import('./shell/split/PaneStripApp') diff --git a/src/renderer/src/shell/chrome/LinkPillApp.tsx b/src/renderer/src/shell/chrome/LinkPillApp.tsx new file mode 100644 index 00000000..e90cbfda --- /dev/null +++ b/src/renderer/src/shell/chrome/LinkPillApp.tsx @@ -0,0 +1,66 @@ +import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { LinkPillContent } from '../../../../shared/nav' +import { invoke } from '../../api' +import { useWindow } from '../../store' +import { paletteVars, usePalette } from '../../theme' +import './link-pill.css' + +/** + * The link status pill (SB-27): a small view main keeps in a bottom corner of the page card while a + * link is hovered. It shows where the link goes, in the Space's theme, and tells main how wide the + * pill came out, so the view fits it. + */ +export function LinkPillApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [content, setContent] = useState<LinkPillContent | null>(null) + const [leaving, setLeaving] = useState(false) + const pill = useRef<HTMLDivElement>(null) + + useEffect(() => { + let live = true + // Newer content only: a reply and a push can cross. + const take = (next: LinkPillContent | null) => { + if (!live || !next) return + setContent((current) => (current && current.key > next.key ? current : next)) + setLeaving(false) + } + const offShow = window.orbit.on('link:show', (payload) => take(payload as LinkPillContent)) + const offHide = window.orbit.on('link:hide', () => setLeaving(true)) + void invoke('nav.linkPill', {}) + .then(take) + .catch(() => {}) + return () => { + live = false + offShow() + offHide() + } + }, []) + + // Measure each new content and say how wide the pill is. + useLayoutEffect(() => { + if (!content || !pill.current) return + const width = Math.ceil(pill.current.getBoundingClientRect().width) + void invoke('nav.linkPillSize', { key: content.key, width }).catch(() => {}) + }, [content]) + + return ( + <div + className={`link-pill-view${leaving ? ' is-leaving' : ''}`} + style={paletteVars(palette) as CSSProperties} + data-testid="link-pill-view" + > + {content && ( + <div + ref={pill} + className="link-pill" + style={{ maxWidth: content.maxWidth }} + data-testid="link-pill" + data-expanded={content.expanded ? 'true' : 'false'} + > + {content.text} + </div> + )} + </div> + ) +} diff --git a/src/renderer/src/shell/chrome/link-pill.css b/src/renderer/src/shell/chrome/link-pill.css new file mode 100644 index 00000000..cf3db5aa --- /dev/null +++ b/src/renderer/src/shell/chrome/link-pill.css @@ -0,0 +1,60 @@ +/* The link status pill (SB-27): its own small transparent view in a bottom corner of the page card. + The view is the pill plus a 6 px margin for the shadow (LINK_PILL in src/main/nav/link-pill.ts). */ +html[data-view="link"] body { + background: transparent; + overflow: hidden; +} + +.link-pill-view { + position: fixed; + inset: 0; + pointer-events: none; +} + +.link-pill { + position: absolute; + left: 6px; + top: 6px; + /* Its own width, whatever the view's width is now, so it can be measured. */ + width: max-content; + box-sizing: border-box; + height: 22px; + padding: 0 9px; + border-radius: 7px; + font: + 12px / 22px system-ui, + sans-serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text, #1c1c1f); + background: color-mix(in srgb, var(--surface, #fff) 96%, transparent); + box-shadow: + 0 1px 4px rgba(0, 0, 0, 0.16), + 0 0 0 0.5px rgba(0, 0, 0, 0.14); + animation: link-pill-in 100ms ease-out; +} + +/* Off the link: a quick fade (main takes the view away 120 ms after). */ +.link-pill-view.is-leaving .link-pill { + opacity: 0; + transition: opacity 100ms ease-out; +} + +@keyframes link-pill-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .link-pill { + animation: none; + } + .link-pill-view.is-leaving .link-pill { + transition: none; + } +} diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index d2d32bb5..b2424527 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -1,4 +1,4 @@ -import type { FindState, ZoomIndicator } from '../nav' +import type { FindState, LinkPillContent, ZoomIndicator } from '../nav' import type { PermissionKind, PromptChoice, SiteControlsInfo } from '../site' import type { TabId } from '../state' @@ -93,4 +93,8 @@ export interface NavCommands { 'site.revokeDevice': { args: { tabId: TabId; key: string }; result: void } /** Deletes the tab's site's cookies and stored data. */ 'site.clearData': { args: { tabId: TabId }; result: void } + /** What the link status pill's view shows now (it asks when it loads), or null (SB-27). */ + 'nav.linkPill': { args: Record<string, never>; result: LinkPillContent | null } + /** How wide the link pill came out for the content numbered `key`, so its view fits it. */ + 'nav.linkPillSize': { args: { key: number; width: number }; result: void } } diff --git a/src/shared/nav.ts b/src/shared/nav.ts index d3cb7609..8bc2d98e 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -186,3 +186,38 @@ export function findTextFromSelection(selection: string, max = 200): string { const line = selection.split(/\r?\n/).find((l) => l.trim() !== '') ?? '' return line.trim().slice(0, max) } + +/** What the link status pill shows (SB-27), numbered so its view can say how wide it came out. */ +export interface LinkPillContent { + key: number + url: string + /** linkPillText(url, expanded). */ + text: string + /** The full URL, after a hover of about 1.5 s. */ + expanded: boolean + /** The pill's widest (CSS px); longer text ends in an ellipsis. */ + maxWidth: number +} + +/** The longest address the link pill takes (a data: URL can be megabytes). */ +export const LINK_PILL_MAX_URL = 2048 + +/** + * The link status pill's text (SB-27): where a hovered link goes, without `http://` or `https://` + * (and without the lone `/` of a bare host); expanded, the full URL. Percent-escapes and + * punycode host names stay as they are, so a link can't pass for another. + */ +export function linkPillText(url: string, expanded: boolean): string { + const shown = url.length > LINK_PILL_MAX_URL ? `${url.slice(0, LINK_PILL_MAX_URL)}…` : url + if (expanded) return shown + let parsed: URL + try { + parsed = new URL(url) + } catch { + return shown + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return shown + const path = parsed.pathname === '/' && !parsed.search && !parsed.hash ? '' : parsed.pathname + const short = `${parsed.host}${path}${parsed.search}${parsed.hash}` + return short.length > LINK_PILL_MAX_URL ? `${short.slice(0, LINK_PILL_MAX_URL)}…` : short +} diff --git a/tests/e2e/nav-page.spec.ts b/tests/e2e/nav-page.spec.ts index bc8de2bb..ff48dae9 100644 --- a/tests/e2e/nav-page.spec.ts +++ b/tests/e2e/nav-page.spec.ts @@ -1,10 +1,19 @@ import { type ElectronApplication, expect, type Page, test } from '@playwright/test' -import { launchOrbit, type Orbit, state } from './helpers' -import { cmd, type NavFixtures, openTab, pressInPage, startNavFixtures, waitLoaded, windowState } from './nav-support' +import { launchOrbit, type Orbit, state, tabPage } from './helpers' +import { + cmd, + type NavFixtures, + openTab, + pageMouse, + pressInPage, + startNavFixtures, + waitLoaded, + windowState, +} from './nav-support' /** - * A page's life in its card: the inactive window's sidebar (SB-21), the loading indicator (SB-28) - * and the address while a page is on its way (NV-04). + * A page's life in its card: the inactive window's sidebar (SB-21), the loading indicator (SB-28), + * the address while a page is on its way (NV-04) and the link status pill (SB-27). */ let fx: NavFixtures @@ -34,24 +43,48 @@ interface LoadingInfo { pending: boolean } -type NavHooks = { __orbitNav: { loading: (windowId?: string) => LoadingInfo } } +interface LinkPillInfo { + visible: boolean + tabId: string | null + url: string | null + text: string | null + expanded: boolean + side: 'left' | 'right' + bounds?: { x: number; y: number; width: number; height: number } + expandedAfter: number | null +} + +type NavHooks = { + __orbitNav: { loading: (windowId?: string) => LoadingInfo; linkPill: (windowId?: string) => LinkPillInfo } +} function loading(app: ElectronApplication): Promise<LoadingInfo> { return app.evaluate(() => (globalThis as unknown as NavHooks).__orbitNav.loading()) } -/** The loading indicator's own page (`view=loading`), once it has started. */ -async function loadingPage(app: ElectronApplication): Promise<Page> { +function linkPill(app: ElectronApplication): Promise<LinkPillInfo> { + return app.evaluate(() => (globalThis as unknown as NavHooks).__orbitNav.linkPill()) +} + +/** A view's own page (`view=<name>`), once it has started. */ +async function viewPageOf(app: ElectronApplication, name: string): Promise<Page> { let found: Page | undefined await expect .poll(() => { - found = app.windows().find((p) => p.url().includes('view=loading')) + found = app.windows().find((p) => p.url().includes(`view=${name}`)) return !!found }) .toBe(true) return found as Page } +/** The middle of an element of a tab's page, in the page's coordinates. */ +async function middleOf(tab: Page, selector: string): Promise<{ x: number; y: number }> { + const box = await tab.locator(selector).boundingBox() + if (!box) throw new Error(`no ${selector}`) + return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } +} + /** A tab's page bounds in its window (window coordinates). */ function pageBounds(app: ElectronApplication, tabId: string) { return app.evaluate( @@ -119,7 +152,7 @@ test('[SB-28][FL-23] a slow page shows the loading indicator at the top centre o // Centred on the card, just inside its top edge. expect(Math.abs(info.bounds.x + info.bounds.width / 2 - (card.x + card.width / 2))).toBeLessThanOrEqual(1) expect(info.bounds.y - card.y).toBe(2) - const view = await loadingPage(app) + const view = await viewPageOf(app, 'loading') const indicator = view.getByTestId('loading-indicator') await expect(indicator).toHaveAttribute('data-state', 'loading') // The sweep moves (an indeterminate animation). @@ -134,6 +167,8 @@ test('[SB-28][FL-23] a slow page shows the loading indicator at the top centre o test('[SB-28][FL-23] a load shorter than the delay never shows the indicator; switching tabs takes it away', async () => { const { app } = orbit const id = await openTab(app, page('Quick')) + // The tab's first load may have shown it (a slow machine): wait until it has finished and gone. + await expect.poll(async () => (await loading(app)).visible || (await loading(app)).pending).toBe(false) const before = (await loading(app)).shown // A load that starts and ends within the delay (a cached page), whatever the machine's speed: the // page's own start and stop, back to back. The indicator waits out the delay after a start… @@ -201,3 +236,62 @@ test('[NV-04] a page’s own navigation shows only once it commits', async () => await expect(pill).toHaveText(`localhost:${fx.port}`) expect((await state(app)).tabs[id]?.pendingUrl).toBeUndefined() }) + +test('[SB-27] hovering a link shows where it goes at the bottom-left of the card, in full after 1.5 s; it goes off the link', async () => { + const { app } = orbit + const id = await openTab(app, page('Pill')) + const tab = await tabPage(app, '/page/Pill') + const link = await middleOf(tab, '#next') + await pageMouse(app, '/page/Pill', 'mouseMove', link.x, link.y) + await expect.poll(async () => (await linkPill(app)).visible).toBe(true) + const short = await linkPill(app) + expect(short).toMatchObject({ tabId: id, url: page('Pill-next'), expanded: false, side: 'left' }) + expect(short.text).toBe(`localhost:${fx.port}/page/Pill-next`) + // In the card's bottom-left corner, inside it. + const card = await pageBounds(app, id) + if (!card || !short.bounds) throw new Error('no bounds') + expect(short.bounds.x).toBe(card.x) + expect(short.bounds.y + short.bounds.height).toBe(card.y + card.height) + expect(short.bounds.width).toBeLessThan(card.width / 2) + // Drawn in the Space's theme. + const view = await viewPageOf(app, 'link') + await expect(view.getByTestId('link-pill')).toHaveText(short.text as string) + expect(await view.getByTestId('link-pill-view').getAttribute('style')).toContain('--surface') + // Still on the link after about 1.5 s: the full URL. + await expect.poll(async () => (await linkPill(app)).expanded, { timeout: 5000 }).toBe(true) + const full = await linkPill(app) + expect(full.text).toBe(page('Pill-next')) + expect(full.expandedAfter).toBeGreaterThanOrEqual(1450) + await expect(view.getByTestId('link-pill')).toHaveText(page('Pill-next')) + await expect(view.getByTestId('link-pill')).toHaveAttribute('data-expanded', 'true') + // Off the link: it goes. + const title = await middleOf(tab, '#title') + await pageMouse(app, '/page/Pill', 'mouseMove', title.x + 200, title.y) + await expect.poll(async () => (await linkPill(app)).visible).toBe(false) + expect((await linkPill(app)).url).toBeNull() +}) + +test('[SB-27] the pill moves to the other corner when the pointer comes near it, and goes with its tab', async () => { + const { app } = orbit + const id = await openTab(app, page('Corner')) + const tab = await tabPage(app, '/page/Corner') + // A link right in the corner where the pill would be. + await tab.evaluate(() => { + document.body.insertAdjacentHTML( + 'beforeend', + '<a id="corner" href="/page/Elsewhere" style="position:fixed;left:10px;bottom:10px">Corner link</a>', + ) + }) + const corner = await middleOf(tab, '#corner') + await pageMouse(app, '/page/Corner', 'mouseMove', corner.x, corner.y) + await expect.poll(async () => (await linkPill(app)).visible).toBe(true) + const info = await linkPill(app) + expect(info.side).toBe('right') + const card = await pageBounds(app, id) + if (!card || !info.bounds) throw new Error('no bounds') + expect(info.bounds.x + info.bounds.width).toBe(card.x + card.width) + expect(info.bounds.y + info.bounds.height).toBe(card.y + card.height) + // Another tab: the pill goes with its page. + await openTab(app, page('Other')) + await expect.poll(async () => (await linkPill(app)).visible).toBe(false) +}) diff --git a/tests/unit/nav-url.test.ts b/tests/unit/nav-url.test.ts index e426be13..c57116d7 100644 --- a/tests/unit/nav-url.test.ts +++ b/tests/unit/nav-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { connectionOf } from '../../src/shared/nav' +import { connectionOf, LINK_PILL_MAX_URL, linkPillText } from '../../src/shared/nav' describe('connectionOf (SB-04)', () => { it('treats https as secure: no padlock, no warning', () => { @@ -38,3 +38,30 @@ describe('connectionOf (SB-04)', () => { expect(connectionOf('not a url')).toBe('none') }) }) + +describe('linkPillText (SB-27)', () => { + it('shows a web link without its scheme, and the whole URL once expanded', () => { + expect(linkPillText('https://example.com/docs/page?q=1#top', false)).toBe('example.com/docs/page?q=1#top') + expect(linkPillText('http://localhost:3330/page/A', false)).toBe('localhost:3330/page/A') + expect(linkPillText('https://example.com/docs/page?q=1#top', true)).toBe('https://example.com/docs/page?q=1#top') + }) + + it('drops the lone slash of a bare host', () => { + expect(linkPillText('https://example.com/', false)).toBe('example.com') + expect(linkPillText('https://example.com/?q=1', false)).toBe('example.com/?q=1') + }) + + it('leaves other schemes, escapes and punycode as they are', () => { + expect(linkPillText('mailto:someone@example.com', false)).toBe('mailto:someone@example.com') + expect(linkPillText('javascript:void(0)', false)).toBe('javascript:void(0)') + expect(linkPillText('https://xn--bcher-kva.example/%E2%80%AE', false)).toBe('xn--bcher-kva.example/%E2%80%AE') + // Credentials in a link are not shown in the short form. + expect(linkPillText('https://user:secret@example.com/a', false)).toBe('example.com/a') + }) + + it('cuts very long addresses short', () => { + const long = `data:text/plain,${'x'.repeat(LINK_PILL_MAX_URL * 2)}` + expect(linkPillText(long, true)).toHaveLength(LINK_PILL_MAX_URL + 1) + expect(linkPillText(long, false).endsWith('…')).toBe(true) + }) +}) From 8e1b32fdf7d44f7914ae62f3eef8b1fb13cd48c7 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:47:19 +0000 Subject: [PATCH 15/69] nav: a page-source tab keeps its view-source: address (NV-12) A view-source: page commits with the address of the page it shows, so the tab's address became the page's own and it woke up as the page. The tab now keeps the view-source: address it shows. The NV-12 test checks the address after the source has loaded instead of only before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/pending-url.ts | 15 +++++++++++++-- tests/e2e/nav-links.spec.ts | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/nav/pending-url.ts b/src/main/nav/pending-url.ts index 6ae9a1ec..a8109143 100644 --- a/src/main/nav/pending-url.ts +++ b/src/main/nav/pending-url.ts @@ -6,9 +6,10 @@ const ERR_ABORTED = -3 /** Addresses that load in the tab; others (mailto:, a custom app's scheme…) go to another app. */ const LOADS_IN_TAB = /^(https?|file):/i +const VIEW_SOURCE = /^view-source:/i /** - * The address while a page is on its way (NV-04) or failed to load (NV-13). + * The address while a page is on its way (NV-04), failed to load (NV-13) or shows a page's source. * * Where a navigation Orbit started is going, before it commits (an address typed in the command * bar, Back or Forward, Reload, a link Orbit opened in a new tab): the address display shows it at @@ -19,7 +20,8 @@ const LOADS_IN_TAB = /^(https?|file):/i * have over its own content. * * An error page commits without `did-navigate`; the address then shows the address that failed, - * so Reload tries it again. + * so Reload tries it again. A page's source (View Page Source, NV-12) commits with the address of + * the page it shows; the tab keeps its `view-source:` address, so it shows and reopens as source. */ export function registerPendingUrl(ctx: MainContext): { markStopped: (tabId: TabId) => void } { const { store } = ctx @@ -66,6 +68,15 @@ export function registerPendingUrl(ctx: MainContext): { markStopped: (tabId: Tab }) }) + ctx.hooks.tabNavigated.push((tabId, contents) => { + const shown = contents.getURL() + if (!VIEW_SOURCE.test(shown) || store.state.tabs[tabId]?.url === shown) return + store.update((d) => { + const tab = d.tabs[tabId] + if (tab) tab.url = shown + }) + }) + return { markStopped: (tabId) => { if (store.state.tabs[tabId]?.pendingUrl) stopped.add(tabId) diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 0266a580..44c0e7ff 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -325,9 +325,19 @@ test('[NV-12] right-clicking the page offers Back, Forward, Reload, Save Page As expect(menu?.enabled.Back).toBe(true) expect(menu?.enabled.Forward).toBe(false) expect(menu?.labels).not.toContain('Open Link in New Tab') - // View Page Source opens the source in a new tab. + // View Page Source opens the source in a new tab, which keeps its view-source: address once loaded. + const source = `view-source:${page('Plain-next')}` expect(await choosePageMenu(app, 'View Page Source')).toBe(true) - await expect.poll(async () => urlOf(app, await selectedTab(app))).toBe(`view-source:${page('Plain-next')}`) + await expect.poll(async () => urlOf(app, await selectedTab(app))).toBe(source) + await expect + .poll(() => + app.evaluate( + ({ webContents }, url) => webContents.getAllWebContents().some((w) => w.getURL() === url && !w.isLoading()), + source, + ), + ) + .toBe(true) + expect(await urlOf(app, await selectedTab(app))).toBe(source) }) test('[NV-12] right-clicking a text field offers the editing items and Writing Direction', async () => { From 84126ef69cb37fa71271b3e8b228f8df31af8c58 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:48:58 +0000 Subject: [PATCH 16/69] docs: NV-08 note names what the peek lane routes and what is left Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 96a300bd..3657dd23 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -236,7 +236,7 @@ Spec: `spec/navigation.md` | 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; Cmd+J jumps to the selection (macOS); Edit > Find menu | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; 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-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e | – | Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Peek, Mini Window and pinned-tab links come with the peek lane; hover pill is SB-27; dragging links not built | +| NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e | – | Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; not built: dragging links, and the New tab created toast while the sidebar is hidden (SB-13) | | 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 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 | | NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index 940a9362..bba20bde 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -165,7 +165,7 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), 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; Cmd+J jumps to the selection (macOS); Edit > Find menu"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, - "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Peek, Mini Window and pinned-tab links come with the peek lane; hover pill is SB-27; dragging links not built"}, + "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; not built: dragging links, and the New tab created toast while the sidebar is hidden (SB-13)"}, "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 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"}, "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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"}, From 23fdc0450dc44b1fa6d6fccc8d881b8a1b56bcc7 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:54:05 +0000 Subject: [PATCH 17/69] fix(media): a window's media tray lists only tabs that window may show With chrome's private windows merged, trayTabIds listed every tab with a tray controller, so a normal window's sidebar showed the title of media playing in a private window. The tray now takes its window and filters with chrome's windowMayShow: no private tab in a normal window, and only its own tabs in a private one. Private windows don't show a tray yet (their sidebar has no bottom bar); that's a follow-up. The new unit test fails without the fix: expected [ 't-p', 't-q', 't-a' ] to deeply equal [ 't-a' ]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/renderer/src/shell/media/MediaTray.tsx | 6 ++--- src/renderer/src/shell/sidebar/Sidebar.tsx | 2 +- src/shared/media.ts | 14 ++++++++---- tests/unit/media.test.ts | 26 +++++++++++++++++++++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/shell/media/MediaTray.tsx b/src/renderer/src/shell/media/MediaTray.tsx index 61c3d3d7..b2bb7ea4 100644 --- a/src/renderer/src/shell/media/MediaTray.tsx +++ b/src/renderer/src/shell/media/MediaTray.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react' import { inCall, mediaSubtitle, mediaTitle, trayTabIds } from '../../../../shared/media' -import type { TabId } from '../../../../shared/state' +import type { TabId, WindowId } from '../../../../shared/state' import { invoke } from '../../api' import { useOrbit, useOrbitShallow } from '../../store' import { Favicon } from '../../ui/Favicon' @@ -31,8 +31,8 @@ const FADE = { duration: 0.12, ease: QUICK_EASE } * in the background, newest on top, just above the bottom bar. It slides up when a playing tab is * left and down when it goes (FL-32, MD-16); with reduced motion it only fades. */ -export const MediaTray = memo(function MediaTray() { - const ids = useOrbitShallow(trayTabIds) +export const MediaTray = memo(function MediaTray({ windowId }: { windowId: WindowId }) { + const ids = useOrbitShallow((s) => trayTabIds(s, windowId)) const reduce = useReducedMotion() const variants = reduce ? { diff --git a/src/renderer/src/shell/sidebar/Sidebar.tsx b/src/renderer/src/shell/sidebar/Sidebar.tsx index e0e64fb2..22695207 100644 --- a/src/renderer/src/shell/sidebar/Sidebar.tsx +++ b/src/renderer/src/shell/sidebar/Sidebar.tsx @@ -42,7 +42,7 @@ export function Sidebar({ win, floating = false }: { win: WindowState; floating? <SpaceStrip windowId={win.id} spaceId={win.spaceId} /> <UndoToast /> {/* Media lane: transient media tray above the bottom bar (SB-10, MD-09). */} - <MediaTray /> + <MediaTray windowId={win.id} /> <SpaceBar win={win} /> </> )} diff --git a/src/shared/media.ts b/src/shared/media.ts index 59524fab..c3459a89 100644 --- a/src/shared/media.ts +++ b/src/shared/media.ts @@ -4,7 +4,8 @@ * leaving a tab opens picture-in-picture. No Electron or DOM here, so it is unit-tested. */ import { DEFAULT_PIP_PLACEMENT, normalizePlacement } from './pip' -import type { OrbitState, Settings, Tab, TabId, TabMedia } from './state' +import { windowMayShow } from './private' +import type { OrbitState, Settings, Tab, TabId, TabMedia, WindowId } from './state' /** One report from Orbit's page script (src/main/media/agent.ts). */ export interface AgentSnapshot { @@ -274,10 +275,15 @@ export function isAnyMediaPlaying(state: OrbitState): boolean { return false } -/** Tabs in the sidebar media tray, newest first (MD-10). */ -export function trayTabIds(state: OrbitState): TabId[] { +/** + * Tabs in a window's sidebar media tray, newest first (MD-10). A private window lists only its own + * tabs, and no other window lists a private one (`windowMayShow`). + */ +export function trayTabIds(state: OrbitState, windowId: WindowId): TabId[] { const tabs: Tab[] = [] - for (const tab of Object.values(state.tabs)) if (tab.media?.tray) tabs.push(tab) + for (const tab of Object.values(state.tabs)) { + if (tab.media?.tray && windowMayShow(state, windowId, tab.id)) tabs.push(tab) + } tabs.sort((a, b) => (b.media?.since ?? 0) - (a.media?.since ?? 0) || (a.id < b.id ? -1 : 1)) return tabs.map((t) => t.id) } diff --git a/tests/unit/media.test.ts b/tests/unit/media.test.ts index 1e5ae08f..b99d22c0 100644 --- a/tests/unit/media.test.ts +++ b/tests/unit/media.test.ts @@ -167,8 +167,32 @@ describe('tray membership (MD-08, MD-09, MD-10, MD-14)', () => { 't-d': tab('t-d', at(20)), 't-e': tab('t-e'), }, + windows: { 'w-1': { id: 'w-1', kind: 'normal', spaceId: 's-1' } }, + spaces: { 's-1': { id: 's-1', profileId: 'p-default' } }, } as unknown as OrbitState - expect(trayTabIds(state)).toEqual(['t-b', 't-d', 't-a']) + expect(trayTabIds(state, 'w-1')).toEqual(['t-b', 't-d', 't-a']) + }) + + it('a window lists only tabs it may show: no private tab in a normal window, only its own in a private one', () => { + const at = (since: number): TabMedia => ({ ...playing, tray: true, since }) + const state = { + tabs: { + 't-a': tab('t-a', at(10)), + 't-p': tab('t-p', at(30), { spaceId: 's-p', profileId: 'private-1' }), + 't-q': tab('t-q', at(20), { spaceId: 's-q', profileId: 'private-2' }), + }, + windows: { + 'w-1': { id: 'w-1', kind: 'normal', spaceId: 's-1' }, + 'w-p': { id: 'w-p', kind: 'private', spaceId: 's-p' }, + }, + spaces: { + 's-1': { id: 's-1', profileId: 'p-default' }, + 's-p': { id: 's-p', profileId: 'private-1' }, + 's-q': { id: 's-q', profileId: 'private-2' }, + }, + } as unknown as OrbitState + expect(trayTabIds(state, 'w-1')).toEqual(['t-a']) + expect(trayTabIds(state, 'w-p')).toEqual(['t-p']) }) it('titles come from the Media Session, then the tab', () => { From 984fef4134f232751f94322335154a0752a7eddf Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:10:19 +0000 Subject: [PATCH 18/69] fix(nav): a page can't fake notification clicks (review B-M1) The page preload called its captured built-ins through their own `.call`, so a page that replaced Function.prototype.call while making a Notification received Orbit's click listener and could call it with { isTrusted: true }: its tab came forward, on its Space, with no permission, and a flood froze the shell. - The preload captures Reflect.apply (and String) before the page runs and calls everything through it. - Main acts on a notification click only from a frame whose own origin is allowed to show notifications, and at most once per 500 ms per tab (or pop-up page), so even a granted page can't flood activations. Test: nav-site.spec.ts [NV-15] notification click now fakes a trusted click through a hijacked Function.prototype.call / apply / Reflect.apply and sends a click from a site without the permission; the window must stay on the other Space. Unit test for the per-tab gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/page-bridge.ts | 37 +++++++++++++++++---- src/preload/page.ts | 24 ++++++++------ src/shared/site.ts | 24 +++++++++++++- tests/e2e/nav-site.spec.ts | 64 ++++++++++++++++++++++++++++++++++--- tests/unit/nav-site.test.ts | 33 +++++++++++++++++++ 5 files changed, 161 insertions(+), 21 deletions(-) diff --git a/src/main/nav/page-bridge.ts b/src/main/nav/page-bridge.ts index 15741730..a6503681 100644 --- a/src/main/nav/page-bridge.ts +++ b/src/main/nav/page-bridge.ts @@ -1,7 +1,14 @@ import { join } from 'node:path' import { BrowserWindow, type IpcMainEvent, ipcMain, type Session, type WebContents } from 'electron' import { windowMayShow } from '../../shared/private' -import { PAGE_CHANNEL, type PagePermissionState, pagePermissionKinds, permissionOrigin } from '../../shared/site' +import { + NOTIFICATION_CLICK_GAP_MS, + PAGE_CHANNEL, + type PagePermissionState, + pagePermissionKinds, + perKeyGate, + permissionOrigin, +} from '../../shared/site' import type { ProfileId, TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { Permissions } from './permissions' @@ -17,6 +24,10 @@ export function pagePreloadPath(): string { * decides, which Electron's yes/no check can't say) and says when the user clicked one of the * page's notifications, which brings the tab that sent it forward: in the window that shows it, or * else selected in the last window the user worked in that may show it, on its Space. + * + * A page can't send anything else, and main checks what it is told (D40): a permission read + * answers only for the sending frame's own origin and profile, and a click counts only from a site + * allowed to show notifications, at most once per NOTIFICATION_CLICK_GAP_MS per tab. */ export function registerPageBridge(ctx: MainContext, permissions: Permissions): void { const { store, sessions, tabs, windows, commands } = ctx @@ -95,11 +106,25 @@ export function registerPageBridge(ctx: MainContext, permissions: Permissions): raise(windows.get(windowId)) } + /** One bring-forward per tab (or pop-up page) per gap: the clicks in between change nothing. */ + const clickGate = perKeyGate<string>(NOTIFICATION_CLICK_GAP_MS) + ipcMain.on(PAGE_CHANNEL.notificationClick, (event: IpcMainEvent) => { - const contents = event.sender - const tabId = tabs.tabIdFor(contents) - if (tabId) bringForward(tabId) - // A pop-up window's page: its window comes forward. - else raise(BrowserWindow.fromWebContents(contents) ?? undefined) + try { + const contents = event.sender + const frame = event.senderFrame + const origin = frame ? permissionOrigin(frame.url) : null + const profileId = profileOf(contents) + // Only a site that may show notifications has one to click. + if (!origin || !profileId) return + if (permissions.pageState(profileId, origin, ['notifications']) !== 'granted') return + const tabId = tabs.tabIdFor(contents) + if (!clickGate(tabId ?? `page:${contents.id}`)) return + if (tabId) bringForward(tabId) + // A pop-up window's page: its window comes forward. + else raise(BrowserWindow.fromWebContents(contents) ?? undefined) + } catch (err) { + console.error('[page-bridge] notification click', err) + } }) } diff --git a/src/preload/page.ts b/src/preload/page.ts index d27d9dfb..a81ba855 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -33,11 +33,15 @@ const notificationClicked = () => { * Orbit's functions through them. */ function patchPage(stateOf: StateOf, clicked: () => 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 const define = Object.defineProperty const describe = Object.getOwnPropertyDescriptor const construct = Reflect.construct const listen = EventTarget.prototype.addEventListener const then = Promise.prototype.then + const text = String const NativeNotification = window.Notification if (typeof NativeNotification === 'function') { @@ -51,7 +55,7 @@ function patchPage(stateOf: StateOf, clicked: () => void) { configurable: true, enumerable: permission.enumerable, get() { - return undecided(read.call(NativeNotification)) + return undecided(apply(read, NativeNotification, [])) }, }) } @@ -64,11 +68,13 @@ function patchPage(stateOf: StateOf, clicked: () => void) { enumerable: request.enumerable, writable: true, value: function requestPermission(callback?: (value: NotificationPermission) => void) { - return then.call(ask.call(NativeNotification), (value: NotificationPermission) => { - const answer = undecided(value) - if (typeof callback === 'function') callback(answer) - return answer - }) + return apply(then, apply(ask, NativeNotification, []), [ + (value: NotificationPermission) => { + const answer = undecided(value) + if (typeof callback === 'function') callback(answer) + return answer + }, + ]) }, }) } @@ -79,7 +85,7 @@ function patchPage(stateOf: StateOf, clicked: () => void) { const Notification = new Proxy(NativeNotification, { construct(target, args, newTarget) { const notification = construct(target, args, newTarget) - listen.call(notification, 'click', onClick) + apply(listen, notification, ['click', onClick]) return notification }, }) @@ -106,8 +112,8 @@ function patchPage(stateOf: StateOf, clicked: () => void) { configurable: true, enumerable: state.enumerable, get() { - const value = readState.call(this) - return value === 'denied' && stateOf(String(readName.call(this))) === 'prompt' ? 'prompt' : value + const value = apply(readState, this, []) + return value === 'denied' && stateOf(text(apply(readName, this, []))) === 'prompt' ? 'prompt' : value }, }) } diff --git a/src/shared/site.ts b/src/shared/site.ts index a93cedbf..2f20f8a2 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -175,10 +175,32 @@ export type PagePermissionState = 'granted' | 'denied' | 'prompt' export const PAGE_CHANNEL = { /** sendSync(name): the PagePermissionState for a permission name, or null for the engine's answer. */ permission: 'orbit-page:permission', - /** send(): the user clicked one of the page's notifications (NV-15). */ + /** + * send(): the user clicked one of the page's notifications (NV-15). Main acts on it only for a + * site with the notification permission, and at most once per NOTIFICATION_CLICK_GAP_MS per tab. + */ notificationClick: 'orbit-page:notification-click', } as const +/** A tab comes forward for its notifications' clicks at most this often (a flood can't stall the shell). */ +export const NOTIFICATION_CLICK_GAP_MS = 500 + +/** + * Lets the first call for a key through, then drops that key's calls until `gapMs` has passed. + * Keys quiet for a gap are forgotten as others arrive. + */ +export function perKeyGate<K>(gapMs: number, now: () => number = Date.now): (key: K) => boolean { + const last = new Map<K, number>() + return (key) => { + const t = now() + const before = last.get(key) + if (before !== undefined && t - before < gapMs) return false + if (last.size >= 64) for (const [k, at] of last) if (t - at >= gapMs) last.delete(k) + last.set(key, t) + return true + } +} + /** Sorts permissions into PERMISSION_ORDER, without repeats. */ export function sortKinds(kinds: Iterable<PermissionKind>): PermissionKind[] { const set = new Set(kinds) diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index e09a0f49..7689dc99 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -277,6 +277,17 @@ test('[NV-15] pages read an undecided permission as not decided yet, as in Chrom expect(await p.evaluate(() => new Notification('Hello') instanceof Notification)).toBe(true) }) +/** + * The message a page's preload sends main when the user clicks one of its notifications, as main + * receives it from that page's main frame. + */ +const notificationClick = (app: ElectronApplication, url: string) => + app.evaluate(({ ipcMain, webContents }, u) => { + const sender = webContents.getAllWebContents().find((c) => c.getURL() === u) + if (!sender) throw new Error(`no page at ${u}`) + ipcMain.emit('orbit-page:notification-click', { sender, senderFrame: sender.mainFrame }) + }, url) + test('[NV-15] clicking a notification brings its tab forward, on its Space; a click the page makes up does nothing', async () => { const { app, shell } = orbit const { id, p } = await open(app, page('Sender')) @@ -286,6 +297,9 @@ test('[NV-15] clicking a notification brings its tab forward, on its Space; a cl await p.evaluate(() => { ;(window as unknown as { __note: Notification }).__note = new Notification('Ping', { body: 'From the sender' }) }) + // A site that never asked for notifications, in a background tab. + const quiet = elsewhere('Quiet') + const quietId = await openTab(app, quiet) // The user moves on: another tab, then another Space. const other = await openTab(app, page('Elsewhere')) const work = await cmd<string>(app, 'spaces.create', { name: 'Work' }) @@ -296,12 +310,51 @@ test('[NV-15] clicking a notification brings its tab forward, on its Space; a cl await shellRoundTrip(shell) expect((await windowState(app)).spaceId).toBe(work) + // Nor is a "trusted" click the page fakes with the listener Orbit adds to its notifications: the + // built-ins a page replaces (here while it makes one) never see that listener. + const leaked = await p.evaluate(() => { + type Fn = (...args: unknown[]) => unknown + const seen: Fn[] = [] + const keep = (args: ArrayLike<unknown> | undefined) => { + for (const arg of Array.from(args ?? [])) if (typeof arg === 'function') seen.push(arg as Fn) + } + const fn = Function.prototype as unknown as Record<'call' | 'apply', unknown> + const reflect = Reflect as unknown as Record<'apply', unknown> + const { call, apply } = fn + const reflectApply = Reflect.apply + fn.call = function (this: Fn, ...args: unknown[]) { + keep(args) + return reflectApply(this, args[0], args.slice(1)) + } + fn.apply = function (this: Fn, self: unknown, args?: ArrayLike<unknown>) { + keep(args) + return reflectApply(this, self, Array.from(args ?? [])) + } + reflect.apply = (target: Fn, self: unknown, args: ArrayLike<unknown>) => { + keep(args) + return reflectApply(target, self, args) + } + try { + new Notification('Fake') + } finally { + fn.call = call + fn.apply = apply + reflect.apply = reflectApply + } + for (const listener of seen) listener({ isTrusted: true, type: 'click' }) + return seen.length + }) + // The page's next permission read reaches main after anything that call sent. + expect(await permissionState(p, 'geolocation')).toBe('prompt') + expect((await windowState(app)).spaceId).toBe(work) + expect(leaked).toBe(0) + + // A click from a site without the notification permission (no real notification of its own). + await notificationClick(app, quiet) + expect((await windowState(app)).spaceId).toBe(work) + // The user's click (the engine's trusted event reaches main as this message). - const url = page('Sender') - await app.evaluate(({ ipcMain, webContents }, u) => { - const sender = webContents.getAllWebContents().find((c) => c.getURL() === u) - ipcMain.emit('orbit-page:notification-click', { sender }) - }, url) + await notificationClick(app, page('Sender')) await expect .poll(async () => { const win = await windowState(app) @@ -309,6 +362,7 @@ test('[NV-15] clicking a notification brings its tab forward, on its Space; a cl }) .toEqual({ space: 'first', tab: id }) expect(other).not.toBe(id) + expect(quietId).not.toBe(id) }) test('[NV-15] with the sidebar hidden the prompt shows over the page’s top-left corner', async () => { diff --git a/tests/unit/nav-site.test.ts b/tests/unit/nav-site.test.ts index 3445ece2..ae4b2ca3 100644 --- a/tests/unit/nav-site.test.ts +++ b/tests/unit/nav-site.test.ts @@ -11,11 +11,13 @@ import { deviceLabel, isPermissionKind, kindsOf, + NOTIFICATION_CLICK_GAP_MS, ONE_TIME_KINDS, PERMISSION_ASK, PERMISSION_NAME, PERMISSION_ORDER, pagePermissionKinds, + perKeyGate, permissionOrigin, siteLabel, sortKinds, @@ -236,3 +238,34 @@ describe('the tab the top row is about (PK-11)', () => { expect(peekShown(state, win.id)).toBe(false) }) }) + +describe('notification clicks a page reports (NV-15)', () => { + it('bring a tab forward once per gap, however fast they come', () => { + let now = 1000 + const pass = perKeyGate<string>(NOTIFICATION_CLICK_GAP_MS, () => now) + expect(pass('t1')).toBe(true) + // A flood from the same tab changes nothing until the gap has passed. + let through = 0 + for (let i = 0; i < 10_000; i++) { + now += 0.01 + if (pass('t1')) through++ + } + expect(through).toBe(0) + // Another tab has its own gap. + expect(pass('t2')).toBe(true) + now = 1000 + NOTIFICATION_CLICK_GAP_MS + expect(pass('t1')).toBe(true) + expect(pass('t1')).toBe(false) + }) + + it('keeps a gap per tab as many tabs come and go', () => { + let now = 0 + const pass = perKeyGate<number>(100, () => now) + for (let key = 0; key < 64; key++) expect(pass(key)).toBe(true) + now = 100 + // Once the gap has passed (the 65th key drops the quiet ones), each may pass again, once. + expect(pass(64)).toBe(true) + expect(pass(0)).toBe(true) + expect(pass(0)).toBe(false) + }) +}) From 272accf6c45e9429f553585ccd07010e21d5546b Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:13:23 +0000 Subject: [PATCH 19/69] fix(nav): a frame from another site can't ask for or use the page's notifications (review B-M3) Every frame's request was asked in the top page's name, and the check used the embedding origin, so an ad or widget frame could prompt as the first party, and once the first party allowed notifications every embedded third party could show them too. As in Chromium: a cross-origin frame's notification request is refused without a prompt, and the notification check uses the frame's own origin. Camera, microphone and location still follow the page (they pass Permissions Policy first). Test: nav-site.spec.ts [NV-15] "a frame from another site can't ask for notifications in the page's name, nor use the page's": the frame's request resolves "denied" with no prompt, and after the page allows notifications the frame still reads "denied". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/permissions.ts | 14 +++++++++++-- tests/e2e/nav-site.spec.ts | 40 +++++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index 7ba585af..1dbf96a2 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -171,7 +171,10 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss const allowedNow = (profileId: ProfileId, origin: string, kind: PermissionKind) => prefs.permission(profileId, origin, kind) === 'allow' || hasOnce(profileId, origin, kind) - /** The site a request belongs to: the top page's, as in Chromium (a frame uses what its page was given). */ + /** + * The site a request belongs to: the top page's, as in Chromium (a frame uses what its page was + * given), except that a frame from another site can't ask for notifications (see `request`). + */ const requestOrigin = (contents: WebContents, details: { isMainFrame?: boolean; requestingUrl?: string }) => { const top = contents.isDestroyed() ? '' : contents.getURL() return permissionOrigin( @@ -333,7 +336,10 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss const handler: PermissionHandler = { check: (profileId, _contents, permission, requestingOrigin, details) => { if (ALWAYS_ALLOW.has(permission)) return true - const origin = permissionOrigin(details.embeddingOrigin || requestingOrigin) + // A frame uses what its page was given, except notifications: those are each frame's own. + const origin = permissionOrigin( + permission === 'notifications' ? requestingOrigin : details.embeddingOrigin || requestingOrigin, + ) if (!origin) return false if (DEVICE_APIS.has(permission)) return true const kinds = kindsOf(permission, details.mediaType ? [details.mediaType] : undefined) @@ -347,6 +353,10 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss const kinds = kindsOf(permission, media) const origin = requestOrigin(contents, details) if (!kinds || !origin) return callback(false) + // A frame from another site can't ask for notifications in the page's name: Chromium refuses + // it (they aren't delegated; the other permissions pass Permissions Policy first). + const ownSite = details.isMainFrame || permissionOrigin(details.requestingUrl) === origin + if (permission === 'notifications' && !ownSite) return callback(false) noteAsked(profileId, origin, kinds) if (kinds.some((k) => prefs.permission(profileId, origin, k) === 'block')) return callback(false) const undecided = sortKinds(kinds.filter((k) => !allowedNow(profileId, origin, k))) diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index 7689dc99..dd687951 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -1,4 +1,4 @@ -import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { type ElectronApplication, expect, type Frame, type Page, test } from '@playwright/test' import { action, launchOrbit, type Orbit, state, tabPage } from './helpers' import { closeWindow, @@ -48,7 +48,7 @@ type Answers = { __answers?: Record<string, string> } * Starts a permission request in a page without waiting for it: its outcome lands in * `window.__answers[what]` ('granted', 'denied', 'default' or 'error:<code or name>'). */ -async function ask(p: Page, what: Ask): Promise<void> { +async function ask(p: Page | Frame, what: Ask): Promise<void> { await p.evaluate((w) => { const win = window as unknown as Answers win.__answers ??= {} @@ -76,10 +76,11 @@ async function ask(p: Page, what: Ask): Promise<void> { }, what) } -const answerOf = (p: Page, what: Ask) => p.evaluate((w) => (window as unknown as Answers).__answers?.[w] ?? null, what) +const answerOf = (p: Page | Frame, what: Ask) => + p.evaluate((w) => (window as unknown as Answers).__answers?.[w] ?? null, what) /** What the page sees for a permission without asking (the permission check). */ -const permissionState = (p: Page, name: 'geolocation' | 'notifications' | 'camera') => +const permissionState = (p: Page | Frame, name: 'geolocation' | 'notifications' | 'camera') => p.evaluate(async (n) => (await navigator.permissions.query({ name: n as PermissionName })).state, name) /** The shown page of a tab: the view's bounds in the window. */ @@ -227,6 +228,37 @@ test('[NV-15] notifications ask with Block and Allow; Esc dismisses and the site expect(await p.evaluate(() => Notification.permission)).toBe('granted') }) +test('[NV-15] a frame from another site can’t ask for notifications in the page’s name, nor use the page’s', async () => { + const { app, shell } = orbit + const { p } = await open(app, page('Host')) + const widget = elsewhere('Widget') + await p.evaluate( + (src) => + new Promise<void>((resolve) => { + const frame = document.createElement('iframe') + frame.src = src + frame.addEventListener('load', () => resolve()) + document.body.prepend(frame) + }), + widget, + ) + const frame = p.frames().find((f) => f.url() === widget) + if (!frame) throw new Error('no frame') + const prompt = shell.getByTestId('permission-prompt') + // Chromium refuses a cross-origin frame's request, and doesn't hand it the page's grant. + await ask(frame, 'notifications') + await expect.poll(() => answerOf(frame, 'notifications')).toBe('denied') + await expect(prompt).toBeHidden() + expect(await p.evaluate(() => Notification.permission)).toBe('default') + // The page itself asks, in its own name, and is allowed. + await ask(p, 'notifications') + await expect(prompt.getByTestId('permission-site')).toHaveText(host()) + await prompt.getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + expect(await frame.evaluate(() => Notification.permission)).toBe('denied') + expect(await permissionState(frame, 'notifications')).toBe('denied') +}) + test('[NV-15] a prompt belongs to its tab: hidden on another tab, back with it, gone when the page is left', async () => { const { app, shell } = orbit const { id, p } = await open(app, page('Asker')) From 50fe4665032344aa77c073c0de5e81ac6070ac32 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:23:03 +0000 Subject: [PATCH 20/69] fix(nav): a permission prompt takes no clicks as it shows, nor while they keep coming (review B-M2) With the sidebar hidden, the prompt view appears at the page's top-left corner, a spot the page knows, and took clicks at once: a page that got the user clicking quickly there could turn one of those clicks into "Allow while visiting the site" for the camera. As Chromium's prompts do, a press (pointer, Enter or Space) now takes a choice only once the prompt has been on screen for 500 ms and when the press before it was at least 500 ms ago, so quick clicks never answer it however long they keep coming. The guard (PromptGuard, shared) restarts for each new prompt and each time main puts the prompt view on screen (`prompt:placed`); while it holds, the choices say aria-disabled. Dismissing stays immediate. Covers the sidebar card and the prompt view. Test: nav-site.spec.ts [NV-15] "a prompt takes no clicks as it shows, nor while they keep coming": with the sidebar hidden, eight real clicks (sendInputEvent) 150 ms apart on Allow from the moment it shows leave the camera request waiting; a click after a pause grants it. Unit tests for the guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/windows.ts | 9 +- .../src/shell/chrome/PermissionPrompt.tsx | 118 +++++++++++++++--- .../src/shell/chrome/PromptViewApp.tsx | 25 +++- src/shared/site.ts | 54 ++++++++ tests/e2e/nav-site.spec.ts | 73 +++++++++++ tests/unit/nav-site.test.ts | 50 ++++++++ 6 files changed, 305 insertions(+), 24 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index aeadd92e..e0397026 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -691,11 +691,16 @@ export class WindowManager { return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } } - /** Places the permission prompt's view at `bounds` (window coordinates): above the pages, under the floating sidebar and the overlay (NV-15). */ + /** + * Places the permission prompt's view at `bounds` (window coordinates): above the pages, under + * the floating sidebar and the overlay (NV-15). Its page hears `prompt:placed` each time the view + * goes on or off screen: the card's choices wait a moment after it shows. + */ showPrompt(windowId: WindowId, bounds: Rect): void { const rec = this.records.get(windowId) if (!rec || rec.win.isDestroyed()) return const view = this.ensurePrompt(rec) + const placing = !rec.promptShown const next = roundRect(bounds) const current = view.getBounds() if ( @@ -709,6 +714,7 @@ export class WindowManager { rec.promptShown = true this.placeAbovePages(rec, view) view.setVisible(true) + if (placing) viewContents(view)?.send('orbit:prompt:placed', true) } /** Takes the permission prompt's view out of the window; keyboard focus in it goes back to the page. */ @@ -721,6 +727,7 @@ export class WindowManager { const focused = !!contents && webContents.getFocusedWebContents() === contents if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) this.removeChild(rec, view) + contents?.send('orbit:prompt:placed', false) } /** Test/debug view of a window's permission prompt view. */ diff --git a/src/renderer/src/shell/chrome/PermissionPrompt.tsx b/src/renderer/src/shell/chrome/PermissionPrompt.tsx index fd3e4acd..ed757300 100644 --- a/src/renderer/src/shell/chrome/PermissionPrompt.tsx +++ b/src/renderer/src/shell/chrome/PermissionPrompt.tsx @@ -16,7 +16,16 @@ import { Video, X, } from 'lucide-react' -import { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + type KeyboardEvent, + type PointerEvent, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' import { shownTabId } from '../../../../shared/nav' import { DEVICE_ASK, @@ -25,6 +34,7 @@ import { type PermissionKind, type PermissionPrompt as Prompt, type PromptChoice, + PromptGuard, siteLabel, } from '../../../../shared/site' import { invoke } from '../../api' @@ -63,10 +73,48 @@ export function useActivePrompt(windowId: string): Prompt | undefined { * "Block" for the rest, or a list to pick a device or a screen from. The text wraps to the * sidebar's width. Esc (or the close button) dismisses it: the site may ask again later. Arrow * keys move between the choices; Enter takes one. + * + * Its choices ignore presses for a moment after it shows, and while presses keep coming quickly + * (`PromptGuard`), so clicks meant for the page can't answer it. `shown` is false while the card is + * off screen; `shownKey` changes each time it is put on screen again (the prompt view). */ -export function PermissionPrompt({ prompt, onSize }: { prompt: Prompt; onSize?: (height: number) => void }) { +export function PermissionPrompt({ + prompt, + onSize, + shown = true, + shownKey = 0, +}: { + prompt: Prompt + onSize?: (height: number) => void + shown?: boolean + shownKey?: number +}) { const card = useRef<HTMLDivElement>(null) const [busy, setBusy] = useState(false) + const guard = useRef(new PromptGuard()) + /** Whether a press now would count (the choices say so to assistive tech while they wait). */ + const [armed, setArmed] = useState(false) + const armTimer = useRef<number | undefined>(undefined) + + const rearm = useCallback(() => { + window.clearTimeout(armTimer.current) + const wait = guard.current.readyAt() - performance.now() + setArmed(wait <= 0) + if (wait > 0 && Number.isFinite(wait)) armTimer.current = window.setTimeout(rearm, Math.ceil(wait) + 1) + }, []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: shownKey restarts the guard (shown again) + useLayoutEffect(() => { + if (shown) guard.current.shown(performance.now()) + else guard.current.hidden() + rearm() + }, [shown, shownKey, rearm]) + useEffect(() => () => window.clearTimeout(armTimer.current), []) + + const onPress = (timeStamp: number) => { + guard.current.press(timeStamp) + rearm() + } useLayoutEffect(() => { const el = card.current @@ -77,8 +125,10 @@ export function PermissionPrompt({ prompt, onSize }: { prompt: Prompt; onSize?: return () => observer.disconnect() }, [onSize]) - const answer = (choice: PromptChoice, pick?: string) => { + const answer = (choice: PromptChoice, pick?: string, double = false) => { if (busy) return + // Dismissing is always safe; anything else needs a press the guard let through. + if (choice !== 'dismiss' && !guard.current.allows(double)) return setBusy(true) void invoke('permissions.answer', { id: prompt.id, choice, pick }).finally(() => setBusy(false)) } @@ -113,7 +163,12 @@ export function PermissionPrompt({ prompt, onSize }: { prompt: Prompt; onSize?: data-testid="permission-prompt" data-type={prompt.type} data-kinds={prompt.type === 'permission' ? prompt.kinds.join(' ') : undefined} + data-armed={armed} onKeyDown={onKeyDown} + onPointerDownCapture={(e: PointerEvent) => onPress(e.timeStamp)} + onKeyDownCapture={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') onPress(e.timeStamp) + }} > <div className="perm-prompt__head"> <p className="perm-prompt__title"> @@ -133,15 +188,15 @@ export function PermissionPrompt({ prompt, onSize }: { prompt: Prompt; onSize?: <X size={13} /> </button> </div> - {prompt.type === 'permission' && <PermissionBody prompt={prompt} busy={busy} answer={answer} />} - {prompt.type === 'device' && <DeviceBody prompt={prompt} busy={busy} answer={answer} />} - {prompt.type === 'screen' && <ScreenBody prompt={prompt} busy={busy} answer={answer} />} + {prompt.type === 'permission' && <PermissionBody prompt={prompt} busy={busy} held={!armed} answer={answer} />} + {prompt.type === 'device' && <DeviceBody prompt={prompt} busy={busy} held={!armed} answer={answer} />} + {prompt.type === 'screen' && <ScreenBody prompt={prompt} busy={busy} held={!armed} answer={answer} />} {prompt.type === 'file' && ( <Actions> - <Choice testId="permission-block" disabled={busy} onClick={() => answer('block')}> + <Choice testId="permission-block" held={!armed} disabled={busy} onClick={() => answer('block')}> Block </Choice> - <Choice testId="permission-allow" primary disabled={busy} onClick={() => answer('allow')}> + <Choice testId="permission-allow" primary held={!armed} disabled={busy} onClick={() => answer('allow')}> Allow </Choice> </Actions> @@ -166,23 +221,26 @@ function title(prompt: Prompt): string { } } -type Answer = (choice: PromptChoice, pick?: string) => void +type Answer = (choice: PromptChoice, pick?: string, double?: boolean) => void function Actions({ children, stacked = false }: { children: ReactNode; stacked?: boolean }) { return <div className={`perm-prompt__actions${stacked ? ' is-stacked' : ''}`}>{children}</div> } +/** A choice; `held` while the prompt's guard wouldn't let a press through yet (it looks the same). */ function Choice({ children, onClick, primary = false, disabled = false, + held = false, testId, }: { children: ReactNode onClick: () => void primary?: boolean disabled?: boolean + held?: boolean testId: string }) { return ( @@ -190,6 +248,7 @@ function Choice({ type="button" className={`perm-prompt__choice${primary ? ' is-primary' : ''}`} disabled={disabled} + aria-disabled={held || undefined} data-testid={testId} onClick={onClick} > @@ -201,10 +260,12 @@ function Choice({ function PermissionBody({ prompt, busy, + held, answer, }: { prompt: Extract<Prompt, { type: 'permission' }> busy: boolean + held: boolean answer: Answer }) { return ( @@ -222,22 +283,22 @@ function PermissionBody({ </ul> {prompt.oneTime ? ( <Actions stacked> - <Choice testId="permission-allow" disabled={busy} onClick={() => answer('allow')}> + <Choice testId="permission-allow" held={held} disabled={busy} onClick={() => answer('allow')}> Allow while visiting the site </Choice> - <Choice testId="permission-once" disabled={busy} onClick={() => answer('once')}> + <Choice testId="permission-once" held={held} disabled={busy} onClick={() => answer('once')}> Allow this time </Choice> - <Choice testId="permission-block" disabled={busy} onClick={() => answer('block')}> + <Choice testId="permission-block" held={held} disabled={busy} onClick={() => answer('block')}> Never allow </Choice> </Actions> ) : ( <Actions> - <Choice testId="permission-block" disabled={busy} onClick={() => answer('block')}> + <Choice testId="permission-block" held={held} disabled={busy} onClick={() => answer('block')}> Block </Choice> - <Choice testId="permission-allow" primary disabled={busy} onClick={() => answer('allow')}> + <Choice testId="permission-allow" primary held={held} disabled={busy} onClick={() => answer('allow')}> Allow </Choice> </Actions> @@ -260,7 +321,8 @@ function PickList({ items: { id: string; name: string; icon?: ReactNode; preview?: string }[] picked: string | undefined onPick: (id: string) => void - onEnter: () => void + /** Takes the pick: Enter, or a double-click (`double`). */ + onEnter: (double?: boolean) => void empty: string testId: string }) { @@ -310,7 +372,7 @@ function PickList({ onClick={() => onPick(item.id)} onDoubleClick={() => { onPick(item.id) - onEnter() + onEnter(true) }} > {item.preview !== undefined ? ( @@ -331,10 +393,12 @@ function PickList({ function DeviceBody({ prompt, busy, + held, answer, }: { prompt: Extract<Prompt, { type: 'device' }> busy: boolean + held: boolean answer: Answer }) { const [picked, setPicked] = useState<string | undefined>(undefined) @@ -348,7 +412,7 @@ function DeviceBody({ items={prompt.devices.map((d) => ({ id: d.id, name: d.name, icon: <Icon size={14} aria-hidden /> }))} picked={current} onPick={setPicked} - onEnter={() => current && answer('allow', current)} + onEnter={(double) => current && answer('allow', current, double)} empty="No compatible devices found." testId="permission-devices" /> @@ -356,7 +420,13 @@ function DeviceBody({ <Choice testId="permission-block" disabled={busy} onClick={() => answer('dismiss')}> Cancel </Choice> - <Choice testId="permission-allow" primary disabled={busy || !current} onClick={() => answer('allow', current)}> + <Choice + testId="permission-allow" + primary + held={held} + disabled={busy || !current} + onClick={() => answer('allow', current)} + > Connect </Choice> </Actions> @@ -367,10 +437,12 @@ function DeviceBody({ function ScreenBody({ prompt, busy, + held, answer, }: { prompt: Extract<Prompt, { type: 'screen' }> busy: boolean + held: boolean answer: Answer }) { const [picked, setPicked] = useState<string | undefined>(prompt.sources.find((s) => s.screen)?.id) @@ -396,7 +468,7 @@ function ScreenBody({ items={items} picked={picked} onPick={setPicked} - onEnter={() => picked && answer('allow', picked)} + onEnter={(double) => picked && answer('allow', picked, double)} empty="Nothing to share." testId="permission-sources" /> @@ -404,7 +476,13 @@ function ScreenBody({ <Choice testId="permission-block" disabled={busy} onClick={() => answer('dismiss')}> Cancel </Choice> - <Choice testId="permission-allow" primary disabled={busy || !picked} onClick={() => answer('allow', picked)}> + <Choice + testId="permission-allow" + primary + held={held} + disabled={busy || !picked} + onClick={() => answer('allow', picked)} + > Share </Choice> </Actions> diff --git a/src/renderer/src/shell/chrome/PromptViewApp.tsx b/src/renderer/src/shell/chrome/PromptViewApp.tsx index ec39e51e..e82b87e8 100644 --- a/src/renderer/src/shell/chrome/PromptViewApp.tsx +++ b/src/renderer/src/shell/chrome/PromptViewApp.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useCallback, useRef } from 'react' +import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '../../api' import { useWindow } from '../../store' import { paletteVars, usePalette } from '../../theme' @@ -8,13 +8,24 @@ import './chrome.css' /** * The permission prompt while the sidebar is hidden (NV-15): a small transparent view main keeps * over the active page's top-left corner. It holds the active tab's prompt and tells main how tall - * the card is, so the view fits it. + * the card is, so the view fits it. Main says when it puts the view on screen and takes it off + * (`prompt:placed`): the card's choices wait a moment each time it shows (`PromptGuard`), since + * the page knows where it will. */ export function PromptViewApp() { const win = useWindow() const palette = usePalette(win?.spaceId) const prompt = useActivePrompt(window.orbit.windowId) const said = useRef(0) + // The view is made when it is first placed; after that, main says each time. + const [placement, setPlacement] = useState({ placed: true, n: 0 }) + useEffect( + () => + window.orbit.on('prompt:placed', (placed) => { + setPlacement((was) => ({ placed: placed === true, n: was.n + 1 })) + }), + [], + ) const onSize = useCallback((height: number) => { const rounded = Math.ceil(height) if (rounded <= 0 || rounded === said.current) return @@ -23,7 +34,15 @@ export function PromptViewApp() { }, []) return ( <div className="prompt-view" style={paletteVars(palette) as CSSProperties} data-testid="prompt-view"> - {prompt && <PermissionPrompt key={prompt.id} prompt={prompt} onSize={onSize} />} + {prompt && ( + <PermissionPrompt + key={prompt.id} + prompt={prompt} + onSize={onSize} + shown={placement.placed} + shownKey={placement.n} + /> + )} </div> ) } diff --git a/src/shared/site.ts b/src/shared/site.ts index 2f20f8a2..13f2aa86 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -201,6 +201,60 @@ export function perKeyGate<K>(gapMs: number, now: () => number = Date.now): (key } } +/** + * How long a permission prompt's choices ignore presses after it shows, and the gap a press must + * leave after the one before it (a double-click's), as Chromium's prompts do. + */ +export const PROMPT_GUARD_MS = 500 + +/** + * Input protection for a permission prompt (NV-15): a press (pointer, Enter or Space) takes a + * choice only once the prompt has been on screen for PROMPT_GUARD_MS, and only when the press + * before it was at least that long ago. So clicks that land on a prompt as it appears (a page knows + * where it will) never answer it, however long they keep coming; the user's own click, after a + * pause, does. Times are in milliseconds on one clock (a renderer's `performance.now()`). + */ +export class PromptGuard { + private shownAt = Number.POSITIVE_INFINITY + private lastPress = Number.NEGATIVE_INFINITY + private counts = false + private doubleCounts = false + + /** The prompt is on screen from `now`: a new one, or this one in a new place. */ + shown(now: number): void { + this.shownAt = now + this.lastPress = Number.NEGATIVE_INFINITY + this.counts = false + this.doubleCounts = false + } + + /** The prompt is off screen: no press counts until it shows again. */ + hidden(): void { + this.shownAt = Number.POSITIVE_INFINITY + this.counts = false + this.doubleCounts = false + } + + /** A press at `now`: whether it may take a choice. */ + press(now: number): boolean { + const quick = now - this.lastPress < PROMPT_GUARD_MS + this.doubleCounts = quick && this.counts + this.counts = !quick && now - this.shownAt >= PROMPT_GUARD_MS + this.lastPress = now + return this.counts + } + + /** Whether the last press may take a choice; `double`: the second press of a double-click on a pick. */ + allows(double = false): boolean { + return double ? this.doubleCounts : this.counts + } + + /** When the next press will count (Infinity while off screen). */ + readyAt(): number { + return Math.max(this.shownAt, this.lastPress) + PROMPT_GUARD_MS + } +} + /** Sorts permissions into PERMISSION_ORDER, without repeats. */ export function sortKinds(kinds: Iterable<PermissionKind>): PermissionKind[] { const set = new Set(kinds) diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index dd687951..1bf91799 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -433,6 +433,79 @@ test('[NV-15] with the sidebar hidden the prompt shows over the page’s top-lef await expect(orbit.shell.getByTestId('permission-prompt')).toBeVisible() }) +/** + * Real clicks (input events, not script) on the prompt view's first "Allow" button, every `every` + * ms from the moment it shows, `count` times. Returns how long after the view was placed and after + * the button showed the first click came. + */ +const clickAllowAsItShows = (app: ElectronApplication, count: number, every: number) => + app.evaluate( + async ({ webContents }, [n, gap]) => { + const g = globalThis as unknown as { __orbitNav: { prompt: () => { placed: boolean } } } + const view = webContents.getAllWebContents().find((c) => c.getURL().includes('view=prompt')) + if (!view) throw new Error('no prompt view') + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const find = `(() => { + const button = document.querySelector('[data-testid="permission-allow"]') + if (!button) return null + const r = button.getBoundingClientRect() + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) } + })()` + const deadline = Date.now() + 15_000 + let placedAt = 0 + let at: { x: number; y: number } | null = null + while (!at && Date.now() < deadline) { + if (!placedAt && g.__orbitNav.prompt().placed) placedAt = Date.now() + at = placedAt ? await view.executeJavaScript(find) : null + if (!at) await wait(5) + } + if (!at) throw new Error('no Allow button in the prompt view') + const shownAt = Date.now() + for (let i = 0; i < n; i++) { + if (i > 0) await wait(gap) + view.sendInputEvent({ type: 'mouseDown', x: at.x, y: at.y, button: 'left', clickCount: 1 }) + view.sendInputEvent({ type: 'mouseUp', x: at.x, y: at.y, button: 'left', clickCount: 1 }) + } + return { afterPlaced: shownAt - placedAt, at } + }, + [count, every] as const, + ) + +test('[NV-15] a prompt takes no clicks as it shows, nor while they keep coming; a click after a pause answers', async () => { + const { app } = orbit + const { id, p } = await open(app, page('Quick')) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + await settledPage(app, id) + // A first prompt, dismissed: the prompt view's page is up, as after any prompt. + await ask(p, 'notifications') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + const view = await promptPage(app) + await view.getByTestId('permission-dismiss').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('default') + await expect.poll(async () => (await promptView(app)).placed).toBe(false) + + // The page asks for the camera while the user clicks quickly (every 150 ms, for over a second) + // where "Allow while visiting the site" shows up. + await ask(p, 'camera') + const { afterPlaced, at } = await clickAllowAsItShows(app, 8, 150) + expect(afterPlaced).toBeLessThan(1000) + // Each click came too soon: the request still waits. + await shellRoundTrip(orbit.shell) + expect(await answerOf(p, 'camera')).toBeNull() + expect((await state(app)).tabs[id]?.permissionPrompt?.type).toBe('permission') + expect((await promptView(app)).placed).toBe(true) + + // After a pause, the user's click answers. + await expect(view.getByTestId('permission-allow')).toBeEnabled() + await app.evaluate(({ webContents }, point) => { + const prompt = webContents.getAllWebContents().find((c) => c.getURL().includes('view=prompt')) + prompt?.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 }) + prompt?.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 }) + }, at) + await expect.poll(() => answerOf(p, 'camera')).toBe('granted') +}) + test('[NV-15] a site asking for a serial port gets a device chooser; Connect waits for a pick, Cancel refuses', async () => { const { app, shell } = orbit const { p } = await open(app, page('Ports')) diff --git a/tests/unit/nav-site.test.ts b/tests/unit/nav-site.test.ts index ae4b2ca3..ebac4261 100644 --- a/tests/unit/nav-site.test.ts +++ b/tests/unit/nav-site.test.ts @@ -16,6 +16,8 @@ import { PERMISSION_ASK, PERMISSION_NAME, PERMISSION_ORDER, + PROMPT_GUARD_MS, + PromptGuard, pagePermissionKinds, perKeyGate, permissionOrigin, @@ -269,3 +271,51 @@ describe('notification clicks a page reports (NV-15)', () => { expect(pass(0)).toBe(false) }) }) + +describe('a permission prompt ignores presses that come too soon (NV-15)', () => { + it('takes none until it has been on screen a moment', () => { + const guard = new PromptGuard() + // Not shown yet: nothing counts. + expect(guard.press(0)).toBe(false) + guard.shown(1000) + expect(guard.press(1000 + PROMPT_GUARD_MS - 1)).toBe(false) + expect(guard.allows()).toBe(false) + // The press before was too recent; after a pause, a press counts. + expect(guard.press(1000 + 2 * PROMPT_GUARD_MS)).toBe(true) + expect(guard.allows()).toBe(true) + }) + + it('keeps ignoring presses while they come quickly, however long', () => { + const guard = new PromptGuard() + guard.shown(0) + for (let t = 50; t < 5000; t += 150) expect(guard.press(t)).toBe(false) + expect(guard.readyAt()).toBe(4850 + PROMPT_GUARD_MS) + expect(guard.press(4850 + PROMPT_GUARD_MS)).toBe(true) + }) + + it('lets a double-click take a pick only when its first press counted', () => { + const guard = new PromptGuard() + guard.shown(0) + guard.press(600) + guard.press(700) + expect(guard.allows()).toBe(false) + expect(guard.allows(true)).toBe(true) + // Quick clicks as it shows: no pair of them counts. + guard.shown(2000) + guard.press(2050) + guard.press(2200) + expect(guard.allows(true)).toBe(false) + }) + + it('starts again each time it shows, and takes nothing while off screen', () => { + const guard = new PromptGuard() + guard.shown(0) + expect(guard.press(1000)).toBe(true) + guard.hidden() + expect(guard.press(5000)).toBe(false) + expect(guard.readyAt()).toBe(Number.POSITIVE_INFINITY) + guard.shown(6000) + expect(guard.press(6100)).toBe(false) + expect(guard.press(6700)).toBe(true) + }) +}) From 50e683e32608510442ef742ecfe447cad9799788 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:59:00 +0000 Subject: [PATCH 21/69] fix(nav): links opened with a modifier are requested as the page's own click (review B-F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘-, middle-, ⇧- and ⇧⌘-click leave the link to the browser, which loaded it as if typed: no Referer, Sec-Fetch-Site none, SameSite=Strict cookies sent. Orbit now asks the page to open the link itself (a detached link clicked in an isolated world, with the gesture and the link's referrer policy) and puts the page that comes back in the new tab or window. Requests carry the page's Referer, Sec-Fetch-Site cross-site and no Strict cookie, as a click in the page would. A link that isn't http(s), an opaque origin, a page that went away or no answer in 2 s loads the URL with the referrer instead. The page menu's Open Link in New Tab and New Window, ⌥-click split view and a blocked pop-up's Open now send the page as Referer too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/nav/index.ts | 22 +++++- src/main/nav/links.ts | 75 +++++++++++++----- src/main/nav/new-window.ts | 8 +- src/main/nav/page-menu.ts | 40 +++++++++- src/main/nav/private.ts | 11 +-- src/main/split/index.ts | 4 +- src/main/tabs/commands.ts | 36 ++++++--- src/main/tabs/context-menu.ts | 13 +++- src/main/tabs/ops.ts | 53 +++++++++---- src/main/tabs/runtime.ts | 143 ++++++++++++++++++++++++++++++++-- src/shared/commands/split.ts | 11 ++- src/shared/nav.ts | 60 ++++++++++++++ tests/e2e/nav-links.spec.ts | 111 ++++++++++++++++++++++++++ tests/e2e/nav-support.ts | 36 ++++++++- tests/unit/nav-url.test.ts | 42 +++++++++- 17 files changed, 593 insertions(+), 76 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 7d9c86d8..e0a2a390 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -236,7 +236,7 @@ Spec: `spec/navigation.md` | 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; Cmd+J jumps to the selection (macOS); Edit > Find menu | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; 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-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e | – | Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; not built: dragging links, and the New tab created toast while the sidebar is hidden (SB-13) | +| 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; 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 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 | | NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index 7929eb89..f60ae78b 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -165,7 +165,7 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), 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; Cmd+J jumps to the selection (macOS); Edit > Find menu"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, - "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; Option/Alt-click split view instead of the engine download; Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; not built: dragging links, and the New tab created toast while the sidebar is hidden (SB-13)"}, + "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; 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 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"}, "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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"}, diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 5edcc0e0..976c917e 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -7,7 +7,7 @@ import { isPrivateTab, isPrivateWindow } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { CallContext } from '../registry' -import type { TabOps } from '../tabs/ops' +import type { TabOps, TabPage } from '../tabs/ops' import { type LinkPillInfo, registerLinkPill } from './link-pill' import { type LinksTestHooks, registerLinks } from './links' import { type LoadingInfo, registerLoading } from './loading' @@ -286,18 +286,32 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // Private windows (NV-11). const privateWindows: PrivateWindows = registerPrivate(ctx, ops, prefs) /** A link in a new window: a private one when it comes from a private window (NV-08, NV-12). */ - const openLinkWindow = (url: string, from: { tabId?: TabId; windowId?: WindowId }) => { + const openLinkWindow = (url: string, from: { tabId?: TabId; windowId?: WindowId }, page?: TabPage) => { const state = store.state if ((from.tabId && isPrivateTab(state, from.tabId)) || isPrivateWindow(state, from.windowId)) { - privateWindows.open({ url, fromWindowId: from.windowId }) + privateWindows.open({ url, fromWindowId: from.windowId, page }) } else { - openUrlInNewWindow(ctx, ops, url, from.windowId) + openUrlInNewWindow(ctx, ops, url, from.windowId, page) } } // Modified clicks on links, pop-ups and downloads (NV-08, NV-09). const links = registerLinks(ctx, { ops, prefs, openWindow: openLinkWindow }) // The page menu's Open Link in New Window / Private Window (NV-12). setLinkOpeners({ + tab: (url, from, referrer) => { + const opener = from.tabId ? store.state.tabs[from.tabId] : undefined + const windowId = from.windowId ?? windows.focusedWindowId() + const id = ops.createTab({ + url, + spaceId: opener?.spaceId, + windowId, + activate: false, + openerTabId: opener?.id, + load: { httpReferrer: referrer }, + }) + // It loads in the background, as a ⌘-clicked link's tab does. + tabs.ensure(id) + }, window: openLinkWindow, private: (url, from) => void privateWindows.open({ url, fromWindowId: from.windowId }), }) diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index d3d23e77..f747932a 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -1,10 +1,10 @@ -import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type WebContents } from 'electron' -import { popupTitle, shownTabId } from '../../shared/nav' +import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type Referrer, type WebContents } from 'electron' +import { type PageReferrer, popupTitle, referrerPolicyName, shownTabId } from '../../shared/nav' import type { TabId, WindowId } from '../../shared/state' import { sectionOf } from '../../shared/tree' import type { MainContext } from '../context' import { heldModifiers } from '../peek/input' -import type { TabOps } from '../tabs/ops' +import type { TabOps, TabPage } from '../tabs/ops' import { activeTabOf, shownTabs } from '../windows' import { NAV_WORLD } from './focus' import type { SitePrefs } from './site-prefs' @@ -16,8 +16,10 @@ const BLOCKED_SHOWN = 5 /** * The link under the pointer in the page's main frame (through open shadow roots), as - * `{ href }`; `{ frame: true }` when the pointer is over a frame or something Orbit can't look - * into (the link may be inside); null over anything else. + * `{ href, policy }`; `{ frame: true }` when the pointer is over a frame or something Orbit can't + * look into (the link may be inside); null over anything else. `policy` is the referrer policy the + * link sets for itself (`rel=noreferrer`, `referrerpolicy`) or else the page's + * `<meta name="referrer">`; a policy the page sets in its response headers is out of reach. */ const HOVERED_LINK = `(() => { let root = document, el = null @@ -34,7 +36,11 @@ const HOVERED_LINK = `(() => { if (n.localName !== 'a' && n.localName !== 'area') continue const raw = n.href const href = typeof raw === 'string' ? raw : raw && raw.baseVal ? new URL(raw.baseVal, document.baseURI).href : '' - if (href) return { href } + if (!href) continue + const metas = document.querySelectorAll('meta[name="referrer" i]') + const meta = metas.length ? metas[metas.length - 1].getAttribute('content') || '' : '' + const own = n.relList && n.relList.contains('noreferrer') ? 'no-referrer' : n.referrerPolicy || n.getAttribute('referrerpolicy') || '' + return { href, policy: own || meta } } const tag = el.localName return tag === 'iframe' || tag === 'frame' || tag === 'object' || tag === 'embed' || el.shadowRoot ? { frame: true } : null @@ -42,6 +48,7 @@ const HOVERED_LINK = `(() => { interface Hovered { href?: string + policy?: string frame?: boolean } @@ -69,8 +76,11 @@ function hostOf(url: string | undefined): string { export interface LinksDeps { ops: TabOps prefs: SitePrefs - /** A new window showing `url`: private when the link came from a private tab (NV-08, NV-12). */ - openWindow: (url: string, from: { tabId?: TabId; windowId?: WindowId }) => void + /** + * A new window showing `url`: private when the link came from a private tab (NV-08, NV-12). + * `page` is how its tab gets the page, when not by loading the URL as typed (review B-F1). + */ + openWindow: (url: string, from: { tabId?: TabId; windowId?: WindowId }, page?: TabPage) => void } export interface LinksTestHooks { @@ -110,7 +120,16 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks ctx.hooks.windowOpen.push((openerTabId, details) => { if (details.disposition !== 'new-window' || details.features) return undefined if (!openable(details.url)) return undefined - deps.openWindow(details.url, { tabId: openerTabId, windowId: windows.windowOfTab(openerTabId) }) + const from = { tabId: openerTabId, windowId: windows.windowOfTab(openerTabId) } + const load = { httpReferrer: details.referrer } + // The engine leaves the link to the browser: the page opens it itself (review B-F1). + tabs.openFromPage( + openerTabId, + details.url, + details.referrer, + (view) => deps.openWindow(details.url, from, { view, load }), + () => deps.openWindow(details.url, from, { load }), + ) return { action: 'deny' } }) @@ -118,10 +137,13 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks const hovered = new Map<TabId, string>() const altClicks = new Map<TabId, AltClick>() - const openInSplit = (tabId: TabId, url: string) => { + /** The link in a pane beside the page's; `referrer` is the page it came from, when known. */ + const openInSplit = (tabId: TabId, url: string, referrer?: PageReferrer) => { const windowId = windows.windowOfTab(tabId) if (!windowId || !store.state.tabs[tabId]) return - void commands.run('split.openUrl', { url, target: tabId, side: 'right' }, { windowId }).catch(() => {}) + void commands + .run('split.openUrl', { url, target: tabId, side: 'right', ...(referrer ? { referrer } : {}) }, { windowId }) + .catch(() => {}) } const onAltClick = async (tabId: TabId, contents: WebContents) => { @@ -141,7 +163,8 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks if (found?.href) { if (!openable(found.href)) return altClicks.set(tabId, { url: found.href, until, split: false }) - openInSplit(tabId, found.href) + // Sent with the page as referrer, as the link's policy or the page's allows (review B-F1). + openInSplit(tabId, found.href, { url: contents.getURL(), policy: referrerPolicyName(found.policy) }) } else if (found?.frame && openable(fallback)) { // A link in a frame: the engine's download of it says which, and opens the split. altClicks.set(tabId, { url: fallback, until, split: true }) @@ -215,12 +238,14 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks } // ---- Blocked pop-ups (NV-09) ---- - const blocked = new Map<TabId, string[]>() + /** A pop-up a page tried to open, and the referrer it would have had. */ + type Blocked = { url: string; referrer: Referrer } + const blocked = new Map<TabId, Blocked[]>() // Private profiles' choices stay in memory (SitePrefs). const popupsAllowed = (profileId: string, host: string) => prefs.popupsAllowed(profileId, host) const setPopupsAllowed = (profileId: string, host: string, allowed: boolean) => prefs.setPopupsAllowed(profileId, host, allowed) - const setBlocked = (tabId: TabId, urls: string[]) => { + const setBlocked = (tabId: TabId, urls: Blocked[]) => { if (urls.length > 0) blocked.set(tabId, urls) else blocked.delete(tabId) const count = urls.length || undefined @@ -239,10 +264,10 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks return !!tab && !!host && popupsAllowed(tab.profileId, host) } const previousBlocked = tabs.onPopupBlocked - tabs.onPopupBlocked = (tabId, url) => { - previousBlocked?.(tabId, url) - const list = (blocked.get(tabId) ?? []).filter((u) => u !== url) - list.push(url) + tabs.onPopupBlocked = (tabId, url, referrer) => { + previousBlocked?.(tabId, url, referrer) + const list = (blocked.get(tabId) ?? []).filter((b) => b.url !== url) + list.push({ url, referrer }) setBlocked(tabId, list.slice(-20)) } // A new page starts with nothing blocked. @@ -266,14 +291,22 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks .slice(-BLOCKED_SHOWN) .reverse() .map( - (url): MenuItemConstructorOptions => ({ + ({ url, referrer }): MenuItemConstructorOptions => ({ label: `Open ${url.length > 60 ? `${url.slice(0, 59)}…` : url}`, click: () => { setBlocked( tabId, - (blocked.get(tabId) ?? []).filter((u) => u !== url), + (blocked.get(tabId) ?? []).filter((b) => b.url !== url), ) - ops.createTab({ url, spaceId: tab.spaceId, windowId, activate: true, openerTabId: tabId }) + // With the referrer the page's own request would have had (review B-F1). + ops.createTab({ + url, + spaceId: tab.spaceId, + windowId, + activate: true, + openerTabId: tabId, + load: { httpReferrer: referrer }, + }) }, }), ), diff --git a/src/main/nav/new-window.ts b/src/main/nav/new-window.ts index e1777479..95414666 100644 --- a/src/main/nav/new-window.ts +++ b/src/main/nav/new-window.ts @@ -2,19 +2,21 @@ import { createWindowState } from '../../shared/defaults' import { isPrivateWindow } from '../../shared/private' import type { SpaceId, WindowId } from '../../shared/state' import type { MainContext } from '../context' -import type { TabOps } from '../tabs/ops' +import type { TabOps, TabPage } from '../tabs/ops' /** * A normal window showing `url` in a new tab at the top of Today (⇧-click, "Open Link in New * Window"; NV-08, NV-12). It shows the Space of the window the link came from, a step down and * right of it, in full screen when that one is (NV-10). Every normal window lists the same tabs - * (TB-17), so the tab is in the other windows' sidebars too, shown only here. + * (TB-17), so the tab is in the other windows' sidebars too, shown only here. `page` is how the tab + * gets its page, when not by loading the URL as typed (review B-F1). */ export function openUrlInNewWindow( ctx: MainContext, ops: TabOps, url: string, fromWindowId: WindowId | undefined, + page: TabPage = {}, ): WindowId | null { const { store, windows } = ctx const state = store.state @@ -30,7 +32,7 @@ export function openUrlInNewWindow( store.update((d) => { d.windows[winState.id] = winState }) - ops.createTab({ url, spaceId, windowId: winState.id, activate: true }) + ops.createTab({ url, spaceId, windowId: winState.id, activate: true, ...page }) const created = store.state.windows[winState.id] if (!created) return null const id = windows.open(created) diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index f1d93258..2224227c 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -1,9 +1,11 @@ import { join } from 'node:path' import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, type WebContents } from 'electron' +import { type PageReferrer, referrerPolicyName } from '../../shared/nav' import { isPrivateTab } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' import { SEARCH_ENGINES } from '../../shared/url' import type { MainContext } from '../context' +import type { TabPage } from '../tabs/ops' import { NAV_WORLD } from './focus' /** @@ -15,8 +17,10 @@ import { NAV_WORLD } from './focus' type From = { tabId?: TabId; windowId?: WindowId } interface LinkOpeners { + /** A link in a new background tab, loaded with `referrer` (review B-F1). */ + tab: (url: string, from: From, referrer: PageReferrer) => void /** A link in a new window (a private one from a private window). */ - window: (url: string, from: From) => void + window: (url: string, from: From, page?: TabPage) => void /** A link in a new private window. */ private: (url: string, from: From) => void } @@ -29,15 +33,43 @@ export function setLinkOpeners(next: LinkOpeners): void { openers = next } +/** + * The referrer for what the page menu opens: the frame the menu is over, trimmed by that frame's + * policy, as a click there would send it (review B-F1). + */ +export function menuReferrer(params: ContextMenuParams): PageReferrer { + // Typed as a Referrer, but Electron passes the policy's name. + const raw = params.referrerPolicy as unknown + const policy = typeof raw === 'string' ? raw : (raw as { policy?: string } | undefined)?.policy + return { url: params.frameURL || params.pageURL, policy: referrerPolicyName(policy) } +} + +/** + * Open Link (or Image) in New Tab: a background tab, loaded with the page as referrer (review + * B-F1). False when the nav module isn't there to open it. + */ +export function openLinkInTab(ctx: MainContext, tabId: TabId, url: string, referrer: PageReferrer): boolean { + if (!openers) return false + openers.tab(url, { tabId, windowId: ctx.windows.windowOfTab(tabId) }, referrer) + return true +} + /** * Open Link in New Window / in Private Window. A private window opens both as private windows, - * so it shows only the first (NV-11). + * so it shows only the first (NV-11). A new window gets `referrer`; a private one from a normal + * window gets none, so it doesn't tell the site where the link was. */ -export function linkWindowItems(ctx: MainContext, tabId: TabId, url: string): MenuItemConstructorOptions[] { +export function linkWindowItems( + ctx: MainContext, + tabId: TabId, + url: string, + referrer?: PageReferrer, +): MenuItemConstructorOptions[] { if (!openers) return [] const from = (): From => ({ tabId, windowId: ctx.windows.windowOfTab(tabId) }) + const page: TabPage = referrer ? { load: { httpReferrer: referrer } } : {} const items: MenuItemConstructorOptions[] = [ - { label: 'Open Link in New Window', click: () => openers?.window(url, from()) }, + { label: 'Open Link in New Window', click: () => openers?.window(url, from(), page) }, ] if (!isPrivateTab(ctx.store.state, tabId)) { items.push({ label: 'Open Link in Private Window', click: () => openers?.private(url, from()) }) diff --git a/src/main/nav/private.ts b/src/main/nav/private.ts index bcc959b5..df32f8d9 100644 --- a/src/main/nav/private.ts +++ b/src/main/nav/private.ts @@ -11,7 +11,7 @@ import { import type { ItemId, OrbitState, Profile, ProfileId, SpaceId, SpaceTheme, WindowId } from '../../shared/state' import { tabsInItem } from '../../shared/tree' import type { MainContext } from '../context' -import type { TabOps } from '../tabs/ops' +import type { TabOps, TabPage } from '../tabs/ops' import type { SitePrefs } from './site-prefs' /** Actions for what a private window doesn't have (SB-25). */ @@ -24,9 +24,10 @@ export const PRIVATE_WINDOW_SWITCH = '--private-window' export interface PrivateWindows { /** * Opens a private window, a step down and right of `fromWindowId`: with `url` in its first tab, - * else with the command bar open for one (SB-25). + * else with the command bar open for one (SB-25). `page` is how that tab gets its page, when not + * by loading the URL as typed (review B-F1). */ - open(opts?: { url?: string; fromWindowId?: WindowId }): WindowId | null + open(opts?: { url?: string; fromWindowId?: WindowId; page?: TabPage }): WindowId | null /** The current private session's profile, while a private window is open. */ profileId(): ProfileId | undefined } @@ -55,7 +56,7 @@ export function registerPrivate(ctx: MainContext, ops: TabOps, prefs: SitePrefs) } } - const open: PrivateWindows['open'] = ({ url, fromWindowId } = {}) => { + const open: PrivateWindows['open'] = ({ url, fromWindowId, page } = {}) => { const now = Date.now() const from = fromWindowId ?? windows.focusedWindowId() let windowId: WindowId | undefined @@ -77,7 +78,7 @@ export function registerPrivate(ctx: MainContext, ops: TabOps, prefs: SitePrefs) spaceId = space.id }) if (!windowId || !spaceId) return null - if (url) ops.createTab({ url, spaceId, windowId, activate: true }) + if (url) ops.createTab({ url, spaceId, windowId, activate: true, ...page }) const created = store.state.windows[windowId] if (!created) return null const id = windows.open(created) diff --git a/src/main/split/index.ts b/src/main/split/index.ts index 88a4df58..6a37e4c9 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -181,7 +181,7 @@ export function registerSplit(ctx: MainContext): void { return result }) - commands.register('split.openUrl', ({ url, target, side = 'right' }, c) => { + commands.register('split.openUrl', ({ url, target, side = 'right', referrer }, c) => { const windowId = windowOf(c) const anchor = store.state.tabs[target] if (!anchor) return null @@ -190,6 +190,7 @@ export function registerSplit(ctx: MainContext): void { return null } const tab = makeTab({ spaceId: anchor.spaceId, profileId: anchor.profileId, url }) + if (referrer) ctx.tabs.loadOptions.set(tab.id, { httpReferrer: referrer }) let result: ReturnType<typeof addPaneInDraft> = 'invalid' store.update((d) => { d.tabs[tab.id] = tab @@ -198,6 +199,7 @@ export function registerSplit(ctx: MainContext): void { else if (windowId) focusInDraft(d, windowId, tab.id) }) if (isFailure(result)) { + ctx.tabs.loadOptions.delete(tab.id) refuse(windowId, result) return null } diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index ac17f3ce..86985158 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -28,7 +28,7 @@ import { tabSplitMenuItems } from '../split/menu' import { TabMaintenance } from './maintenance' import { findMenuItem, folderMenu, type MenuOps, menuLabels, tabMenu } from './menus' import { makeTab, numberedTarget, steppedTarget, todayInsertIndex } from './model' -import { TabOps } from './ops' +import { TabOps, type TabPage } from './ops' /** Test-only handle (ORBIT_E2E=1): globalThis.__orbitTabs. */ export interface TabTestHooks { @@ -433,16 +433,31 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM // The size and position the page asked for (features) are kept. return { action: 'allow', overrideBrowserWindowOptions: { autoHideMenuBar: true, title: '' } } } - const background = details.disposition === 'background-tab' - if (background) { - // ⌘/middle-click: an ordinary tab that loads in the background. A view handed back through - // createWindow would never be shown, and Chromium keeps the opener page waiting for it. + // A new tab for the link, given its page or loading it with the page as referrer (review B-F1). + const linkTab = (activate: boolean) => (page: TabPage) => { const windowId = windows.windowOfTab(openerTabId) ?? windows.focusedWindowId() const spaceId = windowId ? ops.spaceOfWindow(windowId) : opener.spaceId - const id = ops.createTab({ url: details.url, spaceId, windowId, activate: false, openerTabId }) + const id = ops.createTab({ url: details.url, spaceId, windowId, activate, openerTabId, ...page }) + if (activate) return tabs.ensure(id) // With the sidebar hidden the new tab would open unseen: say so (TB-02, SB-13). if (windowId && !store.state.windows[windowId]?.sidebar.visible) toast(windowId, 'Opened in a new tab') + } + /** A link the engine left to the browser, opened by its page itself (TabRuntime.openFromPage). */ + const fromPage = (open: (page: TabPage) => void) => { + const load = { httpReferrer: details.referrer } + tabs.openFromPage( + openerTabId, + details.url, + details.referrer, + (view) => open({ view, load }), + () => open({ load }), + ) + } + if (details.disposition === 'background-tab') { + // ⌘/middle-click: an ordinary tab that loads in the background. A view handed back through + // createWindow would never be shown, and Chromium keeps the opener page waiting for it. + fromPage(linkTab(false)) return { action: 'deny' } } return { @@ -453,9 +468,12 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const spaceId = windowId ? ops.spaceOfWindow(windowId) : opener.spaceId if (!(options as { webContents?: unknown }).webContents) { // ⇧⌘-click: the engine brings no page of its own (a view built from these options - // would throw), so an ordinary new tab loads the link (NV-08). - const id = ops.createTab({ url: details.url, spaceId, windowId, activate: true, openerTabId }) - return (tabs.ensure(id) as WebContentsView).webContents + // would throw), so the page opens the link itself, as a new selected tab (NV-08). + fromPage(linkTab(true)) + // Electron uses nothing that comes back for an open without a page of its own. + const spare = new WebContentsView() + setImmediate(() => spare.webContents.close()) + return spare.webContents } const tab = makeTab({ spaceId, profileId: opener.profileId, url: details.url, openerTabId }) // Add the tab, give it the real (opener-linked) view, and only then select it: selecting diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index 9fd2ba03..51e00722 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -6,6 +6,8 @@ import { keepPageMenu, linkWindowItems, lookUpItems, + menuReferrer, + openLinkInTab, pageItems, saveLinkItem, searchLabel, @@ -24,15 +26,20 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We const tab = ctx.store.state.tabs[tabId] void ctx.commands.run('tabs.create', { url, spaceId: tab?.spaceId, activate }, {}) } + // A link or image from the page: a background tab, with the page as referrer (review B-F1). + const referrer = menuReferrer(params) + const openFromPage = (url: string) => { + if (!openLinkInTab(ctx, tabId, url, referrer)) openInNewTab(url, false) + } if (params.linkURL) { items.push( - { label: 'Open Link in New Tab', click: () => openInNewTab(params.linkURL, false) }, + { label: 'Open Link in New Tab', click: () => openFromPage(params.linkURL) }, // A new pane beside the page's pane (SV-08). linkSplitMenuItem(ctx, tabId, params.linkURL), // Then Peek and Mini Window, new and private windows: the order NV-12 lists. ...linkPeekMenuItems(ctx, tabId, params.linkURL), - ...linkWindowItems(ctx, tabId, params.linkURL), + ...linkWindowItems(ctx, tabId, params.linkURL, referrer), { label: 'Copy Link Address', click: () => clipboard.writeText(params.linkURL) }, saveLinkItem(contents, params.linkURL), { type: 'separator' }, @@ -40,7 +47,7 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We } if (params.mediaType === 'image' && params.srcURL) { items.push( - { label: 'Open Image in New Tab', click: () => openInNewTab(params.srcURL, false) }, + { label: 'Open Image in New Tab', click: () => openFromPage(params.srcURL) }, { label: 'Save Image As…', click: () => contents.downloadURL(params.srcURL) }, { label: 'Copy Image', click: () => contents.copyImageAt(params.x, params.y) }, { label: 'Copy Image Address', click: () => clipboard.writeText(params.srcURL) }, diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index 77086d09..8ccc82c9 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -3,6 +3,7 @@ * drag-and-drop and the maintenance timers all go through these so the rules * (selection after close, archive, undo, pinned URL bookkeeping) live in one place. */ +import type { LoadURLOptions, WebContentsView } from 'electron' import { createWindowState, FAVORITES_FULL_MESSAGE, MAX_FAVORITES } from '../../shared/defaults' import { isFolderId, isTabId, newId } from '../../shared/ids' import { isMediaBusy } from '../../shared/media' @@ -70,6 +71,17 @@ import { import type { SavedHistory } from './runtime' import { UndoStack } from './undo' +/** How a new tab gets its page, when not by loading its URL as typed (review B-F1). */ +export interface TabPage { + /** + * The page the engine made for a link its page opened (`TabRuntime.openFromPage`). A page from + * another profile's session is closed, and the tab loads its URL instead. + */ + view?: WebContentsView + /** Options for the tab's first load of its URL: the page a link came from, as referrer. */ + load?: LoadURLOptions +} + export interface ClosedTab { tab: Tab location?: ItemLocation @@ -140,18 +152,20 @@ export class TabOps { // ---- Create / activate ---- - createTab(opts: { - url?: string - spaceId?: SpaceId - container?: ContainerRef - index?: number - activate?: boolean - windowId?: WindowId - openerTabId?: TabId - history?: SavedHistory - title?: string - favicon?: string - }): TabId { + createTab( + opts: { + url?: string + spaceId?: SpaceId + container?: ContainerRef + index?: number + activate?: boolean + windowId?: WindowId + openerTabId?: TabId + history?: SavedHistory + title?: string + favicon?: string + } & TabPage, + ): TabId { const state = this.state const url = opts.url ?? 'about:blank' const space = @@ -175,16 +189,29 @@ export class TabOps { tab.pinnedTitle = tab.title } if (opts.history) this.ctx.tabs.savedHistory.set(tab.id, opts.history) + // The engine's page is used only in the tab's own profile's session. + const given = opts.view?.webContents + const view = + given && !given.isDestroyed() && given.session === this.ctx.sessions.get(profileId) ? opts.view : undefined + if (given && !view && !given.isDestroyed()) given.close() + if (!view && opts.load) this.ctx.tabs.loadOptions.set(tab.id, opts.load) const index = opts.index ?? (target.kind === 'today' ? todayInsertIndex(spaceObj, opts.activate === false ? 'background' : 'new') : Number.MAX_SAFE_INTEGER) + const activate = opts.activate !== false && !!opts.windowId this.ctx.store.update((d) => { d.tabs[tab.id] = tab insertItem(d, target, index, tab.id) - if (opts.activate !== false && opts.windowId) activateInDraft(d, opts.windowId, tab.id, this.now()) + // A tab given its page is selected once it has it: selecting first would make the layout + // pass create a second view for it (review M1). + if (activate && !view && opts.windowId) activateInDraft(d, opts.windowId, tab.id, this.now()) }) + if (view) { + this.ctx.tabs.adopt(tab.id, view) + if (activate && opts.windowId) this.activate(opts.windowId, tab.id) + } return tab.id } diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 7dcdd080..54b25b97 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -1,5 +1,15 @@ import { join } from 'node:path' -import { type NavigationEntry, nativeImage, type WebContents, WebContentsView } from 'electron' +import { + type LoadURLOptions, + type NavigationEntry, + nativeImage, + type Referrer, + type WebContents, + WebContentsView, + type WebContentsViewConstructorOptions, + type WindowOpenHandlerResponse, +} from 'electron' +import { reopenReferrerPolicy } from '../../shared/nav' import { isPrivateProfileId } from '../../shared/private' import type { Tab, TabId } from '../../shared/state' import { isAwayFromPinned, sameDocUrl } from '../../shared/tabs' @@ -86,6 +96,36 @@ const FOCUS_IS_EDITABLE = `(() => { return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'IFRAME' || tag === 'EMBED' || tag === 'OBJECT' })()` +/** The isolated world Orbit asks pages to open links from (`openFromPage`). */ +const LINK_WORLD = 1003 +/** How long a page has to open a link Orbit asked it to open (it may be busy) before Orbit loads it. */ +const PAGE_OPEN_MS = 2000 + +/** + * Opens `url` from the page as a click on a link to it would: a link of Orbit's that isn't in the + * document (so no page script sees the click), with the given referrer policy, into a new window + * without an opener. Does nothing on a page from another origin than the one the link was on. + */ +const openLinkScript = (url: string, origin: string, policy: string) => `(() => { + if (self.origin !== ${JSON.stringify(origin)}) return false + const a = document.createElement('a') + a.href = ${JSON.stringify(url)} + a.target = '_blank' + a.rel = 'noopener' + a.referrerPolicy = ${JSON.stringify(policy)} + a.click() + return true +})()` + +/** A link a page was asked to open (`openFromPage`). */ +interface PageOpen { + url: string + place: (view: WebContentsView) => void + fallback: () => void + /** waiting: not opened yet; taken: the page opened it; late: Orbit loaded it itself. */ + state: 'waiting' | 'taken' | 'late' +} + type RuntimeFields = Partial< Pick< Tab, @@ -117,11 +157,15 @@ export class TabRuntime { /** Control was released in a page (ends recent-tab switching, TB-05). */ onControlUp?: () => void /** A page tried to open a tab or pop-up without a user gesture. */ - onPopupBlocked?: (tabId: TabId, url: string) => void + onPopupBlocked?: (tabId: TabId, url: string, referrer: Referrer) => void /** True when the tab's site may open pop-ups without a click (allowed in site controls, NV-09). */ allowsPopups?: (tabId: TabId) => boolean /** Last user gesture per tab, for the pop-up rule (NV-09). */ private gestures = new Map<TabId, { at: number; used: boolean }>() + /** Links pages were asked to open, by the asking page's contents id (`openFromPage`). */ + private pageOpens = new Map<number, PageOpen[]>() + /** Options for a tab's first load, used once: the page a link came from (review B-F1). */ + readonly loadOptions = new Map<TabId, LoadURLOptions>() /** Favicon URL → data URL, fetched in the tab's own session (no fetches from Orbit's UI). */ private favicons = new Map<string, string>() /** Icons by origin, out of OrbitState (lane review MF-2). */ @@ -173,6 +217,8 @@ export class TabRuntime { this.adopt(tabId, view) const saved = this.savedHistory.get(tabId) this.savedHistory.delete(tabId) + const options = this.loadOptions.get(tabId) + this.loadOptions.delete(tabId) const contents = view.webContents if (tab.pinnedUrl && sameDocUrl(tab.url, tab.pinnedUrl) && !(saved && saved.entries.length > 0)) { this.homeLoads.add(tabId) @@ -182,7 +228,7 @@ export class TabRuntime { .restore({ entries: saved.entries, index: saved.index }) .catch(() => this.load(contents, tab.url)) } else { - this.load(contents, tab.url) + this.load(contents, tab.url, options) } if (tab.muted) contents.setAudioMuted(true) return view @@ -267,9 +313,12 @@ export class TabRuntime { }) contents.on('context-menu', (_e, params) => showPageContextMenu(this.ctx, tabId, contents, params)) contents.setWindowOpenHandler((details) => { + // A link Orbit asked this page to open (openFromPage): the click was counted already. + const asked = this.takePageOpen(contents.id, details.url) + if (asked) return asked // No tabs or pop-ups without a user gesture, and one per gesture (NV-09). if (!this.consumeGesture(tabId) && !this.allowsPopups?.(tabId)) { - this.onPopupBlocked?.(tabId, details.url) + this.onPopupBlocked?.(tabId, details.url, details.referrer) return { action: 'deny' } } for (const hook of this.ctx.hooks.windowOpen) { @@ -280,6 +329,7 @@ export class TabRuntime { }) contents.once('destroyed', () => { this.byContents.delete(contents.id) + this.pageOpens.delete(contents.id) this.gestures.delete(tabId) this.wantedFavicon.delete(tabId) if (this.views.get(tabId) !== view) return @@ -316,6 +366,7 @@ export class TabRuntime { /** Destroys a tab's contents without remembering history (tab is being removed). */ destroy(tabId: TabId): void { this.savedHistory.delete(tabId) + this.loadOptions.delete(tabId) const view = this.views.get(tabId) if (!view) return this.views.delete(tabId) @@ -421,9 +472,89 @@ export class TabRuntime { this.load(contents, url) } - private load(contents: WebContents, url: string): void { + /** + * Opens a link from `tabId`'s page the way the page itself requests it (review B-F1). Chromium + * hands ⌘-, middle-, ⇧⌘- and ⇧-clicks on links to the browser as a bare address, with no page + * made for it, and loading that from here would request it as if typed: no Referer, + * `Sec-Fetch-Site: none`, and the site's SameSite=Strict cookies. So the page is asked to open + * the link itself, from an isolated world and with a user gesture, and the page Chromium makes for + * that goes to `place`, to be shown as a tab or a window. `fallback` loads the link Orbit's way + * when no page comes: a link that isn't http(s), a page with an opaque origin (sandboxed), one + * that went away or moved to another origin, or one too busy to do it within PAGE_OPEN_MS. + */ + openFromPage( + tabId: TabId, + url: string, + referrer: Referrer, + place: (view: WebContentsView) => void, + fallback: () => void, + ): void { + const contents = this.contents(tabId) + const origin = contents?.mainFrame.origin + if (!contents || !origin || origin === 'null' || !/^https?:/i.test(url)) { + fallback() + return + } + const key = contents.id + const open: PageOpen = { url, place, fallback, state: 'waiting' } + this.pageOpens.set(key, [...(this.pageOpens.get(key) ?? []), open]) + const giveUp = () => { + if (open.state !== 'waiting') return + open.state = 'late' + try { + fallback() + } catch (err) { + console.error('[tabs] could not open a link', err) + } + } + const timer = setTimeout(giveUp, PAGE_OPEN_MS) + const code = openLinkScript(url, origin, reopenReferrerPolicy(referrer, origin)) + contents + .executeJavaScriptInIsolatedWorld(LINK_WORLD, [{ code }], true) + .catch(() => false) + .then(() => { + clearTimeout(timer) + const rest = (this.pageOpens.get(key) ?? []).filter((o) => o !== open) + if (rest.length > 0) this.pageOpens.set(key, rest) + else this.pageOpens.delete(key) + // The page opened nothing (or not this link): Orbit loads it. + giveUp() + }) + } + + /** The answer to a page opening a link Orbit asked it to open (`openFromPage`), if this is one. */ + private takePageOpen(contentsId: number, url: string): WindowOpenHandlerResponse | undefined { + const opens = (this.pageOpens.get(contentsId) ?? []).filter((o) => o.url === url) + const open = opens.find((o) => o.state === 'waiting') ?? opens.find((o) => o.state === 'late') + if (!open) return undefined + // Orbit has loaded the link itself already. + if (open.state === 'late') return { action: 'deny' } + open.state = 'taken' + return { + action: 'allow', + outlivesOpener: true, + createWindow: (options) => { + const guest = (options as { webContents?: WebContents }).webContents + // No page after all (only links the browser opens come without one): Orbit loads it. + const view = new WebContentsView(guest ? (options as WebContentsViewConstructorOptions) : {}) + const created = view.webContents + const createdId = created.id + try { + if (guest) open.place(view) + else open.fallback() + } catch (err) { + console.error('[tabs] could not open a link', err) + } + // A view no tab took (Electron ignores what comes back for an open without a page). + if (!this.byContents.has(createdId)) setImmediate(() => liveContents(view)?.close()) + return created + }, + } + } + + private load(contents: WebContents, url: string, options?: LoadURLOptions): void { if (!url || url === 'about:blank') return - contents.loadURL(url).catch(() => { + contents.loadURL(url, options).catch(() => { // Failures surface through did-fail-load; ERR_ABORTED is normal on redirects. }) } diff --git a/src/shared/commands/split.ts b/src/shared/commands/split.ts index e668bbf9..7ebca946 100644 --- a/src/shared/commands/split.ts +++ b/src/shared/commands/split.ts @@ -1,3 +1,4 @@ +import type { PageReferrer } from '../nav' import type { LayoutPath, PaneSide, SplitAxis } from '../split' import type { ContainerRef, SplitId, TabId } from '../state' @@ -19,8 +20,14 @@ export interface SplitCommands { 'split.addTab': { args: { tabId: TabId; target: TabId; side?: PaneSide; focus?: boolean }; result: SplitId | null } /** New empty pane beside the focused pane, with the command bar open to fill it (SV-02/SV-03). */ 'split.addPane': { args: { side?: PaneSide }; result: void } - /** Opens a URL in a new pane beside a tab (link → split, SV-08). */ - 'split.openUrl': { args: { url: string; target: TabId; side?: PaneSide }; result: SplitId | null } + /** + * Opens a URL in a new pane beside a tab (link → split, SV-08); `referrer` is the page the link + * was on, sent as its referrer (review B-F1). + */ + 'split.openUrl': { + args: { url: string; target: TabId; side?: PaneSide; referrer?: PageReferrer } + result: SplitId | null + } /** Closes one pane; its tab is archived like a closed Today tab (SV-17). */ 'split.closePane': { args: { tabId: TabId }; result: void } /** Closes the whole split as one unit (SV-19). */ diff --git a/src/shared/nav.ts b/src/shared/nav.ts index 8bc2d98e..97c1fc33 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -221,3 +221,63 @@ export function linkPillText(url: string, expanded: boolean): string { const short = `${parsed.host}${path}${parsed.search}${parsed.hash}` return short.length > LINK_PILL_MAX_URL ? `${short.slice(0, LINK_PILL_MAX_URL)}…` : short } + +/** A referrer policy as Electron names them (`Electron.Referrer['policy']`). */ +export type ReferrerPolicyName = + | 'default' + | 'unsafe-url' + | 'no-referrer-when-downgrade' + | 'no-referrer' + | 'origin' + | 'strict-origin-when-cross-origin' + | 'same-origin' + | 'strict-origin' + +/** The page a request comes from and the policy that trims it, as `loadURL`'s `httpReferrer` takes it. */ +export interface PageReferrer { + url: string + policy: ReferrerPolicyName +} + +const REFERRER_POLICIES: Record<string, ReferrerPolicyName> = { + 'unsafe-url': 'unsafe-url', + 'no-referrer-when-downgrade': 'no-referrer-when-downgrade', + 'no-referrer': 'no-referrer', + origin: 'origin', + 'strict-origin-when-cross-origin': 'strict-origin-when-cross-origin', + 'same-origin': 'same-origin', + 'strict-origin': 'strict-origin', + // Electron has no name for this one; its stricter twin differs only from https to http. + 'origin-when-cross-origin': 'strict-origin-when-cross-origin', + // The old `<meta name="referrer">` words. + never: 'no-referrer', + always: 'unsafe-url', + 'origin-when-crossorigin': 'strict-origin-when-cross-origin', +} + +/** + * A referrer policy word (a link's `referrerpolicy`, a page's `<meta name="referrer">`) as Electron + * names it; anything else is the default policy (review B-F1). + */ +export function referrerPolicyName(word: string | undefined): ReferrerPolicyName { + return REFERRER_POLICIES[(word ?? '').trim().toLowerCase()] ?? 'default' +} + +/** + * The `referrerpolicy` a link gets when Orbit asks its page to open it again (review B-F1): the one + * the engine reported for the click (empty for the page's own), but `no-referrer` when the engine + * had no referrer to send, or the link was in a frame from another origin: the page would send its + * own address, which that frame's link never could. + */ +export function reopenReferrerPolicy(referrer: { url: string; policy: string }, pageOrigin: string): string { + if (!referrer.url) return 'no-referrer' + let from: string + try { + from = new URL(referrer.url).origin + } catch { + return 'no-referrer' + } + if (from !== pageOrigin) return 'no-referrer' + const policy = referrerPolicyName(referrer.policy) + return policy === 'default' ? '' : policy +} diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 44c0e7ff..a44dc6e6 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -164,6 +164,117 @@ test('[NV-08] ⇧-click opens the link in a new window, a step down and right, w expect([second.bounds?.x, second.bounds?.y]).not.toEqual([first.bounds?.x, first.bounds?.y]) }) +/** + * A page on localhost with a link (#away) to a page on the other site, 127.0.0.1, which has + * cookies: `away(n)` points it at `/page/<name>-<n>` there. + */ +async function linkToOtherSite( + app: ElectronApplication, + name: string, +): Promise<{ src: string; srcTab: string; away: (n: number) => Promise<string> }> { + await openTab(app, fx.urlOn('127.0.0.1', '/cookies')) + const target = (n: number) => fx.urlOn('127.0.0.1', `/page/${name}-${n}`) + const src = fx.url(`/page/${name}?away=${encodeURIComponent(target(0))}`) + const srcTab = await openTab(app, src) + const tab = await tabPage(app, src) + /** Points the link at <name>-n. */ + const away = async (n: number) => { + await tab.evaluate((href) => document.querySelector('#away')?.setAttribute('href', href), target(n)) + return target(n) + } + return { src, srcTab, away } +} + +/** How a click on a link to the other site is requested: as the page's own, as a browser's is. */ +const sentAsLink = () => ({ referer: `http://localhost:${fx.port}/`, site: 'cross-site', cookie: 'lax=1' }) + +test('[NV-08] links opened with ⌘-, middle-, ⇧- and ⇧⌘-click are requested as the page’s own clicks: with a Referer, as cross-site, without the SameSite=Strict cookie (review B-F1)', async () => { + const { app } = orbit + await app.evaluate(() => { + const g = globalThis as unknown as { + __orbit: { ctx: { store: { update: (f: (d: { settings: Record<string, unknown> }) => void) => void } } } + } + // ⇧-click opens a new window, not Peek (peek.md). + g.__orbit.ctx.store.update((d) => { + d.settings.peekShiftClick = false + }) + }) + const { src, away } = await linkToOtherSite(app, 'Clicked') + const opener = await selectedTab(app) + const requested = async (n: number) => { + await expect.poll(() => fx.hits(`/page/Clicked-${n}`)).toBe(1) + expect(fx.sentFrom(`/page/Clicked-${n}`)).toEqual(sentAsLink()) + } + + // ⌘-click (Ctrl-click) and middle-click: background tabs at the top of Today. + const url1 = await away(1) + await clickInPage(app, src, '#away', [mod]) + await requested(1) + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(url1) + const [first] = await today(app) + expect((await state(app)).tabs[first as string]?.openerTabId).toBe(opener) + await waitLoaded(app, first as string, url1) + const url2 = await away(2) + await clickInPage(app, src, '#away', [], 'middle') + await requested(2) + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(url2) + expect(await selectedTab(app)).toBe(opener) + + // ⇧-click: a new window. + const url3 = await away(3) + await clickInPage(app, src, '#away', ['shift']) + await requested(3) + await expect.poll(async () => (await windowList(app)).length).toBe(2) + const second = (await windowList(app))[1] + const shown = second ? (await state(app)).windows[second.id]?.activeTab[second.spaceId] : undefined + expect(await urlOf(app, shown ?? undefined)).toBe(url3) + + // ⇧⌘-click (Ctrl+Shift-click): a new tab, selected. + const url4 = await away(4) + await clickInPage(app, src, '#away', [mod, 'shift']) + await requested(4) + await expect.poll(async () => urlOf(app, await selectedTab(app))).toBe(url4) + // Each was requested once: the page's request, not Orbit's as well. + expect([1, 2, 3, 4].map((n) => fx.hits(`/page/Clicked-${n}`))).toEqual([1, 1, 1, 1]) +}) + +test('[NV-08][NV-09][NV-12] links Orbit opens for a page (its menu, ⌥-click, a blocked pop-up) are sent with the page as Referer (review B-F1)', async () => { + const { app, shell } = orbit + const { src, srcTab, away } = await linkToOtherSite(app, 'Opened') + const referer = `http://localhost:${fx.port}/` + const fromMenu = async (label: string) => { + await clickInPage(app, src, '#away', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain(label) + expect(await choosePageMenu(app, label)).toBe(true) + } + const url1 = await away(1) + await fromMenu('Open Link in New Tab') + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(url1) + // Shown, in case it hasn't loaded in the background. + await cmd(app, 'tabs.activate', { tabId: (await today(app))[0] }) + await expect.poll(() => fx.hits('/page/Opened-1')).toBe(1) + expect(fx.sentFrom('/page/Opened-1')?.referer).toBe(referer) + await cmd(app, 'tabs.activate', { tabId: srcTab }) + await expect.poll(() => selectedTab(app)).toBe(srcTab) + await away(2) + await fromMenu('Open Link in New Window') + await expect.poll(() => fx.hits('/page/Opened-2')).toBe(1) + expect(fx.sentFrom('/page/Opened-2')?.referer).toBe(referer) + // ⌥-click: split view. + await away(3) + await clickInPage(app, src, '#away', ['alt']) + await expect.poll(() => fx.hits('/page/Opened-3')).toBe(1) + expect(fx.sentFrom('/page/Opened-3')?.referer).toBe(referer) + // A pop-up blocked as the page loaded, opened from the address display. + const blocked = fx.urlOn('127.0.0.1', '/page/Opened-4') + await openTab(app, fx.url(`/page/Eager?open=${encodeURIComponent(blocked)}`)) + await shell.getByTestId('popups-blocked').click() + await expect.poll(() => blockedMenu(app)).toContain(`Open ${blocked}`) + expect(await chooseBlocked(app, `Open ${blocked}`)).toBe(true) + await expect.poll(() => fx.hits('/page/Opened-4')).toBe(1) + expect(fx.sentFrom('/page/Opened-4')?.referer).toBe(referer) +}) + test('[NV-08][MW-07] ⌥-click opens split view and ⌘⌥-click (Ctrl+Alt-click) a Mini Window: neither takes the other’s click', async () => { const { app } = orbit const id = await openTab(app, page('Both')) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 410ad475..c0cf9365 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -34,9 +34,19 @@ export interface NavFixtures { hits: (path: string) => number /** The Cache-Control header of the latest request for a path (a hard reload sends no-cache). */ cacheControl: (path: string) => string | undefined + /** What the latest request for a path said about where it came from, or undefined before one. */ + sentFrom: (path: string) => SentFrom | undefined close: () => Promise<void> } +/** Request headers that say where a request comes from (review B-F1); absent ones are left out. */ +export interface SentFrom { + referer?: string + /** Sec-Fetch-Site: `none` for an address typed by the user, `cross-site` for a link from another site. */ + site?: string + cookie?: string +} + const ICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%2338a169'/%3E%3C/svg%3E" @@ -49,9 +59,10 @@ const escapeHtml = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charC * tab), and buttons that open a small pop-up window (`/page/Popup-<title>`, #popup) and close it * again (#closepopup). * `background` is its CSS background; `text` goes in a paragraph (#text); `open` is a page it - * opens in a pop-up as soon as it loads, without a click. + * opens in a pop-up as soon as it loads, without a click; `away` is the address of one more link + * (#away). */ -function page(title: string, background = '#f2fbf5', text = '', open = ''): string { +function page(title: string, background = '#f2fbf5', text = '', open = '', away = ''): string { const name = encodeURIComponent(title) return `<!doctype html> <html><head><meta charset="utf-8"><title>${title} @@ -61,6 +72,7 @@ function page(title: string, background = '#f2fbf5', text = '', open = ''): stri

Child page

+${away ? `

Away

` : ''}

Download

@@ -83,11 +95,15 @@ ${open ? `window.open(${JSON.stringify(open)}, 'auto', 'width=400,height=300')` * - `/slow/?ms=N`: the page after N ms (default 30 s): keeps a tab loading. * - `/slow-reload/<title>`: the page at once on the first request, then slowly (30 s) on reloads. * - `/page/<title>?open=<path>`: the page, opening `<path>` in a pop-up as it loads (no click). + * - `/page/<title>?away=<url>`: the page with a link (#away) to `<url>`. * - `/download/<name>`: a small text file sent as a download (Content-Disposition: attachment). + * - `/cookies`: a page that sets two cookies for its host, `strict=1` (SameSite=Strict) and + * `lax=1` (SameSite=Lax). */ export async function startNavFixtures(): Promise<NavFixtures> { const hits = new Map<string, number>() const cacheControl = new Map<string, string | undefined>() + const sentFrom = new Map<string, SentFrom>() const pending = new Set<ServerResponse>() const sockets = new Set<Socket>() const server = createServer((req, res) => { @@ -95,6 +111,12 @@ export async function startNavFixtures(): Promise<NavFixtures> { const count = (hits.get(u.pathname) ?? 0) + 1 hits.set(u.pathname, count) cacheControl.set(u.pathname, req.headers['cache-control']) + const from: SentFrom = {} + if (req.headers.referer) from.referer = req.headers.referer + const site = req.headers['sec-fetch-site'] + if (typeof site === 'string') from.site = site + if (req.headers.cookie) from.cookie = req.headers.cookie + sentFrom.set(u.pathname, from) const [, kind = '', raw = 'index'] = u.pathname.split('/') const title = decodeURIComponent(raw) const send = () => { @@ -107,6 +129,7 @@ export async function startNavFixtures(): Promise<NavFixtures> { u.searchParams.get('bg') ?? undefined, u.searchParams.get('text') ?? '', u.searchParams.get('open') ?? '', + u.searchParams.get('away') ?? '', ), ) } @@ -127,6 +150,14 @@ export async function startNavFixtures(): Promise<NavFixtures> { }) return res.end(`Downloaded ${title}\n`) } + if (kind === 'cookies') { + res.writeHead(200, { + 'content-type': 'text/html', + 'cache-control': 'no-store', + 'set-cookie': ['strict=1; SameSite=Strict; Path=/', 'lax=1; SameSite=Lax; Path=/'], + }) + return res.end('<!doctype html><title>Cookies

Cookies set.

') + } if (kind === 'slow') return later(Number(u.searchParams.get('ms') ?? 30_000)) if (kind === 'slow-reload') return count === 1 ? send() : later(30_000) res.writeHead(404, { 'content-type': 'text/plain' }) @@ -153,6 +184,7 @@ export async function startNavFixtures(): Promise { urlOn: (host, path) => `http://${host}:${port}${path}`, hits: (path) => hits.get(path) ?? 0, cacheControl: (path) => cacheControl.get(path), + sentFrom: (path) => sentFrom.get(path), close: () => new Promise((r) => { for (const socket of sockets) socket.destroy() diff --git a/tests/unit/nav-url.test.ts b/tests/unit/nav-url.test.ts index c57116d7..0ceca60f 100644 --- a/tests/unit/nav-url.test.ts +++ b/tests/unit/nav-url.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { connectionOf, LINK_PILL_MAX_URL, linkPillText } from '../../src/shared/nav' +import { + connectionOf, + LINK_PILL_MAX_URL, + linkPillText, + referrerPolicyName, + reopenReferrerPolicy, +} from '../../src/shared/nav' describe('connectionOf (SB-04)', () => { it('treats https as secure: no padlock, no warning', () => { @@ -65,3 +71,37 @@ describe('linkPillText (SB-27)', () => { expect(linkPillText(long, false).endsWith('…')).toBe(true) }) }) + +describe('referrer policies of links Orbit opens (NV-08, review B-F1)', () => { + it('names a link’s or page’s policy word as Electron does, and anything else as the default', () => { + expect(referrerPolicyName('no-referrer')).toBe('no-referrer') + expect(referrerPolicyName(' Strict-Origin ')).toBe('strict-origin') + expect(referrerPolicyName('unsafe-url')).toBe('unsafe-url') + // Electron has no name for it: its stricter twin. + expect(referrerPolicyName('origin-when-cross-origin')).toBe('strict-origin-when-cross-origin') + // The old words. + expect(referrerPolicyName('never')).toBe('no-referrer') + expect(referrerPolicyName('always')).toBe('unsafe-url') + expect(referrerPolicyName('')).toBe('default') + expect(referrerPolicyName('nonsense')).toBe('default') + expect(referrerPolicyName(undefined)).toBe('default') + }) + + it('reopens a link from its page with the policy the click had', () => { + const page = 'https://a.example' + expect(reopenReferrerPolicy({ url: 'https://a.example/', policy: 'strict-origin-when-cross-origin' }, page)).toBe( + 'strict-origin-when-cross-origin', + ) + expect(reopenReferrerPolicy({ url: 'https://a.example/list?q=1', policy: 'unsafe-url' }, page)).toBe('unsafe-url') + // The page's own policy decides. + expect(reopenReferrerPolicy({ url: 'https://a.example/', policy: 'default' }, page)).toBe('') + }) + + it('sends no referrer when the click had none, or came from a frame of another origin', () => { + const page = 'https://a.example' + expect(reopenReferrerPolicy({ url: '', policy: 'no-referrer' }, page)).toBe('no-referrer') + expect(reopenReferrerPolicy({ url: '', policy: 'strict-origin-when-cross-origin' }, page)).toBe('no-referrer') + expect(reopenReferrerPolicy({ url: 'https://ads.example/frame', policy: 'unsafe-url' }, page)).toBe('no-referrer') + expect(reopenReferrerPolicy({ url: 'not a url', policy: 'unsafe-url' }, page)).toBe('no-referrer') + }) +}) From bbe165a0eb37f98000e615488535348f948db3ab Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:10:00 +0000 Subject: [PATCH 22/69] fix(nav): the loading indicator leaves the window when it hides, and stays off HTML full screen (review A-M1) hideUiView only made the view invisible, so the loading indicator (and the link pill) stayed a child above every page, a full-screen one included, and the core TB-19 and NV-20 tests read it as the top view. A hidden UI view now leaves the window, as the page tools and the prompt do, and showUiView puts it back above the pages. The indicator no longer shows over a page in HTML full screen (an iframe loading under a video showed it); it comes back when full screen ends if the page is still loading. Native full screen keeps it (SB-28). TB-19 now waits for the indicator to go with the load before it reads the top view: a page that takes over 150 ms to load shows it, as it should. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/loading.ts | 11 +++++- src/main/windows.ts | 19 +++++++--- tests/e2e/core.spec.ts | 4 +- tests/e2e/nav-page.spec.ts | 75 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/main/nav/loading.ts b/src/main/nav/loading.ts index 6e4f61a9..98804be4 100644 --- a/src/main/nav/loading.ts +++ b/src/main/nav/loading.ts @@ -126,6 +126,12 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) if (!s) return clearTimeout(s.showTimer) s.showTimer = undefined + // Never over a page in HTML full screen (a video): it comes back when that ends, if the page is + // still loading (review A-M1). Native full screen keeps it (SB-28). + if (windows.inHtmlFullscreen(windowId)) { + if (s.tabId) hide(windowId, s) + return + } const target = activeTabOf(store.state, windowId) const started = target ? since.get(target) : undefined if (!target || started === undefined) { @@ -183,9 +189,12 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) if (tabId && since.has(tabId)) evaluate(windowId) } }) + // The page card moved, or HTML full screen began or ended: the indicator follows, goes, or comes back. windows.onLayout((windowId) => { const s = perWindow.get(windowId) - if (s?.tabId) evaluate(windowId) + if (!s) return + const target = activeTabOf(store.state, windowId) + if (s.tabId || (target && since.has(target))) evaluate(windowId) }) return { diff --git a/src/main/windows.ts b/src/main/windows.ts index e0397026..ed6cb64a 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -740,8 +740,8 @@ export class WindowManager { /** * A small transparent Orbit UI view for another module (the loading indicator, SB-28; the link - * pill, SB-27; page dialogs, NV-24): hidden until showUiView(), kept above the window's pages - * (under the overlay and the floating sidebar) and closed with the window. + * pill, SB-27; page dialogs, NV-24): out of the window until showUiView(), then above the window's + * pages (under the overlay and the floating sidebar) until hideUiView(); closed with the window. */ createUiView(windowId: WindowId, view: RendererView): WebContentsView | undefined { const rec = this.records.get(windowId) @@ -758,7 +758,10 @@ export class WindowManager { return created } - /** Shows a createUiView() view at `bounds` (window coordinates); `focus` gives it the keyboard. */ + /** + * Shows a createUiView() view at `bounds` (window coordinates), placing it above the pages again; + * `focus` gives it the keyboard. + */ showUiView(windowId: WindowId, view: WebContentsView, bounds: Rect, focus = false): void { const rec = this.records.get(windowId) if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || !viewContents(view)) return @@ -777,13 +780,19 @@ export class WindowManager { if (focus) viewContents(view)?.focus() } - /** Hides a createUiView() view; keyboard focus in it goes back to the page. */ + /** + * Takes a createUiView() view out of the window, as hideTools() does: a view only made invisible + * would stay above every page, a full-screen one included (review A-M1). Keyboard focus in it + * goes back to the page. + */ hideUiView(windowId: WindowId, view: WebContentsView): void { const rec = this.records.get(windowId) if (!rec || rec.win.isDestroyed() || !rec.uiViews.has(view) || !viewContents(view)) return - if (!view.getVisible()) return + const placed = rec.win.contentView.children.includes(view) + if (!view.getVisible() && !placed) return const focused = webContents.getFocusedWebContents() === viewContents(view) view.setVisible(false) + if (placed) this.removeChild(rec, view) if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) } diff --git a/tests/e2e/core.spec.ts b/tests/e2e/core.spec.ts index 180f5339..bf2db4c0 100644 --- a/tests/e2e/core.spec.ts +++ b/tests/e2e/core.spec.ts @@ -130,8 +130,8 @@ test('[TB-19] a target=_blank link shows its tab in exactly one attached, top-mo await expect .poll(async () => (await placedUrls(orbit.app, windowId)).filter((v) => v.url.includes('/page/External'))) .toEqual([{ url: expect.stringContaining('/page/External'), current: true }]) - const placed = await placedUrls(orbit.app, windowId) - expect(placed.at(-1)?.url).toContain('/page/External') + // Above every other view but the overlay, once the loading indicator (SB-28) has gone with the load. + await expect.poll(async () => (await placedUrls(orbit.app, windowId)).at(-1)?.url).toContain('/page/External') }) test('[TB-06] a page that closes itself leaves no dead view; the tab shows again (review M3)', async () => { diff --git a/tests/e2e/nav-page.spec.ts b/tests/e2e/nav-page.spec.ts index ff48dae9..cccd2a50 100644 --- a/tests/e2e/nav-page.spec.ts +++ b/tests/e2e/nav-page.spec.ts @@ -98,6 +98,43 @@ function pageBounds(app: ElectronApplication, tabId: string) { ) } +/** Runs script in the page whose address contains `part`, as the person's own act (a user gesture). */ +function inPage(app: ElectronApplication, part: string, code: string): Promise { + return app.evaluate( + ({ webContents }, [p, c]) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL().includes(p)) + if (!contents) throw new Error(`no page ${p}`) + return contents.executeJavaScript(c, true) + }, + [part, code] as const, + ) +} + +/** The first window's views, bottom to top: a page's path, or an Orbit view's name in capitals. */ +function zOrder(app: ElectronApplication): Promise { + return app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('view=shell')) + return (win?.contentView.children ?? []).map((v) => { + const wc = (v as Electron.WebContentsView).webContents + if (!wc || wc.isDestroyed()) return 'dead' + const url = new URL(wc.getURL() || 'about:blank') + return url.searchParams.get('view')?.toUpperCase() ?? url.pathname + }) + }) +} + +/** True while a page of the first window is in HTML full screen. */ +function htmlFullscreen(app: ElectronApplication): Promise { + return app.evaluate(() => { + const { windows } = ( + globalThis as unknown as { + __orbit: { ctx: { windows: { ids: () => string[]; inHtmlFullscreen: (id: string) => boolean } } } + } + ).__orbit.ctx + return windows.inHtmlFullscreen(windows.ids()[0] as string) + }) +} + /** Tells the first window it lost (or got) the focus, as the OS does when another app comes forward. */ function focusEvent(app: ElectronApplication, kind: 'blur' | 'focus') { return app.evaluate(({ BrowserWindow }, k) => { @@ -194,6 +231,44 @@ test('[SB-28][FL-23] a load shorter than the delay never shows the indicator; sw await expect.poll(async () => (await loading(app)).visible).toBe(true) }) +test('[SB-28][NV-20] the loading indicator stays off a page in HTML full screen and its view leaves the window: the page is top-most under the overlay; it comes back after (review A-M1)', async () => { + const { app } = orbit + const id = await openTab(app, page('Start')) + const url = fx.url('/slow/Film?ms=600') + // The indicator comes for a slow load and goes, before full screen. + await cmd(app, 'tabs.navigate', { tabId: id, url }) + await expect.poll(async () => (await loading(app)).visible).toBe(true) + await waitLoaded(app, id, url) + await expect.poll(async () => (await loading(app)).visible).toBe(false) + expect(await inPage(app, '/slow/Film', 'document.documentElement.requestFullscreen().then(() => "in")')).toBe('in') + await expect.poll(() => htmlFullscreen(app)).toBe(true) + // Nothing of Orbit's but the overlay is above the page: no hidden indicator either. + await expect.poll(async () => (await zOrder(app)).slice(-2)).toEqual(['/slow/Film', 'OVERLAY']) + // A frame that takes a second to load: the tab loads all that time, but the indicator never shows. + const before = (await loading(app)).shown + const framed = `new Promise((done) => { + const frame = document.createElement('iframe') + frame.onload = () => done('loaded') + frame.src = '/slow/Frame?ms=1000' + document.body.append(frame) + })` + expect(await inPage(app, '/slow/Film', framed)).toBe('loaded') + expect(await loading(app)).toMatchObject({ visible: false, shown: before }) + expect((await zOrder(app)).slice(-2)).toEqual(['/slow/Film', 'OVERLAY']) + // A frame still loading when full screen ends: the indicator comes for it then. + await inPage( + app, + '/slow/Film', + `document.body.append(Object.assign(document.createElement('iframe'), { src: '/slow/Long?ms=30000' })), 1`, + ) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) + expect(await loading(app)).toMatchObject({ visible: false, shown: before }) + expect(await inPage(app, '/slow/Film', 'document.exitFullscreen().then(() => "out")')).toBe('out') + await expect.poll(() => htmlFullscreen(app)).toBe(false) + await expect.poll(async () => (await loading(app)).visible).toBe(true) + expect((await loading(app)).tabId).toBe(id) +}) + test('[NV-04][SB-28] the address shows where a typed address is going; Esc stops it, the indicator goes and the address stays', async () => { const { app, shell } = orbit const id = await openTab(app, page('Steady')) From f622007df6bacf2ac42057c978542b555e34dcdf Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:10:00 +0000 Subject: [PATCH 23/69] =?UTF-8?q?fix(nav):=20=E2=8C=98F=20and=20=E2=8C=98G?= =?UTF-8?q?=20do=20nothing=20while=20a=20Peek=20is=20open,=20so=20the=20ke?= =?UTF-8?q?yboard=20stays=20in=20it=20(review=20A-M3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Find acted on the tab under the Peek: its bar opened under the overlay, unseen, and took the keyboard from the Peek. Finding in the Peek itself is PK-11's, with a find session for its page. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/page-tools.ts | 12 ++++++++++-- tests/e2e/nav.spec.ts | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts index 45264890..01bc21d5 100644 --- a/src/main/nav/page-tools.ts +++ b/src/main/nav/page-tools.ts @@ -3,6 +3,7 @@ import { type FindState, findTextFromSelection, PAGE_TOOLS, + peekShown, sameZoom, type ZoomIndicator, zoomPercent, @@ -211,11 +212,18 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( windows.toolsContents(windowId)?.focus() } + /** + * Find acts on the window's page, not on one a Peek covers: while a Peek is open, ⌘F and ⌘G do + * nothing and the keyboard stays in the Peek (review A-M3; finding in the Peek is PK-11's). + */ + const findable = (windowId: WindowId | undefined): windowId is WindowId => + !!windowId && !peekShown(store.state, windowId) + /** ⌘F: the bar opens (or its text is selected again), starting from the page's selection or the tab's last search. */ const openFind = (windowId: WindowId | undefined) => { const tabId = activeOf(windowId) const contents = pageOf(tabId) - if (!windowId || !tabId || !contents) return + if (!findable(windowId) || !tabId || !contents) return const s = sessionOf(tabId) if (s.open) { focusBar(windowId, s) @@ -242,7 +250,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( const step = (windowId: WindowId | undefined, forward: boolean) => { const tabId = activeOf(windowId) const contents = pageOf(tabId) - if (!windowId || !tabId || !contents) return + if (!findable(windowId) || !tabId || !contents) return const s = finds.get(tabId) if (!s?.query) { openFind(windowId) diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index f4e69085..c16a24f6 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -365,6 +365,30 @@ test('[NV-05] ⌘F starts from the selected text, else the tab’s last search; await expect(count).toHaveText('3/3') }) +test('[NV-05][PK-11] ⌘F and ⌘G with a Peek open leave the tab under it alone: no find bar comes, and the keyboard stays in the Peek (review A-M3)', async () => { + const { app } = orbit + await openTab(app, findPage('Under')) + // The tab under the Peek has a search of its own, which ⌘G would bring back. + const tools = await openFind('/page/Under') + await tools.keyboard.type('orbit') + await expect(tools.getByTestId('find-count')).toHaveText('1/3') + await tools.keyboard.press('Escape') + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) + const peekUrl = findPage('Peeked') + const peekTab = await cmd(app, 'peek.open', { url: peekUrl }) + await waitLoaded(app, peekTab, peekUrl) + await expect.poll(() => focusedUrl(app)).toContain('/page/Peeked') + const peek = await tabPage(app, '/page/Peeked') + await pressInPage(app, '/page/Peeked', 'F', [mod]) + await pressInPage(app, '/page/Peeked', 'G', [mod]) + // Keys reach main in order: once the page has a later key, Orbit has dealt with ⌘F and ⌘G. + await pressInPage(app, '/page/Peeked', 'End') + await expect.poll(() => keysSeen(peek)).toContain('End') + expect(await focusedUrl(app)).toContain('/page/Peeked') + expect(await toolsInfo(app)).toMatchObject({ placed: false, find: { open: false } }) + expect((await windowState(app)).overlay.kind).toBe('peek') +}) + test('[NV-05][KS-08] Jump to Selection (⌘J on macOS) scrolls the page back to the selected text', async () => { const { app } = orbit await openTab(app, findPage('Jump')) From 17ea8af3dd5a3d5e84b239ba078450d06f30e496 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:19:07 +0000 Subject: [PATCH 24/69] fix(chrome): the hover reveal hears a real pointer at the window's edge (review A-M2, A-F12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge dropped every move without movement, and the first move the shell hears from a pointer coming off the page has none, so a flick to the edge often did nothing. On Linux the window's left 5 px are its resize border and nothing in the window hears the pointer there at all. Main now decides whether the pointer moved: it notes where the pointer was when the sidebar hid, and the edge brings the card in once the pointer is anywhere else, so ⌘S with the pointer at the edge still doesn't. When the pointer leaves a page or the shell, main reads it for 300 ms and brings the card in if it reached the window's left edge. Under ORBIT_E2E the pointer is where the last mouse event put it, as test input never moves the system's. A closed window's grace timer is cleared with its hold (A-F12). Co-Authored-By: Claude Opus 5.5 --- src/main/nav/sidebar-reveal.ts | 127 ++++++++++++++++-- src/main/windows.ts | 7 + src/renderer/src/shell/chrome/SidebarEdge.tsx | 9 +- tests/e2e/chrome.spec.ts | 31 +++++ tests/e2e/nav-support.ts | 16 ++- 5 files changed, 175 insertions(+), 15 deletions(-) diff --git a/src/main/nav/sidebar-reveal.ts b/src/main/nav/sidebar-reveal.ts index ce8921cb..c64e0930 100644 --- a/src/main/nav/sidebar-reveal.ts +++ b/src/main/nav/sidebar-reveal.ts @@ -1,4 +1,4 @@ -import type { MouseInputEvent } from 'electron' +import { type MouseInputEvent, screen } from 'electron' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' import type { CallContext } from '../registry' @@ -7,6 +7,18 @@ import type { CallContext } from '../registry' export const REVEAL_EDGE = 6 /** The floating sidebar stays this long after the pointer has left it (FL-03: 300–500 ms). */ export const HIDE_GRACE_MS = 400 +/** + * After the pointer leaves a page or the shell, how long Orbit looks for it at the window's left + * edge, where nothing in the window may hear it (review A-M2). + */ +export const EDGE_WATCH_MS = 300 +/** How often the pointer is read meanwhile. */ +const EDGE_WATCH_EVERY_MS = 20 + +interface Point { + x: number + y: number +} /** What keeps the floating sidebar out. */ export interface RevealHold { @@ -39,6 +51,12 @@ const buttonDown = (mouse: MouseInputEvent) => (mouse.modifiers ?? []).some((m) * Where the pointer is comes from the card itself (it enters, or leaves through the window's edge) * and from the pages (it moves or clicks there). A native menu opened from the card holds the * pointer, so nothing arrives from the pages and the card stays while the menu is up. + * + * Only a pointer that moves brings the card in: one resting at the edge as the sidebar hides (⌘S) + * doesn't, until it moves. Main reads the pointer for that, as the first event the shell hears from + * a pointer coming off the page has no movement. On Linux a frameless window keeps its left 5 px + * for resizing and nothing in the window hears the pointer there: when the pointer leaves a page or + * the shell, main reads it for a moment and brings the card in if it is at the edge (review A-M2). */ export function registerSidebarReveal(ctx: MainContext): { hold: (windowId: WindowId) => RevealHoldInfo | undefined @@ -46,6 +64,24 @@ export function registerSidebarReveal(ctx: MainContext): { const { store, commands, windows } = ctx const holds = new Map() const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() + /** Per window: where the pointer was when the sidebar hid; the edge waits until it moves from there. */ + const restAt = new Map() + /** Per window: the look for the pointer at the left edge after it left a page or the shell. */ + const edgeWatches = new Map() + + /** + * The pointer on screen. Input a test sends never moves the system's pointer, so under ORBIT_E2E + * it is where the last mouse event Orbit heard (in a window's shell or page) put it. + */ + let testPointer: Point | undefined + const pointer = (): Point => (ctx.e2e && testPointer) || screen.getCursorScreenPoint() + /** Under ORBIT_E2E: a mouse event at `x`, `y` in a window's content puts the pointer there. */ + const follow = (windowId: WindowId, x: number, y: number) => { + const win = ctx.e2e ? windows.get(windowId) : undefined + if (!win || win.isDestroyed()) return + const content = win.getContentBounds() + testPointer = { x: content.x + x, y: content.y + y } + } const holdOf = (windowId: WindowId): Hold => { let hold = holds.get(windowId) @@ -79,6 +115,52 @@ export function registerSidebarReveal(ctx: MainContext): { setPeeking(windowId, true) } + /** + * A reveal the pointer asked for (the edge in the shell, or a page in full screen): only once the + * pointer has moved since the sidebar hid. + */ + const revealIfMoved = (windowId: WindowId): boolean => { + const rest = restAt.get(windowId) + const now = pointer() + if (rest && rest.x === now.x && rest.y === now.y) return false + restAt.delete(windowId) + reveal(windowId) + return true + } + + /** The pointer within the window's left edge band (its content area, a resize border included). */ + const atLeftEdge = (windowId: WindowId): boolean => { + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return false + const content = win.getContentBounds() + const now = pointer() + return ( + now.x >= content.x && now.x < content.x + REVEAL_EDGE && now.y >= content.y && now.y < content.y + content.height + ) + } + + const stopEdgeWatch = (windowId: WindowId) => { + clearInterval(edgeWatches.get(windowId)) + edgeWatches.delete(windowId) + } + + /** The pointer left a page or the shell: it may have gone where only reading it finds it. */ + const watchEdge = (windowId: WindowId) => { + const watching = () => { + const win = store.state.windows[windowId] + return !!win && win.kind !== 'mini' && !win.sidebar.visible && !win.sidebar.peeking + } + if (!watching()) return + stopEdgeWatch(windowId) + const until = Date.now() + EDGE_WATCH_MS + const look = () => { + if (!watching() || Date.now() > until) stopEdgeWatch(windowId) + else if (atLeftEdge(windowId) && revealIfMoved(windowId)) stopEdgeWatch(windowId) + } + edgeWatches.set(windowId, setInterval(look, EDGE_WATCH_EVERY_MS)) + look() + } + const hideNow = (windowId: WindowId) => { clearTimeout(holds.get(windowId)?.timer) holds.delete(windowId) @@ -97,8 +179,11 @@ export function registerSidebarReveal(ctx: MainContext): { }, HIDE_GRACE_MS) } - store.subscribe((next) => { + store.subscribe((next, prev) => { for (const [windowId, win] of Object.entries(next.windows)) { + // Hidden (⌘S, the button, a drag): where the pointer rests then doesn't count as reaching the edge. + if (win.sidebar.visible) restAt.delete(windowId) + else if (prev.windows[windowId]?.sidebar.visible) restAt.set(windowId, pointer()) if (!win.sidebar.peeking || win.sidebar.visible) { const hold = holds.get(windowId) if (hold) { @@ -115,15 +200,32 @@ export function registerSidebarReveal(ctx: MainContext): { settle(windowId) } } - for (const windowId of holds.keys()) if (!next.windows[windowId]) holds.delete(windowId) + // A closed window's timers go with it (review A-F12). + for (const [windowId, hold] of holds) { + if (next.windows[windowId]) continue + clearTimeout(hold.timer) + holds.delete(windowId) + } + for (const windowId of edgeWatches.keys()) if (!next.windows[windowId]) stopEdgeWatch(windowId) + for (const windowId of restAt.keys()) if (!next.windows[windowId]) restAt.delete(windowId) }) - /** Pointer input over a page: it left the card, clicked into the page, or (full screen) reached the edge. */ + /** + * Pointer input over a page: it left the card, clicked into the page, (full screen) reached the + * edge, or left the page, perhaps for the window's edge. + */ const onPageMouse = (tabId: TabId, mouse: MouseInputEvent) => { - if (mouse.type !== 'mouseMove' && mouse.type !== 'mouseDown') return + if (mouse.type !== 'mouseMove' && mouse.type !== 'mouseDown' && mouse.type !== 'mouseLeave') return const windowId = windows.windowOfTab(tabId) const win = windowId ? store.state.windows[windowId] : undefined - if (!windowId || !win || win.sidebar.visible) return + if (!windowId || !win) return + const bounds = windows.tabViewBounds(tabId) + if (ctx.e2e && bounds) follow(windowId, bounds.x + mouse.x, bounds.y + mouse.y) + if (win.sidebar.visible) return + if (mouse.type === 'mouseLeave') { + watchEdge(windowId) + return + } if (win.sidebar.peeking) { if (mouse.type === 'mouseDown') { hideNow(windowId) @@ -138,16 +240,23 @@ export function registerSidebarReveal(ctx: MainContext): { } // In full screen the page reaches the window's left edge, so the edge is in the page. if (mouse.type !== 'mouseMove' || buttonDown(mouse)) return - const bounds = windows.tabViewBounds(tabId) - if (bounds && bounds.x + mouse.x < REVEAL_EDGE) reveal(windowId) + if (bounds && bounds.x + mouse.x < REVEAL_EDGE) revealIfMoved(windowId) } ctx.hooks.tabCreated.push((tabId, contents) => { contents.on('before-mouse-event', (_event, mouse) => onPageMouse(tabId, mouse)) }) + // The shell: the edge's own events come through `sidebar.reveal`; here, the pointer leaving it. + windows.onWindowCreated((windowId, win) => { + win.webContents.on('before-mouse-event', (_event, mouse) => { + if (ctx.e2e) follow(windowId, mouse.x, mouse.y) + if (mouse.type === 'mouseLeave') watchEdge(windowId) + }) + win.once('closed', () => stopEdgeWatch(windowId)) + }) commands.register('sidebar.reveal', (_args, c) => { const windowId = windowOf(c) - if (windowId) reveal(windowId) + if (windowId) revealIfMoved(windowId) }) commands.register('sidebar.peekState', ({ over, editing }, c) => { const windowId = windowOf(c) diff --git a/src/main/windows.ts b/src/main/windows.ts index ed6cb64a..bb763dbd 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -189,6 +189,7 @@ export class WindowManager { /** Each tab's view that is placed in a window, and which window. Compared by view, not tab id. */ private attached = new Map() private layoutListeners: ((windowId: WindowId) => void)[] = [] + private createdListeners: ((windowId: WindowId, win: BrowserWindow) => void)[] = [] /** 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)[] = [] @@ -389,6 +390,7 @@ export class WindowManager { this.records.set(id, rec) // macOS: the traffic lights live in the sidebar's top row (SB-20). if (isMac && !winState.sidebar.visible) win.setWindowButtonVisibility(false) + for (const listener of this.createdListeners) listener(id, win) win.once('ready-to-show', () => { this.ctx.perf.mark(`window:${id}:ready-to-show`) @@ -644,6 +646,11 @@ export class WindowManager { this.layoutListeners.push(listener) } + /** Runs for each main window as it is created, before it shows. */ + onWindowCreated(listener: (windowId: WindowId, win: BrowserWindow) => void): void { + this.createdListeners.push(listener) + } + /** The page tools' web contents (NV-05, NV-06), started if it wasn't yet. */ toolsContents(windowId: WindowId): WebContents | undefined { const rec = this.records.get(windowId) diff --git a/src/renderer/src/shell/chrome/SidebarEdge.tsx b/src/renderer/src/shell/chrome/SidebarEdge.tsx index a9cccfe3..dbbc79dd 100644 --- a/src/renderer/src/shell/chrome/SidebarEdge.tsx +++ b/src/renderer/src/shell/chrome/SidebarEdge.tsx @@ -3,15 +3,16 @@ import { invoke } from '../../api' /** * The window's left edge while the sidebar is hidden (SB-12, FL-03): the pointer reaching it brings - * the sidebar in as a floating card, at once. Only a pointer that moves counts: one resting there - * when the sidebar is hidden (⌘S with the pointer at the edge) doesn't bring it straight back, and - * neither does a drag passing by (a text selection, a tab). + * the sidebar in as a floating card, at once. A drag passing by (a text selection, a tab) doesn't. + * Main decides whether the pointer moved: one resting there when the sidebar is hidden (⌘S with the + * pointer at the edge) doesn't bring it straight back. The event's own movement can't tell, as the + * first event from a pointer coming off the page has none (review A-M2). */ export function SidebarEdge() { const last = useRef(0) useLeavingWindow() const onMove = (e: MouseEvent) => { - if (e.buttons || (e.movementX === 0 && e.movementY === 0)) return + if (e.buttons) return const now = performance.now() if (now - last.current < 100) return last.current = now diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index d7c13983..2f0bc941 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -19,6 +19,7 @@ import { pageCorner, pageMouse, setNativeFullScreen, + shellMouseMove, shellRoundTrip, spyCommand, startNavFixtures, @@ -744,6 +745,36 @@ test('[SB-12] the edge does nothing while the sidebar is docked or the command b expect((await sidebarState()).peeking).toBe(false) }) +test('[SB-12][FL-03] a pointer coming off the page brings the sidebar in with the first move the edge hears (review A-M2)', async () => { + const { app } = orbit + await openTab(app, page('Coming')) + await hideSidebar() + // Over the page, as a person's pointer is before it goes for the edge. + await pageMouse(app, '/page/Coming', 'mouseMove', 400, 300) + await pageMouse(app, '/page/Coming', 'mouseMove', 200, 300) + // One move at the edge: the first the shell hears, so it has no movement of its own. + await shellMouseMove(app, 3, 300) + await expect.poll(async () => (await sidebarState()).peeking).toBe(true) +}) + +test('[SB-12][FL-03] a pointer that leaves the page for the window’s very edge, where nothing in the window hears it (Linux keeps it for resizing), brings the sidebar in (review A-M2)', async () => { + const { app } = orbit + const id = await openTab(app, page('Leaving')) + await hideSidebar() + await pageMouse(app, '/page/Leaving', 'mouseMove', 300, 300) + const card = (await tabView(app, id))?.bounds + if (!card) throw new Error('no page view') + // The pointer jumps to x = 2 of the window: the page hears it leave, and nothing hears it arrive. + await pageMouse(app, '/page/Leaving', 'mouseLeave', 2 - card.x, 300) + await expect.poll(async () => (await sidebarState()).peeking).toBe(true) + // Somewhere else, it doesn't: the right edge. + await pageMouse(app, '/page/Leaving', 'mouseMove', 300, 300) + await expect.poll(async () => (await sidebarState()).peeking).toBe(false) + await pageMouse(app, '/page/Leaving', 'mouseLeave', card.width + 3, 300) + await shellRoundTrip(orbit.shell) + expect((await sidebarState()).peeking).toBe(false) +}) + test('[SB-14] dragging the edge sets the width 1:1 within limits; double-click resets; far left hides', async () => { const { app, shell } = orbit await openTab(app, page('Resize')) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index c0cf9365..7c5fe984 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -583,7 +583,7 @@ export async function pressInPage( export async function pageMouse( app: ElectronApplication, urlPart: string, - type: 'mouseMove' | 'mouseDown' | 'mouseUp', + type: 'mouseMove' | 'mouseDown' | 'mouseUp' | 'mouseLeave', x: number, y: number, ): Promise { @@ -591,13 +591,25 @@ export async function pageMouse( ({ webContents }, [part, t, px, py]) => { const page = webContents.getAllWebContents().find((w) => w.getURL().includes(part)) if (!page) throw new Error(`no page with ${part}`) - if (t === 'mouseMove') page.sendInputEvent({ type: t, x: px, y: py }) + if (t === 'mouseMove' || t === 'mouseLeave') page.sendInputEvent({ type: t, x: px, y: py }) else page.sendInputEvent({ type: t, x: px, y: py, button: 'left', clickCount: 1 }) }, [urlPart, type, x, y] as const, ) } +/** Moves the mouse over the first window's shell the way the hardware does: through the browser process. */ +export async function shellMouseMove(app: ElectronApplication, x: number, y: number): Promise { + await app.evaluate( + ({ webContents }, [px, py]) => { + const shell = webContents.getAllWebContents().find((w) => w.getURL().includes('view=shell')) + if (!shell) throw new Error('no shell') + shell.sendInputEvent({ type: 'mouseMove', x: px, y: py }) + }, + [x, y] as const, + ) +} + /** Presses a mouse side button ('back' or 'forward') over a page, as the hardware would send it. */ export async function sideButton(page: Page, button: 'back' | 'forward'): Promise { const cdp = await page.context().newCDPSession(page) From ce9e3655dfb5dd82d6bc02997b48746603a46e06 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:32:08 +0000 Subject: [PATCH 25/69] fix(split): the page keeps out of the sliding sidebar's way (review A-M4) The native page views can't move in step with the shell's frames, so following the frame during a sidebar show or hide let them cover the sidebar for a few frames. While the sidebar's column slides, the pane engine now reports the panes in the part of the frame the sidebar never covers: showing, where they end (measured with the transitions seeked to their end and put back before the frame is drawn); hiding, where they were. The page-colour card fills the rest in step with the sidebar. The slide ends with the column's transition (a reversal runs on) or the follow cap. Test: the page view's setBounds calls during a toggle. Without the fix, hiding moved it to 174, 40, 16, 10, 6 before the sidebar had gone, and showing first put it at 149, not 248. Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/shell/split/engine.ts | 92 +++++++++++++++++++++++--- tests/e2e/chrome.spec.ts | 64 ++++++++++++++++++ 2 files changed, 147 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/shell/split/engine.ts b/src/renderer/src/shell/split/engine.ts index d93a7201..35bc18ae 100644 --- a/src/renderer/src/shell/split/engine.ts +++ b/src/renderer/src/shell/split/engine.ts @@ -31,6 +31,16 @@ export const FRAME_RADIUS = 10 /** Follow the frame for at most this long after a sidebar/padding transition starts (review F6). */ const FOLLOW_CAP_MS = 700 +/** True while the shell's sidebar column is mid-transition. */ +function columnSliding(shell: HTMLElement): boolean { + return shell + .getAnimations() + .some( + (a) => + a instanceof CSSTransition && a.transitionProperty === 'grid-template-columns' && a.playState === 'running', + ) +} + export interface FrameModel { /** Leaf for a single tab, a tree for a split, null for an empty window. */ layout: SplitLayout | null @@ -98,6 +108,13 @@ export class PaneEngine { private ring: HTMLElement | null = null private raf = 0 private followUntil = 0 + /** + * Where the native page views stay while the sidebar slides in or out (SB-11, FL-02). They can't + * move in step with the shell's frames, so they keep to the part of the frame the sidebar never + * covers: showing, where they will end; hiding, where they were. The page-colour card (SB-19) + * fills the rest, in step with the sidebar (review A-M4). + */ + private slide: Box | null = null private lastReport = '' private cleanup: (() => void)[] = [] /** Nothing is reported before the first model arrives (avoids hiding the views on mount). */ @@ -121,11 +138,20 @@ export class PaneEngine { if (e.target !== content && e.target !== shell) return if (e.propertyName !== 'grid-template-columns' && !e.propertyName.startsWith('padding')) return this.followUntil = performance.now() + FOLLOW_CAP_MS + if (shell && content && e.target === shell && e.propertyName === 'grid-template-columns') { + this.startSlide(shell, content) + } this.schedule() } const onEnd = (e: TransitionEvent) => { if (e.target !== content && e.target !== shell) return - requestAnimationFrame(() => this.inFrameDo(() => this.measure() && this.relayout(false))) + requestAnimationFrame(() => + this.inFrameDo(() => { + // A reversal cancels one slide and runs another: the slide is over once no column transition runs. + if (this.slide && !(shell && columnSliding(shell))) this.endSlide() + else if (this.measure()) this.relayout(false) + }), + ) } document.addEventListener('transitionrun', onRun) document.addEventListener('transitionend', onEnd) @@ -427,8 +453,51 @@ export class PaneEngine { this.inFrameDo(() => this.step(now)) } + /** The sidebar's column started to slide: the views keep to where the frame is both now and at the end. */ + private startSlide(shell: HTMLElement, content: HTMLElement): void { + const end = this.endFrame(shell, content) + // A reversal starts from where the views are, not from where the frame has got to. + const kept = end && intersect(this.slide ?? this.frame, end) + this.slide = kept && kept.width >= 1 && kept.height >= 1 ? kept : null + } + + /** The slide ended: the views go where the frame now is. */ + private endSlide(): void { + this.slide = null + this.lastReport = '' + this.measure() + this.relayout(false) + } + + /** + * Where the frame will be once the running sidebar and padding transitions end: measured with + * them at their end, then put back where they were, all before the frame is drawn. + */ + private endFrame(shell: HTMLElement, content: HTMLElement): Box | null { + if (!this.host) return null + const running = [...shell.getAnimations(), ...content.getAnimations()].filter( + (a): a is CSSTransition => + a instanceof CSSTransition && + (a.transitionProperty === 'grid-template-columns' || a.transitionProperty.startsWith('padding')), + ) + const times = new Map(running.map((a) => [a, a.currentTime])) + try { + for (const [a, time] of times) { + const end = Number(a.effect?.getComputedTiming().endTime ?? 0) + // Just short of the end, so the transition never finishes (or sends its end event) here. + if (time !== null && end > 0) a.currentTime = end - 0.01 + } + const r = this.host.getBoundingClientRect() + return { x: r.left, y: r.top, width: r.width, height: r.height } + } finally { + for (const [a, time] of times) if (time !== null) a.currentTime = time + } + } + private step(now: number): void { this.raf = 0 + // An end event that never came: the slide ends with the time cap. + if (this.slide && now >= this.followUntil) this.endSlide() let moving = false for (const anim of this.anims.values()) { const a = anim.x.step(now) @@ -527,21 +596,26 @@ export class PaneEngine { } private sendReport(): void { + const frame = this.slide ?? this.frame const panes: { tabId: TabId; rect: Box }[] = [] - for (const [tabId, anim] of this.anims) { + for (const [tabId, r] of this.reportedRects(frame)) { if (tabId === DROP_PANE || this.model.blank.has(tabId)) continue - const r = rectOf(anim) - const rect = intersect( - { x: this.frame.x + r.x, y: this.frame.y + r.y, width: r.width, height: r.height }, - this.frame, - ) + const rect = intersect({ x: frame.x + r.x, y: frame.y + r.y, width: r.width, height: r.height }, frame) if (rect.width < 1 || rect.height < 1) continue panes.push({ tabId, rect }) } - const key = JSON.stringify([panes, this.frame]) + const key = JSON.stringify([panes, frame]) if (key === this.lastReport) return this.lastReport = key - window.orbit.send('layout.panes', { panes, radius: FRAME_RADIUS, seq: getSeq(), frame: { ...this.frame } }) + window.orbit.send('layout.panes', { panes, radius: FRAME_RADIUS, seq: getSeq(), frame: { ...frame } }) + } + + /** The panes in `frame`: as drawn, or (the sidebar sliding) laid out in the part of the frame kept for the views. */ + private reportedRects(frame: Box): [TabId, Box][] { + if (!this.slide) return [...this.anims].map(([tabId, anim]) => [tabId, rectOf(anim)]) + const layout = this.displayLayout() + const local = { x: 0, y: 0, width: frame.width, height: frame.height } + return (layout ? layoutBoxes(layout, local, PANE_GAP).panes : []).map((p) => [p.tabId, p.rect]) } /** Forces the next report even if nothing changed (e.g. after main dropped one). */ diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 2f0bc941..30df204f 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -566,6 +566,70 @@ test('[FL-02] the sidebar slides out as the card widens, edge to edge in every f await expect.poll(async () => (await sidebarState()).visible).toBe(true) }) +test('[SB-11][FL-02] the page keeps out of the sliding sidebar’s way: hiding, it stays where it was until the sidebar has gone; showing, it goes at once to where it ends (review A-M4)', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Native')) + const width = (await sidebarState()).width + await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(width) + // Every place main puts the page's native view, and when (by the wall clock, which both processes share). + await app.evaluate((_electron, tabId) => { + const hooks = ( + globalThis as unknown as { + __orbit: { ctx: { tabs: { get: (id: string) => Electron.WebContentsView | undefined } } } + } + ).__orbit + const view = hooks.ctx.tabs.get(tabId) + if (!view) throw new Error(`no view for ${tabId}`) + const moves: { t: number; x: number }[] = [] + ;(globalThis as unknown as { __moves: typeof moves }).__moves = moves + const setBounds = view.setBounds.bind(view) + view.setBounds = (bounds: Electron.Rectangle) => { + moves.push({ t: Date.now(), x: bounds.x }) + setBounds(bounds) + } + }, id) + const takeMoves = () => + app.evaluate(() => (globalThis as unknown as { __moves: { t: number; x: number }[] }).__moves.splice(0)) + /** Toggles the sidebar from the shell: when, and when its column stopped sliding. */ + const toggle = () => + shell.evaluate(async () => { + const el = document.querySelector('[data-testid="shell"]') as HTMLElement + const orbitApi = (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise } }).orbit + const ended = new Promise((resolve) => { + const done = (e: TransitionEvent) => { + if (e.target !== el || e.propertyName !== 'grid-template-columns') return + el.removeEventListener('transitionend', done) + resolve(Date.now()) + } + el.addEventListener('transitionend', done) + }) + const start = Date.now() + void orbitApi.invoke('action.run', { id: 'sidebar.toggle' }) + return { start, end: await ended } + }) + + // Hiding: the view stays at its start until the sidebar has gone, then takes the window. + const hid = await toggle() + expect(hid.end - hid.start).toBeGreaterThan(100) + await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(MARGIN_HIDDEN) + const hiding = await takeMoves() + const sliding = hiding.filter((m) => m.t < hid.end) + expect( + sliding.map((m) => m.x), + 'where main put the view while the sidebar slid out', + ).toEqual(sliding.map(() => width)) + expect(hiding.at(-1)?.x).toBe(MARGIN_HIDDEN) + + // Showing: the first move already puts it where it ends, clear of the sidebar coming in. + const shown = await toggle() + expect(shown.end - shown.start).toBeGreaterThan(100) + await expect.poll(async () => (await tabView(app, id))?.bounds.x).toBe(width) + const showing = await takeMoves() + expect(showing[0]?.x, 'the first place main put the view once the sidebar started to slide in').toBe(width) + expect(showing[0]?.t ?? Number.POSITIVE_INFINITY).toBeLessThan(shown.end) + expect(showing.every((m) => m.x === width)).toBe(true) +}) + test('[SB-12][FL-03] with the sidebar hidden, the left edge brings it in over the page; it goes once the pointer moves on', async () => { const { app, shell } = orbit const id = await openTab(app, page('Under')) From 1cd73aa1da7bb53193a0571e17532cb0b9170362 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:43:02 +0000 Subject: [PATCH 26/69] fix(nav): page menu media items and the hidden-sidebar toast (review B-M4) Right-clicking a video or audio now offers Loop and Show Controls, ticked as the element is and greyed where the engine can't (a live stream doesn't loop; an audio player keeps its controls), ahead of the media lane's Picture in Picture; the group ends with a separator whoever added its last item. They act on the element under the pointer, or in a frame inside the page on the frame's element playing the same source. Open Link in New Tab (and Open Image in New Tab) from the page menu now shows "Opened in a new tab" while the sidebar is hidden, as a Cmd-clicked link does. The page menu test hook also reports which items are ticked. Media's MD-02 test now reads the menu through it instead of replacing Menu.prototype.popup, which the e2e menu hook never calls. Docs: ARCHITECTURE's preload line and security section describe the page preload on D40's terms and list its two channels (review B-F10). Tests: without the fix the video menu's first group was just ["Picture in Picture"], and no toast came (toast not found). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/ARCHITECTURE.md | 8 +++- docs/feature-status.json | 2 +- src/main/nav/index.ts | 4 +- src/main/nav/page-menu.ts | 74 +++++++++++++++++++++++++++++++++-- src/main/tabs/context-menu.ts | 12 ++++-- tests/e2e/media.spec.ts | 26 ++++-------- tests/e2e/nav-links.spec.ts | 70 +++++++++++++++++++++++++++++++++ tests/e2e/nav-support.ts | 72 ++++++++++++++++++++++++++++++++++ 9 files changed, 238 insertions(+), 32 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index e0a2a390..b25d5b70 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -240,7 +240,7 @@ Spec: `spec/navigation.md` | 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 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 | | NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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, Print, View Page Source), link (New Tab, Split View, New Window, Private Window, Copy, Save Link As), selection (Copy, Search engine, Look Up on macOS), image, editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek, Mini Window and media items come from their lanes; P2 Translate and Share Quote not built | +| NV-12 | Page context menu | P0 | ✅ | ✅ e2e | – | native menu: page (Back, Forward, Reload, Save Page As, 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), 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-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): 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; Esc dismisses; kept per profile and site (private in memory); pages read undecided as default/prompt; a notification click brings its tab forward (page notifications, not service-worker ones); Peek, Mini Window and pop-ups ask in a dialog; toolbar anchor waits on SB-17, Site settings is ST-13 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 758b9cfc..a14dc5e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,7 +17,7 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) - **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. - **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. Web pages get **no preload**. IPC handlers reject any sender that isn't registered Orbit UI. +- **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 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. ## 2. State: one store, patches out @@ -118,7 +118,11 @@ Rules: no synchronous disk I/O on hot paths after boot, no full-state broadcasts ## 10. Security baseline -Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages never get a preload. 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). +Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get one preload, `src/preload/page.ts`, on D40's terms: main frames only, nothing added to page globals, built-ins patched only where the spec needs Chromium's behaviour (captured before the page runs, `Reflect.apply` included), and its own `orbit-page:*` channels. Main takes a page's origin and profile from the sender frame (`event.senderFrame`), never from the message, and each channel either returns read-only state for that origin or carries a request main checks against its own state before acting. Its uses, all of them (any other channel, patch or global is a lead decision, D40): +- `orbit-page:permission` (NV-15): a synchronous read of how a permission reads for the sending frame's own origin and profile ("prompt" while the user hasn't decided). Read-only. +- `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. + +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). ## 11. Packaging and updates diff --git a/docs/feature-status.json b/docs/feature-status.json index f60ae78b..7095b379 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -169,7 +169,7 @@ "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 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"}, "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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, Print, View Page Source), link (New Tab, Split View, New Window, Private Window, Copy, Save Link As), selection (Copy, Search engine, Look Up on macOS), image, editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek, Mini Window and media items come from their lanes; P2 Translate and Share Quote not built"}, + "NV-12": {"built": "yes", "note": "native menu: page (Back, Forward, Reload, Save Page As, 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), 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-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden): 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; Esc dismisses; kept per profile and site (private in memory); pages read undecided as default/prompt; a notification click brings its tab forward (page notifications, not service-worker ones); Peek, Mini Window and pop-ups ask in a dialog; 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); rules UI is ST-09, Archive restores AR-05"}, diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 976c917e..a944004c 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -309,8 +309,8 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { openerTabId: opener?.id, load: { httpReferrer: referrer }, }) - // It loads in the background, as a ⌘-clicked link's tab does. - tabs.ensure(id) + // It loads in the background, as a ⌘-clicked link's tab does, and says so while the sidebar is hidden (NV-12). + openedInBackground(windowId, id) }, window: openLinkWindow, private: (url, from) => void privateWindows.open({ url, fromWindowId: from.windowId }), diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index 2224227c..1d5715da 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -179,6 +179,61 @@ export function pageItems(ctx: MainContext, tabId: TabId, contents: WebContents) ] } +/** + * Loop and Show Controls for the video or audio right-clicked (NV-12), ticked as the element is; + * media adds Picture in Picture after them. Each is greyed where the engine says it can't be done: + * a live stream doesn't loop, and an audio player's controls stay, as without them it would be gone + * with no way back. + */ +export function mediaItems(contents: WebContents, params: ContextMenuParams): MenuItemConstructorOptions[] { + if (params.mediaType !== 'video' && params.mediaType !== 'audio') return [] + const flags = params.mediaFlags + const set = (property: 'loop' | 'controls', on: boolean) => () => void setMedia(contents, params, property, on) + return [ + { + label: 'Loop', + type: 'checkbox', + checked: flags.isLooping, + enabled: flags.canLoop, + click: set('loop', !flags.isLooping), + }, + { + label: 'Show Controls', + type: 'checkbox', + checked: flags.isControlsVisible, + enabled: flags.canToggleControls, + click: set('controls', !flags.isControlsVisible), + }, + ] +} + +/** + * Sets `loop` or `controls` on the media element the menu was opened over: the one under the + * pointer, or in a frame inside the page (where the pointer's place is the page's, not the + * frame's) the frame's element playing the same source. + */ +async function setMedia( + contents: WebContents, + params: ContextMenuParams, + property: 'loop' | 'controls', + on: boolean, +): Promise { + const bySource = `[...document.querySelectorAll('video, audio')].find((m) => m.currentSrc === ${JSON.stringify(params.srcURL)})` + try { + if (params.frame?.parent) { + await params.frame.executeJavaScript(`(() => { const m = ${bySource}; if (m) m.${property} = ${on} })()`) + return + } + const zoom = contents.getZoomFactor() || 1 + const x = Math.round(params.x / zoom) + const y = Math.round(params.y / zoom) + const code = `(() => { const at = document.elementFromPoint(${x}, ${y}); const m = (at && at.closest('video, audio')) || ${bySource}; if (m) m.${property} = ${on} })()` + await contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code }]) + } catch { + // The page or its frame went away meanwhile. + } +} + /** In tests, keeps the menu for the test hooks instead of showing it. True when kept. */ export function keepPageMenu(ctx: MainContext, tabId: TabId, items: MenuItemConstructorOptions[]): boolean { if (!ctx.e2e) return false @@ -189,8 +244,16 @@ export function keepPageMenu(ctx: MainContext, tabId: TabId, items: MenuItemCons const labelOf = (item: MenuItemConstructorOptions) => item.label ?? (item.role ? `role:${item.role}` : '') export interface PageMenuTestHooks { - /** The page menu last opened: its items' labels (`role:copy` for built-in ones; `-` for separators), or null. */ - pageMenu: () => { tabId: TabId; labels: string[]; enabled: Record } | null + /** + * The page menu last opened: its items' labels (`role:copy` for built-in ones; `-` for separators), + * which are enabled, and whether each tickable one is ticked; or null. + */ + pageMenu: () => { + tabId: TabId + labels: string[] + enabled: Record + checked: Record + } | null /** Chooses an item of the page menu by label (`Menu > Item` for one in a submenu). */ choosePageMenu: (label: string) => boolean } @@ -199,11 +262,16 @@ export const pageMenuTestHooks: PageMenuTestHooks = { pageMenu: () => { if (!shown) return null const enabled: Record = {} - for (const item of shown.items) if (item.type !== 'separator') enabled[labelOf(item)] = item.enabled !== false + const checked: Record = {} + for (const item of shown.items) { + if (item.type !== 'separator') enabled[labelOf(item)] = item.enabled !== false + if (item.type === 'checkbox' || item.type === 'radio') checked[labelOf(item)] = !!item.checked + } return { tabId: shown.tabId, labels: shown.items.map((i) => (i.type === 'separator' ? '-' : labelOf(i))), enabled, + checked, } }, choosePageMenu: (label) => { diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index 51e00722..9c9b1eae 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -6,6 +6,7 @@ import { keepPageMenu, linkWindowItems, lookUpItems, + mediaItems, menuReferrer, openLinkInTab, pageItems, @@ -18,10 +19,13 @@ import { linkSplitMenuItem } from '../split/menu' /** Native right-click menu for web pages. */ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: WebContents, params: ContextMenuParams) { - // Items other features add (media: Picture in Picture on a video). - const items: MenuItemConstructorOptions[] = (ctx.hooks.pageContextMenu ?? []).flatMap((hook) => - hook(tabId, contents, params), - ) + // Video and audio first: Loop and Show Controls, then what other features add (media: Picture in + // Picture on a video) (NV-12). The group ends with a separator, whoever added its last item. + const items: MenuItemConstructorOptions[] = [ + ...mediaItems(contents, params), + ...(ctx.hooks.pageContextMenu ?? []).flatMap((hook) => hook(tabId, contents, params)), + ] + if (items.length > 0 && items.at(-1)?.type !== 'separator') items.push({ type: 'separator' }) const openInNewTab = (url: string, activate: boolean) => { const tab = ctx.store.state.tabs[tabId] void ctx.commands.run('tabs.create', { url, spaceId: tab?.spaceId, activate }, {}) diff --git a/tests/e2e/media.spec.ts b/tests/e2e/media.spec.ts index 7b06d737..9022fb0e 100644 --- a/tests/e2e/media.spec.ts +++ b/tests/e2e/media.spec.ts @@ -6,6 +6,7 @@ import type { PlayerHost, PlayerInfo } from '../../src/main/media/player' import { formatClock } from '../../src/shared/media' import type { TabMedia } from '../../src/shared/state' import { FRAME_SLACK, launchOrbit, MOD, type Orbit, state, tabPage, tabView } from './helpers' +import { choosePageMenu, pageMenu } from './nav-support' import { act, activeId, cmd, newTab, tab, tabsHook, waitTab } from './tabs-support' // ---- Local media fixtures (on ports 3330–3349) ---- @@ -1564,25 +1565,12 @@ test('[MD-02] right-clicking a video offers Picture in Picture, which floats it const { app } = orbit const v = await openPlaying(app, '/media/long-video/Film') await expect.poll(async () => (await media(app, v.id))?.video).toBe(true) - // The page's menu is kept for the test instead of being shown. - await app.evaluate(({ Menu }) => { - Menu.prototype.popup = function (this: Electron.Menu) { - ;(globalThis as unknown as { __pageMenu?: Electron.Menu }).__pageMenu = this - } - }) - const menu = () => - app.evaluate(() => { - const m = (globalThis as unknown as { __pageMenu?: Electron.Menu }).__pageMenu - return m ? m.items.map((i) => `${i.label}${i.checked ? ' ✓' : ''}`) : [] - }) - const choose = (label: string) => - app.evaluate((_e, l) => { - const g = globalThis as unknown as { __pageMenu?: Electron.Menu } - const item = g.__pageMenu?.items.find((i) => i.label === l) - g.__pageMenu = undefined - item?.click() - return !!item - }, label) + // The page's menu, which Orbit keeps for the test instead of showing it (NV-12); ✓ marks a ticked item. + const menu = async () => { + const m = await pageMenu(app) + return m ? m.labels.map((label) => `${label}${m.checked[label] ? ' ✓' : ''}`) : [] + } + const choose = (label: string) => choosePageMenu(app, label) const center = await v.page.evaluate(() => { const r = (document.getElementById('v') as HTMLVideoElement).getBoundingClientRect() return { x: r.x + r.width / 2, y: r.y + r.height / 2 } diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index a44dc6e6..2465bce9 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -423,6 +423,76 @@ test('[NV-12] right-clicking a link offers new tab, split view, Peek, Mini Windo await expect(shell.getByTestId('split-pane')).toHaveCount(2) }) +test('[NV-12] Open Link in New Tab with the sidebar hidden says so, as a ⌘-clicked link does (review B-M4)', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Hidden')) + await cmd(app, 'window.setSidebar', { visible: false }) + await expect(shell.getByTestId('sidebar')).toBeHidden() + await clickInPage(app, page('Hidden'), '#next', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + expect(await choosePageMenu(app, 'Open Link in New Tab')).toBe(true) + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(page('Hidden-next')) + await expect(shell.getByTestId('toast').last()).toContainText('Opened in a new tab') + expect(await selectedTab(app)).toBe(id) +}) + +test('[NV-12] right-clicking a video or audio offers Loop and Show Controls, ticked as the element is, before Picture in Picture; each sets the element (review B-M4)', async () => { + const { app } = orbit + const id = await openTab(app, fx.url('/media/Clip')) + const tab = await tabPage(app, '/media/Clip') + // The video has been recorded and has a length and a picture; the tone has loaded. + await expect + .poll(() => + tab.evaluate(() => { + const v = document.getElementById('v') as HTMLVideoElement + const a = document.getElementById('a') as HTMLAudioElement + return v.readyState >= 1 && Number.isFinite(v.duration) && v.videoWidth > 0 && a.readyState >= 1 + }), + ) + .toBe(true) + const elements = () => + tab.evaluate(() => { + const v = document.getElementById('v') as HTMLVideoElement + const a = document.getElementById('a') as HTMLAudioElement + return { video: { loop: v.loop, controls: v.controls }, audio: { loop: a.loop, controls: a.controls } } + }) + /** Right-clicks an element near its top left (clear of a paused video's play button): the menu's first group. */ + const menuOn = async (selector: string) => { + const box = await tab.locator(selector).boundingBox() + if (!box) throw new Error(`no ${selector}`) + await tab.mouse.click(box.x + 16, box.y + 16, { button: 'right' }) + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + const menu = await pageMenu(app) + const labels = menu?.labels ?? [] + const end = labels.indexOf('-') + return { group: end < 0 ? labels : labels.slice(0, end), checked: menu?.checked, enabled: menu?.enabled } + } + + // The video: Loop unticked, Show Controls ticked, then media's Picture in Picture. + let menu = await menuOn('#v') + expect(menu.group).toEqual(['Loop', 'Show Controls', 'Picture in Picture']) + expect(menu.checked).toMatchObject({ Loop: false, 'Show Controls': true }) + expect(menu.enabled).toMatchObject({ Loop: true, 'Show Controls': true }) + expect(await choosePageMenu(app, 'Loop')).toBe(true) + await expect.poll(async () => (await elements()).video).toEqual({ loop: true, controls: true }) + menu = await menuOn('#v') + expect(menu.checked).toMatchObject({ Loop: true, 'Show Controls': true }) + expect(await choosePageMenu(app, 'Show Controls')).toBe(true) + await expect.poll(async () => (await elements()).video).toEqual({ loop: true, controls: false }) + menu = await menuOn('#v') + expect(menu.checked).toMatchObject({ Loop: true, 'Show Controls': false }) + expect(await choosePageMenu(app, 'Loop')).toBe(true) + await expect.poll(async () => (await elements()).video).toEqual({ loop: false, controls: false }) + + // The tone: no Picture in Picture, and its controls stay (without them it would be gone for good). + menu = await menuOn('#a') + expect(menu.group).toEqual(['Loop', 'Show Controls']) + expect(menu.checked).toMatchObject({ Loop: false, 'Show Controls': true }) + expect(menu.enabled).toMatchObject({ Loop: true, 'Show Controls': false }) + expect(await choosePageMenu(app, 'Loop')).toBe(true) + await expect.poll(async () => (await elements()).audio).toEqual({ loop: true, controls: true }) +}) + test('[NV-12] right-clicking the page offers Back, Forward, Reload, Save Page As, Print and View Page Source', async () => { const { app } = orbit const id = await openTab(app, page('Plain')) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 7c5fe984..83db04ad 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -88,6 +88,67 @@ ${open ? `window.open(${JSON.stringify(open)}, 'auto', 'width=400,height=300')` ` } +/** + * A page with a short video (#v, with controls) and a tone (#a, with controls). The video is + * recorded in the page and played from a MediaSource, so it has a length (it can loop) and a + * picture (its controls can be hidden). + */ +function mediaPage(title: string): string { + return ` +${escapeHtml(title)} + + + +` +} + +/** A WAV file: `seconds` of a quiet 440 Hz tone (8-bit mono, 8 kHz). */ +function tone(seconds: number): Buffer { + const rate = 8000 + const samples = Math.round(seconds * rate) + const wav = Buffer.alloc(44 + samples) + wav.write('RIFF', 0) + wav.writeUInt32LE(36 + samples, 4) + wav.write('WAVEfmt ', 8) + wav.writeUInt32LE(16, 16) + wav.writeUInt16LE(1, 20) + wav.writeUInt16LE(1, 22) + wav.writeUInt32LE(rate, 24) + wav.writeUInt32LE(rate, 28) + wav.writeUInt16LE(1, 32) + wav.writeUInt16LE(8, 34) + wav.write('data', 36) + wav.writeUInt32LE(samples, 40) + for (let i = 0; i < samples; i++) wav[44 + i] = 128 + Math.round(16 * Math.sin((2 * Math.PI * 440 * i) / rate)) + return wav +} + /** * Routes: * - `/page/?bg=<css colour>&text=<words>`: a page (see `page()`), optionally in another @@ -99,6 +160,7 @@ ${open ? `window.open(${JSON.stringify(open)}, 'auto', 'width=400,height=300')` * - `/download/<name>`: a small text file sent as a download (Content-Disposition: attachment). * - `/cookies`: a page that sets two cookies for its host, `strict=1` (SameSite=Strict) and * `lax=1` (SameSite=Lax). + * - `/media/<title>`: a page with a video and a tone (see `mediaPage()`); `/tone.wav`: the tone. */ export async function startNavFixtures(): Promise<NavFixtures> { const hits = new Map<string, number>() @@ -158,6 +220,14 @@ export async function startNavFixtures(): Promise<NavFixtures> { }) return res.end('<!doctype html><title>Cookies

Cookies set.

') } + if (kind === 'media') { + res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }) + return res.end(mediaPage(title)) + } + if (kind === 'tone.wav') { + res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'no-store' }) + return res.end(tone(2)) + } if (kind === 'slow') return later(Number(u.searchParams.get('ms') ?? 30_000)) if (kind === 'slow-reload') return count === 1 ? send() : later(30_000) res.writeHead(404, { 'content-type': 'text/plain' }) @@ -221,6 +291,8 @@ export interface PageMenu { tabId: string labels: string[] enabled: Record + /** Whether each tickable item is ticked. */ + checked: Record } /** The page context menu last opened (kept instead of shown in tests), or null. */ From 8236aeec252a1e1f94cb28cf37b4ab5d65c4b292 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:54:21 +0000 Subject: [PATCH 27/69] fix(nav): site choices reach disk on quit; a deleted profile's go (review A-F9, B-F7) - A quit while a background write was on its way wrote nothing: the write had cleared the dirty flag before it landed. The file now counts changes, and a write on quit catches up with whatever hasn't landed. An older background write that lands after it is dropped. This was the NV-06 restart flake. - profiles.delete left the profile's zoom, pop-up hosts, permissions and device grants in orbit-site-prefs.json. SitePrefs.forgetProfile drops them when a profile leaves the state. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/index.ts | 5 ++++ src/main/nav/site-prefs.ts | 57 ++++++++++++++++++++++++++++--------- tests/e2e/nav.spec.ts | 30 ++++++++++++++++++- tests/unit/nav-site.test.ts | 56 ++++++++++++++++++++++++++++++++++-- 4 files changed, 131 insertions(+), 17 deletions(-) diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index a944004c..fbb395eb 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -269,6 +269,11 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { const prefs = new SitePrefs(join(ctx.paths.userData, 'orbit-site-prefs.json')) prefs.load() app.on('before-quit', () => prefs.flush()) + // A deleted profile's site choices go with its data (review B-F7). + store.subscribe((next, prev) => { + if (next.profiles === prev.profiles) return + for (const id of Object.keys(prev.profiles)) if (!next.profiles[id]) prefs.forgetProfile(id) + }) commands.register('nav.copyHint', (_args, c) => { if (prefs.hintShown('copy-url')) return const action = ACTIONS_BY_ID['tab.copyUrl'] diff --git a/src/main/nav/site-prefs.ts b/src/main/nav/site-prefs.ts index fb164a3f..32166c4b 100644 --- a/src/main/nav/site-prefs.ts +++ b/src/main/nav/site-prefs.ts @@ -1,5 +1,5 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { mkdir, rename, writeFile } from 'node:fs/promises' +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { sameZoom, ZOOM_STEPS } from '../../shared/nav' import { isPrivateProfileId } from '../../shared/private' @@ -46,7 +46,10 @@ export class SitePrefs { /** Choices made in private windows: never written, gone with the private session. */ private ephemeral: SiteChoices = emptyChoices() private timer: NodeJS.Timeout | undefined - private dirty = false + /** Counts changes; `saved` is the count the file on disk has caught up with. */ + private changes = 0 + private saved = 0 + private writing: Promise = Promise.resolve() constructor(private file: string) {} @@ -217,6 +220,15 @@ export class SitePrefs { this.ephemeral = emptyChoices() } + /** Forgets a deleted profile's choices: zoom, pop-up hosts, permissions and devices (review B-F7). */ + forgetProfile(profileId: string): void { + const choices = this.choices(profileId) + const kept = [choices.zoom, choices.popups, choices.permissions, choices.devices].filter((map) => profileId in map) + if (kept.length === 0) return + for (const map of kept) delete map[profileId] + this.changed(profileId) + } + private choices(profileId: string): SiteChoices { return isPrivateProfileId(profileId) ? this.ephemeral : this.data } @@ -225,34 +237,53 @@ export class SitePrefs { if (!isPrivateProfileId(profileId)) this.schedule() } - /** Writes pending changes now (quit). */ + /** + * Writes what the file hasn't caught up with now (quit), including changes a write still on its + * way holds: the app may be gone before that write lands (review A-F9). + */ flush(): void { - if (!this.dirty) return clearTimeout(this.timer) this.timer = undefined - this.dirty = false + if (this.saved >= this.changes) return + const upTo = this.changes try { mkdirSync(dirname(this.file), { recursive: true }) const tmp = `${this.file}.tmp` writeFileSync(tmp, JSON.stringify(this.data)) renameSync(tmp, this.file) + this.saved = upTo } catch (err) { console.error('[site-prefs] could not save', err) } } + /** Resolves once the background writes started so far have landed, or given way to a newer one (tests). */ + written(): Promise { + return this.writing + } + private schedule(): void { - this.dirty = true + this.changes++ if (this.timer) return this.timer = setTimeout(() => { this.timer = undefined - if (!this.dirty) return - this.dirty = false - const tmp = `${this.file}.tmp` - void mkdir(dirname(this.file), { recursive: true }) - .then(() => writeFile(tmp, JSON.stringify(this.data))) - .then(() => rename(tmp, this.file)) + const upTo = this.changes + if (this.saved >= upTo) return + const data = JSON.stringify(this.data) + // A file of its own: a write on quit, or a later one, may run while this one is on its way. + const tmp = `${this.file}.${upTo}.tmp` + const write = mkdir(dirname(this.file), { recursive: true }) + .then(() => writeFile(tmp, data)) + .then(() => { + // In the same tick as the check: something newer on disk (a write on quit) is never undone. + if (this.saved >= upTo) rmSync(tmp, { force: true }) + else { + renameSync(tmp, this.file) + this.saved = upTo + } + }) .catch((err) => console.error('[site-prefs] could not save', err)) + this.writing = Promise.all([this.writing, write]).then(() => undefined) }, 500) this.timer.unref?.() } diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index c16a24f6..267a5db9 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, type Page, test } from '@playwright/test' @@ -477,6 +477,34 @@ test('[NV-06] zoom is remembered per site and profile across a restart', async ( expect(await zoomOf(app, '/page/KeepWorkAgain')).toBeCloseTo(1) }) +test('[NV-06] deleting a profile forgets its sites’ zoom; the other profiles keep theirs (review B-F7)', async () => { + const userData = mkdtempSync(join(tmpdir(), 'orbit-e2e-forget-')) + await orbit.app.close() + orbit = await launchOrbit({ userData }) + const { app } = orbit + const personal = (await windowState(app)).spaceId + const kept = (await state(app)).spaces[personal]?.profileId + await openTab(app, page('Stay')) + await pressInPage(app, '/page/Stay', '=', [mod]) + await expect.poll(() => zoomOf(app, '/page/Stay')).toBeCloseTo(1.1) + const profileId = await cmd(app, 'profiles.create', { name: 'Work' }) + const work = await cmd(app, 'spaces.create', { name: 'Work', profileId }) + await expect.poll(async () => (await windowState(app)).spaceId).toBe(work) + await openTab(app, page('Gone')) + await pressInPage(app, '/page/Gone', '=', [mod]) + await expect.poll(() => zoomOf(app, '/page/Gone')).toBeCloseTo(1.1) + // Its Space goes, then the profile. + await cmd(app, 'spaces.switch', { spaceId: personal }) + expect(await cmd(app, 'spaces.delete', { spaceId: work })).toBe(true) + expect(await cmd(app, 'profiles.delete', { profileId })).toBe(true) + await app.close() + const saved = JSON.parse(readFileSync(join(userData, 'orbit-site-prefs.json'), 'utf8')) as { + zoom: Record> + } + expect(Object.keys(saved.zoom)).toEqual([kept]) + orbit = await launchOrbit({ userData }) +}) + test('[NV-06] ⌘0 also undoes a pinch zoom', async () => { const { app } = orbit await openTab(app, page('Pinch')) diff --git a/tests/unit/nav-site.test.ts b/tests/unit/nav-site.test.ts index ebac4261..3e9c46c6 100644 --- a/tests/unit/nav-site.test.ts +++ b/tests/unit/nav-site.test.ts @@ -1,7 +1,7 @@ -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' import { SitePrefs } from '../../src/main/nav/site-prefs' import { makeTab } from '../../src/main/tabs/model' import { createInitialState, createWindowState, DEFAULT_PROFILE_ID } from '../../src/shared/defaults' @@ -172,6 +172,56 @@ describe('site choices on disk (NV-15, NV-16)', () => { expect(prefs.devicesFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual([]) }) + it('writes on quit what a background write still on its way holds, and that write never undoes it (review A-F9)', async () => { + const file = fileIn() + const zoomOnDisk = () => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')).zoom : undefined) + vi.useFakeTimers() + let prefs: SitePrefs + try { + prefs = new SitePrefs(file) + prefs.setZoom(DEFAULT_PROFILE_ID, 'a.test', 1.1) + // Half a second on, the background write starts; the app quits before it lands. + vi.advanceTimersByTime(500) + prefs.flush() + expect(zoomOnDisk()).toEqual({ [DEFAULT_PROFILE_ID]: { 'a.test': 1.1 } }) + // A change while the next write is on its way, then quit: the older write lands after the quit's. + prefs.setZoom(DEFAULT_PROFILE_ID, 'a.test', 1.25) + vi.advanceTimersByTime(500) + prefs.setZoom(DEFAULT_PROFILE_ID, 'a.test', 1.5) + prefs.flush() + } finally { + vi.useRealTimers() + } + await prefs.written() + expect(zoomOnDisk()).toEqual({ [DEFAULT_PROFILE_ID]: { 'a.test': 1.5 } }) + expect(readdirSync(dirname(file))).toEqual(['site-prefs.json']) + }) + + it('forgets a deleted profile’s choices and keeps the others’ (review B-F7)', () => { + const file = fileIn() + const prefs = new SitePrefs(file) + const board = { type: 'usb' as const, key: 'usb:1:2:A', name: 'Board' } + for (const profile of ['work', DEFAULT_PROFILE_ID]) { + prefs.setZoom(profile, 'a.test', 1.25) + prefs.setPopupsAllowed(profile, 'a.test', true) + prefs.setPermission(profile, 'https://a.test', 'camera', 'allow') + prefs.grantDevice(profile, 'https://a.test', board) + } + prefs.forgetProfile('work') + expect(prefs.zoomFor('work', 'a.test')).toBeUndefined() + expect(prefs.popupsAllowed('work', 'a.test')).toBe(false) + expect(prefs.permissionsFor('work', 'https://a.test')).toEqual({}) + expect(prefs.devicesFor('work', 'https://a.test')).toEqual([]) + expect(prefs.zoomFor(DEFAULT_PROFILE_ID, 'a.test')).toBe(1.25) + expect(prefs.popupsAllowed(DEFAULT_PROFILE_ID, 'a.test')).toBe(true) + expect(prefs.permissionsFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual({ camera: 'allow' }) + expect(prefs.devicesFor(DEFAULT_PROFILE_ID, 'https://a.test')).toEqual([board]) + prefs.flush() + const saved = JSON.parse(readFileSync(file, 'utf8')) + for (const key of ['zoom', 'popups', 'permissions', 'devices']) + expect(Object.keys(saved[key])).toEqual([DEFAULT_PROFILE_ID]) + }) + it('drops what it doesn’t understand when loading', () => { const file = fileIn() writeFileSync( From b179d7733c5640072e9733811ef47b1083c242d0 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:55:53 +0000 Subject: [PATCH 28/69] =?UTF-8?q?refactor(tabs):=20drop=20the=20dead=20?= =?UTF-8?q?=C2=B10.5=20zoom=20handlers=20(review=20A-F7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page-tools registers page.zoomIn/Out/Reset with Chromium's steps; the tabs lane's older handlers were only replaced because they registered first. A unit test now checks that no action is registered twice. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/index.ts | 2 +- src/main/tabs/commands.ts | 14 +------------- tests/unit/actions.test.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index fbb395eb..a588ef01 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -286,7 +286,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // The hidden sidebar's hover reveal (SB-12, FL-03). const reveal = registerSidebarReveal(ctx) registerPageColor(ctx) - // Find in page and page zoom (NV-05, NV-06). Registered after the tabs lane, so its zoom actions are replaced. + // Find in page and page zoom (NV-05, NV-06). const pageTools = registerPageTools(ctx, prefs) // Private windows (NV-11). const privateWindows: PrivateWindows = registerPrivate(ctx, ops, prefs) diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 86985158..7a09a26d 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -729,24 +729,12 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM send(windowId, 'sidebar:focus', {}) }) - // Page actions on the active tab. Back, Forward, Reload and Stop live in src/main/nav/. + // Page actions on the active tab. Back, Forward, Reload, Stop and zoom live in src/main/nav/. const onContents = (fn: (contents: Electron.WebContents) => void) => withActive((tabId) => { const contents = tabs.contents(tabId) if (contents) fn(contents) }) - actions.register( - 'page.zoomIn', - onContents((w) => w.setZoomLevel(Math.min(w.getZoomLevel() + 0.5, 5))), - ) - actions.register( - 'page.zoomOut', - onContents((w) => w.setZoomLevel(Math.max(w.getZoomLevel() - 0.5, -5))), - ) - actions.register( - 'page.zoomReset', - onContents((w) => w.setZoomLevel(0)), - ) actions.register( 'page.print', onContents((w) => w.print()), diff --git a/tests/unit/actions.test.ts b/tests/unit/actions.test.ts index d7e5a2ae..6f493a71 100644 --- a/tests/unit/actions.test.ts +++ b/tests/unit/actions.test.ts @@ -1,3 +1,5 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { ACTIONS, @@ -125,3 +127,18 @@ describe('keymap', () => { expect(formatAccelerator('Ctrl+Shift+T', 'linux')).toBe('Ctrl+Shift+T') }) }) + +describe('action handlers', () => { + it('registers each action once: a later handler would silently replace the first (review A-F7)', () => { + const counts = new Map() + const main = join(__dirname, '../../src/main') + for (const file of readdirSync(main, { recursive: true, encoding: 'utf8' })) { + if (!file.endsWith('.ts')) continue + for (const [, id] of readFileSync(join(main, file), 'utf8').matchAll(/actions\.register\(\s*'([^']+)'/g)) { + counts.set(id as string, (counts.get(id as string) ?? 0) + 1) + } + } + expect(counts.size).toBeGreaterThan(50) + expect([...counts].filter(([, n]) => n > 1).map(([id]) => id)).toEqual([]) + }) +}) From 4a7b5035720fec40d191dac3a4122b2785f1fa25 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:59:55 +0000 Subject: [PATCH 29/69] fix(nav): zoom in a Peek or Mini Window takes the tab steps and is kept (review A-F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Peek's and Mini Window's page actions zoomed by half a level (109.5%, then 120%), showed nothing and weren't remembered, and the site's tabs followed to those odd steps. They now go through page zoom: Chromium's steps, the pinch reset on ⌘0 and the per-site, per-profile memory. A Peek says the new zoom in a toast; a Mini Window has no toast to show. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/page-tools.ts | 19 +++++++++++++------ src/main/peek/page-actions.ts | 28 ++++++++++++++++++++++------ tests/e2e/nav.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts index 01bc21d5..32018e22 100644 --- a/src/main/nav/page-tools.ts +++ b/src/main/nav/page-tools.ts @@ -11,6 +11,7 @@ import { } from '../../shared/nav' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { setPageZoom } from '../peek/page-actions' import type { CallContext } from '../registry' import { activeTabOf } from '../windows' import { NAV_WORLD } from './focus' @@ -341,17 +342,23 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( .catch(() => {}) } - /** Zooms a tab one step in (1) or out (-1), or back to 100% (0), and shows the indicator in its window. */ - const zoom = (windowId: WindowId, tabId: TabId, direction: 1 | -1 | 0) => { - const contents = pageOf(tabId) - if (!contents) return + /** Zooms a page one step in (1) or out (-1), or back to 100% (0), and keeps it for the site. Returns the new factor. */ + const zoomContents = (contents: WebContents, tabId: TabId | undefined, direction: 1 | -1 | 0): number => { const before = contents.getZoomFactor() const next = direction === 0 ? 1 : zoomStep(before, direction) if (!sameZoom(before, next)) contents.setZoomFactor(next) if (direction === 0) resetPinch(contents) - remember(tabId, contents.getURL(), next) - showZoom(windowId, next) + if (tabId) remember(tabId, contents.getURL(), next) + return next + } + + /** Zooms a tab and shows the indicator in its window. */ + const zoom = (windowId: WindowId, tabId: TabId, direction: 1 | -1 | 0) => { + const contents = pageOf(tabId) + if (contents) showZoom(windowId, zoomContents(contents, tabId, direction)) } + // A Peek's page and a Mini Window's zoom the same way, and are remembered too (review A-F4). + setPageZoom((contents, direction) => zoomContents(contents, tabs.tabIdFor(contents), direction)) const zoomAction = (direction: 1 | -1 | 0) => (c: CallContext) => { const windowId = windowOf(c) diff --git a/src/main/peek/page-actions.ts b/src/main/peek/page-actions.ts index 541c5b37..a26a0dcd 100644 --- a/src/main/peek/page-actions.ts +++ b/src/main/peek/page-actions.ts @@ -1,7 +1,24 @@ import { clipboard, type WebContents } from 'electron' +import { sameZoom, zoomPercent, zoomStep } from '../../shared/nav' import { cleanUrl, markdownLink } from '../../shared/tabs' import { fallbackTitle } from '../../shared/url' +/** 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 + +/** Chromium's zoom steps (NV-06), until page zoom puts in its own, which also remembers the site's zoom. */ +let pageZoom: PageZoom = (contents, direction) => { + const before = contents.getZoomFactor() + const next = direction === 0 ? 1 : zoomStep(before, direction) + if (!sameZoom(before, next)) contents.setZoomFactor(next) + return next +} + +/** Page zoom (NV-06) zooms a Peek's or a Mini Window's page as it zooms a tab's (review A-F4). */ +export function setPageZoom(zoom: PageZoom): void { + pageZoom = zoom +} + /** * Page-level actions run on a given page: the Peek's while it is open (PK-11) and a Mini Window's * (MW-03). Returns false for actions that are not about the page. @@ -24,15 +41,14 @@ export function runPageAction(id: string, contents: WebContents, toast: (message case 'nav.stop': contents.stop() return true + // Each change says the new zoom, as the tab's indicator does (NV-06). case 'page.zoomIn': - contents.setZoomLevel(Math.min(contents.getZoomLevel() + 0.5, 5)) - return true case 'page.zoomOut': - contents.setZoomLevel(Math.max(contents.getZoomLevel() - 0.5, -5)) - return true - case 'page.zoomReset': - contents.setZoomLevel(0) + case 'page.zoomReset': { + const factor = pageZoom(contents, id === 'page.zoomIn' ? 1 : id === 'page.zoomOut' ? -1 : 0) + toast(`Zoom ${zoomPercent(factor)}%`) return true + } case 'page.print': contents.print() return true diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index 267a5db9..b900ed90 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -411,6 +411,26 @@ test('[NV-05][KS-08] Jump to Selection (⌘J on macOS) scrolls the page back to // ---------- Page zoom (NV-06) ---------- +test('[NV-06][PK-11] zoom in a Peek takes the tab steps and says the new zoom; the site’s tabs follow and keep it (review A-F4)', async () => { + const { app, shell } = orbit + await openTab(app, page('Under')) + const peekUrl = page('PeekZoom') + const peekTab = await cmd(app, 'peek.open', { url: peekUrl }) + await waitLoaded(app, peekTab, peekUrl) + await expect.poll(() => focusedUrl(app)).toContain('/page/PeekZoom') + await pressInPage(app, '/page/PeekZoom', '=', [mod]) + await pressInPage(app, '/page/PeekZoom', '=', [mod]) + await expect.poll(() => zoomOf(app, '/page/PeekZoom')).toBeCloseTo(1.25, 3) + await expect(shell.getByTestId('toast').last()).toHaveText('Zoom 125%') + expect((await windowState(app)).overlay.kind).toBe('peek') + // The tab under the Peek is on the same site: it follows. + expect(await zoomOf(app, '/page/Under')).toBeCloseTo(1.25, 3) + // Kept for the site: a new tab on it opens at that zoom. + await openTab(app, page('AfterPeek')) + await expect.poll(() => zoomOf(app, '/page/AfterPeek')).toBeCloseTo(1.25, 3) + expect(await zoomOf(app, '/page/Under')).toBeCloseTo(1.25, 3) +}) + test('[NV-06][KS-08] ⌘+ and ⌘− step through the zoom levels with an indicator; the site’s other tabs follow; ⌘0 resets', async () => { const { app } = orbit const first = await openTab(app, page('ZoomA')) From 740dae52497bfe146705bf960054dd977ce45a44 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:04:15 +0000 Subject: [PATCH 30/69] fix(core): editing the address starts from where the page is going (review A-F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a navigation Orbit started, the address shows the pending URL, but ⌘L and a click on the host pre-filled the committed one. They now pre-fill what the address shows. Co-Authored-By: Claude Opus 5.5 --- src/main/core.ts | 5 +++-- tests/e2e/chrome.spec.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/core.ts b/src/main/core.ts index 737e53fb..85d3ea89 100644 --- a/src/main/core.ts +++ b/src/main/core.ts @@ -64,8 +64,9 @@ export function registerCore(ctx: MainContext): void { setOverlay(windowId, { kind: 'none' }) return } - const tabId = activeTabOf(store.state, windowId) - const url = tabId ? (store.state.tabs[tabId]?.url ?? '') : '' + const tab = store.state.tabs[activeTabOf(store.state, windowId) ?? ''] + // What the address shows: where a navigation Orbit started is going, before it commits (NV-04; review A-F3). + const url = tab ? (tab.pendingUrl ?? tab.url ?? '') : '' // CB-23: with no current page, ⌘L behaves like ⌘T. const effective = mode === 'edit-url' && (!url || url === 'about:blank') ? 'new-tab' : mode ctx.perf.mark('commandbar:open:start') diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 30df204f..e8e4d14b 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -283,6 +283,20 @@ test('[SB-04] the address shows only the host; clicking it edits the full URL in expect((await windowState(app)).overlay).toMatchObject({ kind: 'commandbar', mode: 'edit-url' }) }) +test('[SB-04][NV-04] while a page is on its way, editing the address starts from where it is going, as the address shows (review A-F3)', async () => { + const { app, shell } = orbit + const id = await openTab(app, page('Before')) + const going = fx.url('/slow/After?ms=30000') + await cmd(app, 'tabs.navigate', { tabId: id, url: going }) + const pill = shell.getByTestId('address-pill') + await expect(pill).toHaveAttribute('title', going) + await pill.click() + const input = (await viewPage(app, 'overlay')).getByTestId('command-input') + await expect(input).toBeFocused() + await expect(input).toHaveValue(going) + expect((await state(app)).tabs[id]?.url).toBe(page('Before')) +}) + test('[SB-04] no padlock on pages; plain http to another machine shows a warning', async () => { const { app, shell } = orbit const address = shell.getByTestId('address') From f518a219ed429f3540494748cb9ec78481e826a1 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:04:15 +0000 Subject: [PATCH 31/69] fix(chrome): an address without a host says what it is (review A-F2) The address pill was blank on view-source: and data: pages. It now shows a page's source as view-source: and the page's host, a blob as the host of the page that made it, a data: page by its type (never its content), and anything else without a host as the address, shortened. Co-Authored-By: Claude Opus 5.5 --- src/shared/url.ts | 15 +++++++++++++-- tests/e2e/chrome.spec.ts | 9 +++++++++ tests/unit/url.test.ts | 12 ++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/shared/url.ts b/src/shared/url.ts index d909e7f5..b2178a2a 100644 --- a/src/shared/url.ts +++ b/src/shared/url.ts @@ -119,13 +119,24 @@ export function parseInput(raw: string, engine: SearchEngineId = 'google'): Pars return { kind: 'search', query: text, url: searchUrl(engine, text) } } -/** Short, human display of a URL for the sidebar address pill: host without www. */ +/** How much of an address without a host the address pill shows. */ +const HOSTLESS_MAX = 40 + +/** + * Short, human display of a URL for the sidebar address pill: host without www. Addresses without + * a host say what they are (review A-F2): a page's source as the page's host, a blob as the host of + * the page that made it, a data: page by its type, never its content. + */ export function displayHost(url: string): string { try { const u = new URL(url) if (u.protocol === 'about:') return url if (u.protocol === 'file:') return decodeURIComponent(u.pathname.split('/').pop() || 'file') - return u.host.replace(/^www\./, '') + if (u.protocol === 'view-source:') return `view-source:${displayHost(url.slice(u.protocol.length))}` + if (u.protocol === 'blob:') return u.origin === 'null' ? 'blob:' : displayHost(u.origin) + if (u.protocol === 'data:') return `data:${u.pathname.split(/[;,]/)[0]?.trim().toLowerCase() || 'text/plain'}` + if (u.host) return u.host.replace(/^www\./, '') + return url.length > HOSTLESS_MAX ? `${url.slice(0, HOSTLESS_MAX - 1)}…` : url } catch { return url } diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index e8e4d14b..46491749 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -297,6 +297,15 @@ test('[SB-04][NV-04] while a page is on its way, editing the address starts from expect((await state(app)).tabs[id]?.url).toBe(page('Before')) }) +test('[SB-04][NV-12] an address without a host says what it is: a page’s source, a data: page (review A-F2)', async () => { + const { app, shell } = orbit + const pill = shell.getByTestId('address-pill') + await cmd(app, 'tabs.create', { url: `view-source:${page('Source')}` }) + await expect(pill).toHaveText(`view-source:localhost:${fx.port}`) + await cmd(app, 'tabs.create', { url: 'data:text/html,DataHello' }) + await expect(pill).toHaveText('data:text/html') +}) + test('[SB-04] no padlock on pages; plain http to another machine shows a warning', async () => { const { app, shell } = orbit const address = shell.getByTestId('address') diff --git a/tests/unit/url.test.ts b/tests/unit/url.test.ts index 54253836..bf558288 100644 --- a/tests/unit/url.test.ts +++ b/tests/unit/url.test.ts @@ -32,6 +32,18 @@ describe('displayHost / siteOf', () => { expect(displayHost('https://www.github.com/orbit')).toBe('github.com') expect(displayHost('http://localhost:3000/x')).toBe('localhost:3000') }) + it('says what an address without a host is (review A-F2)', () => { + expect(displayHost('view-source:http://localhost:3000/page/X')).toBe('view-source:localhost:3000') + expect(displayHost('view-source:https://www.github.com/orbit')).toBe('view-source:github.com') + expect(displayHost('blob:https://www.example.com/0b6c1d2e-5f00-4c1b-9a2e-3d4e5f607182')).toBe('example.com') + expect(displayHost('data:text/html;charset=utf-8,

Hello

')).toBe('data:text/html') + expect(displayHost('data:IMAGE/PNG;base64,iVBORw0KGgo=')).toBe('data:image/png') + expect(displayHost('data:,Hello')).toBe('data:text/plain') + expect(displayHost('about:blank')).toBe('about:blank') + expect(displayHost('file:///tmp/a%20b.html')).toBe('a b.html') + expect(displayHost('mailto:someone@example.com')).toBe('mailto:someone@example.com') + expect(displayHost(`mailto:${'a'.repeat(60)}@example.com`)).toBe(`mailto:${'a'.repeat(32)}…`) + }) it('finds the registrable site', () => { expect(siteOf('https://mail.google.com/a')).toBe('google.com') expect(siteOf('https://news.bbc.co.uk/')).toBe('bbc.co.uk') From fba58b926f83d3bc2cb89703b1d7c088174ca180 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:07:26 +0000 Subject: [PATCH 32/69] test(chrome): make the SB-11 scroll and SB-28 slow-page tests deterministic (review A-F9) - SB-11 set the sidebar's scroll position while 24 to 30 animations were still running there (rows growing in, the new width), with the list's height still moving. It now waits for the width and for the rows to settle first. - SB-28's slow page arrived after 1.5 s, which a loaded machine could reach before the indicator was read ('done' for 'loading'). The page now waits until the test releases it. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/chrome.spec.ts | 6 ++++++ tests/e2e/nav-page.spec.ts | 7 +++++-- tests/e2e/nav-support.ts | 21 +++++++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 46491749..8f879208 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -28,6 +28,7 @@ import { waitLoaded, windowState, } from './nav-support' +import { settled } from './tabs-support' let fx: NavFixtures let orbit: Orbit @@ -467,9 +468,14 @@ test('[SB-11] ⌘S hides the sidebar and the page card takes the window; again b await cmd(app, 'window.setSidebar', { width: 300 }) const scroll = shell.getByTestId('sidebar-scroll') await expect.poll(() => scroll.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(200) + // Scrolled once the rows have grown in and the sidebar has its new width: rows still growing + // above the visible ones would move the scroll position (review A-F9). + await expect.poll(async () => (await shell.getByTestId('sidebar').boundingBox())?.width).toBe(300) + await settled(shell) await scroll.evaluate((el) => { el.scrollTop = 120 }) + expect(await scroll.evaluate((el) => el.scrollTop)).toBe(120) const size = await contentSize() await shell.keyboard.press(`${MOD}+S`) diff --git a/tests/e2e/nav-page.spec.ts b/tests/e2e/nav-page.spec.ts index cccd2a50..cc8510ac 100644 --- a/tests/e2e/nav-page.spec.ts +++ b/tests/e2e/nav-page.spec.ts @@ -176,7 +176,9 @@ test('[SB-28][FL-23] a slow page shows the loading indicator at the top centre o const { app } = orbit const id = await openTab(app, page('Start')) const before = (await loading(app)).shown - await cmd(app, 'tabs.navigate', { tabId: id, url: fx.url('/slow/Slowly?ms=1500') }) + // The page comes when the test lets it, however slow the machine (review A-F9). + const slow = fx.url('/slow/Slowly') + await cmd(app, 'tabs.navigate', { tabId: id, url: slow }) await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(true) // Out after the page has been loading a little while, for this tab. await expect.poll(async () => (await loading(app)).visible).toBe(true) @@ -197,7 +199,8 @@ test('[SB-28][FL-23] a slow page shows the loading indicator at the top centre o 'loading-sweep', ) // The page arrives: the pill finishes, then its view goes. - await waitLoaded(app, id, fx.url('/slow/Slowly?ms=1500')) + fx.release('/slow/Slowly') + await waitLoaded(app, id, slow) await expect.poll(async () => (await loading(app)).visible, { timeout: 2000 }).toBe(false) }) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 83db04ad..f2861709 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -32,6 +32,8 @@ export interface NavFixtures { urlOn: (host: string, path: string) => string /** How many requests a path has had. */ hits: (path: string) => number + /** Sends the pages a `/slow/` path is holding back now. */ + release: (path: string) => void /** The Cache-Control header of the latest request for a path (a hard reload sends no-cache). */ cacheControl: (path: string) => string | undefined /** What the latest request for a path said about where it came from, or undefined before one. */ @@ -153,7 +155,8 @@ function tone(seconds: number): Buffer { * Routes: * - `/page/?bg=<css colour>&text=<words>`: a page (see `page()`), optionally in another * background and with some text. - * - `/slow/<title>?ms=N`: the page after N ms (default 30 s): keeps a tab loading. + * - `/slow/<title>?ms=N`: the page after N ms (default 30 s), or when the test releases it: keeps + * a tab loading. * - `/slow-reload/<title>`: the page at once on the first request, then slowly (30 s) on reloads. * - `/page/<title>?open=<path>`: the page, opening `<path>` in a pop-up as it loads (no click). * - `/page/<title>?away=<url>`: the page with a link (#away) to `<url>`. @@ -167,6 +170,8 @@ export async function startNavFixtures(): Promise<NavFixtures> { const cacheControl = new Map<string, string | undefined>() const sentFrom = new Map<string, SentFrom>() const pending = new Set<ServerResponse>() + /** The held-back pages per path, to send early (`release`). */ + const held = new Map<string, Set<() => void>>() const sockets = new Set<Socket>() const server = createServer((req, res) => { const u = new URL(req.url ?? '/', 'http://localhost') @@ -197,9 +202,18 @@ export async function startNavFixtures(): Promise<NavFixtures> { } const later = (ms: number) => { pending.add(res) - const timer = setTimeout(send, ms) + const waiting = held.get(u.pathname) ?? new Set<() => void>() + held.set(u.pathname, waiting) + const done = () => { + clearTimeout(timer) + waiting.delete(done) + send() + } + const timer = setTimeout(done, ms) + waiting.add(done) res.on('close', () => { clearTimeout(timer) + waiting.delete(done) pending.delete(res) }) } @@ -253,6 +267,9 @@ export async function startNavFixtures(): Promise<NavFixtures> { url: (path) => `http://localhost:${port}${path}`, urlOn: (host, path) => `http://${host}:${port}${path}`, hits: (path) => hits.get(path) ?? 0, + release: (path) => { + for (const done of [...(held.get(path) ?? [])]) done() + }, cacheControl: (path) => cacheControl.get(path), sentFrom: (path) => sentFrom.get(path), close: () => From 0f9418c976f573cea7140f2102634fc10835cf6d Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:15:23 +0000 Subject: [PATCH 33/69] fix(nav): the plain permission dialog: Esc keeps nothing, one per request, over its page (review B-F2) Peek, Mini Window and pop-up pages ask in a native dialog. Esc or closing it stored Block for good; it is now Not Now, which keeps nothing, as the sidebar prompt's dismiss. The same request again waits for the dialog on screen instead of stacking another. The dialog goes over the page's own window, not whichever window had the focus. Under ORBIT_E2E the dialog waits for the test to answer it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/index.ts | 8 ++- src/main/nav/permissions.ts | 108 ++++++++++++++++++++++++++++++++---- tests/e2e/nav-site.spec.ts | 75 +++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 13 deletions(-) diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index a588ef01..63448be5 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -17,7 +17,7 @@ import { registerPageColor } from './page-color' import { type PageMenuTestHooks, pageMenuTestHooks, setLinkOpeners } from './page-menu' import { type PageToolsInfo, registerPageTools } from './page-tools' import { registerPendingUrl } from './pending-url' -import { registerPermissions } from './permissions' +import { type PermissionDialogInfo, registerPermissions } from './permissions' import { type PrivateWindows, registerPrivate } from './private' import { type RevealHoldInfo, registerSidebarReveal } from './sidebar-reveal' import { registerSiteControls } from './site-controls' @@ -33,6 +33,10 @@ export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { tools: (windowId?: WindowId) => PageToolsInfo /** A window's permission prompt view (default: the first): placed while the sidebar is hidden (NV-15). */ prompt: (windowId?: WindowId) => { placed: boolean; bounds?: { x: number; y: number; width: number; height: number } } + /** The plain permission dialogs open for pages without a sidebar (Peek, Mini Window, pop-up), oldest first. */ + permissionDialogs: () => PermissionDialogInfo[] + /** Answers the oldest permission dialog with a button's label, or `cancel` (Esc). */ + answerPermissionDialog: (button: string) => boolean /** The corner radius of a shown page's card (0 without a frame: full screen) (SB-19). */ corner: (tabId: TabId) => number | null /** The floating sidebar of a window (default: the first): its view, and what keeps it out (SB-12). */ @@ -367,6 +371,8 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { corner: (tabId) => windows.cornerOf(tabId) ?? null, tools: (windowId) => pageTools.info(windowId ?? windows.ids()[0] ?? ''), prompt: (windowId) => permissions.info(windowId ?? windows.ids()[0] ?? ''), + permissionDialogs: () => permissions.dialogs(), + answerPermissionDialog: (button) => permissions.answerDialog(button), floating: (windowId) => { const id = windowId ?? windows.ids()[0] ?? '' return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index 1dbf96a2..aca2f8ab 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -5,6 +5,7 @@ import { type DesktopCapturerSource, desktopCapturer, dialog, + type MessageBoxOptions, type Rectangle, type WebContents, } from 'electron' @@ -83,8 +84,32 @@ export interface Permissions { pageState(profileId: ProfileId, origin: string, kinds: PermissionKind[]): PagePermissionState /** Test hooks. */ info(windowId: WindowId): { placed: boolean; bounds?: Rectangle } + /** The plain dialogs asking for pages without a sidebar, oldest first (ORBIT_E2E: they wait for `answerDialog`). */ + dialogs(): PermissionDialogInfo[] + /** Answers the oldest dialog with one of its buttons, or `cancel` (Esc, or closing it). */ + answerDialog(button: string): boolean } +/** A plain permission dialog as tests see it. */ +export interface PermissionDialogInfo { + message: string + detail: string + buttons: string[] + /** The id of the window it is over (`BrowserWindow.id`), or null for none. */ + parent: number | null + /** How many of the page's requests wait for its answer (the same request asked again shares it). */ + requests: number +} + +/** + * The plain dialog's buttons. Esc or closing it is Not Now: nothing is kept and the site may ask + * again, as with the sidebar prompt's dismiss (review B-F2). + */ +const DIALOG_BUTTONS = ['Allow', 'Block', 'Not Now'] +const DIALOG_ALLOW = 0 +const DIALOG_BLOCK = 1 +const DIALOG_NOT_NOW = 2 + /** * Permission prompts (NV-15). A site asking for the camera, microphone, location, notifications, * clipboard, MIDI, device use, a device (serial, USB, HID) or a screen to share gets a small prompt @@ -281,7 +306,42 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss enqueue(pending) } - /** Pages without a sidebar (pop-up windows, Mini Windows, Peek): a plain dialog asks instead. */ + /** Dialogs on screen, by page and request: the same request again waits for the same answer. */ + const dialogsOpen = new Map<string, ((granted: boolean) => void)[]>() + /** Under ORBIT_E2E the dialogs wait for the test to answer (a real one would wait for a person). */ + const testDialogs: (PermissionDialogInfo & { cancelId: number; answer: (response: number) => void })[] = [] + + const showDialog = ( + parent: BrowserWindow | null, + options: MessageBoxOptions, + callbacks: unknown[], + ): Promise<number> => { + if (ctx.e2e) { + return new Promise((resolve) => { + const entry = { + message: options.message, + detail: options.detail ?? '', + buttons: options.buttons ?? [], + parent: parent?.id ?? null, + cancelId: options.cancelId ?? 0, + get requests() { + return callbacks.length + }, + answer: (response: number) => { + testDialogs.splice(testDialogs.indexOf(entry), 1) + resolve(response) + }, + } + testDialogs.push(entry) + }) + } + return (parent ? dialog.showMessageBox(parent, options) : dialog.showMessageBox(options)).then((r) => r.response) + } + + /** + * Pages without a sidebar (pop-up windows, Mini Windows, Peek): a plain dialog asks instead, over + * the page's own window (review B-F2). + */ const askInDialog = ( profileId: ProfileId, contents: WebContents, @@ -289,20 +349,29 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss kinds: PermissionKind[], callback: (granted: boolean) => void, ) => { - if (ctx.e2e) return callback(false) - const parent = BrowserWindow.getFocusedWindow() - const options = { - type: 'question' as const, - buttons: ['Allow', 'Block'], - defaultId: 1, - cancelId: 1, + const key = `${contents.id}|${origin}|${kinds.join(',')}` + const waiting = dialogsOpen.get(key) + if (waiting) { + waiting.push(callback) + return + } + const callbacks = [callback] + dialogsOpen.set(key, callbacks) + const own = BrowserWindow.fromWebContents(contents) + const parent = own && !own.isDestroyed() ? own : BrowserWindow.getFocusedWindow() + const options: MessageBoxOptions = { + type: 'question', + buttons: DIALOG_BUTTONS, + defaultId: DIALOG_BLOCK, + cancelId: DIALOG_NOT_NOW, message: `${siteLabel(origin)} wants to:`, detail: kinds.map((k) => PERMISSION_ASK[k]).join('\n'), } - void (parent ? dialog.showMessageBox(parent, options) : dialog.showMessageBox(options)).then(({ response }) => { - const allowed = response === 0 - for (const k of kinds) prefs.setPermission(profileId, origin, k, allowed ? 'allow' : 'block') - if (!contents.isDestroyed()) callback(allowed) + void showDialog(parent, options, callbacks).then((response) => { + dialogsOpen.delete(key) + const choice = response === DIALOG_ALLOW ? 'allow' : response === DIALOG_BLOCK ? 'block' : undefined + if (choice) for (const k of kinds) prefs.setPermission(profileId, origin, k, choice) + if (!contents.isDestroyed()) for (const cb of callbacks) cb(choice === 'allow') }) } @@ -675,5 +744,20 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss const placed = windows.promptInfo(windowId) return { placed: placed.placed, bounds: placed.placed ? placed.bounds : undefined } }, + dialogs: () => + testDialogs.map(({ message, detail, buttons, parent, requests }) => ({ + message, + detail, + buttons, + parent, + requests, + })), + answerDialog: (button) => { + const open = testDialogs[0] + const response = button === 'cancel' ? (open?.cancelId ?? -1) : (open?.buttons.indexOf(button) ?? -1) + if (!open || response < 0) return false + open.answer(response) + return true + }, } } diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index 1bf91799..a4329edc 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -228,6 +228,81 @@ test('[NV-15] notifications ask with Block and Allow; Esc dismisses and the site expect(await p.evaluate(() => Notification.permission)).toBe('granted') }) +type DialogInfo = { message: string; detail: string; buttons: string[]; parent: number | null; requests: number } +type DialogHooks = { + __orbitNav: { permissionDialogs: () => DialogInfo[]; answerPermissionDialog: (button: string) => boolean } +} +/** The plain permission dialogs open for pages without a sidebar (Peek, Mini Window, pop-up), oldest first. */ +const permissionDialogs = (app: ElectronApplication) => + app.evaluate(() => (globalThis as unknown as DialogHooks).__orbitNav.permissionDialogs()) +/** Answers the oldest permission dialog with a button, or `cancel` (Esc, or closing it). */ +const answerDialog = (app: ElectronApplication, button: string) => + app.evaluate((_e, b) => (globalThis as unknown as DialogHooks).__orbitNav.answerPermissionDialog(b), button) +/** The id of the first window whose own page's address contains `part`. */ +const browserWindowId = (app: ElectronApplication, part: string) => + app.evaluate( + ({ BrowserWindow }, p) => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes(p))?.id ?? null, + part, + ) +/** The id of the window that shows the web page at `url`. */ +const windowWithPage = (app: ElectronApplication, url: string) => + app.evaluate( + ({ BrowserWindow }, u) => + BrowserWindow.getAllWindows().find((w) => + w.contentView.children.some((v) => (v as Electron.WebContentsView).webContents?.getURL() === u), + )?.id ?? null, + url, + ) +const focusedWindowId = (app: ElectronApplication) => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getFocusedWindow()?.id ?? null) + +test('[NV-15][MW-03] a Mini Window’s page asks in a dialog over its own window; the same request twice asks once; Esc keeps nothing (review B-F2)', async () => { + const { app } = orbit + const url = page('MiniAsks') + await cmd<string>(app, 'mini.open', { url }) + const p = await tabPage(app, url) + const mini = await windowWithPage(app, url) + const main = await browserWindowId(app, 'view=shell') + expect(mini).not.toBeNull() + // The Mini Window has come forward (it takes the focus as it shows); then the main window has the focus. + await expect.poll(() => focusedWindowId(app)).toBe(mini) + await app.evaluate(({ BrowserWindow }, id) => BrowserWindow.fromId(id ?? -1)?.focus(), main) + await expect.poll(() => focusedWindowId(app)).toBe(main) + const both = () => p.evaluate(() => (window as unknown as { __both?: string[] | null }).__both ?? null) + const askTwice = () => + p.evaluate(() => { + const w = window as unknown as { __both?: string[] | null } + w.__both = null + void Promise.all([Notification.requestPermission(), Notification.requestPermission()]).then((r) => { + w.__both = r + }) + }) + await askTwice() + await expect + .poll(() => permissionDialogs(app)) + .toEqual([ + { + message: `${host()} wants to:`, + detail: 'Show notifications', + buttons: ['Allow', 'Block', 'Not Now'], + parent: mini, + requests: 2, + }, + ]) + // Over the page's window, not the one that had the focus. + expect(await focusedWindowId(app)).toBe(main) + // Esc (or closing it) is this time no, and keeps nothing: the site may ask again. + expect(await answerDialog(app, 'cancel')).toBe(true) + await expect.poll(both).toEqual(['default', 'default']) + expect(await p.evaluate(() => Notification.permission)).toBe('default') + await askTwice() + await expect.poll(async () => (await permissionDialogs(app)).length).toBe(1) + expect(await answerDialog(app, 'Allow')).toBe(true) + await expect.poll(both).toEqual(['granted', 'granted']) + expect(await p.evaluate(() => Notification.permission)).toBe('granted') + expect(await permissionDialogs(app)).toEqual([]) +}) + test('[NV-15] a frame from another site can’t ask for notifications in the page’s name, nor use the page’s', async () => { const { app, shell } = orbit const { p } = await open(app, page('Host')) From 4b911e01e7053e48d4fdff7ee083117fb2db34f8 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:23:06 +0000 Subject: [PATCH 34/69] fix(nav): a split view's panes ask in the sidebar, the pane without the focus too (review B-F3) A pane of a split view wasn't taken for a tab in a Space (its Space lists the split, not the pane), so its requests went to the plain dialog, and it couldn't share its screen, pick a device or open a file at all. Now it asks in the sidebar like any tab. The window shows the prompt of the focused pane, else of another pane that waits, and with the sidebar hidden the card sits over that pane's corner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/permissions.ts | 15 +++--- .../src/shell/chrome/PermissionPrompt.tsx | 9 ++-- src/shared/nav.ts | 16 ++++++ tests/e2e/nav-site.spec.ts | 28 +++++++++++ tests/unit/nav-site.test.ts | 49 ++++++++++++++++++- 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index aca2f8ab..61d10ac2 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -9,7 +9,7 @@ import { type Rectangle, type WebContents, } from 'electron' -import { peekShown } from '../../shared/nav' +import { peekShown, promptTabId } from '../../shared/nav' import { type DeviceKind, deviceKey, @@ -28,10 +28,9 @@ import { sortKinds, } from '../../shared/site' import type { ProfileId, TabId, WindowId } from '../../shared/state' -import { locate } from '../../shared/tree' +import { locate, splitOfTab } from '../../shared/tree' import type { MainContext } from '../context' import type { PermissionHandler, PermissionRequestDetails } from '../sessions' -import { activeTabOf } from '../windows' import type { SitePrefs } from './site-prefs' /** @@ -207,11 +206,14 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss ) } - /** A tab that shows its prompt in a window's sidebar (or the page's corner): one in a Space, not Peek or a Mini Window. */ + /** + * A tab that shows its prompt in a window's sidebar (or the page's corner): one in a Space, a pane + * of a split view in one included (its Space lists the split; review B-F3), not Peek or a Mini Window. + */ const promptable = (tabId: TabId | undefined): tabId is TabId => { if (!tabId) return false const state = store.state - return !!state.tabs[tabId] && !!locate(state, tabId) + return !!state.tabs[tabId] && !!locate(state, splitOfTab(state, tabId) ?? tabId) } // ---------- The queue on each tab ---------- @@ -654,7 +656,8 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss const place = (windowId: WindowId) => { const state = store.state const win = state.windows[windowId] - const tabId = activeTabOf(state, windowId) + // Over the corner of the pane whose page asks (review B-F3). + const tabId = promptTabId(state, windowId) const prompt = tabId ? state.tabs[tabId]?.permissionPrompt : undefined const page = tabId ? windows.tabViewBounds(tabId) : undefined // The sidebar shows it (docked, or the floating card while that is out over the corner); a diff --git a/src/renderer/src/shell/chrome/PermissionPrompt.tsx b/src/renderer/src/shell/chrome/PermissionPrompt.tsx index ed757300..d6308bdd 100644 --- a/src/renderer/src/shell/chrome/PermissionPrompt.tsx +++ b/src/renderer/src/shell/chrome/PermissionPrompt.tsx @@ -26,7 +26,7 @@ import { useRef, useState, } from 'react' -import { shownTabId } from '../../../../shared/nav' +import { promptTabId } from '../../../../shared/nav' import { DEVICE_ASK, type DeviceKind, @@ -57,10 +57,13 @@ export const PERMISSION_ICON: Record<PermissionKind, LucideIcon> = { export const DEVICE_ICON: Record<DeviceKind, LucideIcon> = { serial: Cable, usb: Usb, hid: Gamepad2 } -/** The permission prompt waiting on the tab a window's address display shows (NV-15), if any. */ +/** + * The permission prompt a window shows (NV-15), if any: the one waiting on the tab its address + * display shows, else on another pane of its split view (review B-F3). + */ export function useActivePrompt(windowId: string): Prompt | undefined { return useOrbit((s) => { - const id = shownTabId(s, windowId) + const id = promptTabId(s, windowId) return id ? s.tabs[id]?.permissionPrompt : undefined }) } diff --git a/src/shared/nav.ts b/src/shared/nav.ts index 97c1fc33..6e38f3de 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -4,6 +4,7 @@ */ import type { OrbitState, TabId, WindowId } from './state' +import { splitLeaves, splitOfTab } from './tree' /** * The tab a window's top row and address display are about: an open Peek's page (PK-11: while a @@ -26,6 +27,21 @@ export function peekShown(state: OrbitState, windowId: WindowId): boolean { return overlay?.kind === 'peek' && overlay.phase !== 'closing' && !!state.tabs[overlay.tabId] } +/** + * The tab whose permission prompt a window shows (NV-15): the one its address display shows, else + * another pane of its split view that waits for an answer, so a request from a pane without the + * focus never waits unseen (review B-F3). A Peek covers the panes. + */ +export function promptTabId(state: OrbitState, windowId: WindowId): TabId | null { + const shown = shownTabId(state, windowId) + if (!shown) return null + if (state.tabs[shown]?.permissionPrompt) return shown + if (peekShown(state, windowId)) return null + const splitId = splitOfTab(state, shown) + const split = splitId ? state.splits[splitId] : undefined + return (split && splitLeaves(split.layout).find((id) => !!state.tabs[id]?.permissionPrompt)) || null +} + /** How the address display describes a page's connection (SB-04). */ export type Connection = /** https: no icon (Orbit shows no padlock for secure sites). */ diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index a4329edc..f9ef2029 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -472,6 +472,34 @@ test('[NV-15] clicking a notification brings its tab forward, on its Space; a cl expect(quietId).not.toBe(id) }) +test('[NV-15][SV-01] in split view the pane without the focus shows its prompt too; with the sidebar hidden, at its own corner (review B-F3)', async () => { + const { app, shell } = orbit + const { id: left, p } = await open(app, page('LeftAsks')) + const right = await openTab(app, elsewhere('Right')) + await cmd(app, 'split.create', { tabIds: [left, right], focus: right }) + await expect(shell.getByTestId('address-pill')).toHaveText(`127.0.0.1:${fx.port}`) + const prompt = shell.getByTestId('permission-prompt') + await ask(p, 'notifications') + await expect(prompt).toContainText('Show notifications') + await expect(prompt.getByTestId('permission-site')).toHaveText(host()) + // In the sidebar, not a dialog: the pane is a tab in the Space. + expect(await permissionDialogs(app)).toEqual([]) + // The focus stays where it was. + const win = await windowState(app) + expect(win.activeTab[win.spaceId]).toBe(right) + // With the sidebar hidden it waits over the corner of the pane that asks. + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + const shown = await settledPage(app, left) + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + const bounds = (await promptView(app)).bounds + expect(bounds && { x: bounds.x, y: bounds.y }).toEqual({ x: shown.x - 2, y: shown.y - 2 }) + const card = (await promptPage(app)).getByTestId('permission-prompt') + await card.getByTestId('permission-allow').click() + await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') + await expect.poll(async () => (await promptView(app)).placed).toBe(false) +}) + test('[NV-15] with the sidebar hidden the prompt shows over the page’s top-left corner', async () => { const { app } = orbit const { id, p } = await open(app, page('Corner')) diff --git a/tests/unit/nav-site.test.ts b/tests/unit/nav-site.test.ts index 3e9c46c6..0a0958d2 100644 --- a/tests/unit/nav-site.test.ts +++ b/tests/unit/nav-site.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { SitePrefs } from '../../src/main/nav/site-prefs' import { makeTab } from '../../src/main/tabs/model' import { createInitialState, createWindowState, DEFAULT_PROFILE_ID } from '../../src/shared/defaults' -import { peekShown, shownTabId } from '../../src/shared/nav' +import { peekShown, promptTabId, shownTabId } from '../../src/shared/nav' import { deviceKey, deviceLabel, @@ -291,6 +291,53 @@ describe('the tab the top row is about (PK-11)', () => { }) }) +describe('the tab whose permission prompt a window shows (NV-15)', () => { + it('is the shown tab’s, else a waiting split pane’s; a Peek covers the panes (review B-F3)', () => { + const state: OrbitState = createInitialState(0) + const spaceId = state.spaceOrder[0] as string + const tab = (url: string) => { + const t = makeTab({ spaceId, profileId: DEFAULT_PROFILE_ID, url }, 0) + state.tabs[t.id] = t + return t + } + const left = tab('https://left.test/') + const right = tab('https://right.test/') + const peek = tab('https://peek.test/') + peek.floating = 'peek' + const leaf = (tabId: string) => ({ type: 'leaf' as const, tabId }) + state.splits['s-1'] = { + id: 's-1', + layout: { type: 'row', children: [leaf(left.id), leaf(right.id)], sizes: [0.5, 0.5] }, + createdAt: 0, + } + state.spaces[spaceId]?.today.push('s-1') + const win = createWindowState(spaceId) + win.activeTab[spaceId] = right.id + state.windows[win.id] = win + const asks = (origin: string, id: number) => ({ + id, + type: 'permission' as const, + origin, + kinds: ['notifications' as const], + oneTime: false, + }) + + expect(promptTabId(state, win.id)).toBeNull() + left.permissionPrompt = asks('https://left.test', 1) + expect(promptTabId(state, win.id)).toBe(left.id) + // The focused pane's own request comes first. + right.permissionPrompt = asks('https://right.test', 2) + expect(promptTabId(state, win.id)).toBe(right.id) + delete right.permissionPrompt + // A Peek covers the panes: only its own request shows. + win.overlay = { kind: 'peek', tabId: peek.id, nonce: 1, sourceTabId: right.id } + expect(promptTabId(state, win.id)).toBeNull() + peek.permissionPrompt = asks('https://peek.test', 3) + expect(promptTabId(state, win.id)).toBe(peek.id) + expect(promptTabId(state, 'w-gone')).toBeNull() + }) +}) + describe('notification clicks a page reports (NV-15)', () => { it('bring a tab forward once per gap, however fast they come', () => { let now = 1000 From b72c78d3e2ec9ea866d28f8be292512a8dd04deb Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:28:19 +0000 Subject: [PATCH 35/69] fix(nav): orbit --private-window <url> opens the link in the private window (review B-F6) Two handlers read the same command line: the links went to a normal Mini Window (and its history), and an empty private window opened beside it. A launch with the switch now leaves its links to the private window, which opens with the first in front and the rest beside it, and doesn't raise a normal window first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/external-links.ts | 23 ++++++++++++++++--- src/main/nav/private.ts | 22 ++++++++++++------ tests/e2e/nav-windows.spec.ts | 38 +++++++++++++++++++++++++++++++ tests/unit/external-links.test.ts | 10 +++++++- 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/main/external-links.ts b/src/main/external-links.ts index 75cc78df..804b4b7c 100644 --- a/src/main/external-links.ts +++ b/src/main/external-links.ts @@ -23,8 +23,24 @@ export function routeExternalUrl(ctx: MainContext, url: string): void { /** Files Orbit opens as pages (what the packaged app registers for, plus close relatives). */ const OPENABLE_FILE = /\.(html?|xhtml|shtml|svg|pdf)$/i -/** URLs to open from a command line: web and file URLs, and paths of existing openable files. */ +/** The command line switch that opens a private window (Windows taskbar task, NV-11), with the launch's links in it. */ +export const PRIVATE_WINDOW_SWITCH = '--private-window' + +/** True when a command line asks for a private window (Chromium may reorder arguments). */ +export function privateSwitchIn(argv: readonly string[]): boolean { + return argv.some((a) => a.toLowerCase() === PRIVATE_WINDOW_SWITCH) +} + +/** + * URLs to open from a command line as links from another app (NV-18): none when it asks for a + * private window, which opens them itself (nav/private.ts; review B-F6). + */ export function linksFromArgv(argv: readonly string[], cwd: string): string[] { + return privateSwitchIn(argv) ? [] : urlsInArgv(argv, cwd) +} + +/** The URLs on a command line: web and file URLs, and paths of existing openable files. */ +export function urlsInArgv(argv: readonly string[], cwd: string): string[] { const links: string[] = [] for (const arg of argv.slice(1)) { if (!arg || arg.startsWith('-')) continue @@ -70,8 +86,9 @@ export function captureExternalLinks(): void { app.on('second-instance', (_event, argv, workingDirectory) => { const links = linksFromArgv(argv, workingDirectory) for (const url of links) open(url) - // A jump-list launch acts on Mini Windows only (the Mini Window module handles it, MW-14). - if (links.length > 0 || !target || miniSwitchIn(argv)) return + // A jump-list launch acts on Mini Windows only (the Mini Window module handles it, MW-14), and + // one for a private window opens that window (nav/private.ts, NV-11). + if (links.length > 0 || !target || miniSwitchIn(argv) || privateSwitchIn(argv)) return // Launched again without a link: bring Orbit forward (a window opens if none is). const windowId = target.windows.focusedWindowId() ?? target.windows.ids()[0] ?? target.windows.open() const win = target.windows.get(windowId) diff --git a/src/main/nav/private.ts b/src/main/nav/private.ts index df32f8d9..6b3ab6e0 100644 --- a/src/main/nav/private.ts +++ b/src/main/nav/private.ts @@ -11,6 +11,7 @@ import { import type { ItemId, OrbitState, Profile, ProfileId, SpaceId, SpaceTheme, WindowId } from '../../shared/state' import { tabsInItem } from '../../shared/tree' import type { MainContext } from '../context' +import { PRIVATE_WINDOW_SWITCH, privateSwitchIn, urlsInArgv } from '../external-links' import type { TabOps, TabPage } from '../tabs/ops' import type { SitePrefs } from './site-prefs' @@ -18,9 +19,6 @@ import type { SitePrefs } from './site-prefs' const NOT_IN_PRIVATE = /^(space\.|tab\.togglePin$|tab\.favorite$|tab\.resetToPinned$|pinned\.toggleCollapsed$|folder\.new$|app\.archive$)/ -/** The command line switch that opens a private window (Windows taskbar task, NV-11). */ -export const PRIVATE_WINDOW_SWITCH = '--private-window' - export interface PrivateWindows { /** * Opens a private window, a step down and right of `fromWindowId`: with `url` in its first tab, @@ -209,10 +207,20 @@ export function registerPrivate(ctx: MainContext, ops: TabOps, prefs: SitePrefs) }, ]) } - app.on('second-instance', (_event, argv) => { - if (argv.includes(PRIVATE_WINDOW_SWITCH)) open() - }) - if (process.argv.includes(PRIVATE_WINDOW_SWITCH)) setImmediate(() => open()) + /** + * A launch with the private window switch: a private window, with the command line's links in its + * tabs (the first in front), never in a Mini Window or the history (review B-F6). + */ + const openFromArgv = (argv: readonly string[], cwd: string) => { + if (!privateSwitchIn(argv)) return + const [first, ...rest] = urlsInArgv(argv, cwd) + const windowId = open(first ? { url: first } : {}) + const spaceId = windowId ? store.state.windows[windowId]?.spaceId : undefined + if (!windowId || !spaceId) return + for (const url of rest) ops.createTab({ url, spaceId, windowId, activate: false }) + } + app.on('second-instance', (_event, argv, workingDirectory) => openFromArgv(argv, workingDirectory)) + if (privateSwitchIn(process.argv)) setImmediate(() => openFromArgv(process.argv, process.cwd())) return { open, profileId: () => current } } diff --git a/tests/e2e/nav-windows.spec.ts b/tests/e2e/nav-windows.spec.ts index a40ac442..0dd0e559 100644 --- a/tests/e2e/nav-windows.spec.ts +++ b/tests/e2e/nav-windows.spec.ts @@ -368,3 +368,41 @@ test('[NV-11][NV-18][MW-07] a private window keeps its links: no Peek or Mini Wi expect((s3.spaces[priv.spaceId]?.today ?? []).map((t) => s3.tabs[t]?.url)).not.toContain(page('FromChat')) expect(Object.values(s3.tabs).filter((t) => t.url === page('FromChat') && isPrivateTab(s3, t.id))).toEqual([]) }) + +test('[NV-11][NV-18] a launch with --private-window opens its links in a private window: no Mini Window, nothing in the history (review B-F6)', async () => { + const { app } = orbit + const token = `Launched${Date.now().toString(36)}` + const first = page(`${token}One`) + const second = page(`${token}Two`) + const main = (await windowList(app))[0] + if (!main) throw new Error('no window') + // A second launch, as the Windows taskbar task or another app runs it, with two links. + await app.evaluate( + ({ app: a }, urls) => a.emit('second-instance', {}, ['orbit', '--private-window', ...urls], '/'), + [first, second], + ) + await expect + .poll(async () => Object.values((await state(app)).windows).map((w) => w.kind)) + .toEqual(['main', 'private']) + const priv = (await windowList(app)).find((w) => w.kind === 'private') as WindowInfo + // Both links in its tabs, the first in front. + await expect + .poll(async () => { + const s = await state(app) + return (s.spaces[priv.spaceId]?.today ?? []).map((id) => s.tabs[id]?.url).sort() + }) + .toEqual([first, second].sort()) + const s = await state(app) + const front = s.windows[priv.id]?.activeTab[priv.spaceId] ?? '' + expect(s.tabs[front]?.url).toBe(first) + expect(s.tabs[front]?.profileId).toMatch(/^private-/) + await waitLoaded(app, front, first) + // The history gets a normal page opened after them, and not them. + await openTabIn(app, main.id, page(`${token}Kept`)) + const history = () => + cmd<{ url: string }[]>(app, 'history.search', { profileId: 'p-default', text: token }).then((found) => + found.map((e) => e.url), + ) + await expect.poll(history).toEqual([page(`${token}Kept`)]) + expect(Object.values((await state(app)).windows).map((w) => w.kind)).toEqual(['main', 'private']) +}) diff --git a/tests/unit/external-links.test.ts b/tests/unit/external-links.test.ts index f51ee3bd..4a472773 100644 --- a/tests/unit/external-links.test.ts +++ b/tests/unit/external-links.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' -import { linksFromArgv } from '../../src/main/external-links' +import { linksFromArgv, privateSwitchIn, urlsInArgv } from '../../src/main/external-links' describe('links from another launch (review F10)', () => { it('keeps web and file URLs and existing page files, skipping switches and the app path', () => { @@ -30,4 +30,12 @@ describe('links from another launch (review F10)', () => { 'file:///tmp/x.html', ]) }) + + it('leaves the links of a launch for a private window to that window (review B-F6)', () => { + const argv = ['/opt/Orbit/orbit', '--private-window', 'https://example.com/a', 'https://example.org/b'] + expect(linksFromArgv(argv, '/')).toEqual([]) + expect(urlsInArgv(argv, '/')).toEqual(['https://example.com/a', 'https://example.org/b']) + expect(privateSwitchIn(['orbit', 'https://example.com/a', '--Private-Window'])).toBe(true) + expect(privateSwitchIn(['orbit', 'https://example.com/--private-window'])).toBe(false) + }) }) From b5c111525523ff6f065c7480832cb2f37da211e0 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:31:01 +0000 Subject: [PATCH 36/69] fix(nav): the page's Notification keeps the engine's shape (review B-F11) Notification.requestPermission declared one argument where the engine's declares none (length 1, not 0), and a notification's constructor was the engine's Notification rather than the one the page sees. The callback now comes as a rest argument, and the prototype's constructor is the page's Notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/preload/page.ts | 8 +++++++- tests/e2e/nav-site.spec.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/preload/page.ts b/src/preload/page.ts index a81ba855..64496ffe 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -67,7 +67,10 @@ function patchPage(stateOf: StateOf, clicked: () => void) { configurable: true, enumerable: request.enumerable, writable: true, - value: function requestPermission(callback?: (value: NotificationPermission) => void) { + // The callback comes as a rest argument, so the function declares none (`length` 0), as the + // engine's (review B-F11). + value: function requestPermission(...args: unknown[]) { + const callback = args[0] return apply(then, apply(ask, NativeNotification, []), [ (value: NotificationPermission) => { const answer = undecided(value) @@ -97,6 +100,9 @@ function patchPage(stateOf: StateOf, clicked: () => void) { writable: true, value: Notification, }) + // A notification's `constructor` is the Notification the page sees (review B-F11). + const made = describe(NativeNotification.prototype, 'constructor') + if (made?.configurable) define(NativeNotification.prototype, 'constructor', { ...made, value: Notification }) } } diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index f9ef2029..650ced77 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -358,15 +358,26 @@ test('[NV-15] pages read an undecided permission as not decided yet, as in Chrom expect(await permissionState(p, 'notifications')).toBe('prompt') expect(await permissionState(p, 'geolocation')).toBe('prompt') expect(await permissionState(p, 'camera')).toBe('prompt') - // The page's Notification is still the one it knows. + // The page's Notification is still the one it knows, down to what it declares (review B-F11). expect( await p.evaluate(() => ({ name: Notification.name, request: typeof Notification.requestPermission, + requestName: Notification.requestPermission.name, + requestLength: Notification.requestPermission.length, + constructor: Notification.prototype.constructor === Notification, maxActions: typeof (Notification as unknown as { maxActions: number }).maxActions, own: Object.keys(window).filter((k) => /orbit/i.test(k)), })), - ).toEqual({ name: 'Notification', request: 'function', maxActions: 'number', own: [] }) + ).toEqual({ + name: 'Notification', + request: 'function', + requestName: 'requestPermission', + requestLength: 0, + constructor: true, + maxActions: 'number', + own: [], + }) const prompt = shell.getByTestId('permission-prompt') await ask(p, 'notifications') @@ -381,7 +392,17 @@ test('[NV-15] pages read an undecided permission as not decided yet, as in Chrom await prompt.getByTestId('permission-allow').click() await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') expect(await permissionState(p, 'notifications')).toBe('granted') - expect(await p.evaluate(() => new Notification('Hello') instanceof Notification)).toBe(true) + expect( + await p.evaluate(() => { + const made = new Notification('Hello') + made.close() + return { instance: made instanceof Notification, constructor: made.constructor === Notification } + }), + ).toEqual({ instance: true, constructor: true }) + // The old callback form answers too. + expect(await p.evaluate(() => new Promise<string>((resolve) => void Notification.requestPermission(resolve)))).toBe( + 'granted', + ) }) /** From 39dfaa9e1fe3b5b5e0def5b8e47428645915b192 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:32:27 +0000 Subject: [PATCH 37/69] docs: feature notes for the review fixes; regenerate FEATURES.md (review B-F8) SB-04, SB-11, SB-12, SB-28, NV-05, NV-06, NV-11, NV-15 and NV-18 say what this round changed. NV-15 also says its recording indicators wait on TB-15, and what the page preload still doesn't match (the change event, toString); SB-12 names the overlapping-window gap (A-F1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 18 +++++++++--------- docs/feature-status.json | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index b25d5b70..e694875a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,15 +24,15 @@ Spec: `spec/sidebar.md` | SB-01 | Sidebar-first window layout | P0 | ✅ | ✅ e2e | – | sidebar on the left with controls, navigation, address and every tab; the page an inset rounded card beside it in the Space theme; no tab strip | | SB-02 | Sidebar anatomy (top to bottom) | P0 | ✅ | ✅ e2e | – | top row, address, Favorites, title row, pinned, divider, + New Tab, Today, empty area, bottom bar in that order; title row to empty area scroll together, the rest stays; trays come with SB-10, the Library button with SB-09 | | SB-03 | Top row: window controls and navigation buttons | P0 | ✅ | ✅ e2e | – | traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow | -| SB-04 | Address display at the top of the sidebar | P0 | ✅ | ✅ e2e+unit | – | host only (focused pane, or the Peek's page while one shows), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button, site controls button (NV-16) at the right end | +| SB-04 | Address display at the top of the sidebar | P0 | ✅ | ✅ e2e+unit | – | host only (focused pane, or the Peek's page while one shows; during a navigation Orbit started, where it is going), click or Cmd+L edits that full URL; an address without a host says what it is (view-source: and the host, a blob by the host that made it, data: and its type, else the address shortened); warning icon for plain http (no padlock), copy-link button, site controls button (NV-16) at the right end | | SB-05 | Favorites grid placement | P0 | ✅ | ✅ e2e | – | Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile | | SB-06 | Space title row placement | P0 | ✅ | ✅ e2e | – | title row: inline rename, hover profile badge and … menu (pinned caret is PN-09) | | SB-07 | "+ New Tab" row | P0 | ✅ | ✅ e2e | – | + New Tab row with press animation opens the command bar | | SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | – | – | | | SB-09 | Bottom bar: Library, Space switcher, "+" menu | P0 | 🟡 partial | ✅ e2e | – | switcher and + menu done; Library button arrives with the archive lane | | SB-10 | Transient trays above the bottom bar | P1 | 🟡 partial | ✅ e2e | – | media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes | -| SB-11 | Show / hide the sidebar | P0 | ✅ | ✅ e2e | – | Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); back at the same width, scroll and selected tab; per window, persisted; new windows show it | -| SB-12 | Hover to reveal the hidden sidebar | P0 | ✅ | ✅ e2e | – | left 6 px edge (moving pointer, 0 ms dwell) brings a floating card in its own view over the page, which never moves; stays while hovered (16 px band), while a field in it is edited or the command bar is open; 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 | +| SB-11 | Show / hide the sidebar | P0 | ✅ | ✅ e2e | – | Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it | +| SB-12 | Hover to reveal the hidden sidebar | P0 | ✅ | ✅ e2e | – | 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 or the command bar is open; 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-13 | Feedback when things happen while the sidebar is hidden | P1 | – | – | – | | | SB-14 | Resize the sidebar width | P0 | ✅ | ✅ e2e | – | 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-15 | Narrow ("compact") sidebar rendering | P1 | – | – | – | | @@ -48,7 +48,7 @@ Spec: `spec/sidebar.md` | SB-25 | Incognito window sidebar | P1 | ✅ | ✅ e2e | – | private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with | | SB-26 | Platform notes (Windows / Linux) | P2 | – | – | – | | | SB-27 | Link status pill | P1 | ✅ | ✅ e2e+unit | – | hovering (or focusing) a link shows a theme-styled pill with where it goes (no scheme) at the bottom-left of that page card, in its own small view; the full URL after 1.5 s on the link, kept for the next links while it stays up; moves to the other corner when the pointer comes within 24 px, out of the way if near both; goes 120 ms after the link is left; not on Peek pages | -| SB-28 | Page loading indicator | P1 | ✅ | ✅ e2e | – | loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; shows in full screen; tab rows keep their favicon loading ring | +| SB-28 | Page loading indicator | P1 | ✅ | ✅ e2e | – | loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; it leaves the window when hidden; shows in native full screen, not over a page in HTML full screen (back when that ends if still loading); tab rows keep their favicon loading ring | ## Tabs, Pinned tabs, Favorites, Today tabs & auto-archive, Folders @@ -233,20 +233,20 @@ Spec: `spec/navigation.md` | NV-02 | Trackpad two-finger swipe back/forward | P1 | – | – | – | | | 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), 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; Cmd+J jumps to the selection (macOS); Edit > Find menu | -| NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | +| 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; 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-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; 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 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 | -| NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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-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, 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), 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-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): 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; Esc dismisses; kept per profile and site (private in memory); pages read undecided as default/prompt; a notification click brings its tab forward (page notifications, not service-worker ones); Peek, Mini Window and pop-ups ask in a dialog; toolbar anchor waits on SB-17, Site settings is ST-13 | +| 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 | 🟡 partial | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on 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); rules UI is ST-09, Archive restores AR-05 | +| 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-20 | Full screen | P1 | – | ✅ e2e | – | | | NV-21 | Save page and view source | P2 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 7095b379..e18834e7 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -2,13 +2,13 @@ "SB-01": {"built": "yes", "note": "sidebar on the left with controls, navigation, address and every tab; the page an inset rounded card beside it in the Space theme; no tab strip"}, "SB-02": {"built": "yes", "note": "top row, address, Favorites, title row, pinned, divider, + New Tab, Today, empty area, bottom bar in that order; title row to empty area scroll together, the rest stays; trays come with SB-10, the Library button with SB-09"}, "SB-03": {"built": "yes", "note": "traffic lights (Linux: window controls), sidebar toggle, Back/Forward/Reload with disabled and Stop states; long-press/right-click history menu; Cmd/middle-click into a new tab; Esc or double-click stops; toggle gives way first when narrow"}, - "SB-04": {"built": "yes", "note": "host only (focused pane, or the Peek's page while one shows), click or Cmd+L edits the full URL, warning icon for plain http (no padlock), copy-link button, site controls button (NV-16) at the right end"}, + "SB-04": {"built": "yes", "note": "host only (focused pane, or the Peek's page while one shows; during a navigation Orbit started, where it is going), click or Cmd+L edits that full URL; an address without a host says what it is (view-source: and the host, a blob by the host that made it, data: and its type, else the address shortened); warning icon for plain http (no padlock), copy-link button, site controls button (NV-16) at the right end"}, "SB-05": {"built": "yes", "note": "Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile"}, "SB-06": {"built": "yes", "note": "title row: inline rename, hover profile badge and … menu (pinned caret is PN-09)"}, "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, "SB-09": {"built": "partial", "note": "switcher and + menu done; Library button arrives with the archive lane"}, - "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); back at the same width, scroll and selected tab; per window, persisted; new windows show it"}, - "SB-12": {"built": "yes", "note": "left 6 px edge (moving pointer, 0 ms dwell) brings a floating card in its own view over the page, which never moves; stays while hovered (16 px band), while a field in it is edited or the command bar is open; 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"}, + "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it"}, + "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 or the command bar is open; 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"}, @@ -18,7 +18,7 @@ "SB-24": {"built": "partial", "note": "theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet"}, "SB-25": {"built": "yes", "note": "private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with"}, "SB-27": {"built": "yes", "note": "hovering (or focusing) a link shows a theme-styled pill with where it goes (no scheme) at the bottom-left of that page card, in its own small view; the full URL after 1.5 s on the link, kept for the next links while it stays up; moves to the other corner when the pointer comes within 24 px, out of the way if near both; goes 120 ms after the link is left; not on Peek pages"}, - "SB-28": {"built": "yes", "note": "loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; shows in full screen; tab rows keep their favicon loading ring"}, + "SB-28": {"built": "yes", "note": "loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; it leaves the window when hidden; shows in native full screen, not over a page in HTML full screen (back when that ends if still loading); tab rows keep their favicon loading ring"}, "TB-01": {"built": "yes", "note": "row: favicon/emoji, title or breadcrumb, hover close, audio, selected pill, dimmed when asleep"}, "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop / Cmd+Alt+V paste; background link tabs gesture-gated"}, "TB-03": {"built": "yes", "note": "single rule todayInsertIndex(): top of Today"}, @@ -163,16 +163,16 @@ "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-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), 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; Cmd+J jumps to the selection (macOS); Edit > Find menu"}, - "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux); per host in each profile (all its tabs), remembered across launches; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, + "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; 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-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; 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 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"}, - "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task: 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-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, 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), 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-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden): 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; Esc dismisses; kept per profile and site (private in memory); pages read undecided as default/prompt; a notification click brings its tab forward (page notifications, not service-worker ones); Peek, Mini Window and pop-ups ask in a dialog; toolbar anchor waits on SB-17, Site settings is ST-13"}, + "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); rules UI is ST-09, Archive restores AR-05"}, + "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": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, "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"}, "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"}, From 8647efea23e0aca3daf59db594600a5077418270 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:34:22 +0000 Subject: [PATCH 38/69] docs: the page preload's Notification patch includes its prototype's constructor (review B-F11) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a14dc5e7..ab6d1f11 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,7 +17,7 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) - **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. - **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 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. +- **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. ## 2. State: one store, patches out From 5010cbfbc1a0a2c0ec8c6da64e98e41df0fa37d2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:20:04 +0000 Subject: [PATCH 39/69] test(nav): Restore Data and Recently Closed with a private window open (archive review M2, M3) - Restore Data from the main window leaves an open private window on its own Space with its tab; closing that window afterwards deletes no listed Space or tab (M2, AR-11). - The main window's Recently Closed menus (the Library's, and File on macOS) leave out a page closed in a private window, before and after that window closes; the private window still reopens its own closed tab (M3, AR-09). Both pass on the merged build. With the archive lane's fixes taken out (e4231a8's kept Spaces and window skip, fba1217's profile filter) both fail: the private window moves to Space A, and the menu lists "SecretClosedPrivate". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/nav-windows.spec.ts | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/e2e/nav-windows.spec.ts b/tests/e2e/nav-windows.spec.ts index 0dd0e559..bcf6d280 100644 --- a/tests/e2e/nav-windows.spec.ts +++ b/tests/e2e/nav-windows.spec.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { type ElectronApplication, expect, test } from '@playwright/test' +import type { BackupInfo } from '../../src/shared/commands/archive' import type { OrbitState } from '../../src/shared/state' import { action, isMac, launchOrbit, MOD, type Orbit, state } from './helpers' import { @@ -406,3 +407,85 @@ test('[NV-11][NV-18] a launch with --private-window opens its links in a private await expect.poll(history).toEqual([page(`${token}Kept`)]) expect(Object.values((await state(app)).windows).map((w) => w.kind)).toEqual(['main', 'private']) }) + +/** Each listed Space's pinned and Today items: the sidebar normal windows show. */ +const sidebarOf = (s: OrbitState) => + Object.fromEntries(s.spaceOrder.map((id) => [id, { pinned: s.spaces[id]?.pinned, today: s.spaces[id]?.today }])) + +test('[NV-11][AR-11] Restore Data leaves an open private window on its own Space with its tab, and closing that window afterwards deletes no other Space (archive review M2)', async () => { + const { app } = orbit + const main = (await windowList(app))[0] as WindowInfo + const a = main.spaceId + // Space A, first in the list: a pinned tab and a Today tab. The window then goes to Space B. + const pinned = await openTab(app, page('PinnedA')) + await cmd(app, 'tabs.pin', { tabId: pinned }) + const today = await openTab(app, page('TodayA')) + const b = await cmd<string>(app, 'spaces.create', { name: 'Bravo' }) + await expect.poll(async () => (await windowList(app))[0]?.spaceId).toBe(b) + const inB = await openTab(app, page('InB')) + const backup = await cmd<BackupInfo>(app, 'backups.take') + // Since the backup: a tab in Space B (the restore archives it) and a private window with a tab. + const late = await openTab(app, page('LateB')) + const priv = await openPrivate(app) + const secret = await openTabIn(app, priv.id, page('SecretRestore')) + const listed = (await state(app)).spaceOrder + + // Restore Data, from the main window. + expect(await cmdIn(app, main.id, 'backups.restore', { id: backup.id })).toMatchObject({ restored: true }) + await expect.poll(async () => (await state(app)).tabs[late]).toBeUndefined() + const s = await state(app) + // The private window still shows its own Space and tab, and the tab's page is still there. + expect(s.windows[priv.id]).toMatchObject({ kind: 'private', spaceId: priv.spaceId }) + expect(s.windows[priv.id]?.activeTab[priv.spaceId]).toBe(secret) + expect(s.spaces[priv.spaceId]?.today).toEqual([secret]) + expect(s.tabs[secret]).toMatchObject({ url: page('SecretRestore'), spaceId: priv.spaceId }) + await waitLoaded(app, secret, page('SecretRestore')) + // The main window is still on Space B, and the Space list is as it was. + expect(s.windows[main.id]?.spaceId).toBe(b) + expect(s.spaceOrder).toEqual(listed) + const restored = sidebarOf(s) + expect(restored[a]?.pinned).toContain(pinned) + expect(restored[a]?.today).toContain(today) + expect(restored[b]?.today).toContain(inB) + + // Closing the private window takes its Space and tab along, and nothing else. + await closeWindow(app, priv.id) + await expect.poll(async () => privateLeft(await state(app))).toEqual(NOTHING_PRIVATE) + const after = await state(app) + expect(Object.keys(after.spaces).toSorted()).toEqual(listed.toSorted()) + expect(sidebarOf(after)).toEqual(restored) + for (const id of [pinned, today, inB]) expect(after.tabs[id]).toBeDefined() + expect(after.windows[main.id]?.spaceId).toBe(b) +}) + +test('[NV-11][AR-09] a normal window’s Recently Closed menus leave out pages closed in a private window, which still reopens its own (archive review M3)', async () => { + const { app } = orbit + const main = (await windowList(app))[0] as WindowInfo + const normal = await openTab(app, page('ClosedNormal')) + await cmd(app, 'tabs.close', { tabId: normal }) + const priv = await openPrivate(app) + const secret = await openTabIn(app, priv.id, page('SecretClosedPrivate')) + await cmdIn(app, priv.id, 'tabs.close', { tabId: secret }) + await expect.poll(async () => (await state(app)).tabs[secret]).toBeUndefined() + // The Library's Recently Closed menu in the main window, while the private window is open. + const menu = async () => { + await cmdIn(app, main.id, 'archive.recentlyClosedMenu', {}) + return cmd<string[]>(app, 'archive.testMenu', {}) + } + expect(await menu()).toEqual(['ClosedNormal', 'Reopen Closed Tab']) + // File → Recently Closed (macOS has the only app menu). + const appMenu = () => cmd<string[]>(app, 'archive.testMenu', { appMenu: true }) + if (isMac) await expect.poll(appMenu).toEqual(['ClosedNormal']) + else expect(await appMenu()).toEqual([]) + // The private window still brings its own closed tab back. + expect(await cmdIn(app, priv.id, 'tabs.reopenClosed', {})).toBe(secret) + await expect.poll(async () => (await state(app)).spaces[priv.spaceId]?.today).toEqual([secret]) + await cmdIn(app, priv.id, 'tabs.close', { tabId: secret }) + await expect.poll(async () => (await state(app)).tabs[secret]).toBeUndefined() + expect(await menu()).toEqual(['ClosedNormal', 'Reopen Closed Tab']) + // Once the private window is gone too. + await closeWindow(app, priv.id) + await expect.poll(async () => privateLeft(await state(app))).toEqual(NOTHING_PRIVATE) + expect(await menu()).toEqual(['ClosedNormal', 'Reopen Closed Tab']) + if (isMac) await expect.poll(appMenu).toEqual(['ClosedNormal']) +}) From ae2adb3b692b5f1bfa42b876f9d76011fe0868d8 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:29:00 +0000 Subject: [PATCH 40/69] fix(chrome): a page's find bar, prompt and loading views stay under the open Library (merge with AR-02) The Library is a view over the whole page area, placed just above the window's pages. Chrome's small page views (page tools, permission prompt, loading indicator, link pill) are placed above the pages under the overlay and the floating sidebar, so on the merged build an open find bar, or a load starting under the Library, showed over the Library. The window manager now takes a page cover (setPageCover): while one is set, those views go under it with the page. The Library sets itself as the cover when it opens or moves, and clears it when it closes. Without a cover nothing changes; the overlay, the floating sidebar and a Peek stay above. Test: with the find bar out, opening the Library put view=tools above view=library (failed before the fix); with it, the find bar and a slow load's indicator are under the Library, and over the page again once it closes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/archive/library.ts | 5 +++++ src/main/windows.ts | 36 +++++++++++++++++++++++++++++++++--- tests/e2e/nav-page.spec.ts | 34 +++++++++++++++++++++++++++++++++- tests/e2e/nav-support.ts | 24 +++++++++++++++++++++++- 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/main/archive/library.ts b/src/main/archive/library.ts index 4108c3b7..fea5d81d 100644 --- a/src/main/archive/library.ts +++ b/src/main/archive/library.ts @@ -156,6 +156,8 @@ export class LibraryHost { view.setBounds(this.bounds(windowId)) view.setBorderRadius(this.radii.get(windowId) ?? DEFAULT_RADIUS) this.place(windowId) + // The page's own small views (find bar, loading indicator) go under the Library (AR-02). + this.ctx.windows.setPageCover(windowId, view) view.setVisible(true) page.send('orbit:library:show', w.show) // With the command bar still up, focus comes here when it closes (onState). @@ -169,6 +171,7 @@ export class LibraryHost { const w = windowId ? this.wins.get(windowId) : undefined if (!windowId || !w?.open) return w.open = false + this.ctx.windows.setPageCover(windowId, undefined) const view = w.view const page = pageOf(view) if (view && page) { @@ -307,6 +310,8 @@ export class LibraryHost { if (at > top) return // addChildView() moves a child it already holds to the index given (the views above shift down). parent.addChildView(view, at >= 0 ? top : top + 1) + // The page's own small views it went under come back under it. + this.ctx.windows.setPageCover(windowId, view) } /** A page under the open Library took focus (the window refocused it): give it back to the Library. */ diff --git a/src/main/windows.ts b/src/main/windows.ts index bb763dbd..379f5477 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -82,6 +82,11 @@ interface WindowRecord { * link pill (SB-27), page dialogs (NV-24). Kept above the pages, closed with the window. */ uiViews: Set<WebContentsView> + /** + * A view over the whole page area while it is open, such as the Library (AR-02): the page tools, + * the prompt and the small views above stay under it, with the page (setPageCover). + */ + cover?: WebContentsView } /** A tab view placed in a window. */ @@ -668,7 +673,9 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - const above = [rec.overlay, rec.floating].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const above = [rec.overlay, rec.floating, rec.cover] + .map((v) => (v ? children.indexOf(v) : -1)) + .filter((i) => i >= 0) const ceiling = above.length > 0 ? Math.min(...above) : -1 if (ceiling < 0) { if (at !== children.length - 1) parent.addChildView(view) @@ -803,12 +810,35 @@ export class WindowManager { if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) } - /** Puts a small view above every page, under the overlay and the floating sidebar; leaves it be when it already is. */ + /** + * Sets the view that covers a window's page area while it is open (the Library, AR-02), or clears + * it. The page tools, the permission prompt and the small views over the page (loading indicator, + * link pill) belong to the page: while a cover is set they go under it, not over it. The overlay + * and the floating sidebar stay above it. The cover places itself above the pages, and sets itself + * again after it moves. + */ + setPageCover(windowId: WindowId, view: WebContentsView | undefined): void { + const rec = this.records.get(windowId) + if (!rec || rec.win.isDestroyed()) return + rec.cover = view + if (!view) return + const children = rec.win.contentView.children + for (const small of [rec.tools, rec.prompt, ...rec.uiViews]) { + if (small && children.includes(small)) this.placeAbovePages(rec, small) + } + } + + /** + * Puts a small view above every page, under the overlay, the floating sidebar and a page cover; + * leaves it be when it already is. + */ private placeAbovePages(rec: WindowRecord, view: WebContentsView): void { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - const ceilings = [rec.overlay, rec.floating].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const ceilings = [rec.overlay, rec.floating, rec.cover] + .map((v) => (v ? children.indexOf(v) : -1)) + .filter((i) => i >= 0) const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : children.length let topPage = -1 for (const entry of this.attached.values()) { diff --git a/tests/e2e/nav-page.spec.ts b/tests/e2e/nav-page.spec.ts index cc8510ac..5bd11ccb 100644 --- a/tests/e2e/nav-page.spec.ts +++ b/tests/e2e/nav-page.spec.ts @@ -1,5 +1,5 @@ import { type ElectronApplication, expect, type Page, test } from '@playwright/test' -import { launchOrbit, type Orbit, state, tabPage } from './helpers' +import { action, launchOrbit, type Orbit, run, state, tabPage } from './helpers' import { cmd, type NavFixtures, @@ -7,6 +7,8 @@ import { pageMouse, pressInPage, startNavFixtures, + toolsInfo, + viewStack, waitLoaded, windowState, } from './nav-support' @@ -373,3 +375,33 @@ test('[SB-27] the pill moves to the other corner when the pointer comes near it, await openTab(app, page('Other')) await expect.poll(async () => (await linkPill(app)).visible).toBe(false) }) + +test('[SB-28][NV-05][AR-02] a page’s find bar and loading indicator stay under the Library while it is open, and are over the page again once it closes', async () => { + const { app } = orbit + const id = await openTab(app, page('Covered')) + await action(app, 'page.find') + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(true) + await run(app, 'library.open', {}) + await expect + .poll(() => run(app, 'library.testInfo') as Promise<{ open: boolean; visible: boolean }>) + .toMatchObject({ open: true, visible: true }) + /** Whether a view is above the page and under the Library. */ + const covered = (stack: string[], view: string) => + stack.indexOf(view) > stack.indexOf(`page:${id}`) && stack.indexOf(view) < stack.indexOf('view=library') + await expect.poll(async () => covered(await viewStack(app), 'view=tools')).toBe(true) + // A slow load in the page under the Library: its indicator goes under the Library too. + const slow = fx.url('/slow/Covered') + await cmd(app, 'tabs.navigate', { tabId: id, url: slow }) + await expect.poll(async () => (await loading(app)).visible).toBe(true) + const stack = await viewStack(app) + expect(covered(stack, 'view=loading')).toBe(true) + expect(covered(stack, 'view=tools')).toBe(true) + // The Library closes: both are over the page again. + await run(app, 'library.close', {}) + await expect.poll(async () => (await viewStack(app)).includes('view=library')).toBe(false) + const after = await viewStack(app) + expect(after.indexOf('view=tools')).toBeGreaterThan(after.indexOf(`page:${id}`)) + expect(after.indexOf('view=loading')).toBeGreaterThan(after.indexOf(`page:${id}`)) + fx.release('/slow/Covered') + await waitLoaded(app, id, slow) +}) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index f2861709..c03be136 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -287,7 +287,10 @@ type Hooks = { flush: () => unknown ctx: { windows: { get: (id: string) => Electron.BrowserWindow | undefined } - tabs: { contents: (id: string) => Electron.WebContents | undefined } + tabs: { + contents: (id: string) => Electron.WebContents | undefined + tabIdFor: (contents: Electron.WebContents) => string | undefined + } } } __orbitNav: { @@ -472,6 +475,25 @@ export async function closeWindow(app: ElectronApplication, windowId: string): P }, windowId) } +/** + * The first window's shown views from the bottom up: `page:<tab id>` for a page, `view=<name>` for + * Orbit's own (the Library, the page tools, the loading indicator…). + */ +export function viewStack(app: ElectronApplication): Promise<string[]> { + return app.evaluate(() => { + const { __orbit } = globalThis as unknown as Hooks + const win = __orbit.ctx.windows.get(__orbit.windowIds()[0] as string) + return (win?.contentView.children ?? []).flatMap((child) => { + const contents = (child as Electron.WebContentsView).webContents + if (!child.getVisible() || !contents || contents.isDestroyed()) return [] + const tabId = __orbit.ctx.tabs.tabIdFor(contents) + if (tabId) return [`page:${tabId}`] + const view = /[?&]view=([a-z]+)/.exec(contents.getURL())?.[1] + return view ? [`view=${view}`] : [] + }) + }) +} + /** Writes the state file now (it is otherwise written a moment after each change). */ export async function flushState(app: ElectronApplication): Promise<void> { await app.evaluate(async () => { From 132e1ceb97d9a3ac372791f787c606d301753962 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:29:25 +0000 Subject: [PATCH 41/69] docs: the views over the pages and the page cover; regenerate FEATURES.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68721384..54cb3716 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,6 +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. - **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. From 6f34029c692d766377b9255005cdf516b01f2b43 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:53:29 +0000 Subject: [PATCH 42/69] test(nav): the split-view prompt test follows the pane's corner as the layout settles (B-F3) The test read the pane once it looked settled (two equal reads 100 ms apart) and the prompt view once. Panes keep still while the sidebar slides away, then move, and the new split is still easing to equal widths, so on the merged build both reads could land on a step on the way (pane at 743,8 or 595,6; view at 589,4 or 615,4). Sampling showed the view follows the pane to its final corner (623,6 and 621,4). The test now polls the view's offset from the pane's corner, both read at once, until it is (-2, -2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/nav-site.spec.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index 650ced77..2c3502d7 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -120,6 +120,21 @@ const promptView = (app: ElectronApplication) => return g.__orbitNav.prompt() }) +/** + * Where the prompt view sits from a tab page's top-left corner, both read at once; null while the + * view isn't placed. The view follows the page as the layout settles. + */ +const promptFromCorner = (app: ElectronApplication, tabId: string) => + app.evaluate((_e, id) => { + const g = globalThis as unknown as { + __orbit: { ctx: { windows: { tabViewBounds: (t: string) => Electron.Rectangle | undefined } } } + __orbitNav: { prompt: () => { placed: boolean; bounds?: Electron.Rectangle } } + } + const view = g.__orbitNav.prompt() + const page = g.__orbit.ctx.windows.tabViewBounds(id) + return view.placed && view.bounds && page ? { x: view.bounds.x - page.x, y: view.bounds.y - page.y } : null + }, tabId) + /** The prompt view's own page (view=prompt), once it has started. */ async function promptPage(app: ElectronApplication): Promise<Page> { const deadline = Date.now() + 20_000 @@ -508,13 +523,11 @@ test('[NV-15][SV-01] in split view the pane without the focus shows its prompt t // The focus stays where it was. const win = await windowState(app) expect(win.activeTab[win.spaceId]).toBe(right) - // With the sidebar hidden it waits over the corner of the pane that asks. + // With the sidebar hidden it waits over the corner of the pane that asks. The panes move as the + // sidebar slides away and the split settles: the view follows the pane's corner. await action(app, 'sidebar.toggle') await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) - const shown = await settledPage(app, left) - await expect.poll(async () => (await promptView(app)).placed).toBe(true) - const bounds = (await promptView(app)).bounds - expect(bounds && { x: bounds.x, y: bounds.y }).toEqual({ x: shown.x - 2, y: shown.y - 2 }) + await expect.poll(() => promptFromCorner(app, left)).toEqual({ x: -2, y: -2 }) const card = (await promptPage(app)).getByTestId('permission-prompt') await card.getByTestId('permission-allow').click() await expect.poll(() => answerOf(p, 'notifications')).toBe('granted') From d8a79245611b3d181537fb45cfd5872fe7f23bba Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:24:33 +0000 Subject: [PATCH 43/69] fix(nav): the page menu's search item names and uses the tab's profile engine (settings review F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome's searchLabel named the global engine (Google with a custom default), while settings' fix made the click search with the tab's profile engine: in a Bing profile's tab the item said "Search Google for “…”" and searched Bing. The engine is now resolved once, with engineFor, and the item names the engine it searches with. The ST-06 test reads the page menu through chrome's test hooks (pageMenu, choosePageMenu): in tests the menu is kept instead of shown, so patching Menu.popup no longer sees it. Without the fix it fails: Expected "Search Bing for “Selected”", Received ["role:copy", "Search Google for “Selected”", "-", "Inspect"]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/nav/page-menu.ts | 6 ++---- src/main/tabs/context-menu.ts | 12 +++++------- tests/e2e/settings.spec.ts | 22 +++++++--------------- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index 1d5715da..77679543 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -3,7 +3,6 @@ import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, t import { type PageReferrer, referrerPolicyName } from '../../shared/nav' import { isPrivateTab } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' -import { SEARCH_ENGINES } from '../../shared/url' import type { MainContext } from '../context' import type { TabPage } from '../tabs/ops' import { NAV_WORLD } from './focus' @@ -82,9 +81,8 @@ export function saveLinkItem(contents: WebContents, url: string): MenuItemConstr return { label: 'Save Link As…', click: () => contents.downloadURL(url) } } -/** Search [engine] for “…” (the search engine named, NV-12). */ -export function searchLabel(ctx: MainContext, selection: string): string { - const engine = SEARCH_ENGINES[ctx.store.state.settings.searchEngine] ?? SEARCH_ENGINES.google +/** Search [engine] for “…”, naming the engine the item searches with (NV-12). */ +export function searchLabel(engine: { name: string }, selection: string): string { const text = selection.trim() return `Search ${engine.name} for “${text.slice(0, 40)}${text.length > 40 ? '…' : ''}”` } diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index f6f5b0af..d51d9462 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -79,16 +79,14 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We { type: 'separator' }, ) } else if (params.selectionText) { + // The engine of the tab's profile, as for a search typed in its Space (ST-06); the item names it. + const state = ctx.store.state + const engine = engineFor(state, state.tabs[tabId]?.profileId) items.push( { role: 'copy' }, { - label: searchLabel(ctx, params.selectionText), - click: () => { - // The engine of the tab's profile, as for a search typed in its Space (ST-06). - const state = ctx.store.state - const engine = engineFor(state, state.tabs[tabId]?.profileId) - openInNewTab(searchUrl(engine, params.selectionText.trim()), true) - }, + label: searchLabel(engine, params.selectionText), + click: () => openInNewTab(searchUrl(engine, params.selectionText.trim()), true), }, ...lookUpItems(contents, params.selectionText), { type: 'separator' }, diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts index a33f4c5e..c1cf092c 100644 --- a/tests/e2e/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -22,6 +22,7 @@ import { tabPage, viewPage, } from './helpers' +import { choosePageMenu, pageMenu } from './nav-support' import { browserFocused, focusBrowserWindow, @@ -492,14 +493,8 @@ test("[ST-06] Search for a selection, from the page's menu, uses the engine of t await run(orbit.app, 'spaces.switch', { spaceId }) await run(orbit.app, 'tabs.create', { url: fx.url('/page/Selected') }) const web = await tabPage(orbit.app, '/page/Selected') - // The page's menu is kept for the test instead of being shown. - type Kept = { __pageMenu?: Electron.Menu } - await orbit.app.evaluate(({ Menu }) => { - Menu.prototype.popup = function (this: Electron.Menu) { - ;(globalThis as unknown as Kept).__pageMenu = this - } - }) - // The heading's text, selected, and a right-click on it. + // The heading's text, selected, and a right-click on it. In tests, the page's menu is kept for the + // test hooks instead of being shown (NV-12). const at = await web.evaluate(() => { const range = document.createRange() range.selectNodeContents(document.getElementById('title') as HTMLElement) @@ -509,13 +504,10 @@ test("[ST-06] Search for a selection, from the page's menu, uses the engine of t return { x: r.x + r.width / 2, y: r.y + r.height / 2 } }) await web.mouse.click(at.x, at.y, { button: 'right' }) - const search = 'Search for “Selected”' - await expect - .poll(() => orbit.app.evaluate(() => (globalThis as unknown as Kept).__pageMenu?.items.map((i) => i.label) ?? [])) - .toContain(search) - await orbit.app.evaluate((_electron, label) => { - ;(globalThis as unknown as Kept).__pageMenu?.items.find((i) => i.label === label)?.click() - }, search) + // The item names the engine it searches with (NV-12): the tab's profile's, not the global one. + const search = 'Search Bing for “Selected”' + await expect.poll(async () => (await pageMenu(orbit.app))?.labels ?? []).toContain(search) + expect(await choosePageMenu(orbit.app, search)).toBe(true) await expect.poll(activeUrl).toBe('https://www.bing.com/search?q=Selected') }) From 183ac509059ec319375e1ded005b2ea63ccfb172 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:49:42 +0000 Subject: [PATCH 44/69] fix(nav): a prompt the closing Library uncovers takes no click at once (fix check B, M1) The permission prompt's view under a page cover such as the Library now counts as off screen: its page hears `prompt:placed` false when the cover comes, and true again only once the Library's view has actually hidden, so the card's choices wait their moment again. The Library clears its cover from its hide timer instead of at close, and showPrompt no longer says true while a cover is set. A prompt view made under the Library starts off screen: its page asks main where it is when it loads (`permissions.promptPlaced`), since what main said before it listened is lost. Tests (nav-site): FC1, one click on Allow the moment the Library hides leaves the request waiting and a click after a pause answers; a prompt that comes while the Library is open (first prompt of the window, and one with the prompt view's page up) keeps its choices off under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/main/archive/library.ts | 9 +- src/main/nav/index.ts | 11 +- src/main/nav/permissions.ts | 5 +- src/main/windows.ts | 55 ++++-- .../src/shell/chrome/PromptViewApp.tsx | 27 +-- src/shared/commands/nav.ts | 5 + tests/e2e/nav-site.spec.ts | 172 +++++++++++++++++- 7 files changed, 253 insertions(+), 31 deletions(-) diff --git a/src/main/archive/library.ts b/src/main/archive/library.ts index fea5d81d..471de3e0 100644 --- a/src/main/archive/library.ts +++ b/src/main/archive/library.ts @@ -171,15 +171,20 @@ export class LibraryHost { const w = windowId ? this.wins.get(windowId) : undefined if (!windowId || !w?.open) return w.open = false - this.ctx.windows.setPageCover(windowId, undefined) const view = w.view const page = pageOf(view) if (view && page) { page.send('orbit:library:hide', {}) clearTimeout(w.hideTimer) + // The page's own views count as covered until the Library's view has hidden: a permission + // prompt it uncovers takes no click before its choices have waited again (NV-15). w.hideTimer = setTimeout(() => { - if (!w.open && pageOf(view)) view.setVisible(false) + if (w.open) return + if (pageOf(view)) view.setVisible(false) + this.ctx.windows.setPageCover(windowId, undefined) }, EXIT_MS) + } else { + this.ctx.windows.setPageCover(windowId, undefined) } const win = this.ctx.windows.get(windowId) if (opts.focusPage !== false && win && !win.isDestroyed() && win.isFocused()) { diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 63448be5..27aa838d 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -31,8 +31,15 @@ export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { chooseMenu: (label: string, newTab?: boolean) => boolean /** A window's page tools (default: the first): what the find bar and zoom indicator show, and where (NV-05, NV-06). */ tools: (windowId?: WindowId) => PageToolsInfo - /** A window's permission prompt view (default: the first): placed while the sidebar is hidden (NV-15). */ - prompt: (windowId?: WindowId) => { placed: boolean; bounds?: { x: number; y: number; width: number; height: number } } + /** + * A window's permission prompt view (default: the first): placed while the sidebar is hidden + * (NV-15); `covered` while it is placed under a page cover such as the Library. + */ + prompt: (windowId?: WindowId) => { + placed: boolean + covered: boolean + bounds?: { x: number; y: number; width: number; height: number } + } /** The plain permission dialogs open for pages without a sidebar (Peek, Mini Window, pop-up), oldest first. */ permissionDialogs: () => PermissionDialogInfo[] /** Answers the oldest permission dialog with a button's label, or `cancel` (Esc). */ diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index 61d10ac2..074add4c 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -82,7 +82,7 @@ export interface Permissions { /** A permission's state as the site's pages should read it: "prompt" until the user decides. */ pageState(profileId: ProfileId, origin: string, kinds: PermissionKind[]): PagePermissionState /** Test hooks. */ - info(windowId: WindowId): { placed: boolean; bounds?: Rectangle } + info(windowId: WindowId): { placed: boolean; covered: boolean; bounds?: Rectangle } /** The plain dialogs asking for pages without a sidebar, oldest first (ORBIT_E2E: they wait for `answerDialog`). */ dialogs(): PermissionDialogInfo[] /** Answers the oldest dialog with one of its buttons, or `cancel` (Esc, or closing it). */ @@ -642,6 +642,7 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss if (pending) finish(pending, choice, pick) }) commands.register('permissions.thumbnails', ({ id }) => thumbnails.get(id) ?? {}) + commands.register('permissions.promptPlaced', (_args, c) => !!c.windowId && windows.promptOnScreen(c.windowId)) commands.register('permissions.promptSize', ({ height }, c) => { const windowId = c.windowId if (!windowId || !(height > 0)) return @@ -745,7 +746,7 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss }, info: (windowId) => { const placed = windows.promptInfo(windowId) - return { placed: placed.placed, bounds: placed.placed ? placed.bounds : undefined } + return { placed: placed.placed, covered: placed.covered, bounds: placed.placed ? placed.bounds : undefined } }, dialogs: () => testDialogs.map(({ message, detail, buttons, parent, requests }) => ({ diff --git a/src/main/windows.ts b/src/main/windows.ts index 379f5477..eea46d58 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -77,6 +77,11 @@ interface WindowRecord { */ prompt?: WebContentsView promptShown: boolean + /** + * What the prompt view's page was last told (`prompt:placed`): whether the view is on screen, + * placed and not under a page cover. Unset for a new page, which asks when it loads. + */ + promptSaid?: boolean /** * Other modules' small views over the pages (createUiView): the loading indicator (SB-28), the * link pill (SB-27), page dialogs (NV-24). Kept above the pages, closed with the window. @@ -707,14 +712,14 @@ export class WindowManager { /** * Places the permission prompt's view at `bounds` (window coordinates): above the pages, under - * the floating sidebar and the overlay (NV-15). Its page hears `prompt:placed` each time the view - * goes on or off screen: the card's choices wait a moment after it shows. + * the floating sidebar and the overlay (NV-15), and under a page cover such as the Library. Its + * page hears `prompt:placed` each time the view goes on or off screen, where under a cover counts + * as off: the card's choices wait a moment each time it shows (tellPrompt). */ showPrompt(windowId: WindowId, bounds: Rect): void { const rec = this.records.get(windowId) if (!rec || rec.win.isDestroyed()) return const view = this.ensurePrompt(rec) - const placing = !rec.promptShown const next = roundRect(bounds) const current = view.getBounds() if ( @@ -728,7 +733,7 @@ export class WindowManager { rec.promptShown = true this.placeAbovePages(rec, view) view.setVisible(true) - if (placing) viewContents(view)?.send('orbit:prompt:placed', true) + this.tellPrompt(rec) } /** Takes the permission prompt's view out of the window; keyboard focus in it goes back to the page. */ @@ -741,15 +746,37 @@ export class WindowManager { const focused = !!contents && webContents.getFocusedWebContents() === contents if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) this.removeChild(rec, view) - contents?.send('orbit:prompt:placed', false) + this.tellPrompt(rec) + } + + /** + * Whether a window's permission prompt view is on screen: placed, and not under a page cover + * (the prompt view's page asks when it loads; after that it hears each change). + */ + promptOnScreen(windowId: WindowId): boolean { + const rec = this.records.get(windowId) + return !!rec && rec.promptShown && !rec.cover } - /** Test/debug view of a window's permission prompt view. */ - promptInfo(windowId: WindowId): { exists: boolean; placed: boolean; bounds?: Rect } { + /** + * Tells the prompt view's page when the view goes on or off screen (`prompt:placed`), once per + * change. Under a page cover counts as off screen: clicks go to the cover, and the card's choices + * wait a moment again once the cover has gone (fix check B, M1). + */ + private tellPrompt(rec: WindowRecord): void { + const onScreen = rec.promptShown && !rec.cover + if (rec.promptSaid === onScreen) return + rec.promptSaid = onScreen + viewContents(rec.prompt)?.send('orbit:prompt:placed', onScreen) + } + + /** Test/debug view of a window's permission prompt view; `covered`: placed under a page cover. */ + promptInfo(windowId: WindowId): { exists: boolean; placed: boolean; covered: boolean; bounds?: Rect } { const rec = this.records.get(windowId) const view = rec?.prompt - if (!rec || !view || !viewContents(view)) return { exists: false, placed: false } - return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } + if (!rec || !view || !viewContents(view)) return { exists: false, placed: false, covered: false } + const placed = rec.win.contentView.children.includes(view) + return { exists: true, placed, covered: placed && !!rec.cover, bounds: view.getBounds() } } /** @@ -812,15 +839,16 @@ export class WindowManager { /** * Sets the view that covers a window's page area while it is open (the Library, AR-02), or clears - * it. The page tools, the permission prompt and the small views over the page (loading indicator, - * link pill) belong to the page: while a cover is set they go under it, not over it. The overlay - * and the floating sidebar stay above it. The cover places itself above the pages, and sets itself - * again after it moves. + * it once that view has hidden. The page tools, the permission prompt and the small views over + * the page (loading indicator, link pill) belong to the page: while a cover is set they go under + * it, not over it, and the prompt counts as off screen. The overlay and the floating sidebar stay + * above it. The cover places itself above the pages, and sets itself again after it moves. */ setPageCover(windowId: WindowId, view: WebContentsView | undefined): void { const rec = this.records.get(windowId) if (!rec || rec.win.isDestroyed()) return rec.cover = view + this.tellPrompt(rec) if (!view) return const children = rec.win.contentView.children for (const small of [rec.tools, rec.prompt, ...rec.uiViews]) { @@ -1366,6 +1394,7 @@ export class WindowManager { loadRenderer(view.webContents, 'prompt') rec.prompt = view rec.promptShown = false + rec.promptSaid = undefined return view } diff --git a/src/renderer/src/shell/chrome/PromptViewApp.tsx b/src/renderer/src/shell/chrome/PromptViewApp.tsx index e82b87e8..6fc626ad 100644 --- a/src/renderer/src/shell/chrome/PromptViewApp.tsx +++ b/src/renderer/src/shell/chrome/PromptViewApp.tsx @@ -9,23 +9,28 @@ import './chrome.css' * The permission prompt while the sidebar is hidden (NV-15): a small transparent view main keeps * over the active page's top-left corner. It holds the active tab's prompt and tells main how tall * the card is, so the view fits it. Main says when it puts the view on screen and takes it off - * (`prompt:placed`): the card's choices wait a moment each time it shows (`PromptGuard`), since - * the page knows where it will. + * (`prompt:placed`), where under the Library counts as off: the card's choices wait a moment each + * time it shows (`PromptGuard`), since the page knows where it will. */ export function PromptViewApp() { const win = useWindow() const palette = usePalette(win?.spaceId) const prompt = useActivePrompt(window.orbit.windowId) const said = useRef(0) - // The view is made when it is first placed; after that, main says each time. - const [placement, setPlacement] = useState({ placed: true, n: 0 }) - useEffect( - () => - window.orbit.on('prompt:placed', (placed) => { - setPlacement((was) => ({ placed: placed === true, n: was.n + 1 })) - }), - [], - ) + // Off screen until main says: the view can be made under the Library (fix check B, M1). + const [placement, setPlacement] = useState({ placed: false, n: 0 }) + useEffect(() => { + let heard = false + const off = window.orbit.on('prompt:placed', (placed) => { + heard = true + setPlacement((was) => ({ placed: placed === true, n: was.n + 1 })) + }) + // What main said before this page listened is lost: ask where the view is now. + void invoke('permissions.promptPlaced', {}).then((placed) => { + if (!heard) setPlacement((was) => (was.placed === placed ? was : { placed, n: was.n + 1 })) + }) + return off + }, []) const onSize = useCallback((height: number) => { const rounded = Math.ceil(height) if (rounded <= 0 || rounded === said.current) return diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index b2424527..6f3cab95 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -79,6 +79,11 @@ export interface NavCommands { 'permissions.answer': { args: { id: number; choice: PromptChoice; pick?: string }; result: void } /** A screen picker's previews: data URLs by source id. */ 'permissions.thumbnails': { args: { id: number }; result: Record<string, string> } + /** + * Whether the prompt view is on screen now (it asks when it loads; after that main says each + * time): placed while the sidebar is hidden, and not under a page cover such as the Library. + */ + 'permissions.promptPlaced': { args: Record<string, never>; result: boolean } /** The prompt view's card height, so its view fits it (sidebar hidden). */ 'permissions.promptSize': { args: { height: number }; result: void } /** Opens the site controls panel for the active tab, under a point in window coordinates (NV-16). */ diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index 2c3502d7..c8b3b002 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -1,5 +1,6 @@ import { type ElectronApplication, expect, type Frame, type Page, test } from '@playwright/test' -import { action, launchOrbit, type Orbit, state, tabPage } from './helpers' +import { PROMPT_GUARD_MS } from '../../src/shared/site' +import { action, launchOrbit, type Orbit, run, state, tabPage } from './helpers' import { closeWindow, cmd, @@ -9,6 +10,7 @@ import { shellRoundTrip, startNavFixtures, uiPageOf, + viewStack, visit, waitLoaded, windowList, @@ -643,6 +645,174 @@ test('[NV-15] a prompt takes no clicks as it shows, nor while they keep coming; await expect.poll(() => answerOf(p, 'camera')).toBe('granted') }) +/** Where the prompt view's first "Allow" button is, in the view's coordinates. */ +async function allowPoint(view: Page): Promise<{ x: number; y: number }> { + const box = await view.getByTestId('permission-allow').boundingBox() + if (!box) throw new Error('no Allow button') + return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } +} + +/** One real click (input events, not script) on the prompt view at a point. */ +const clickPrompt = (app: ElectronApplication, point: { x: number; y: number }) => + app.evaluate(({ webContents }, at) => { + const prompt = webContents.getAllWebContents().find((c) => c.getURL().includes('view=prompt')) + if (!prompt) throw new Error('no prompt view') + prompt.sendInputEvent({ type: 'mouseDown', x: at.x, y: at.y, button: 'left', clickCount: 1 }) + prompt.sendInputEvent({ type: 'mouseUp', x: at.x, y: at.y, button: 'left', clickCount: 1 }) + }, point) + +/** How many clicks the prompt view's page has handled (a listener of its own, after the card's). */ +const promptClicks = (view: Page) => + view.evaluate(() => { + const w = window as unknown as { __clicks?: number } + if (w.__clicks === undefined) { + w.__clicks = 0 + window.addEventListener('click', () => { + w.__clicks = (w.__clicks ?? 0) + 1 + }) + } + return w.__clicks + }) + +/** + * Waits until the prompt view's page has handled `count` clicks in all, then until main has + * handled any call they made (calls from one page reach main in order). + */ +async function promptRoundTrip(view: Page, count: number): Promise<void> { + await expect.poll(() => promptClicks(view)).toBe(count) + await view.evaluate(() => + (window as unknown as { orbit: { invoke: (n: string, a: unknown) => Promise<unknown> } }).orbit.invoke( + 'app.info', + {}, + ), + ) +} + +/** The prompt card's `data-armed` values seen in the prompt view's page every 40 ms for `ms` ms. */ +const armedOver = (view: Page, ms: number) => + view.evaluate(async (span) => { + const seen = new Set<string>() + const end = performance.now() + span + while (performance.now() < end) { + const card = document.querySelector('[data-testid="permission-prompt"]') + seen.add(card?.getAttribute('data-armed') ?? 'no card') + await new Promise((r) => setTimeout(r, 40)) + } + return [...seen] + }, ms) + +/** + * Closes the first window's Library and clicks the prompt view's "Allow" once (a real click) the + * moment the Library's view has hidden. Says whether the prompt counted as covered for as long as + * the Library's view showed. + */ +const closeLibraryAndClick = (app: ElectronApplication, point: { x: number; y: number }) => + app.evaluate(async ({ webContents }, at) => { + const g = globalThis as unknown as { + __orbit: { run: (n: string, a: unknown, w?: string) => unknown; windowIds: () => string[] } + __orbitNav: { prompt: () => { placed: boolean; covered?: boolean } } + } + const windowId = g.__orbit.windowIds()[0] + const prompt = webContents.getAllWebContents().find((c) => c.getURL().includes('view=prompt')) + if (!prompt) throw new Error('no prompt view') + const libraryShown = async () => + ((await g.__orbit.run('library.testInfo', {}, windowId)) as { visible: boolean }).visible + await g.__orbit.run('library.close', {}, windowId) + let uncoveredEarly = false + const deadline = Date.now() + 5000 + while (await libraryShown()) { + if (g.__orbitNav.prompt().covered !== true) uncoveredEarly = true + if (Date.now() > deadline) throw new Error('the Library did not hide') + await new Promise((r) => setTimeout(r, 2)) + } + prompt.sendInputEvent({ type: 'mouseDown', x: at.x, y: at.y, button: 'left', clickCount: 1 }) + prompt.sendInputEvent({ type: 'mouseUp', x: at.x, y: at.y, button: 'left', clickCount: 1 }) + return { uncoveredEarly } + }, point) + +/** Opens the first window's Library and waits until its view shows. */ +async function openLibrary(app: ElectronApplication): Promise<void> { + await run(app, 'library.open', {}) + await expect + .poll(() => run(app, 'library.testInfo') as Promise<{ open: boolean; visible: boolean }>) + .toMatchObject({ open: true, visible: true }) +} + +test('[NV-15][AR-02] a prompt the closing Library uncovers takes no click at once; a click after a pause answers (fix check B, M1)', async () => { + const { app } = orbit + const { id, p } = await open(app, page('Uncovered')) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + await settledPage(app, id) + await ask(p, 'camera') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + const view = await promptPage(app) + const card = view.getByTestId('permission-prompt') + // The prompt has been on screen long enough: a click now would answer it. + await expect(card).toHaveAttribute('data-armed', 'true') + const at = await allowPoint(view) + const clicks = await promptClicks(view) + + // The Library opens over the page and its prompt, then closes: one click on Allow the moment the + // Library's view has hidden. + await openLibrary(app) + const { uncoveredEarly } = await closeLibraryAndClick(app, at) + await promptRoundTrip(view, clicks + 1) + // The click came as the prompt showed again: the request still waits. + expect((await state(app)).tabs[id]?.permissionPrompt?.type).toBe('permission') + expect(await answerOf(p, 'camera')).toBeNull() + // It counted as covered, off screen, until the Library's view had hidden. + expect(uncoveredEarly).toBe(false) + + // After a pause, the user's click answers. + await expect(view.getByTestId('permission-allow')).toBeEnabled() + await clickPrompt(app, at) + await expect.poll(() => answerOf(p, 'camera')).toBe('granted') +}) + +test('[NV-15][AR-02] a prompt that comes while the Library is open waits under it, its choices off; once the Library hides, a click at once does nothing (fix check B, M1)', async () => { + const { app } = orbit + const { id, p } = await open(app, page('UnderLibrary')) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + await settledPage(app, id) + /** Whether the prompt view is above the page and under the Library. */ + const underLibrary = (stack: string[]) => + stack.indexOf('view=prompt') > stack.indexOf(`page:${id}`) && + stack.indexOf('view=prompt') < stack.indexOf('view=library') + + // The window's first prompt comes while the Library is open: its view starts under the Library. + await openLibrary(app) + await ask(p, 'camera') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + await expect.poll(async () => underLibrary(await viewStack(app))).toBe(true) + const view = await promptPage(app) + await expect(view.getByTestId('permission-prompt')).toBeVisible() + // Under the Library it counts as off screen: its choices stay off for longer than the guard. + expect(await armedOver(view, PROMPT_GUARD_MS + 300)).toEqual(['false']) + const at = await allowPoint(view) + await promptClicks(view) + await closeLibraryAndClick(app, at) + await promptRoundTrip(view, 1) + expect((await state(app)).tabs[id]?.permissionPrompt?.type).toBe('permission') + await expect(view.getByTestId('permission-allow')).toBeEnabled() + await clickPrompt(app, at) + await expect.poll(() => answerOf(p, 'camera')).toBe('granted') + await expect.poll(async () => (await promptView(app)).placed).toBe(false) + + // Another prompt while the Library is open, now that the prompt view's page is up. + await openLibrary(app) + await ask(p, 'location') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + await expect.poll(async () => underLibrary(await viewStack(app))).toBe(true) + await expect(view.getByTestId('permission-prompt')).toHaveAttribute('data-kinds', 'location') + expect(await armedOver(view, PROMPT_GUARD_MS + 300)).toEqual(['false']) + await closeLibraryAndClick(app, await allowPoint(view)) + await promptRoundTrip(view, 3) + expect((await state(app)).tabs[id]?.permissionPrompt?.type).toBe('permission') + expect(await answerOf(p, 'location')).toBeNull() +}) + test('[NV-15] a site asking for a serial port gets a device chooser; Connect waits for a pick, Cancel refuses', async () => { const { app, shell } = orbit const { p } = await open(app, page('Ports')) From 9dc79d99b02999ca24ad90349883c954b577e13f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:12:31 +0000 Subject: [PATCH 45/69] fix(nav): a web page can't have Orbit open or save a local file (fix check B, M2; D49) pageMayOpen(from, url) in shared/link-routing: a file: address (or the source view of one) loads only for a page that is itself a file: page, as the engine allows a plain click. - Page menu: a file: link or image on a page that isn't a file: page keeps only Copy Link Address, Copy Image and Copy Image Address. Open Link in New Tab, Split View, New Window, Private Window, Save Link As..., Open Image in New Tab and Save Image As... are left out. - Option-click: both branches (a link in the page, a link in a frame) ignore it. - A link or text dropped on the sidebar never opens a local file's address: where it was dragged from isn't known, and the engine gives no address for a file dragged from the system. Checked and already refused by the engine, so unchanged: plain, Cmd-, middle-, Shift- and Shift-Cmd-click, target=_blank, window.open with and without features, a blocked pop-up, a pop-up's own window.open and navigation, and a server redirect to a file: address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- FEATURES.md | 6 +- docs/feature-status.json | 6 +- src/main/nav/links.ts | 9 +- src/main/tabs/context-menu.ts | 38 +++-- .../src/shell/sidebar/SidebarItems.tsx | 5 + src/shared/link-routing.ts | 15 ++ tests/e2e/nav-links.spec.ts | 148 +++++++++++++++++- tests/unit/link-routing.test.ts | 29 ++++ 8 files changed, 235 insertions(+), 21 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 1a5001b0..5f4bce7a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -56,7 +56,7 @@ Spec: `spec/tabs.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | TB-01 | Tab row anatomy and states | P0 | ✅ | ✅ e2e+unit | – | row: favicon/emoji, title or breadcrumb, hover close, audio, selected pill, dimmed when asleep | -| TB-02 | Create a new tab | P0 | ✅ | ✅ e2e | – | Cmd+T / + New Tab / link drop / Cmd+Alt+V paste; background link tabs gesture-gated | +| TB-02 | Create a new tab | P0 | ✅ | ✅ e2e | – | Cmd+T / + New Tab / link drop (a local file address dropped opens nothing, D49) / Cmd+Alt+V paste; background link tabs gesture-gated | | TB-03 | Where new tabs are inserted | P0 | ✅ | ✅ e2e+unit | – | single rule todayInsertIndex(): top of Today | | TB-04 | Switching between tabs (pointer and keyboard) | P0 | ✅ | ✅ e2e+unit | – | click, Cmd+1-9 (Favorites setting, Cmd+9 last), Cmd+Alt+Up/Down skip collapsed rows | | TB-05 | Recent-tab switcher (Ctrl+Tab) | P1 | 🟡 partial | ✅ e2e | – | Ctrl+Tab / Ctrl+Shift+Tab step through the 5 most recent tabs while Ctrl is held (keyboard only); no overlay list yet | @@ -235,11 +235,11 @@ Spec: `spec/navigation.md` | 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; 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-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; 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-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 link only from a local file page, 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 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 | | 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, 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), 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-12 | Page context menu | P0 | ✅ | ✅ e2e | – | native menu: page (Back, Forward, Reload, Save Page As, 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), 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-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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index 8634c0c6..7b248608 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -20,7 +20,7 @@ "SB-27": {"built": "yes", "note": "hovering (or focusing) a link shows a theme-styled pill with where it goes (no scheme) at the bottom-left of that page card, in its own small view; the full URL after 1.5 s on the link, kept for the next links while it stays up; moves to the other corner when the pointer comes within 24 px, out of the way if near both; goes 120 ms after the link is left; not on Peek pages"}, "SB-28": {"built": "yes", "note": "loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; it leaves the window when hidden; shows in native full screen, not over a page in HTML full screen (back when that ends if still loading); tab rows keep their favicon loading ring"}, "TB-01": {"built": "yes", "note": "row: favicon/emoji, title or breadcrumb, hover close, audio, selected pill, dimmed when asleep"}, - "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop / Cmd+Alt+V paste; background link tabs gesture-gated"}, + "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop (a local file address dropped opens nothing, D49) / Cmd+Alt+V paste; background link tabs gesture-gated"}, "TB-03": {"built": "yes", "note": "single rule todayInsertIndex(): top of Today"}, "TB-04": {"built": "yes", "note": "click, Cmd+1-9 (Favorites setting, Cmd+9 last), Cmd+Alt+Up/Down skip collapsed rows"}, "TB-05": {"built": "partial", "note": "Ctrl+Tab / Ctrl+Shift+Tab step through the 5 most recent tabs while Ctrl is held (keyboard only); no overlay list yet"}, @@ -181,11 +181,11 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), 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; 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-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; 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-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 link only from a local file page, 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 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"}, "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, 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), 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-12": {"built": "yes", "note": "native menu: page (Back, Forward, Reload, Save Page As, 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), 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-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/nav/links.ts b/src/main/nav/links.ts index f747932a..f55db60c 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -1,4 +1,5 @@ import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type Referrer, type WebContents } from 'electron' +import { pageMayOpen } from '../../shared/link-routing' import { type PageReferrer, popupTitle, referrerPolicyName, shownTabId } from '../../shared/nav' import type { TabId, WindowId } from '../../shared/state' import { sectionOf } from '../../shared/tree' @@ -160,12 +161,14 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks } if (contents.isDestroyed()) return const until = Date.now() + ALT_CLICK_DOWNLOAD_MS + // A local file opens from a page that is a local file too, never from a web page (D49). + const page = contents.getURL() if (found?.href) { - if (!openable(found.href)) return + if (!openable(found.href) || !pageMayOpen(page, found.href)) return altClicks.set(tabId, { url: found.href, until, split: false }) // Sent with the page as referrer, as the link's policy or the page's allows (review B-F1). - openInSplit(tabId, found.href, { url: contents.getURL(), policy: referrerPolicyName(found.policy) }) - } else if (found?.frame && openable(fallback)) { + openInSplit(tabId, found.href, { url: page, policy: referrerPolicyName(found.policy) }) + } else if (found?.frame && openable(fallback) && pageMayOpen(page, fallback)) { // A link in a frame: the engine's download of it says which, and opens the split. altClicks.set(tabId, { url: fallback, until, split: true }) } diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index d51d9462..2ab27929 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -1,4 +1,5 @@ import { type ContextMenuParams, clipboard, Menu, type MenuItemConstructorOptions, type WebContents } from 'electron' +import { pageMayOpen } from '../../shared/link-routing' import type { TabId } from '../../shared/state' import { engineFor, searchUrl } from '../../shared/url' import type { MainContext } from '../context' @@ -37,25 +38,40 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We if (!openLinkInTab(ctx, tabId, url, referrer)) openInNewTab(url, false) } + // A local file's link or picture on a page that isn't a local file keeps only its copy items: + // Orbit opens or saves it for no web page (D49). + const mayOpen = (url: string) => pageMayOpen(referrer.url, url) + if (params.linkURL) { + const link = params.linkURL + const open: MenuItemConstructorOptions[] = mayOpen(link) + ? [ + { label: 'Open Link in New Tab', click: () => openFromPage(link) }, + // A new pane beside the page's pane (SV-08). + linkSplitMenuItem(ctx, tabId, link), + // Then Peek and Mini Window, new and private windows: the order NV-12 lists. + ...linkPeekMenuItems(ctx, tabId, link), + ...linkWindowItems(ctx, tabId, link, referrer), + ] + : [] items.push( - { label: 'Open Link in New Tab', click: () => openFromPage(params.linkURL) }, - // A new pane beside the page's pane (SV-08). - linkSplitMenuItem(ctx, tabId, params.linkURL), - // Then Peek and Mini Window, new and private windows: the order NV-12 lists. - ...linkPeekMenuItems(ctx, tabId, params.linkURL), - ...linkWindowItems(ctx, tabId, params.linkURL, referrer), - { label: 'Copy Link Address', click: () => clipboard.writeText(params.linkURL) }, - saveLinkItem(contents, params.linkURL), + ...open, + { label: 'Copy Link Address', click: () => clipboard.writeText(link) }, + ...(mayOpen(link) ? [saveLinkItem(contents, link)] : []), { type: 'separator' }, ) } if (params.mediaType === 'image' && params.srcURL) { + const image = params.srcURL + if (mayOpen(image)) { + items.push( + { label: 'Open Image in New Tab', click: () => openFromPage(image) }, + { label: 'Save Image As…', click: () => contents.downloadURL(image) }, + ) + } items.push( - { label: 'Open Image in New Tab', click: () => openFromPage(params.srcURL) }, - { label: 'Save Image As…', click: () => contents.downloadURL(params.srcURL) }, { label: 'Copy Image', click: () => contents.copyImageAt(params.x, params.y) }, - { label: 'Copy Image Address', click: () => clipboard.writeText(params.srcURL) }, + { label: 'Copy Image Address', click: () => clipboard.writeText(image) }, { type: 'separator' }, ) } diff --git a/src/renderer/src/shell/sidebar/SidebarItems.tsx b/src/renderer/src/shell/sidebar/SidebarItems.tsx index 53f3f2fd..aa7deffa 100644 --- a/src/renderer/src/shell/sidebar/SidebarItems.tsx +++ b/src/renderer/src/shell/sidebar/SidebarItems.tsx @@ -12,6 +12,7 @@ import { useState, } from 'react' import { isFolderId, isTabId } from '../../../../shared/ids' +import { pageMayOpen } from '../../../../shared/link-routing' import type { Space, SpaceId } from '../../../../shared/state' import { containerKey, locate, sectionOf, splitOfTab } from '../../../../shared/tree' import { engineForSpace, parseInput } from '../../../../shared/url' @@ -283,6 +284,10 @@ function onLinkDrop(e: ReactDragEvent, spaceId: string) { if (!text) return e.preventDefault() const parsed = parseInput(text, engineForSpace(getOrbitState(), spaceId)) + // Where it was dragged from isn't known, and a web page may have offered it: a local file's + // address opens only when typed or pasted (D49). The engine gives no address for a file dragged + // from the system. + if (!pageMayOpen(undefined, parsed.url)) return void invoke('tabs.create', { url: parsed.url, spaceId }) } diff --git a/src/shared/link-routing.ts b/src/shared/link-routing.ts index 26cf7b12..74060aac 100644 --- a/src/shared/link-routing.ts +++ b/src/shared/link-routing.ts @@ -45,6 +45,21 @@ export function isWebUrl(url: string): boolean { return /^https?:\/\//i.test(url) } +/** A local file's address: `file:`, or the source view of one. */ +export function isLocalFileUrl(url: string): boolean { + return /^(view-source:)?file:/i.test(url.trim()) +} + +/** + * Whether Orbit may itself load or save `url` for the page at `from`, as it does for the page menu, + * ⌥-click and a dropped link: a local file only for a page that is a local file too, as the engine + * allows a plain click (D49). `from` is empty when the page isn't known. Other addresses are left + * to each route's own rules. + */ +export function pageMayOpen(from: string | undefined, url: string): boolean { + return !isLocalFileUrl(url) || /^file:/i.test(from?.trim() ?? '') +} + /** Same registrable domain (eTLD+1): `mail.example.com` and `docs.example.com` are one site (PK-02). */ export function sameSite(a: string, b: string): boolean { if (!isWebUrl(a) || !isWebUrl(b)) return false diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 2465bce9..d88ba824 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -1,6 +1,7 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { type ElectronApplication, expect, test } from '@playwright/test' import type { SplitLayout } from '../../src/shared/state' import { isMac, launchOrbit, type Orbit, state, tabPage } from './helpers' @@ -574,3 +575,148 @@ test('[NV-12] Open Link in New Window and in Private Window', async () => { ) expect(sessions).toEqual([false, true]) }) + +// ---- Local files from web pages (D49, fix check B M2) ---- + +/** A 1×1 PNG. */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +) + +/** A text file and a picture in a new temporary folder, with their file: addresses. */ +function localFiles(): { folder: string; text: string; image: string } { + const folder = mkdtempSync(join(tmpdir(), 'orbit-e2e-local-')) + writeFileSync(join(folder, 'orbit-local.txt'), 'orbit-local-file') + writeFileSync(join(folder, 'orbit-local.png'), PNG) + return { + folder: pathToFileURL(folder).href, + text: pathToFileURL(join(folder, 'orbit-local.txt')).href, + image: pathToFileURL(join(folder, 'orbit-local.png')).href, + } +} + +/** Records from now on every page, new ones too, that starts loading an address under `prefix`. */ +async function watchLoads(app: ElectronApplication, prefix: string): Promise<() => Promise<string[]>> { + await app.evaluate(({ app: electronApp, webContents }, p) => { + const g = globalThis as unknown as { __loads: string[] } + g.__loads = [] + const watch = (contents: Electron.WebContents) => + contents.on('did-start-navigation', (details) => { + if (details.url.startsWith(p)) g.__loads.push(details.url) + }) + for (const contents of webContents.getAllWebContents()) watch(contents) + electronApp.on('web-contents-created', (_e, contents) => watch(contents)) + }, prefix) + return () => app.evaluate(() => (globalThis as unknown as { __loads: string[] }).__loads) +} + +/** The items that would open or save a link or an image. */ +const OPEN_OR_SAVE = [ + 'Open Link in New Tab', + 'Open Link in Split View', + 'Open Link in Peek', + 'Open Link in Mini Window', + 'Open Link in New Window', + 'Open Link in Private Window', + 'Save Link As…', + 'Open Image in New Tab', + 'Save Image As…', +] + +test('[NV-12][NV-08] a web page’s link to a local file and its local picture: the page menu offers no way to open or save them, ⌥-click opens nothing (D49, fix check B M2)', async () => { + const { app } = orbit + const local = localFiles() + const loads = await watchLoads(app, local.folder) + const web = fx.url(`/page/Web?away=${encodeURIComponent(local.text)}`) + const id = await openTab(app, web) + const tab = await tabPage(app, '/page/Web') + await tab.evaluate((src) => { + document.body.insertAdjacentHTML('afterbegin', `<img id="local" src="${src}" alt="Local" width="48" height="48">`) + }, local.image) + const downloads = await watchDownloads(app, web) + + // The link: only Copy Link Address. + await clickInPage(app, web, '#away', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + const linkMenu = (await pageMenu(app))?.labels ?? [] + expect(linkMenu.filter((l) => OPEN_OR_SAVE.includes(l))).toEqual([]) + expect(linkMenu).toContain('Copy Link Address') + // The picture: copying stays. + await clickInPage(app, web, '#local', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain('Copy Image Address') + const imageMenu = (await pageMenu(app))?.labels ?? [] + expect(imageMenu.filter((l) => OPEN_OR_SAVE.includes(l))).toEqual([]) + expect(imageMenu).toContain('Copy Image') + + // ⌥-click on it opens no pane; ⌥-click on a web link after it does, alone. + await clickInPage(app, web, '#away', ['alt']) + await clickInPage(app, web, '#next', ['alt']) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [, right] = await splitTabs(app, id) + expect(await urlOf(app, right)).toBe(page('Web-next')) + + // No page ever started loading the local files, and nothing downloaded them. + expect(await loads()).toEqual([]) + expect(Object.values((await state(app)).tabs).filter((t) => t.url.startsWith(local.folder))).toEqual([]) + expect((await downloads()).filter((d) => d.url.startsWith(local.folder))).toEqual([]) +}) + +test('[NV-12][NV-08] a local file page still opens its links to local files: Open Link in New Tab and ⌥-click (D49)', async () => { + const { app } = orbit + const local = localFiles() + const folder = new URL(local.folder.endsWith('/') ? local.folder : `${local.folder}/`) + const first = new URL('first.html', folder).href + const second = new URL('second.html', folder).href + writeFileSync( + new URL(first), + '<!doctype html><title>First

Second

', + ) + writeFileSync(new URL(second), 'Second

Second local page

') + const id = await openTab(app, first) + await clickInPage(app, first, '#second', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(id) + const labels = (await pageMenu(app))?.labels ?? [] + expect(labels).toEqual(expect.arrayContaining(['Open Link in New Tab', 'Open Link in Split View', 'Save Link As…'])) + expect(await choosePageMenu(app, 'Open Link in New Tab')).toBe(true) + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(second) + const opened = (await today(app))[0] as string + await cmd(app, 'tabs.activate', { tabId: opened }) + await expect.poll(() => loadedTitle(app, second)).toBe('Second') + // ⌥-click in the local page: the other local page beside it. + await cmd(app, 'tabs.activate', { tabId: id }) + await expect.poll(() => selectedTab(app)).toBe(id) + await clickInPage(app, first, '#second', ['alt']) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [, right] = await splitTabs(app, id) + expect(await urlOf(app, right)).toBe(second) +}) + +test('[TB-02] a local file’s address dropped on the sidebar opens nothing, as a link or as text; a web link dropped after it opens (D49)', async () => { + const { app, shell } = orbit + const local = localFiles() + const loads = await watchLoads(app, local.folder) + /** Drops `text` on the sidebar as data of `type`, as a link or a selection dragged from a page does. */ + const drop = async (type: string, text: string) => { + const dt = await shell.evaluateHandle( + ([t, u]) => { + const d = new DataTransfer() + d.setData(t, u) + return d + }, + [type, text] as const, + ) + await shell.getByTestId('sidebar-scroll').dispatchEvent('dragover', { dataTransfer: dt }) + await shell.getByTestId('sidebar-scroll').dispatchEvent('drop', { dataTransfer: dt }) + } + await drop('text/uri-list', local.text) + await drop('text/plain', local.text) + await drop('text/plain', `view-source:${local.text}`) + await drop('text/uri-list', page('DroppedAfter')) + await expect.poll(async () => urlOf(app, (await today(app))[0])).toBe(page('DroppedAfter')) + expect(await today(app)).toHaveLength(1) + expect(Object.values((await state(app)).tabs).map((t) => t.url)).not.toContainEqual( + expect.stringContaining('orbit-local'), + ) + expect(await loads()).toEqual([]) +}) diff --git a/tests/unit/link-routing.test.ts b/tests/unit/link-routing.test.ts index 5a1bf768..6687240e 100644 --- a/tests/unit/link-routing.test.ts +++ b/tests/unit/link-routing.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { decideLink, + isLocalFileUrl, isMeetingLink, type LinkSettings, type LinkSource, + pageMayOpen, routeFor, sameSite, } from '../../src/shared/link-routing' @@ -142,6 +144,33 @@ describe('meeting links (PK-04)', () => { }) }) +describe('local files a page may have Orbit open (D49)', () => { + const file = 'file:///tmp/orbit/notes.txt' + it('a web page: no local file, nor its source; web and other links stay', () => { + expect(pageMayOpen('https://example.com/a', file)).toBe(false) + expect(pageMayOpen('http://localhost:3330/page/A', 'FILE:///tmp/x')).toBe(false) + expect(pageMayOpen('https://example.com/a', `view-source:${file}`)).toBe(false) + expect(pageMayOpen('https://example.com/a', 'https://example.org/b')).toBe(true) + expect(pageMayOpen('https://example.com/a', 'mailto:someone@example.com')).toBe(true) + }) + it('a page that is itself a local file opens local files', () => { + expect(pageMayOpen('file:///tmp/orbit/index.html', file)).toBe(true) + expect(pageMayOpen('file:///tmp/orbit/index.html', 'https://example.org/b')).toBe(true) + }) + it('a page not known (a dropped link), or a blank, data or source page, is not a local file', () => { + for (const from of [undefined, '', 'about:blank', 'data:text/html,x', `view-source:${file}`]) { + expect(pageMayOpen(from, file)).toBe(false) + } + }) + it('which addresses are local files', () => { + expect(isLocalFileUrl(file)).toBe(true) + expect(isLocalFileUrl(` ${file}`)).toBe(true) + expect(isLocalFileUrl('view-source:file:///etc/hosts')).toBe(true) + expect(isLocalFileUrl('https://example.com/file:///x')).toBe(false) + expect(isLocalFileUrl('filesystem:https://example.com/temporary/x')).toBe(false) + }) +}) + describe('link routing rules (ST-09)', () => { it('matches "contains" and "exact" rules, first rule wins', () => { const rules = [ From a3c8f70d60a8ec26566b7ba27a57e5ced1c0f37e Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:27:56 +0000 Subject: [PATCH 46/69] fix(nav): Option-click finds the link at the click, not where the page's hover is (fix check B, F1) nav-links:278 (Option-click split, Cmd-Option-click Mini Window) failed 4 of 20 alone. The Mini Window opens over the X pointer, and the real pointer's crossing moves the main page's :hover off #next. Two things read :hover after that: - the product: Option-click asked the page for its :hover link after the press, so the lookup could find no link (no split, the engine's download went ahead) or the link hovered before. It now asks for the link at the press's own point (elementFromPoint through open shadow roots, divided by the zoom, as Peek's click tracker does). - the test helper: clickInPage sent one mouseMove, then waited for :hover. The wait is now hoverInPage, which sends the move again while it waits. New test: Option-click on a busy page, with the pointer leaving before the page answers, opens the clicked link (fails 5 of 5 without the product change). Co-Authored-By: Claude Opus 5.5 --- src/main/nav/links.ts | 33 +++++++++++++---------- tests/e2e/nav-links.spec.ts | 29 ++++++++++++++++++++ tests/e2e/nav-support.ts | 53 ++++++++++++++++++++++++++++--------- 3 files changed, 89 insertions(+), 26 deletions(-) diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index f55db60c..980686e0 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -16,21 +16,23 @@ const ALT_CLICK_DOWNLOAD_MS = 15_000 const BLOCKED_SHOWN = 5 /** - * The link under the pointer in the page's main frame (through open shadow roots), as - * `{ href, policy }`; `{ frame: true }` when the pointer is over a frame or something Orbit can't + * The link at a point of the page's main frame, in CSS pixels (through open shadow roots), as + * `{ href, policy }`; `{ frame: true }` when the point is over a frame or something Orbit can't * look into (the link may be inside); null over anything else. `policy` is the referrer policy the * link sets for itself (`rel=noreferrer`, `referrerpolicy`) or else the page's * ``; a policy the page sets in its response headers is out of reach. + * + * The click's own point, not the page's `:hover`: the page's hover follows the pointer it was last + * told about, which can change between the press and this lookup (fix check B, F1). */ -const HOVERED_LINK = `(() => { +const linkAt = (x: number, y: number) => `(() => { let root = document, el = null for (let i = 0; i < 16; i++) { - const list = root.querySelectorAll(':hover') - const last = list[list.length - 1] - if (!last) break - el = last - if (!last.shadowRoot) break - root = last.shadowRoot + const hit = root.elementFromPoint(${x}, ${y}) + if (!hit || hit === el) break + el = hit + if (!hit.shadowRoot) break + root = hit.shadowRoot } if (!el) return null for (let n = el; n; n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null) { @@ -47,7 +49,7 @@ const HOVERED_LINK = `(() => { return tag === 'iframe' || tag === 'frame' || tag === 'object' || tag === 'embed' || el.shadowRoot ? { frame: true } : null })()` -interface Hovered { +interface PageLink { href?: string policy?: string frame?: boolean @@ -147,15 +149,18 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks .catch(() => {}) } - const onAltClick = async (tabId: TabId, contents: WebContents) => { + /** An ⌥-click at `at` (the press, in the page's view): the link there, in a pane beside the page. */ + const onAltClick = async (tabId: TabId, contents: WebContents, at: { x: number; y: number }) => { const windowId = windows.windowOfTab(tabId) // Only a page that is one of a browser window's panes (not Peek, a Mini Window or a pop-up). if (!windowId || store.state.windows[windowId]?.kind === 'mini') return if (!shownTabs(store.state, windowId).has(tabId)) return const fallback = hovered.get(tabId) - let found: Hovered | null + const zoom = contents.getZoomFactor() || 1 + let found: PageLink | null try { - found = (await contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code: HOVERED_LINK }])) as Hovered | null + const code = linkAt(at.x / zoom, at.y / zoom) + found = (await contents.executeJavaScriptInIsolatedWorld(NAV_WORLD, [{ code }])) as PageLink | null } catch { found = { frame: true } } @@ -181,7 +186,7 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks }) contents.on('before-mouse-event', (_e, mouse) => { if (mouse.type !== 'mouseDown' || mouse.button !== 'left' || !altOnly(mouse)) return - void onAltClick(tabId, contents) + void onAltClick(tabId, contents, { x: mouse.x, y: mouse.y }) }) contents.on('did-create-window', (popup, details) => keepHostTitle(popup, details.url)) contents.once('destroyed', () => { diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index d88ba824..8d2139b7 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -9,9 +9,11 @@ import { blockedMenu, chooseBlocked, choosePageMenu, + clickEvents, clickInPage, cmd, foreignWindowTitles, + hoverInPage, type NavFixtures, openTab, pageMenu, @@ -327,6 +329,33 @@ test('[NV-08][SV-08] ⌥-click (Alt-click) opens the link in split view beside t expect(await urlOf(app, id)).toBe(page('Alted')) }) +test('[NV-08][SV-08] ⌥-click opens the link under the click, though the pointer leaves the page before a busy page answers which (fix check B, F1)', async () => { + const { app } = orbit + const url = page('Busy') + const id = await openTab(app, url) + const downloads = await watchDownloads(app, url) + const next = await hoverInPage(app, url, '#next') + await hoverInPage(app, url, '#child') + // The page is busy for a moment. Meanwhile ⌥-click comes on the link above the pointer, and the + // pointer leaves the page, as when a window opens over the real one. + const events = [...clickEvents(next.x, next.y, ['alt']), { type: 'mouseLeave', x: next.x, y: next.y }] + await app.evaluate( + ({ webContents }, [u, list]) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL() === u) + if (!contents) throw new Error(`no page ${u}`) + void contents.executeJavaScript( + '(() => { const end = performance.now() + 400; while (performance.now() < end); })()', + ) + for (const event of list) contents.sendInputEvent(event as unknown as Electron.MouseInputEvent) + }, + [url, events] as const, + ) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [, right] = await splitTabs(app, id) + expect(await urlOf(app, right)).toBe(page('Busy-next')) + await expect.poll(downloads, { timeout: 10_000 }).toEqual([{ url: page('Busy-next'), cancelled: true }]) +}) + test('[NV-09] a target=_blank link opens a new tab at the top of Today and selects it', async () => { const { app } = orbit const opener = await openTab(app, page('Blank')) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index c03be136..06ef3667 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -333,7 +333,7 @@ export function chooseBlocked(app: ElectronApplication, label: string): Promise< return app.evaluate((_e, l) => (globalThis as unknown as Hooks).__orbitNav.chooseBlocked(l), label) } -type Modifier = 'shift' | 'control' | 'alt' | 'meta' +export type Modifier = 'shift' | 'control' | 'alt' | 'meta' type InputEventInit = { type: string } & Record /** Runs a script in the page with this address (or a part of it); null while there's none or it loads. */ @@ -350,7 +350,7 @@ function inPage(app: ElectronApplication, page: string, code: string): Promis } /** Sends input events to the page with this address (or a part of it) as the hardware would. */ -async function sendToPage(app: ElectronApplication, page: string, events: InputEventInit[]): Promise { +export async function sendToPage(app: ElectronApplication, page: string, events: InputEventInit[]): Promise { await app.evaluate( ({ webContents }, [part, list]) => { const matches = (url: string) => (part.startsWith('http') ? url === part : url.includes(part)) @@ -363,18 +363,16 @@ async function sendToPage(app: ElectronApplication, page: string, events: InputE } /** - * Clicks an element of a page the way a person does, through the browser process: the pointer - * goes onto it first (until the page has it hovered), then the modifier keys go down, the button - * is pressed and released, and the keys come up, so Orbit sees the keys as well as the page. + * Moves the pointer onto the middle of an element of a page until the page has it hovered, and + * returns that point. The move goes again while waiting: the real pointer's own crossing (a window + * opening over it, say) can take the page's hover back after it (fix check B, F1). * `page` is the page's full address (matched exactly), or a part of it. */ -export async function clickInPage( +export async function hoverInPage( app: ElectronApplication, page: string, selector: string, - modifiers: Modifier[] = [], - button: 'left' | 'middle' | 'right' = 'left', -): Promise { +): Promise<{ x: number; y: number }> { const sel = JSON.stringify(selector) const deadline = Date.now() + 10_000 let at: { x: number; y: number } | null = null @@ -388,8 +386,39 @@ export async function clickInPage( } if (!at) throw new Error(`no ${selector} in ${page}`) const { x, y } = at - await sendToPage(app, page, [{ type: 'mouseMove', x, y }]) - await expect.poll(() => inPage(app, page, `!!document.querySelector(${sel})?.matches(':hover')`)).toBe(true) + await expect + .poll(async () => { + await sendToPage(app, page, [{ type: 'mouseMove', x, y }]) + return inPage(app, page, `!!document.querySelector(${sel})?.matches(':hover')`) + }) + .toBe(true) + return { x, y } +} + +/** + * Clicks an element of a page the way a person does, through the browser process: the pointer + * goes onto it first (until the page has it hovered), then the modifier keys go down, the button + * is pressed and released, and the keys come up, so Orbit sees the keys as well as the page. + * `page` is the page's full address (matched exactly), or a part of it. + */ +export async function clickInPage( + app: ElectronApplication, + page: string, + selector: string, + modifiers: Modifier[] = [], + button: 'left' | 'middle' | 'right' = 'left', +): Promise { + const { x, y } = await hoverInPage(app, page, selector) + await sendToPage(app, page, clickEvents(x, y, modifiers, button)) +} + +/** The keys going down, a press and release at (x, y), and the keys coming up. */ +export function clickEvents( + x: number, + y: number, + modifiers: Modifier[] = [], + button: 'left' | 'middle' | 'right' = 'left', +): InputEventInit[] { const keys: Record = { shift: 'Shift', control: 'Control', alt: 'Alt', meta: 'Meta' } const events: InputEventInit[] = [] const held: Modifier[] = [] @@ -403,7 +432,7 @@ export async function clickInPage( held.pop() events.push({ type: 'keyUp', keyCode: keys[m], modifiers: [...held] }) } - await sendToPage(app, page, events) + return events } /** Orbit's windows: id, kind and bounds, in the order they opened. */ From f7886e2f2d2518e3154c2946e3d1cfac3511b55a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:30:08 +0000 Subject: [PATCH 47/69] fix(nav): closing a private window drops only a private Space (fix check B, F3) dropSpaces removed the Space of every private window that closed, closing its tabs with remove: true (no Archive entry, no undo), without checking that the Space was private. A private window showing a normal Space would take that Space and its tabs with it. It now skips any Space that isn't private. Unit test: a private window that shows a normal Space closes; the Space keeps its tab (fails without the guard: closeTabs is called on the normal tab with remove: true). A second test keeps the private window's own clean-up. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/private.ts | 3 + tests/unit/nav-private-close.test.ts | 121 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/unit/nav-private-close.test.ts diff --git a/src/main/nav/private.ts b/src/main/nav/private.ts index 6b3ab6e0..3621dafd 100644 --- a/src/main/nav/private.ts +++ b/src/main/nav/private.ts @@ -4,6 +4,7 @@ import { createSpace, createWindowState, THEME_PRESETS } from '../../shared/defa import { isFolderId } from '../../shared/ids' import { isPrivateProfileId, + isPrivateSpace, isPrivateWindow, PRIVATE_PARTITION_PREFIX, PRIVATE_PROFILE_PREFIX, @@ -114,6 +115,8 @@ export function registerPrivate(ctx: MainContext, ops: TabOps, prefs: SitePrefs) for (const spaceId of spaceIds) { const state = store.state if (!state.spaces[spaceId]) continue + // Only a private Space: a normal one keeps its tabs, whatever window showed it (fix check B, F3). + if (!isPrivateSpace(state, spaceId)) continue if (Object.values(state.windows).some((w) => w.spaceId === spaceId)) continue const tabIds = itemsOf(state, spaceId).flatMap((id) => tabsInItem(state, id)) if (tabIds.length > 0) ops.closeTabs(tabIds, 'auto', { remove: true, undoLabel: false }) diff --git a/tests/unit/nav-private-close.test.ts b/tests/unit/nav-private-close.test.ts new file mode 100644 index 00000000..25fa97d5 --- /dev/null +++ b/tests/unit/nav-private-close.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { on: () => {} }, + Menu: { buildFromTemplate: () => ({}) }, + session: { + fromPartition: () => ({ + clearStorageData: async () => {}, + clearCache: async () => {}, + clearAuthCache: async () => {}, + clearHostResolverCache: async () => {}, + }), + }, +})) +vi.mock('../../src/main/external-links', () => ({ + PRIVATE_WINDOW_SWITCH: '--private', + privateSwitchIn: () => false, + urlsInArgv: () => [], +})) + +import type { MainContext } from '../../src/main/context' +import { registerPrivate } from '../../src/main/nav/private' +import type { SitePrefs } from '../../src/main/nav/site-prefs' +import { Store } from '../../src/main/store' +import { makeTab } from '../../src/main/tabs/model' +import type { TabOps } from '../../src/main/tabs/ops' +import { createInitialState, createSpace, createWindowState, DEFAULT_PROFILE_ID } from '../../src/shared/defaults' +import type { OrbitState, Profile } from '../../src/shared/state' + +/** The private windows module with fakes around it; `closed` is every closeTabs call it makes. */ +function register(state: OrbitState) { + const store = new Store(state) + const closed: { tabIds: string[]; opts: unknown }[] = [] + const ops = { + // Closed with `remove`: gone from the state, as the tabs module does it. + closeTabs: (tabIds: string[], _how: string, opts: unknown) => { + closed.push({ tabIds: [...tabIds], opts }) + store.update((d) => { + for (const id of tabIds) { + const space = d.spaces[d.tabs[id]?.spaceId ?? ''] + if (space) space.today = space.today.filter((t) => t !== id) + delete d.tabs[id] + } + }) + }, + recentlyClosed: [], + createTab: () => undefined, + } as unknown as TabOps + const ctx = { + store, + actions: { register: () => {}, intercept: () => {}, run: async () => {} }, + commands: { run: async () => {} }, + windows: { focusedWindowId: () => undefined }, + sessions: { forget: () => {} }, + e2e: true, + } as unknown as MainContext + registerPrivate(ctx, ops, { forgetPrivate: () => {} } as unknown as SitePrefs) + return { store, closed } +} + +/** What the module does once a window has gone comes on the next turn of the event loop. */ +const settled = () => new Promise((resolve) => setImmediate(resolve)) + +/** A normal Space with a tab, and a second one the normal window shows. */ +function normalSpaces() { + const state = createInitialState(0) + const home = state.spaceOrder[0] as string + const tab = makeTab({ spaceId: home, profileId: DEFAULT_PROFILE_ID, url: 'https://a.test/' }, 0) + state.tabs[tab.id] = tab + state.spaces[home]?.today.push(tab.id) + const work = createSpace('Work', DEFAULT_PROFILE_ID, undefined, '', 0) + state.spaces[work.id] = work + state.spaceOrder.push(work.id) + const main = createWindowState(work.id) + state.windows[main.id] = main + return { state, home, tab: tab.id } +} + +describe('closing a private window (NV-11, fix check B F3)', () => { + it('a private window showing a normal Space leaves that Space and its tabs when it closes', async () => { + const { state, home, tab } = normalSpaces() + const win = createWindowState(home, 'private') + state.windows[win.id] = win + const { store, closed } = register(state) + store.update((d) => { + delete d.windows[win.id] + }) + await settled() + await settled() + expect(closed).toEqual([]) + expect(store.state.spaces[home]?.today).toEqual([tab]) + }) + + it('a private window’s own Space and tabs go when it closes, with nothing kept', async () => { + const { state } = normalSpaces() + const profile: Profile = { + id: 'private-abc', + name: 'Private', + partition: 'orbit-private-abc', + favorites: [], + createdAt: 0, + } + state.profiles[profile.id] = profile + const space = createSpace('Private', profile.id, undefined, '', 0) + state.spaces[space.id] = space + const secret = makeTab({ spaceId: space.id, profileId: profile.id, url: 'https://secret.test/' }, 0) + state.tabs[secret.id] = secret + space.today.push(secret.id) + const win = createWindowState(space.id, 'private') + state.windows[win.id] = win + const { store, closed } = register(state) + store.update((d) => { + delete d.windows[win.id] + }) + await settled() + await settled() + expect(closed).toEqual([{ tabIds: [secret.id], opts: { remove: true, undoLabel: false } }]) + expect(store.state.spaces[space.id]).toBeUndefined() + expect(store.state.profiles[profile.id]).toBeUndefined() + }) +}) From a8cf952a7e660aeda8fb076d74d126a974aa374d Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:35:24 +0000 Subject: [PATCH 48/69] fix(chrome): the card the window's very edge brought in goes when the pointer leaves the window (fix check A, M1) The edge watch (review A-M2) brings the floating sidebar in for a pointer in the window's left resize border, where nothing in the window hears it. If the pointer then went on out of the window, nothing heard that either: hold.over stayed true and the card stayed out until the pointer came back over a page. While the card is out and neither it nor a page has heard the pointer, main now reads the pointer every 20 ms, as the edge watch does. When the pointer is outside the window's content with no button down (as the shell and pages last reported), the card lets go and goes after the usual grace delay. The watch stops when the card or a page hears the pointer. Test (chrome): after the very-edge reveal, the e2e pointer goes outside the window through a new test hook (__orbitNav.pointerAt); the card goes within 1 s. A pointer still at the edge keeps it. Without the watch: peeking stays true (Timeout 1000ms). Co-Authored-By: Claude Opus 5.5 --- src/main/nav/index.ts | 6 +++ src/main/nav/sidebar-reveal.ts | 70 ++++++++++++++++++++++++++++++++-- tests/e2e/chrome.spec.ts | 26 +++++++++++++ tests/e2e/nav-support.ts | 9 +++++ 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index 27aa838d..e46c88ba 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -54,6 +54,11 @@ export interface NavTestHooks extends LinksTestHooks, PageMenuTestHooks { bounds?: { x: number; y: number; width: number; height: number } hold: RevealHoldInfo | null } + /** + * The pointer Orbit reads for the sidebar's edge, at `x`, `y` of a window's content (default: the + * first), inside it or not: a real pointer where no view hears it (SB-12, fix check A M1). + */ + pointerAt: (x: number, y: number, windowId?: WindowId) => void /** A window's page loading indicator (default: the first) (SB-28). */ loading: (windowId?: WindowId) => LoadingInfo /** A window's link status pill (default: the first) (SB-27). */ @@ -384,6 +389,7 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { const id = windowId ?? windows.ids()[0] ?? '' return { ...windows.floatingInfo(id), hold: reveal.hold(id) ?? null } }, + pointerAt: (x, y, windowId) => reveal.pointerAt(windowId ?? windows.ids()[0] ?? '', x, y), loading: (windowId) => loading.info(windowId ?? windows.ids()[0] ?? ''), linkPill: (windowId) => linkPill.info(windowId ?? windows.ids()[0] ?? ''), } diff --git a/src/main/nav/sidebar-reveal.ts b/src/main/nav/sidebar-reveal.ts index c64e0930..62ff95af 100644 --- a/src/main/nav/sidebar-reveal.ts +++ b/src/main/nav/sidebar-reveal.ts @@ -12,7 +12,7 @@ export const HIDE_GRACE_MS = 400 * edge, where nothing in the window may hear it (review A-M2). */ export const EDGE_WATCH_MS = 300 -/** How often the pointer is read meanwhile. */ +/** How often the pointer is read meanwhile, and while the card is out for a pointer nothing heard. */ const EDGE_WATCH_EVERY_MS = 20 interface Point { @@ -57,9 +57,13 @@ const buttonDown = (mouse: MouseInputEvent) => (mouse.modifiers ?? []).some((m) * a pointer coming off the page has no movement. On Linux a frameless window keeps its left 5 px * for resizing and nothing in the window hears the pointer there: when the pointer leaves a page or * the shell, main reads it for a moment and brings the card in if it is at the edge (review A-M2). + * The card that comes for it has heard nothing either, so main reads the pointer on until the card + * or a page hears it, and lets the card go when the pointer has left the window (fix check A, M1). */ export function registerSidebarReveal(ctx: MainContext): { hold: (windowId: WindowId) => RevealHoldInfo | undefined + /** Under ORBIT_E2E: the pointer at `x`, `y` of a window's content, inside it or not, where no view hears it. */ + pointerAt: (windowId: WindowId, x: number, y: number) => void } { const { store, commands, windows } = ctx const holds = new Map() @@ -68,6 +72,10 @@ export function registerSidebarReveal(ctx: MainContext): { const restAt = new Map() /** Per window: the look for the pointer at the left edge after it left a page or the shell. */ const edgeWatches = new Map() + /** Per window: the look for the pointer while the card is out and nothing has heard it (fix check A, M1). */ + const outWatches = new Map() + /** Per window: a mouse button was down at the last event the shell or a page heard. */ + const pressed = new Map() /** * The pointer on screen. Input a test sends never moves the system's pointer, so under ORBIT_E2E @@ -105,6 +113,7 @@ export function registerSidebarReveal(ctx: MainContext): { const hold = holdOf(windowId) hold.over = true settle(windowId) + watchOut(windowId) return } // Nothing summons it while something else floats over the window (the command bar, the site @@ -113,6 +122,7 @@ export function registerSidebarReveal(ctx: MainContext): { clearTimeout(holds.get(windowId)?.timer) holds.set(windowId, { over: true, editing: false, overlay: false }) setPeeking(windowId, true) + watchOut(windowId) } /** @@ -128,6 +138,20 @@ export function registerSidebarReveal(ctx: MainContext): { return true } + /** The pointer within the window's content area (a resize border included). */ + const inWindow = (windowId: WindowId): boolean => { + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return false + const content = win.getContentBounds() + const now = pointer() + return ( + now.x >= content.x && + now.x < content.x + content.width && + now.y >= content.y && + now.y < content.y + content.height + ) + } + /** The pointer within the window's left edge band (its content area, a resize border included). */ const atLeftEdge = (windowId: WindowId): boolean => { const win = windows.get(windowId) @@ -161,6 +185,29 @@ export function registerSidebarReveal(ctx: MainContext): { look() } + const stopOutWatch = (windowId: WindowId) => { + clearInterval(outWatches.get(windowId)) + outWatches.delete(windowId) + } + + /** + * The card is out for a pointer that may be where nothing hears it (the window's very edge): until + * the card or a page hears it, main reads it. Gone out of the window with no button down, the + * pointer has left the card's side, and the card goes as it would when left (fix check A, M1). + */ + const watchOut = (windowId: WindowId) => { + stopOutWatch(windowId) + const look = () => { + const hold = holds.get(windowId) + if (!store.state.windows[windowId]?.sidebar.peeking || !hold?.over) return stopOutWatch(windowId) + if (pressed.get(windowId) || inWindow(windowId)) return + stopOutWatch(windowId) + hold.over = false + settle(windowId) + } + outWatches.set(windowId, setInterval(look, EDGE_WATCH_EVERY_MS)) + } + const hideNow = (windowId: WindowId) => { clearTimeout(holds.get(windowId)?.timer) holds.delete(windowId) @@ -190,6 +237,7 @@ export function registerSidebarReveal(ctx: MainContext): { clearTimeout(hold.timer) holds.delete(windowId) } + stopOutWatch(windowId) continue } // Opening the command bar or a panel from the card keeps it out until that closes (SB-12). @@ -207,7 +255,9 @@ export function registerSidebarReveal(ctx: MainContext): { holds.delete(windowId) } for (const windowId of edgeWatches.keys()) if (!next.windows[windowId]) stopEdgeWatch(windowId) + for (const windowId of outWatches.keys()) if (!next.windows[windowId]) stopOutWatch(windowId) for (const windowId of restAt.keys()) if (!next.windows[windowId]) restAt.delete(windowId) + for (const windowId of pressed.keys()) if (!next.windows[windowId]) pressed.delete(windowId) }) /** @@ -215,8 +265,9 @@ export function registerSidebarReveal(ctx: MainContext): { * edge, or left the page, perhaps for the window's edge. */ const onPageMouse = (tabId: TabId, mouse: MouseInputEvent) => { - if (mouse.type !== 'mouseMove' && mouse.type !== 'mouseDown' && mouse.type !== 'mouseLeave') return const windowId = windows.windowOfTab(tabId) + if (windowId) pressed.set(windowId, buttonDown(mouse)) + if (mouse.type !== 'mouseMove' && mouse.type !== 'mouseDown' && mouse.type !== 'mouseLeave') return const win = windowId ? store.state.windows[windowId] : undefined if (!windowId || !win) return const bounds = windows.tabViewBounds(tabId) @@ -227,6 +278,8 @@ export function registerSidebarReveal(ctx: MainContext): { return } if (win.sidebar.peeking) { + // A page heard the pointer: where it is is known again. + stopOutWatch(windowId) if (mouse.type === 'mouseDown') { hideNow(windowId) return @@ -249,9 +302,13 @@ export function registerSidebarReveal(ctx: MainContext): { windows.onWindowCreated((windowId, win) => { win.webContents.on('before-mouse-event', (_event, mouse) => { if (ctx.e2e) follow(windowId, mouse.x, mouse.y) + pressed.set(windowId, buttonDown(mouse)) if (mouse.type === 'mouseLeave') watchEdge(windowId) }) - win.once('closed', () => stopEdgeWatch(windowId)) + win.once('closed', () => { + stopEdgeWatch(windowId) + stopOutWatch(windowId) + }) }) commands.register('sidebar.reveal', (_args, c) => { @@ -262,7 +319,11 @@ export function registerSidebarReveal(ctx: MainContext): { const windowId = windowOf(c) if (!windowId || !store.state.windows[windowId]?.sidebar.peeking) return const hold = holdOf(windowId) - if (typeof over === 'boolean') hold.over = over + if (typeof over === 'boolean') { + // The card heard the pointer: it says when the pointer leaves. + stopOutWatch(windowId) + hold.over = over + } if (typeof editing === 'boolean') hold.editing = editing settle(windowId) }) @@ -280,6 +341,7 @@ export function registerSidebarReveal(ctx: MainContext): { }) return { + pointerAt: follow, hold: (windowId) => { const hold = holds.get(windowId) return hold diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 8f879208..2accc014 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -18,6 +18,7 @@ import { openTab, pageCorner, pageMouse, + pointerAt, setNativeFullScreen, shellMouseMove, shellRoundTrip, @@ -868,6 +869,31 @@ test('[SB-12][FL-03] a pointer that leaves the page for the window’s very edge expect((await sidebarState()).peeking).toBe(false) }) +test('[SB-12][FL-03] the card the window’s very edge brought in goes again when the pointer leaves the window there, where nothing hears it (fix check A, M1)', async () => { + const { app } = orbit + const id = await openTab(app, page('Outward')) + await hideSidebar() + await pageMouse(app, '/page/Outward', 'mouseMove', 300, 300) + const card = (await tabView(app, id))?.bounds + if (!card) throw new Error('no page view') + await pageMouse(app, '/page/Outward', 'mouseLeave', 2 - card.x, 300) + await expect.poll(async () => (await sidebarState()).peeking).toBe(true) + expect((await floatingInfo(app)).hold?.over).toBe(true) + // On out of the window through its left edge: neither the card nor a page hears that. + const left = Date.now() + await pointerAt(app, -40, 300) + await expect.poll(async () => (await sidebarState()).peeking, { timeout: 1000 * FRAME_SLACK }).toBe(false) + expect(Date.now() - left).toBeLessThan(1000 * FRAME_SLACK) + // A pointer still at the very edge keeps it out: main reads it every 20 ms, and it is in the window. + await pageMouse(app, '/page/Outward', 'mouseMove', 300, 300) + await pageMouse(app, '/page/Outward', 'mouseLeave', 2 - card.x, 300) + await expect.poll(async () => (await sidebarState()).peeking).toBe(true) + await pointerAt(app, 1, 320) + await new Promise((r) => setTimeout(r, 300)) + expect((await floatingInfo(app)).hold).toMatchObject({ over: true, hiding: false }) + expect((await sidebarState()).peeking).toBe(true) +}) + test('[SB-14] dragging the edge sets the width 1:1 within limits; double-click resets; far left hides', async () => { const { app, shell } = orbit await openTab(app, page('Resize')) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 06ef3667..6ae8a6cb 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -297,6 +297,7 @@ type Hooks = { menu: () => { windowId: string; direction: 'back' | 'forward'; labels: string[]; icons: boolean[] } | null chooseMenu: (label: string, newTab?: boolean) => boolean floating: (windowId?: string) => FloatingInfo + pointerAt: (x: number, y: number, windowId?: string) => void corner: (tabId: string) => number | null tools: (windowId?: string) => ToolsInfo blockedMenu: () => string[] | null @@ -595,6 +596,14 @@ export async function floatingPage(app: ElectronApplication): Promise { throw new Error('No floating sidebar page') } +/** + * The pointer Orbit reads for the sidebar's edge at `x`, `y` of the first window's content, inside + * it or not, as a real pointer no view in the window hears (fix check A, M1). + */ +export async function pointerAt(app: ElectronApplication, x: number, y: number): Promise { + await app.evaluate((_e, [px, py]) => (globalThis as unknown as Hooks).__orbitNav.pointerAt(px, py), [x, y] as const) +} + /** The first window's floating sidebar. */ export function floatingInfo(app: ElectronApplication): Promise { return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.floating()) From 23bc8b0da4c41e59d44c3b944fb4656288fb4293 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:38:43 +0000 Subject: [PATCH 49/69] fix(nav): with the Library open, find and zoom keys leave the tab under it alone (fix check A, M2) The Library covers the window's pages, and setPageCover puts the page tools under it, but Ctrl+G, F3 and Shift+F3 still opened or stepped find on the tab under it, and the find bar took the keyboard while it couldn't be seen. Ctrl+=, Ctrl+- and Ctrl+0 zoomed that tab, and the zoom was kept for its site. The window manager now says whether a window has a page cover (hasPageCover). While it does, find is not available (as with a Peek, review A-M3) and the zoom actions do nothing. The Library's own Ctrl+F still searches it. Test (nav): with a search on the tab, the Library open and focused, Ctrl+G, F3 and Ctrl+= in it: the keyboard stays in the Library, the tools view isn't placed, find isn't open, and the page stays at 100%. Without the fix, the keyboard goes to view=tools. Without the zoom half, the zoom indicator is placed (under the Library). Co-Authored-By: Claude Opus 5.5 --- src/main/nav/page-tools.ts | 10 ++++++---- src/main/windows.ts | 8 ++++++++ tests/e2e/nav.spec.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts index 32018e22..2d9aeaf7 100644 --- a/src/main/nav/page-tools.ts +++ b/src/main/nav/page-tools.ts @@ -214,11 +214,12 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( } /** - * Find acts on the window's page, not on one a Peek covers: while a Peek is open, ⌘F and ⌘G do - * nothing and the keyboard stays in the Peek (review A-M3; finding in the Peek is PK-11's). + * Find acts on the window's page, not on one a Peek or the Library covers: while either is open, + * ⌘F, ⌘G and F3 do nothing here and the keyboard stays where it is (review A-M3, fix check A M2; + * finding in the Peek is PK-11's, the Library's ⌘F searches it). */ const findable = (windowId: WindowId | undefined): windowId is WindowId => - !!windowId && !peekShown(store.state, windowId) + !!windowId && !peekShown(store.state, windowId) && !windows.hasPageCover(windowId) /** ⌘F: the bar opens (or its text is selected again), starting from the page's selection or the tab's last search. */ const openFind = (windowId: WindowId | undefined) => { @@ -363,7 +364,8 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( const zoomAction = (direction: 1 | -1 | 0) => (c: CallContext) => { const windowId = windowOf(c) const tabId = activeOf(windowId) - if (windowId && tabId) zoom(windowId, tabId, direction) + // Not the page under the Library: it would stay zoomed, and kept so for its site (fix check A, M2). + if (windowId && tabId && !windows.hasPageCover(windowId)) zoom(windowId, tabId, direction) } actions.register('page.zoomIn', zoomAction(1)) actions.register('page.zoomOut', zoomAction(-1)) diff --git a/src/main/windows.ts b/src/main/windows.ts index eea46d58..cf88a372 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -753,6 +753,14 @@ export class WindowManager { * Whether a window's permission prompt view is on screen: placed, and not under a page cover * (the prompt view's page asks when it loads; after that it hears each change). */ + /** + * Whether a view covers the window's pages (the Library, AR-02): what acts on the shown page, + * find and zoom, leaves it alone meanwhile (fix check A, M2). + */ + hasPageCover(windowId: WindowId): boolean { + return !!this.records.get(windowId)?.cover + } + promptOnScreen(windowId: WindowId): boolean { const rec = this.records.get(windowId) return !!rec && rec.promptShown && !rec.cover diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index b900ed90..95c63e4f 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -389,6 +389,36 @@ test('[NV-05][PK-11] ⌘F and ⌘G with a Peek open leave the tab under it alone expect((await windowState(app)).overlay.kind).toBe('peek') }) +test('[NV-05][NV-06] with the Library open, ⌘G, F3 and ⌘+ leave the tab under it alone: no find bar, no zoom, and the keyboard stays in the Library (fix check A, M2)', async () => { + const { app } = orbit + await openTab(app, findPage('Covered')) + // The tab has a search of its own, which ⌘G or F3 would bring back. + const tools = await openFind('/page/Covered') + await tools.keyboard.type('orbit') + await expect(tools.getByTestId('find-count')).toHaveText('1/3') + await tools.keyboard.press('Escape') + await expect.poll(async () => (await toolsInfo(app)).placed).toBe(false) + await cmd(app, 'library.open') + await expect.poll(() => cmd(app, 'library.testInfo')).toMatchObject({ open: true, visible: true }) + await expect.poll(() => focusedUrl(app)).toContain('view=library') + const library = app.windows().find((p) => p.url().includes('view=library')) + if (!library) throw new Error('no Library page') + await library.evaluate(() => { + const w = window as unknown as { __keys: string[] } + w.__keys = [] + addEventListener('keydown', (e) => w.__keys.push(e.key), true) + }) + await pressInPage(app, 'view=library', 'G', [mod]) + await pressInPage(app, 'view=library', 'F3') + await pressInPage(app, 'view=library', '=', [mod]) + // Keys reach main in order: once the Library has a later key, Orbit has dealt with those. + await pressInPage(app, 'view=library', 'End') + await expect.poll(() => library.evaluate(() => (window as unknown as { __keys: string[] }).__keys)).toContain('End') + expect(await focusedUrl(app)).toContain('view=library') + expect(await toolsInfo(app)).toMatchObject({ placed: false, find: { open: false } }) + expect(await zoomOf(app, '/page/Covered')).toBeCloseTo(1, 3) +}) + test('[NV-05][KS-08] Jump to Selection (⌘J on macOS) scrolls the page back to the selected text', async () => { const { app } = orbit await openTab(app, findPage('Jump')) From 4adf91654ace0fa1c39c5ba7f4cc3cc3fcf5731a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:55:19 +0000 Subject: [PATCH 50/69] fix(nav): a window's first loading indicator no longer takes the keyboard (fix check A, F1) The indicator's view was made at its first show and put in the window at once, while its own page was still loading. A view placed that way takes the keyboard as its page arrives, so the command bar, a Library search or the page lost typed keys. A probe saw it in 6 of 6 new windows whose first page was slow. The view is now made out of the window 1 s after the window shows, as the page tools are. It goes into the window only once its page has loaded. A slow load that comes first waits for that, then shows it. Test (nav-page): three new windows load a slow page at once with the command bar open. Then a fourth window waits until its indicator is ready before loading one. No indicator view ever takes the keyboard, and the command bar keeps it. - Without the fix, the first window's indicator view takes the keyboard. - Without the placement rule, the same happens. - Without the prewarm, the fourth window's indicator is never ready. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/loading.ts | 47 ++++++++++++++++++++++--- tests/e2e/nav-page.spec.ts | 70 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/main/nav/loading.ts b/src/main/nav/loading.ts index 98804be4..764ed1de 100644 --- a/src/main/nav/loading.ts +++ b/src/main/nav/loading.ts @@ -7,6 +7,11 @@ import { activeTabOf, viewContents } from '../windows' export const LOADING_SHOW_AFTER_MS = 150 /** The indicator's finish (a short fill, then a fade; loading.css) before its view goes: within 200 ms (FL-23). */ const LOADING_FINISH_MS = 180 +/** + * Each window's indicator view is made this long after the window shows, out of the window, so its + * page has loaded by the window's first slow load (fix check A, F1). + */ +const LOADING_PREWARM_MS = 1000 /** * The indicator's view: a small pill centred just inside the page card's top edge, with room for its * shadow. Kept small, as it sits over the page (a view takes the clicks under it). @@ -18,6 +23,8 @@ export type LoadingState = 'loading' | 'done' export interface LoadingInfo { tabId: TabId | null visible: boolean + /** Its view exists and its page has loaded, so it can show (fix check A, F1). */ + ready: boolean state: LoadingState | null bounds?: Electron.Rectangle /** How many times it has been shown in this window (tests: a quick load never shows it). */ @@ -28,6 +35,11 @@ export interface LoadingInfo { interface WindowLoading { view?: WebContentsView + /** + * The view's page has loaded. Only then does the view go into the window: a view placed while its + * page loads takes the keyboard (fix check A, F1). + */ + ready: boolean /** The tab the indicator is out for. */ tabId: TabId | null state: LoadingState | null @@ -53,7 +65,7 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) if (s) return s const win = windows.get(windowId) if (!win || win.isDestroyed()) return undefined - s = { tabId: null, state: null, shown: 0 } + s = { tabId: null, state: null, shown: 0, ready: false } perWindow.set(windowId, s) const record = s win.once('closed', () => { @@ -92,20 +104,36 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) if (live(s.view)) windows.hideUiView(windowId, s.view) } + /** The window's indicator view, made out of the window if it hasn't one; `ready` once its page has loaded. */ + const viewFor = (windowId: WindowId, s: WindowLoading): WebContentsView | undefined => { + if (live(s.view)) return s.view + s.ready = false + const view = windows.createUiView(windowId, 'loading') + s.view = view + viewContents(view)?.once('did-finish-load', () => { + if (s.view !== view) return + s.ready = true + // A slow load that waited for it shows it now. + if (perWindow.get(windowId) === s) evaluate(windowId) + }) + return view + } + const show = (windowId: WindowId, s: WindowLoading, tabId: TabId) => { const bounds = boundsFor(tabId) if (!bounds) { hide(windowId, s) return } + const view = viewFor(windowId, s) + // Not before its page has loaded (fix check A, F1): evaluate() comes back here when it has. + if (!view || !s.ready) return clearTimeout(s.hideTimer) s.hideTimer = undefined - if (!live(s.view)) s.view = windows.createUiView(windowId, 'loading') - if (!s.view) return const fresh = s.tabId !== tabId || s.state !== 'loading' s.tabId = tabId send(s, 'loading') - windows.showUiView(windowId, s.view, bounds) + windows.showUiView(windowId, view, bounds) if (fresh) s.shown++ } @@ -178,6 +206,16 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) }) }) + // Each window's view is made once the window has settled, so the first slow load finds it ready. + windows.onWindowCreated((windowId, win) => { + win.once('ready-to-show', () => { + setTimeout(() => { + const s = stateOf(windowId) + if (s) viewFor(windowId, s) + }, LOADING_PREWARM_MS) + }) + }) + // Another tab shown (tab switch, Space switch, split focus), or the page card moved. store.subscribe((next, prev) => { for (const windowId of perWindow.keys()) { @@ -204,6 +242,7 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) return { tabId: s?.tabId ?? null, visible: !!view?.getVisible(), + ready: !!view && !!s?.ready, state: s?.state ?? null, bounds: view?.getBounds(), shown: s?.shown ?? 0, diff --git a/tests/e2e/nav-page.spec.ts b/tests/e2e/nav-page.spec.ts index 5bd11ccb..f938a6bf 100644 --- a/tests/e2e/nav-page.spec.ts +++ b/tests/e2e/nav-page.spec.ts @@ -2,6 +2,8 @@ import { type ElectronApplication, expect, type Page, test } from '@playwright/t import { action, launchOrbit, type Orbit, run, state, tabPage } from './helpers' import { cmd, + cmdIn, + focusedUrl, type NavFixtures, openTab, pageMouse, @@ -39,6 +41,7 @@ const page = (title: string) => fx.url(`/page/${title}`) interface LoadingInfo { tabId: string | null visible: boolean + ready: boolean state: 'loading' | 'done' | null bounds?: { x: number; y: number; width: number; height: number } shown: number @@ -57,11 +60,13 @@ interface LinkPillInfo { } type NavHooks = { + __orbit: { windowIds: () => string[] } __orbitNav: { loading: (windowId?: string) => LoadingInfo; linkPill: (windowId?: string) => LinkPillInfo } } -function loading(app: ElectronApplication): Promise { - return app.evaluate(() => (globalThis as unknown as NavHooks).__orbitNav.loading()) +/** A window's loading indicator (the first window's by default). */ +function loading(app: ElectronApplication, windowId?: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as NavHooks).__orbitNav.loading(id), windowId) } function linkPill(app: ElectronApplication): Promise { @@ -236,6 +241,67 @@ test('[SB-28][FL-23] a load shorter than the delay never shows the indicator; sw await expect.poll(async () => (await loading(app)).visible).toBe(true) }) +test('[SB-28] a window’s first loading indicator never takes the keyboard: a new window’s command bar keeps it while a slow page loads (fix check A, F1)', async () => { + const { app } = orbit + // Each web contents that takes the keyboard, as it does (a view's own page may not be in yet). + await app.evaluate(({ app: electronApp, webContents }) => { + const g = globalThis as unknown as { __tookKeyboard: number[] } + g.__tookKeyboard = [] + const watch = (contents: Electron.WebContents) => contents.on('focus', () => g.__tookKeyboard.push(contents.id)) + for (const contents of webContents.getAllWebContents()) watch(contents) + electronApp.on('web-contents-created', (_e, contents) => watch(contents)) + }) + const indicatorsThatTookKeyboard = () => + app.evaluate(({ webContents }) => + (globalThis as unknown as { __tookKeyboard: number[] }).__tookKeyboard + .map((id) => webContents.fromId(id)?.getURL() ?? '') + .filter((url) => url.includes('view=loading')), + ) + const indicatorPagesIn = () => + app.evaluate(({ webContents }) => + webContents + .getAllWebContents() + .filter((c) => c.getURL().includes('view=loading')) + .every((c) => !c.isLoading()), + ) + /** A window opened from the first one, as ⌘N does: its command bar opens with the keyboard. */ + const newWindow = async () => { + const ids = () => app.evaluate(() => (globalThis as unknown as NavHooks).__orbit.windowIds()) + const before = await ids() + await action(app, 'window.new') + let id = '' + await expect + .poll(async () => { + id = (await ids()).find((w) => !before.includes(w)) ?? '' + return id + }) + .not.toBe('') + await expect.poll(() => focusedUrl(app)).toContain('view=overlay') + return id + } + // Slow pages load in new windows at once, as ones links open there do. The page comes when the + // test lets it: the indicator shows, and its view's page is in. + for (const n of [1, 2, 3]) { + const windowId = await newWindow() + const path = `/slow/First${n}` + await cmdIn(app, windowId, 'tabs.create', { url: fx.url(path) }) + await expect.poll(async () => (await loading(app, windowId)).visible).toBe(true) + await expect.poll(indicatorPagesIn).toBe(true) + // The keyboard stays in the command bar. + expect(await indicatorsThatTookKeyboard()).toEqual([]) + expect(await focusedUrl(app)).toContain('view=overlay') + fx.release(path) + } + // A window open a moment has its indicator ready, out of the window, before its first load. + const windowId = await newWindow() + await expect.poll(() => loading(app, windowId)).toMatchObject({ ready: true, visible: false, shown: 0 }) + await cmdIn(app, windowId, 'tabs.create', { url: fx.url('/slow/Later') }) + await expect.poll(async () => (await loading(app, windowId)).visible).toBe(true) + expect(await indicatorsThatTookKeyboard()).toEqual([]) + expect(await focusedUrl(app)).toContain('view=overlay') + fx.release('/slow/Later') +}) + test('[SB-28][NV-20] the loading indicator stays off a page in HTML full screen and its view leaves the window: the page is top-most under the overlay; it comes back after (review A-M1)', async () => { const { app } = orbit const id = await openTab(app, page('Start')) From a7bc6cf345794e13fecdd00d98004e49e0aee647 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:55:35 +0000 Subject: [PATCH 51/69] docs(windows): put promptOnScreen's comment back above it The fix for fix check A, M2 (23bc8b0) put hasPageCover between promptOnScreen and its doc comment. hasPageCover now comes first. No code change. Co-Authored-By: Claude Opus 5.5 --- src/main/windows.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index cf88a372..232dfe80 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -749,10 +749,6 @@ export class WindowManager { this.tellPrompt(rec) } - /** - * Whether a window's permission prompt view is on screen: placed, and not under a page cover - * (the prompt view's page asks when it loads; after that it hears each change). - */ /** * Whether a view covers the window's pages (the Library, AR-02): what acts on the shown page, * find and zoom, leaves it alone meanwhile (fix check A, M2). @@ -761,6 +757,10 @@ export class WindowManager { return !!this.records.get(windowId)?.cover } + /** + * Whether a window's permission prompt view is on screen: placed, and not under a page cover + * (the prompt view's page asks when it loads; after that it hears each change). + */ promptOnScreen(windowId: WindowId): boolean { const rec = this.records.get(windowId) return !!rec && rec.promptShown && !rec.cover From 3c4e5323c326b43b16b5a789f8042cdea7be6618 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:03:09 +0000 Subject: [PATCH 52/69] fix(nav): a window's first permission prompt over a page leaves the keyboard with it (fix check A, F1) This has the same cause as the loading indicator. With the sidebar hidden, the prompt's view was made at the window's first prompt and placed at once, while its own page loaded. It took the keyboard from the page as that page arrived. Keys typed into the page went to the prompt, where Esc dismisses it. Later prompts in that window were fine. The view now goes into the window once its page has loaded, if it is still wanted then. Until then it doesn't count as on screen (prompt:placed). Test (nav-site): a page with the keyboard asks for notifications with the sidebar hidden. The prompt shows, and its view never takes the keyboard. Without the fix, the prompt view takes it. The corner test now reads the prompt and the page at once. It compared the prompt with a page position read before the sidebar's slide had begun, and passed only because the view was placed at once. Co-Authored-By: Claude Opus 5.5 --- src/main/windows.ts | 23 +++++++++++++++-- tests/e2e/nav-site.spec.ts | 53 +++++++++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index 232dfe80..0cd27111 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -76,7 +76,13 @@ interface WindowRecord { * with the sidebar showing, the prompt is part of it). Started when first needed. */ prompt?: WebContentsView + /** The prompt is wanted on screen: from showPrompt() to hidePrompt(). */ promptShown: boolean + /** + * The prompt view's page has loaded. Only then does the view go into the window: a view placed + * while its page loads takes the keyboard from the page (fix check A, F1). + */ + promptReady: boolean /** * What the prompt view's page was last told (`prompt:placed`): whether the view is on screen, * placed and not under a page cover. Unset for a new page, which asks when it loads. @@ -395,6 +401,7 @@ export class WindowManager { floatingShown: false, toolsShown: false, promptShown: false, + promptReady: false, uiViews: new Set(), } this.records.set(id, rec) @@ -731,6 +738,11 @@ export class WindowManager { view.setBounds(next) } rec.promptShown = true + // Not before its page has loaded (fix check A, F1): ensurePrompt() places it then. + if (rec.promptReady) this.placePrompt(rec, view) + } + + private placePrompt(rec: WindowRecord, view: WebContentsView): void { this.placeAbovePages(rec, view) view.setVisible(true) this.tellPrompt(rec) @@ -763,7 +775,7 @@ export class WindowManager { */ promptOnScreen(windowId: WindowId): boolean { const rec = this.records.get(windowId) - return !!rec && rec.promptShown && !rec.cover + return !!rec && rec.promptShown && rec.promptReady && !rec.cover } /** @@ -772,7 +784,7 @@ export class WindowManager { * wait a moment again once the cover has gone (fix check B, M1). */ private tellPrompt(rec: WindowRecord): void { - const onScreen = rec.promptShown && !rec.cover + const onScreen = rec.promptShown && rec.promptReady && !rec.cover if (rec.promptSaid === onScreen) return rec.promptSaid = onScreen viewContents(rec.prompt)?.send('orbit:prompt:placed', onScreen) @@ -1402,7 +1414,14 @@ export class WindowManager { loadRenderer(view.webContents, 'prompt') rec.prompt = view rec.promptShown = false + rec.promptReady = false rec.promptSaid = undefined + // Into the window once its page has loaded, if it is still wanted then (fix check A, F1). + view.webContents.once('did-finish-load', () => { + if (rec.prompt !== view || rec.win.isDestroyed()) return + rec.promptReady = true + if (rec.promptShown) this.placePrompt(rec, view) + }) return view } diff --git a/tests/e2e/nav-site.spec.ts b/tests/e2e/nav-site.spec.ts index c8b3b002..f1c376b4 100644 --- a/tests/e2e/nav-site.spec.ts +++ b/tests/e2e/nav-site.spec.ts @@ -5,6 +5,7 @@ import { closeWindow, cmd, cmdIn, + focusedUrl, type NavFixtures, openTab, shellRoundTrip, @@ -541,14 +542,12 @@ test('[NV-15] with the sidebar hidden the prompt shows over the page’s top-lef const { id, p } = await open(app, page('Corner')) await action(app, 'sidebar.toggle') await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) - const shown = await settledPage(app, id) + await settledPage(app, id) await ask(p, 'notifications') await expect.poll(async () => (await promptView(app)).placed).toBe(true) - const bounds = (await promptView(app)).bounds - if (!bounds) throw new Error('no bounds') - // The card sits 10 px in from the page's corner (its view has a 12 px margin for the shadow). - expect(bounds.x).toBe(shown.x - 2) - expect(bounds.y).toBe(shown.y - 2) + // The card sits 10 px in from the page's corner (its view has a 12 px margin for the shadow). Both + // are read at once: the page may still be sliding when the view's page is in and it is placed. + await expect.poll(() => promptFromCorner(app, id)).toEqual({ x: -2, y: -2 }) const view = await promptPage(app) const card = view.getByTestId('permission-prompt') await expect(card).toBeVisible() @@ -610,6 +609,48 @@ const clickAllowAsItShows = (app: ElectronApplication, count: number, every: num [count, every] as const, ) +test('[NV-15] the window’s first prompt over a page leaves the keyboard with the page (fix check A, F1)', async () => { + const { app } = orbit + const { id, p } = await open(app, page('Typing')) + await action(app, 'sidebar.toggle') + await expect.poll(async () => (await windowState(app)).sidebar.visible).toBe(false) + await settledPage(app, id) + // The page has the keyboard, as while someone types in it. + await app.evaluate((_e, tabId) => { + const g = globalThis as unknown as { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } + } + g.__orbit.ctx.tabs.contents(tabId)?.focus() + }, id) + await expect.poll(() => focusedUrl(app)).toBe(page('Typing')) + // Each web contents that takes the keyboard from here on, as it does (a view's own page may not be in yet). + await app.evaluate(({ app: electronApp, webContents }) => { + const g = globalThis as unknown as { __tookKeyboard: number[] } + g.__tookKeyboard = [] + const watch = (contents: Electron.WebContents) => contents.on('focus', () => g.__tookKeyboard.push(contents.id)) + for (const contents of webContents.getAllWebContents()) watch(contents) + electronApp.on('web-contents-created', (_e, contents) => watch(contents)) + }) + await ask(p, 'notifications') + await expect.poll(async () => (await promptView(app)).placed).toBe(true) + // Its view's page is in too: a view placed before then took the keyboard as its page arrived. + await expect + .poll(() => + app.evaluate(({ webContents }) => + webContents.getAllWebContents().some((c) => c.getURL().includes('view=prompt') && !c.isLoading()), + ), + ) + .toBe(true) + await expect((await promptPage(app)).getByTestId('permission-prompt')).toBeVisible() + const tookKeyboard = await app.evaluate(({ webContents }) => + (globalThis as unknown as { __tookKeyboard: number[] }).__tookKeyboard.map( + (contentsId) => webContents.fromId(contentsId)?.getURL() ?? '', + ), + ) + expect(tookKeyboard.filter((url) => url.includes('view=prompt'))).toEqual([]) + expect(await focusedUrl(app)).toBe(page('Typing')) +}) + test('[NV-15] a prompt takes no clicks as it shows, nor while they keep coming; a click after a pause answers', async () => { const { app } = orbit const { id, p } = await open(app, page('Quick')) From b76dd54e63227781a387862751a161b06e708ed0 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:06:23 +0000 Subject: [PATCH 53/69] test(nav): zoom in a Mini Window, with its steps and the site's memory (fix check A, F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mini Window's zoom goes through page zoom since review A-F4, but only the Peek part had a test. This one zooms a Mini Window's page in twice (110%, then 125%). A tab of the same site in the main window follows, and Orbit's saved zoom for the site becomes 125%. Then ⌘0 in the Mini Window puts both back to 100%, and the saved zoom goes. With the Mini Window zooming as it did before A-F4 (the engine's steps, nothing kept), the saved zoom never comes. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/nav.spec.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index 95c63e4f..34732a3c 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -461,6 +461,48 @@ test('[NV-06][PK-11] zoom in a Peek takes the tab steps and says the new zoom; t expect(await zoomOf(app, '/page/Under')).toBeCloseTo(1.25, 3) }) +/** The zoom Orbit has saved for a host in a profile (NV-06), once its file has it; undefined is 100%. */ +function savedZoom(userData: string, profileId: string, host: string): number | undefined { + try { + const saved = JSON.parse(readFileSync(join(userData, 'orbit-site-prefs.json'), 'utf8')) as { + zoom?: Record> + } + return saved.zoom?.[profileId]?.[host] + } catch { + return undefined + } +} + +test('[NV-06] zoom in a Mini Window takes the tab steps; the site’s tabs follow, and the site keeps it until ⌘0 (fix check A, F3)', async () => { + const { app, userData } = orbit + await openTab(app, page('BesideMini')) + const url = page('MiniZoom') + await cmd(app, 'mini.open', { url }) + let miniTab = '' + await expect + .poll(async () => { + miniTab = Object.values((await state(app)).tabs).find((t) => t.url === url)?.id ?? '' + return miniTab + }) + .not.toBe('') + await waitLoaded(app, miniTab, url) + const profileId = (await state(app)).tabs[miniTab]?.profileId ?? '' + const host = new URL(url).hostname + // Chromium's steps, as in a tab: 110%, then 125%. + await pressInPage(app, '/page/MiniZoom', '=', [mod]) + await expect.poll(() => zoomOf(app, '/page/MiniZoom')).toBeCloseTo(1.1, 3) + await pressInPage(app, '/page/MiniZoom', '=', [mod]) + await expect.poll(() => zoomOf(app, '/page/MiniZoom')).toBeCloseTo(1.25, 3) + // The site's tab in the main window follows, and Orbit keeps the zoom for the site. + expect(await zoomOf(app, '/page/BesideMini')).toBeCloseTo(1.25, 3) + await expect.poll(() => savedZoom(userData, profileId, host)).toBeCloseTo(1.25, 3) + // ⌘0 in the Mini Window resets the site in all its tabs, and Orbit forgets it. + await pressInPage(app, '/page/MiniZoom', '0', [mod]) + await expect.poll(() => zoomOf(app, '/page/MiniZoom')).toBeCloseTo(1, 3) + await expect.poll(() => zoomOf(app, '/page/BesideMini')).toBeCloseTo(1, 3) + await expect.poll(() => savedZoom(userData, profileId, host)).toBeUndefined() +}) + test('[NV-06][KS-08] ⌘+ and ⌘− step through the zoom levels with an indicator; the site’s other tabs follow; ⌘0 resets', async () => { const { app } = orbit const first = await openTab(app, page('ZoomA')) From 886e3bb26b6fc55cdc947cc6b5772bb9c46ae475 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:06:36 +0000 Subject: [PATCH 54/69] docs: PK-11 note, find does nothing while a Peek is open (fix check A, F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note still said find in a Peek waits for the find bar, a core placeholder. Since review A-M3, ⌘F, ⌘G and F3 do nothing while a Peek is open, so the keyboard stays in it, as NV-05's note says. FEATURES.md is 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 5f4bce7a..71246403 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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, back/forward, reload, zoom, copy URL, devtools act on the Peek; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; find in page waits for the find bar (core placeholder) | +| PK-11 | Navigating and acting inside a Peek | P1 | 🟡 partial | ✅ e2e+unit | – | links, back/forward, reload, zoom, copy URL, devtools act on the Peek; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; no find in a Peek yet: ⌘F, ⌘G and F3 do nothing while one is open, so the keyboard stays in it (NV-05) | | PK-12 | Peek settings | P1 | 🟡 partial | ✅ e2e+unit | – | settings.peekExternalLinks / peekShiftClick honoured; toggles UI belongs to the settings lane | | 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 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 7b248608..11688a03 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -224,7 +224,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, back/forward, reload, zoom, copy URL, devtools act on the Peek; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; find in page waits for the find bar (core placeholder)"}, + "PK-11": {"built": "partial", "note": "links, back/forward, reload, zoom, copy URL, devtools act on the Peek; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; no find in a Peek yet: ⌘F, ⌘G and F3 do nothing while one is open, so the keyboard stays in it (NV-05)"}, "PK-12": {"built": "partial", "note": "settings.peekExternalLinks / peekShiftClick honoured; toggles UI belongs to the settings lane"}, "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 0db0d6f1a96a98b61ffca4730888ced5a6930caf Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:08:40 +0000 Subject: [PATCH 55/69] fix(core): drop the placeholder Settings action the settings module replaces After the settings merge (cd87da0), core.ts and the settings module both register app.settings. Core's "Settings are coming soon" toast was registered first and then silently replaced. The unit test from review A-F7 fails on it: "expected [ 'app.settings' ] to deeply equal []". It is in the settings PR head and in main too. With the placeholder gone, the test passes. Co-Authored-By: Claude Opus 5.5 --- src/main/core.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/core.ts b/src/main/core.ts index 85d3ea89..d9b87ce6 100644 --- a/src/main/core.ts +++ b/src/main/core.ts @@ -87,6 +87,6 @@ export function registerCore(ctx: MainContext): void { if (!win) return void commands.run('window.setSidebar', { visible: !win.sidebar.visible }, { windowId }) }) - // window.new is registered by the spaces lane (SP-24: same Space, command bar open, cascade). - actions.register('app.settings', (c) => ctx.toast(windowOf(c), 'Settings are coming soon')) + // window.new is registered by the spaces lane (SP-24: same Space, command bar open, cascade), and + // app.settings by the settings module: this placeholder's handler would only be replaced. } From d6bcef4e1688b379b5cd1d5f6c568b7f71f60509 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:19:34 +0000 Subject: [PATCH 56/69] fix(nav): make each window's loading view as it shows, so a first-second load shows its indicator after 150 ms (round 2 fix check, F3) The view was made 1 s after the window showed, or on demand by the first load, which then waited for its page: a load in the window's first second showed the indicator about 400 ms after it started (FL-23 says 150 ms). nav-page :305 polled past that short window and failed 2 of 3 runs after the harness merge. The view is still made out of the window and placed only once its page has loaded (fix check A, F1). Co-Authored-By: Claude Opus 5.5 --- src/main/nav/loading.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/nav/loading.ts b/src/main/nav/loading.ts index 764ed1de..ace52f5b 100644 --- a/src/main/nav/loading.ts +++ b/src/main/nav/loading.ts @@ -7,11 +7,6 @@ import { activeTabOf, viewContents } from '../windows' export const LOADING_SHOW_AFTER_MS = 150 /** The indicator's finish (a short fill, then a fade; loading.css) before its view goes: within 200 ms (FL-23). */ const LOADING_FINISH_MS = 180 -/** - * Each window's indicator view is made this long after the window shows, out of the window, so its - * page has loaded by the window's first slow load (fix check A, F1). - */ -const LOADING_PREWARM_MS = 1000 /** * The indicator's view: a small pill centred just inside the page card's top edge, with room for its * shadow. Kept small, as it sits over the page (a view takes the clicks under it). @@ -206,13 +201,12 @@ export function registerLoading(ctx: MainContext): { info: (windowId: WindowId) }) }) - // Each window's view is made once the window has settled, so the first slow load finds it ready. + // Each window's view is made as the window shows, out of the window, so a load in its first second + // finds it ready and shows the indicator after the usual 150 ms (fix check A, F1 and round 2, F3). windows.onWindowCreated((windowId, win) => { win.once('ready-to-show', () => { - setTimeout(() => { - const s = stateOf(windowId) - if (s) viewFor(windowId, s) - }, LOADING_PREWARM_MS) + const s = stateOf(windowId) + if (s) viewFor(windowId, s) }) }) From 6448a25337db1d9730fb9ba7f5f954d70e990aac Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:27:25 +0000 Subject: [PATCH 57/69] =?UTF-8?q?fix(nav):=20Save=20Page=20As=E2=80=A6=20s?= =?UTF-8?q?aves=20complete=20only=20a=20page=20whose=20frames=20are=20all?= =?UTF-8?q?=20local=20files=20(round=202=20fix=20check,=20M1;=20D49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A complete save has Chromium read every file: resource the page's frames name from disk, though no web page could load one, and copy it beside the saved page. A page with any frame that isn't a local file (asked of pageMayOpen, by each frame's origin) now saves as a single file (MHTML: the page and what it loaded, nothing read from disk), or as its HTML alone when the name ends in .html or .htm. A local page alone still saves complete. Test (nav-links): a web page and a local page with a web frame name local files as style sheet, script, picture and frame; no saved file or MHTML part holds their marker, and the local page alone saves its own files. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/nav/page-menu.ts | 45 +++++++++- tests/e2e/nav-links.spec.ts | 169 +++++++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 7 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 71246403..e11696eb 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -239,7 +239,7 @@ Spec: `spec/navigation.md` | 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 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 | | 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, 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), 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-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), 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-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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index 11688a03..a82a658e 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -185,7 +185,7 @@ "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 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"}, "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, 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), 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-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), 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-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/nav/page-menu.ts b/src/main/nav/page-menu.ts index 77679543..d37cd6bc 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, type WebContents } from 'electron' +import { pageMayOpen } from '../../shared/link-routing' import { type PageReferrer, referrerPolicyName } from '../../shared/nav' import { isPrivateTab } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' @@ -143,19 +144,55 @@ function fileName(title: string): string { return clean || 'Page' } -/** Save Page As… (the page with its resources), Print… and View Page Source. */ +/** Any local file's address: what a complete save would read for a frame, asked of `pageMayOpen`. */ +const LOCAL_FILE = 'file:///' + +/** + * Whether Save Page As… may save the page complete, the resources its frames name copied beside it. + * Chromium reads each `file:` resource named from disk for that, though no web page could load one, + * so only a page whose frames are all local files saves complete (D49, round 2 fix check M1). By each + * frame's origin, so an about:blank frame a local page made counts as local. + */ +export function savesComplete(contents: WebContents): boolean { + try { + return contents.mainFrame.framesInSubtree.every((frame) => pageMayOpen(frame.origin, LOCAL_FILE)) + } catch { + // The page went away. + return false + } +} + +/** + * How Save Page As… saves to `filePath`. A page that may save complete does, unless the name asks for + * a single file (.mht, .mhtml). Any other page saves as a single file (MHTML: the page and what it + * loaded, nothing read from disk), or as its HTML alone when the name ends in .html or .htm (NV-21). + */ +export function saveFormat(complete: boolean, filePath: string): 'HTMLComplete' | 'HTMLOnly' | 'MHTML' { + if (/\.mht(ml)?$/i.test(filePath)) return 'MHTML' + if (complete) return 'HTMLComplete' + return /\.html?$/i.test(filePath) ? 'HTMLOnly' : 'MHTML' +} + +/** Save Page As… (complete for a local page, else a single file; NV-21), Print… and View Page Source. */ export function pageItems(ctx: MainContext, tabId: TabId, contents: WebContents): MenuItemConstructorOptions[] { return [ { label: 'Save Page As…', click: () => { const win = ctx.windows.get(ctx.windows.windowOfTab(tabId) ?? '') - const defaultPath = join(app.getPath('downloads'), `${fileName(contents.getTitle())}.html`) - const options = { defaultPath, filters: [{ name: 'Web Page, Complete', extensions: ['html', 'htm'] }] } + const complete = savesComplete(contents) + const name = join(app.getPath('downloads'), fileName(contents.getTitle())) + const single = { name: 'Web Page, Single File', extensions: ['mhtml'] } + const html = { name: complete ? 'Web Page, Complete' : 'Web Page, HTML Only', extensions: ['html', 'htm'] } + const options = complete + ? { defaultPath: `${name}.html`, filters: [html, single] } + : { defaultPath: `${name}.mhtml`, filters: [single, html] } void (win ? dialog.showSaveDialog(win, options) : dialog.showSaveDialog(options)).then( ({ canceled, filePath }) => { if (canceled || !filePath || contents.isDestroyed()) return - contents.savePage(filePath, 'HTMLComplete').catch((err) => console.error('[nav] save page', err)) + // Asked again: the page may have changed while the dialog was open. + const format = saveFormat(complete && savesComplete(contents), filePath) + contents.savePage(filePath, format).catch((err) => console.error('[nav] save page', err)) }, ) }, diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 8d2139b7..f7725661 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -749,3 +749,170 @@ test('[TB-02] a local file’s address dropped on the sidebar opens nothing, as ) expect(await loads()).toEqual([]) }) + +// ---- Save Page As… (D49, round 2 fix check M1) ---- + +/** A local style sheet, script and picture holding a marker no web page could know, in a new folder. */ +function markedFiles(tag: string): { folder: string; marker: string; css: string; js: string; png: string } { + const folder = mkdtempSync(join(tmpdir(), `orbit-e2e-${tag}-`)) + const marker = `orbit-marker-${tag}-${Math.random().toString(36).slice(2)}` + writeFileSync(join(folder, `${tag}.css`), `/* ${marker} */ h1 { outline: 1px solid red }`) + writeFileSync(join(folder, `${tag}.js`), `/* ${marker} */ window.__ran = true`) + // A picture with the marker after its end, where readers ignore it. + writeFileSync(join(folder, `${tag}.png`), Buffer.concat([PNG, Buffer.from(marker)])) + const url = (name: string) => pathToFileURL(join(folder, name)).href + return { folder, marker, css: url(`${tag}.css`), js: url(`${tag}.js`), png: url(`${tag}.png`) } +} + +/** HTML naming the files as a style sheet, a script, a picture and a frame's page. */ +const naming = (files: { css: string; js: string; png: string }) => + `` + + `` + +/** The saved files under `folder` (paths from it) that hold `marker`; an MHTML file's parts are each decoded. */ +function holding(folder: string, marker: string): string[] { + const files = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + return statSync(path).isDirectory() ? files(path) : [path] + }) + return files(folder) + .filter((path) => mhtmlParts(readFileSync(path)).some((part) => part.includes(marker))) + .map((path) => path.slice(folder.length + 1)) +} + +/** An MHTML file's parts, decoded; any other file as it is. */ +function mhtmlParts(raw: Buffer): Buffer[] { + const text = raw.toString('latin1') + const boundary = /^MIME-Version:[\s\S]*?boundary="?([^";\r\n]+)"?/im.exec(text.slice(0, 2000))?.[1] + if (!boundary) return [raw] + return text + .split(`--${boundary}`) + .slice(1) + .map((part) => { + const end = part.indexOf('\r\n\r\n') + const body = part.slice(end + 4) + const encoding = /content-transfer-encoding:\s*([\w-]+)/i.exec(part.slice(0, end))?.[1]?.toLowerCase() + if (encoding === 'base64') return Buffer.from(body.replace(/\s+/g, ''), 'base64') + if (encoding !== 'quoted-printable') return Buffer.from(body, 'latin1') + const unwrapped = body.replace(/=\r?\n/g, '') + return Buffer.from( + unwrapped.replace(/=([0-9A-F]{2})/gi, (_m, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))), + 'latin1', + ) + }) +} + +type TabsHook = { __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } } +type Saved = { path: string; format: string; done: boolean } + +/** Runs a script in the frame of a tab whose address contains `part` (the page's own too); null while there's none. */ +function inFrame(app: ElectronApplication, tabId: string, part: string, code: string): Promise { + return app.evaluate( + async (_e, [id, p, js]) => { + const contents = (globalThis as unknown as TabsHook).__orbit.ctx.tabs.contents(id) + const frame = contents?.mainFrame.framesInSubtree.find((f) => f.url.includes(p)) + return frame ? await frame.executeJavaScript(js) : null + }, + [tabId, part, code] as const, + ) as Promise +} + +/** + * Chooses Save Page As… on a tab's page, with the save dialog answering `target`, and waits until the + * page is saved. Returns the format it was saved as. + */ +async function savePageAs(app: ElectronApplication, url: string, tabId: string, target: string): Promise { + await app.evaluate( + ({ dialog }, [id, path]) => { + const g = globalThis as unknown as TabsHook & { __saved?: Saved[] } + dialog.showSaveDialog = (async () => ({ canceled: false, filePath: path })) as typeof dialog.showSaveDialog + const contents = g.__orbit.ctx.tabs.contents(id) as (Electron.WebContents & { __watched?: true }) | undefined + if (!contents) throw new Error(`no tab ${id}`) + g.__saved = g.__saved ?? [] + const saved = g.__saved + if (contents.__watched) return + contents.__watched = true + // Watched, not changed: what the page is saved as, and when that is done. + const savePage = contents.savePage.bind(contents) + contents.savePage = (path, format) => { + const save = { path, format, done: false } + saved.push(save) + return savePage(path, format).then(() => { + save.done = true + }) + } + }, + [tabId, target] as const, + ) + await clickInPage(app, url, '#title', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.tabId).toBe(tabId) + expect(await choosePageMenu(app, 'Save Page As…')).toBe(true) + const save = () => + app.evaluate( + (_e, path) => (globalThis as unknown as { __saved?: Saved[] }).__saved?.find((s) => s.path === path), + target, + ) + await expect.poll(async () => (await save())?.done).toBe(true) + return (await save())?.format ?? '' +} + +test('[NV-12] Save Page As… copies no local file a web page names: a web page, or a local page with a web frame, saves as one file or as its HTML alone; a local page alone still saves complete (D49, round 2 fix check M1)', async () => { + const { app } = orbit + // A web page naming local files as its style sheet, script, picture and a frame. + const secret = markedFiles('secret') + const web = page('SaveWeb') + const webId = await openTab(app, web) + await inFrame( + app, + webId, + '/page/SaveWeb', + `document.body.insertAdjacentHTML('beforeend', ${JSON.stringify(naming(secret))})`, + ) + for (const [name, format] of [ + ['SaveWeb.mhtml', 'MHTML'], + ['SaveWeb.html', 'HTMLOnly'], + ] as const) { + const out = mkdtempSync(join(tmpdir(), 'orbit-e2e-saved-')) + const saved = await savePageAs(app, web, webId, join(out, name)) + expect(holding(out, secret.marker), name).toEqual([]) + expect(holding(out, 'SaveWeb'), name).toEqual([name]) + expect(saved).toBe(format) + } + + // A local page with its own local files and a web frame, which names other local files. + const own = markedFiles('own') + const framed = markedFiles('framed') + const localPath = join(own.folder, 'local.html') + writeFileSync( + localPath, + `Local

Local

${naming(own)}`, + ) + const local = pathToFileURL(localPath).href + const localId = await openTab(app, local) + await expect.poll(() => inFrame(app, localId, '/page/SaveFrame', 'document.readyState')).toBe('complete') + await inFrame( + app, + localId, + '/page/SaveFrame', + `document.body.insertAdjacentHTML('beforeend', ${JSON.stringify(naming(framed))})`, + ) + for (const [name, format] of [ + ['Framed.html', 'HTMLOnly'], + ['Framed.mhtml', 'MHTML'], + ] as const) { + const out = mkdtempSync(join(tmpdir(), 'orbit-e2e-saved-')) + const saved = await savePageAs(app, local, localId, join(out, name)) + expect(holding(out, framed.marker), name).toEqual([]) + expect(saved).toBe(format) + } + + // Without the web frame, the local page saves complete: its own local files beside it. + await inFrame(app, localId, local, `document.getElementById('web').remove()`) + await expect.poll(() => inFrame(app, localId, '/page/SaveFrame', '1')).toBe(null) + const out = mkdtempSync(join(tmpdir(), 'orbit-e2e-saved-')) + expect(await savePageAs(app, local, localId, join(out, 'Local.html'))).toBe('HTMLComplete') + expect(holding(out, own.marker)).toEqual( + expect.arrayContaining(['Local_files/own.css', 'Local_files/own.js', 'Local_files/own.png']), + ) +}) From 02e2eba055f626aba61dbf4de0a2c551e4d6d6f9 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:43:36 +0000 Subject: [PATCH 58/69] =?UTF-8?q?fix(links):=20an=20=E2=8C=A5-click=20over?= =?UTF-8?q?=20a=20frame=20counts=20as=20the=20frame's,=20and=20a=20frame?= =?UTF-8?q?=20counts=20as=20a=20web=20page=20(round=202=20fix=20check=20M2?= =?UTF-8?q?,=20F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A press in a frame from another site comes in the frame's own coordinates, so elementFromPoint in the main frame named whatever of the page's sat at that point: ⌥-click in the frame could open the page's link instead. When the main frame's last :hover element is a frame, the press now takes the frame branch (the hovered link, confirmed by the engine's download); elementFromPoint is used only otherwise. The frame branch checks the hovered link as a web page's (pageMayOpen with no page origin), so an ⌥-click on a local file's link in a frame never opens it beside the page, even in a local page; the engine's download goes ahead. Tests: nav-links M2 (real pointer through xdotool under xvfb; skipped without it) and F2. Co-Authored-By: Claude Opus 5.5 --- src/main/nav/links.ts | 24 +++++-- tests/e2e/nav-links.spec.ts | 134 ++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index 980686e0..747ffbfd 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -17,15 +17,27 @@ const BLOCKED_SHOWN = 5 /** * The link at a point of the page's main frame, in CSS pixels (through open shadow roots), as - * `{ href, policy }`; `{ frame: true }` when the point is over a frame or something Orbit can't + * `{ href, policy }`; `{ frame: true }` when the press was on a frame or something Orbit can't * look into (the link may be inside); null over anything else. `policy` is the referrer policy the * link sets for itself (`rel=noreferrer`, `referrerpolicy`) or else the page's * ``; a policy the page sets in its response headers is out of reach. * * The click's own point, not the page's `:hover`: the page's hover follows the pointer it was last - * told about, which can change between the press and this lookup (fix check B, F1). + * told about, which can change between the press and this lookup (fix check B, F1). Except over a + * frame: a press there comes in the frame's own coordinates when the frame is from another site, + * which would name whatever of the page's sits at that point. So when the page's hover ends on a + * frame, the press counts as the frame's (round 2 fix check, M2). */ const linkAt = (x: number, y: number) => `(() => { + const isFrame = (el) => ['iframe', 'frame', 'object', 'embed'].includes(el.localName) + let hovered = null + for (let root = document, i = 0; root && i < 16; i++) { + const list = root.querySelectorAll(':hover') + if (!list.length) break + hovered = list[list.length - 1] + root = hovered.shadowRoot + } + if (hovered && isFrame(hovered)) return { frame: true } let root = document, el = null for (let i = 0; i < 16; i++) { const hit = root.elementFromPoint(${x}, ${y}) @@ -45,8 +57,7 @@ const linkAt = (x: number, y: number) => `(() => { const own = n.relList && n.relList.contains('noreferrer') ? 'no-referrer' : n.referrerPolicy || n.getAttribute('referrerpolicy') || '' return { href, policy: own || meta } } - const tag = el.localName - return tag === 'iframe' || tag === 'frame' || tag === 'object' || tag === 'embed' || el.shadowRoot ? { frame: true } : null + return isFrame(el) || el.shadowRoot ? { frame: true } : null })()` interface PageLink { @@ -173,8 +184,9 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks altClicks.set(tabId, { url: found.href, until, split: false }) // Sent with the page as referrer, as the link's policy or the page's allows (review B-F1). openInSplit(tabId, found.href, { url: page, policy: referrerPolicyName(found.policy) }) - } else if (found?.frame && openable(fallback) && pageMayOpen(page, fallback)) { - // A link in a frame: the engine's download of it says which, and opens the split. + } else if (found?.frame && openable(fallback) && pageMayOpen(undefined, fallback)) { + // A link in a frame: the engine's download of it says which, and opens the split. The frame + // counts as a web page, whatever the page is: a local file opens from no frame (round 2, F2). altClicks.set(tabId, { url: fallback, until, split: true }) } } diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index f7725661..eb315f0a 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,6 +18,7 @@ import { type NavFixtures, openTab, pageMenu, + sendToPage, startNavFixtures, visit, waitLoaded, @@ -356,6 +358,84 @@ test('[NV-08][SV-08] ⌥-click opens the link under the click, though the pointe await expect.poll(downloads, { timeout: 10_000 }).toEqual([{ url: page('Busy-next'), cancelled: true }]) }) +/** The real X pointer and keyboard on the test's display (xdotool, Linux), or null where there are none. */ +const xdotool = (() => { + if (process.platform !== 'linux' || !process.env.DISPLAY) return null + try { + execFileSync('xdotool', ['version'], { stdio: 'ignore' }) + } catch { + return null + } + return (...args: string[]) => execFileSync('xdotool', args, { encoding: 'utf8' }) +})() + +/** Where a point of a tab's page (in CSS pixels at 100%) is on the screen. */ +function onScreen(app: ElectronApplication, tabId: string, x: number, y: number): Promise<{ x: number; y: number }> { + return app.evaluate( + (_e, [id, px, py]) => { + type Windows = { + windowOfTab: (id: string) => string | undefined + get: (id: string) => Electron.BrowserWindow | undefined + tabViewBounds: (id: string) => Electron.Rectangle | undefined + } + const { windows } = (globalThis as unknown as { __orbit: { ctx: { windows: Windows } } }).__orbit.ctx + const win = windows.get(windows.windowOfTab(id) ?? '') + const view = windows.tabViewBounds(id) + if (!win || !view) throw new Error(`tab ${id} isn't on screen`) + const content = win.getContentBounds() + return { x: content.x + view.x + px, y: content.y + view.y + py } + }, + [tabId, x, y] as const, + ) +} + +test('[NV-08][SV-08] ⌥-click on a link in a frame from another site opens that link, not the page’s link at the frame’s own point (round 2 fix check, M2)', async () => { + test.skip(!xdotool, 'the press has to come from the real pointer: test input never reaches a frame from another site') + const x = xdotool as NonNullable + const { app } = orbit + // The page on 127.0.0.1; its frame on localhost, another site, so in a process of its own. + const top = fx.urlOn('127.0.0.1', '/page/AltTop') + const decoy = fx.urlOn('127.0.0.1', '/page/AltDecoy') + const target = page('AltTarget') + const id = await openTab(app, top) + const downloads = await watchDownloads(app, top) + const box = 'position:absolute;display:block;left:40px;top:70px;width:80px;height:40px' + const frame = 'position:absolute;left:100px;top:100px;width:400px;height:300px;border:0' + const fill = (html: string) => + `document.body.style.cssText = 'margin:0;padding:0'; document.body.innerHTML = ${JSON.stringify(html)}` + // The page's own link sits at (80, 90): where the frame's link is in the frame's own coordinates. + await inFrame( + app, + id, + '/page/AltTop', + fill( + `Decoy`, + ), + ) + await expect.poll(() => inFrame(app, id, '/page/AltInner', 'document.readyState')).toBe('complete') + await inFrame(app, id, '/page/AltInner', fill(`Target`)) + // The frame's link, at (80, 90) in the frame and (180, 190) in the page. + const at = await onScreen(app, id, 180, 190) + const [, startX = '0', startY = '0'] = /X=(\d+)\s+Y=(\d+)/.exec(x('getmouselocation', '--shell')) ?? [] + try { + x('mousemove', String(at.x), String(at.y)) + await expect + .poll(() => inFrame(app, id, '/page/AltInner', `document.getElementById('target').matches(':hover')`)) + .toBe(true) + x('keydown', 'alt') + x('click', '1') + } finally { + x('keyup', 'alt') + x('mousemove', startX, startY) + } + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [, right] = await splitTabs(app, id) + expect(await urlOf(app, right)).toBe(target) + // The engine's own download of the frame's link said which, and was cancelled. + await expect.poll(downloads, { timeout: 10_000 }).toEqual([{ url: target, cancelled: true }]) + expect(fx.hits('/page/AltDecoy')).toBe(0) +}) + test('[NV-09] a target=_blank link opens a new tab at the top of Today and selects it', async () => { const { app } = orbit const opener = await openTab(app, page('Blank')) @@ -721,6 +801,60 @@ test('[NV-12][NV-08] a local file page still opens its links to local files: Ope expect(await urlOf(app, right)).toBe(second) }) +/** Records the links a page's pointer goes over (`update-target-url`), latest last. */ +async function watchTargets(app: ElectronApplication, pageUrl: string): Promise<() => Promise> { + await app.evaluate(({ webContents }, url) => { + const g = globalThis as unknown as { __targets: string[] } + g.__targets = [] + const contents = webContents.getAllWebContents().find((w) => w.getURL() === url) + if (!contents) throw new Error(`no page ${url}`) + contents.on('update-target-url', (_e, target) => g.__targets.push(target)) + }, pageUrl) + return () => app.evaluate(() => (globalThis as unknown as { __targets: string[] }).__targets.at(-1)) +} + +test('[NV-08] ⌥-click on a local file’s link in a frame never opens it beside the page, even in a local page: the engine’s download goes ahead, as a frame counts as a web page (D49, round 2 fix check F2)', async () => { + const { app } = orbit + const local = localFiles() + const folder = new URL(local.folder.endsWith('/') ? local.folder : `${local.folder}/`) + const outer = new URL('outer.html', folder).href + writeFileSync( + new URL(outer), + 'Outer', + ) + writeFileSync( + new URL('inner.html', folder), + `InnerLocal`, + ) + const id = await openTab(app, outer) + await expect.poll(() => inFrame(app, id, 'inner.html', 'document.readyState')).toBe('complete') + const loads = await watchLoads(app, local.folder) + const downloads = await watchDownloads(app, outer) + const target = await watchTargets(app, outer) + /** ⌥-clicks the frame's link at (x, y) in the page once the pointer is over it. */ + const altClick = async (x: number, url: string) => { + await sendToPage(app, outer, [{ type: 'mouseMove', x, y: 140 }]) + await expect.poll(target).toBe(url) + await sendToPage(app, outer, clickEvents(x, 140, ['alt'])) + } + await altClick(180, local.text) + // The engine downloads the link it was ⌥-clicked on. Orbit lets that go ahead as a download and + // opens nothing. + await expect.poll(downloads).toEqual([{ url: local.text, cancelled: false }]) + // A web link in the same frame opens beside the page, so the ⌥-click before it was seen. + await inFrame(app, id, 'inner.html', `document.getElementById('link').href = ${JSON.stringify(page('FramedWeb'))}`) + await altClick(181, page('FramedWeb')) + await expect.poll(() => splitTabs(app, id)).toHaveLength(2) + const [, right] = await splitTabs(app, id) + expect(await urlOf(app, right)).toBe(page('FramedWeb')) + expect(Object.values((await state(app)).tabs).filter((t) => t.url === local.text)).toEqual([]) + expect((await loads()).filter((u) => u.includes('orbit-local.txt'))).toEqual([]) + await expect.poll(downloads, { timeout: 10_000 }).toEqual([ + { url: local.text, cancelled: false }, + { url: page('FramedWeb'), cancelled: true }, + ]) +}) + test('[TB-02] a local file’s address dropped on the sidebar opens nothing, as a link or as text; a web link dropped after it opens (D49)', async () => { const { app, shell } = orbit const local = localFiles() From ecc28e4b10b657fa1f3e871bc2b81c4c64ad0cd3 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:02:24 +0000 Subject: [PATCH 59/69] =?UTF-8?q?fix(page-tools):=20a=20new=20window's=20p?= =?UTF-8?q?age=20tools=20never=20take=20the=20keyboard=20while=20they=20lo?= =?UTF-8?q?ad;=20keys=20typed=20after=20=E2=8C=98F=20wait=20for=20the=20ba?= =?UTF-8?q?r=20(round=202=20fix=20check=20M3,=20F4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3: the page tools' view went into the window as soon as it was made, and a view placed while its page loads takes the keyboard. In a new window's first 1.5 s (before the prewarm), ⌘+ handed the keyboard to the zoom indicator, and it never came back. As 4adf916 and 3c4e532 did for the loading indicator and the prompt, the view now goes into the window only once its page has loaded (windows.onToolsReady); the nav module then shows it, the zoom indicator for its full time from then. hideTools also gives the keyboard to the active page when nothing has it in the focused window. When find opens, the bar takes the keyboard once it is in the window. F4: from ⌘F until the bar's field says it has the keyboard (find.focused), plain keys typed in the page or the page tools (text, Backspace, Enter, Escape; never shortcuts) are held, then typed into the field in order. So keys typed right after ⌘F in a window's first second reach the bar. Tests: nav.spec M3 (zoom in a new window's first moments; keys reach the page's field before, while and after the indicator shows; the tools view never takes the keyboard) and F4 (keys typed right after ⌘F reach the bar, not the page). Co-Authored-By: Claude Opus 5.5 --- src/main/nav/page-tools.ts | 177 ++++++++++++-- src/main/windows.ts | 50 +++- .../src/overlay/find/PageToolsApp.tsx | 2 + src/shared/commands/nav.ts | 5 + tests/e2e/nav.spec.ts | 228 ++++++++++++++++++ 5 files changed, 437 insertions(+), 25 deletions(-) diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts index 2d9aeaf7..487462c7 100644 --- a/src/main/nav/page-tools.ts +++ b/src/main/nav/page-tools.ts @@ -21,6 +21,10 @@ import type { SitePrefs } from './site-prefs' export const ZOOM_INDICATOR_MS = 1500 /** Pinch-to-zoom reaches Chromium's own limit (NV-06). */ const PINCH_MAX = 5 +/** Keys typed for a find bar that never takes the keyboard are let go after this long (F4). */ +const HOLD_MS = 5000 +/** Keys other than text that are held for the find bar (F4): it acts on them as it would. */ +const HELD_KEYS = new Set(['Backspace', 'Enter', 'Escape']) const SELECTION = `String(getSelection() || '')` @@ -61,11 +65,34 @@ interface WindowTools { sent?: string } +/** A key typed for the find bar before its field had the keyboard (F4). */ +interface HeldKey { + key: string + shift: boolean +} + +/** + * Keys held for a window's find bar from ⌘F until its field has the keyboard (round 2 fix check, + * F4). `nonce`: the focus request the field must say it took, once the bar is in the window and + * has been given the keyboard; null before then. + */ +interface HeldKeys { + tabId: TabId + nonce: number | null + keys: HeldKey[] + timer?: NodeJS.Timeout +} + +/** Where a window's page tools are after a refresh: in the window, waiting for their page to load, or out. */ +type ToolsShown = 'placed' | 'loading' | 'hidden' + export interface PageToolsInfo { find: FindState zoom: ZoomIndicator | null /** The pointer is on the zoom indicator (it stays while it is). */ hovered: boolean + /** The page tools' page has loaded: they can go into the window. */ + ready: boolean placed: boolean bounds?: Rectangle } @@ -141,12 +168,17 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( return { tabId, open, query, active, matches, searched, focus } } - /** Shows, moves or hides a window's page tools to match what they hold. */ - const refresh = (windowId: WindowId) => { + /** + * Shows, moves or hides a window's page tools to match what they hold. They go into the window + * only once their page has loaded (round 2 fix check, M3): until then this says 'loading', and + * the view's page hears what to show when it asks. + */ + const refresh = (windowId: WindowId): ToolsShown => { if (!windows.get(windowId)) { clearTimeout(tools.get(windowId)?.zoomTimer) tools.delete(windowId) - return + dropKeys(windowId) + return 'hidden' } const t = tools.get(windowId) const find = findState(windowId) @@ -156,10 +188,11 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( // Nothing is under the pointer once the view has gone. if (t) t.hovered = false windows.hideTools(windowId) - return + return 'hidden' } const contents = windows.toolsContents(windowId) - if (!contents) return + if (!contents) return 'hidden' + watchKeys(windowId, contents) const state = toolsOf(windowId) const payload = { find, zoom } const json = JSON.stringify(payload) @@ -167,7 +200,76 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( state.sent = json contents.send('orbit:pageTools', payload) } - windows.showTools(windowId, toolsBounds(page, find.open, !!zoom)) + return windows.showTools(windowId, toolsBounds(page, find.open, !!zoom)) ? 'placed' : 'loading' + } + + // ---------- Keys typed before the find bar can take them (round 2 fix check, F4) ---------- + + const held = new Map() + /** The page tools' web contents whose keys are watched. */ + const watched = new WeakSet() + + const dropKeys = (windowId: WindowId) => { + clearTimeout(held.get(windowId)?.timer) + held.delete(windowId) + } + + /** Starts holding (or goes on holding) keys for the find bar of a window's tab. */ + const holdKeys = (windowId: WindowId, tabId: TabId): HeldKeys => { + let h = held.get(windowId) + if (!h || h.tabId !== tabId) { + dropKeys(windowId) + h = { tabId, nonce: null, keys: [] } + held.set(windowId, h) + } + const holding = h + clearTimeout(holding.timer) + holding.timer = setTimeout(() => { + if (held.get(windowId) === holding) held.delete(windowId) + }, HOLD_MS) + return holding + } + + /** + * Holds a key typed in the page or the page tools while the find bar waits for the keyboard: + * text, Backspace, Enter and Escape, without ⌘ or Ctrl (shortcuts go on as ever). True when held. + */ + const holdKey = (windowId: WindowId | undefined, tabId: TabId | null, input: Electron.Input): boolean => { + const h = windowId ? held.get(windowId) : undefined + if (!h || (tabId !== null && tabId !== h.tabId)) return false + if (input.control || input.meta || input.isComposing) return false + if (input.alt && process.platform !== 'darwin') return false + const text = [...input.key].length === 1 + if (!text && !HELD_KEYS.has(input.key)) return false + if (input.type === 'keyDown') h.keys.push({ key: input.key, shift: input.shift }) + return true + } + + const watchKeys = (windowId: WindowId, contents: WebContents) => { + if (watched.has(contents)) return + watched.add(contents) + contents.on('before-input-event', (event, input) => { + if (holdKey(windowId, null, input)) event.preventDefault() + }) + } + + /** Types a held key into the page tools, as the keyboard would have. */ + const typeKey = (contents: WebContents, { key, shift }: HeldKey) => { + if ([...key].length === 1) { + contents.sendInputEvent({ type: 'keyDown', keyCode: key }) + contents.sendInputEvent({ type: 'char', keyCode: key }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key }) + return + } + const modifiers: 'shift'[] = shift ? ['shift'] : [] + contents.sendInputEvent({ type: 'keyDown', keyCode: key, modifiers }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key, modifiers }) + } + + /** The find bar is in the window: it takes the keyboard, and its field says when it has it (find.focused). */ + const giveBarKeyboard = (windowId: WindowId, h: HeldKeys, s: FindSession) => { + h.nonce = s.focus + windows.toolsContents(windowId)?.focus() } const refreshTab = (tabId: TabId) => { @@ -207,10 +309,17 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( } } - const focusBar = (windowId: WindowId, s: FindSession) => { + /** + * The find bar takes the keyboard, its text selected. Not before it is in the window (round 2 fix + * check, M3): until then the page keeps the keyboard, and keys typed for the bar meanwhile are + * held for it (F4). + */ + const focusBar = (windowId: WindowId, tabId: TabId, s: FindSession) => { s.focus = ++focusNonce - refresh(windowId) - windows.toolsContents(windowId)?.focus() + const shown = refresh(windowId) + if (shown === 'hidden') return + const h = holdKeys(windowId, tabId) + if (shown === 'placed') giveBarKeyboard(windowId, h, s) } /** @@ -228,13 +337,13 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( if (!findable(windowId) || !tabId || !contents) return const s = sessionOf(tabId) if (s.open) { - focusBar(windowId, s) + focusBar(windowId, tabId, s) return } s.open = true clearMatches(s) if (s.query) search(contents, s, true) - focusBar(windowId, s) + focusBar(windowId, tabId, s) // Selected text on the page starts a new search instead (asked after the bar is up, so no key is lost). const edits = s.edits contents @@ -244,7 +353,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( if (!text || text === s.query || !s.open || s.edits !== edits || finds.get(tabId) !== s) return s.query = text search(contents, s, true) - if (activeOf(windowId) === tabId) focusBar(windowId, s) + if (activeOf(windowId) === tabId) focusBar(windowId, tabId, s) }) .catch(() => {}) } @@ -263,7 +372,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( s.open = true clearMatches(s) search(contents, s, true) - focusBar(windowId, s) + focusBar(windowId, tabId, s) return } search(contents, s, !s.searched && s.request === 0, forward) @@ -273,6 +382,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( const tabId = activeOf(windowId) const s = tabId ? finds.get(tabId) : undefined if (!windowId || !tabId || !s?.open) return + dropKeys(windowId) s.open = false clearMatches(s) pageOf(tabId)?.stopFindInPage('keepSelection') @@ -303,6 +413,15 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( if (!query) refresh(windowId) }) commands.register('find.step', ({ forward }, c) => step(windowOf(c), forward)) + // The bar's field has the keyboard: the keys held for it are typed into it, in order (F4). + commands.register('find.focused', ({ focus }, c) => { + const windowId = windowOf(c) + const h = windowId ? held.get(windowId) : undefined + if (!windowId || !h || h.nonce === null || focus < h.nonce) return + dropKeys(windowId) + const contents = windows.toolsContents(windowId) + if (contents) for (const key of h.keys) typeKey(contents, key) + }) commands.register('find.close', (_args, c) => closeFind(windowOf(c))) // ---------- Page zoom (NV-06) ---------- @@ -320,8 +439,10 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( const t = toolsOf(windowId) t.zoom = { percent: zoomPercent(factor), nonce: ++zoomNonce } clearTimeout(t.zoomTimer) - t.zoomTimer = t.hovered ? undefined : setTimeout(() => hideZoom(windowId), ZOOM_INDICATOR_MS) - refresh(windowId) + t.zoomTimer = undefined + // Its time starts once it is in the window: a new window's page tools may still be loading. + if (refresh(windowId) === 'loading') return + if (!t.hovered) t.zoomTimer = setTimeout(() => hideZoom(windowId), ZOOM_INDICATOR_MS) } /** Chromium zooms every tab of the host in the profile's session; Orbit also keeps it for next time. */ @@ -398,11 +519,36 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( windows.onLayout((windowId) => { const t = tools.get(windowId) const tabId = activeOf(windowId) + // Keys held for another tab's bar aren't for this one. + if (held.get(windowId)?.tabId !== tabId) dropKeys(windowId) if (t?.zoom || (tabId && finds.get(tabId)?.open) || windows.toolsInfo(windowId).placed) refresh(windowId) }) + // The page tools' page has loaded, so they can go into the window (round 2 fix check, M3): the zoom + // indicator shows for its full time from now, and an open find bar takes the keyboard. + windows.onToolsReady((windowId) => { + const t = tools.get(windowId) + if (t?.zoom && !t.hovered) { + clearTimeout(t.zoomTimer) + t.zoomTimer = setTimeout(() => hideZoom(windowId), ZOOM_INDICATOR_MS) + } + const tabId = activeOf(windowId) + const s = tabId ? finds.get(tabId) : undefined + const h = held.get(windowId) + if (!s?.open || h?.tabId !== tabId) { + refresh(windowId) + return + } + s.focus = ++focusNonce + if (refresh(windowId) === 'placed') giveBarKeyboard(windowId, h, s) + }) + ctx.hooks.tabCreated.push((tabId, contents) => { allowPinch(contents) + // Keys typed for the find bar before it has the keyboard wait for it, not reach the page (F4). + contents.on('before-input-event', (event, input) => { + if (holdKey(windows.windowOfTab(tabId), tabId, input)) event.preventDefault() + }) contents.on('found-in-page', (_event, result) => { const s = finds.get(tabId) if (!s?.open || result.requestId !== s.request) return @@ -448,6 +594,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( find: findState(windowId), zoom: tools.get(windowId)?.zoom ?? null, hovered: tools.get(windowId)?.hovered ?? false, + ready: placed.ready, placed: placed.placed, bounds: placed.placed ? placed.bounds : undefined, } diff --git a/src/main/windows.ts b/src/main/windows.ts index 0cd27111..4a900879 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -69,7 +69,13 @@ interface WindowRecord { * top-right corner, started a little after the window opens and placed only while it shows something. */ tools?: WebContentsView + /** The page tools are wanted on screen: from showTools() to hideTools(). */ toolsShown: boolean + /** + * The page tools' page has loaded. Only then does their view go into the window: a view placed + * while its page loads takes the keyboard from the page (round 2 fix check, M3). + */ + toolsReady: boolean toolsPrewarm?: ReturnType /** * A permission prompt over the active page's top-left corner while the sidebar is hidden (NV-15; @@ -206,6 +212,7 @@ export class WindowManager { private attached = new Map() private layoutListeners: ((windowId: WindowId) => void)[] = [] private createdListeners: ((windowId: WindowId, win: BrowserWindow) => void)[] = [] + private toolsReadyListeners: ((windowId: WindowId) => void)[] = [] /** 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)[] = [] @@ -400,6 +407,7 @@ export class WindowManager { overlayShown: false, floatingShown: false, toolsShown: false, + toolsReady: false, promptShown: false, promptReady: false, uiViews: new Set(), @@ -668,6 +676,11 @@ export class WindowManager { this.createdListeners.push(listener) } + /** Runs once a window's page tools have loaded their page: from then on showTools() places them. */ + onToolsReady(listener: (windowId: WindowId) => void): void { + this.toolsReadyListeners.push(listener) + } + /** The page tools' web contents (NV-05, NV-06), started if it wasn't yet. */ toolsContents(windowId: WindowId): WebContents | undefined { const rec = this.records.get(windowId) @@ -675,13 +688,18 @@ export class WindowManager { return viewContents(this.ensureTools(rec)) } - /** Places the page tools at `bounds` (window coordinates): above the pages, under the floating sidebar and the overlay. */ - showTools(windowId: WindowId, bounds: Rect): void { + /** + * Places the page tools at `bounds` (window coordinates): above the pages, under the floating + * sidebar and the overlay. Not before their page has loaded (round 2 fix check, M3): the nav + * module shows them again then (onToolsReady). Returns whether they are in the window. + */ + showTools(windowId: WindowId, bounds: Rect): boolean { const rec = this.records.get(windowId) - if (!rec || rec.win.isDestroyed()) return + if (!rec || rec.win.isDestroyed()) return false const view = this.ensureTools(rec) view.setBounds(roundRect(bounds)) rec.toolsShown = true + if (!rec.toolsReady) return false const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) @@ -695,26 +713,31 @@ export class WindowManager { parent.addChildView(view, at >= 0 && at < ceiling ? ceiling - 1 : ceiling) } view.setVisible(true) + return true } - /** Takes the page tools out of the window; keyboard focus in them goes back to the page. */ + /** + * Takes the page tools out of the window. The keyboard goes back to the page when they had it, + * or when nothing has it in the focused window (round 2 fix check, M3). + */ hideTools(windowId: WindowId): void { const rec = this.records.get(windowId) const view = rec?.tools if (!rec || !view || !rec.toolsShown) return rec.toolsShown = false const contents = viewContents(view) - const focused = !!contents && webContents.getFocusedWebContents() === contents - if (focused && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) + const focused = webContents.getFocusedWebContents() + if ((!focused || focused === contents) && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) this.removeChild(rec, view) } - /** Test/debug view of a window's page tools. */ - toolsInfo(windowId: WindowId): { exists: boolean; placed: boolean; bounds?: Rect } { + /** Test/debug view of a window's page tools; `ready`: their page has loaded. */ + toolsInfo(windowId: WindowId): { exists: boolean; ready: boolean; placed: boolean; bounds?: Rect } { const rec = this.records.get(windowId) const view = rec?.tools - if (!rec || !view || !viewContents(view)) return { exists: false, placed: false } - return { exists: true, placed: rec.win.contentView.children.includes(view), bounds: view.getBounds() } + if (!rec || !view || !viewContents(view)) return { exists: false, ready: false, placed: false } + const placed = rec.win.contentView.children.includes(view) + return { exists: true, ready: rec.toolsReady, placed, bounds: view.getBounds() } } /** @@ -1399,6 +1422,13 @@ export class WindowManager { loadRenderer(view.webContents, 'tools') rec.tools = view rec.toolsShown = false + rec.toolsReady = false + // Into the window once its page has loaded (round 2 fix check, M3), when the nav module shows it. + view.webContents.once('did-finish-load', () => { + if (rec.tools !== view || rec.win.isDestroyed()) return + rec.toolsReady = true + for (const listener of this.toolsReadyListeners) listener(rec.id) + }) return view } diff --git a/src/renderer/src/overlay/find/PageToolsApp.tsx b/src/renderer/src/overlay/find/PageToolsApp.tsx index 266de737..2513e3a1 100644 --- a/src/renderer/src/overlay/find/PageToolsApp.tsx +++ b/src/renderer/src/overlay/find/PageToolsApp.tsx @@ -69,6 +69,8 @@ function FindBar({ state }: { state: FindState }) { setEmpty(state.query === '') el.focus() el.select() + // Keys typed for the bar before it had the keyboard come now. + void invoke('find.focused', { focus: state.focus }) } else if (document.activeElement !== el && el.value !== state.query) { el.value = state.query setEmpty(state.query === '') diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index 6f3cab95..c2a13fe3 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -68,6 +68,11 @@ export interface NavCommands { 'find.step': { args: { forward: boolean }; result: void } /** Closes the find bar: highlights go, the page keeps the last match selected and gets focus. */ 'find.close': { args: Record; result: void } + /** + * The find bar's field took the keyboard for focus request `focus` (FindState.focus): keys typed + * for it before then are typed into it now. + */ + 'find.focused': { args: { focus: number }; result: void } /** * The address display's blocked pop-ups button: a menu at a point in window coordinates to open * the active tab's blocked pop-ups or always allow them on its site (NV-09). diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index 34732a3c..ead48458 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -12,6 +12,7 @@ import { shellRoundTrip, sideButton, startNavFixtures, + type ToolsInfo, toolsInfo, toolsPage, visit, @@ -648,3 +649,230 @@ test('[NV-06] Ctrl and the mouse wheel zoom the page under the pointer (Windows, await wheel(-120) await expect.poll(() => zoomOf(app, '/page/Wheel')).toBeCloseTo(1) }) + +// ---- A new window's page tools and the keyboard (round 2 fix check, M3 and F4) ---- + +/** A window's page tools, as main sees them. */ +function toolsIn(windowId: string): Promise { + return orbit.app.evaluate( + (_e, w) => + ( + globalThis as unknown as { __orbitNav: { tools: (w: string) => ToolsInfo & { ready: boolean } } } + ).__orbitNav.tools(w), + windowId, + ) +} + +/** Where a key typed with nothing holding the keyboard goes, as `typeAnywhere` says. */ +const NOWHERE = '(nothing has the keyboard)' +/** As `typeAnywhere` says of a web contents whose page hasn't loaded yet (it has no address). */ +const LOADING = '(a page still loading)' + +/** + * Types each of `keys` (a character, or a key such as 'Backspace') wherever the keyboard is, as the + * keyboard does. Returns the address of what took each key, or NOWHERE, or LOADING. + */ +function typeAnywhere(keys: string[]): Promise { + return orbit.app.evaluate( + ({ webContents }, [list, nowhere, loading]) => + list.map((key) => { + const contents = webContents.getFocusedWebContents() + if (!contents) return nowhere + contents.sendInputEvent({ type: 'keyDown', keyCode: key }) + if ([...key].length === 1) contents.sendInputEvent({ type: 'char', keyCode: key }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key }) + return contents.getURL() || loading + }), + [keys, NOWHERE, LOADING] as const, + ) +} + +/** + * Records each web contents that takes the keyboard (its `focus` event). Read by address, once + * known: a view's page may not have loaded when it takes the keyboard. + */ +async function watchKeyboard(): Promise<() => Promise> { + await orbit.app.evaluate(({ app: electronApp, webContents }) => { + const g = globalThis as unknown as { __tookKeyboard: number[] } + g.__tookKeyboard = [] + const watch = (contents: Electron.WebContents) => contents.on('focus', () => g.__tookKeyboard.push(contents.id)) + for (const contents of webContents.getAllWebContents()) watch(contents) + electronApp.on('web-contents-created', (_e, contents) => watch(contents)) + }) + return () => + orbit.app.evaluate(({ webContents }) => + (globalThis as unknown as { __tookKeyboard: number[] }).__tookKeyboard.map( + (id) => webContents.fromId(id)?.getURL() ?? '', + ), + ) +} + +/** What a window's zoom indicator says on screen: in the window, and its text. */ +function zoomShown(windowId: string): Promise<{ placed: boolean; text: string | null }> { + return orbit.app.evaluate(async (_e, w) => { + const windows = ( + globalThis as unknown as { + __orbit: { + ctx: { + windows: { + toolsInfo: (w: string) => { placed: boolean } + toolsContents: (w: string) => Electron.WebContents + } + } + } + } + ).__orbit.ctx.windows + const text: string | null = await windows + .toolsContents(w) + .executeJavaScript(`document.querySelector('[data-testid=zoom-percent]')?.textContent ?? null`) + return { placed: windows.toolsInfo(w).placed, text } + }, windowId) +} + +interface FreshWindow { + windowId: string + tabId: string + /** How long after the window showed `act` came. */ + at: number + /** The page tools' page had loaded by then. */ + toolsReady: boolean + /** Where each key typed right after `act` went, as `typeAnywhere` says. */ + typedInto: string[] +} + +/** + * As fast as a person could, in main: opens a window as ⌘N does, opens `url` there in a new tab, + * closes the command bar and puts the keyboard in the page's text field (#box). Then does `act`, + * ⌘+ or ⌘F as pressed in the page, and types `keys` wherever the keyboard is, at once. + */ +function freshWindow(url: string, act: 'zoom' | 'find', keys: string[]): Promise { + return orbit.app.evaluate( + async ({ webContents }, [address, what, list, modifier, nowhere, loading]) => { + type Orbit = { + action: (id: string, w?: string) => Promise + run: (name: string, args: unknown, w?: string) => Promise + windowIds: () => string[] + ctx: { + windows: { + get: (w: string) => Electron.BrowserWindow | undefined + toolsInfo: (w: string) => { ready: boolean } + } + tabs: { contents: (id: string) => Electron.WebContents | undefined } + } + } + const o = (globalThis as unknown as { __orbit: Orbit }).__orbit + const before = o.windowIds() + await o.action('window.new', before[0]) + const windowId = o.windowIds().find((w) => !before.includes(w)) + const win = windowId ? o.ctx.windows.get(windowId) : undefined + if (!windowId || !win) throw new Error('no new window') + const shownAt = await new Promise((resolve) => { + if (win.isVisible()) resolve(Date.now()) + else win.once('show', () => resolve(Date.now())) + }) + const tabId = String(await o.run('tabs.create', { url: address }, windowId)) + await o.run('overlay.close', {}, windowId) + let page = o.ctx.tabs.contents(tabId) + for (let i = 0; i < 500 && (!page || page.getURL() !== address || page.isLoading()); i++) { + await new Promise((r) => setTimeout(r, 10)) + page = o.ctx.tabs.contents(tabId) + } + if (!page) throw new Error(`no page for ${tabId}`) + page.focus() + await page.executeJavaScript(`document.getElementById('box').focus()`) + const at = Date.now() - shownAt + const toolsReady = o.ctx.windows.toolsInfo(windowId).ready + if (what === 'zoom') { + await o.action('page.zoomIn', windowId) + } else { + page.sendInputEvent({ type: 'keyDown', keyCode: 'F', modifiers: [modifier] }) + page.sendInputEvent({ type: 'keyUp', keyCode: 'F', modifiers: [modifier] }) + } + const typedInto = list.map((key) => { + const contents = webContents.getFocusedWebContents() + if (!contents) return nowhere + contents.sendInputEvent({ type: 'keyDown', keyCode: key }) + if ([...key].length === 1) contents.sendInputEvent({ type: 'char', keyCode: key }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key }) + return contents.getURL() || loading + }) + return { windowId, tabId, at, toolsReady, typedInto } + }, + [url, act, keys, mod, NOWHERE, LOADING] as const, + ) +} + +/** The first of up to three fresh windows (D46) where `act` came before the page tools' page had loaded. */ +async function earlyWindow(url: string, act: 'zoom' | 'find', keys: string[]): Promise { + const tries: number[] = [] + for (let n = 0; n < 3; n++) { + const fresh = await freshWindow(url, act, keys) + if (!fresh.toolsReady) return fresh + tries.push(fresh.at) + await orbit.app.evaluate((_e, w) => { + ;( + globalThis as unknown as { __orbit: { ctx: { windows: { get: (w: string) => Electron.BrowserWindow } } } } + ).__orbit.ctx.windows + .get(w) + ?.close() + }, fresh.windowId) + } + throw new Error(`the page tools were ready before ${act} in 3 windows (at ${tries.join(', ')} ms)`) +} + +/** The value of a tab's text field (#box). */ +function fieldValue(tabId: string): Promise { + return orbit.app.evaluate( + (_e, id) => + ( + globalThis as unknown as { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } + } + ).__orbit.ctx.tabs + .contents(id) + ?.executeJavaScript(`document.getElementById('box').value`) ?? null, + tabId, + ) +} + +test('[NV-06] zoom in a new window’s first moments never takes the keyboard: keys reach the page’s field before, while and after the indicator shows (round 2 fix check, M3)', async () => { + const tookKeyboard = await watchKeyboard() + const fresh = await earlyWindow(page('Fresh'), 'zoom', ['a', 'b']) + expect(fresh.typedInto).toEqual([page('Fresh'), page('Fresh')]) + // The indicator shows once its page has loaded. + await expect.poll(() => zoomShown(fresh.windowId)).toEqual({ placed: true, text: '110%' }) + expect(await typeAnywhere(['c', 'd'])).toEqual([page('Fresh'), page('Fresh')]) + await expect.poll(async () => (await toolsIn(fresh.windowId)).placed, { timeout: 10_000 }).toBe(false) + expect(await typeAnywhere(['e', 'f'])).toEqual([page('Fresh'), page('Fresh')]) + await expect.poll(() => fieldValue(fresh.tabId)).toBe('abcdef') + expect((await tookKeyboard()).filter((url) => url.includes('view=tools'))).toEqual([]) +}) + +test('[NV-05] keys typed right after ⌘F in a new window’s first moments reach the find bar once it is in, not the page (round 2 fix check, M3 and F4)', async () => { + const fresh = await earlyWindow(findPage('Early'), 'find', ['o', 'r', 'x', 'Backspace', 'b']) + // The page keeps the keyboard until the bar is in the window; what is typed for the bar meanwhile waits for it. + expect(fresh.typedInto).toEqual(Array(5).fill(findPage('Early'))) + await expect.poll(async () => (await toolsIn(fresh.windowId)).find).toMatchObject({ open: true, query: 'orb' }) + await expect.poll(() => focusedUrl(orbit.app)).toContain('view=tools') + const input = () => + orbit.app.evaluate( + (_e, w) => + ( + globalThis as unknown as { + __orbit: { ctx: { windows: { toolsContents: (w: string) => Electron.WebContents } } } + } + ).__orbit.ctx.windows + .toolsContents(w) + .executeJavaScript(`document.querySelector('[data-testid=find-input]')?.value ?? null`), + fresh.windowId, + ) + await expect.poll(input).toBe('orb') + // Typing goes on in the bar; the page heard none of it. + expect(await typeAnywhere(['i', 't'])).toEqual([ + expect.stringContaining('view=tools'), + expect.stringContaining('view=tools'), + ]) + await expect.poll(input).toBe('orbit') + await expect.poll(async () => (await toolsIn(fresh.windowId)).find).toMatchObject({ query: 'orbit', matches: 3 }) + expect(await fieldValue(fresh.tabId)).toBe('') +}) From 8fd507a97f1e1ecc565ed2fafc91cad39e10b56a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:11:19 +0000 Subject: [PATCH 60/69] fix(sidebar): a pointer that leaves the window from inside the floating card lets it go; the card stays while a menu is up (round 2 fix check M4) Over the frameless window's resize border or the card's drag region nothing hears the pointer, so the card's last leave came short of the edge and the card told main nothing: it stayed out with the pointer gone. Now a leave the card can't place says so (sidebar.peekState { over: null }), and main reads the pointer (watchOut): the card stays while the pointer is in the window, and goes once it is out of the window with no button down. The card also stays while a native menu is up in its window, as a menu can reach out of the window: menu-watch counts the menus up in each window for every Menu.popup(), and the card's grace delay waits for the last to close. Test: chrome.spec M4 (a leave to nothing at x = 2, then the pointer out of the window: gone within a second; kept in the window: stays; a menu up: stays until it closes). Co-Authored-By: Claude Opus 5.5 --- src/main/menu-watch.ts | 57 ++++++++++++++++++ src/main/nav/sidebar-reveal.ts | 33 ++++++++--- .../src/shell/chrome/FloatingSidebarApp.tsx | 18 ++++-- src/shared/commands/nav.ts | 3 +- tests/e2e/chrome.spec.ts | 58 +++++++++++++++++++ 5 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 src/main/menu-watch.ts diff --git a/src/main/menu-watch.ts b/src/main/menu-watch.ts new file mode 100644 index 00000000..186b00e3 --- /dev/null +++ b/src/main/menu-watch.ts @@ -0,0 +1,57 @@ +import { BaseWindow, Menu, type PopupOptions } from 'electron' + +/** Native menus up now, by the id of the window each opened in. */ +const upIn = new Map() +const closedListeners: ((win: BaseWindow) => void)[] = [] +let watching = false + +/** + * Counts the native menus up in each window, for every `Menu.popup()` in the app, whichever module + * opens it: the floating sidebar's card stays out while a menu opened from it is up, even where + * the menu reaches out of the window (round 2 fix check, M4). A menu that names no window counts + * for the focused one, where Electron opens it. + */ +export function watchMenus(): void { + if (watching) return + watching = true + const popup = Menu.prototype.popup + Menu.prototype.popup = function (this: Menu, options?: PopupOptions): void { + const win = options?.window ?? BaseWindow.getFocusedWindow() + if (!win || win.isDestroyed()) { + popup.call(this, options) + return + } + const id = win.id + let closed = false + const close = () => { + if (closed) return + closed = true + const left = (upIn.get(id) ?? 1) - 1 + if (left > 0) { + upIn.set(id, left) + return + } + upIn.delete(id) + if (!win.isDestroyed()) for (const listener of closedListeners) listener(win) + } + upIn.set(id, (upIn.get(id) ?? 0) + 1) + this.once('menu-will-close', close) + try { + popup.call(this, options) + } catch (error) { + this.removeListener('menu-will-close', close) + close() + throw error + } + } +} + +/** Whether a native menu is up in `win`. */ +export function menuUpIn(win: BaseWindow | undefined): boolean { + return !!win && !win.isDestroyed() && (upIn.get(win.id) ?? 0) > 0 +} + +/** Runs once the last native menu up in a window has closed. */ +export function onMenusClosed(listener: (win: BaseWindow) => void): void { + closedListeners.push(listener) +} diff --git a/src/main/nav/sidebar-reveal.ts b/src/main/nav/sidebar-reveal.ts index 62ff95af..fd6a0f1a 100644 --- a/src/main/nav/sidebar-reveal.ts +++ b/src/main/nav/sidebar-reveal.ts @@ -1,6 +1,7 @@ import { type MouseInputEvent, screen } from 'electron' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { menuUpIn, onMenusClosed, watchMenus } from '../menu-watch' import type { CallContext } from '../registry' /** The pointer this close to the window's left edge reveals the hidden sidebar (FL-03: a 4–8 px band). */ @@ -59,6 +60,9 @@ const buttonDown = (mouse: MouseInputEvent) => (mouse.modifiers ?? []).some((m) * the shell, main reads it for a moment and brings the card in if it is at the edge (review A-M2). * The card that comes for it has heard nothing either, so main reads the pointer on until the card * or a page hears it, and lets the card go when the pointer has left the window (fix check A, M1). + * The same goes for a pointer that leaves the card for where the card can't tell (`over: null`): + * nothing hears it over the window's resize border or the card's drag region (round 2 fix check, + * M4). While a native menu is up in the window, the card stays: the menu may reach out of it. */ export function registerSidebarReveal(ctx: MainContext): { hold: (windowId: WindowId) => RevealHoldInfo | undefined @@ -67,6 +71,9 @@ export function registerSidebarReveal(ctx: MainContext): { } { const { store, commands, windows } = ctx const holds = new Map() + watchMenus() + /** A native menu is up in the window (one the card opened, as it is out): the card stays (M4). */ + const menuUp = (windowId: WindowId) => menuUpIn(windows.get(windowId)) const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() /** Per window: where the pointer was when the sidebar hid; the edge waits until it moves from there. */ const restAt = new Map() @@ -191,16 +198,17 @@ export function registerSidebarReveal(ctx: MainContext): { } /** - * The card is out for a pointer that may be where nothing hears it (the window's very edge): until - * the card or a page hears it, main reads it. Gone out of the window with no button down, the - * pointer has left the card's side, and the card goes as it would when left (fix check A, M1). + * The card is out for a pointer that may be where nothing hears it (the window's very edge, its + * resize border, the card's drag region): until the card or a page hears it, main reads it. Gone + * out of the window with no button down and no menu up, the pointer has left the card's side, and + * the card goes as it would when left (fix check A, M1; round 2 fix check, M4). */ const watchOut = (windowId: WindowId) => { stopOutWatch(windowId) const look = () => { const hold = holds.get(windowId) if (!store.state.windows[windowId]?.sidebar.peeking || !hold?.over) return stopOutWatch(windowId) - if (pressed.get(windowId) || inWindow(windowId)) return + if (pressed.get(windowId) || inWindow(windowId) || menuUp(windowId)) return stopOutWatch(windowId) hold.over = false settle(windowId) @@ -214,17 +222,23 @@ export function registerSidebarReveal(ctx: MainContext): { setPeeking(windowId, false) } - /** Once nothing holds the card, it slides away after the grace delay. */ + /** Once nothing holds the card, it slides away after the grace delay; not while a menu is up. */ const settle = (windowId: WindowId) => { const hold = holdOf(windowId) clearTimeout(hold.timer) hold.timer = undefined if (!store.state.windows[windowId]?.sidebar.peeking || hold.over || hold.editing || hold.overlay) return + if (menuUp(windowId)) return hold.timer = setTimeout(() => { hold.timer = undefined - if (!hold.over && !hold.editing && !hold.overlay) setPeeking(windowId, false) + if (!hold.over && !hold.editing && !hold.overlay && !menuUp(windowId)) setPeeking(windowId, false) }, HIDE_GRACE_MS) } + // The last menu up in a window closed: the card goes if nothing else holds it. + onMenusClosed((win) => { + const windowId = windows.ids().find((id) => windows.get(id) === win) + if (windowId && holds.has(windowId)) settle(windowId) + }) store.subscribe((next, prev) => { for (const [windowId, win] of Object.entries(next.windows)) { @@ -319,7 +333,12 @@ export function registerSidebarReveal(ctx: MainContext): { const windowId = windowOf(c) if (!windowId || !store.state.windows[windowId]?.sidebar.peeking) return const hold = holdOf(windowId) - if (typeof over === 'boolean') { + if (over === null) { + // The pointer left the card for where the card can't tell: main reads it until a view hears + // it, and the card stays while it is in the window (round 2 fix check, M4). + hold.over = true + watchOut(windowId) + } else if (typeof over === 'boolean') { // The card heard the pointer: it says when the pointer leaves. stopOutWatch(windowId) hold.over = over diff --git a/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx b/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx index bc6e47e6..f61ea5a6 100644 --- a/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx +++ b/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx @@ -56,8 +56,10 @@ const isEditable = (el: Element | null) => /** * Tells main whether the pointer is over the card (or just beside it) and whether a text field in it * has focus. The view reaches the window's top, left and bottom edges and ends beside the card, so - * the pointer leaves it through an edge; a "leave" inside it means a menu or the command bar came up - * over it, which says nothing about where the pointer is (main hears from the page once it moves on). + * the pointer leaves it through an edge. A leave short of the edge says nothing about where the + * pointer went: on over the window's resize border or the card's drag region, where nothing hears + * it, or nowhere, as a menu or the command bar came up over the card. The card says so (`over: + * null`), and main reads the pointer until a view hears it (round 2 fix check, M4). */ function usePointerReports(peeking: boolean) { useEffect(() => { @@ -65,12 +67,14 @@ function usePointerReports(peeking: boolean) { let over: boolean | null = null let editing: boolean | null = null let said = 0 + let unsure = false const setOver = (value: boolean) => { // While the pointer moves over the card, say so now and then: main may have heard otherwise. const now = performance.now() if (value === over && (!value || now - said < REASSERT_MS)) return over = value said = now + unsure = false void invoke('sidebar.peekState', { over: value }) } const setEditing = (value: boolean) => { @@ -82,8 +86,14 @@ function usePointerReports(peeking: boolean) { const onOut = (e: MouseEvent) => { if (e.relatedTarget || e.buttons) return const atEdge = e.clientX <= 0 || e.clientY <= 0 || e.clientX >= innerWidth - 1 || e.clientY >= innerHeight - 1 - if (atEdge) setOver(false) - else over = null + if (atEdge) { + setOver(false) + return + } + over = null + if (unsure) return + unsure = true + void invoke('sidebar.peekState', { over: null }) } let focusTimer = 0 const onFocus = () => { diff --git a/src/shared/commands/nav.ts b/src/shared/commands/nav.ts index c2a13fe3..ddd6f117 100644 --- a/src/shared/commands/nav.ts +++ b/src/shared/commands/nav.ts @@ -54,8 +54,9 @@ export interface NavCommands { /** * From the floating sidebar: whether the pointer is over it (`over`) and whether a text field in it * has focus (`editing`). It slides away shortly after neither holds and nothing it opened is up. + * `over: null`: the pointer left the card for where the card can't tell (main reads it then). */ - 'sidebar.peekState': { args: { over?: boolean; editing?: boolean }; result: void } + 'sidebar.peekState': { args: { over?: boolean | null; editing?: boolean }; result: void } /** A click beside the floating sidebar's card: the card hides at once and the click goes on to the page. */ 'sidebar.peekDismiss': { args: { x: number; y: number; button: 'left' | 'middle' | 'right' }; result: void } /** What the page tools view shows for its window: the find bar and the zoom indicator (NV-05, NV-06). */ diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 2accc014..d3915e49 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -894,6 +894,64 @@ test('[SB-12][FL-03] the card the window’s very edge brought in goes again whe expect((await sidebarState()).peeking).toBe(true) }) +test('[SB-12][FL-03] a pointer that leaves the window from inside the card, where the card can’t tell it went, lets the card go; it stays while the pointer is in the window or a menu is up (round 2 fix check, M4)', async () => { + const { app } = orbit + await openTab(app, page('Inside')) + await hideSidebar() + const floating = await revealFloating() + /** The pointer moves over the card, which hears it. */ + const overCard = async () => { + await floating.mouse.move(100, 400) + await floating.mouse.move(110, 410) + await expect.poll(async () => (await floatingInfo(app)).hold?.over).toBe(true) + } + // The card's last word as the pointer goes over the window's resize border or the card's drag + // region, where nothing hears it: a leave at x = 2, to nothing. + const leaveCard = () => + floating.evaluate(() => + document.body.dispatchEvent(new MouseEvent('mouseout', { clientX: 2, clientY: 45, bubbles: true })), + ) + await overCard() + await leaveCard() + // On out of the window: the card goes within a second. + const left = Date.now() + await pointerAt(app, -40, 300) + await expect.poll(async () => (await sidebarState()).peeking, { timeout: 1000 * FRAME_SLACK }).toBe(false) + expect(Date.now() - left).toBeLessThan(1000 * FRAME_SLACK) + + // Still in the window, over the card's drag region: the card stays. + await revealFloating() + await overCard() + await leaveCard() + await pointerAt(app, 3, 45) + await new Promise((r) => setTimeout(r, 1000)) + expect((await floatingInfo(app)).hold).toMatchObject({ over: true, hiding: false }) + expect((await sidebarState()).peeking).toBe(true) + + // A menu from the card is up, reaching out of the window where the pointer went: the card stays + // while the menu is up, and goes once it closes. + await app.evaluate(({ Menu }) => { + const o = ( + globalThis as unknown as { + __orbit: { windowIds: () => string[]; ctx: { windows: { get: (w: string) => Electron.BrowserWindow } } } + } + ).__orbit + const menu = Menu.buildFromTemplate([{ label: 'Kept' }, { label: 'Out' }]) + ;(globalThis as unknown as { __menu: Electron.Menu }).__menu = menu + const [first] = o.windowIds() + if (!first) throw new Error('no window') + menu.popup({ window: o.ctx.windows.get(first), x: 120, y: 60 }) + }) + await pointerAt(app, -40, 300) + await new Promise((r) => setTimeout(r, 1000)) + expect((await floatingInfo(app)).hold).toMatchObject({ over: true, hiding: false }) + expect((await sidebarState()).peeking).toBe(true) + const closed = Date.now() + await app.evaluate(() => (globalThis as unknown as { __menu: Electron.Menu }).__menu.closePopup()) + await expect.poll(async () => (await sidebarState()).peeking, { timeout: 1000 * FRAME_SLACK }).toBe(false) + expect(Date.now() - closed).toBeLessThan(1000 * FRAME_SLACK) +}) + test('[SB-14] dragging the edge sets the width 1:1 within limits; double-click resets; far left hides', async () => { const { app, shell } = orbit await openTab(app, page('Resize')) From 12c691fea4b82f272e4f192912608484b24dafda Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:32:50 +0000 Subject: [PATCH 61/69] fix(links): pageMayOpen is the one check on every route where Orbit loads what a page chose (round 2 fix check F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A tab's window-open handler refuses a local file before anything else, for a web page or a page with a frame from elsewhere (main can't tell which frame asked): no tab, window, Peek, Mini Window or pop-up, and no blocked pop-up to open later. A pop-up's own opens do the same. - A blocked pop-up is kept to open later only where the page may open it. - The frame split from the engine's download opens only what a web page may open; otherwise the download goes ahead. - framesMayOpen (shared) is pageMayOpen for each frame, by origin; Save Page As… uses it too. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 4 +- docs/feature-status.json | 4 +- src/main/nav/links.ts | 9 ++- src/main/nav/page-menu.ts | 14 ++-- src/main/tabs/commands.ts | 3 + src/main/tabs/runtime.ts | 5 ++ src/shared/link-routing.ts | 31 +++++++-- tests/e2e/nav-links.spec.ts | 110 ++++++++++++++++++++++++++++++++ tests/unit/link-routing.test.ts | 19 ++++++ 9 files changed, 180 insertions(+), 19 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index e11696eb..db3369c9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -235,8 +235,8 @@ Spec: `spec/navigation.md` | 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; 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-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 link only from a local file page, 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 tab that only downloads closes and reselects its opener | +| 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, whatever the engine lets through (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 gets no tab, window or pop-up of a local file, nor one offered there (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 | | 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), 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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index a82a658e..1738705e 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -181,8 +181,8 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), 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; 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-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 link only from a local file page, 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 tab that only downloads closes and reselects its opener"}, + "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, whatever the engine lets through (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 gets no tab, window or pop-up of a local file, nor one offered there (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"}, "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), 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"}, diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index 747ffbfd..c71f84ab 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -1,5 +1,5 @@ import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type Referrer, type WebContents } from 'electron' -import { pageMayOpen } from '../../shared/link-routing' +import { framesMayOpen, pageMayOpen } from '../../shared/link-routing' import { type PageReferrer, popupTitle, referrerPolicyName, shownTabId } from '../../shared/nav' import type { TabId, WindowId } from '../../shared/state' import { sectionOf } from '../../shared/tree' @@ -238,6 +238,9 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks const alt = altClicks.get(tabId) if (alt && Date.now() <= alt.until && withoutHash(alt.url) === withoutHash(url)) { altClicks.delete(tabId) + // A frame's link opens beside the page only where a web page may open it: a local file's + // download goes ahead (D49, round 2 fix check F1, F2). + if (alt.split && !pageMayOpen(undefined, url)) return event.preventDefault() if (alt.split) openInSplit(tabId, url) return @@ -286,6 +289,10 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks const previousBlocked = tabs.onPopupBlocked tabs.onPopupBlocked = (tabId, url, referrer) => { previousBlocked?.(tabId, url, referrer) + // Kept to open later only where the page may open it: a local file for no web page (D49, round 2 + // fix check F1). Open, below, loads only what this list holds. + const contents = tabs.contents(tabId) + if (!(contents ? framesMayOpen(contents, url) : pageMayOpen(undefined, url))) return const list = (blocked.get(tabId) ?? []).filter((b) => b.url !== url) list.push({ url, referrer }) setBlocked(tabId, list.slice(-20)) diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index d37cd6bc..a68c2ce0 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -1,6 +1,6 @@ import { join } from 'node:path' import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, type WebContents } from 'electron' -import { pageMayOpen } from '../../shared/link-routing' +import { framesMayOpen } from '../../shared/link-routing' import { type PageReferrer, referrerPolicyName } from '../../shared/nav' import { isPrivateTab } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' @@ -144,22 +144,16 @@ function fileName(title: string): string { return clean || 'Page' } -/** Any local file's address: what a complete save would read for a frame, asked of `pageMayOpen`. */ +/** Any local file's address: what a complete save would read for a frame, asked of `framesMayOpen`. */ const LOCAL_FILE = 'file:///' /** * Whether Save Page As… may save the page complete, the resources its frames name copied beside it. * Chromium reads each `file:` resource named from disk for that, though no web page could load one, - * so only a page whose frames are all local files saves complete (D49, round 2 fix check M1). By each - * frame's origin, so an about:blank frame a local page made counts as local. + * so only a page whose frames are all local files saves complete (D49, round 2 fix check M1). */ export function savesComplete(contents: WebContents): boolean { - try { - return contents.mainFrame.framesInSubtree.every((frame) => pageMayOpen(frame.origin, LOCAL_FILE)) - } catch { - // The page went away. - return false - } + return framesMayOpen(contents, LOCAL_FILE) } /** diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 810fd3a2..4b8720ff 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -8,6 +8,7 @@ import { type WebContentsViewConstructorOptions, } from 'electron' import { isFolderId, isTabId, newId } from '../../shared/ids' +import { framesMayOpen } from '../../shared/link-routing' import { isPrivateTab, privateTabMenu } from '../../shared/private' import type { ContainerRef, Folder, FolderId, ItemId, TabId, WindowId } from '../../shared/state' import { @@ -509,6 +510,8 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM } }) wc.setWindowOpenHandler((details) => { + // As for a tab's page: a local file opens for no web page (D49, round 2 fix check F1). + if (!framesMayOpen(wc, details.url)) return { action: 'deny' } const windowId = windows.windowOfTab(openerTabId) ?? windows.focusedWindowId() if (gesture.used || Date.now() - gesture.at > 1000) { toast(windowId, 'Pop-up blocked') diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 72a73562..7afb3a84 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -9,6 +9,7 @@ import { type WebContentsViewConstructorOptions, type WindowOpenHandlerResponse, } from 'electron' +import { framesMayOpen } from '../../shared/link-routing' import { reopenReferrerPolicy } from '../../shared/nav' import { isPrivateProfileId } from '../../shared/private' import type { Tab, TabId } from '../../shared/state' @@ -313,6 +314,10 @@ export class TabRuntime { }) contents.on('context-menu', (_e, params) => showPageContextMenu(this.ctx, tabId, contents, params)) contents.setWindowOpenHandler((details) => { + // A local file opens for no web page, nor for a page with a frame from elsewhere, which may be + // the one asking: no tab, window, Peek, Mini Window or pop-up, nor a blocked pop-up to open + // later, whatever the engine let through (D49, round 2 fix check F1). + if (!framesMayOpen(contents, details.url)) return { action: 'deny' } // A link Orbit asked this page to open (openFromPage): the click was counted already. const asked = this.takePageOpen(contents.id, details.url) if (asked) return asked diff --git a/src/shared/link-routing.ts b/src/shared/link-routing.ts index 74060aac..5a4b4ac6 100644 --- a/src/shared/link-routing.ts +++ b/src/shared/link-routing.ts @@ -51,15 +51,38 @@ export function isLocalFileUrl(url: string): boolean { } /** - * Whether Orbit may itself load or save `url` for the page at `from`, as it does for the page menu, - * ⌥-click and a dropped link: a local file only for a page that is a local file too, as the engine - * allows a plain click (D49). `from` is empty when the page isn't known. Other addresses are left - * to each route's own rules. + * Whether Orbit may itself load or save `url` for the page at `from`: a local file only for a page + * that is a local file too, as the engine allows a plain click (D49). `from` is empty when the page + * isn't known. Other addresses are left to each route's own rules. The one check on every route where + * Orbit loads what a page chose, whatever the engine allows: the page menu, ⌥-click and its frame's + * download, a dropped link, and through `framesMayOpen` the windows, tabs and pop-ups a page asks + * for, its blocked pop-ups and Save Page As… (round 2 fix check F1). */ export function pageMayOpen(from: string | undefined, url: string): boolean { return !isLocalFileUrl(url) || /^file:/i.test(from?.trim() ?? '') } +/** A page as the main process sees it: every frame in it, the top one too, each with its origin. */ +export interface PageFrames { + readonly mainFrame: { readonly framesInSubtree: readonly { readonly origin: string }[] } +} + +/** + * `pageMayOpen` for a page that asked for `url` from a frame Orbit can't name: a new window, tab or + * pop-up it requested, a pop-up blocked on it, or a save of it (D49; round 2 fix check M1, F1). A + * local file only when every frame may open it, by each frame's origin: a frame from elsewhere counts + * as a web page (F2), and an about:blank frame a local page made counts as local. A page that went + * away opens no local file. + */ +export function framesMayOpen(page: PageFrames, url: string): boolean { + if (pageMayOpen(undefined, url)) return true + try { + return page.mainFrame.framesInSubtree.every((frame) => pageMayOpen(frame.origin, url)) + } catch { + return false + } +} + /** Same registrable domain (eTLD+1): `mail.example.com` and `docs.example.com` are one site (PK-02). */ export function sameSite(a: string, b: string): boolean { if (!isWebUrl(a) || !isWebUrl(b)) return false diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index eb315f0a..217eedf0 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -15,6 +15,7 @@ import { cmd, foreignWindowTitles, hoverInPage, + type Modifier, type NavFixtures, openTab, pageMenu, @@ -855,6 +856,115 @@ test('[NV-08] ⌥-click on a local file’s link in a frame never opens it besid ]) }) +/** + * Asks the window-open handler Orbit gave the page at `pageUrl` for `url`, as the engine would for a + * link or a window.open() it let through, with a click in the page just before (holding `modifiers`) + * unless `click` is false: the handler's answer. + */ +function askToOpen( + app: ElectronApplication, + pageUrl: string, + url: string, + disposition: 'background-tab' | 'foreground-tab' | 'new-window', + { features = '', click = true, modifiers = [] }: { features?: string; click?: boolean; modifiers?: Modifier[] } = {}, +): Promise { + return app.evaluate( + async ({ webContents }, [p, u, d, f, c, m]) => { + const contents = webContents.getAllWebContents().find((w) => w.getURL() === p) + if (!contents) throw new Error(`no page ${p}`) + if (c) { + // A click in the page's margin: the gesture a new tab or a pop-up needs (NV-09). + const at = { x: 8, y: 8, button: 'left' as const, clickCount: 1, modifiers: [...m] } + contents.sendInputEvent({ type: 'mouseDown', ...at }) + contents.sendInputEvent({ type: 'mouseUp', ...at }) + await new Promise((resolve) => setTimeout(resolve, 50)) + } + type Handler = (details: Electron.HandlerDetails) => Electron.WindowOpenHandlerResponse + // Where Electron keeps the handler a page's opens go to. + const handler = (contents as unknown as { _windowOpenHandler?: Handler })._windowOpenHandler + if (typeof handler !== 'function') throw new Error(`no window-open handler for ${p}`) + const details = { url: u, frameName: '', features: f, disposition: d, referrer: { url: '', policy: 'default' } } + return handler(details as Electron.HandlerDetails).action + }, + [pageUrl, url, disposition, features, click, modifiers] as const, + ) +} + +test('[NV-08][NV-09][MW-07] a web page that asks for a local file in a new tab, window or pop-up gets none, even where the engine lets it ask; a local page still does (D49, round 2 fix check F1)', async () => { + const { app } = orbit + const local = localFiles() + const folder = new URL(local.folder.endsWith('/') ? local.folder : `${local.folder}/`) + const loads = await watchLoads(app, local.folder) + const web = page('Opener') + const webId = await openTab(app, web) + const windows = (await windowList(app)).length + const fileTabs = async () => + Object.values((await state(app)).tabs) + .filter((t) => t.url.includes('orbit-local')) + .map((t) => t.url) + + // From the web page: a pop-up without a click (to block), ⌘-click (a background tab), ⌘⌥-click (a + // Mini Window), ⇧⌘-click (a selected tab), ⇧-click (a window), and a pop-up with a click. + const popup = 'width=300,height=200' + const answers = [ + await askToOpen(app, web, local.text, 'new-window', { features: popup, click: false }), + await askToOpen(app, web, local.text, 'background-tab'), + await askToOpen(app, web, local.text, 'background-tab', { modifiers: [mod, 'alt'] }), + await askToOpen(app, web, local.text, 'foreground-tab'), + await askToOpen(app, web, local.text, 'new-window'), + await askToOpen(app, web, local.text, 'new-window', { features: popup }), + ] + expect(answers).toEqual(['deny', 'deny', 'deny', 'deny', 'deny', 'deny']) + // A pop-up the page opened asks too: its own opens become tabs. + await clickInPage(app, web, '#popup') + await expect.poll(() => loadedTitle(app, page('Popup-Opener'))).toBe('Popup-Opener') + expect(await askToOpen(app, page('Popup-Opener'), local.text, 'foreground-tab')).toBe('deny') + + // A local page with a frame from the web: the frame may be the one asking. + const framed = new URL('framed.html', folder).href + writeFileSync(new URL(framed), `Framed`) + const framedId = await openTab(app, framed) + await expect.poll(() => inFrame(app, framedId, '/page/FramedWeb', 'document.readyState')).toBe('complete') + expect(await askToOpen(app, framed, local.text, 'background-tab')).toBe('deny') + + // Nothing opened, loaded or waits to be opened. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(await fileTabs()).toEqual([]) + expect(await windowList(app)).toHaveLength(windows) + expect(await foreignWindowTitles(app)).toEqual([`localhost:${fx.port}`]) + expect((await state(app)).tabs[webId]?.blockedPopups).toBeUndefined() + expect((await loads()).filter((u) => u.includes('orbit-local'))).toEqual([]) + + // A local page of its own asks, and its ⌘-clicked local file opens in a background tab. + const first = new URL('first.html', folder).href + writeFileSync(new URL(first), 'First

First local page

') + await openTab(app, first) + expect(await askToOpen(app, first, local.text, 'background-tab')).toBe('deny') + await expect.poll(fileTabs).toEqual([local.text]) +}) + +test('[NV-09] a pop-up blocked on a web page is offered to open later unless it is a local file (D49, round 2 fix check F1)', async () => { + const { app } = orbit + const local = localFiles() + const web = page('Web') + const id = await openTab(app, web) + // As the tabs runtime reports the pop-ups it blocked: a local file, then a web page. + await app.evaluate( + (_e, [tabId, urls, from]) => { + type Tabs = { onPopupBlocked?: (tabId: string, url: string, referrer: Electron.Referrer) => void } + const { tabs } = (globalThis as unknown as { __orbit: { ctx: { tabs: Tabs } } }).__orbit.ctx + for (const url of urls) tabs.onPopupBlocked?.(tabId, url, { url: from, policy: 'default' }) + }, + [id, [local.text, page('Surprise')], web] as const, + ) + await expect.poll(async () => (await state(app)).tabs[id]?.blockedPopups).toBe(1) + const host = `localhost:${fx.port}` + await cmd(app, 'nav.blockedPopupsMenu', { x: 0, y: 0 }) + await expect + .poll(() => blockedMenu(app)) + .toEqual([`[Pop-ups blocked on ${host}]`, `Open ${page('Surprise')}`, `Always Allow Pop-ups from ${host}`]) +}) + test('[TB-02] a local file’s address dropped on the sidebar opens nothing, as a link or as text; a web link dropped after it opens (D49)', async () => { const { app, shell } = orbit const local = localFiles() diff --git a/tests/unit/link-routing.test.ts b/tests/unit/link-routing.test.ts index 6687240e..f573efb5 100644 --- a/tests/unit/link-routing.test.ts +++ b/tests/unit/link-routing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { decideLink, + framesMayOpen, isLocalFileUrl, isMeetingLink, type LinkSettings, @@ -162,6 +163,24 @@ describe('local files a page may have Orbit open (D49)', () => { expect(pageMayOpen(from, file)).toBe(false) } }) + it('a page that asks from a frame Orbit can’t name: a local file only when every frame is local (F1)', () => { + const framed = (...origins: string[]) => ({ mainFrame: { framesInSubtree: origins.map((origin) => ({ origin })) } }) + expect(framesMayOpen(framed('file://'), file)).toBe(true) + // An about:blank or srcdoc frame of a local page has its origin. + expect(framesMayOpen(framed('file://', 'file://'), file)).toBe(true) + expect(framesMayOpen(framed('file://', 'http://localhost:3310'), file)).toBe(false) + expect(framesMayOpen(framed('file://', 'null'), file)).toBe(false) + expect(framesMayOpen(framed('https://example.com'), file)).toBe(false) + expect(framesMayOpen(framed('https://example.com'), `view-source:${file}`)).toBe(false) + expect(framesMayOpen(framed('https://example.com', 'null'), 'https://example.org/b')).toBe(true) + const gone = { + get mainFrame(): never { + throw new Error('Render frame was disposed before WebFrameMain could be accessed') + }, + } + expect(framesMayOpen(gone, file)).toBe(false) + expect(framesMayOpen(gone, 'https://example.org/b')).toBe(true) + }) it('which addresses are local files', () => { expect(isLocalFileUrl(file)).toBe(true) expect(isLocalFileUrl(` ${file}`)).toBe(true) From 6fa633361421bfe3d43d0f5166379a91346f406c Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:34:33 +0000 Subject: [PATCH 62/69] fix(actions): ActionRegistry.register throws on an id registered already, as CommandRegistry does (round 2 fix check F5) A second handler silently replaced the first; now any start of Orbit fails on it, whatever made the id (a literal, a template, a list). Co-Authored-By: Claude Opus 5.5 --- src/main/registry.ts | 2 ++ tests/unit/actions.test.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/registry.ts b/src/main/registry.ts index d3b1aadb..8e9b7c35 100644 --- a/src/main/registry.ts +++ b/src/main/registry.ts @@ -59,7 +59,9 @@ export class ActionRegistry { private handlers = new Map void | Promise>() private interceptors: ActionInterceptor[] = [] + /** Throws on an id registered already, as `CommandRegistry` does: a second handler would silently replace the first. */ register(id: string, handler: (ctx: ActionContext) => void | Promise): void { + if (this.handlers.has(id)) throw new Error(`Action already registered: ${id}`) this.handlers.set(id, handler) } diff --git a/tests/unit/actions.test.ts b/tests/unit/actions.test.ts index 6f493a71..6b844201 100644 --- a/tests/unit/actions.test.ts +++ b/tests/unit/actions.test.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { ActionRegistry } from '../../src/main/registry' import { ACTIONS, actionForInput, @@ -141,4 +142,16 @@ describe('action handlers', () => { expect(counts.size).toBeGreaterThan(50) expect([...counts].filter(([, n]) => n > 1).map(([id]) => id)).toEqual([]) }) + + it('refuses a second handler for an action, whatever made its id, so any start of Orbit finds it (round 2 fix check F5)', () => { + const actions = new ActionRegistry() + const first = vi.fn() + actions.register('tab.goto.1', first) + const n = 1 + expect(() => actions.register(`tab.goto.${n}`, () => {})).toThrow('Action already registered: tab.goto.1') + void actions.run('tab.goto.1', {}) + expect(first).toHaveBeenCalledTimes(1) + actions.register('tab.goto.2', () => {}) + expect(actions.has('tab.goto.2')).toBe(true) + }) }) From cd3fd99a7bee8d0823e748e458f2c959691584b3 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:39:10 +0000 Subject: [PATCH 63/69] fix(page-menu): a script, page source or data link offers no open items (round 2 fix check F6) The engine hands the menu a script link as about:blank#blocked, which opened as such; a data link opened as a page of its own. Copy Link Address and Save Link As stay. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/tabs/context-menu.ts | 25 +++++++++++++++---------- tests/e2e/nav-links.spec.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index db3369c9..135ecf10 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -239,7 +239,7 @@ Spec: `spec/navigation.md` | 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 gets no tab, window or pop-up of a local file, nor one offered there (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 | | 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), 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-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-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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index 1738705e..eefdb856 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -185,7 +185,7 @@ "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 gets no tab, window or pop-up of a local file, nor one offered there (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"}, "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), 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-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-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/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index 2ab27929..c5410caf 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -41,19 +41,24 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We // A local file's link or picture on a page that isn't a local file keeps only its copy items: // Orbit opens or saves it for no web page (D49). const mayOpen = (url: string) => pageMayOpen(referrer.url, url) + // A script, a page's source or data is no page to open: the engine names the first two + // about:blank#blocked here, and would show data as a page of its own (round 2 fix check F6). + // Copying and saving stay. + const opensAsPage = (url: string) => !/^\s*(javascript|view-source|data|about):/i.test(url) if (params.linkURL) { const link = params.linkURL - const open: MenuItemConstructorOptions[] = mayOpen(link) - ? [ - { label: 'Open Link in New Tab', click: () => openFromPage(link) }, - // A new pane beside the page's pane (SV-08). - linkSplitMenuItem(ctx, tabId, link), - // Then Peek and Mini Window, new and private windows: the order NV-12 lists. - ...linkPeekMenuItems(ctx, tabId, link), - ...linkWindowItems(ctx, tabId, link, referrer), - ] - : [] + const open: MenuItemConstructorOptions[] = + mayOpen(link) && opensAsPage(link) + ? [ + { label: 'Open Link in New Tab', click: () => openFromPage(link) }, + // A new pane beside the page's pane (SV-08). + linkSplitMenuItem(ctx, tabId, link), + // Then Peek and Mini Window, new and private windows: the order NV-12 lists. + ...linkPeekMenuItems(ctx, tabId, link), + ...linkWindowItems(ctx, tabId, link, referrer), + ] + : [] items.push( ...open, { label: 'Copy Link Address', click: () => clipboard.writeText(link) }, diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 217eedf0..05d64b3b 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -965,6 +965,37 @@ test('[NV-09] a pop-up blocked on a web page is offered to open later unless it .toEqual([`[Pop-ups blocked on ${host}]`, `Open ${page('Surprise')}`, `Always Allow Pop-ups from ${host}`]) }) +test('[NV-12] a script, a page’s source or a data link offers no way to open it; copying it stays (round 2 fix check F6)', async () => { + const { app } = orbit + const web = page('Odd') + await openTab(app, web) + const tab = await tabPage(app, '/page/Odd') + const links: Record = { + script: 'javascript:void(0)', + source: `view-source:${page('Odd-next')}`, + data: 'data:text/html,Data', + } + await tab.evaluate((list) => { + for (const [id, href] of Object.entries(list).reverse()) { + const a = Object.assign(document.createElement('a'), { id, href, textContent: id }) + const p = document.createElement('p') + p.append(a) + document.body.prepend(p) + } + }, links) + const opens = (labels: string[]) => labels.filter((l) => l.startsWith('Open ')) + for (const id of [...Object.keys(links), 'next']) { + // The page's own menu first, so the link's is told from the one before. + await clickInPage(app, web, '#title', [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain('Back') + await clickInPage(app, web, `#${id}`, [], 'right') + await expect.poll(async () => (await pageMenu(app))?.labels ?? []).toContain('Copy Link Address') + const labels = (await pageMenu(app))?.labels ?? [] + if (id === 'next') expect(opens(labels)).toContain('Open Link in New Tab') + else expect(opens(labels), id).toEqual([]) + } +}) + test('[TB-02] a local file’s address dropped on the sidebar opens nothing, as a link or as text; a web link dropped after it opens (D49)', async () => { const { app, shell } = orbit const local = localFiles() From f10e5190445ebb354c41163b40caa21015f6c8fd Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:04:41 +0000 Subject: [PATCH 64/69] test(onboarding): OB-02 counts the loading indicator's view as the browser window's own (round 2 fix check F3) Since F3 (d6bcef4) each window's loading view is made as the window shows, so OB-02's list of pages found it at once and failed 3 of 3 (it passed 3 of 3 with the view made 1 s later, as before). The check is still that no window but the browser window opens. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/onboarding.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 5aef7661..becbf47d 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -35,9 +35,11 @@ test('[OB-02] a fresh install opens a usable browser window with nothing typed; const shell = await viewPage(app, 'shell') await expect(shell.getByTestId('sidebar')).toBeVisible() await expect.poll(() => app.evaluate(() => (globalThis as unknown as OrbitGlobal).__orbit.windowIds().length)).toBe(1) - // No window but the browser window: nothing to sign in to or fill in first. + // No window but the browser window: nothing to sign in to or fill in first. Its own views: the + // shell, the command bar and the loading indicator's, made as the window shows (chrome round 2, F3). + const own = ['shell', 'overlay', 'loading'] const views = () => app.windows().map((p) => new URL(p.url()).searchParams.get('view')) - expect(views().filter((v) => v !== 'shell' && v !== 'overlay')).toEqual([]) + expect(views().filter((v) => !own.includes(v ?? ''))).toEqual([]) // The command bar opens, ready to go anywhere. await shell.keyboard.press(`${MOD}+T`) const overlay = await viewPage(app, 'overlay') From c11aab62cd2891bd4e447bf282bb817e4b9a5f10 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:41:30 +0000 Subject: [PATCH 65/69] fix(sidebar): a menu Chromium refuses no longer holds the floating card out (round 3 fix check M1) On Linux Chromium runs one native menu at a time in the app: a popup() while a menu is up, in any window, closes that one and shows nothing. The refused menu hears no menu-will-show or menu-will-close, only its popup() callback, at once, so menu-watch's count stayed at 1 and the card never went by itself in that window again. A menu now comes off the count at its menu-will-close or its callback (wrapping the caller's), whichever comes first, once. Test (chrome): two menus asked for in one evaluate, then a menu asked for in a second window while the first has one up; each time the card goes within 1 s of the pointer leaving the window. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/menu-watch.ts | 17 +++++- tests/e2e/chrome.spec.ts | 113 +++++++++++++++++++++++++++++++++++++++ tests/e2e/nav-support.ts | 18 ++++--- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 8e16024c..32012ce3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -31,7 +31,7 @@ Spec: `spec/sidebar.md` | SB-09 | Bottom bar: Library, Space switcher, "+" menu | P0 | 🟡 partial | ✅ e2e | – | switcher, + menu and the Library button (opens the Library); the recent-files hover popover belongs to the downloads lane | | SB-10 | Transient trays above the bottom bar | P1 | 🟡 partial | ✅ e2e | – | media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes | | SB-11 | Show / hide the sidebar | P0 | ✅ | ✅ e2e | – | Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it | -| SB-12 | Hover to reveal the hidden sidebar | P0 | ✅ | ✅ e2e | – | 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 or the command bar is open; 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-12 | Hover to reveal the hidden sidebar | P0 | ✅ | ✅ e2e | – | 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-13 | Feedback when things happen while the sidebar is hidden | P1 | – | – | – | | | SB-14 | Resize the sidebar width | P0 | ✅ | ✅ e2e | – | 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-15 | Narrow ("compact") sidebar rendering | P1 | – | – | – | | diff --git a/docs/feature-status.json b/docs/feature-status.json index 18ead72b..b9b3e04b 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -8,7 +8,7 @@ "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, "SB-09": {"built": "partial", "note": "switcher, + menu and the Library button (opens the Library); the recent-files hover popover belongs to the downloads lane"}, "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it"}, - "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 or the command bar is open; 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-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"}, diff --git a/src/main/menu-watch.ts b/src/main/menu-watch.ts index 186b00e3..58225cc4 100644 --- a/src/main/menu-watch.ts +++ b/src/main/menu-watch.ts @@ -10,6 +10,12 @@ let watching = false * opens it: the floating sidebar's card stays out while a menu opened from it is up, even where * the menu reaches out of the window (round 2 fix check, M4). A menu that names no window counts * for the focused one, where Electron opens it. + * + * A menu comes off the count when it closes or when Chromium refuses it, whichever it hears first, + * and only once. On Linux Chromium runs one native menu at a time in the whole app: a `popup()` while + * a menu is up, in any window, closes that one and shows nothing. The refused menu hears no + * `menu-will-show` and no `menu-will-close`, only its `popup()` callback, which runs at once (round 3 + * fix check, M1). */ export function watchMenus(): void { if (watching) return @@ -26,6 +32,7 @@ export function watchMenus(): void { const close = () => { if (closed) return closed = true + this.removeListener('menu-will-close', close) const left = (upIn.get(id) ?? 1) - 1 if (left > 0) { upIn.set(id, left) @@ -34,12 +41,18 @@ export function watchMenus(): void { upIn.delete(id) if (!win.isDestroyed()) for (const listener of closedListeners) listener(win) } + const callback = options?.callback upIn.set(id, (upIn.get(id) ?? 0) + 1) this.once('menu-will-close', close) try { - popup.call(this, options) + popup.call(this, { + ...options, + callback: () => { + close() + callback?.() + }, + }) } catch (error) { - this.removeListener('menu-will-close', close) close() throw error } diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index d3915e49..3fc74e1b 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -8,6 +8,7 @@ import { activeTab, chooseHistory, cmd, + cmdIn, floatingInfo, floatingPage, HOST_ARGS, @@ -952,6 +953,118 @@ test('[SB-12][FL-03] a pointer that leaves the window from inside the card, wher expect(Date.now() - closed).toBeLessThan(1000 * FRAME_SLACK) }) +type MenuHooks = { + __orbit: { windowIds: () => string[]; ctx: { windows: { get: (w: string) => Electron.BrowserWindow } } } + __menus?: Electron.Menu[] + __heard?: string[] +} + +/** + * Asks for a native menu in each window given, in order and at once, as right-clicks close together + * do. Returns what every menu asked for so far heard: `shown`, `closed`, and its popup() callback (`done`). + */ +function popMenus(...menus: [label: string, windowId: string][]): Promise { + return orbit.app.evaluate(({ Menu }, list) => { + const g = globalThis as unknown as MenuHooks + const heard = g.__heard ?? [] + const shown = g.__menus ?? [] + g.__heard = heard + g.__menus = shown + for (const [label, windowId] of list) { + const menu = Menu.buildFromTemplate([{ label }]) + shown.push(menu) + menu.on('menu-will-show', () => heard.push(`${label} shown`)) + menu.on('menu-will-close', () => heard.push(`${label} closed`)) + const window = g.__orbit.ctx.windows.get(windowId) + menu.popup({ window, x: 120, y: 60, callback: () => heard.push(`${label} done`) }) + } + return [...heard] + }, menus) +} + +/** Closes what `popMenus` showed that is still up (where the engine shows more than one menu at a time). */ +function closeMenusUp(): Promise { + return orbit.app.evaluate(() => { + const g = globalThis as unknown as MenuHooks + const heard = g.__heard ?? [] + for (const menu of g.__menus ?? []) { + const label = menu.items[0]?.label + if (heard.includes(`${label} shown`) && !heard.includes(`${label} closed`)) menu.closePopup() + } + }) +} + +test('[SB-12][FL-03] a menu Chromium refuses holds no card: asked for while a menu is up, in this window or another, it shows nothing, and the card goes as the pointer leaves the window (round 3 fix check, M1)', async () => { + const { app } = orbit + await openTab(app, page('Refused')) + await hideSidebar() + const floating = await revealFloating() + await floating.mouse.move(100, 400) + await floating.mouse.move(110, 410) + await expect.poll(async () => (await floatingInfo(app)).hold?.over).toBe(true) + // The card's last word as the pointer goes where nothing hears it (as in the test before). + await floating.evaluate(() => + document.body.dispatchEvent(new MouseEvent('mouseout', { clientX: 2, clientY: 45, bubbles: true })), + ) + const [first] = await app.evaluate(() => (globalThis as unknown as MenuHooks).__orbit.windowIds()) + if (!first) throw new Error('no window') + // Two menus at once, as two right-clicks close together ask: Chromium runs one native menu at a + // time in the app, so asking for the second closes the first, and the second shows nothing. + const heard = await popMenus(['First', first], ['Second', first]) + if (process.platform === 'linux') expect(heard).toEqual(['First shown', 'First closed', 'First done', 'Second done']) + await closeMenusUp() + // The events a closing menu sends the window (the pointer back over it) come in first. + await new Promise((r) => setTimeout(r, 300)) + // Nothing is up: on out of the window, the card goes within a second. + let left = Date.now() + await pointerAt(app, -40, 300) + await expect.poll(async () => (await sidebarState()).peeking, { timeout: 1000 * FRAME_SLACK }).toBe(false) + expect(Date.now() - left).toBeLessThan(1000 * FRAME_SLACK) + + // A menu asked for in a second window while the first window has one up: the same. + const before = [first] + await action(app, 'window.new') + let second = '' + await expect + .poll(async () => { + const ids = await app.evaluate(() => (globalThis as unknown as MenuHooks).__orbit.windowIds()) + second = ids.find((id) => !before.includes(id)) ?? '' + return second + }) + .not.toBe('') + const peekingIn = async (windowId: string) => (await state(app)).windows[windowId]?.sidebar.peeking + const overlayIn = async (windowId: string) => (await state(app)).windows[windowId]?.overlay.kind + // Its command bar closed once it opens, and its sidebar hidden, its card is out for a pointer it + // can't place, in the window. + await expect.poll(() => overlayIn(second)).not.toBe('none') + await cmdIn(app, second, 'overlay.close') + await expect.poll(() => overlayIn(second)).toBe('none') + await cmdIn(app, second, 'window.setSidebar', { visible: false }) + await pointerAt(app, 300, 300, second) + await cmdIn(app, second, 'window.setSidebar', { peeking: true }) + await cmdIn(app, second, 'sidebar.peekState', { over: null }) + await new Promise((r) => setTimeout(r, 600)) + expect((await floatingInfo(app, second)).hold).toEqual({ over: true, editing: false, overlay: false, hiding: false }) + expect(await peekingIn(second)).toBe(true) + await popMenus(['Up in the first', first]) + await new Promise((r) => setTimeout(r, 300)) + const both = await popMenus(['Asked in the second', second]) + if (process.platform === 'linux') { + expect(both.slice(heard.length)).toEqual([ + 'Up in the first shown', + 'Up in the first closed', + 'Up in the first done', + 'Asked in the second done', + ]) + } + await closeMenusUp() + await new Promise((r) => setTimeout(r, 300)) + left = Date.now() + await pointerAt(app, -2000, -2000, second) + await expect.poll(() => peekingIn(second), { timeout: 1000 * FRAME_SLACK }).toBe(false) + expect(Date.now() - left).toBeLessThan(1000 * FRAME_SLACK) +}) + test('[SB-14] dragging the edge sets the width 1:1 within limits; double-click resets; far left hides', async () => { const { app, shell } = orbit await openTab(app, page('Resize')) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 6ae8a6cb..396283ab 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -597,16 +597,20 @@ export async function floatingPage(app: ElectronApplication): Promise { } /** - * The pointer Orbit reads for the sidebar's edge at `x`, `y` of the first window's content, inside - * it or not, as a real pointer no view in the window hears (fix check A, M1). + * The pointer Orbit reads for the sidebar's edge at `x`, `y` of a window's content (default: the + * first), inside it or not, as a real pointer no view in the window hears (fix check A, M1). */ -export async function pointerAt(app: ElectronApplication, x: number, y: number): Promise { - await app.evaluate((_e, [px, py]) => (globalThis as unknown as Hooks).__orbitNav.pointerAt(px, py), [x, y] as const) +export async function pointerAt(app: ElectronApplication, x: number, y: number, windowId?: string): Promise { + await app.evaluate((_e, [px, py, w]) => (globalThis as unknown as Hooks).__orbitNav.pointerAt(px, py, w), [ + x, + y, + windowId, + ] as const) } -/** The first window's floating sidebar. */ -export function floatingInfo(app: ElectronApplication): Promise { - return app.evaluate(() => (globalThis as unknown as Hooks).__orbitNav.floating()) +/** A window's floating sidebar (default: the first). */ +export function floatingInfo(app: ElectronApplication, windowId?: string): Promise { + return app.evaluate((_e, w) => (globalThis as unknown as Hooks).__orbitNav.floating(w), windowId) } /** The corner radius a shown page's card was given (SB-19). */ From 99572a8a47dad28a89e846e394370fe28b5d1be2 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:45:43 +0000 Subject: [PATCH 66/69] =?UTF-8?q?test(links):=20the=20=E2=8C=A5-click=20Re?= =?UTF-8?q?ferer=20check=20waits=20for=20the=20split,=20not=20for=20exactl?= =?UTF-8?q?y=20one=20request=20(round=203=20fix=20check=20M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ⌥-click requests the link twice on a correct build: for the split's page, and for the engine's own ⌥-click download, which Orbit cancels in will-download once its request has gone out. The test polled for exactly 1 request, so a sample after the second failed it. It now waits for the split to show the page, then checks that each request so far had the page as Referer. The nav fixture keeps what every request said about where it came from (sentFromEach), not only the latest. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/nav-links.spec.ts | 14 ++++++++++---- tests/e2e/nav-support.ts | 9 ++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 05d64b3b..3f5f3cd6 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -266,11 +266,17 @@ test('[NV-08][NV-09][NV-12] links Orbit opens for a page (its menu, ⌥-click, a await fromMenu('Open Link in New Window') await expect.poll(() => fx.hits('/page/Opened-2')).toBe(1) expect(fx.sentFrom('/page/Opened-2')?.referer).toBe(referer) - // ⌥-click: split view. - await away(3) + // ⌥-click: split view. The link is requested twice, for the split's page and for the engine's own + // ⌥-click download (Orbit cancels it once it has begun), so the requests aren't counted: once the + // split shows the page, each request so far had the page as Referer. + const url3 = await away(3) await clickInPage(app, src, '#away', ['alt']) - await expect.poll(() => fx.hits('/page/Opened-3')).toBe(1) - expect(fx.sentFrom('/page/Opened-3')?.referer).toBe(referer) + await expect.poll(() => splitTabs(app, srcTab)).toHaveLength(2) + const [, split] = await splitTabs(app, srcTab) + await waitLoaded(app, split as string, url3) + const sent = fx.sentFromEach('/page/Opened-3') + expect(sent.length).toBeGreaterThan(0) + for (const request of sent) expect(request.referer).toBe(referer) // A pop-up blocked as the page loaded, opened from the address display. const blocked = fx.urlOn('127.0.0.1', '/page/Opened-4') await openTab(app, fx.url(`/page/Eager?open=${encodeURIComponent(blocked)}`)) diff --git a/tests/e2e/nav-support.ts b/tests/e2e/nav-support.ts index 396283ab..797d75f9 100644 --- a/tests/e2e/nav-support.ts +++ b/tests/e2e/nav-support.ts @@ -38,6 +38,8 @@ export interface NavFixtures { cacheControl: (path: string) => string | undefined /** What the latest request for a path said about where it came from, or undefined before one. */ sentFrom: (path: string) => SentFrom | undefined + /** What each request for a path said about where it came from, oldest first. */ + sentFromEach: (path: string) => SentFrom[] close: () => Promise } @@ -168,7 +170,7 @@ function tone(seconds: number): Buffer { export async function startNavFixtures(): Promise { const hits = new Map() const cacheControl = new Map() - const sentFrom = new Map() + const sentFrom = new Map() const pending = new Set() /** The held-back pages per path, to send early (`release`). */ const held = new Map void>>() @@ -183,7 +185,7 @@ export async function startNavFixtures(): Promise { const site = req.headers['sec-fetch-site'] if (typeof site === 'string') from.site = site if (req.headers.cookie) from.cookie = req.headers.cookie - sentFrom.set(u.pathname, from) + sentFrom.set(u.pathname, [...(sentFrom.get(u.pathname) ?? []), from]) const [, kind = '', raw = 'index'] = u.pathname.split('/') const title = decodeURIComponent(raw) const send = () => { @@ -271,7 +273,8 @@ export async function startNavFixtures(): Promise { for (const done of [...(held.get(path) ?? [])]) done() }, cacheControl: (path) => cacheControl.get(path), - sentFrom: (path) => sentFrom.get(path), + sentFrom: (path) => sentFrom.get(path)?.at(-1), + sentFromEach: (path) => [...(sentFrom.get(path) ?? [])], close: () => new Promise((r) => { for (const socket of sockets) socket.destroy() From 16641345cef6529d952a223ee1f333907159c8ec Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:55:11 +0000 Subject: [PATCH 67/69] fix(links): a local file Orbit refuses for a page is named in a toast (round 3 fix check F1) The rule stays (D49): a local page with any frame whose origin isn't a local file's, a web page's or an opaque one (a sandboxed or data: frame, even one the page made), counts as a web page for local files, as Orbit can't tell which frame asked. That refuses modified clicks, target=_blank links and window.open alike. The refusal was silent; the tab's and the pop-up's window-open handlers now show "Local file blocked: ", once per tab and file every few seconds. Tests: nav-links, real clicks on a local page with a web, a sandboxed srcdoc and a data: frame (target=_blank and Ctrl-click; a plain click still loads; a same-origin srcdoc frame opens both); the round 2 F1 test checks the toast for the tab's handler and a pop-up's; unit, the file's name. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 4 +- docs/feature-status.json | 4 +- src/main/tabs/commands.ts | 24 +++++++++-- src/main/tabs/runtime.ts | 14 +++++-- src/shared/link-routing.ts | 35 ++++++++++++++-- tests/e2e/nav-links.spec.ts | 72 +++++++++++++++++++++++++++++++-- tests/unit/link-routing.test.ts | 12 ++++++ 7 files changed, 147 insertions(+), 18 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 32012ce3..2d4b69ea 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -235,8 +235,8 @@ Spec: `spec/navigation.md` | 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; 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-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, whatever the engine lets through (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 gets no tab, window or pop-up of a local file, nor one offered there (D49); a tab that only downloads closes and reselects its opener | +| 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 | | 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 | diff --git a/docs/feature-status.json b/docs/feature-status.json index b9b3e04b..5ea58570 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -184,8 +184,8 @@ "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), 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; 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-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, whatever the engine lets through (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 gets no tab, window or pop-up of a local file, nor one offered there (D49); a tab that only downloads closes and reselects its opener"}, + "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"}, "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"}, diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 4b8720ff..a7077cce 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -8,7 +8,7 @@ import { type WebContentsViewConstructorOptions, } from 'electron' import { isFolderId, isTabId, newId } from '../../shared/ids' -import { framesMayOpen } from '../../shared/link-routing' +import { framesMayOpen, localFileBlockedMessage } from '../../shared/link-routing' import { isPrivateTab, privateTabMenu } from '../../shared/private' import type { ContainerRef, Folder, FolderId, ItemId, TabId, WindowId } from '../../shared/state' import { @@ -510,8 +510,12 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM } }) wc.setWindowOpenHandler((details) => { - // As for a tab's page: a local file opens for no web page (D49, round 2 fix check F1). - if (!framesMayOpen(wc, details.url)) return { action: 'deny' } + // As for a tab's page: a local file opens for no web page, and a toast names it (D49, round 2 + // fix check F1, round 3 fix check F1). + if (!framesMayOpen(wc, details.url)) { + localFileRefused(openerTabId, details.url) + return { action: 'deny' } + } const windowId = windows.windowOfTab(openerTabId) ?? windows.focusedWindowId() if (gesture.used || Date.now() - gesture.at > 1000) { toast(windowId, 'Pop-up blocked') @@ -536,6 +540,20 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM }) } + /** + * A local file refused for a page (D49) is named in a toast, so a click that opens nothing says + * why: once per tab and file every few seconds, however often a page asks (round 3 fix check F1). + */ + const refusedAt = new Map() + const localFileRefused = (tabId: TabId, url: string) => { + const now = Date.now() + const last = refusedAt.get(tabId) + if (last?.url === url && now - last.at < 3000) return + refusedAt.set(tabId, { url, at: now }) + toast(windows.windowOfTab(tabId) ?? windows.focusedWindowId(), localFileBlockedMessage(url)) + } + tabs.onLocalFileRefused = localFileRefused + // One "Pop-up blocked" per tab every few seconds, however often a page tries. const blockedAt = new Map() tabs.onPopupBlocked = (tabId) => { diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 7afb3a84..b97b43cd 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -159,6 +159,8 @@ export class TabRuntime { onControlUp?: () => void /** A page tried to open a tab or pop-up without a user gesture. */ onPopupBlocked?: (tabId: TabId, url: string, referrer: Referrer) => void + /** A page asked for a local file in a new tab, window or pop-up, and Orbit refused it (D49). */ + onLocalFileRefused?: (tabId: TabId, url: string) => void /** True when the tab's site may open pop-ups without a click (allowed in site controls, NV-09). */ allowsPopups?: (tabId: TabId) => boolean /** Last user gesture per tab, for the pop-up rule (NV-09). */ @@ -314,10 +316,14 @@ export class TabRuntime { }) contents.on('context-menu', (_e, params) => showPageContextMenu(this.ctx, tabId, contents, params)) contents.setWindowOpenHandler((details) => { - // A local file opens for no web page, nor for a page with a frame from elsewhere, which may be - // the one asking: no tab, window, Peek, Mini Window or pop-up, nor a blocked pop-up to open - // later, whatever the engine let through (D49, round 2 fix check F1). - if (!framesMayOpen(contents, details.url)) return { action: 'deny' } + // A local file opens for no web page, nor for a local page with any frame whose origin isn't a + // local file's (a web page's, or an opaque one), which may be the one asking: no tab, window, + // Peek, Mini Window or pop-up, nor a blocked pop-up to open later, whatever the engine let + // through (D49, round 2 fix check F1). A toast names the file (round 3 fix check F1). + if (!framesMayOpen(contents, details.url)) { + this.onLocalFileRefused?.(tabId, details.url) + return { action: 'deny' } + } // A link Orbit asked this page to open (openFromPage): the click was counted already. const asked = this.takePageOpen(contents.id, details.url) if (asked) return asked diff --git a/src/shared/link-routing.ts b/src/shared/link-routing.ts index 5a4b4ac6..296900d4 100644 --- a/src/shared/link-routing.ts +++ b/src/shared/link-routing.ts @@ -69,10 +69,12 @@ export interface PageFrames { /** * `pageMayOpen` for a page that asked for `url` from a frame Orbit can't name: a new window, tab or - * pop-up it requested, a pop-up blocked on it, or a save of it (D49; round 2 fix check M1, F1). A - * local file only when every frame may open it, by each frame's origin: a frame from elsewhere counts - * as a web page (F2), and an about:blank frame a local page made counts as local. A page that went - * away opens no local file. + * pop-up it requested (a modified click, a `target=_blank` link, `window.open`), a pop-up blocked on + * it, or a save of it (D49; round 2 fix check M1, F1). A local file only when every frame may open + * it, by each frame's origin: any frame whose origin isn't a local file's counts as a web page, a web + * page's (F2) or an opaque one (a sandboxed or `data:` frame), even one the page made itself; an + * about:blank or srcdoc frame a local page made has the page's origin and counts as local. A page + * that went away opens no local file. */ export function framesMayOpen(page: PageFrames, url: string): boolean { if (pageMayOpen(undefined, url)) return true @@ -83,6 +85,31 @@ export function framesMayOpen(page: PageFrames, url: string): boolean { } } +/** + * The toast for a local file Orbit refused to open for a page (D49): it names the file, so a click + * that opens nothing says why (round 3 fix check F1). + */ +export function localFileBlockedMessage(url: string): string { + return `Local file blocked: ${fileNameOf(url)}` +} + +/** A local file's name, from its address (the last part of its path, decoded). */ +function fileNameOf(url: string): string { + let path: string + try { + path = new URL(url.trim().replace(/^view-source:/i, '')).pathname + } catch { + return url + } + const name = path.split('/').filter(Boolean).at(-1) + if (!name) return url + try { + return decodeURIComponent(name) + } catch { + return name + } +} + /** Same registrable domain (eTLD+1): `mail.example.com` and `docs.example.com` are one site (PK-02). */ export function sameSite(a: string, b: string): boolean { if (!isWebUrl(a) || !isWebUrl(b)) return false diff --git a/tests/e2e/nav-links.spec.ts b/tests/e2e/nav-links.spec.ts index 3f5f3cd6..933fa32a 100644 --- a/tests/e2e/nav-links.spec.ts +++ b/tests/e2e/nav-links.spec.ts @@ -897,7 +897,7 @@ function askToOpen( } test('[NV-08][NV-09][MW-07] a web page that asks for a local file in a new tab, window or pop-up gets none, even where the engine lets it ask; a local page still does (D49, round 2 fix check F1)', async () => { - const { app } = orbit + const { app, shell } = orbit const local = localFiles() const folder = new URL(local.folder.endsWith('/') ? local.folder : `${local.folder}/`) const loads = await watchLoads(app, local.folder) @@ -921,10 +921,14 @@ test('[NV-08][NV-09][MW-07] a web page that asks for a local file in a new tab, await askToOpen(app, web, local.text, 'new-window', { features: popup }), ] expect(answers).toEqual(['deny', 'deny', 'deny', 'deny', 'deny', 'deny']) - // A pop-up the page opened asks too: its own opens become tabs. + // A toast names the file, once however often the page asks (round 3 fix check F1). + const refused = (file: string) => shell.getByTestId('toast').filter({ hasText: `Local file blocked: ${file}` }) + await expect(refused('orbit-local.txt')).toHaveCount(1) + // A pop-up the page opened asks too: its own opens become tabs. Its toast is in the opener's window. await clickInPage(app, web, '#popup') await expect.poll(() => loadedTitle(app, page('Popup-Opener'))).toBe('Popup-Opener') - expect(await askToOpen(app, page('Popup-Opener'), local.text, 'foreground-tab')).toBe('deny') + expect(await askToOpen(app, page('Popup-Opener'), local.image, 'foreground-tab')).toBe('deny') + await expect(refused('orbit-local.png')).toBeVisible() // A local page with a frame from the web: the frame may be the one asking. const framed = new URL('framed.html', folder).href @@ -949,6 +953,68 @@ test('[NV-08][NV-09][MW-07] a web page that asks for a local file in a new tab, await expect.poll(fileTabs).toEqual([local.text]) }) +test('[NV-08][NV-09] a local page with a frame of another origin, a web page’s or an opaque one, opens no local file in a new tab, and a toast names it; a plain click still opens it (D49, round 3 fix check F1)', async () => { + const { app, shell } = orbit + const local = localFiles() + const folder = new URL(local.folder.endsWith('/') ? local.folder : `${local.folder}/`) + const fileTabs = async () => + Object.values((await state(app)).tabs) + .filter((t) => t.url.includes('orbit-local')) + .map((t) => t.url) + .sort() + const frameOrigins = (id: string) => + app.evaluate( + (_e, tabId) => + (globalThis as unknown as TabsHook).__orbit.ctx.tabs + .contents(tabId) + ?.mainFrame.framesInSubtree.map((f) => f.origin) ?? [], + id, + ) + /** A local page with a frame, a target=_blank link to the text file and a plain one to the image. */ + const localPage = (name: string, frame: string) => { + const url = new URL(`${name}.html`, folder).href + const links = `Text Image` + writeFileSync(new URL(url), `${name}

${links}

${frame}`) + return url + } + const toasts = shell.getByTestId('toast') + const refused = (file: string) => toasts.filter({ hasText: `Local file blocked: ${file}` }) + const frames = [ + { name: 'web-frame', frame: ``, origin: `http://localhost:${fx.port}` }, + { name: 'sandboxed-frame', frame: '', origin: 'null' }, + { name: 'data-frame', frame: '', origin: 'null' }, + ] + let last = { url: '', id: '' } + for (const { name, frame, origin } of frames) { + const url = localPage(name, frame) + const id = await openTab(app, url) + last = { url, id } + await expect.poll(() => frameOrigins(id)).toEqual(['file://', origin]) + // The toasts before have gone. + await expect(toasts).toHaveCount(0, { timeout: 5000 }) + // A target=_blank link: the page asks for a new tab. + await clickInPage(app, url, '#blank') + await expect(refused('orbit-local.txt')).toBeVisible() + // ⌘-click (Ctrl-click): a background tab. + await clickInPage(app, url, '#plain', [mod]) + await expect(refused('orbit-local.png')).toBeVisible() + expect(await fileTabs()).toEqual([]) + } + // A plain click without a target loads it in the page's own tab, as the engine allows. + await clickInPage(app, last.url, '#plain') + await expect.poll(() => urlOf(app, last.id)).toBe(local.image) + + // A local page with a frame of its own origin (srcdoc) opens both, and no toast. + await expect(toasts).toHaveCount(0, { timeout: 5000 }) + const own = localPage('own-frame', '') + const ownId = await openTab(app, own) + await expect.poll(() => frameOrigins(ownId)).toEqual(['file://', 'file://']) + await clickInPage(app, own, '#blank') + await clickInPage(app, own, '#plain', [mod]) + await expect.poll(fileTabs).toEqual([local.image, local.image, local.text].sort()) + expect(await toasts.count()).toBe(0) +}) + test('[NV-09] a pop-up blocked on a web page is offered to open later unless it is a local file (D49, round 2 fix check F1)', async () => { const { app } = orbit const local = localFiles() diff --git a/tests/unit/link-routing.test.ts b/tests/unit/link-routing.test.ts index f573efb5..1ed11d01 100644 --- a/tests/unit/link-routing.test.ts +++ b/tests/unit/link-routing.test.ts @@ -6,6 +6,7 @@ import { isMeetingLink, type LinkSettings, type LinkSource, + localFileBlockedMessage, pageMayOpen, routeFor, sameSite, @@ -181,6 +182,17 @@ describe('local files a page may have Orbit open (D49)', () => { expect(framesMayOpen(gone, file)).toBe(false) expect(framesMayOpen(gone, 'https://example.org/b')).toBe(true) }) + it('a local file refused for a page is named in its toast (round 3 fix check F1)', () => { + expect(localFileBlockedMessage('file:///tmp/orbit/notes.txt')).toBe('Local file blocked: notes.txt') + expect(localFileBlockedMessage('file:///tmp/my%20docs/caf%C3%A9%20menu.pdf')).toBe( + 'Local file blocked: café menu.pdf', + ) + expect(localFileBlockedMessage('view-source:file:///tmp/orbit/index.html')).toBe('Local file blocked: index.html') + expect(localFileBlockedMessage('file:///C:/Users/me/plan.txt')).toBe('Local file blocked: plan.txt') + expect(localFileBlockedMessage('file:///home/me/')).toBe('Local file blocked: me') + expect(localFileBlockedMessage('file:///tmp/100%.txt')).toBe('Local file blocked: 100%.txt') + expect(localFileBlockedMessage('file:///')).toBe('Local file blocked: file:///') + }) it('which addresses are local files', () => { expect(isLocalFileUrl(file)).toBe(true) expect(isLocalFileUrl(` ${file}`)).toBe(true) From fb98f8cf6674cc91550aef72ff20243c7dc9a76e Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:27:21 +0000 Subject: [PATCH 68/69] fix(find): held keys go into the find bar as text, and a late bar takes the keyboard (round 3 fix check F2) Keys typed for the find bar before its field has the keyboard are typed into it in order once it does: text with insertText, so any character arrives as typed, and only Backspace, Enter and Escape as key events. Text after one of those waits until the bar's page has had the key (its key up), as insertText could overtake it; keys typed meanwhile are held and typed in their turn. A key Chromium names with a control character (as it names keys xdotool maps on the fly) carries no text and is dropped. A bar whose page tools load after the 5 s hold has ended still takes the keyboard when it shows: the hold lets its keys go, but the bar keeps waiting. Shortcuts and IME composition are still not held. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/nav/page-tools.ts | 119 +++++++++++++++++----- tests/e2e/nav.spec.ts | 198 +++++++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+), 28 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 2d4b69ea..b332ffa6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -232,7 +232,7 @@ Spec: `spec/navigation.md` | NV-02 | Trackpad two-finger swipe back/forward | P1 | – | – | – | | | 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), 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; 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-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-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 5ea58570..b2ecf6c8 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -182,7 +182,7 @@ "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-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), 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; 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-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-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"}, diff --git a/src/main/nav/page-tools.ts b/src/main/nav/page-tools.ts index 487462c7..a306fe69 100644 --- a/src/main/nav/page-tools.ts +++ b/src/main/nav/page-tools.ts @@ -25,6 +25,15 @@ const PINCH_MAX = 5 const HOLD_MS = 5000 /** Keys other than text that are held for the find bar (F4): it acts on them as it would. */ const HELD_KEYS = new Set(['Backspace', 'Enter', 'Escape']) +/** A held Backspace, Enter or Escape typed into the find bar waits this long at most for its page to have it. */ +const KEY_WAIT_MS = 1000 +/** + * A key named with a control character carries no text: Chromium names a key "\u0000" when its + * keymap has no symbol for it yet, as for keys xdotool maps on the fly (round 3 fix check, F2). + */ +const CONTROL = /\p{Cc}/u +/** In the page tools' page: the next key up anywhere in it (a held key typed into the find bar). */ +const WATCH_KEY_UP = `window.__orbitKeyUp = new Promise((resolve) => addEventListener('keyup', () => resolve(true), { once: true, capture: true })); 0` const SELECTION = `String(getSelection() || '')` @@ -72,15 +81,20 @@ interface HeldKey { } /** - * Keys held for a window's find bar from ⌘F until its field has the keyboard (round 2 fix check, - * F4). `nonce`: the focus request the field must say it took, once the bar is in the window and - * has been given the keyboard; null before then. + * A window's find bar waiting for the keyboard, from ⌘F until its field says it has it (round 2 + * fix check, F4). Keys typed for it meanwhile are held, for HOLD_MS at most, then typed into it. + * The bar takes the keyboard when it shows, held keys or not (round 3 fix check, F2). `nonce`: the + * focus request the field must say it took, once the bar is in the window and has been given the + * keyboard; null before then. */ interface HeldKeys { tabId: TabId nonce: number | null - keys: HeldKey[] + /** The keys held so far, in order; null once HOLD_MS has passed, when keys go where the keyboard is again. */ + keys: HeldKey[] | null timer?: NodeJS.Timeout + /** The held keys are being typed into the field: keys typed meanwhile are held, and typed in their turn. */ + typing?: boolean } /** Where a window's page tools are after a refresh: in the window, waiting for their page to load, or out. */ @@ -208,13 +222,24 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( const held = new Map() /** The page tools' web contents whose keys are watched. */ const watched = new WeakSet() + /** A held key is being pressed in the page tools: it isn't held again. */ + let pressing = false const dropKeys = (windowId: WindowId) => { clearTimeout(held.get(windowId)?.timer) held.delete(windowId) } - /** Starts holding (or goes on holding) keys for the find bar of a window's tab. */ + /** Keys typed for the bar are held from now, for HOLD_MS at most: then they go where the keyboard is again. */ + const keepHolding = (h: HeldKeys) => { + h.keys ??= [] + clearTimeout(h.timer) + h.timer = setTimeout(() => { + h.keys = null + }, HOLD_MS) + } + + /** The find bar of a window's tab waits for the keyboard (or goes on waiting), holding keys for it. */ const holdKeys = (windowId: WindowId, tabId: TabId): HeldKeys => { let h = held.get(windowId) if (!h || h.tabId !== tabId) { @@ -222,12 +247,8 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( h = { tabId, nonce: null, keys: [] } held.set(windowId, h) } - const holding = h - clearTimeout(holding.timer) - holding.timer = setTimeout(() => { - if (held.get(windowId) === holding) held.delete(windowId) - }, HOLD_MS) - return holding + keepHolding(h) + return h } /** @@ -236,7 +257,7 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( */ const holdKey = (windowId: WindowId | undefined, tabId: TabId | null, input: Electron.Input): boolean => { const h = windowId ? held.get(windowId) : undefined - if (!h || (tabId !== null && tabId !== h.tabId)) return false + if (pressing || !h?.keys || (tabId !== null && tabId !== h.tabId)) return false if (input.control || input.meta || input.isComposing) return false if (input.alt && process.platform !== 'darwin') return false const text = [...input.key].length === 1 @@ -253,17 +274,62 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( }) } - /** Types a held key into the page tools, as the keyboard would have. */ - const typeKey = (contents: WebContents, { key, shift }: HeldKey) => { - if ([...key].length === 1) { - contents.sendInputEvent({ type: 'keyDown', keyCode: key }) - contents.sendInputEvent({ type: 'char', keyCode: key }) - contents.sendInputEvent({ type: 'keyUp', keyCode: key }) - return - } + /** + * Presses a held Backspace, Enter or Escape in the page tools, and resolves once their page has + * had it (its key up), or after KEY_WAIT_MS: text inserted next would otherwise overtake it. + */ + const pressKey = async (contents: WebContents, { key, shift }: HeldKey) => { + await contents.executeJavaScript(WATCH_KEY_UP) + if (contents.isDestroyed()) return const modifiers: 'shift'[] = shift ? ['shift'] : [] - contents.sendInputEvent({ type: 'keyDown', keyCode: key, modifiers }) - contents.sendInputEvent({ type: 'keyUp', keyCode: key, modifiers }) + pressing = true + try { + contents.sendInputEvent({ type: 'keyDown', keyCode: key, modifiers }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key, modifiers }) + } finally { + pressing = false + } + await Promise.race([ + contents.executeJavaScript('window.__orbitKeyUp'), + new Promise((resolve) => setTimeout(resolve, KEY_WAIT_MS)), + ]) + } + + /** + * Types held keys into the page tools' field, in order: text as text, so every character arrives + * as typed (round 3 fix check, F2), and Backspace, Enter and Escape as keys. Escape closes the + * bar, so what comes after it was typed for a bar that has gone. + */ + const typeKeys = async (contents: WebContents, keys: HeldKey[]) => { + let text = '' + for (const key of keys) { + if (!HELD_KEYS.has(key.key)) { + if (!CONTROL.test(key.key)) text += key.key + continue + } + if (text) await contents.insertText(text) + text = '' + if (contents.isDestroyed()) return + await pressKey(contents, key) + if (key.key === 'Escape') return + } + if (text && !contents.isDestroyed()) await contents.insertText(text) + } + + /** Types the keys held for a window's find bar into its field, then stops holding them. */ + const typeHeld = async (windowId: WindowId, h: HeldKeys, contents: WebContents) => { + h.typing = true + keepHolding(h) + try { + for (let keys = h.keys; keys?.length && held.get(windowId) === h; keys = h.keys) { + h.keys = [] + if (contents.isDestroyed()) break + await typeKeys(contents, keys) + } + } catch { + // The page tools' page went away with the keys. + } + if (held.get(windowId) === h) dropKeys(windowId) } /** The find bar is in the window: it takes the keyboard, and its field says when it has it (find.focused). */ @@ -417,10 +483,10 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( commands.register('find.focused', ({ focus }, c) => { const windowId = windowOf(c) const h = windowId ? held.get(windowId) : undefined - if (!windowId || !h || h.nonce === null || focus < h.nonce) return - dropKeys(windowId) + if (!windowId || !h || h.nonce === null || focus < h.nonce || h.typing) return const contents = windows.toolsContents(windowId) - if (contents) for (const key of h.keys) typeKey(contents, key) + if (contents && h.keys?.length) void typeHeld(windowId, h, contents) + else dropKeys(windowId) }) commands.register('find.close', (_args, c) => closeFind(windowOf(c))) @@ -525,7 +591,8 @@ export function registerPageTools(ctx: MainContext, prefs: SitePrefs): { info: ( }) // The page tools' page has loaded, so they can go into the window (round 2 fix check, M3): the zoom - // indicator shows for its full time from now, and an open find bar takes the keyboard. + // indicator shows for its full time from now, and a find bar waiting for the keyboard takes it, + // whether or not keys are still held for it (round 3 fix check, F2). windows.onToolsReady((windowId) => { const t = tools.get(windowId) if (t?.zoom && !t.hovered) { diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index ead48458..2c82f562 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -876,3 +877,200 @@ test('[NV-05] keys typed right after ⌘F in a new window’s first moments reac await expect.poll(async () => (await toolsIn(fresh.windowId)).find).toMatchObject({ query: 'orbit', matches: 3 }) expect(await fieldValue(fresh.tabId)).toBe('') }) + +// ---- Held keys (round 3 fix check, F2) ---- + +/** The value of a window's find field, as its page has it. */ +function findInput(windowId: string): Promise { + return orbit.app.evaluate( + (_e, w) => + ( + globalThis as unknown as { + __orbit: { ctx: { windows: { toolsContents: (w: string) => Electron.WebContents } } } + } + ).__orbit.ctx.windows + .toolsContents(w) + .executeJavaScript(`document.querySelector('[data-testid=find-input]')?.value ?? null`), + windowId, + ) +} + +/** The first window's id. */ +const firstWindow = () => + orbit.app.evaluate( + () => (globalThis as unknown as { __orbit: { windowIds: () => string[] } }).__orbit.windowIds()[0] ?? '', + ) + +/** + * Keeps a window's page tools' page busy for `ms` (a script that runs that long). Resolves with + * when it began, by main's clock. + */ +function busyTools(windowId: string, ms: number): Promise { + return orbit.app.evaluate( + (_e, [w, t]) => { + const tools = ( + globalThis as unknown as { + __orbit: { ctx: { windows: { toolsContents: (w: string) => Electron.WebContents } } } + } + ).__orbit.ctx.windows.toolsContents(w) + const began = new Promise((resolve) => { + const said = (...args: unknown[]) => { + const busy = args.some( + (a) => + a === 'orbit-busy' || + (typeof a === 'object' && !!a && (a as { message?: unknown }).message === 'orbit-busy'), + ) + if (!busy) return + tools.off('console-message', said) + resolve(Date.now()) + } + tools.on('console-message', said) + }) + void tools.executeJavaScript( + `console.log('orbit-busy'); { const until = Date.now() + ${t}; while (Date.now() < until) {} }`, + ) + return began + }, + [windowId, ms] as const, + ) +} + +/** Records the name main hears for each key pressed in any web contents; returns a reader. */ +async function watchKeyNames(): Promise<() => Promise> { + await orbit.app.evaluate(({ app: electronApp, webContents }) => { + const g = globalThis as unknown as { __keyNames: string[] } + g.__keyNames = [] + const watch = (contents: Electron.WebContents) => + contents.on('before-input-event', (_e, input) => { + if (input.type === 'keyDown') g.__keyNames.push(input.key) + }) + for (const contents of webContents.getAllWebContents()) watch(contents) + electronApp.on('web-contents-created', (_e, contents) => watch(contents)) + }) + return () => orbit.app.evaluate(() => (globalThis as unknown as { __keyNames: string[] }).__keyNames) +} + +/** + * The real X keyboard on the test's display (xdotool and xmodmap, Linux), or null where there is none. + * `addKeys` puts keysyms on spare keys, as a keyboard that has them does, until the returned undo. + */ +const xkeyboard = (() => { + if (process.platform !== 'linux' || !process.env.DISPLAY) return null + try { + execFileSync('xdotool', ['version'], { stdio: 'ignore' }) + execFileSync('xmodmap', ['-pke'], { stdio: 'ignore' }) + } catch { + return null + } + return { + type: (text: string) => execFileSync('xdotool', ['type', '--delay', '40', text], { stdio: 'ignore' }), + addKeys: (...keysyms: string[]): (() => void) => { + const spare = execFileSync('xmodmap', ['-pke'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + .split('\n') + .map((line) => /^keycode\s+(\d+)\s*=\s*$/.exec(line)?.[1]) + .filter((code): code is string => !!code && Number(code) > 8) + .slice(0, keysyms.length) + if (spare.length < keysyms.length) throw new Error('no spare keys in the keymap') + execFileSync( + 'xmodmap', + spare.flatMap((code, i) => ['-e', `keycode ${code} = ${keysyms[i]}`]), + { stdio: 'ignore' }, + ) + return () => + execFileSync( + 'xmodmap', + spare.flatMap((code) => ['-e', `keycode ${code} =`]), + { stdio: 'ignore' }, + ) + }, + } +})() + +test('[NV-05] keys typed for the find bar while its page is busy arrive whole: “aBéßz” from a keyboard that has é and ß (round 3 fix check, F2)', async () => { + test.skip(!xkeyboard, 'é and ß have to come from a keyboard that has them: test input names them no key') + const x = xkeyboard as NonNullable + const { app } = orbit + const url = fx.url(`/page/Busy?text=${encodeURIComponent('orbit aBéßz')}`) + await openTab(app, url) + const tab = await tabPage(app, '/page/Busy') + // The bar's page has loaded; the page has the keyboard again. + const tools = await openFind('/page/Busy') + await cmd(app, 'find.close') + await expect.poll(() => focusedUrl(app)).toContain('/page/Busy') + const names = await watchKeyNames() + const undo = x.addKeys('eacute', 'ssharp') + try { + // Chromium names the new keys once it has the new keymap (before, it names them "\u0000"). + const clearBox = () => + tab.evaluate(() => { + const box = document.getElementById('box') as HTMLInputElement + box.value = '' + box.focus() + }) + let named: string[] = [] + for (let i = 0; i < 10 && named.join() !== 'é'; i++) { + await clearBox() + const typed = (await names()).length + x.type('é') + await expect.poll(async () => (await names()).length).toBeGreaterThan(typed) + named = (await names()).slice(typed) + } + expect(named).toEqual(['é']) + await clearBox() + const windowId = await firstWindow() + const BUSY_MS = 2500 + const began = await busyTools(windowId, BUSY_MS) + const before = (await names()).length + await pressInPage(app, '/page/Busy', 'F', [mod]) + x.type('aBéßz') + // Typed while the bar's page was busy: its field couldn't have had the keys. + expect(Date.now() - began).toBeLessThan(BUSY_MS) + await expect.poll(() => findInput(windowId), { timeout: 10_000 }).toBe('aBéßz') + await expect.poll(async () => (await toolsInfo(app)).find).toMatchObject({ query: 'aBéßz', matches: 1 }) + expect((await names()).slice(before).filter((name) => name !== 'Shift')).toEqual(['f', 'a', 'B', 'é', 'ß', 'z']) + expect(await tab.evaluate(() => (document.getElementById('box') as HTMLInputElement).value)).toBe('') + await expect(tools.getByTestId('find-input')).toBeFocused() + } finally { + undo() + } +}) + +test('[NV-05] a find bar whose page tools load after its keys were let go still takes the keyboard when it shows (round 3 fix check, F2)', async () => { + // The next page tools' page starts loading late, as from a slow renderer: long after ⌘F. + const LATE_MS = 8000 + await orbit.app.evaluate(({ webContents }, ms) => { + type Load = ( + this: Electron.WebContents, + where: string, + options?: { query?: Record }, + ) => Promise + const proto = Object.getPrototypeOf(webContents.getAllWebContents()[0]) as { loadFile: Load; loadURL: Load } + const originals = { loadFile: proto.loadFile, loadURL: proto.loadURL } + const g = globalThis as unknown as { __lateTools: number } + for (const method of ['loadFile', 'loadURL'] as const) { + const load = originals[method] + proto[method] = function (this: Electron.WebContents, where, options) { + if (options?.query?.view !== 'tools' && !where.includes('view=tools')) return load.call(this, where, options) + Object.assign(proto, originals) + this.once('did-finish-load', () => { + g.__lateTools = Date.now() + }) + setTimeout(() => void load.call(this, where, options).catch(() => {}), ms) + return Promise.resolve() + } + } + }, LATE_MS) + const fresh = await earlyWindow(findPage('Late'), 'find', []) + const asked = Date.now() + expect((await toolsIn(fresh.windowId)).find).toMatchObject({ open: true }) + await expect.poll(async () => (await toolsIn(fresh.windowId)).placed, { timeout: LATE_MS + 10_000 }).toBe(true) + // The page tools loaded after the keys held for the bar were let go (F4: 5 s). + const loaded = await orbit.app.evaluate(() => (globalThis as unknown as { __lateTools: number }).__lateTools) + expect(loaded - asked).toBeGreaterThan(5000) + // The bar takes the keyboard all the same: typing goes on in it, not in the page. + await expect.poll(() => focusedUrl(orbit.app)).toContain('view=tools') + expect(await typeAnywhere(['o', 'r', 'b'])).toEqual(Array(3).fill(expect.stringContaining('view=tools'))) + await expect.poll(() => findInput(fresh.windowId)).toBe('orb') + await expect.poll(async () => (await toolsIn(fresh.windowId)).find).toMatchObject({ query: 'orb', matches: 3 }) + expect(await fieldValue(fresh.tabId)).toBe('') +}) From e98439a281b6f6eb7221269bf74f7d88c01f8893 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:27:26 +0000 Subject: [PATCH 69/69] fix(find): page tools whose page crashed stay out of the window until it has loaded again (round 3 fix check F6) A crash of the page tools' renderer sets them back to not ready: they leave the window, and the keyboard goes back to the page if they had it. Each load of their page makes them ready again, so the find bar comes back with its search once the reload is done, and is never placed over a page that isn't there. Co-Authored-By: Claude Opus 5.5 --- src/main/windows.ts | 13 ++++++++++++- tests/e2e/nav.spec.ts | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index 4a900879..7fe89dac 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1423,12 +1423,23 @@ export class WindowManager { rec.tools = view rec.toolsShown = false rec.toolsReady = false + const contents = view.webContents // Into the window once its page has loaded (round 2 fix check, M3), when the nav module shows it. - view.webContents.once('did-finish-load', () => { + contents.on('did-finish-load', () => { if (rec.tools !== view || rec.win.isDestroyed()) return rec.toolsReady = true for (const listener of this.toolsReadyListeners) listener(rec.id) }) + // A crash takes their page away until it has loaded again (round 3 fix check, F6): they leave the + // window until then, and the keyboard goes back to the page if they had it. + contents.on('render-process-gone', () => { + if (rec.tools !== view || rec.win.isDestroyed()) return + rec.toolsReady = false + if (!rec.win.contentView.children.includes(view)) return + const focused = webContents.getFocusedWebContents() + this.removeChild(rec, view) + if ((!focused || focused === contents) && !rec.overlayShown && rec.win.isFocused()) this.focusActiveTab(rec.id) + }) return view } diff --git a/tests/e2e/nav.spec.ts b/tests/e2e/nav.spec.ts index 2c82f562..2b8527af 100644 --- a/tests/e2e/nav.spec.ts +++ b/tests/e2e/nav.spec.ts @@ -878,7 +878,7 @@ test('[NV-05] keys typed right after ⌘F in a new window’s first moments reac expect(await fieldValue(fresh.tabId)).toBe('') }) -// ---- Held keys (round 3 fix check, F2) ---- +// ---- Held keys and the page tools' page (round 3 fix check, F2 and F6) ---- /** The value of a window's find field, as its page has it. */ function findInput(windowId: string): Promise { @@ -1074,3 +1074,39 @@ test('[NV-05] a find bar whose page tools load after its keys were let go still await expect.poll(async () => (await toolsIn(fresh.windowId)).find).toMatchObject({ query: 'orb', matches: 3 }) expect(await fieldValue(fresh.tabId)).toBe('') }) + +test('[NV-05] page tools whose page crashed leave the window until it has loaded again; the find bar comes back with its search (round 3 fix check, F6)', async () => { + const { app } = orbit + await openTab(app, findPage('Crash')) + const tools = await openFind('/page/Crash') + await tools.getByTestId('find-input').pressSequentially('orbit') + await expect.poll(async () => (await toolsInfo(app)).find).toMatchObject({ query: 'orbit', matches: 3 }) + const windowId = await firstWindow() + // Right after the crash: out of the window and not ready, and the page has the keyboard. + const after = await app.evaluate(async (_e, w) => { + type Windows = { + toolsContents: (w: string) => Electron.WebContents + toolsInfo: (w: string) => { ready: boolean; placed: boolean } + } + const windows = (globalThis as unknown as { __orbit: { ctx: { windows: Windows } } }).__orbit.ctx.windows + const contents = windows.toolsContents(w) + const gone = new Promise<{ ready: boolean; placed: boolean }>((resolve) => + contents.once('render-process-gone', () => resolve(windows.toolsInfo(w))), + ) + contents.forcefullyCrashRenderer() + return gone + }, windowId) + expect(after).toMatchObject({ ready: false, placed: false }) + await expect.poll(() => focusedUrl(app)).toContain('/page/Crash') + // Back once their page has loaded again: the bar, with its search. + await expect + .poll( + async () => { + const info = await toolsIn(windowId) + return { ready: info.ready, placed: info.placed, query: info.find.query } + }, + { timeout: 15_000 }, + ) + .toEqual({ ready: true, placed: true, query: 'orbit' }) + await expect.poll(() => findInput(windowId)).toBe('orbit') +})