diff --git a/FEATURES.md b/FEATURES.md index 0019dfd9..27a89c22 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:** 472 spec’d · 267 built (77 partial) · 349 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 151 | 173 | -| P1 | 219 | 100 | 156 | -| P2 | 79 | 16 | 20 | +| P0 | 174 | 152 | 173 | +| P1 | 219 | 107 | 162 | +| P2 | 79 | 17 | 21 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -39,7 +39,7 @@ Spec: `spec/sidebar.md` | SB-17 | Optional top toolbar | P1 | – | – | – | | | SB-18 | Developer-mode tab chrome | P2 | – | – | – | | | SB-19 | Inset rounded page frame ("card" look) | P0 | ✅ | ✅ e2e | – | 8 px margins (6 px with the sidebar hidden), 10 px corners on every page and pane while loading, hidden or floating; fine edge and shadow; native and video full screen drop the frame; the card and the page view take the page background colour so resizes show no white | -| SB-20 | Top-of-window behaviour and window dragging | P1 | 🟡 partial | – | – | macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; the rest of the hidden-sidebar chrome is not built | +| SB-20 | Top-of-window behaviour and window dragging | P1 | 🟡 partial | – | – | macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; native full screen hides the sidebar with hover reveal, ⌘N there opens a full-screen window, and Esc in a page goes to the page first, held or pressed twice leaving full screen (NV-20); the rest of the hidden-sidebar chrome is not built | | SB-21 | Dim the sidebar when the window is inactive | P1 | ✅ | ✅ e2e | – | an inactive window's sidebar (and its floating card) fades to 84% over 150 ms on blur and is back at once on focus; no fade with reduced motion | | SB-22 | Keyboard focus and navigation inside the sidebar | P2 | ✅ | ✅ e2e | – | Cmd/Ctrl+Shift+E focuses the sidebar; arrows, Enter, Escape, F2 | | SB-23 | Scrolling, auto-reveal of the active tab and overflow indicator | P1 | ✅ | ✅ e2e | – | selected tab scrolled into view (smooth, then settles); pill points to it while it is out of view; rows use content-visibility | @@ -229,29 +229,29 @@ Spec: `spec/navigation.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | NV-01 | Back and forward | P0 | ✅ | ✅ e2e+unit | – | buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert | -| NV-02 | Trackpad two-finger swipe back/forward | P1 | – | – | – | | +| NV-02 | Trackpad two-finger swipe back/forward | P1 | ✅ | ✅ e2e+unit | – | a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Not offered over a Peek's, a Mini Window's or a pop-up's page, where the swipe does nothing (the spec doesn't say). Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check) | | NV-03 | Reload and hard reload | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page | -| NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes | -| 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; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03) | +| NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads | +| NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names "\u0000", is lost (chrome fix check 4) | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | -| NV-07 | Print | P1 | – | – | – | | +| NV-07 | Print | P1 | 🟡 partial | ✅ e2e+unit | – | ⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog | | NV-08 | Clicking links: plain and modified clicks | P0 | 🟡 partial | ✅ e2e+unit | – | Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links | -| NV-09 | Links with target=_blank and script-opened windows | P0 | ✅ | ✅ e2e+unit | – | target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there, and a toast names the file (D49); a tab that only downloads closes and reselects its opener | +| NV-09 | Links with target=_blank and script-opened windows | P0 | ✅ | ✅ e2e+unit | – | target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener | | NV-10 | New window | P0 | 🟡 partial | ✅ e2e | – | Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built | | NV-11 | Private (incognito) window | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10 | | NV-12 | Page context menu | P0 | ✅ | ✅ e2e | – | native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built | -| NV-13 | Load-error pages | P1 | – | – | – | | -| NV-14 | Crashed or unresponsive tab | P1 | – | ✅ e2e | – | | -| NV-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); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13 | +| NV-13 | Load-error pages | P1 | ✅ | ✅ e2e+unit | – | a main-frame load that fails shows Orbit's screen over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up): an icon, a title, one plain sentence in Chromium's words, the site, the engine's error code and Try again, which has the keyboard when the page had it (never taken from the sidebar or the command bar); it is a view over the engine's own error page rather than an orbit: page (D39), so the address keeps the failed URL and ⌘R, Try again, Back and history stay the engine's; a host that doesn't resolve, a refused connection, no internet, an empty response and other net errors each say what happened; the screen stays through a retry that fails again; the row keeps its title and icon when the site that failed is the one it showed, and otherwise shows that site's name (and its icon if Orbit knows one); Orbit gives up on a server that hasn't answered within 120 s (Chromium waits until the user stops it): "took too long to respond", the address stays where it was going, Try again and ⌘R ask again, and Back shows the page before as it was, its screen included; a form being sent is never stopped (it may be uploading); certificates: "Your connection is not private" with Back to safety on the keyboard (back, or closing a tab with nowhere to go back to), Proceed only behind Advanced and never for a revoked certificate, remembered for that certificate and host per profile until Orbit quits; offline: when the network comes back while the screen shows, the page loads again, once (e2e dispatch the engine's online event, as the emulation is per page); a site that asks for HTTPS only (HSTS) still gets Proceed: Electron doesn't say which sites do; gap, as DL-12's (D54): no Safe Browsing interstitial, which needs a reputation service none is free to use; the http not-secure icon is SB-04 | +| NV-14 | Crashed or unresponsive tab | P1 | ✅ | ✅ e2e+unit | – | a page that crashes or is killed shows "This page crashed" over its area, with the reason (out of memory, killed…) and Reload, which has the keyboard when the page had it; Reload, ⌘R and ⌘⇧R bring the page back, and closing the tab works as ever; the row keeps its title and icon; a page that crashed behind another shows its crash screen when its row is selected, until the user reloads it (it no longer reloads by itself when shown: the review F11 test in core.spec.ts now reloads it); the same screen shows over a Peek's, a Mini Window's or a pop-up's page (e2e cover tabs); a page that stops responding (the engine's own signal, after about 15 s of unanswered input) gets "Page unresponsive" over it with Wait (Enter, Esc) or Close page, which ends the page's process: its screen then says "This page was closed" with Reload, and other tabs that share that process end too, as in Chromium; the dialog goes by itself when the page answers again; after Wait, a page that stays hung isn't asked about again until it answers and hangs anew (the engine says it once); e2e: the engine says nothing of a hang while a debugger is attached, and Playwright attaches to every page, so the hang tests keep a page really busy and emit the engine's signal; TB-16 (busy background tabs paused) and EX-13 (crashing extensions) are their own rows | +| NV-15 | Permission prompts | P0 | ✅ | ✅ e2e+unit | – | prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13 | | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | | NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | -| NV-19 | Links that open other apps (external protocols) | P1 | – | – | – | | -| NV-20 | Full screen | P1 | – | ✅ e2e | – | | -| NV-21 | Save page and view source | P2 | – | – | – | | +| NV-19 | Links that open other apps (external protocols) | P1 | ✅ | ✅ e2e+unit | – | a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): "Open Mail?" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), " wants to open this application." naming the site of the frame that followed it, and "Always allow to open links of this type in the associated app" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets "Open this link in another app?"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it | +| NV-20 | Full screen | P1 | ✅ | ✅ e2e+unit | – | ⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7) | +| NV-21 | Save page and view source | P2 | ✅ | ✅ e2e | – | Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet). | | NV-22 | Translate a page | P2 | ⛔ out of scope | – | – | Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it | | NV-23 | Reader mode | P2 | – | – | – | | -| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | – | – | – | | +| NV-24 | Page dialogs (alert, confirm, prompt, leave-page, sign-in, file pickers) | P1 | 🟡 partial | ✅ e2e+unit | – | alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (" says"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; "Leave site?" ("Reload site?" for a reload) when a page that asks for it, from its main frame or a frame (one from another site too), after a user gesture in any of its frames as in Chromium (a click, or a key that reaches the page: ⌘W and Orbit's other shortcuts are none, D77), goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; known limits (D77): the engine's close runs a page's beforeunload with a user activation, which a page that auto-archive or sleep keeps then holds (Orbit's pop-up rule and links to other apps go by Orbit's own count, not the engine's), and ⌘W on a page that has had a user gesture and has a beforeunload listener, while its script is busy, waits for the engine's answer, up to its 1 s limit, before the tab goes; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's) | | NV-25 | Share the current page | P2 | – | – | – | | ## Peek link preview (prefix PK) @@ -270,7 +270,7 @@ Spec: `spec/peek.md` | PK-08 | Open a Peek in Split View | P1 | ✅ | ✅ e2e | – | the card slides to the right half, then split.addTab makes the source and the live Peek page one split in the source's place (a pinned source gives a pinned split), the page focused; from a full split the Peek stays open and the SV-25 toast shows | | PK-09 | Close a Peek | P0 | ✅ | ✅ e2e+unit | – | Esc (after fullscreen), ⌘W, ×, click outside; archived as from 'peek' | | PK-10 | Reopen a Peek closed by mistake | P1 | ✅ | ✅ e2e | – | ⌘Z right after closing, or ⌘⇧T, reopens on the same page with history | -| PK-11 | Navigating and acting inside a Peek | P1 | 🟡 partial | ✅ e2e+unit | – | links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11 | +| PK-11 | Navigating and acting inside a Peek | P1 | 🟡 partial | ✅ e2e+unit | – | links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; a dialog the Peek's page asks for shows above the find bar, which keeps the keyboard it has until it closes (D72); the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11 | | PK-12 | Peek settings | P1 | ✅ | ✅ e2e+unit | – | Settings → Links has both toggles ("Open links from pinned tabs and Favorites in Peek", "Shift-click opens links in Peek"), on by default on every platform; off, a pinned tab's or Favorite's other-site link opens as a Today tab and ⇧-click opens a new window. Tests: settings.spec ST-09/MW-11 (toggles, defaults), peek.spec PK-12, nav-links.spec NV-08 ⇧-click, link-routing unit PK-12 | | PK-13 | Peek and the sidebar | P0 | ✅ | ✅ e2e | – | no sidebar row; sidebar stays usable; selecting another tab, Space or window dismisses (reopenable) | | PK-14 | Canvases, notes and Library items open in Peek | P2 | – | – | – | | @@ -305,12 +305,12 @@ Spec: `spec/media.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward | +| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one "ResizeObserver loop completed with undelivered notifications" error event, and then its own observers of or get the final size a frame later, as they did before | | MD-02 | Open picture-in-picture manually | P1 | 🟡 partial | ✅ e2e | – | Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over | | MD-03 | The floating player's look and placement | P0 | ✅ | ✅ e2e+unit | – | frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform | | MD-04 | Floating player controls | P0 | ✅ | ✅ e2e+unit | – | on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab | | MD-05 | Keyboard and media keys for the floating player | P1 | ✅ | ✅ e2e | – | with the player focused (clicked) Space plays and pauses and the arrow keys seek 5 s, its buttons 15 s (tested); hardware media keys reach the playing video through Chromium's media key handling, which Orbit turns on: only a person can check them (no media keys under Xvfb) | -| MD-06 | Move, resize and zoom the floating player | P1 | ✅ | ✅ e2e+unit | – | drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers say so), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it | +| MD-06 | Move, resize and zoom the floating player | P1 | ✅ | ✅ e2e+unit | – | drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers and save dialogs say so; so do Orbit's dialogs and screens over a page while they show: a page's alert, confirm, prompt and "Open Mail?", the print dialog, and the error, crash and "Page unresponsive" screens), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert, and with a page's confirm, the print dialog and a crash screen). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it | | MD-07 | Ending picture-in-picture | P0 | ✅ | ✅ e2e | – | returning (select, double-click, Back to tab) ends it with the video playing in the page; x closes it and the media keeps playing in the sidebar controls; closing or unloading the tab stops media and player; navigating the tab or the video ending closes it, without replay; a picture-in-picture the engine closes because another one opened (in another pane, tab or window, by a page or by Orbit) isn't Back to tab: nothing is selected and no window comes forward (D75) | | MD-08 | Turning automatic picture-in-picture off | P0 | ✅ | ✅ e2e+unit | – | site controls panel (NV-16) Picture in Picture switch, on by default: off is Always turn off for this site, on undoes it and Turn off for today, which it shows too; player ... menu Turn Off for Today / Always Turn Off for This Site (also in the tray controller menu); global switch Settings > Advanced (ST-10) and the Picture in Picture action; turning off keeps playback (sidebar controls); muted tabs never trigger | | MD-09 | Sidebar media controls for background audio | P0 | ✅ | ✅ e2e+unit | – | controller above the bottom bar: the Media Session's artwork (loaded with the tab's session, kept small) or the favicon, Media Session title/artist or tab title (marquee), play/pause, next, previous where supported, x pauses and hides; click jumps to the tab (switching Space); quiet audio counts; hidden while its tab is selected; media in frames (embedded players, cross-site too) has the same controls | @@ -337,7 +337,7 @@ Spec: `spec/archive.md` | AR-07 | Clear the Archive | P1 | ✅ | ✅ e2e+unit | – | Clear Archive… from the Archive entry’s right-click menu in the Library, Archive → Clear Archive… (macOS app menu) and the command bar; always asks in the Library first (Cancel has the focus, Esc cancels), says it can’t be undone and that browsing history is kept; clears the profile shown, and the cleared pages leave the disk at once (the log is rewritten). Delete (a row’s menu, or Delete for the selected rows) shows a toast whose Undo puts the entries back with their history for a minute | | AR-08 | Retention of archived entries | P1 | ✅ | ✅ e2e+unit | – | settings.archiveKeepDays, default 30; 30 Days / 90 Days / 1 Year / Forever from the Archive entry’s menu in the Library and Archive → Keep Archived Tabs For (macOS) until Settings exist. A shorter time that would delete entries asks first, in the Library, saying how many (Cancel keeps them all and the time as it was). Purged once the Archive is read at launch, hourly, and at once when a shorter time is confirmed; browsing history is not touched | | AR-09 | Reopen recently closed tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘⇧T and ⌘Z walk back through closes with full history (TB-07, TB-18); ⌘Z also shows the tab again when it was the one shown; a closed Peek comes back through the same key (PK-10, peek lane). A page closed in the last 10 minutes keeps its history rank in the command bar (as the archived row). macOS File menu: Reopen Closed Tab and a Recently Closed submenu of the last 10; on every platform the Archive toolbar has a Recently Closed menu. Neither list shows a tab of a private or deleted profile | -| AR-10 | Browsing history view | P1 | 🟡 partial | ✅ e2e+unit | – | History stays a Library section until Orbit has internal pages (D39), not a page in a tab. ⌘Y (Ctrl+H), Archive → View History (macOS) and the command bar open it next to the Archive: every visit of the current profile from the history store, newest first by day, virtualised and paged, with search (all words, title or address). A row opens its page in a new tab (⌘-click or middle-click: behind the Library); its × or its menu (Open in New Tab, Open in Background, Copy Link, Remove from History) removes every visit to the page. Clearing the Archive leaves it alone | +| AR-10 | Browsing history view | P1 | ✅ | ✅ e2e+unit | – | History is a page in a tab, as D39 planned once internal pages existed: orbit://history/ shows every visit of the tab's profile from the history store, newest first by day, virtualised and paged, with search (all words, title or address). ⌘Y (Ctrl+H), Archive → View History (macOS) and the command bar's View History show the History tab of the Space the window shows (the one used last), else open one; an open Library makes way; from a Mini Window it shows in the latest main window. The list loads again each time its tab shows again. A row opens its page in a new tab (⌘-click or middle-click: behind); its × or its menu (Open in New Tab, Open in Background, Copy Link, Remove from History) removes every visit to the page. The page asks main only about its own tab's profile and opens only pages it listed (D53). The Library keeps its History section, the same list. Clearing the Archive leaves history alone | | AR-11 | Sidebar backups and "Restore Data" | P2 | ✅ | ✅ e2e+unit | – | Backups of the whole sidebar (Spaces, each profile’s Favorites, pinned and Today tabs, folders, split views; no private profiles) in userData/backups, one JSON file each: taken within 5 minutes of a sidebar change (only when it differs from the newest; browsing alone never makes one), once 30 s after launch, before every restore, and with Back Up Now. Kept: the newest 10 of today, then the newest of each of the 10 days before, of each week of the month before that and of each 30 days of the year before that; a deleted profile’s Favorites, Spaces and tabs are taken out of every backup. Help → Restore Data… (macOS) and the command bar open the Backups section of the Library: a dated list (day headings, time, Spaces and tabs; a file that can’t be read is listed as such, without Restore); Restore… asks first, then puts the sidebar back: tabs in both keep their page, tabs only in the backup come back asleep (with the page and history the Archive kept for them, leaving the Archive), tabs opened since go to the Archive, Spaces added since go; private windows and their tabs are left alone; Favorites over 12 go to Today (D33). When the sidebar can’t be backed up first, nothing changes. A running import stops first: none of the tabs it hasn’t shown yet go to the Archive, and its window says why (D65). Each restore is one step on the sidebar’s undo stack (TB-18): the toast’s Undo or ⌘Z, also in the Library, puts back the sidebar it replaced, a second ⌘Z goes further back and ⌘⇧Z restores again; earlier steps stay. After an undo, a window moved off a removed Space stays where it went, and the pages come back asleep | ## Keyboard shortcuts and pointer bindings (prefix KS) @@ -347,13 +347,13 @@ Spec: `spec/shortcuts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | KS-01 | One keymap, remapping and persistence | P0 | ✅ | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now | -| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | -| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving window full screen (NV-20) prefers websites by default: the page gets the first press, and pressing it again within a second or holding it leaves; Settings → Shortcuts' Exit Full Screen row sets it, and with prefer Orbit one Esc leaves at once (D68); a page's Esc gets no toast, as nothing asks the page; Esc leaving a video's own full screen keeps Chromium's rule; nothing holds Cmd+E yet | +| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 and Alt+Shift+F for full screen (NV-20); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion | | KS-04 | Command bar and address | P0 | ✅ | ✅ e2e+unit | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | | KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | | KS-06 | Switching tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05) | | KS-07 | Spaces | P0 | ✅ | ✅ e2e+unit | – | Space shortcuts wired through the action table | -| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items | +| KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06 | | KS-09 | Split view | P0 | ✅ | ✅ e2e+unit | – | ⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page | | KS-10 | Peek and Mini Window | P1 | ✅ | ✅ e2e+unit | – | ⌘O / Ctrl+O expands a Peek into a selected Today tab (PK-07); Esc and ⌘W / Ctrl+W close it (PK-09, Esc fixed); ⌘⇧T and ⌘Z reopen it (PK-10); ⌘⌥N / Ctrl+Alt+N opens a Mini Window (MW-06); in a Mini Window ⌘O moves its page into the most recent Space (MW-05), ⌘⌥O (also ⌘⇧O) / Ctrl+Alt+O offers the Spaces, ⌘W closes it (MW-09). All from the one keymap and remappable but Esc; the tests press the keys (peek, mini-window and keys specs) | | KS-11 | Media | P1 | 🟡 partial | ✅ e2e+unit | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | @@ -361,10 +361,10 @@ Spec: `spec/shortcuts.md` | KS-13 | Developer tools | P1 | 🟡 partial | ✅ e2e+unit | – | ⌥⌘I / F12 (Ctrl+Shift+I / F12) open or close DevTools (DT-01, F12 in e2e), ⌥⌘J (Ctrl+Shift+J) the console, ⌥⌘C the element picker (no default on Windows/Linux, where Ctrl+Shift+C is Copy URL), ⌥⌘U (Ctrl+U) View Source, ⌘⇧R (Ctrl+Shift+R) hard reload: each checked against the spec table per platform, and none fixed; a remapped DevTools key moves in the router and in DevTools' own page, and the picker can take ⇧⌘C from Copy URL (unit tests); ⇧⌘M / Ctrl+Shift+M stays DevTools' device toolbar key (no action has it). A remap reaches DevTools already open only once they open again (devtools lane). Not built: developer mode for a site (DT-06; its ⌃D is a plain ⌃-letter, which macOS keeps for the caret, D60) and the task manager (DT-11, Shift+Esc) | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | | KS-15 | Standard app and window keys | P1 | ✅ | ✅ e2e+unit | – | macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac | -| KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router) | +| KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone), a web component's field (in an open shadow root) included (keys fix check K1); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7) | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | 🟡 partial | ✅ e2e | – | each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click on a video offers Picture in Picture (MD-02: its tests right-click a plain video once, in the page and in a frame from another site; the second right-click the spec describes is for a site with its own video menu, which isn't tested). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09) | | KS-18 | Shortcut hints everywhere | P1 | ✅ | ✅ e2e+unit | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label | -| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e+unit | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there. Not built: leaving full screen with Esc held or pressed twice (NV-20), the print dialog (NV-07) | +| KS-19 | Keys inside overlays | P0 | ✅ | ✅ e2e+unit | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20); the print dialog: Esc closes it wherever its keyboard is, the preview included, Enter prints and Tab stays inside it (NV-07) | | KS-20 | Shortcuts in special windows | P1 | 🟡 partial | ✅ e2e+unit | – | private window: the same keys, but the actions that need Spaces, pinned tabs, Favorites, folders or the Archive (⌘D, ⌃1–9 / Alt+1–9, ⌘⌥← / ⌘⌥→, ⌘⇧K Clear, Favorite, New Folder, View Archive, every Space action) do nothing there, from a key, a menu or the command bar, and a toast says “Not available in a private window”; its sidebar has no Clear button; tab keys work. Mini Window: ⌘W closes it, ⌘O / ⌘⌥O move or offer to move its page (KS-10), ⌘L edits its address with the keyboard in the field and Esc gives the keyboard back to its page, ⌘R, zoom and print act on its page, sidebar and Space keys do nothing; ⌘Y / Ctrl+H History, the Library and Help → Keyboard Shortcuts open in the browser window behind it. Full screen: every key works with the sidebar hidden; ⌘1–9 and ⌘⌥↑ / ⌘⌥↓ switch tabs without bringing it out. Not built: ⌘F in a Mini Window (it has no find bar, Mini Window lane), the blank window (TB-17), extension keys in private windows (EX-06, EX-10) | ## Interaction feel and motion targets (prefix FL) @@ -492,7 +492,7 @@ Spec: `spec/devtools.md` | DT-01 | Open / close DevTools | P0 | ✅ | ✅ e2e+unit | – | ⌥⌘I / F12 (Ctrl+Shift+I / F12) open DevTools for the selected tab or focused split pane, docked inside its view, and close them, also from inside DevTools; a Peek's or Mini Window's page gets its own. ⌥⌘J (Ctrl+Shift+J) opens them on the Console; View → Developer menu (macOS) and the command bar. A key held down acts once. Copy, paste (after DevTools' own paste warning) and the device toolbar work; links DevTools open become tabs. Windows/Linux: while DevTools have the keyboard, only the DevTools keys reach Orbit | | DT-02 | Inspect element (context menu and picker) | P0 | ✅ | ✅ e2e+unit | – | right-click → Inspect (last item) opens DevTools with that element selected, or just selects it when they're open. Picker: ⌥⌘C on macOS, no default key on Windows/Linux, Inspect Element in the command bar. ⇧⌘C stays Copy URL; inside DevTools it is DevTools' own picker. Remap: give the devtools.inspect action ⇧⌘C (and take it from tab.copyUrl) in src/shared/actions.ts, or in Settings → Shortcuts once remapping (ST-08) lands | | DT-03 | DevTools docking, size and focus | P1 | ✅ | ✅ e2e | – | docked right/left/bottom or undocked from DevTools' dock menu; the engine keeps the side per profile across restarts. Each tab (pane, Peek, Mini Window) has its own DevTools, shown and hidden with its page; a page shown again takes the docked size last chosen. Undocked: the open key brings them to the front (checked on Linux; macOS unverified), and closes them when they have the keyboard. No windows added, however many tabs have DevTools. What Orbit puts over a page (find bar, zoom, link pill, loading indicator, permission prompt) goes over the page's own area, never over docked DevTools (e2e for the find bar and link pill, docked right, bottom and left) | -| DT-04 | View page source | P1 | – | – | – | | +| DT-04 | View page source | P1 | ✅ | ✅ e2e | – | ⌥⌘U (Ctrl+U), the command bar's View Source, the page menu's View Page Source and View → Developer → View Source (macOS menu) open the page's own address as view-source: in a new tab, shown at once: the engine's source view, read-only, coloured, with line numbers; a Peek's page opens its own source in a new tab of its window, and a Mini Window's in a main window (one showing its Space if there is one), brought to the front; a source view has no source of its own to open. Only the page's own address goes, so a web page can't make Orbit open a file: URL this way (D49) | | DT-05 | Developer mode turns on automatically for local development URLs | P1 | – | – | – | | | DT-06 | Turn Developer mode on/off for any site | P1 | – | – | – | | | DT-07 | Developer mode appearance and toolbar | P1 | – | – | – | | @@ -501,7 +501,7 @@ Spec: `spec/devtools.md` | DT-10 | Show full URL outside Developer mode | P1 | – | – | – | | | DT-11 | Task manager (per-tab memory/CPU) | P1 | – | – | – | | | DT-12 | Record a performance trace (local) | P2 | – | – | – | | -| DT-13 | Internal pages for engine-level settings | P1 | 🟡 partial | ✅ e2e+unit | – | orbit:/// opens as a normal tab when typed in the command bar or address field, or from another internal page's link; orbit://pages/ lists them; orbit://version/ shows the build info About shows (Orbit, build day, Electron, Chromium, V8, Node, OS) with Copy build info. They follow the app's state (light/dark) through the same bridge as Orbit's UI, which the one web page preload (src/preload/page.ts) gives an orbit: page's main frame only (D40, D53). A web page can't navigate, frame, redirect to, open, load or fetch them, and gets no bridge: Orbit opens one for no page but another internal page on any route (the page menu, Split View's item included, ⌥-click, new tabs, windows and pop-ups, a pop-up's own, blocked pop-ups, a drop), D53. Each page runs only the commands listed for it and sends no events, and its document runs only its own scripts (script-src 'self'), D53. Infrastructure documented for reuse (D39) in src/main/devtools/internal-pages.ts and src/renderer/src/internal/registry.ts: a page is a *.internal-page.tsx module, and lists the commands it runs in INTERNAL_PAGE_COMMANDS. Missing: the pages other lanes add as they build them (D54): extensions EX-07, site settings OT-07/ST-13, privacy and clearing data and system options ST-12, languages ST-14, about UP-07, history AR-10; orbit://pages/ lists each one added | +| DT-13 | Internal pages for engine-level settings | P1 | 🟡 partial | ✅ e2e+unit | – | orbit:/// opens as a normal tab when typed in the command bar or address field, or from another internal page's link; orbit://pages/ lists them; orbit://version/ shows the build info About shows (Orbit, build day, Electron, Chromium, V8, Node, OS) with Copy build info. orbit://history/ is History (AR-10, pages lane). They follow the app's state (light/dark) through the same bridge as Orbit's UI, which the one web page preload (src/preload/page.ts) gives an orbit: page's main frame only (D40, D53). A web page can't navigate, frame, redirect to, open, load or fetch them, and gets no bridge: Orbit opens one for no page but another internal page on any route (the page menu, Split View's item included, ⌥-click, new tabs, windows and pop-ups, a pop-up's own, blocked pop-ups, a drop), D53. Each page runs only the commands listed for it and sends no events, and its document runs only its own scripts (script-src 'self'), D53. Infrastructure documented for reuse (D39) in src/main/devtools/internal-pages.ts and src/renderer/src/internal/registry.ts: a page is a *.internal-page.tsx module, and lists the commands it runs in INTERNAL_PAGE_COMMANDS. Missing: the pages other lanes add as they build them (D54): extensions EX-07, site settings OT-07/ST-13, privacy and clearing data and system options ST-12, languages ST-14, about UP-07; orbit://pages/ lists each one added | | DT-14 | Hard reload and cache-bypassing reload | P1 | ✅ | ✅ e2e | – | ⇧⌘R (Ctrl+Shift+R) runs the navigation lane's nav.hardReload action (the e2e checks that action ran): page and subresources refetched (no-cache). A crashed page hands the keyboard to the window, so ⇧⌘R brings it back. Stopping a load (Esc in the page, double-clicking Reload) is NV-04, built with the sidebar's Reload/Stop button; its e2e tests cover both | ## Downloads and the Library @@ -542,7 +542,7 @@ Spec: `spec/settings.md` | ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | – | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | | ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | – | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | | ST-07 | Per-profile: download location, new-document app, data shortcuts | P1 | – | – | – | | -| ST-08 | Shortcuts section (remapping) | P1 | 🟡 partial | ✅ e2e+unit | – | Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device. Not built: the Esc → exit full screen row, which comes when full screen (NV-20) registers that as an action | +| ST-08 | Shortcuts section (remapping) | P1 | 🟡 partial | ✅ e2e+unit | – | Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device; Exit Full Screen (Esc in full screen, NV-20) has its row with no keys of its own, preferring websites by default, and its priority sets Esc's | | ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | – | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | | ST-10 | Advanced section | P1 | 🟡 partial | ✅ e2e+unit | – | tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet | | ST-11 | Appearance section | P1 | 🟡 partial | ✅ e2e | – | Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c76fc44c..27d9ff59 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,10 +16,10 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) - **Main process** (`src/main`) owns all state, all web contents, sessions, windows, menus, shortcuts and persistence. - **Shell renderer** (`src/renderer`, `?view=shell`) draws the sidebar and an empty rounded "frame". It measures the frame (and split panes) and reports pane rectangles to main (`layout.panes` event). Main then places the native tab views exactly over them. Pages are never iframes. - **Overlay renderer** (`?view=overlay`) is a transparent full-window view above the tabs. It's shown only while something floats (command bar, Peek card chrome, menus). Clicking its backdrop closes it. -- **Views over the pages**, bottom to top: the tab views; the page's small views (page tools, permission prompt, loading indicator, link pill: `WindowManager.createUiView` and kin); a page cover while one is open (the Library, `WindowManager.setPageCover`), which those small views go under; then a Peek, the floating sidebar card and the overlay. +- **Views over the pages**, bottom to top: the tab views; the page's small views (page tools, permission prompt, loading indicator, link pill, a page's dialog or its error or crash screen: `WindowManager.createUiView` and kin); a page cover while one is open (the Library, `WindowManager.setPageCover`), which those small views go under; then a Peek, the floating sidebar card and the overlay. - **Other renderer views**: `mini` (Mini Window chrome) and `settings` (settings window). They come from the same bundle and are selected by the `view` query. -- **Preload** (`src/preload/shell.ts`) runs sandboxed with context isolation. It exposes `window.orbit` (`OrbitBridge` in `src/shared/rpc.ts`) only to Orbit UI contents, and its `orbit:*` IPC handlers reject any sender that isn't registered Orbit UI. Web pages get one preload of their own, `src/preload/page.ts` (NV-15), on D40's terms. It runs in main frames only and adds nothing to page globals: it patches only the built-ins NV-15 needs (`Notification.permission`, `Notification.requestPermission`, the `Notification` constructor, with its prototype's `constructor` pointing back at it, and `PermissionStatus.prototype.state`), so a permission the user hasn't decided reads "default"/"prompt" as in Chromium and a click on one of the page's notifications reaches main, and those MD-11's sharing indicator needs (`getDisplayMedia`, `MediaStreamTrack` `stop` and `clone`; D69). It talks to main only over its own `orbit-page:*` channels, separate from the UI's `orbit:*` channels, and main takes the origin and profile from `event.senderFrame`, never from the message. -- **Internal pages** (`orbit:///`, DT-13) are Orbit UI in a normal tab, served from the renderer bundle by a protocol handler on each profile session. The web page preload (`src/preload/page.ts`) gives an `orbit:` page's main frame, and nothing else, the same bridge on channels of its own (`PAGE_CHANNEL.internal*`), which main answers only for such frames, and only with the commands listed for the page (`INTERNAL_PAGE_COMMANDS`; no events), D53. Their documents run only their own scripts (`script-src 'self'`). Web content can't navigate, frame, redirect to, open, load or fetch them. How they work and how to add a page: `src/main/devtools/internal-pages.ts`. +- **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; those MD-11's sharing indicator needs (`getDisplayMedia`, `MediaStreamTrack` `stop` and `clone`; D69); and those a page's dialogs and print need (`alert`, `confirm`, `prompt`, `print` and `close`; NV-24, NV-07, D76: below). It talks to main only over its own `orbit-page:*` channels, separate from the UI's `orbit:*` channels, and main takes the origin and profile from `event.senderFrame`, never from the message. +- **Internal pages** (`orbit:///`, DT-13) are Orbit UI in a normal tab, served from the renderer bundle by a protocol handler on each profile session. The web page preload (`src/preload/page.ts`) gives an `orbit:` page's main frame, and nothing else, the same bridge on channels of its own (`PAGE_CHANNEL.internal*`), which main answers only for such frames, and only with the commands listed for the page (`INTERNAL_PAGE_COMMANDS`; no events), D53. Their documents run only their own scripts (`script-src 'self'`). Web content can't navigate, frame, redirect to, open, load or fetch them. How they work and how to add a page: `src/main/devtools/internal-pages.ts`. History (AR-10) is one: `orbit://history/` shows the Library's History section; its commands (`pages.history*`, `src/main/pages/history.ts`) take the profile from the page's own tab and open only pages it listed, and main tells it, on the same channel, what its menu did and when its tab shows again. ## 2. State: one store, patches out @@ -55,6 +55,7 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) Electron runs no isolated-world script while a main frame loads, so a page on its way can't answer; Orbit takes the keys at once then. - **Focus mirror** (`page-focus.ts`, D60). Main knows before a press where the keyboard is in each tab's page: a script in the page's own world says whether a text field, the page, a frame inside it, or nothing there has it, and says it again when focus moves. The frame inside the page that has the keyboard (`contents.focusedFrame`) is followed too, and notes the presses Orbit may ask about. So Back and Forward run at once outside text fields, however busy the page, and a press in a frame is judged by that frame. - **Esc on a loading page** (KS-19, D59, `escape.ts`): the page gets Esc first. The page preload says whether a handler used it, and Orbit stops the load only if none did. +- **Esc in full screen** (NV-20, KS-02, `src/main/pages/fullscreen.ts`). Esc is no binding: an open overlay must take it first (KS-19). The pages lane hears each Esc before the rest of main does (`prependListener` on each tab page's `before-input-event`, and on its error and crash screens) and follows the priority of the hidden Exit Full Screen action. With prefer websites (the default) the page gets the press; a second press within `AGAIN_MS`, or the first repeat of a held one, leaves full screen and is kept from the page (`preventDefault`), which the Esc-stops-a-load hook (`src/main/nav/index.ts`) reads as "not the page's". With prefer Orbit the first press leaves. Nothing asks the page what it did with the press: a single Esc never leaves full screen. - **No web contents takes the keyboard on navigation** (`KEEP_KEYBOARD`, `focus.ts`, KS-16). Electron focuses a web contents each time its page commits a navigation unless `focusOnNavigation` is off, even a page behind another tab. Every web contents Orbit makes for its windows spreads `KEEP_KEYBOARD` into its `webPreferences`: tab views, Orbit's own views, and pop-ups through `overrideBrowserWindowOptions`. Orbit gives a page the keyboard itself when the user selects, opens or clicks it. A lane that makes or shows a page does the same. ## 4. Tabs: lifecycle, sleeping, layout @@ -145,9 +146,19 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:notification-click` (NV-15): a trusted click on one of the page's notifications. Main brings the tab forward only for a site allowed to show notifications, and at most once per `NOTIFICATION_CLICK_GAP_MS` per tab. - `orbit-page:internal-*` (DT-13, D53): Orbit's internal pages only. In the main frame of an `orbit:` page, which is Orbit's own UI, the preload patches nothing and gives `window.orbit`: the store's state and changes, and the commands listed for that page (`INTERNAL_PAGE_COMMANDS`), with no events. Main answers only a tab's main frame showing an `orbit:` page. In a web page it does nothing. - `orbit-page:escape` (KS-19, D59): one bit after a trusted Esc reaches the main frame, sent once every handler of the page has run: whether one called `preventDefault`. The listener is in the preload's own world. Main reads the bit only while it waits after an Esc in a loading tab with no overlay open (at most 250 ms; no answer counts as not used), only from that tab's main frame, and only for a press still waiting: a bit with none waiting does nothing. A page that always uses Esc can keep Esc from stopping its load; the Stop button still stops it. +- `orbit-page:dialog` (NV-24): the main frame's `alert`, `confirm` and `prompt`, patched (D40), send a synchronous request: the kind, the page's text and a prompt's starting text. Main shows Orbit's dialog over that page only (a tab's, a Peek's, a Mini Window's or a pop-up's) and replies with what the call returns once the user answers, or Cancel's answer when the dialog goes (the page navigates or closes). The text is shown as text, cut to 10,000 characters. Main answers a frame that isn't a main frame, or a page with no place for a dialog, at once with Cancel's answer. In the page's `beforeunload`, `pagehide` or `unload` handlers the calls return at once, as the engine's do: the patch reads the event being handled through the engine's own getters. The preload adds no `beforeunload` or `unload` listener of its own: with one, the engine would run every page's `beforeunload` before each navigation (a busy page would hold up Back, D60), and keep every page out of the back-forward cache. Dialogs a frame asks for aren't patched (D40: main frames only): Electron shows `alert` and `confirm` in its own message box, and a frame's `prompt` returns null. +- `orbit-page:form-sent` (NV-13): the main frame is about to send a form (POST), from the Navigation API's `navigate` event in the preload's own world: the address it goes to. Main reads it only from a tab's or a pop-up's main frame, and it counts only for that frame's next navigation, to that address: Orbit's time limit for a server's first answer leaves that navigation alone, since it may be a large upload. A page that says so falsely only keeps the time limit off its own load. +- `orbit-page:swipe` (NV-02): Electron reports no scroll phases to main, so a two-finger swipe over the page is told from the page's own wheel events. A passive listener in the preload's own world takes trusted pixel-delta wheel events in the main frame, after the page's handlers have run, and recognises a horizontal swipe (`src/preload/swipe.ts`). Unless something under the pointer can still scroll that way or the page called `preventDefault`, it sends how far the swipe has gone (`move`, a direction and px) and whether it was let go with the arrow fully in (`end`); nothing else. Main takes it only from a main frame of a page in a window's page card, clamps the travel, shows the arrow only when that page can go that way, and goes back or forward as the Back button does: no more than the page could do itself (`history.back()`). A new use under D40 (4), for the lead to log. +- `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the dialog the page's own `print()` opened is open now; main answers at once and waits on nothing, and nothing else goes with it. A `print()` while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76). Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. While the dialog the page asked for is open, what would take the page away waits, as it would behind the engine's `print()`, which returns only once its dialog has closed: the page's `window.close()`, patched (D40) with the built-ins it calls taken before the page runs, which waits only while that dialog is open (D76), and a navigation the page starts that the Navigation API lets the preload stop, from its own world, and start again as it was (a link, `location`, a form that gets its page, a reload). When the dialog closes, main tells the page whose `print()` opened it, and only that page, on `orbit-page:print-closed` (main → page, with nothing in it, D76), and the close goes on, or else the page's last such navigation starts again. A form the page posts and a move through history go at once, and take the dialog with them. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. - `orbit-page:screen-share` (MD-11, TB-15, D69): the count of the main frame's live shared-screen tracks, sent when it changes. The preload wraps `getDisplayMedia` and `MediaStreamTrack` `stop` and `clone` to follow the tracks that call returns. Main takes the count only from a tab's main frame (same process and routing id as `mainFrame`), clamps it to 0…1000, and uses it only for that tab's sharing indicators (the row, and the recent-tab switcher once it shows one): nothing that grants, refuses, sleeps or archives reads it (`TabMedia.screen` vs `screenGranted`). It sees the shares the system's own picker grants without asking Orbit (macOS 15 and later). Known limit: neither this nor main's watcher (below) holds against a page that wants to hide its share; it can clone a track with another realm's built-ins, such as a same-origin frame's, and stop the one Orbit follows. The OS's own capture indicator is the backstop where the OS has one. -Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. +A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog or a screen takes the keyboard when it shows over the page the user is on, but only where the page had it, never from Orbit's own fields (the find bar, a sidebar field, the address field), so a page can't time a dialog to catch keys meant for Orbit (D72); it takes the keyboard once the user clicks it or the keyboard next goes to the page. A page's dialog and the print dialog stay above the window's page tools (the find bar) in a tab and over a Peek's page alike (`WindowManager.keepAboveTools`, PK-11), so the find bar keeps the keyboard it has under a dialog it can't cover; error and crash screens aren't kept so, and the find bar can show over them. While a cover is on screen, its window counts as showing a dialog for the floating player (`withDialog`, `src/main/media/dialog-watch.ts`, MD-06), which keeps clear of it; so do Save Page As… and Save as PDF's save dialogs. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. + +Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its `print()` opens the system's dialog, so Orbit shows its own dialog as a cover over the page. The preview is the engine's PDF of the page (`printToPDF`, which runs the page's `beforeprint` and `afterprint` and leaves the page as it was), shown in the engine's PDF viewer in a frame of the cover's view (a `blob:` URL: the renderer's CSP allows `blob:` frames for it). One function sends a job out (`deliverPrint`: `print()` to the printer chosen, without the system's dialog, or the PDF saved where the user says); test runs replace it with a recorder, and take their printers from `ORBIT_E2E_PRINTERS`. Callers outside the lane (the Print action for a tab, a Peek or a Mini Window, the page menu) open the dialog through `src/main/pages/entry.ts`, as they open a page's source (DT-04, `view-source.ts`: the page's own address as `view-source:` in a new tab; a Mini Window's in a main window). + +Links to other apps (NV-19): the engine never launches another app itself. Its `openExternal` request is refused (`src/main/nav/permissions.ts`) and handed to the pages lane (`src/main/pages/other-apps.ts`), which asks over the page that followed the link, naming the app and the site of the frame that followed it, and launches the app through one function (`launchInApp`: `shell.openExternal`, the link escaped as Chromium escapes it); test runs replace that function with a recorder. Schemes Orbit or the engine loads itself, and the ones Chromium never hands to the OS (`shell:`, `vbscript:`, `ms-msdt:`…), are never launched. "Always allow" is kept per profile, web origin and scheme in `orbit-app-links.json`; a private profile's stays in memory. An always-allowed link opens the app once per user gesture in its page (a press or a key in any of its frames: the page's gesture count, below), as in Chromium: each link to another app uses up the page's gestures so far, so a page can't open the app again and again unasked. Links to other apps keep their own mark of what they used, not the tab runtime's `consumeGesture`: that one a `target=_blank` link to the app has already used to open its tab, and the engine names the opener as the page that asks. Once the page has asked, whatever the answer, Open or Cancel, it asks again only with a new gesture, as in Chromium, so it can't keep asking (D77). + +Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. The pages lane asks the preload's own world (999) about the leave-page warning (NV-24): what kind of navigation a warning stopped (the page's last one, from the Navigation API's `navigate` event, if it started within 2 s), and to start that navigation again on Leave. The preload keeps these in a frozen `__orbitLeave` in its own world; the page's world gets nothing. What they say is the page's own word: main checks each answer's shape, and an answer can only change which of that page's own navigations "Leave site?" names and starts again. Whether a page warns is never the preload's word: closing a tab, a window or a Space, auto-archive and sleep close a page that a user gesture has reached through the engine (`webContents.close({ waitForBeforeUnload: true })`, D73), which runs every frame's `beforeunload` only once the close has started and says when one warns (`will-prevent-unload`); the page then stays, and Orbit asks (auto-archive and sleep keep it without asking). A page no gesture has reached can't warn (Chromium's rule), and closes at once. A page's gestures are one count fed by all its frames (`src/main/pages/gestures.ts`, D77), which the leave-page warning, links to other apps (NV-19) and the tab runtime's pop-up rule (NV-09) read: a mouse press, from `before-mouse-event`, which sees every frame's (`input-event` sees only the main frame's widget, so never a press in a frame from another site, which has a process of its own), and a key that reaches the page, from `before-input-event`, but not one Orbit takes as its own shortcut, so ⌘W never counts for the page it closes. Two limits (D77): the engine's close notifies the main frame of a user activation before it runs `beforeunload`, so a page that auto-archive or sleep keeps holds one; and ⌘W on a page that has had a gesture and has a `beforeunload` listener, while its script is busy, waits for the engine's answer, up to the engine's 1 s limit, before its tab goes. Media in frames (embedded players, MD-01, MD-09; D67's trade-off): a frame that has media elements, in a tab that plays media, gets media's script (`src/main/media/agent.ts`) in the frame's own world, through `webFrameMain` (Electron runs scripts in a frame only there). It sits on a hidden, read-only property with a name drawn at random for each run. The frame's page can reach it, so its reports are the page's own word: main checks them (`parseSnapshot`) and they can only change that tab's media: what its controller shows and which of its videos floats. Back in its place, a frame's page learns its new size only when its parent lays it out, after that frame's events, so its `resize` comes a frame late. While its video, or a frame on the way, is lifted, the frame's script also watches its root element and a hidden element as big as its viewport with a ResizeObserver, which runs after layout and before the frame is drawn, and puts the video back there (MD-01: no blank frame, whenever main's own call comes). Known limit: a page in a frame that observes its own root element (or its body, when only the frame's height changes) gets the engine's "ResizeObserver loop" error event as its video goes back. Media's script, in a frame or the tab's page, counts a `leavepictureinpicture` or `ended` only when the event is trusted (D71). A frame's "Back to tab" is a request main may refuse: it brings a window forward or switches Space only for a picture-in-picture Orbit opened in that document (the Picture in Picture action, the controller or the page menu), once per such picture-in-picture. Otherwise it selects the tab only in a window that already has focus, in the Space that window shows, and once for each frame document (D74). Orbit's mark lasts only as long as its picture-in-picture: main clears a tab's marks when it closes that picture-in-picture or hands it to its player, and every other document's mark when it opens one, since the engine shows one at a time (D74). In any document a leave counts as "Back to tab" only if no picture-in-picture is left there after the script's 300 ms wait, so a page that moves its picture-in-picture to another of its videos gets nothing (D74). The engine shows one picture-in-picture at a time and closes the one showing when another opens, in a page or by Orbit: a leave counts as "Back to tab" only while no other document's latest report shows the engine's picture-in-picture, and before Orbit opens one (the action, the controller, the page menu) it ends any other with its own exit, so that document reports it as Orbit's (D75). Known limits: a page's own PiP button inside a frame returns to the tab without raising the window, and a page can end its own picture-in-picture once with `exitPictureInPicture()` and bring the window forward, as the engine's button does (D74). A page that opens a picture-in-picture and ends it again within the script's 300 ms wait leaves the one it replaced counting as "Back to tab" (D75). diff --git a/docs/feature-status.json b/docs/feature-status.json index b1781876..63f768ea 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -11,7 +11,7 @@ "SB-12": {"built": "yes", "note": "left 6 px edge brings a floating card in its own view over the page, which never moves; main decides whether the pointer moved (not from where it was when the sidebar hid, so Cmd+S at the edge doesn't) and reads it for 300 ms after it leaves a page or the shell, so a flick to the edge (or onto the Linux resize border) brings the card; 0 ms dwell; stays while hovered (16 px band), while a field in it is edited, the command bar is open or a native menu is up in the window (one Chromium refuses counts as closed); goes 400 ms after the pointer moves on, at once on a click into the page (the click reaches the page); not over full-screen video; stays out while the pointer is on another window over it, until it comes back (review A-F1)"}, "SB-14": {"built": "yes", "note": "drag the edge 1:1 with a live width, clamped 180-420 with no drift; double-click resets to 248; 40 px past the minimum hides (width kept); saved per window"}, "SB-19": {"built": "yes", "note": "8 px margins (6 px with the sidebar hidden), 10 px corners on every page and pane while loading, hidden or floating; fine edge and shadow; native and video full screen drop the frame; the card and the page view take the page background colour so resizes show no white"}, - "SB-20": {"built": "partial", "note": "macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; the rest of the hidden-sidebar chrome is not built"}, + "SB-20": {"built": "partial", "note": "macOS: traffic lights hidden with the sidebar, shown (moved in) with the floating card; native full screen hides the sidebar with hover reveal, ⌘N there opens a full-screen window, and Esc in a page goes to the page first, held or pressed twice leaving full screen (NV-20); the rest of the hidden-sidebar chrome is not built"}, "SB-21": {"built": "yes", "note": "an inactive window's sidebar (and its floating card) fades to 84% over 150 ms on blur and is back at once on focus; no fade with reduced motion"}, "SB-22": {"built": "yes", "note": "Cmd/Ctrl+Shift+E focuses the sidebar; arrows, Enter, Escape, F2"}, "SB-23": {"built": "yes", "note": "selected tab scrolled into view (smooth, then settles); pill points to it while it is out of view; rows use content-visibility"}, @@ -67,7 +67,7 @@ "AR-07": {"built": "yes", "note": "Clear Archive… from the Archive entry’s right-click menu in the Library, Archive → Clear Archive… (macOS app menu) and the command bar; always asks in the Library first (Cancel has the focus, Esc cancels), says it can’t be undone and that browsing history is kept; clears the profile shown, and the cleared pages leave the disk at once (the log is rewritten). Delete (a row’s menu, or Delete for the selected rows) shows a toast whose Undo puts the entries back with their history for a minute"}, "AR-08": {"built": "yes", "note": "settings.archiveKeepDays, default 30; 30 Days / 90 Days / 1 Year / Forever from the Archive entry’s menu in the Library and Archive → Keep Archived Tabs For (macOS) until Settings exist. A shorter time that would delete entries asks first, in the Library, saying how many (Cancel keeps them all and the time as it was). Purged once the Archive is read at launch, hourly, and at once when a shorter time is confirmed; browsing history is not touched"}, "AR-09": {"built": "yes", "note": "⌘⇧T and ⌘Z walk back through closes with full history (TB-07, TB-18); ⌘Z also shows the tab again when it was the one shown; a closed Peek comes back through the same key (PK-10, peek lane). A page closed in the last 10 minutes keeps its history rank in the command bar (as the archived row). macOS File menu: Reopen Closed Tab and a Recently Closed submenu of the last 10; on every platform the Archive toolbar has a Recently Closed menu. Neither list shows a tab of a private or deleted profile"}, - "AR-10": {"built": "partial", "note": "History stays a Library section until Orbit has internal pages (D39), not a page in a tab. ⌘Y (Ctrl+H), Archive → View History (macOS) and the command bar open it next to the Archive: every visit of the current profile from the history store, newest first by day, virtualised and paged, with search (all words, title or address). A row opens its page in a new tab (⌘-click or middle-click: behind the Library); its × or its menu (Open in New Tab, Open in Background, Copy Link, Remove from History) removes every visit to the page. Clearing the Archive leaves it alone"}, + "AR-10": {"built": "yes", "note": "History is a page in a tab, as D39 planned once internal pages existed: orbit://history/ shows every visit of the tab's profile from the history store, newest first by day, virtualised and paged, with search (all words, title or address). ⌘Y (Ctrl+H), Archive → View History (macOS) and the command bar's View History show the History tab of the Space the window shows (the one used last), else open one; an open Library makes way; from a Mini Window it shows in the latest main window. The list loads again each time its tab shows again. A row opens its page in a new tab (⌘-click or middle-click: behind); its × or its menu (Open in New Tab, Open in Background, Copy Link, Remove from History) removes every visit to the page. The page asks main only about its own tab's profile and opens only pages it listed (D53). The Library keeps its History section, the same list. Clearing the Archive leaves history alone"}, "AR-11": {"built": "yes", "note": "Backups of the whole sidebar (Spaces, each profile’s Favorites, pinned and Today tabs, folders, split views; no private profiles) in userData/backups, one JSON file each: taken within 5 minutes of a sidebar change (only when it differs from the newest; browsing alone never makes one), once 30 s after launch, before every restore, and with Back Up Now. Kept: the newest 10 of today, then the newest of each of the 10 days before, of each week of the month before that and of each 30 days of the year before that; a deleted profile’s Favorites, Spaces and tabs are taken out of every backup. Help → Restore Data… (macOS) and the command bar open the Backups section of the Library: a dated list (day headings, time, Spaces and tabs; a file that can’t be read is listed as such, without Restore); Restore… asks first, then puts the sidebar back: tabs in both keep their page, tabs only in the backup come back asleep (with the page and history the Archive kept for them, leaving the Archive), tabs opened since go to the Archive, Spaces added since go; private windows and their tabs are left alone; Favorites over 12 go to Today (D33). When the sidebar can’t be backed up first, nothing changes. A running import stops first: none of the tabs it hasn’t shown yet go to the Archive, and its window says why (D65). Each restore is one step on the sidebar’s undo stack (TB-18): the toast’s Undo or ⌘Z, also in the Library, puts back the sidebar it replaced, a second ⌘Z goes further back and ⌘⇧Z restores again; earlier steps stay. After an undo, a window moved off a removed Space stays where it went, and the pages come back asleep"}, "FD-01": {"built": "yes", "note": "folders only in the pinned area (tree.canMoveTo)"}, "FD-02": {"built": "yes", "note": "New Folder at end of pinned in rename mode; New Folder with selected tabs"}, @@ -138,13 +138,13 @@ "CB-23": {"built": "yes", "note": "split pane: the bar that fills a new pane (⌃⇧=, Add Split View) takes ⌘W, ⌘T, ⌘L and Esc first: each closes it and the empty pane goes; a URL or an open tab fills the pane; ⌘L on a pane edits that pane (SV-02, SV-15). Private (non-persistent) profiles: no history or archive rows, and their tabs, folders and Spaces never show in normal windows. Mini Window: the blank window's lookup bar and the address field over its page (⌘L or a click on the host) are the bar's compact variant: the same ranking, inline completion, site search and engine suggestions, from its profile's history and the typed text, with no open tabs, archive, Spaces, folders, actions or Settings sections (they act on the browser); a choice loads in that Mini Window. Over a page the list covers a still image of the page (the page's own view steps aside until the bar closes); ⌘W and ⌘L close the bar first, Esc too, and Enter with nothing to load gives the page the keyboard; ⌘T opens the main window's bar. ⌘L on an empty Space acts like ⌘T"}, "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, - "KS-19": {"built": "partial", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there. Not built: leaving full screen with Esc held or pressed twice (NV-20), the print dialog (NV-07)"}, - "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router)"}, + "KS-19": {"built": "yes", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there; in window full screen Esc goes to the page first, and held or pressed twice leaves full screen (NV-20); the print dialog: Esc closes it wherever its keyboard is, the preview included, Enter prints and Tab stays inside it (NV-07)"}, + "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone), a web component's field (in an open shadow root) included (keys fix check K1); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router). Known limits: with the keyboard in a frame of a loading page, Esc stops the load after 250 ms, as only the main frame says whether it used Esc (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is: Back and Forward then run at once, even in a field that uses them, and a prefer-websites key acts after 250 ms (K7)"}, "KS-17": {"built": "partial", "note": "each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click on a video offers Picture in Picture (MD-02: its tests right-click a plain video once, in the page and in a frame from another site; the second right-click the spec describes is for a site with its own video menu, which isn't tested). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09)"}, "KS-01": {"built": "yes", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now"}, - "KS-02": {"built": "partial", "note": "who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, + "KS-02": {"built": "partial", "note": "who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving window full screen (NV-20) prefers websites by default: the page gets the first press, and pressing it again within a second or holding it leaves; Settings → Shortcuts' Exit Full Screen row sets it, and with prefer Orbit one Esc leaves at once (D68); a page's Esc gets no toast, as nothing asks the page; Esc leaving a video's own full screen keeps Chromium's rule; nothing holds Cmd+E yet"}, "KS-18": {"built": "yes", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label"}, - "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion"}, + "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 and Alt+Shift+F for full screen (NV-20); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion"}, "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, "KS-06": {"built": "yes", "note": "⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05)"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade; the rubber band keeps the spring FL-13 names (response 0.3 s, ratio 1.0) and comes back within 0.5 px about 480 ms after the last event: FL-29's 350 ms is for swipes that land on a Space (D70)"}, @@ -188,32 +188,40 @@ "PR-08": {"built": "partial", "note": "profiles.delete refuses Default and profiles in use, erases the profile's Favorites and clears its stored data; the partition folder stays on disk and the Settings button is the settings lane's"}, "PR-09": {"built": "partial", "note": "profiles.rename command; the Settings UI is the settings lane's"}, "NV-01": {"built": "yes", "note": "buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert"}, + "NV-02": {"built": "yes", "note": "a horizontal two-finger swipe over the page goes back or forward: an arrow slides in from that edge of the page's card, following the fingers 1:1, fills once it's fully in (112 px), and letting go then goes as Back and Forward do; let go sooner, it fades and nothing happens. Only with somewhere to go, and on macOS only with the system's Swipe between pages on. The page gets the swipe first: while what's under the pointer (or around it) can still scroll that way, or the page uses the wheel (preventDefault), the swipe is the page's until the fingers lift, so it navigates only at the scroll edge. Over the sidebar the same swipe switches Space (SP-08) and never navigates. Not offered over a Peek's, a Mini Window's or a pop-up's page, where the swipe does nothing (the spec doesn't say). Windows and Linux: wherever the touchpad scrolls smoothly (pixel deltas); a mouse wheel's notches never navigate. Recognition follows the Space swipe (FL-13): 9 px decide the axis, and the fingers have lifted after a 90 ms pause or when the OS momentum starts. Limits: Electron reports no scroll phases, so the page preload watches the page's wheel events in its own world (D40 use orbit-page:swipe); the swipe goes by the scroll's direction, so with macOS's natural scrolling off a swipe right is forward; a swipe that starts over a frame of the page stays the frame's (frames get no preload); the arrow's small view takes the wheel if the pointer is right over it. Tests use synthetic trackpad scrolls; the real gesture, with momentum, is unverified on macOS (person-check)"}, "NV-03": {"built": "yes", "note": "Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page"}, - "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes"}, - "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; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03)"}, + "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes. Known limit (keys fix check K6): a page's answer about an Esc that comes after Orbit stopped waiting for it (250 ms) answers the next Esc pressed while that page loads"}, + "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character on the keyboard's own layout); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; with a Peek open the bar finds in the Peek, over its card (PK-11); a Mini Window finds with a field in its top bar (MW-03). Known limits (chrome fix check 4, N3, by reading): keys held while a replay of held keys runs past 5 s (each held Backspace, Enter or Escape waits up to 1 s for the bar) are dropped, and keys typed after that can reach the bar ahead of the rest of the replay; keys held after a held Escape are dropped, where a ready bar would have let them reach the page once it closed; and a held character an auto-type tool maps onto the keyboard on the fly, which Chromium names \"\\u0000\", is lost (chrome fix check 4)"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, + "NV-07": {"built": "partial", "note": "⌘P (Ctrl+P), File → Print… (macOS menu), the command bar's Print…, the page menu's Print…, ⌘P in a Peek or a Mini Window, and the page's own window.print() open Orbit's print dialog over that page only (Electron has no Chromium print preview): the page as it prints (the engine's PDF of it in the PDF viewer, made again when the layout changes) beside Destination (Save as PDF and the OS's printers), Pages (all, or ranges such as 1-5, 8, 11-13, with Chromium's messages for pages it can't print), Copies, Layout, Color, Headers and footers, Background graphics, and Print (Save for a PDF, which asks where) and Cancel; Esc closes it wherever its keyboard is (KS-19), Enter prints, Tab stays inside it, and the page gets the keyboard back (KS-16); it waits while its tab isn't shown and comes back with the keyboard, and goes when the page navigates; the page is left as it was (scroll, focus, fields); the next dialog starts from the last one's choices until Orbit quits (all pages and one copy each time). Differences: window.print() returns at once and the page's script goes on (Chromium's waits for the dialog), but what would take the page away waits for the dialog: its window.close() (a receipt written into a pop-up or a new tab, printed and closed at once) and a link, location or reload it starts go on once the dialog closes, as they would have (the same address and kind of history entry); a print() while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76); missing: a form the page posts, or a move through history (history.back(), go()), right after print() goes at once and takes the dialog with it (the engine doesn't let a page's history move be stopped, and posting a form again would run the page's submit handlers twice); a frame's own window.print() opens the system's print dialog (frames have no preload, D40); missing: paper size, margins, scale, pages per sheet and two-sided choices (the preview and PDF are A4, or Letter where the region uses it; a printer uses its own paper); a page may call print() again as soon as its dialog closes. Tests print and save nothing (the one function that sends a job out records it, and the printers come from ORBIT_E2E_PRINTERS); only a person can check that a real printer prints what the preview shows, and Save as PDF's save dialog"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links"}, - "NV-09": {"built": "yes", "note": "target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there, and a toast names the file (D49); a tab that only downloads closes and reselects its opener"}, + "NV-09": {"built": "yes", "note": "target=_blank and window.open without size: selected tab at the top of Today with its opener; with size: a small window without sidebar titled with the host (Not secure marked), the opener can close it; pop-ups without a click blocked, shown in the address display with Open and Always Allow for the site; a web page, or a local page with a frame of another origin (web or opaque), gets no tab, window or pop-up of a local file, nor one offered there (D49); a toast names the file only where Orbit itself refuses it, for a local page with a frame of another origin: the engine refuses a web page's before Orbit's handler runs, with no toast; a tab that only downloads closes and reselects its opener"}, "NV-10": {"built": "partial", "note": "Cmd/Ctrl+N (SP-24): same Spaces and pinned tabs, cascaded, command bar open, full screen follows; links opened in new windows (Shift-click, link menu) cascade too; the Ctrl+Cmd+N blank window (TB-17) is not built"}, "NV-11": {"built": "yes", "note": "Cmd/Ctrl+Shift+N, File menu, macOS Dock menu, Windows taskbar task, orbit --private-window (with links, they open in it and never in a Mini Window): in-memory session shared by open private windows, wiped when the last closes; no state, history, favicon, archive, site-choice or reopen record kept; own Space-less sidebar, dark look, Private label with a plain note; command bar sees only private tabs; extension rules are EX-10"}, "NV-12": {"built": "yes", "note": "native menu: page (Back, Forward, Reload, Save Page As: complete for a local page, else as a single file or its HTML alone, so no local file a web page or web frame names is copied, D49; Print, View Page Source), link (New Tab, which toasts Opened in a new tab while the sidebar is hidden; Split View, New Window, Private Window, Copy, Save Link As; a local file link or image on a page that is not a local file keeps only its Copy items, D49; a script, page source or data link has no open items), selection (Copy, Search engine, Look Up on macOS), image, video and audio (Loop and Show Controls, ticked as the element is, greyed where the engine cannot: an audio player keeps its controls; then the media lane Picture in Picture on a video), editable (suggestions, Add to Dictionary, edit roles, Paste and Match Style, Writing Direction), Inspect last; Peek and Mini Window items come from their lanes; media items in a frame inside the page act on the frame element playing the same source; P2 Translate and Share Quote not built"}, - "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13"}, + "NV-13": {"built": "yes", "note": "a main-frame load that fails shows Orbit's screen over that page's area only (a tab, a split pane, a Peek, a Mini Window, a pop-up): an icon, a title, one plain sentence in Chromium's words, the site, the engine's error code and Try again, which has the keyboard when the page had it (never taken from the sidebar or the command bar); it is a view over the engine's own error page rather than an orbit: page (D39), so the address keeps the failed URL and ⌘R, Try again, Back and history stay the engine's; a host that doesn't resolve, a refused connection, no internet, an empty response and other net errors each say what happened; the screen stays through a retry that fails again; the row keeps its title and icon when the site that failed is the one it showed, and otherwise shows that site's name (and its icon if Orbit knows one); Orbit gives up on a server that hasn't answered within 120 s (Chromium waits until the user stops it): \"took too long to respond\", the address stays where it was going, Try again and ⌘R ask again, and Back shows the page before as it was, its screen included; a form being sent is never stopped (it may be uploading); certificates: \"Your connection is not private\" with Back to safety on the keyboard (back, or closing a tab with nowhere to go back to), Proceed only behind Advanced and never for a revoked certificate, remembered for that certificate and host per profile until Orbit quits; offline: when the network comes back while the screen shows, the page loads again, once (e2e dispatch the engine's online event, as the emulation is per page); a site that asks for HTTPS only (HSTS) still gets Proceed: Electron doesn't say which sites do; gap, as DL-12's (D54): no Safe Browsing interstitial, which needs a reputation service none is free to use; the http not-secure icon is SB-04"}, + "NV-14": {"built": "yes", "note": "a page that crashes or is killed shows \"This page crashed\" over its area, with the reason (out of memory, killed…) and Reload, which has the keyboard when the page had it; Reload, ⌘R and ⌘⇧R bring the page back, and closing the tab works as ever; the row keeps its title and icon; a page that crashed behind another shows its crash screen when its row is selected, until the user reloads it (it no longer reloads by itself when shown: the review F11 test in core.spec.ts now reloads it); the same screen shows over a Peek's, a Mini Window's or a pop-up's page (e2e cover tabs); a page that stops responding (the engine's own signal, after about 15 s of unanswered input) gets \"Page unresponsive\" over it with Wait (Enter, Esc) or Close page, which ends the page's process: its screen then says \"This page was closed\" with Reload, and other tabs that share that process end too, as in Chromium; the dialog goes by itself when the page answers again; after Wait, a page that stays hung isn't asked about again until it answers and hangs anew (the engine says it once); e2e: the engine says nothing of a hang while a debugger is attached, and Playwright attaches to every page, so the hang tests keep a page really busy and emit the engine's signal; TB-16 (busy background tabs paused) and EX-13 (crashing extensions) are their own rows"}, + "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); screen sharing asks only in the screen chooser, never first as camera and microphone (MD-11); a prompt whose view crashed leaves the window, and the keyboard with the page, until its page has loaded again, then shows again (chrome fix check 4, N4); the recording indicators are TB-15's; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, automatic Picture in Picture for the site (MD-08), zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, + "NV-19": {"built": "yes", "note": "a link to another app (mailto:, tel:, zoommtg:…) asks first, in a dialog over the page that followed it (a tab's, a split pane's, a Peek's, a Mini Window's or a pop-up's): \"Open Mail?\" with the app's icon where the OS gives one (macOS, Windows; Linux names the app from its desktop entry and gives no icon), \" wants to open this application.\" naming the site of the frame that followed it, and \"Always allow to open links of this type in the associated app\" (web sites only; kept per profile, site and kind of link, a private window's in memory only; such a link opens the app without asking once per user gesture in the page, as in Chromium: a page that follows it again before the next click or key in any of its frames opens nothing); Cancel is the default (the keyboard starts there; Enter and Esc give it); an app the OS can't name gets \"Open this link in another app?\"; on macOS and Windows a kind of link no app opens gets a toast instead of the dialog (not in e2e: test runs give the OS's apps through ORBIT_E2E_APPS); a tab, Peek, Mini Window or pop-up opened only for such a link (target=_blank, window.open, or a redirect there) closes itself once its navigation ends, the tab it came from is selected again, and nothing goes to the Archive; the launch is one function (launchInApp, shell.openExternal) that test runs replace with a recorder, so no test starts an app; schemes Chromium never hands to the OS (shell:, vbscript:, ms-msdt:…) never launch; a page asks one question at a time, and once it has asked, whatever the answer (Open, Cancel or Esc), it asks again only after a new click or key in it, as in Chromium (D77): a link it follows before then gets nothing (a click or a key in any of its frames counts, a frame from another site's too); an Always allow can't be undone in Orbit's UI yet (Site settings has no row for it): deleting the profile forgets it"}, + "NV-20": {"built": "yes", "note": "⌃⌘F on macOS (fn+F / Globe+F is the system's own key and does the same), F11 and Alt+Shift+F on Windows and Linux, View → Toggle Full Screen (macOS) and the command bar toggle window full screen; a held key toggles once. In full screen the sidebar hides and the page fills the window; the window's left edge brings the sidebar in over the page (SB-12), and the loading indicator stays (SB-28). Leaving docks the sidebar again, unless the user docked or hid it meanwhile (⌘S); a window closed or quit in full screen keeps its docked sidebar for next time; a window opened from one in full screen opens in full screen, its sidebar hidden (NV-10). Esc in a page, or over its error or crash screen, goes to the page first and full screen stays; pressed again within a second, or held (at its first repeat), it leaves full screen, and the page gets neither that press nor its repeats (nor, as the engine does after a key it didn't deliver, the release); presses further apart each go to the page. A single Esc never leaves, even one the page left alone: nothing asks the page (D60), and a web app's Esc is often its own without preventDefault. Exit Full Screen in Settings → Shortcuts prefers websites by default; set to prefer Orbit, one Esc leaves at once and the page gets nothing of it (KS-02, D68). Esc is no binding of it: an open overlay (command bar, Peek, find bar, a page dialog) takes Esc first (KS-19). The Esc that leaves full screen on a loading page doesn't stop the load (NV-04). A video's own full screen is Chromium's: it fills the window without the sidebar, Esc leaves only it, and the toggle keys leave both. Mini Windows don't go full screen this way. Xvfb has no window manager, so the e2e tests check the state and layout Orbit keeps; a person checks that the window really fills the screen, the macOS animation (the sidebar goes and comes back as it ends), fn+F / Globe+F, and that the View menu has one full-screen item (macOS may add its own Enter Full Screen beside Orbit's). Known limits (keys lane): with the keyboard in a frame of a loading page, the Esc the page gets stops the load after 250 ms, as only the main frame answers (K5); the first key right after the keyboard moves into a frame can come before that frame says where its keyboard is (K7)"}, + "NV-21": {"built": "yes", "note": "Save Page As…: ⇧⌘S (Ctrl+Shift+S on Windows and Linux; ⌘S is the sidebar's, SB-11), File → Save As… (macOS menu), the command bar's Save Page As… and the page menu's, each showing its live keys (KS-18). The OS save dialog opens from the Downloads folder, named after the page: a web page saves as a single file (MHTML) or, named .html, as its HTML alone; a local page whose frames are all local files saves complete (D49). A Peek's or a Mini Window's page saves itself. View Source is DT-04. On a canvas tab the same command exports the canvas: that comes with EZ-11 (no canvases yet)."}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, - "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"}, + "NV-24": {"built": "partial", "note": "alert, confirm and prompt show Orbit's own dialog over that page only (a tab, a split view pane, a Peek, a Mini Window, a pop-up): the sidebar, other tabs and the command bar stay usable, a tab's dialog waits while another tab shows and shows again on return, over a Peek it shows above the find bar there; it takes the keyboard only where the page had it (the find bar, a sidebar field such as a tab being renamed, and the address field keep theirs until the user clicks the dialog or the keyboard goes back to the page, D72), Enter answers, Esc cancels (closes an alert), Tab stays in the dialog, and the page gets the keyboard back; the page's text shows as text, cut at 10,000 characters; the title names the asking page's site (\" says\"), and an about:blank tab or pop-up that its opener writes into names the opener's, as in Chromium; \"Leave site?\" (\"Reload site?\" for a reload) when a page that asks for it, from its main frame or a frame (one from another site too), after a user gesture in any of its frames as in Chromium (a click, or a key that reaches the page: ⌘W and Orbit's other shortcuts are none, D77), goes: a link, a form or location, Orbit's address, Back, Forward or Reload (Leave starts it again), closing its tab (shown first; Cancel keeps it), closing its window or deleting its Space (each page shows as it asks; Cancel keeps the window or the Space, and a page that went before it sleeps), a pop-up's window; a close goes through the engine, which runs every frame's beforeunload only once the close has started (D73); a page's own history.back() or a frame's navigation it stopped goes on at the user's next try within 10 s; auto-archive and sleep let a page go the same way and keep it, without asking, when it warns, or while it waits on its dialog, and don't try it again until it navigates or gets a new user gesture; known limits (D77): the engine's close runs a page's beforeunload with a user activation, which a page that auto-archive or sleep keeps then holds (Orbit's pop-up rule and links to other apps go by Orbit's own count, not the engine's), and ⌘W on a page that has had a user gesture and has a beforeunload listener, while its script is busy, waits for the engine's answer, up to its 1 s limit, before the tab goes; HTTP sign-in (Basic, Digest; a server or a proxy) over the page, Cancel shows the server's own page; file, folder, colour and date pickers are the engine's own (not run in tests: they wait for a person); a dialog a frame asks for is Electron's: alert and confirm in its own message box, not over the page, which blocks the whole window and not only the tab (a known limit), and prompt returns null (the preload runs in main frames only, D40); quitting doesn't ask; closing a Peek or a Mini Window doesn't ask yet (their close paths are the finish-views lane's)"}, + "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; full screen ⌃⌘F (F11, Alt+Shift+F) with NV-20; print ⌘P (Ctrl+P) with NV-07; view source ⌥⌘U (Ctrl+U) with DT-04; save page ⇧⌘S (Ctrl+Shift+S) with NV-21. Missing: reader mode ⌥⌘R comes with NV-23 (not built), developer mode ⌃D with DT-06"}, "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "yes", "note": "every icon button presses to 0.94 at once and springs back (pop), the Library button too; Back/Forward nudge, Reload's glyph turns once, the + scales to 0.92; restarts on repeat; none when disabled; no spring with reduced motion"}, "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; starts a frame along, so the frame that draws ⌘S already moves"}, "FL-03": {"built": "yes", "note": "0 ms dwell; card in 200 ms (snappy), out 160 ms (quick); hysteresis band; re-entering while it leaves brings it back"}, "AP-05": {"built": "yes", "note": "private windows are near-black whatever the theme (frame, sidebar, window background) with hover and selection tuned for black; Private label with icon in the sidebar, not over the page; no Space switcher or Favorites"}, - "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward"}, + "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one \"ResizeObserver loop completed with undelivered notifications\" error event, and then its own observers of or get the final size a frame later, as they did before"}, "MD-02": {"built": "partial", "note": "Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over"}, "MD-03": {"built": "yes", "note": "frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform"}, "MD-04": {"built": "yes", "note": "on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab"}, "MD-05": {"built": "yes", "note": "with the player focused (clicked) Space plays and pauses and the arrow keys seek 5 s, its buttons 15 s (tested); hardware media keys reach the playing video through Chromium's media key handling, which Orbit turns on: only a person can check them (no media keys under Xvfb)"}, - "MD-06": {"built": "yes", "note": "drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers say so), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it"}, + "MD-06": {"built": "yes", "note": "drag or two-finger hover-scroll moves it and it glides to the corner the release or fling heads for; corner drag resizes keeping the shape, at least 200 px wide (tall videos too) and at most half the screen width and the screen's height, with rubber-band resistance; Cmd/Ctrl+scroll or pinch zooms (crops) inside the window; corner and size remembered. While an Orbit window shows an alert or dialog (the engine disables the window meanwhile, macOS shows a sheet, and Orbit's own file pickers and save dialogs say so; so do Orbit's dialogs and screens over a page while they show: a page's alert, confirm, prompt and \"Open Mail?\", the print dialog, and the error, crash and \"Page unresponsive\" screens), the player keeps clear of where it shows, up to 900 x 620 centered on the window (from its top on macOS): it glides to the nearest clear corner at its size, or goes out of sight, still playing, when no corner is clear, and comes back when the dialog closes unless it was moved meanwhile; its remembered place doesn't change (tested with an alert, and with a page's confirm, the print dialog and a crash screen). A web page's own file picker on Linux isn't seen: the engine doesn't disable the window for it"}, "MD-07": {"built": "yes", "note": "returning (select, double-click, Back to tab) ends it with the video playing in the page; x closes it and the media keeps playing in the sidebar controls; closing or unloading the tab stops media and player; navigating the tab or the video ending closes it, without replay; a picture-in-picture the engine closes because another one opened (in another pane, tab or window, by a page or by Orbit) isn't Back to tab: nothing is selected and no window comes forward (D75)"}, "MD-08": {"built": "yes", "note": "site controls panel (NV-16) Picture in Picture switch, on by default: off is Always turn off for this site, on undoes it and Turn off for today, which it shows too; player ... menu Turn Off for Today / Always Turn Off for This Site (also in the tray controller menu); global switch Settings > Advanced (ST-10) and the Picture in Picture action; turning off keeps playback (sidebar controls); muted tabs never trigger"}, "MD-09": {"built": "yes", "note": "controller above the bottom bar: the Media Session's artwork (loaded with the tab's session, kept small) or the favicon, Media Session title/artist or tab title (marquee), play/pause, next, previous where supported, x pauses and hides; click jumps to the tab (switching Space); quiet audio counts; hidden while its tab is selected; media in frames (embedded players, cross-site too) has the same controls"}, @@ -242,7 +250,7 @@ "PK-08": {"built": "yes", "note": "the card slides to the right half, then split.addTab makes the source and the live Peek page one split in the source's place (a pinned source gives a pinned split), the page focused; from a full split the Peek stays open and the SV-25 toast shows"}, "PK-09": {"built": "yes", "note": "Esc (after fullscreen), ⌘W, ×, click outside; archived as from 'peek'"}, "PK-10": {"built": "yes", "note": "⌘Z right after closing, or ⌘⇧T, reopens on the same page with history"}, - "PK-11": {"built": "partial", "note": "links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11"}, + "PK-11": {"built": "partial", "note": "links, forms, back/forward, reload, zoom, copy URL and copy URL as Markdown, print, devtools and find in page act on the Peek: ⌘F, ⌘G and F3 bring the find bar over the Peek card's top-right corner, searching the Peek's page with its own search, and Esc gives the Peek the keyboard back; a dialog the Peek's page asks for shows above the find bar, which keeps the keyboard it has until it closes (D72); the tab under it keeps its own search; the sidebar address shows the Peek host; ⌘L makes it a tab and edits it; downloads go to the normal list (DL-16). Not built: extensions in Peek pages come with EX-11"}, "PK-12": {"built": "yes", "note": "Settings → Links has both toggles (\"Open links from pinned tabs and Favorites in Peek\", \"Shift-click opens links in Peek\"), on by default on every platform; off, a pinned tab's or Favorite's other-site link opens as a Today tab and ⇧-click opens a new window. Tests: settings.spec ST-09/MW-11 (toggles, defaults), peek.spec PK-12, nav-links.spec NV-08 ⇧-click, link-routing unit PK-12"}, "PK-13": {"built": "yes", "note": "no sidebar row; sidebar stays usable; selecting another tab, Space or window dismisses (reopenable)"}, "PK-15": {"built": "yes", "note": "no hover-to-Peek trigger: hovering links or pinned rows never opens a Peek (hover cards are TB-21)"}, @@ -269,7 +277,8 @@ "DT-01": {"built": "yes", "note": "⌥⌘I / F12 (Ctrl+Shift+I / F12) open DevTools for the selected tab or focused split pane, docked inside its view, and close them, also from inside DevTools; a Peek's or Mini Window's page gets its own. ⌥⌘J (Ctrl+Shift+J) opens them on the Console; View → Developer menu (macOS) and the command bar. A key held down acts once. Copy, paste (after DevTools' own paste warning) and the device toolbar work; links DevTools open become tabs. Windows/Linux: while DevTools have the keyboard, only the DevTools keys reach Orbit"}, "DT-02": {"built": "yes", "note": "right-click → Inspect (last item) opens DevTools with that element selected, or just selects it when they're open. Picker: ⌥⌘C on macOS, no default key on Windows/Linux, Inspect Element in the command bar. ⇧⌘C stays Copy URL; inside DevTools it is DevTools' own picker. Remap: give the devtools.inspect action ⇧⌘C (and take it from tab.copyUrl) in src/shared/actions.ts, or in Settings → Shortcuts once remapping (ST-08) lands"}, "DT-03": {"built": "yes", "note": "docked right/left/bottom or undocked from DevTools' dock menu; the engine keeps the side per profile across restarts. Each tab (pane, Peek, Mini Window) has its own DevTools, shown and hidden with its page; a page shown again takes the docked size last chosen. Undocked: the open key brings them to the front (checked on Linux; macOS unverified), and closes them when they have the keyboard. No windows added, however many tabs have DevTools. What Orbit puts over a page (find bar, zoom, link pill, loading indicator, permission prompt) goes over the page's own area, never over docked DevTools (e2e for the find bar and link pill, docked right, bottom and left)"}, - "DT-13": {"built": "partial", "note": "orbit:/// opens as a normal tab when typed in the command bar or address field, or from another internal page's link; orbit://pages/ lists them; orbit://version/ shows the build info About shows (Orbit, build day, Electron, Chromium, V8, Node, OS) with Copy build info. They follow the app's state (light/dark) through the same bridge as Orbit's UI, which the one web page preload (src/preload/page.ts) gives an orbit: page's main frame only (D40, D53). A web page can't navigate, frame, redirect to, open, load or fetch them, and gets no bridge: Orbit opens one for no page but another internal page on any route (the page menu, Split View's item included, ⌥-click, new tabs, windows and pop-ups, a pop-up's own, blocked pop-ups, a drop), D53. Each page runs only the commands listed for it and sends no events, and its document runs only its own scripts (script-src 'self'), D53. Infrastructure documented for reuse (D39) in src/main/devtools/internal-pages.ts and src/renderer/src/internal/registry.ts: a page is a *.internal-page.tsx module, and lists the commands it runs in INTERNAL_PAGE_COMMANDS. Missing: the pages other lanes add as they build them (D54): extensions EX-07, site settings OT-07/ST-13, privacy and clearing data and system options ST-12, languages ST-14, about UP-07, history AR-10; orbit://pages/ lists each one added"}, + "DT-04": {"built": "yes", "note": "⌥⌘U (Ctrl+U), the command bar's View Source, the page menu's View Page Source and View → Developer → View Source (macOS menu) open the page's own address as view-source: in a new tab, shown at once: the engine's source view, read-only, coloured, with line numbers; a Peek's page opens its own source in a new tab of its window, and a Mini Window's in a main window (one showing its Space if there is one), brought to the front; a source view has no source of its own to open. Only the page's own address goes, so a web page can't make Orbit open a file: URL this way (D49)"}, + "DT-13": {"built": "partial", "note": "orbit:/// opens as a normal tab when typed in the command bar or address field, or from another internal page's link; orbit://pages/ lists them; orbit://version/ shows the build info About shows (Orbit, build day, Electron, Chromium, V8, Node, OS) with Copy build info. orbit://history/ is History (AR-10, pages lane). They follow the app's state (light/dark) through the same bridge as Orbit's UI, which the one web page preload (src/preload/page.ts) gives an orbit: page's main frame only (D40, D53). A web page can't navigate, frame, redirect to, open, load or fetch them, and gets no bridge: Orbit opens one for no page but another internal page on any route (the page menu, Split View's item included, ⌥-click, new tabs, windows and pop-ups, a pop-up's own, blocked pop-ups, a drop), D53. Each page runs only the commands listed for it and sends no events, and its document runs only its own scripts (script-src 'self'), D53. Infrastructure documented for reuse (D39) in src/main/devtools/internal-pages.ts and src/renderer/src/internal/registry.ts: a page is a *.internal-page.tsx module, and lists the commands it runs in INTERNAL_PAGE_COMMANDS. Missing: the pages other lanes add as they build them (D54): extensions EX-07, site settings OT-07/ST-13, privacy and clearing data and system options ST-12, languages ST-14, about UP-07; orbit://pages/ lists each one added"}, "DT-14": {"built": "yes", "note": "⇧⌘R (Ctrl+Shift+R) runs the navigation lane's nav.hardReload action (the e2e checks that action ran): page and subresources refetched (no-cache). A crashed page hands the keyboard to the window, so ⇧⌘R brings it back. Stopping a load (Esc in the page, double-clicking Reload) is NV-04, built with the sidebar's Reload/Stop button; its e2e tests cover both"}, "DL-02": {"built": "yes", "note": "a file icon flies from the page's edge into the Library button (≈0.5 s curved path, ease-out) and the button bounces; one flight per download; reduce motion: a fade on the button. Sidebar hidden: a \"Downloading \" toast (and \"Downloaded\"/\"Couldn't download\"). A Mini Window's downloads show in its main window"}, "DL-03": {"built": "yes", "note": "row above the bottom bar: name truncated in the middle, progress bar, bytes of total, time left (smoothed), ✕ cancels and deletes the partial file; several: newest + \"+N\", hover or \"+N\" lists each with its own ✕; done for 4 s, click opens; failed: Resume or Retry, ✕ dismisses; no downloads, no room taken. Right-click: the download's menu"}, @@ -295,7 +304,7 @@ "ST-20": {"built": "partial", "note": "per-profile rows carry the profile's name, the download location (DL-07) too; passwords and site permissions don't exist yet"}, "AP-01": {"built": "yes", "note": "Light / Dark / Match system in Settings and the Space theme picker; about 200 ms cross-fade, no flash"}, "OB-02": {"built": "yes", "note": "audit and e2e: no account UI anywhere, onboarding included; a first run sends nothing off the computer, with or without onboarding, except Chromium's spell-check dictionary download (see tests)"}, - "ST-08": {"built": "partial", "note": "Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device. Not built: the Esc → exit full screen row, which comes when full screen (NV-20) registers that as an action"}, + "ST-08": {"built": "partial", "note": "Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device; Exit Full Screen (Esc in full screen, NV-20) has its row with no keys of its own, preferring websites by default, and its priority sets Esc's"}, "ST-09": {"built": "yes", "note": "links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options"}, "ST-10": {"built": "partial", "note": "tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet"}, "ST-12": {"built": "partial", "note": "privacy promise (Orbit sends nothing); Clear browsing data per profile (⌘⇧⌫ / Ctrl+Shift+Delete): history by time range; cookies and site data, and the cache, always from all time (Electron has no range), so they start ticked only for All time; Secure DNS (Automatic, Off, Cloudflare, Google, Quad9); graphics acceleration lives in Advanced. No HTTPS-first or Safe Browsing (not in Electron), no per-site clearing here"}, diff --git a/src/main/archive/index.ts b/src/main/archive/index.ts index 47ff70f4..3e1ed71c 100644 --- a/src/main/archive/index.ts +++ b/src/main/archive/index.ts @@ -145,8 +145,8 @@ export function registerArchive(ctx: MainContext): ArchiveApi { ctx.actions.register('app.archive', (c) => openLibrary(c, { section: 'archive' })) // The menu and the command bar ask in the Library before anything goes (AR-07). ctx.actions.register('archive.clear', (c) => openLibrary(c, { section: 'archive', confirmClear: true })) - // ⌘Y / Ctrl+H, Archive → View History and the command bar (AR-10). - ctx.actions.register('app.history', (c) => openLibrary(c, { section: 'history' })) + // ⌘Y / Ctrl+H, Archive → View History and the command bar show History in a tab (AR-10, pages + // lane: src/main/pages/history.ts); the Library keeps its History section. // Help → Restore Data… and the command bar: the backups to choose from (AR-11). ctx.actions.register('app.restoreData', (c) => openLibrary(c, { section: 'backups' })) diff --git a/src/main/devtools/internal-urls.ts b/src/main/devtools/internal-urls.ts index dd242daa..4c63dd77 100644 --- a/src/main/devtools/internal-urls.ts +++ b/src/main/devtools/internal-urls.ts @@ -19,6 +19,8 @@ export function isInternalUrl(url: string | undefined): boolean { */ export const INTERNAL_PAGE_COMMANDS: Readonly> = { version: ['settings.buildInfo', 'settings.copyBuildInfo'], + // History (AR-10): its own tab's profile only (src/main/pages/history.ts). + history: ['pages.history', 'pages.historyOpen', 'pages.historyDelete', 'pages.historyMenu'], } /** diff --git a/src/main/features.ts b/src/main/features.ts index c8082132..9788a122 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -13,6 +13,7 @@ import { registerMedia } from './media' import { registerMiniWindow } from './miniwindow' import { registerMotion } from './motion' import { registerNav } from './nav' +import { registerPages } from './pages' import { registerPeek } from './peek' import { registerSettings } from './settings' import { registerSpaces } from './spaces/commands' @@ -29,6 +30,7 @@ export function registerFeatures(ctx: MainContext): void { registerMotion(ctx) const { ops } = registerTabs(ctx) registerNav(ctx, ops) + registerPages(ctx, ops) // Before Peek and Mini Windows: a DevTools key held down is ignored before they act on it. registerDevtools(ctx) registerSpaces(ctx) diff --git a/src/main/keys/menu-hints.ts b/src/main/keys/menu-hints.ts index 3791bdfa..9388808b 100644 --- a/src/main/keys/menu-hints.ts +++ b/src/main/keys/menu-hints.ts @@ -40,6 +40,7 @@ export const PAGE_MENU_KEYS: KeyHints = [ ['Back', 'nav.back'], ['Forward', 'nav.forward'], ['Reload', 'nav.reload'], + ['Save Page As…', 'page.save'], ['Print…', 'page.print'], ['View Page Source', 'page.viewSource'], ] diff --git a/src/main/keys/page-keys.ts b/src/main/keys/page-keys.ts index 4f881282..87af1284 100644 --- a/src/main/keys/page-keys.ts +++ b/src/main/keys/page-keys.ts @@ -5,9 +5,11 @@ export const KEYS_WORLD = 1020 /** * Whether an element is a text field: one that types or edits text (page script source, shared by - * the watcher here and the focus mirror in page-focus.ts). + * the watcher here and the focus mirror in page-focus.ts). A web component's field counts: the + * element focused inside open shadow roots, not their host (keys fix check K1). */ export const EDITABLE = `(el) => { + while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement if (!el || el === document.body || el === document.documentElement) return false if (el.isContentEditable) return true const tag = el.tagName diff --git a/src/main/menu.ts b/src/main/menu.ts index 9783464f..ee0e0940 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -82,6 +82,8 @@ export function installMenu(ctx: MainContext): void { // Anti-lock-in (import lane, IM-17). item('app.exportPinned', 'Export Pinned Items as Bookmarks…'), { type: 'separator' }, + // The page saved where the save dialog says (pages lane, NV-21). + item('page.save', 'Save As…'), item('page.print'), ], }, @@ -125,7 +127,8 @@ export function installMenu(ctx: MainContext): void { item('page.zoomIn'), item('page.zoomOut'), { type: 'separator' }, - { role: 'togglefullscreen' }, + // Orbit's own (NV-20), so the menu shows its keys as the user set them. + item('window.toggleFullScreen'), { type: 'separator' }, // View → Developer (DT-01, DT-02, DT-04). { diff --git a/src/main/nav/focus.ts b/src/main/nav/focus.ts index 863e59e0..5fb5f4eb 100644 --- a/src/main/nav/focus.ts +++ b/src/main/nav/focus.ts @@ -3,9 +3,13 @@ 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 +/** + * Is keyboard focus in something that edits text (or in a frame Orbit can't see into)? Inside open + * shadow roots: a web component's field counts (keys fix check K1). Page script source. + */ +export const FOCUS_IS_EDITABLE = `(() => { + let el = document.activeElement + while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement if (!el || el === document.body || el === document.documentElement) return false if (el.isContentEditable) return true const tag = el.tagName diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index dd302ddf..f0209a6d 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -348,9 +348,10 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { // Esc stops a loading page (NV-04), unless the page uses it: the page gets the key first, and // Orbit stops the load once the page's preload says no handler took it (D59, keys lane). An // open overlay (command bar, find bar, Peek) holds keyboard focus and takes Esc before the page - // could see it (KS-19). - contents.on('before-input-event', (_event, input) => { - if (input.type !== 'keyDown' || input.key !== 'Escape') return + // could see it (KS-19). An Esc that left full screen (NV-20, src/main/pages/fullscreen.ts) is + // taken from the page: it stops nothing. + contents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.key !== 'Escape' || event.defaultPrevented) return if (input.meta || input.control || input.alt || input.shift) return if (!store.state.tabs[tabId]?.loading) return const windowId = windows.windowOfTab(tabId) diff --git a/src/main/nav/links.ts b/src/main/nav/links.ts index c71f84ab..67f71c82 100644 --- a/src/main/nav/links.ts +++ b/src/main/nav/links.ts @@ -1,5 +1,11 @@ import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type Referrer, type WebContents } from 'electron' -import { framesMayOpen, pageMayOpen } from '../../shared/link-routing' +import { + type AltClickFound, + altClickOpens, + framesMayOpen, + isOpenableLink, + 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' @@ -60,10 +66,8 @@ const linkAt = (x: number, y: number) => `(() => { return isFrame(el) || el.shadowRoot ? { frame: true } : null })()` -interface PageLink { - href?: string +interface PageLink extends AltClickFound { policy?: string - frame?: boolean } /** An ⌥-click Orbit took over: the engine's download for the same link is cancelled when it comes. */ @@ -74,9 +78,6 @@ interface AltClick { 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 { @@ -133,7 +134,7 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks // ---- ⇧-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 + if (!isOpenableLink(details.url)) return undefined 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). @@ -177,18 +178,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). + // A local file opens from a page that is a local file too, never from a web page or a frame (D49). const page = contents.getURL() - if (found?.href) { - 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: page, policy: referrerPolicyName(found.policy) }) - } 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 }) - } + const opens = altClickOpens(page, found, fallback) + if (!opens) return + altClicks.set(tabId, { url: opens.url, until, split: opens.split }) + // A link in a frame waits for the engine's download of it, which says which and opens the split. + // Sent with the page as referrer, as the link's policy or the page's allows (review B-F1). + if (!opens.split) openInSplit(tabId, opens.url, { url: page, policy: referrerPolicyName(found?.policy) }) } ctx.hooks.tabCreated.push((tabId, contents) => { diff --git a/src/main/nav/page-menu.ts b/src/main/nav/page-menu.ts index a83a95e8..e4178ac3 100644 --- a/src/main/nav/page-menu.ts +++ b/src/main/nav/page-menu.ts @@ -1,11 +1,10 @@ -import { join } from 'node:path' -import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, type WebContents } from 'electron' +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron' 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' import type { MainContext } from '../context' -import { withDialog } from '../media/dialog-watch' +import { printPage, savePage, viewSource } from '../pages/entry' import type { TabPage } from '../tabs/ops' import { NAV_WORLD } from './focus' @@ -134,17 +133,6 @@ export function writingDirectionItem(contents: WebContents): MenuItemConstructor } } -/** A file name from a page title, without characters file systems refuse. */ -function fileName(title: string): string { - const printable = Array.from(title, (c) => (c.charCodeAt(0) < 32 ? ' ' : c)).join('') - const clean = printable - .replace(/[\\/:*?"<>|]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 120) - return clean || 'Page' -} - /** Any local file's address: what a complete save would read for a frame, asked of `framesMayOpen`. */ const LOCAL_FILE = 'file:///' @@ -168,44 +156,16 @@ export function saveFormat(complete: boolean, filePath: string): '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[] { +/** Save Page As… (NV-21), Print… and View Page Source: the pages lane's, as their keys are. */ +export function pageItems(contents: WebContents): MenuItemConstructorOptions[] { return [ - { - label: 'Save Page As…', - click: () => { - const win = ctx.windows.get(ctx.windows.windowOfTab(tabId) ?? '') - 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 ? withDialog(win, dialog.showSaveDialog(win, options)) : dialog.showSaveDialog(options)).then( - ({ canceled, filePath }) => { - if (canceled || !filePath || contents.isDestroyed()) return - // 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)) - }, - ) - }, - }, - { label: 'Print…', click: () => contents.print() }, + // Complete for a local page, else a single file (pages lane, src/main/pages/save-page.ts). + { label: 'Save Page As…', click: () => savePage(contents) }, + // Orbit's print dialog over the page (pages lane, NV-07). + { label: 'Print…', click: () => printPage(contents) }, { 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(() => {}) - }, - }, + // The page's source in a new tab (pages lane, DT-04). + { label: 'View Page Source', click: () => viewSource(contents) }, ] } diff --git a/src/main/nav/permissions.ts b/src/main/nav/permissions.ts index 73ca78b5..42909789 100644 --- a/src/main/nav/permissions.ts +++ b/src/main/nav/permissions.ts @@ -54,6 +54,18 @@ const ALWAYS_ALLOW = new Set([ /** Device APIs: a site may ask, and the chooser asks the user for each device. */ const DEVICE_APIS = new Set(['serial', 'usb', 'hid']) +/** Who takes a page's link to another app (`openExternal`): the pages lane asks the user (NV-19). */ +export type AppLinkHandler = (profileId: ProfileId, contents: WebContents, details: PermissionRequestDetails) => void +let appLinkHandler: AppLinkHandler | undefined + +/** + * Sets who handles a page's links to other apps (`mailto:`, `zoommtg:`…; pages lane, NV-19). The + * engine never launches one itself: until a handler is set, each is refused. + */ +export function setAppLinkHandler(handler: AppLinkHandler): void { + appLinkHandler = handler +} + /** The prompt's card in its own view (sidebar hidden): width, and the shadow's margin around it. */ export const PROMPT_VIEW = { width: 320, margin: 12, inset: 10, estimate: 168 } @@ -418,6 +430,13 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss return kinds.every((k) => allowedNow(profileId, origin, k)) }, request: (profileId, contents, permission, callback, details) => { + if (permission === 'openExternal') { + // A link to another app: refused to the engine, which would launch it unasked; the pages + // lane asks the user and launches it itself (NV-19). + callback(false) + appLinkHandler?.(profileId, contents, details) + return + } if (ALWAYS_ALLOW.has(permission)) return callback(true) if (permission === 'fileSystem') return askFile(profileId, contents, details, callback) const media = 'mediaTypes' in details ? details.mediaTypes : undefined diff --git a/src/main/pages/covers.ts b/src/main/pages/covers.ts new file mode 100644 index 00000000..2b9d6323 --- /dev/null +++ b/src/main/pages/covers.ts @@ -0,0 +1,430 @@ +import { webContents as allContents, BrowserWindow, type WebContents, WebContentsView } from 'electron' +import type { Rect, RendererView } from '../../shared/rpc' +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { withDialog } from '../media/dialog-watch' +import { activeTabOf, loadRenderer, rendererEntry, uiWebPreferences, viewContents } from '../windows' + +/** + * Where a page's cover shows: over a tab's page in a main window (a split pane included), over a + * Peek's or a Mini Window's page, or over a pop-up window's page. + */ +export type HostKind = 'tab' | 'floating' | 'popup' + +interface Host { + kind: HostKind + win: BrowserWindow + /** The Orbit window: the main window (a tab, a Peek) or the Mini Window. None for a pop-up. */ + windowId?: WindowId +} + +/** What a cover's view shows; its page is sent each new one (by id). */ +export interface CoverContent { + id: number +} + +/** One page's cover. */ +interface Cover { + page: WebContents + view?: WebContentsView + host?: Host + /** The view's page has loaded: only then does the view go into its window. */ + ready: boolean + /** What the view's page was last sent. */ + sent?: number + /** The view is in its window, over the page. */ + onScreen: boolean + /** While it is: ends the dialog-watch note that its window shows a dialog (MD-06). */ + unnote?: () => void + off: () => void +} + +export interface CoverInfo { + /** Where it shows; null while the page has no window. */ + host: HostKind | null + /** Its view is on screen over the page. */ + onScreen: boolean + /** Its view has the keyboard. */ + focused: boolean + bounds?: Rect +} + +export interface PageCovers { + /** Brings a page's cover in line with what it shows now (`content`): shown, moved or gone. */ + update(page: WebContents): void + /** What the cover view `sender` shows, and over which page (for the view's own requests). */ + shownBy(sender: WebContents): { page: WebContents; content: C } | undefined + /** Whether Orbit can place a cover over a page: a tab's (a Peek's and a Mini Window's too) or a pop-up's. */ + hostable(page: WebContents): boolean + info(page: WebContents): CoverInfo +} + +/** + * Orbit's own views over one page's area only (NV-24, NV-13, NV-14): a transparent view of the + * renderer (`view`) placed over the page while `content(page)` gives it something to show, and + * kept there as the page moves (tab switches, splits, resizes, a Peek springing open). It waits + * while the page is off screen and shows again with it. When it appears over the page the user is + * on, it takes the keyboard (unless `mayTakeKeyboard` says no), and keeps it while it shows: the + * page gets it back when it goes. + * + * A main window's views are made when first needed and kept for the next cover; a Peek's, a Mini + * Window's or a pop-up's view is its own and is closed when the cover goes. + * + * While a cover is on screen, its window counts as showing a dialog (src/main/media/dialog-watch.ts), + * so the picture-in-picture player keeps clear of it (MD-06). + */ +export function createCovers( + ctx: MainContext, + opts: { + view: RendererView + content: (page: WebContents) => C | undefined + /** + * Whether a cover that appears over the page the user is on may take the keyboard from + * `focused` (what has it now). By default it always does, as a page's own dialog does. + */ + mayTakeKeyboard?: (page: WebContents, focused: WebContents | null) => boolean + /** A key pressed in the view (`view`) while it shows over `page`. */ + onKey?: (page: WebContents, view: WebContents, event: Electron.Event, input: Electron.Input) => void + /** + * The view stays above the window's page tools (the find bar), as a page's own dialog does: in + * a tab and over a Peek's page alike (`WindowManager.keepAboveTools`). An error or crash screen + * isn't kept so: the find bar can show over it. + */ + aboveTools?: boolean + }, +): PageCovers { + const { store, tabs, windows } = ctx + const channel = `orbit:${opts.view}:content` + const covers = new Map() + const pools = new Map>() + const inUse = new Set() + /** Views whose page has loaded, and not crashed since: only these go on screen. */ + const loaded = new WeakSet() + + const hostOf = (page: WebContents): Host | undefined => { + const tabId = tabs.tabIdFor(page) + if (tabId) { + const windowId = windows.windowOfTab(tabId) + if (!windowId) return undefined + if (store.state.tabs[tabId]?.floating) { + const win = BrowserWindow.fromWebContents(page) + return win && !win.isDestroyed() ? { kind: 'floating', win, windowId } : undefined + } + const win = windows.get(windowId) + return win && !win.isDestroyed() ? { kind: 'tab', win, windowId } : undefined + } + // A pop-up window's page: the window's own contents. + if (windows.isUi(page)) return undefined + const win = BrowserWindow.fromWebContents(page) + return win && !win.isDestroyed() && win.webContents === page ? { kind: 'popup', win } : undefined + } + + const hostable = (page: WebContents): boolean => { + if (tabs.tabIdFor(page)) return true + if (windows.isUi(page)) return false + return BrowserWindow.fromWebContents(page)?.webContents === page + } + + const sameHost = (a: Host, b: Host) => a.kind === b.kind && a.win === b.win + + /** Where the cover goes (window coordinates): over the page, while the page is on screen. */ + const placeOf = (c: Cover, host: Host): Rect | undefined => { + if (host.kind === 'popup') { + if (!host.win.isVisible()) return undefined + const [width = 0, height = 0] = host.win.getContentSize() + return { x: 0, y: 0, width, height } + } + const tabId = tabs.tabIdFor(c.page) + if (!tabId) return undefined + if (host.kind === 'tab') return windows.tabViewBounds(tabId) + const view = tabs.get(tabId) + if (!view?.getVisible() || !host.win.contentView.children.includes(view)) return undefined + return view.getBounds() + } + + /** The page is the one the user is on: its window has focus, and nothing covers the page. */ + const pageIsCurrent = (c: Cover, host: Host): boolean => { + if (!host.win.isFocused()) return false + if (host.kind !== 'tab' || !host.windowId) return true + const tabId = tabs.tabIdFor(c.page) + const win = store.state.windows[host.windowId] + return ( + !!tabId && + activeTabOf(store.state, host.windowId) === tabId && + win?.overlay.kind === 'none' && + !windows.hasPageCover(host.windowId) + ) + } + + /** The cover left the screen: its window no longer shows it as a dialog (MD-06). */ + const unnote = (c: Cover) => { + const end = c.unnote + c.unnote = undefined + end?.() + } + + const hasKeyboard = (view: WebContentsView | undefined) => { + const contents = viewContents(view) + return !!contents && allContents.getFocusedWebContents() === contents + } + + /** Orbit's UI never navigates away or opens windows (as WindowManager's own views). */ + const harden = (contents: WebContents) => { + contents.on('will-navigate', (event, url) => { + const entry = rendererEntry() + if (!(entry.url && url.startsWith(entry.url))) event.preventDefault() + }) + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + } + + /** Follows a view: its loads, its crashes, and keys pressed in it. */ + const watchView = (view: WebContentsView) => { + const contents = viewContents(view) + const { onKey } = opts + if (contents && onKey) { + contents.on('before-input-event', (event, input) => { + for (const c of covers.values()) if (c.view === view && c.onScreen) onKey(c.page, contents, event, input) + }) + } + contents?.on('did-finish-load', () => { + loaded.add(view) + for (const c of covers.values()) { + if (c.view !== view) continue + c.ready = true + c.sent = undefined + update(c.page) + } + }) + contents?.on('render-process-gone', () => { + loaded.delete(view) + for (const c of covers.values()) { + if (c.view !== view) continue + c.ready = false + c.onScreen = false + unnote(c) + } + }) + } + + /** A main window's free view, made if none is free. */ + const pooledView = (windowId: WindowId): WebContentsView | undefined => { + const pool = pools.get(windowId) ?? new Set() + pools.set(windowId, pool) + for (const view of pool) { + if (!viewContents(view)) pool.delete(view) + else if (!inUse.has(view)) return view + } + const view = windows.createUiView(windowId, opts.view) + if (!view) return undefined + if (opts.aboveTools) windows.keepAboveTools(view) + pool.add(view) + watchView(view) + windows.get(windowId)?.once('closed', () => { + for (const v of pool) inUse.delete(v) + pools.delete(windowId) + }) + return view + } + + /** A view over a Peek's, a Mini Window's or a pop-up's page: its own, closed after the cover goes. */ + const looseView = (host: Host): WebContentsView => { + const windowId = host.windowId ?? windows.focusedWindowId() ?? windows.ids()[0] ?? '' + const view = new WebContentsView({ webPreferences: uiWebPreferences(windowId, opts.view) }) + view.setBackgroundColor('#00000000') + view.setVisible(false) + if (opts.aboveTools) windows.keepAboveTools(view) + const contents = view.webContents + windows.registerUi(contents, windowId) + windows.attachShortcuts(contents) + harden(contents) + // A view that crashed loads again, once: what it showed shows again. + contents.once('render-process-gone', (_event, details) => { + if (details.reason !== 'clean-exit' && !contents.isDestroyed()) loadRenderer(contents, opts.view) + }) + watchView(view) + loadRenderer(contents, opts.view) + host.win.once('closed', () => viewContents(view)?.close()) + return view + } + + const ensureView = (c: Cover, host: Host): WebContentsView | undefined => { + if (c.view && viewContents(c.view) && c.host && sameHost(c.host, host)) return c.view + release(c) + c.host = host + c.ready = false + c.sent = undefined + const view = host.kind === 'tab' && host.windowId ? pooledView(host.windowId) : looseView(host) + if (!view) return undefined + c.view = view + inUse.add(view) + // A pooled view that has loaded already is ready now (`isLoading` can stay true for a frame of + // its last cover's, such as the print preview's). + c.ready = loaded.has(view) + return view + } + + /** Takes a page's view off screen; `focusPage` gives the page the keyboard if the view had it. */ + const hide = (c: Cover, focusPage: boolean) => { + const view = c.view + const host = c.host + c.onScreen = false + unnote(c) + if (!view || !host || !viewContents(view)) return + const had = hasKeyboard(view) + if (host.kind === 'tab' && host.windowId) windows.hideUiView(host.windowId, view) + else { + if (!host.win.isDestroyed() && host.win.contentView.children.includes(view)) + host.win.contentView.removeChildView(view) + view.setVisible(false) + } + if (had && focusPage && !c.page.isDestroyed()) c.page.focus() + } + + /** Gives a page's view back: a pooled one to its window's pool, a loose one is closed. */ + const release = (c: Cover) => { + const view = c.view + if (!view) return + hide(c, false) + inUse.delete(view) + c.view = undefined + c.host = undefined + c.ready = false + c.sent = undefined + const pooled = [...pools.values()].some((pool) => pool.has(view)) + if (!pooled) viewContents(view)?.close() + } + + /** The cover's gone: the keyboard goes back to the page (KS-16), and a main window keeps the view. */ + const drop = (c: Cover) => { + covers.delete(c.page.id) + hide(c, true) + release(c) + c.off() + } + + const entryFor = (page: WebContents): Cover => { + const existing = covers.get(page.id) + if (existing) return existing + // The page has the keyboard while its cover shows: the cover takes it (KS-19), just after the + // page's own focus change (one made during it is lost, as when its tab comes back). + const onFocus = () => { + setImmediate(() => { + const c = covers.get(page.id) + if (!c?.onScreen || page.isDestroyed() || allContents.getFocusedWebContents() !== page) return + viewContents(c.view)?.focus() + }) + } + const gone = () => { + const c = covers.get(page.id) + if (!c) return + covers.delete(page.id) + c.off() + release(c) + } + const onBounds = () => { + if (covers.has(page.id)) update(page) + } + page.on('focus', onFocus) + page.once('destroyed', gone) + // A Peek's or a Mini Window's page moves on its own: the cover follows it. + const tabId = tabs.tabIdFor(page) + const pageView = tabId ? tabs.get(tabId) : undefined + pageView?.on('bounds-changed', onBounds) + const c: Cover = { + page, + ready: false, + onScreen: false, + off: () => { + if (!page.isDestroyed()) { + page.off('focus', onFocus) + page.off('destroyed', gone) + } + pageView?.off('bounds-changed', onBounds) + }, + } + covers.set(page.id, c) + return c + } + + const update = (page: WebContents) => { + const content = page.isDestroyed() ? undefined : opts.content(page) + const existing = covers.get(page.id) + if (!content) { + if (existing) drop(existing) + return + } + const c = existing ?? entryFor(page) + const host = hostOf(page) + const bounds = host ? placeOf(c, host) : undefined + if (!host || !bounds || bounds.width < 1 || bounds.height < 1) { + hide(c, false) + return + } + const view = ensureView(c, host) + if (!view || !c.ready) return + if (c.sent !== content.id) { + c.sent = content.id + viewContents(view)?.send(channel, content) + } + const appearing = !c.onScreen + if (host.kind === 'tab' && host.windowId) windows.showUiView(host.windowId, view, bounds) + else { + // Over the page, which sits above the window's overlay (a Peek) or alone in its window. + const parent = host.win.contentView + if (parent.children[parent.children.length - 1] !== view) parent.addChildView(view) + const at = view.getBounds() + if (at.x !== bounds.x || at.y !== bounds.y || at.width !== bounds.width || at.height !== bounds.height) { + view.setBounds(bounds) + } + if (!view.getVisible()) view.setVisible(true) + } + c.onScreen = true + if (!c.unnote) { + let end = () => {} + void withDialog( + host.win, + new Promise((resolve) => { + end = resolve + }), + ) + c.unnote = end + } + if (!appearing || !pageIsCurrent(c, host)) return + const focused = allContents.getFocusedWebContents() ?? null + if (opts.mayTakeKeyboard && !opts.mayTakeKeyboard(page, focused)) return + viewContents(view)?.focus() + } + + const updateAll = () => { + for (const c of [...covers.values()]) update(c.page) + } + // Pages move: tab switches, splits, resizes, full screen, DevTools docking, the Library. + windows.onLayout(() => updateAll()) + store.subscribe(() => { + if (covers.size > 0) updateAll() + }) + + return { + update, + shownBy(sender) { + for (const c of covers.values()) { + if (viewContents(c.view) !== sender) continue + const content = c.page.isDestroyed() ? undefined : opts.content(c.page) + if (!content) return undefined + c.sent = content.id + return { page: c.page, content } + } + return undefined + }, + hostable, + info(page) { + const c = covers.get(page.id) + return { + host: page.isDestroyed() ? null : (hostOf(page)?.kind ?? null), + onScreen: !!c?.onScreen, + focused: hasKeyboard(c?.view), + ...(c?.onScreen && c.view ? { bounds: c.view.getBounds() } : {}), + } + }, + } +} diff --git a/src/main/pages/dialogs.ts b/src/main/pages/dialogs.ts new file mode 100644 index 00000000..5e281d64 --- /dev/null +++ b/src/main/pages/dialogs.ts @@ -0,0 +1,206 @@ +import { webContents as allContents, type IpcMainEvent, ipcMain, type WebContents } from 'electron' +import { type PageDialogAnswer, type PageDialogContent, scriptAnswer, scriptDialog } from '../../shared/pages' +import type { Rect } from '../../shared/rpc' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import { createCovers, type HostKind } from './covers' + +interface Dialog { + content: PageDialogContent + resolve: (answer: PageDialogAnswer | null) => void + /** The page's own alert, confirm or prompt: it goes when the page navigates. */ + script: boolean + /** The page had the keyboard when the dialog came: it takes the keyboard when it shows (D72). */ + hadKeyboard: boolean +} + +export interface PageDialogInfo { + id: number + kind: PageDialogContent['kind'] + title: string + message: string + tabId: TabId | null + /** Where it shows; null while the page has no window. */ + host: HostKind | null + /** Its view is on screen over the page. */ + onScreen: boolean + /** Its view has the keyboard. */ + focused: boolean + bounds?: Rect + /** Later dialogs of the same page, waiting their turn. */ + waiting: number +} + +/** Which of a page's dialogs: its own alert, confirm and prompt, "Page unresponsive", or all. */ +export type DialogsWhich = 'script' | 'hang' | 'all' + +export interface PageDialogs$ { + /** Shows a dialog over a page (once the page is on screen); resolves with the answer, or null if it went away. */ + open(page: WebContents, content: Omit, script?: boolean): Promise + /** Closes some of a page's dialogs, each as Cancel. */ + dismiss(page: WebContents, which: DialogsWhich): void + /** Whether a page has a dialog open or waiting: its own alert, confirm or prompt (the page waits), or any. */ + pending(page: WebContents, which?: DialogsWhich): boolean + /** Whether Orbit can show a dialog over a page: a tab's (a Peek's and a Mini Window's too) or a pop-up's. */ + hostable(page: WebContents): boolean + /** Tests: every page's first dialog. */ + info(): PageDialogInfo[] +} + +/** + * Page dialogs (NV-24): a page's alert, confirm and prompt, the leave-page warning, HTTP sign-in, + * "Page unresponsive" and "Open Mail?" (NV-19), in Orbit's own small dialog over that page's area only. The rest of the + * window stays usable: other tabs, the sidebar, the command bar. A dialog waits while its page is + * off screen (another tab is selected, the window is closed to the Library) and shows again when + * the page does. Each page's dialogs show one at a time in a transparent view over the page + * (`createCovers`), which takes the keyboard where the page had it (Enter, Esc and Tab go to the + * dialog first, KS-19) and gives it back to the page when the last one closes (KS-16). Where Orbit's + * own UI has the keyboard (the find bar, a sidebar field, the address field), it stays there, and + * the dialog takes it once the user clicks the dialog or the keyboard next goes to the page (D72). + */ +export function registerDialogs(ctx: MainContext): PageDialogs$ { + const { tabs, commands } = ctx + /** Each page's dialogs, by its contents' id: the first shows, the rest wait their turn. */ + const queues = new Map() + let nextId = 1 + + /** Whether nothing, or the page itself, has the keyboard. */ + const pageHasKeyboard = (page: WebContents, focused = allContents.getFocusedWebContents() ?? null) => + !focused || focused.isDestroyed() || focused === page + + const covers = createCovers(ctx, { + view: 'dialog', + content: (page) => queues.get(page.id)?.list[0]?.content, + // A dialog takes the keyboard only where the page had it, as a screen does (D72): a page can't + // time one to catch keys meant for the find bar or a sidebar field. + mayTakeKeyboard: (page, focused) => + pageHasKeyboard(page, focused) || queues.get(page.id)?.list[0]?.hadKeyboard === true, + // Above the find bar, in a tab or a Peek, where the find bar keeps the keyboard (D72). + aboveTools: true, + }) + + const matches = (dialog: Dialog, which: DialogsWhich) => + which === 'all' || (which === 'script' ? dialog.script : dialog.content.kind === 'hang') + + const settle = (page: WebContents, dialog: Dialog, answer: PageDialogAnswer | null) => { + const queue = queues.get(page.id)?.list + const at = queue?.indexOf(dialog) ?? -1 + if (!queue || at < 0) return + queue.splice(at, 1) + if (queue.length === 0) queues.delete(page.id) + dialog.resolve(answer) + // The next dialog shows, or the view goes and the page gets the keyboard back (KS-16). + if (at === 0) covers.update(page) + } + + const service: PageDialogs$ = { + open(page, content, script = false) { + return new Promise((resolve) => { + if (page.isDestroyed()) { + resolve(null) + return + } + let queue = queues.get(page.id) + if (!queue) { + const made = { page, list: [] as Dialog[] } + queue = made + queues.set(page.id, made) + // A page that goes takes its dialogs with it. + page.once('destroyed', () => { + if (queues.get(page.id) !== made) return + queues.delete(page.id) + for (const dialog of made.list.splice(0)) dialog.resolve(null) + }) + } + queue.list.push({ content: { ...content, id: nextId++ }, resolve, script, hadKeyboard: pageHasKeyboard(page) }) + if (queue.list.length === 1) covers.update(page) + }) + }, + dismiss(page, which) { + for (const dialog of [...(queues.get(page.id)?.list ?? [])]) + if (matches(dialog, which)) settle(page, dialog, null) + }, + pending(page, which = 'all') { + return (queues.get(page.id)?.list ?? []).some((dialog) => matches(dialog, which)) + }, + hostable: (page) => covers.hostable(page), + info() { + return [...queues.values()].flatMap(({ page, list: queue }) => { + const first = queue[0] + if (!first || page.isDestroyed()) return [] + const shown = covers.info(page) + return [ + { + id: first.content.id, + kind: first.content.kind, + title: first.content.title, + message: first.content.message, + tabId: tabs.tabIdFor(page) ?? null, + host: shown.host, + onScreen: shown.onScreen, + focused: shown.focused, + ...(shown.bounds ? { bounds: shown.bounds } : {}), + waiting: queue.length - 1, + }, + ] + }) + }, + } + + commands.register('pages.dialogContent', (_args, c) => { + if (!c.sender) return null + return covers.shownBy(c.sender)?.content ?? null + }) + + commands.register('pages.dialogAnswer', (answer, c) => { + const page = c.sender ? covers.shownBy(c.sender)?.page : undefined + const first = page ? queues.get(page.id)?.list[0] : undefined + if (!page || !first || first.content.id !== answer.id) return + settle(page, first, { + id: answer.id, + ok: answer.ok === true, + ...(typeof answer.value === 'string' ? { value: answer.value } : {}), + ...(typeof answer.user === 'string' ? { user: answer.user } : {}), + ...(typeof answer.password === 'string' ? { password: answer.password } : {}), + ...(first.content.check && typeof answer.checked === 'boolean' ? { checked: answer.checked } : {}), + }) + }) + + // A page's alert, confirm or prompt (the page preload, NV-24): the page waits for the answer. + ipcMain.on(PAGE_CHANNEL.dialog, (event: IpcMainEvent, kind: unknown, message: unknown, value: unknown) => { + let replied = false + const reply = (result: unknown) => { + if (replied) return + replied = true + event.returnValue = result + } + try { + const page = event.sender + const frame = event.senderFrame + if (kind !== 'alert' && kind !== 'confirm' && kind !== 'prompt') { + reply(null) + return + } + // Only a page's main frame has the preload (D40), and only a tab's or a pop-up's page has a + // place for the dialog. + if (!frame || frame.parent || page.isDestroyed() || !covers.hostable(page)) { + reply(scriptAnswer(kind, null)) + return + } + // An empty page (`about:blank`) that its opener writes into is the opener's: the dialog names + // the opener's site, as Chromium's does, from the page's origin when its address isn't a web one. + const asking = /^https?:/i.test(frame.url) ? frame.url : frame.origin + const content = scriptDialog(kind, asking, String(message ?? ''), String(value ?? '')) + service.open(page, content, true).then( + (answer) => reply(scriptAnswer(kind, answer)), + () => reply(scriptAnswer(kind, null)), + ) + } catch (err) { + console.error('[pages] page dialog', err) + reply(null) + } + }) + + return service +} diff --git a/src/main/pages/entry.ts b/src/main/pages/entry.ts new file mode 100644 index 00000000..5e421d7c --- /dev/null +++ b/src/main/pages/entry.ts @@ -0,0 +1,31 @@ +import type { WebContents } from 'electron' + +/** + * The pages lane's ways in for code outside it: the actions for a tab's, a Peek's or a Mini + * Window's page, and the page menu. The lane sets them when it starts; until then they do nothing. + * Kept apart from the lane's modules, so these callers load nothing more. + */ +const entries = { + print: (_page: WebContents): void => {}, + viewSource: (_page: WebContents): void => {}, + savePage: (_page: WebContents): void => {}, +} + +/** Opens the print dialog over `page`, or brings back the one it has (NV-07). */ +export function printPage(page: WebContents): void { + entries.print(page) +} + +/** Opens `page`'s source in a new tab (DT-04). */ +export function viewSource(page: WebContents): void { + entries.viewSource(page) +} + +/** Asks where to save `page`, in the OS save dialog, and saves it there (NV-21). */ +export function savePage(page: WebContents): void { + entries.savePage(page) +} + +export function setPageEntries(next: Partial): void { + Object.assign(entries, next) +} diff --git a/src/main/pages/fullscreen.ts b/src/main/pages/fullscreen.ts new file mode 100644 index 00000000..c690b91a --- /dev/null +++ b/src/main/pages/fullscreen.ts @@ -0,0 +1,233 @@ +import type { BrowserWindow, WebContents } from 'electron' +import { ACTIONS_BY_ID, type ActionDef, priorityFor } from '../../shared/actions' +import type { ShortcutPriority } from '../../shared/commands/keys' +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { AGAIN_MS } from '../keys/router' + +/** Isolated world in which main asks a page to leave its own full screen (a video's). */ +const FULLSCREEN_WORLD = 1030 +/** How long main waits for a page to leave its own full screen before it goes on without it. */ +const PAGE_EXIT_WAIT_MS = 1000 + +/** What one page's Esc presses did: what the page got, and what Orbit took. */ +export interface EscapeMemory { + /** When the last press the page got came (main's clock). */ + toPage?: number + /** Who got the press held now: its release goes the same way. */ + down?: 'page' | 'orbit' + /** The press held now left full screen: the rest of its repeats go nowhere. */ + left?: boolean +} + +export interface EscapePress { + type: 'down' | 'up' + /** The keyboard's repeat of a held key. */ + repeat: boolean + /** When it came (main's clock). */ + now: number + /** The window is in full screen, and nothing takes Esc before the page (an overlay, a video's own full screen). */ + fullScreen: boolean +} + +/** 'page': the page gets the key; 'exit': Orbit takes it and leaves full screen; 'swallow': Orbit takes it. */ +export type EscapeVerdict = 'page' | 'exit' | 'swallow' + +/** + * Esc in a page while its window is in full screen (NV-20, KS-02). Pure: takes what the page's + * presses did before and this press, and gives what to do and what to remember. + * - Prefer websites (the default): the page gets the press, so a web app's own Esc never ends full + * screen. Pressing Esc again within AGAIN_MS of the press the page got, or holding it (its first + * repeat), leaves full screen, and the page doesn't get that press. + * - Prefer Orbit: a press leaves full screen at once, and the page never gets it. + * A press Orbit took is Orbit's to its release, and so are the repeats of a held press once it left + * full screen. + */ +export function escapeInFullScreen( + memory: EscapeMemory, + press: EscapePress, + priority: ShortcutPriority, +): { verdict: EscapeVerdict; memory: EscapeMemory } { + if (press.type === 'up') { + return { verdict: memory.down === 'orbit' ? 'swallow' : 'page', memory: { toPage: memory.toPage } } + } + if (press.repeat) { + if (memory.left) return { verdict: 'swallow', memory } + // Held: the page got the press, and its first repeat leaves. A press the page never saw (the + // engine took it to end a video's full screen) leaves nothing more. + if (press.fullScreen && memory.down === 'page') return { verdict: 'exit', memory: { ...memory, left: true } } + return { verdict: 'page', memory } + } + if (!press.fullScreen) return { verdict: 'page', memory: { down: 'page' } } + const again = memory.toPage !== undefined && press.now - memory.toPage <= AGAIN_MS + if (priority === 'orbit' || again) return { verdict: 'exit', memory: { down: 'orbit', left: true } } + return { verdict: 'page', memory: { toPage: press.now, down: 'page' } } +} + +/** A plain Esc: no modifier held. */ +const plainEscape = (input: Electron.Input) => + input.key === 'Escape' && !input.meta && !input.control && !input.alt && !input.shift + +export interface FullScreen$ { + /** + * Esc pressed in `contents` (a tab's page, or Orbit's own view over one) of window `windowId`: + * follows the full-screen rule, and takes the press from the page (`event.preventDefault()`) + * when Orbit uses it. + */ + onKey(windowId: WindowId | undefined, contents: WebContents, event: Electron.Event, input: Electron.Input): void + info(windowId: WindowId): FullScreenInfo +} + +/** A window's full screen as tests see it. */ +export interface FullScreenInfo { + /** The window is in full screen (the OS's, or one a page asked for). */ + fullScreen: boolean + /** Orbit hid the window's sidebar for its full screen, and docks it again when that ends. */ + sidebarHidden: boolean +} + +/** + * Window full screen (NV-20): Toggle Full Screen (⌃⌘F; F11 and Alt+Shift+F elsewhere; the command + * bar; View → Toggle Full Screen on macOS) and Exit Full Screen. + * + * In full screen the sidebar hides, and comes in over the page from the window's left edge (SB-12, + * src/main/nav/sidebar-reveal.ts); the loading indicator stays (SB-28). Orbit docks the sidebar + * again when full screen ends, unless the user chose meanwhile (⌘S). A page's own full screen (a + * video's) is the page's: the sidebar is left as it is, and Esc there is Chromium's. + * + * Esc follows `escapeInFullScreen`, with the priority of Exit Full Screen (Settings → Shortcuts, + * KS-02). Only a main or private window goes full screen this way; a Mini Window doesn't. + */ +export function registerFullScreen(ctx: MainContext): FullScreen$ { + const { store, windows, tabs, actions } = ctx + const exitAction = ACTIONS_BY_ID['window.exitFullScreen'] as ActionDef + /** Windows whose sidebar Orbit hid for full screen. */ + const hiddenFor = new Set() + /** Pages in their own full screen. */ + const inPageFullScreen = new Set() + /** What each page's Esc presses did, by contents id. */ + const escapes = new Map() + + const fullScreenWindow = (windowId: WindowId | undefined): BrowserWindow | undefined => { + const win = windowId ? windows.get(windowId) : undefined + const kind = windowId ? store.state.windows[windowId]?.kind : undefined + return win && !win.isDestroyed() && (kind === 'main' || kind === 'private') ? win : undefined + } + + /** The window's page in its own full screen, if one is. */ + const pageInFullScreen = (windowId: WindowId): WebContents | undefined => { + for (const page of inPageFullScreen) { + const tabId = page.isDestroyed() ? undefined : tabs.tabIdFor(page) + if (tabId && windows.windowOfTab(tabId) === windowId) return page + } + return undefined + } + + /** Asks a page to leave its own full screen; resolves once it has, or after PAGE_EXIT_WAIT_MS. */ + const exitPageFullScreen = (page: WebContents): Promise => + Promise.race([ + page + .executeJavaScriptInIsolatedWorld(FULLSCREEN_WORLD, [ + { code: 'document.fullscreenElement ? document.exitFullscreen().then(() => true, () => false) : false' }, + ]) + .catch(() => false), + new Promise((resolve) => setTimeout(resolve, PAGE_EXIT_WAIT_MS)), + ]) + + /** Leaves full screen: a page's own first (it would stay over the window), then the window's. */ + const leave = async (windowId: WindowId) => { + const page = pageInFullScreen(windowId) + if (page) await exitPageFullScreen(page) + const win = fullScreenWindow(windowId) + if (win?.isFullScreen()) win.setFullScreen(false) + } + + actions.register('window.toggleFullScreen', (c) => { + // A held key toggles once. + if (c.repeat) return + const windowId = c.windowId ?? windows.focusedWindowId() + const win = fullScreenWindow(windowId) + if (!win || !windowId) return + if (win.isFullScreen()) void leave(windowId) + else win.setFullScreen(true) + }) + actions.register('window.exitFullScreen', (c) => { + const windowId = c.windowId ?? windows.focusedWindowId() + if (windowId && fullScreenWindow(windowId)?.isFullScreen()) void leave(windowId) + }) + + // The sidebar in full screen. + const setVisible = (windowId: WindowId, visible: boolean) => + store.update((d) => { + const w = d.windows[windowId] + if (!w || w.sidebar.visible === visible) return + w.sidebar.visible = visible + w.sidebar.peeking = false + }) + const entered = (windowId: WindowId) => { + const win = fullScreenWindow(windowId) + // A page's own full screen (a video's) covers the window by itself: the sidebar stays as it is. + if (!win?.isFullScreen() || windows.inHtmlFullscreen(windowId)) return + if (!store.state.windows[windowId]?.sidebar.visible) return + hiddenFor.add(windowId) + setVisible(windowId, false) + } + const left = (windowId: WindowId) => { + if (hiddenFor.delete(windowId)) setVisible(windowId, true) + } + windows.onWindowCreated((windowId, win) => { + win.on('enter-full-screen', () => { + // A page asking for full screen says so just after the window goes (Linux, Windows) or + // before (macOS): look once both have. + setImmediate(() => entered(windowId)) + }) + win.on('leave-full-screen', () => left(windowId)) + // A window closed in full screen keeps its sidebar for the next time it opens. + win.once('closed', () => left(windowId)) + }) + // The user docked the sidebar in full screen (⌘S): theirs from now on. + store.subscribe((next) => { + for (const windowId of hiddenFor) if (next.windows[windowId]?.sidebar.visible !== false) hiddenFor.delete(windowId) + }) + + ctx.hooks.tabCreated.push((_tabId, contents) => { + const { id } = contents + contents.on('enter-html-full-screen', () => inPageFullScreen.add(contents)) + contents.on('leave-html-full-screen', () => inPageFullScreen.delete(contents)) + // Before the page's other Esc handling (stopping a load, NV-04), which skips a press taken here. + contents.prependListener('before-input-event', (event: Electron.Event, input: Electron.Input) => { + const tabId = tabs.tabIdFor(contents) + onKey(tabId ? windows.windowOfTab(tabId) : undefined, contents, event, input) + }) + contents.once('destroyed', () => { + inPageFullScreen.delete(contents) + escapes.delete(id) + }) + }) + + const onKey: FullScreen$['onKey'] = (windowId, contents, event, input) => { + if (input.key !== 'Escape' || contents.isDestroyed()) return + const type = input.type === 'keyDown' ? 'down' : input.type === 'keyUp' ? 'up' : undefined + if (!type || (type === 'down' && !plainEscape(input))) return + const win = fullScreenWindow(windowId) + const w = windowId ? store.state.windows[windowId] : undefined + // An open overlay (the command bar, a Peek) and a page's own full screen take Esc first (KS-19). + const fullScreen = + !!win?.isFullScreen() && !!windowId && w?.overlay.kind === 'none' && !windows.inHtmlFullscreen(windowId) + const priority = priorityFor(exitAction, process.platform, store.state.settings.shortcutPriority) + const memory = escapes.get(contents.id) ?? {} + const next = escapeInFullScreen(memory, { type, repeat: input.isAutoRepeat, now: Date.now(), fullScreen }, priority) + escapes.set(contents.id, next.memory) + if (next.verdict === 'page') return + event.preventDefault() + if (next.verdict === 'exit' && windowId) void leave(windowId) + } + + return { + onKey, + info: (windowId) => ({ + fullScreen: !!windows.get(windowId)?.isFullScreen(), + sidebarHidden: hiddenFor.has(windowId), + }), + } +} diff --git a/src/main/pages/gestures.ts b/src/main/pages/gestures.ts new file mode 100644 index 00000000..8eafce1f --- /dev/null +++ b/src/main/pages/gestures.ts @@ -0,0 +1,79 @@ +import type { WebContents } from 'electron' + +/** + * A page's user gestures (D77): one count per page, which a press in any of its frames feeds, as + * Chromium's user activation does. The leave-page warning (NV-24, D73), links to other apps (NV-19) + * and the pop-up rule (NV-09) all read it. + * + * - **A mouse-down.** `before-mouse-event` sees every frame's; `input-event` sees only the main + * frame's widget, so never a press in a frame from another site, which has a widget of its own. + * One press in the main frame brings both, in the same task: it counts once. + * - **A key that reaches the page** (`before-input-event`, every frame's). Not one Orbit takes as its + * own shortcut: a listener prevents it (`windows.attachShortcuts`), so ⌘W never counts for the + * page it closes. Nor, as in Chromium, Esc, a modifier alone, or a key held down. + * - **A touch's end or a tap** (`input-event`). + * + * Orbit's own calls into a page (its scripts, a navigation it starts) are no gesture, and nor is + * the user activation the engine gives a page it asks to leave (D77 (3)). + */ + +/** Keys that never count as a gesture (HTML user activation: Escape, and a modifier alone). */ +const NOT_A_GESTURE = new Set(['Escape', 'Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock', 'Fn', 'FnLock']) + +interface Gestures { + count: number + listeners: Set<() => void> + /** A mouse-down came this task (`before-mouse-event`): the main frame's `input-event` is the same press. */ + pressing: boolean +} + +const pages = new WeakMap() + +function watched(page: WebContents): Gestures { + const known = pages.get(page) + if (known) return known + const g: Gestures = { count: 0, listeners: new Set(), pressing: false } + pages.set(page, g) + const press = () => { + g.count++ + for (const listener of [...g.listeners]) listener() + } + page.on('before-mouse-event', (_event, mouse) => { + if (mouse.type !== 'mouseDown') return + if (!g.pressing) { + g.pressing = true + setImmediate(() => { + g.pressing = false + }) + } + press() + }) + page.on('input-event', (_event, input) => { + if (input.type === 'mouseDown') { + if (!g.pressing) press() + } else if (input.type === 'touchEnd' || input.type === 'gestureTap') press() + }) + page.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.isAutoRepeat || NOT_A_GESTURE.has(input.key)) return + // Whether a listener took the key for Orbit is known once every listener has run. + queueMicrotask(() => { + if (!event.defaultPrevented && !page.isDestroyed()) press() + }) + }) + return g +} + +/** Counts a page's gestures from now on: as it is created, so that none is missed. */ +export function watchGestures(page: WebContents): void { + watched(page) +} + +/** The gestures a page has had since it was watched: it only grows. */ +export function gestureCount(page: WebContents): number { + return pages.get(page)?.count ?? 0 +} + +/** Calls `listener` on each of the page's gestures from now on. */ +export function onGesture(page: WebContents, listener: () => void): void { + watched(page).listeners.add(listener) +} diff --git a/src/main/pages/history.ts b/src/main/pages/history.ts new file mode 100644 index 00000000..98842b72 --- /dev/null +++ b/src/main/pages/history.ts @@ -0,0 +1,155 @@ +import { clipboard, Menu, type MenuItemConstructorOptions, type WebContents } from 'electron' +import { INTERNAL_SCHEME } from '../../shared/commands/devtools' +import { PAGE_CHANNEL } from '../../shared/site' +import type { Tab, TabId, WindowId } from '../../shared/state' +import { historyMenuItems } from '../archive/history-view' +import type { MainContext } from '../context' +import { internalPageName } from '../devtools/internal-urls' +import { isUnplacedImportTab } from '../import/unplaced' +import type { ActionContext, CallContext } from '../registry' +import { findMenuItem, menuLabels } from '../tabs/menus' +import { shownTabs } from '../windows' + +/** The History page's name: `orbit://history/` (src/renderer/src/pages/history.internal-page.tsx). */ +export const HISTORY_PAGE = 'history' +export const HISTORY_URL = `${INTERNAL_SCHEME}://${HISTORY_PAGE}/` + +/** + * History in a tab (AR-10): `orbit://history/`, an internal page (DT-13) that shows the Library's + * History section, every visit of the tab's profile. ⌘Y (Ctrl+H), Archive → View History and the + * command bar's View History show it in a main window: the History tab of the Space the window + * shows if it has one (the one used last), else a new tab. An open Library makes way. The Library + * keeps its History section. + * + * The page runs only the `pages.history*` commands (INTERNAL_PAGE_COMMANDS, D53). Each one takes the + * profile from the calling page's own tab, never from the page, and opens only a page that page was + * given: a script that got into it (through a title, say) can't read another profile's history or + * open any other address. + */ +export function registerHistoryPage(ctx: MainContext): void { + const { actions, commands, store, tabs, windows } = ctx + + // ---- ⌘Y / Ctrl+H, the menu and the command bar ---- + + /** History shows in a main window: from a Mini Window, the latest one, brought to the front. */ + const mainWindowFor = (c: ActionContext): WindowId | undefined => { + const { state } = store + const isMain = (id: WindowId | undefined): id is WindowId => !!id && state.windows[id]?.kind === 'main' + const asked = c.windowId ?? windows.focusedWindowId() + if (isMain(asked)) return asked + const mains = windows.ids().filter(isMain) + const target = mains[mains.length - 1] ?? windows.open(undefined, asked ? state.windows[asked]?.spaceId : undefined) + windows.get(target)?.focus() + return target + } + + /** The History tab of the Space a window shows, the one used last; an import's unplaced tabs aside (D65). */ + const historyTabIn = (windowId: WindowId): TabId | undefined => { + const { state } = store + const spaceId = state.windows[windowId]?.spaceId + let found: Tab | undefined + for (const tab of Object.values(state.tabs)) { + if (tab.spaceId !== spaceId || tab.floating || isUnplacedImportTab(tab.id)) continue + if (internalPageName(tab.url) !== HISTORY_PAGE) continue + if (!found || tab.lastActiveAt > found.lastActiveAt) found = tab + } + return found?.id + } + + actions.register('app.history', async (c) => { + const windowId = mainWindowFor(c) + if (!windowId) return + // The Library makes way, as for any tab opened from it. + await commands.run('library.close', {}, { windowId }) + const tabId = historyTabIn(windowId) + if (tabId) await commands.run('tabs.activate', { tabId }, { windowId }) + else await commands.run('tabs.create', { url: HISTORY_URL, activate: true }, { windowId }) + }) + + // ---- The page's commands (D53) ---- + + /** The History page that calls and its tab's profile: from the tab, never from the page. */ + const caller = (c: CallContext): { page: WebContents; profileId: string } => { + const page = c.sender + const tabId = page && !page.isDestroyed() ? tabs.tabIdFor(page) : undefined + const profileId = tabId ? store.state.tabs[tabId]?.profileId : undefined + if (!page || !profileId || internalPageName(page.getURL()) !== HISTORY_PAGE) throw new Error('Forbidden') + return { page, profileId } + } + + /** The pages each History page was given: the only ones it may open. */ + const listed = new WeakMap>() + const isListed = (page: WebContents, url: unknown): url is string => + typeof url === 'string' && !!listed.get(page)?.has(url) + + /** Tells a History page what its menu did (a toast, a page that went), as the Library is told. */ + const tell = (page: WebContents, channel: string, payload: unknown) => { + if (page.isDestroyed() || internalPageName(page.getURL()) !== HISTORY_PAGE) return + try { + page.mainFrame.send(PAGE_CHANNEL.internalEvent, `orbit:${channel}`, payload) + } catch { + // The page went meanwhile. + } + } + + // A History tab shown again loads its list again: pages were visited since. (The page can't tell: + // the engine keeps a hidden tab's page visible.) + let shown = new Set() + store.subscribe((next, prev) => { + if (next.windows === prev.windows && next.tabs === prev.tabs) return + const now = new Set() + for (const windowId of Object.keys(next.windows)) { + for (const tabId of shownTabs(next, windowId)) { + if (internalPageName(next.tabs[tabId]?.url) === HISTORY_PAGE) now.add(tabId) + } + } + for (const tabId of now) { + const page = shown.has(tabId) ? undefined : tabs.contents(tabId) + if (page) tell(page, 'history:shown', {}) + } + shown = now + }) + + commands.register('pages.history', async (q, c) => { + const { page, profileId } = caller(c) + const { text, before, limit } = q ?? {} + const rows = await commands.run('library.history', { text, before, limit, profileId }, c) + const urls = listed.get(page) ?? new Set() + listed.set(page, urls) + for (const row of rows) urls.add(row.url) + return rows + }) + commands.register('pages.historyOpen', async ({ url, background }, c) => { + const { page } = caller(c) + if (!isListed(page, url)) return + await commands.run('tabs.create', { url, activate: background !== true }, { windowId: c.windowId }) + }) + commands.register('pages.historyDelete', async ({ url }, c) => { + const { profileId } = caller(c) + if (typeof url === 'string' && url) await commands.run('history.delete', { profileId, url }) + }) + + let lastMenu: MenuItemConstructorOptions[] = [] + commands.register('pages.historyMenu', ({ url }, c) => { + const { page, profileId } = caller(c) + if (!isListed(page, url)) return + const windowId = c.windowId + lastMenu = historyMenuItems(ctx, { + url, + profileId, + windowId, + send: (channel, payload) => tell(page, channel, payload), + copy: (text) => clipboard.writeText(text), + }) + if (ctx.e2e) return + const win = windowId ? windows.get(windowId) : undefined + if (win && !win.isDestroyed()) Menu.buildFromTemplate(lastMenu).popup({ window: win }) + }) + if (ctx.e2e) { + commands.register('pages.testHistoryMenu', ({ click }) => { + const item = click ? findMenuItem(lastMenu, click) : undefined + if (item?.click && item.enabled !== false) (item.click as () => void)() + return menuLabels(lastMenu) + }) + } +} diff --git a/src/main/pages/index.ts b/src/main/pages/index.ts new file mode 100644 index 00000000..9a8db580 --- /dev/null +++ b/src/main/pages/index.ts @@ -0,0 +1,139 @@ +import { app } from 'electron' +import { authDialog } from '../../shared/pages' +import { countPdfPages } from '../../shared/print' +import type { TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' +import { type PageDialogInfo, type PageDialogs$, registerDialogs } from './dialogs' +import { type FullScreenInfo, registerFullScreen } from './fullscreen' +import { registerHistoryPage } from './history' +import { type LeaveInfo, registerLeave } from './leave' +import { type AppLinkOutcome, findSystemApp, findTestApp, launchInApp, registerOtherApps } from './other-apps' +import { deliverPrint, type PrintInfo, type PrintRecord, registerPrint, systemPrinters, testPrinters } from './print' +import { registerSavePage } from './save-page' +import { LOAD_TIMEOUT_MS, type PageScreenInfo, registerScreens } from './screens' +import { registerSwipe, type SwipeInfo } from './swipe' +import { registerViewSource } from './view-source' + +/** Test-only handle (ORBIT_E2E=1): globalThis.__orbitPages. */ +export interface PagesTestHooks { + /** Every page's dialog on show or waiting to (the first of each page's). */ + dialogs: () => PageDialogInfo[] + /** What Orbit knows of a tab's page about its leave-page warning. */ + leave: (tabId: TabId) => LeaveInfo | null + /** Every page's error or crash screen (NV-13, NV-14). */ + screens: () => PageScreenInfo[] + /** Links Orbit opened in another app, oldest first (NV-19): test runs never start one. */ + launched: string[] + /** Links to other apps that pages followed, oldest first, and what came of each (NV-19). */ + appLinks: { url: string; outcome: AppLinkOutcome }[] + /** A window's full screen (NV-20). */ + fullScreen: (windowId: WindowId) => FullScreenInfo + /** Every page's print dialog (NV-07). */ + print: () => PrintInfo[] + /** Print jobs, oldest first (NV-07): test runs never print or save one. */ + printed: PrintRecord[] + /** A window's two-finger swipe over a page, and its arrow (NV-02). */ + swipe: (windowId: WindowId) => SwipeInfo +} + +/** + * The pages lane: what a page does beyond loading. Page dialogs (NV-24): a page's alert, confirm and + * prompt, the leave-page warning and HTTP sign-in, in Orbit's own dialog over that page. Error and + * crash screens (NV-13, NV-14): what the page's area shows when its page couldn't load, crashed or + * stopped responding. Links that open other apps (NV-19): asked about over the page. Window full + * screen (NV-20): the sidebar hidden, and Esc in the page. Print (NV-07): Orbit's print dialog over + * the page. View source (DT-04): the page's source in a new tab. Save Page As… (NV-21): the page + * saved where the save dialog says. Two-finger swipe (NV-02): back and + * forward over the page, with its arrow. History (AR-10): the profile's visits as a page in a tab. + */ +export function registerPages(ctx: MainContext, ops: TabOps): void { + const dialogs = registerDialogs(ctx) + const leave = registerLeave(ctx, ops, dialogs) + registerSignIn(dialogs) + // Tests give a navigation's first answer less time (ORBIT_E2E_LOAD_TIMEOUT_MS). + const testTimeout = ctx.e2e ? Number(process.env.ORBIT_E2E_LOAD_TIMEOUT_MS) : Number.NaN + const fullScreen = registerFullScreen(ctx) + const screens = registerScreens(ctx, ops, dialogs, fullScreen, testTimeout > 0 ? testTimeout : LOAD_TIMEOUT_MS) + // Test runs record each launch instead, and take the OS's apps from ORBIT_E2E_APPS. + const launched: string[] = [] + const appLinks: PagesTestHooks['appLinks'] = [] + registerOtherApps( + ctx, + dialogs, + ctx.e2e + ? { + launch: async (url) => { + launched.push(url) + }, + findApp: findTestApp(process.env.ORBIT_E2E_APPS), + note: (url, outcome) => { + appLinks.push({ url, outcome }) + }, + } + : { launch: launchInApp, findApp: findSystemApp }, + ) + // Test runs record each print job instead, and take the printers from ORBIT_E2E_PRINTERS. + const printed: PrintRecord[] = [] + const print = registerPrint( + ctx, + ctx.e2e + ? { + deliver: async (page, job) => { + const url = page.isDestroyed() ? '' : page.getURL() + if (job.kind === 'printer') printed.push({ kind: 'printer', url, options: job.options }) + else { + const pages = countPdfPages(job.data.toString('latin1')) + printed.push({ kind: 'pdf', url, name: job.name, bytes: job.data.length, pages }) + } + return { ok: true } + }, + printers: testPrinters(process.env.ORBIT_E2E_PRINTERS), + } + : { deliver: deliverPrint, printers: systemPrinters }, + ) + + registerViewSource(ctx) + registerSavePage(ctx) + const swipe = registerSwipe(ctx) + registerHistoryPage(ctx) + + if (ctx.e2e) { + const hooks: PagesTestHooks = { + dialogs: () => dialogs.info(), + leave: (tabId) => { + const page = ctx.tabs.contents(tabId) + return page ? leave.info(page) : null + }, + screens: () => screens.info(), + launched, + appLinks, + fullScreen: (windowId) => fullScreen.info(windowId), + print: () => print.info(), + printed, + swipe: (windowId) => swipe.info(windowId), + } + ;(globalThis as unknown as { __orbitPages: PagesTestHooks }).__orbitPages = hooks + } +} + +/** + * HTTP sign-in (Basic and Digest, NV-24): a server or a proxy asks for a username and password, and + * Orbit asks the user over the page that asked. Cancel (or the page going) sends none, and the page + * gets the server's answer to that. + */ +function registerSignIn(dialogs: PageDialogs$): void { + app.on('login', (event, page, details, authInfo, callback) => { + // Without a page to ask over (a request of Orbit's own, a utility process): no sign-in. + if (!page || page.isDestroyed() || !dialogs.hostable(page)) return + event.preventDefault() + void dialogs.open(page, authDialog(authInfo, details.url)).then((answer) => { + try { + if (answer?.ok) callback(answer.user ?? '', answer.password ?? '') + else callback() + } catch { + // The request went meanwhile. + } + }) + }) +} diff --git a/src/main/pages/leave.ts b/src/main/pages/leave.ts new file mode 100644 index 00000000..6f268ba2 --- /dev/null +++ b/src/main/pages/leave.ts @@ -0,0 +1,470 @@ +import { app, BrowserWindow, type WebContents } from 'electron' +import { leaveDialog } from '../../shared/pages' +import type { ArchivedTab, TabId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import type { CloseTabsOptions, TabOps } from '../tabs/ops' +import type { PageDialogs$ } from './dialogs' +import { gestureCount, watchGestures } from './gestures' + +/** How long Orbit waits for the page preload to say which navigation a warning stopped. */ +const ASK_MS = 250 +/** After Leave, the warning of the navigation Orbit starts again goes through within this time. */ +const ALLOW_MS = 5_000 +/** + * After Leave on a navigation the page started and Orbit can't start again (a page's own Back, a + * frame's own navigation), the user's next try goes through within this time. + */ +const ALLOW_RETRY_MS = 10_000 +/** A navigation Orbit started is the one a warning stopped, within this time. */ +const STARTED_MS = 10_000 +/** A pop-up's window began closing: its page's warning comes within this time. */ +const CLOSING_MS = 5_000 +/** A window whose pages went is closing: its tabs are asleep once it has, or after this time. */ +const WINDOW_CLOSE_MS = 2_000 +/** The page preload's own world, where it answers main (`__orbitLeave`, src/preload/page.ts). */ +const PRELOAD_WORLD = 999 + +const STOPPED = `typeof __orbitLeave === 'object' ? __orbitLeave.stopped() : ''` +const REDO = `typeof __orbitLeave === 'object' ? __orbitLeave.redo() : false` + +/** A navigation Orbit started (the address field, Back, Reload…), to start again on Leave. */ +interface Started { + redo: () => void + reload: boolean + until: number +} + +type Stopped = Started | 'page' | 'close' + +/** What Orbit knows of one page about its leave-page warning. */ +interface LeavePage { + /** + * The page's gesture count (`gestureCount`, D77) when its current document began: a document + * that no gesture has reached, in any of its frames, never warns (Chromium's rule), so Orbit + * closes it without asking the engine. + */ + since: number + /** Main-frame documents so far. */ + doc: number + /** + * Auto-archive or sleep tried to let the page go, and it warned: they leave it until it navigates + * or gets a new user gesture (D73). `gestures`: its gesture count then. + */ + refused?: { doc: number; gestures: number } + /** The user chose Leave: the page's next warning goes through until then. */ + allowUntil: number + /** The navigation Orbit started last, until it starts or a warning stops it. */ + started?: Started + /** A pop-up's window is closing (its 'close' came; the warning comes after it). */ + closingUntil: number + /** "Leave site?" is open over the page, for this stopped navigation or close. */ + asking?: Stopped +} + +export interface LeaveInfo { + /** User gestures in the page's current document, from any of its frames. */ + gestures: number + /** Auto-archive and sleep found the page warning, and leave it until it navigates or gets a new gesture. */ + refused: boolean + /** The page's next warning goes through (the user chose Leave). */ + allowed: boolean +} + +export interface PageLeave { + /** Whether auto-archive and sleep may try to let a live tab's page go (ops.mayDiscard). */ + mayDiscard(tabId: TabId): boolean + /** Tests: what Orbit knows of a page. */ + info(page: WebContents): LeaveInfo | null +} + +/** + * The leave-page warning (NV-24): "Leave site? Changes you made may not be saved" when closing or + * navigating away from a page that asks for it (`beforeunload`), in Orbit's own dialog over the page. + * + * - **Navigations.** The engine runs the `beforeunload` of every frame, and where Chromium would ask + * (a frame that has had a user gesture cancels the event), Electron says so (`will-prevent-unload`) + * and stops the navigation unless told there and then to go on. So Orbit goes on only where the + * user just chose Leave; otherwise it asks, and on Leave starts the navigation again: one Orbit + * started (the address field, Back, Reload…) from here, one the page started (a link, a form, + * `location`, its own reload) from the page preload. A page's own Back and a frame's own + * navigation can't be started again: the user's next try within ALLOW_RETRY_MS goes through. + * - **Closing a tab, a window or a Space** closes each page through the engine (D73): every frame's + * `beforeunload` runs, as when Chromium closes a tab, and a page that warns stays. Orbit shows its + * tab and asks there; Leave closes it, Cancel keeps that tab (or the window, or the Space). A page + * whose document no user gesture has reached, in any of its frames (`gestures.ts`, D77), closes at + * once: it can't warn (the engine itself would let its main frame warn on a close, which is why + * ⌘W, Orbit's own key, is no gesture). A busy page that may warn holds its close until the engine + * answers, up to the engine's own limit (D77). Quitting doesn't ask. + * - **A pop-up's window.** Closing it runs the page's `beforeunload` as a navigation does. + * - **Auto-archive and sleep** let a page go the same way (D73), and keep it, without asking, when + * the engine says it warned; they leave it until it navigates or gets a new user gesture. The + * engine's close leaves a page it keeps with a user activation (D77). + * + * Orbit runs no page's `beforeunload` of its own: only the engine does, once a close, an archive or + * a sleep has started. A page's own alert, confirm or prompt goes as soon as Orbit navigates the + * page: the page can't answer its warning while it waits for one. + */ +export function registerLeave(ctx: MainContext, ops: TabOps, dialogs: PageDialogs$): PageLeave { + const { store, tabs, windows } = ctx + const pages = new Map() + /** Windows the user agreed to close (the next 'close' goes through). */ + const confirmedWindows = new Set() + /** Windows and tabs Orbit is letting go now. */ + const guardingWindows = new Set() + const guardingTabs = new Set() + + const stateOf = (page: WebContents): LeavePage => { + let p = pages.get(page.id) + if (!p) { + p = { since: gestureCount(page), doc: 0, allowUntil: 0, closingUntil: 0 } + pages.set(page.id, p) + } + return p + } + + /** User gestures in the page's current document, from any of its frames (D77). */ + const gesturesIn = (page: WebContents, p: LeavePage | undefined): number => (p ? gestureCount(page) - p.since : 0) + + /** + * Whether a page could warn now: Chromium's warning needs a user gesture in one of the page's + * frames first, and a page waiting on its own alert, confirm or prompt goes with it. + */ + const mayWarn = (page: WebContents | undefined): page is WebContents => + !!page && !page.isDestroyed() && gesturesIn(page, pages.get(page.id)) > 0 && !dialogs.pending(page, 'script') + + const isRefused = (page: WebContents, p: LeavePage | undefined): boolean => + !!p?.refused && p.refused.doc === p.doc && p.refused.gestures === gestureCount(page) + + /** Runs one of the preload's answers in its world; `fallback` when the page is gone or too busy. */ + const ask = (page: WebContents, code: string, fallback: T, timeout = ASK_MS): Promise => { + if (page.isDestroyed()) return Promise.resolve(fallback) + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(fallback), timeout) + page.executeJavaScriptInIsolatedWorld(PRELOAD_WORLD, [{ code }]).then( + (value: unknown) => { + clearTimeout(timer) + resolve(value as T) + }, + () => { + clearTimeout(timer) + resolve(fallback) + }, + ) + }) + } + + /** Closes a tab's page through the engine (D73): true once it's gone, false if a frame warned. */ + const unload = (tabId: TabId): Promise => new Promise((resolve) => tabs.unload(tabId, resolve)) + + /** Puts a tab's page on screen, so its dialog shows: its window shows the tab. */ + const reveal = (tabId: TabId) => { + if (store.state.tabs[tabId]?.floating || windows.tabViewBounds(tabId)) return + const windowId = windows.windowOfTab(tabId) ?? windows.focusedWindowId() ?? windows.ids()[0] + if (windowId) ops.activate(windowId, tabId) + } + + /** "Leave site?" over a tab whose page stayed when Orbit closed it: true to close it. */ + const askToClose = async (tabId: TabId): Promise => { + const page = tabs.contents(tabId) + if (!page) return true + reveal(tabId) + const answer = await dialogs.open(page, leaveDialog(false)) + // The page went meanwhile: nothing is left to keep. + if (!answer && page.isDestroyed()) return true + return answer?.ok === true + } + + /** + * Lets tabs' pages go through the engine, all at once, and asks over each that stayed, one at a + * time (D73): false as soon as the user keeps one (Cancel). The pages that went are in `gone`: + * their tabs are the caller's to close or put to sleep. + */ + const letGo = async (tabIds: TabId[], gone: TabId[]): Promise => { + const went = await Promise.all(tabIds.map(unload)) + for (const [i, tabId] of tabIds.entries()) if (went[i]) gone.push(tabId) + for (const [i, tabId] of tabIds.entries()) { + if (went[i] || !store.state.tabs[tabId]) continue + if (!(await askToClose(tabId))) return false + } + return true + } + + /** Auto-archive or sleep found the page warning: not again until it navigates or gets a new gesture. */ + const refuse = (tabId: TabId) => { + const page = tabs.contents(tabId) + if (!page) return + const p = stateOf(page) + p.refused = { doc: p.doc, gestures: gestureCount(page) } + } + + // ---- Navigations ---- + + /** A warning stopped a navigation (or a pop-up's close): asks, and on Leave goes on with it. */ + const askToLeave = async (page: WebContents, p: LeavePage, stopped: Stopped) => { + if (p.asking) { + // A later navigation stopped while asking: Leave goes on with that one (the page's own: the + // preload notes which, as it does when asked). + p.asking = stopped + if (stopped === 'page') void ask(page, STOPPED, '') + return + } + p.asking = stopped + try { + const reload = + stopped === 'page' ? (await ask(page, STOPPED, '')) === 'reload' : stopped !== 'close' && stopped.reload + if (page.isDestroyed()) return + const answer = await dialogs.open(page, leaveDialog(reload)) + const now = p.asking + if (!answer?.ok || !now || page.isDestroyed()) return + p.allowUntil = Date.now() + (now === 'page' ? ALLOW_RETRY_MS : ALLOW_MS) + if (now === 'close') BrowserWindow.fromWebContents(page)?.close() + else if (now === 'page') void ask(page, REDO, false, ALLOW_MS) + else now.redo() + } finally { + p.asking = undefined + } + } + + /** + * Navigations Orbit starts in a tab: the page's own alert, confirm or prompt goes first (it can't + * answer its warning while it waits), and the navigation is noted, to start it again on Leave. + */ + const watchNavigations = (page: WebContents, p: LeavePage) => { + const wrap = (target: object, name: string, reload: boolean, navigates: (args: unknown[]) => boolean) => { + const original = (target as Record)[name] + if (typeof original !== 'function') return + const replacement = function (this: unknown, ...args: unknown[]) { + if (!page.isDestroyed() && navigates(args)) { + dialogs.dismiss(page, 'script') + p.started = { + reload, + until: Date.now() + STARTED_MS, + redo: () => { + const result: unknown = Reflect.apply(original, target, args) + // A load a warning stops again is ERR_ABORTED: its failure is the page's to show. + if (result instanceof Promise) result.catch(() => {}) + }, + } + } + return Reflect.apply(original, this, args) + } + try { + Object.defineProperty(target, name, { value: replacement, configurable: true, writable: true }) + } catch { + // Not replaceable: a dialog still goes when the navigation starts, and Leave lets the next try through. + } + } + const always = () => true + wrap(page, 'loadURL', false, always) + wrap(page, 'reload', true, always) + wrap(page, 'reloadIgnoringCache', true, always) + const history = page.navigationHistory + const moves = (offset: number) => offset !== 0 && history.canGoToOffset(offset) + wrap(history, 'goBack', false, () => history.canGoBack()) + wrap(history, 'goForward', false, () => history.canGoForward()) + wrap(history, 'goToOffset', false, ([offset]) => typeof offset === 'number' && moves(offset)) + wrap(history, 'goToIndex', false, ([index]) => typeof index === 'number' && moves(index - history.getActiveIndex())) + } + + /** Watches a page's gestures, navigations and warnings: a tab's or a pop-up's. */ + const watched = new WeakSet() + const watch = (page: WebContents, kind: 'tab' | 'popup') => { + if (watched.has(page)) return + watched.add(page) + // A press in any of its frames counts (D77): an embedded editor is often another site's. + watchGestures(page) + const p = stateOf(page) + page.on('did-start-navigation', (details) => { + if (!details.isMainFrame) return + // The navigation goes on: nothing is stopped, and a Leave was used or no longer needed. + p.started = undefined + p.allowUntil = 0 + // A page that goes takes its dialogs with it. + if (!details.isSameDocument) dialogs.dismiss(page, 'all') + }) + page.on('did-navigate', () => { + // A new document: no gesture has reached it yet. + p.doc++ + p.since = gestureCount(page) + }) + page.on('will-prevent-unload', (event) => { + if (windows.isUi(page)) return + // A close Orbit started through the engine (D73): the page stays, and that close asks (or, + // for auto-archive and sleep, leaves it). + const tabId = tabs.tabIdFor(page) + if (tabId && tabs.isUnloading(tabId)) { + tabs.kept(tabId) + return + } + const now = Date.now() + if (now <= p.allowUntil) { + p.allowUntil = 0 + event.preventDefault() + return + } + const started = p.started && now <= p.started.until ? p.started : undefined + p.started = undefined + const closing = now <= p.closingUntil + p.closingUntil = 0 + void askToLeave(page, p, closing ? 'close' : (started ?? 'page')) + }) + if (kind === 'tab') watchNavigations(page, p) + page.once('destroyed', () => pages.delete(page.id)) + } + + ctx.hooks.tabCreated.push((_tabId, page) => watch(page, 'tab')) + + // A pop-up's page is its window's own: closing the window runs its `beforeunload`. + app.on('browser-window-created', (_event, win) => { + const page = win.webContents + watch(page, 'popup') + win.on('close', () => { + if (!page.isDestroyed() && !windows.isUi(page) && !tabs.tabIdFor(page)) { + stateOf(page).closingUntil = Date.now() + CLOSING_MS + } + }) + }) + + // ---- Closing tabs ---- + + /** + * Closes tabs once each page that may warn was let go through the engine: one that warns stays, + * and Orbit asks over it (Cancel keeps that tab), or, for auto-archive, keeps it without asking. + */ + const closeAsking = async ( + tabIds: TabId[], + asking: TabId[], + reason: ArchivedTab['reason'], + opts: CloseTabsOptions, + ) => { + for (const id of asking) guardingTabs.add(id) + const keep = new Set() + try { + const went = await Promise.all(asking.map(unload)) + for (const [i, tabId] of asking.entries()) { + if (went[i] || !store.state.tabs[tabId]) continue + if (opts.idle) { + refuse(tabId) + keep.add(tabId) + } else if (!(await askToClose(tabId))) keep.add(tabId) + } + } finally { + for (const id of asking) guardingTabs.delete(id) + } + if (windows.quitting) return + const closing = tabIds.filter((id) => !keep.has(id) && store.state.tabs[id]) + if (closing.length > 0) ops.closeTabs(closing, reason, { ...opts, confirmed: true }) + } + + ops.closeGuard = (tabIds, reason, opts) => { + // Orbit's own closes (a private window's tabs, a download's empty tab) go; auto-archive (`idle`) + // lets its pages go through the engine. + if (reason === 'auto' && !opts.idle) return false + const ids = [...new Set(tabIds)] + // Being let go already: that answer closes it. + if (ids.some((id) => guardingTabs.has(id))) return true + const asking = ids.filter((id) => mayWarn(tabs.contents(id))) + if (asking.length === 0) return false + void closeAsking(ids, asking, reason, opts) + return true + } + + // ---- Auto-sleep ---- + + ops.sleepGuard = (tabId) => { + if (guardingTabs.has(tabId)) return true + if (!mayWarn(tabs.contents(tabId))) return false + guardingTabs.add(tabId) + void unload(tabId).then((gone) => { + guardingTabs.delete(tabId) + if (windows.quitting) return + if (gone) tabs.sleep(tabId) + else refuse(tabId) + }) + return true + } + + // ---- Closing a window ---- + + windows.onWindowCreated((windowId, win) => { + win.on('close', (event) => { + // Quitting doesn't ask; another guard asking first (downloads in progress) goes first. + if (windows.quitting || event.defaultPrevented) return + if (confirmedWindows.delete(windowId)) return + const asking = tabs + .liveTabIds() + .filter((id) => windows.windowOfTab(id) === windowId && !guardingTabs.has(id) && mayWarn(tabs.contents(id))) + if (asking.length === 0) return + event.preventDefault() + if (guardingWindows.has(windowId)) return + guardingWindows.add(windowId) + void (async () => { + const gone: TabId[] = [] + let closes = false + for (const id of asking) guardingTabs.add(id) + try { + closes = await letGo(asking, gone) + } finally { + for (const id of asking) guardingTabs.delete(id) + guardingWindows.delete(windowId) + } + if (windows.quitting) return + const sleep = () => { + for (const id of gone) tabs.sleep(id) + } + // Cancel keeps the window: the pages that went are asleep, and show again when their tab does. + if (!closes || win.isDestroyed()) { + sleep() + return + } + // Their tabs sleep once the window has gone (before, the window would load them again), or + // if it stays (another guard kept it). + const timer = setTimeout(sleep, WINDOW_CLOSE_MS) + win.once('closed', () => { + clearTimeout(timer) + sleep() + }) + confirmedWindows.add(windowId) + win.close() + })() + }) + win.once('closed', () => confirmedWindows.delete(windowId)) + }) + + // ---- Deleting a Space ---- + + tabs.letGo = async (tabIds) => { + const asking = [...new Set(tabIds)].filter((id) => !guardingTabs.has(id) && mayWarn(tabs.contents(id))) + if (asking.length === 0) return true + const gone: TabId[] = [] + for (const id of asking) guardingTabs.add(id) + try { + const goes = await letGo(asking, gone) + // Cancel keeps the Space: the pages that went are asleep. + if (!goes) for (const id of gone) tabs.sleep(id) + return goes && !windows.quitting + } finally { + for (const id of asking) guardingTabs.delete(id) + } + } + + // ---- Auto-archive and sleep ---- + + const service: PageLeave = { + mayDiscard(tabId) { + const page = tabs.contents(tabId) + if (!page) return true + // A page waiting on the user (its dialog, or a sign-in) waits for them. + if (dialogs.pending(page, 'all')) return false + // Being let go already, or it warned when last tried (D73). + return !guardingTabs.has(tabId) && !isRefused(page, pages.get(page.id)) + }, + info(page) { + const p = pages.get(page.id) + if (!p) return null + return { gestures: gesturesIn(page, p), refused: isRefused(page, p), allowed: Date.now() <= p.allowUntil } + }, + } + ops.mayDiscard = (tabId) => service.mayDiscard(tabId) + return service +} diff --git a/src/main/pages/other-apps.ts b/src/main/pages/other-apps.ts new file mode 100644 index 00000000..c914ba01 --- /dev/null +++ b/src/main/pages/other-apps.ts @@ -0,0 +1,352 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { app, BrowserWindow, shell, type WebContents } from 'electron' +import { + appDialog, + appName, + appScheme, + launchableUrl, + noAppMessage, + type OtherApp, + type PageDialogAnswer, +} from '../../shared/pages' +import { isPrivateProfile } from '../../shared/private' +import { permissionOrigin } from '../../shared/site' +import type { ProfileId, TabId } from '../../shared/state' +import { sectionOf } from '../../shared/tree' +import type { MainContext } from '../context' +import { setAppLinkHandler } from '../nav/permissions' +import { activeTabOf } from '../windows' +import type { PageDialogs$ } from './dialogs' +import { gestureCount, watchGestures } from './gestures' + +/** + * Opens a link in the app the OS has for it: the one place Orbit launches another app (NV-19). + * Fails when the OS couldn't. Test runs put a recorder in its place, so no test ever starts an app. + */ +export type LaunchApp = (url: string) => Promise + +/** + * The app the OS opens a link in: its name and icon, `undefined` when the OS can't say (Linux may + * not know), or `null` when the OS says no app opens links of that kind. + */ +export type FindApp = (url: string) => Promise + +/** + * What came of a page's link to another app (NV-19): Orbit asked, opened it without asking (always + * allowed), or dropped it (a question was up already, or the page had no new user gesture for it). + */ +export type AppLinkOutcome = 'asked' | 'opened' | 'dropped' + +/** The real launch: the OS opens the link in its app. */ +export const launchInApp: LaunchApp = (url) => shell.openExternal(launchableUrl(url)) + +/** The app the OS has for a link: macOS and Windows name it and give its icon; Linux names its desktop entry. */ +export const findSystemApp: FindApp = async (url) => { + let name = '' + try { + name = app.getApplicationNameForProtocol(url) + } catch { + // The OS couldn't say. + } + if (process.platform !== 'darwin' && process.platform !== 'win32') { + const shown = appName(name, process.platform) + return shown ? { name: shown } : undefined + } + let icon: string | undefined + try { + const info = await app.getApplicationInfoForProtocol(url) + if (!info.icon.isEmpty()) icon = info.icon.resize({ width: 64, height: 64 }).toDataURL() + name ||= info.name + } catch { + // No app, or none the OS will describe. + } + const shown = appName(name, process.platform) + return shown ? { name: shown, ...(icon ? { icon } : {}) } : null +} + +/** + * The apps a test run's OS has (ORBIT_E2E_APPS: JSON, by scheme, `{ name, icon? }`, or null for "no + * app opens these"); any other scheme's app goes unnamed, as on Linux. + */ +export function findTestApp(spec: string | undefined): FindApp { + let apps: Record = {} + try { + const parsed = JSON.parse(spec ?? '{}') as unknown + if (parsed && typeof parsed === 'object') apps = parsed as Record + } catch { + // None. + } + return async (url) => { + const found = apps[appScheme(url)] + if (found === null) return null + return found && typeof found.name === 'string' ? found : undefined + } +} + +/** + * The sites always allowed to open links of a kind in their app ("Always allow", NV-19), per + * profile, origin and scheme. Kept in their own file; a private profile's stay in memory and go + * with it. + */ +class AlwaysAllowed { + private saved: Record> = {} + private memory: Record> = {} + + constructor( + private file: string, + private isPrivate: (profileId: ProfileId) => boolean, + ) {} + + load(): void { + let raw: unknown + try { + raw = (JSON.parse(readFileSync(this.file, 'utf8')) as { allowed?: unknown }).allowed + } catch { + return + } + if (!raw || typeof raw !== 'object') return + for (const [profileId, origins] of Object.entries(raw as Record)) { + if (!origins || typeof origins !== 'object') continue + for (const [origin, schemes] of Object.entries(origins as Record)) { + if (!Array.isArray(schemes) || permissionOrigin(origin) !== origin) continue + const kept = schemes.filter((s): s is string => typeof s === 'string' && appScheme(`${s}:`) === s) + if (kept.length === 0) continue + const origins = this.saved[profileId] ?? {} + origins[origin] = kept + this.saved[profileId] = origins + } + } + } + + has(profileId: ProfileId, origin: string, scheme: string): boolean { + return this.of(profileId)[profileId]?.[origin]?.includes(scheme) ?? false + } + + add(profileId: ProfileId, origin: string, scheme: string): void { + const all = this.of(profileId) + const origins = all[profileId] ?? {} + const schemes = origins[origin] ?? [] + if (schemes.includes(scheme)) return + origins[origin] = [...schemes, scheme] + all[profileId] = origins + if (!this.isPrivate(profileId)) this.save() + } + + /** A profile that was deleted, or a private session that ended. */ + forget(profileId: ProfileId): void { + if (profileId in this.memory) delete this.memory[profileId] + if (!(profileId in this.saved)) return + delete this.saved[profileId] + this.save() + } + + private of(profileId: ProfileId): Record> { + return this.isPrivate(profileId) ? this.memory : this.saved + } + + /** Written at once: a choice is rare, and small. */ + private save(): void { + try { + mkdirSync(dirname(this.file), { recursive: true }) + const next = `${this.file}.tmp` + writeFileSync(next, JSON.stringify({ allowed: this.saved })) + renameSync(next, this.file) + } catch (err) { + console.error('[pages] saving “Always allow”', err) + } + } +} + +/** Said when the OS couldn't open a link in its app. */ +const NOT_OPENED = 'Couldn’t open the link in another app' + +/** A page that never showed one: nothing committed, nothing to go back to. */ +function neverShowedPage(page: WebContents): boolean { + const url = page.getURL() + return (url === '' || url === 'about:blank') && !page.navigationHistory.canGoBack() +} + +/** + * Links that open other apps (NV-19): `mailto:`, `tel:`, `zoommtg:`… The engine hands each to + * Orbit instead of launching it (`setAppLinkHandler`, nav/permissions.ts), naming the frame that + * followed the link. Orbit asks over that page, in a page dialog: the app with its icon, the site, + * and "Always allow" for a web site; Cancel is the default. Open launches the app through + * `launch`, the one place Orbit starts another app. A site always allowed for a kind of link opens + * it without asking, once per user gesture in the page (a click or a key in any of its frames, + * `gestureCount`, D77), as in Chromium: a page can't open the app again and again unasked. A page + * asks one question at a time: a link it follows meanwhile is dropped. Once it has asked, it asks + * again only after a new user gesture in it, whatever the answer was, Open or Cancel (D77): a page + * can't keep asking. + * + * A tab, Peek, Mini Window or pop-up opened only for such a link (`target=_blank`, `window.open`, + * or a redirect there) never shows a page: it closes itself once its navigation ends, and the tab + * it came from is selected again. The engine names the page that opened it as the one asking, so + * the question shows there. When the one asking is that page itself (nothing opened it), it closes + * after the answer. + */ +export function registerOtherApps( + ctx: MainContext, + dialogs: PageDialogs$, + deps: { + launch: LaunchApp + findApp: FindApp + /** Test runs: hears what came of each link (NV-19). */ + note?: (url: string, outcome: AppLinkOutcome) => void + }, +): void { + const { store, tabs, windows, commands } = ctx + const choices = new AlwaysAllowed(join(ctx.paths.userData, 'orbit-app-links.json'), (id) => + isPrivateProfile(store.state.profiles[id]), + ) + choices.load() + // A deleted profile's choices, and a private session's, go with it. + store.subscribe((next, prev) => { + if (next.profiles === prev.profiles) return + for (const id of Object.keys(prev.profiles)) if (!next.profiles[id]) choices.forget(id) + }) + + /** Pages asking now (by contents id): one question at a time. */ + const asking = new Set() + /** Where each watched page's main frame is going, or went last: the address after any redirect. */ + const going = new Map() + /** Each page's gesture count (by contents id) when it last followed a link to another app: that link used them up. */ + const used = new Map() + /** Pages (by contents id) that asked, or opened an app, already: they do again only with a new gesture. */ + const spent = new Set() + + const launchOnly = (page: WebContents) => + !page.isDestroyed() && !asking.has(page.id) && !!appScheme(going.get(page.id)) && neverShowedPage(page) + + /** Closes a page opened only for another app's link: its tab (selecting its opener again), Peek, Mini Window or pop-up. */ + const closeLaunchOnly = (page: WebContents) => { + if (!launchOnly(page)) return + const tabId = tabs.tabIdFor(page) + if (tabId) { + closeTab(tabId) + return + } + const win = BrowserWindow.fromWebContents(page) + // A pop-up's page is its window's own contents (a browser window's is Orbit's UI). + if (win && !win.isDestroyed() && win.webContents === page) win.close() + } + + const closeTab = (tabId: TabId) => { + const tab = store.state.tabs[tabId] + if (!tab) return + const windowId = windows.windowOfTab(tabId) + if (tab.floating === 'peek') { + if (windowId) void commands.run('peek.close', {}, { windowId }) + return + } + if (tab.floating === 'mini') { + if (windowId) void commands.run('mini.control', { op: 'close' }, { windowId }) + return + } + // Pinned tabs and Favorites stay, whatever they were asked to load. + if (sectionOf(store.state, tabId) !== 'today') return + const wasShown = !!windowId && activeTabOf(store.state, windowId) === tabId + const opener = tab.openerTabId && store.state.tabs[tab.openerTabId] ? tab.openerTabId : undefined + // It never showed a page: no Archive entry, and nothing for Reopen Closed Tab. + store.update((d) => { + const t = d.tabs[tabId] + if (t) t.url = 'about:blank' + }) + // The opener first: closing the selected tab would select a neighbour on the way. + const back = wasShown && opener ? commands.run('tabs.activate', { tabId: opener }, { windowId }) : undefined + void Promise.resolve(back) + .then(() => commands.run('tabs.close', { tabId }, { windowId })) + .catch(() => {}) + } + + const watch = (page: WebContents) => { + const { id } = page + page.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) going.set(id, details.url) + }) + page.on('did-redirect-navigation', (details) => { + if (details.isMainFrame) going.set(id, details.url) + }) + page.on('did-stop-loading', () => { + if (launchOnly(page)) setImmediate(() => closeLaunchOnly(page)) + }) + // A click or a key in any of its frames, one from another site's too (D77). + watchGestures(page) + page.once('destroyed', () => { + going.delete(id) + asking.delete(id) + used.delete(id) + spent.delete(id) + }) + } + ctx.hooks.tabCreated.push((_tabId, contents) => { + watch(contents) + contents.on('did-create-window', (popup) => watch(popup.webContents)) + }) + + const toastFor = (page: WebContents, message: string) => { + const tabId = page.isDestroyed() ? undefined : tabs.tabIdFor(page) + ctx.toast((tabId && windows.windowOfTab(tabId)) || windows.focusedWindowId(), message) + } + + const launch = (page: WebContents, url: string) => { + deps.launch(url).catch((err: unknown) => { + console.error('[pages] opening another app', err) + toastFor(page, NOT_OPENED) + }) + } + + const ask = async (profileId: ProfileId, page: WebContents, url: string, scheme: string, origin: string | null) => { + const { id } = page + const at = page.getURL() + let answer: PageDialogAnswer | null = null + try { + const found = await deps.findApp(url) + // The page went, or moved on, while the OS was asked. + if (page.isDestroyed() || page.getURL() !== at) return + if (found === null) { + toastFor(page, noAppMessage(scheme)) + return + } + answer = await dialogs.open(page, appDialog(found, origin)) + } finally { + asking.delete(id) + } + if (answer?.ok) { + if (answer.checked === true && origin && /^https?:\/\//i.test(origin)) choices.add(profileId, origin, scheme) + launch(page, url) + } + if (!page.isDestroyed()) closeLaunchOnly(page) + } + + setAppLinkHandler((profileId, page, details) => { + const url = 'externalURL' in details && typeof details.externalURL === 'string' ? details.externalURL : '' + const scheme = appScheme(url) + if (!scheme || page.isDestroyed() || !dialogs.hostable(page)) return + // The site asking is the frame that followed the link (the page's own, or one in it). + const origin = permissionOrigin(details.requestingUrl) + // Each link to another app uses up the page's user gestures so far (Chromium's rule). + const count = gestureCount(page) + const fresh = count > (used.get(page.id) ?? 0) + used.set(page.id, count) + if (origin && /^https?:\/\//i.test(origin) && choices.has(profileId, origin, scheme)) { + // Always allowed: it opens without asking, but only for a gesture no link used yet (NV-19). + if (fresh) { + spent.add(page.id) + launch(page, url) + } + deps.note?.(url, fresh ? 'opened' : 'dropped') + return + } + // One question at a time; and once the page has asked, whatever the answer (Open or Cancel), + // none until the user does something in it (D77). + if (asking.has(page.id) || (spent.has(page.id) && !fresh)) { + deps.note?.(url, 'dropped') + return + } + spent.add(page.id) + asking.add(page.id) + deps.note?.(url, 'asked') + void ask(profileId, page, url, scheme, origin).catch((err: unknown) => console.error('[pages] link to an app', err)) + }) +} diff --git a/src/main/pages/print.ts b/src/main/pages/print.ts new file mode 100644 index 00000000..c47bc825 --- /dev/null +++ b/src/main/pages/print.ts @@ -0,0 +1,435 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { app, BrowserWindow, dialog, type IpcMainEvent, ipcMain, type WebContents } from 'electron' +import { + cleanSettings, + countPdfPages, + DEFAULT_PRINT_SETTINGS, + LAYOUT_SETTINGS, + type PrintContent, + type PrintDestination, + type PrintPreview, + type PrintSettings, + pagesProblem, + pdfFileName, + pdfRanges, + printerRanges, + printersFromJson, + SAVE_AS_PDF, +} from '../../shared/print' +import type { Rect } from '../../shared/rpc' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import { withDialog } from '../media/dialog-watch' +import { createCovers, type HostKind } from './covers' +import { setPageEntries } from './entry' + +/** How long the preview waits for the page to print; a page that hangs gets no preview. */ +const PREVIEW_TIMEOUT_MS = 15_000 +/** How long a page still loading gets to finish before its preview is made (Chromium prints once it has loaded). */ +const LOAD_WAIT_MS = 5_000 + +/** A print job, as it leaves Orbit. */ +export type PrintJob = + | { kind: 'printer'; options: Electron.WebContentsPrintOptions } + | { kind: 'pdf'; data: Buffer; name: string } + +/** + * Sends a job out of Orbit: to a printer, or a PDF to a file the user picks. The one place Orbit + * prints (NV-07); test runs record each job instead, so none ever prints or asks where to save. + * Resolves `ok: false` when nothing went out: with a reason when it failed, without when the user + * cancelled. + */ +export type DeliverPrint = (page: WebContents, job: PrintJob) => Promise<{ ok: boolean; reason?: string }> + +/** The printers a page can print on. */ +export type ListPrinters = (page: WebContents) => Promise + +/** The real delivery: Electron's `print` without its own dialog, or the PDF saved where the user says. */ +export const deliverPrint: DeliverPrint = async (page, job) => { + if (job.kind === 'printer') { + return new Promise((resolve) => + page.print({ ...job.options, silent: true }, (ok, reason) => resolve(ok ? { ok } : { ok, reason })), + ) + } + const options = { + defaultPath: join(app.getPath('downloads'), job.name), + filters: [{ name: 'PDF', extensions: ['pdf'] }], + } + const win = BrowserWindow.fromWebContents(page) + // The picture-in-picture player keeps clear of it (MD-06). + const { canceled, filePath } = await (win + ? withDialog(win, dialog.showSaveDialog(win, options)) + : dialog.showSaveDialog(options)) + if (canceled || !filePath) return { ok: false } + await writeFile(filePath, job.data) + return { ok: true } +} + +/** The OS's printers. */ +export const systemPrinters: ListPrinters = async (page) => + (await page.getPrintersAsync()).map((p) => ({ id: p.name, name: p.displayName || p.name })) + +/** Test runs: the printers named in ORBIT_E2E_PRINTERS (JSON: `[{ id, name }]`), else none. */ +export function testPrinters(json: string | undefined): ListPrinters { + const list = printersFromJson(json) + return async () => list +} + +/** A print dialog as tests see it. */ +export interface PrintInfo { + dialog: number + tabId: TabId | null + host: HostKind | null + onScreen: boolean + focused: boolean + bounds?: Rect + preview: PrintPreview['state'] + /** The pages of the preview, once it's ready. */ + pages: number | null + settings: PrintSettings + destinations: string[] + busy: boolean + error: string | null +} + +/** A job a test run's delivery recorded: what a printer would get, or the PDF's name and size. */ +export interface PrintRecord { + kind: PrintJob['kind'] + url: string + options?: Electron.WebContentsPrintOptions + name?: string + bytes?: number + pages?: number +} + +export interface Print$ { + open(page: WebContents): void + info(): PrintInfo[] +} + +interface Session { + page: WebContents + dialog: number + /** The content id the view last got this dialog's state under. */ + contentId: number + settings: PrintSettings + destinations: PrintDestination[] + preview: PrintPreview + /** Bumps with each preview asked for: an older one that comes late is dropped. */ + generation: number + busy: boolean + error?: string + /** + * The page's own `window.print()` opened it (D76): its close and navigations wait for it, and it + * alone hears when it closes (`PAGE_CHANNEL.printClosed`). + */ + byPage: boolean + off: () => void +} + +/** + * Print (NV-07): Orbit's print dialog over a page, as Chromium's print preview is (Electron has + * none, and its `print` opens the system's dialog instead). ⌘P / Ctrl+P, File → Print, the command + * bar, the page menu and the page's own `window.print()` open it; Esc closes it and leaves the page + * as it was. It shows the page as it prints (the engine's PDF of it, in the PDF viewer) with the + * destination (the OS's printers, Save as PDF), pages, copies, layout, colour, headers and footers + * and background graphics, and remembers the last choices until Orbit quits. Like a page's dialog + * (NV-24), it covers that page only, waits while the page is off screen, takes the keyboard while + * it shows (KS-19) and gives it back to the page when it closes (KS-16). + */ +export function registerPrint(ctx: MainContext, deps: { deliver: DeliverPrint; printers: ListPrinters }): Print$ { + const { commands, tabs } = ctx + const sessions = new Map() + let nextId = 1 + /** The last dialog's choices: the next one starts from them (Chromium keeps them across runs). */ + let last: PrintSettings = DEFAULT_PRINT_SETTINGS + + const contentOf = (s: Session): PrintContent => ({ + id: s.contentId, + dialog: s.dialog, + title: s.page.isDestroyed() ? '' : s.page.getTitle(), + settings: s.settings, + destinations: s.destinations, + preview: s.preview, + busy: s.busy, + ...(s.error ? { error: s.error } : {}), + }) + + const covers = createCovers(ctx, { + view: 'print', + // Above the find bar, as a page's dialog is. + aboveTools: true, + content: (page) => { + const s = sessions.get(page.id) + return s ? contentOf(s) : undefined + }, + // Esc closes the dialog wherever its keyboard is, the preview included (KS-19). + onKey: (page, _view, event, input) => { + if (input.type !== 'keyDown' || input.key !== 'Escape') return + if (input.meta || input.control || input.alt || input.shift) return + event.preventDefault() + close(page) + }, + }) + + const current = (s: Session) => !s.page.isDestroyed() && sessions.get(s.page.id) === s + const changed = (s: Session) => { + if (!current(s)) return + s.contentId = nextId++ + covers.update(s.page) + } + + const close = (page: WebContents) => { + const s = sessions.get(page.id) + if (!s) return + sessions.delete(page.id) + s.off() + // The view goes, and the page gets the keyboard back (KS-16). + covers.update(page) + // A close or a navigation the page started after its `print()` goes on now (the page preload). + // Only the page whose `print()` opened the dialog waits for it (D76). + if (s.byPage && !page.isDestroyed()) page.send(PAGE_CHANNEL.printClosed) + } + + const preview = async (s: Session) => { + const generation = ++s.generation + if (s.preview.state !== 'loading') { + s.preview = { state: 'loading' } + changed(s) + } + const { page } = s + // A page that prints while it loads prints once it has (as Chromium's does). + if (page.isLoading()) { + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer) + if (!page.isDestroyed()) page.off('did-stop-loading', done) + resolve() + } + const timer = setTimeout(done, LOAD_WAIT_MS) + page.once('did-stop-loading', done) + }) + } + if (!current(s) || s.generation !== generation) return + let next: PrintPreview + try { + const pdf = await withTimeout(page.printToPDF(pdfOptions(s.settings)), PREVIEW_TIMEOUT_MS) + next = { state: 'ready', version: generation, pages: countPdfPages(pdf.toString('latin1')), pdf } + } catch (err) { + if (current(s)) console.error('[pages] print preview', err) + next = { state: 'error', message: 'This page can’t be previewed.' } + } + if (!current(s) || s.generation !== generation) return + s.preview = next + changed(s) + } + + const open = (page: WebContents) => { + if (page.isDestroyed() || !covers.hostable(page)) return + // Asked again (⌘P, `window.print()`): the dialog it has stays as it is. + if (sessions.has(page.id)) { + covers.update(page) + return + } + const onNavigate = (details: Electron.Event) => { + // The page going elsewhere takes its dialog with it. + if (details.isMainFrame && !details.isSameDocument) close(page) + } + const onGone = () => close(page) + page.on('did-start-navigation', onNavigate) + page.on('render-process-gone', onGone) + page.once('destroyed', onGone) + const s: Session = { + page, + dialog: nextId++, + contentId: nextId++, + settings: { ...last, allPages: true, ranges: '', copies: 1 }, + destinations: [{ id: SAVE_AS_PDF, name: 'Save as PDF' }], + preview: { state: 'loading' }, + generation: 0, + busy: false, + byPage: false, + off: () => { + if (page.isDestroyed()) return + page.off('did-start-navigation', onNavigate) + page.off('render-process-gone', onGone) + page.off('destroyed', onGone) + }, + } + sessions.set(page.id, s) + covers.update(page) + void preview(s) + void deps.printers(page).then( + (printers) => { + if (!current(s)) return + s.destinations = [{ id: SAVE_AS_PDF, name: 'Save as PDF' }, ...printers] + // The printer used last, if it's still there; else the PDF. + if (!s.destinations.some((d) => d.id === s.settings.destination)) { + s.settings = { ...s.settings, destination: SAVE_AS_PDF } + } + changed(s) + }, + () => {}, + ) + } + + /** Prints (or saves) with the dialog's settings; the dialog closes once it has. */ + const send = async (s: Session) => { + const { page, settings } = s + if (s.busy || s.preview.state !== 'ready' || pagesProblem(settings, s.preview.pages)) return + const total = s.preview.pages + s.busy = true + s.error = undefined + changed(s) + let result: { ok: boolean; reason?: string } + try { + if (settings.destination === SAVE_AS_PDF) { + const data = await withTimeout( + page.printToPDF({ ...pdfOptions(settings), pageRanges: pdfRanges(settings, total) }), + PREVIEW_TIMEOUT_MS, + ) + result = current(s) + ? await deps.deliver(page, { kind: 'pdf', data, name: pdfFileName(page.getTitle()) }) + : { ok: false } + } else { + result = await deps.deliver(page, { kind: 'printer', options: printerOptions(page, settings, total) }) + } + } catch (err) { + console.error('[pages] print', err) + result = { ok: false, reason: 'Something went wrong' } + } + if (!current(s)) return + s.busy = false + if (result.ok) { + last = { ...settings } + close(page) + return + } + // Cancelled (where to save): the dialog stays as it was. + if (result.reason) s.error = `Couldn’t print: ${result.reason}` + changed(s) + } + + const sessionOf = (sender: WebContents | undefined, dialogId: unknown): Session | undefined => { + const page = sender ? covers.shownBy(sender)?.page : undefined + const s = page ? sessions.get(page.id) : undefined + return s && s.dialog === dialogId ? s : undefined + } + + commands.register('pages.printContent', (_args, c) => { + const shown = c.sender ? covers.shownBy(c.sender) : undefined + return shown?.content ?? null + }) + commands.register('pages.printChange', ({ dialog, settings }, c) => { + const s = sessionOf(c.sender, dialog) + if (!s || s.busy) return + const was = s.settings + s.settings = cleanSettings(settings ?? {}, was, s.destinations) + s.error = undefined + if (LAYOUT_SETTINGS.some((key) => s.settings[key] !== was[key])) void preview(s) + changed(s) + }) + commands.register('pages.printAnswer', ({ dialog, ok }, c) => { + const s = sessionOf(c.sender, dialog) + if (!s) return + if (ok === true) void send(s) + else if (!s.busy) close(s.page) + }) + + // The page's own `window.print()` (the page preload): only a page's main frame has it (D40). The + // reply says whether the dialog the page's own `print()` opened is open now: its close and + // navigations wait for it. A `print()` while a dialog that ⌘P or a menu opened shows returns at + // once and holds nothing, as Chromium's does (D76). The reply goes out at once, waiting on + // nothing, as soon as returnValue is set, so it is set once. + ipcMain.on(PAGE_CHANNEL.print, (event: IpcMainEvent) => { + let opened = false + try { + const frame = event.senderFrame + if (frame && !frame.parent && !event.sender.isDestroyed()) { + const page = event.sender + const had = sessions.has(page.id) + open(page) + const s = sessions.get(page.id) + if (s && !had) s.byPage = true + opened = s?.byPage === true + } + } catch (err) { + console.error('[pages] print', err) + } + event.returnValue = opened + }) + + setPageEntries({ print: open }) + + return { + open, + info: () => + [...sessions.values()].map((s) => { + const cover = covers.info(s.page) + return { + dialog: s.dialog, + tabId: tabs.tabIdFor(s.page) ?? null, + host: cover.host, + onScreen: cover.onScreen, + focused: cover.focused, + ...(cover.bounds ? { bounds: cover.bounds } : {}), + preview: s.preview.state, + pages: s.preview.state === 'ready' ? s.preview.pages : null, + settings: s.settings, + destinations: s.destinations.map((d) => d.id), + busy: s.busy, + error: s.error ?? null, + } + }), + } +} + +/** The PDF the preview shows, and Save as PDF saves: the whole page, laid out as it prints. */ +function pdfOptions(settings: PrintSettings): Electron.PrintToPDFOptions { + return { + landscape: settings.landscape, + displayHeaderFooter: settings.headerFooter, + printBackground: settings.background, + pageSize: paperSize(), + } +} + +/** What a printer gets: the headers and footers name the page's title and address, as the preview does. */ +function printerOptions(page: WebContents, settings: PrintSettings, total: number): Electron.WebContentsPrintOptions { + const ranges = printerRanges(settings, total) + return { + deviceName: settings.destination, + copies: settings.copies, + landscape: settings.landscape, + color: settings.color, + printBackground: settings.background, + ...(ranges ? { pageRanges: ranges } : {}), + ...(settings.headerFooter ? { header: page.getTitle(), footer: page.getURL() } : {}), + } +} + +/** Letter where the system's region uses it (the Americas' Letter countries), else A4 (as Chromium's PDF). */ +function paperSize(): 'Letter' | 'A4' { + const region = app.getLocaleCountryCode().toUpperCase() + return ['US', 'CA', 'MX', 'PR', 'PH', 'CL', 'CO', 'VE', 'GT', 'CR', 'PA', 'DO', 'SV', 'NI', 'BO'].includes(region) + ? 'Letter' + : 'A4' +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out')), ms) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (err: unknown) => { + clearTimeout(timer) + reject(err) + }, + ) + }) +} diff --git a/src/main/pages/save-page.ts b/src/main/pages/save-page.ts new file mode 100644 index 00000000..1e256da8 --- /dev/null +++ b/src/main/pages/save-page.ts @@ -0,0 +1,50 @@ +import { join } from 'node:path' +import { app, dialog, type WebContents } from 'electron' +import type { MainContext } from '../context' +import { withDialog } from '../media/dialog-watch' +import { saveFormat, savesComplete } from '../nav/page-menu' +import { setPageEntries } from './entry' + +/** A file name from a page title, without characters file systems refuse. */ +function fileName(title: string): string { + const printable = Array.from(title, (c) => (c.charCodeAt(0) < 32 ? ' ' : c)).join('') + const clean = printable + .replace(/[\\/:*?"<>|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + return clean || 'Page' +} + +/** + * Save Page As… (NV-21): ⇧⌘S (Ctrl+Shift+S; ⌘S is the sidebar's), File → Save As… (macOS), the + * command bar and the page menu. The OS save dialog asks where, from the Downloads folder, named after + * the page. A local page whose frames are all local files saves complete, its resources copied beside + * it; any other page as a single file (MHTML) or as its HTML alone, so a web page never has Orbit copy + * a local file (D49, `savesComplete` in src/main/nav/page-menu.ts). A Peek's or a Mini Window's page + * saves itself. Tests answer the dialog by replacing `dialog.showSaveDialog`, as for any save. + */ +export function registerSavePage(ctx: MainContext): void { + const save = (page: WebContents) => { + if (page.isDestroyed()) return + const windowId = ctx.windows.windowIdFor(page) + const win = windowId ? ctx.windows.get(windowId) : undefined + const complete = savesComplete(page) + const name = join(app.getPath('downloads'), fileName(page.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] } + // The picture-in-picture player keeps clear of it (MD-06). + const asked = + win && !win.isDestroyed() ? withDialog(win, dialog.showSaveDialog(win, options)) : dialog.showSaveDialog(options) + void asked.then(({ canceled, filePath }) => { + if (canceled || !filePath || page.isDestroyed()) return + // Asked again: the page may have changed while the dialog was open. + const format = saveFormat(complete && savesComplete(page), filePath) + page.savePage(filePath, format).catch((err: unknown) => console.error('[pages] save page', err)) + }) + } + setPageEntries({ savePage: save }) +} diff --git a/src/main/pages/screens.ts b/src/main/pages/screens.ts new file mode 100644 index 00000000..6095cb18 --- /dev/null +++ b/src/main/pages/screens.ts @@ -0,0 +1,516 @@ +import { + webContents as allContents, + app, + BrowserWindow, + type IpcMainEvent, + ipcMain, + type Session, + type WebContents, +} from 'electron' +import { + crashScreen, + errorScreen, + hangDialog, + type PageScreenAction, + type PageScreenContent, + screenSite, + timeoutScreen, +} from '../../shared/pages' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId } from '../../shared/state' +import type { MainContext } from '../context' +import type { TabOps } from '../tabs/ops' +import { type CoverInfo, createCovers } from './covers' +import type { PageDialogs$ } from './dialogs' +import type { FullScreen$ } from './fullscreen' + +/** + * A main-frame navigation that gets no answer from its server within this time is stopped and gets + * the timeout screen. Chromium has no such limit (it waits for a server that never answers until + * the user stops it); Orbit's is generous, so a slow server still gets its time. + */ +export const LOAD_TIMEOUT_MS = 120_000 + +/** Chromium's net::ERR_ABORTED: a navigation stopped or replaced, or turned into a download. */ +const ERR_ABORTED = -3 + +/** What a page's row shows: its title and icon. */ +interface RowLook { + title: string + favicon?: string +} + +interface Screen { + content: PageScreenContent + /** The address it's for: what Try again loads. */ + url: string + /** + * Orbit's own time limit stopped the navigation: it never committed, so the page underneath is + * the one before it, and Try again (or Reload) loads the address again rather than reloading. + */ + timedOut: boolean + /** The page had the keyboard when this happened (or nothing had): the screen takes it. */ + hadKeyboard: boolean + /** What the page's row shows while the screen is up; none for a crash (the row keeps what it had). */ + row?: RowLook + /** A timeout's: the screen the page showed when it timed out, which Back shows again. */ + under?: Screen +} + +/** What Orbit knows of one page for its screens. */ +interface PageState { + screen?: Screen + /** The address of the page's last document that loaded (not an error page). */ + committed?: string + /** + * The row when the page's last navigation started with no screen up: what an error on the site + * it showed keeps, and what Back from a timeout puts back. + */ + row?: RowLook + /** The navigation being timed: where it goes, and the timer. */ + timing?: { url: string; timer: ReturnType } + /** The page said it sends a form to this address (`PAGE_CHANNEL.formSent`): its navigation is next. */ + formTo?: string + /** The navigation under way sends a form, so it isn't timed: it may be uploading. */ + sending?: boolean + /** The address the network's return already retried once, until a page loads. */ + autoRetried?: string + /** The last certificate error the page met: Proceed lets that certificate through for its host. */ + cert?: { host: string; fingerprint: string } + /** "Page unresponsive" is open over the page. */ + hangOpen?: boolean + /** The user chose Close page there: the crash screen says so. */ + closedHung?: boolean +} + +export interface PageScreenInfo extends CoverInfo { + id: number + kind: PageScreenContent['kind'] + title: string + message: string + site: string + code?: string + /** The address Try again loads. */ + url: string + timedOut: boolean + tabId: TabId | null +} + +export interface PageScreens { + /** Tests: every page's screen. */ + info(): PageScreenInfo[] +} + +/** + * What a page's area shows when its page couldn't load (NV-13) or crashed (NV-14), in a view of + * Orbit's over that page only (`createCovers`), as a page dialog is. The page stays where it was: + * an error page keeps the address that failed (the engine's own error document is under the + * screen, so Reload and Try again load it again, and Back leaves it), and a crashed page keeps its + * tab and its row until it is reloaded. + * + * - **Load errors.** A main-frame navigation that fails shows a title, one plain sentence, the site + * and Try again (Chromium's wording, Orbit's look). The screen stays until a page loads, so a + * retry that fails again doesn't flash. The row keeps its title and icon when the site that + * failed is the one it showed; another site's failure shows that site's name instead (and its + * icon, if Orbit knows one). + * - **Time limit.** A navigation with no answer within LOAD_TIMEOUT_MS is stopped and shows "took + * too long to respond"; the address stays, and Reload or Try again loads it again. Back shows + * the page as it was before, screen and row and all. A navigation that sends a form isn't timed: + * it may be a large upload (the page preload says so, `PAGE_CHANNEL.formSent`). + * - **Certificates.** "Your connection is not private": Back to safety leads (goes back, or closes + * a tab with nowhere to go back to); Advanced, then Proceed, lets that certificate through for + * that host until Orbit quits (per profile). + * - **Offline.** When the network comes back while an error screen shows, the page loads again, + * once (the screen's view hears the engine's `online` event). + * - **Crashes.** "This page crashed" with Reload; ⌘R and ⌘⇧R reload as well. + * - **Hangs.** A page that stops responding (`unresponsive`) gets "Page unresponsive" over it: + * Wait, or Close page, which ends the page's process (its crash screen says so). It goes by + * itself when the page responds again. + */ +export function registerScreens( + ctx: MainContext, + ops: TabOps, + dialogs: PageDialogs$, + fullScreen: FullScreen$, + loadTimeout = LOAD_TIMEOUT_MS, +): PageScreens { + const { store, tabs, commands } = ctx + const states = new Map() + let nextId = 1 + + /** Whether nothing, or the page itself, has the keyboard. */ + const pageHasKeyboard = (page: WebContents, focused = allContents.getFocusedWebContents() ?? null) => + !focused || focused.isDestroyed() || focused === page + + const covers = createCovers(ctx, { + view: 'screen', + content: (page) => states.get(page.id)?.state.screen?.content, + // A screen takes the keyboard only where the page would have it: not from the sidebar's field. + mayTakeKeyboard: (page, focused) => + pageHasKeyboard(page, focused) || states.get(page.id)?.state.screen?.hadKeyboard === true, + // In full screen, Esc on a screen leaves it as on a page: pressed twice, or held (NV-20). + onKey: (page, view, event, input) => { + const tabId = page.isDestroyed() ? undefined : tabs.tabIdFor(page) + fullScreen.onKey(tabId ? ctx.windows.windowOfTab(tabId) : undefined, view, event, input) + }, + }) + + const stateOf = (page: WebContents): PageState => { + let entry = states.get(page.id) + if (!entry) { + entry = { page, state: {} } + states.set(page.id, entry) + } + return entry.state + } + + type Tab = NonNullable<(typeof store.state.tabs)[TabId]> + const tabOf = (page: WebContents): Tab | undefined => { + const tabId = tabs.tabIdFor(page) + return tabId ? store.state.tabs[tabId] : undefined + } + + const setTab = (page: WebContents, change: (tab: Tab) => void) => { + const tabId = tabs.tabIdFor(page) + if (!tabId || !store.state.tabs[tabId]) return + store.update((d) => { + const tab = d.tabs[tabId] + if (tab) change(tab) + }) + } + + const rowOf = (page: WebContents): RowLook | undefined => { + const tab = tabOf(page) + return tab && { title: tab.title, ...(tab.favicon ? { favicon: tab.favicon } : {}) } + } + + /** Puts a title and icon on the page's row (if it has one). */ + const showRow = (page: WebContents, row: RowLook) => { + const tab = tabOf(page) + if (!tab || (tab.title === row.title && tab.favicon === row.favicon)) return + setTab(page, (t) => { + t.title = row.title + if (row.favicon) t.favicon = row.favicon + else delete t.favicon + }) + } + + // ---- Showing and clearing ---- + + const show = (page: WebContents, content: Omit, url: string, timedOut: boolean) => { + const st = stateOf(page) + const before = st.screen + const screen: Screen = { content: { ...content, id: nextId++ }, url, timedOut, hadKeyboard: pageHasKeyboard(page) } + // A timeout leaves the page as it was, screen and all: Back shows that again. + if (timedOut && before) screen.under = before.timedOut ? before.under : before + // The row keeps the title and icon of the site it showed. Another site's failure is named by + // its site (with its icon, if Orbit knows one), as the engine names its own error page by its + // address. + if (content.kind !== 'crash' && content.site) { + const sameSite = screenSite(st.committed) === content.site + const icon = tabs.faviconFor(url) + screen.row = sameSite && st.row ? st.row : { title: content.site, ...(icon ? { favicon: icon } : {}) } + showRow(page, screen.row) + } + st.screen = screen + covers.update(page) + } + + const clear = (page: WebContents) => { + const st = states.get(page.id)?.state + if (!st?.screen) return + st.screen = undefined + covers.update(page) + } + + // ---- Orbit's time limit ---- + + const stopTiming = (st: PageState) => { + if (st.timing) clearTimeout(st.timing.timer) + st.timing = undefined + } + + const timedOut = (page: WebContents, url: string) => { + const st = states.get(page.id)?.state + if (!st || st.timing?.url !== url || page.isDestroyed() || !page.isLoading()) return + st.timing = undefined + // The screen first: stopping updates the tab's Back button, which the screen keeps on (Back + // shows the page underneath). + show(page, timeoutScreen(url), url, true) + // The address stays where it was going (as when the user stops a load), so it can be tried again. + page.once('did-stop-loading', () => { + if (page.isDestroyed() || !stateOf(page).screen?.timedOut) return + setTab(page, (tab) => { + tab.pendingUrl = url + }) + }) + page.stop() + } + + const startTiming = (page: WebContents, url: string) => { + const st = stateOf(page) + stopTiming(st) + if (st.sending || !/^https?:/i.test(url)) return + st.timing = { url, timer: setTimeout(() => timedOut(page, url), loadTimeout) } + } + + // ---- What the screen's buttons do ---- + + /** Loads the screen's address again: a crashed or failed page reloads, a timed-out one loads its address. */ + const retry = (page: WebContents) => { + const screen = states.get(page.id)?.state.screen + if (!screen || page.isDestroyed()) return + if (screen.timedOut) void page.loadURL(screen.url).catch(() => {}) + else page.reload() + } + + /** Back to safety: back where the user came from; a tab with nowhere to go back to closes. */ + const back = (page: WebContents) => { + if (page.navigationHistory.canGoBack()) { + page.navigationHistory.goBack() + return + } + const tabId = tabs.tabIdFor(page) + if (tabId && !store.state.tabs[tabId]?.floating) { + ops.closeTabs([tabId], 'closed') + return + } + const win = BrowserWindow.fromWebContents(page) + if (!tabId && win?.webContents === page) win.close() + else void page.loadURL('about:blank').catch(() => {}) + } + + /** Certificates the user chose to go on with, by profile session: `|`. */ + const allowed = new WeakMap>() + + const proceed = (page: WebContents) => { + const st = states.get(page.id)?.state + const screen = st?.screen + if (!st || screen?.content.kind !== 'cert' || !screen.content.advanced?.proceedLabel || !st.cert) return + if (st.cert.host !== hostOf(screen.url)) return + const set = allowed.get(page.session) ?? new Set() + allowed.set(page.session, set) + set.add(`${st.cert.host}|${st.cert.fingerprint}`) + page.reload() + } + + const act = (page: WebContents, action: PageScreenAction) => { + const st = states.get(page.id)?.state + const screen = st?.screen + if (!st || !screen) return + if (action === 'proceed') proceed(page) + else if (action === 'back') back(page) + else if (action === 'online') { + // The network came back: an error page loads again, once (until a page loads). + if (screen.content.kind === 'crash' || screen.content.kind === 'cert') return + if (st.autoRetried === screen.url || page.isLoading()) return + st.autoRetried = screen.url + retry(page) + } else retry(page) + } + + // ---- Hangs ---- + + const askHang = (page: WebContents) => { + const st = stateOf(page) + // A page waiting on its own dialog isn't hung: it waits for the user. + if (st.hangOpen || dialogs.pending(page, 'script') || !covers.hostable(page)) return + st.hangOpen = true + void dialogs.open(page, hangDialog(page.getURL())).then((answer) => { + st.hangOpen = false + if (!answer || answer.ok || page.isDestroyed()) return + st.closedHung = true + page.forcefullyCrashRenderer() + }) + } + + // ---- Watching pages ---- + + /** + * A timed-out page's Reload loads the address it was going to, and Back shows the page it was on + * (the navigation never committed, so the page underneath is that page, with the screen it had if + * it had one): Back is on while the screen shows, as it would be with an error page. (Wraps the + * page's own methods, as leave.ts does.) + */ + const wrapNavigation = (page: WebContents) => { + const wrap = (target: object, name: string, instead: () => unknown) => { + const original = (target as Record)[name] + if (typeof original !== 'function') return + const replacement = function (this: unknown, ...args: unknown[]) { + if (!page.isDestroyed() && states.get(page.id)?.state.screen?.timedOut) return instead() + return Reflect.apply(original, this, args) + } + try { + Object.defineProperty(target, name, { value: replacement, configurable: true, writable: true }) + } catch { + // Not replaceable: Reload reloads the page underneath. + } + } + const history = page.navigationHistory + wrap(page, 'reload', () => retry(page)) + wrap(page, 'reloadIgnoringCache', () => retry(page)) + wrap(history, 'canGoBack', () => true) + wrap(history, 'goBack', () => { + const st = stateOf(page) + stopTiming(st) + // A retry still on its way stops: the page underneath is where Back leads. + if (page.isLoading()) page.stop() + // That page, with the screen it had if it had one, and its row as it was. + const under = st.screen?.under + const row = under ? under.row : st.row + st.screen = under + covers.update(page) + if (row) showRow(page, row) + setTab(page, (tab) => { + delete tab.pendingUrl + tab.canGoBack = history.canGoBack() + tab.canGoForward = history.canGoForward() + }) + }) + } + + const watched = new WeakSet() + const watch = (page: WebContents, tab: boolean) => { + if (watched.has(page)) return + watched.add(page) + page.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument || !covers.hostable(page)) return + const st = stateOf(page) + st.sending = st.formTo === details.url + st.formTo = undefined + if (!st.screen) st.row = rowOf(page) + startTiming(page, details.url) + }) + // The server answered: where it sends the page is timed afresh (a form's redirect isn't). + page.on('did-redirect-navigation', (details) => { + if (details.isMainFrame && states.has(page.id)) startTiming(page, details.url) + }) + page.on('did-navigate', (_event, url) => { + if (!covers.hostable(page)) return + const st = stateOf(page) + stopTiming(st) + st.sending = false + st.committed = url + st.autoRetried = undefined + st.closedHung = false + clear(page) + }) + page.on('did-fail-provisional-load', (_event, code, description, url, isMainFrame) => { + if (!isMainFrame || !covers.hostable(page)) return + const st = stateOf(page) + stopTiming(st) + st.sending = false + if (code === ERR_ABORTED) return + show(page, errorScreen(code, description, url), url, false) + }) + page.on('did-stop-loading', () => { + const st = states.get(page.id)?.state + if (!st) return + stopTiming(st) + st.sending = false + }) + // The engine names an error page by nothing, and a page under a timeout may still rename + // itself: while a screen is up, its row shows what the screen chose. (After the tab runtime's + // own listener, which adds the title to the store.) + page.on('page-title-updated', () => { + const row = states.get(page.id)?.state.screen?.row + if (row) showRow(page, row) + }) + page.on('render-process-gone', (_event, details) => { + if (!covers.hostable(page)) return + const st = stateOf(page) + stopTiming(st) + if (details.reason === 'clean-exit') return + dialogs.dismiss(page, 'all') + const url = page.getURL() + show(page, crashScreen(st.closedHung ? 'hung' : details.reason, url), url, false) + }) + page.on('unresponsive', () => askHang(page)) + page.on('responsive', () => dialogs.dismiss(page, 'hang')) + page.once('destroyed', () => { + const st = states.get(page.id)?.state + if (st) stopTiming(st) + states.delete(page.id) + }) + if (tab) wrapNavigation(page) + } + + // The page is about to send a form: its navigation isn't timed (it may be a large upload). + ipcMain.on(PAGE_CHANNEL.formSent, (event: IpcMainEvent, url: unknown) => { + if (typeof url !== 'string') return + const page = event.sender + try { + const frame = event.senderFrame + if (!frame || page.isDestroyed() || frame.frameTreeNodeId !== page.mainFrame.frameTreeNodeId) return + } catch { + // A frame or page that went away while the message was on its way. + return + } + if (!watched.has(page) || !covers.hostable(page)) return + const st = stateOf(page) + // Its navigation started first: it isn't timed from now on. + if (st.timing?.url === url) { + stopTiming(st) + st.sending = true + } else st.formTo = url + }) + + ctx.hooks.tabCreated.push((_tabId, page) => watch(page, true)) + // A pop-up's page is its window's own. + app.on('browser-window-created', (_event, win) => watch(win.webContents, false)) + + // A certificate the user chose to go on with goes through for its host; any other is refused, + // and the page's error screen offers the choice. + app.on('certificate-error', (event, page, url, _error, certificate, callback, isMainFrame) => { + const host = hostOf(url) + if (isMainFrame && covers.hostable(page)) stateOf(page).cert = { host, fingerprint: certificate.fingerprint } + if (allowed.get(page.session)?.has(`${host}|${certificate.fingerprint}`)) { + event.preventDefault() + callback(true) + } + }) + + commands.register('pages.screenContent', (_args, c) => { + if (!c.sender) return null + return covers.shownBy(c.sender)?.content ?? null + }) + + commands.register('pages.screenAction', (args, c) => { + const shown = c.sender ? covers.shownBy(c.sender) : undefined + if (!shown || shown.content.id !== args.id) return + act(shown.page, args.action) + }) + + return { + info() { + return [...states.values()].flatMap(({ page, state }) => { + const screen = state.screen + if (!screen || page.isDestroyed()) return [] + const { content } = screen + return [ + { + id: content.id, + kind: content.kind, + title: content.title, + message: content.message, + site: content.site, + ...(content.code ? { code: content.code } : {}), + url: screen.url, + timedOut: screen.timedOut, + tabId: tabs.tabIdFor(page) ?? null, + ...covers.info(page), + }, + ] + }) + }, + } +} + +/** A URL's host with its port, or "". */ +function hostOf(url: string): string { + try { + return new URL(url).host + } catch { + return '' + } +} diff --git a/src/main/pages/swipe.ts b/src/main/pages/swipe.ts new file mode 100644 index 00000000..d3c1687e --- /dev/null +++ b/src/main/pages/swipe.ts @@ -0,0 +1,159 @@ +import { type IpcMainEvent, ipcMain, systemPreferences, type WebContents, type WebContentsView } from 'electron' +import { PAGE_CHANNEL } from '../../shared/site' +import type { TabId, WindowId } from '../../shared/state' +import { HISTORY_SWIPE, type SwipeArrow, type SwipeDirection } from '../../shared/swipe' +import type { MainContext } from '../context' +import { shownTabs, viewContents } from '../windows' + +export interface SwipeInfo { + visible: boolean + tabId: TabId | null + direction: SwipeDirection | null + travel: number + armed: boolean + bounds?: Electron.Rectangle + /** How the last swipe ended: how far it had gone, and whether it went back or forward. */ + last: { direction: SwipeDirection; travel: number; went: boolean } | null +} + +interface WindowSwipe { + view?: WebContentsView + tabId: TabId | null + direction: SwipeDirection + travel: number + /** What the view shows: the last swipe's arrow, which a view that loads late shows too (faded, if done). */ + arrow: SwipeArrow | null + fade?: ReturnType + last: SwipeInfo['last'] +} + +/** Whether a two-finger swipe may go back or forward here: macOS's "Swipe between pages" setting. */ +const swipesBetweenPages = (): boolean => + process.platform !== 'darwin' || systemPreferences.isSwipeTrackingFromScrollEventsEnabled() + +/** + * Two-finger swipe back and forward over a page (NV-02). The page preload tells of a horizontal + * swipe the page doesn't take (src/preload/swipe.ts). An arrow slides in from that edge of the + * page's card, following the fingers 1:1, and letting go once it's fully in goes back or forward, as + * the Back and Forward buttons do. Only for a page shown in a window's page card (split panes + * included), with somewhere to go, and on macOS only with the system's "Swipe between pages" on. + * Over the sidebar the same swipe is the Space swipe (SP-08): the sidebar isn't the page. + */ +export function registerSwipe(ctx: MainContext): { info: (windowId: WindowId) => SwipeInfo } { + const { commands, store, tabs, windows } = ctx + const perWindow = new Map() + const { size, distance } = HISTORY_SWIPE + + const stateOf = (windowId: WindowId): WindowSwipe | undefined => { + const existing = perWindow.get(windowId) + if (existing) return existing + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return undefined + const s: WindowSwipe = { tabId: null, direction: 'back', travel: 0, arrow: null, last: null } + perWindow.set(windowId, s) + win.once('closed', () => { + clearTimeout(s.fade) + perWindow.delete(windowId) + }) + return s + } + + const tell = (s: WindowSwipe, arrow: SwipeArrow) => { + const was = s.arrow + s.arrow = arrow + if (was?.direction === arrow.direction && was.armed === arrow.armed && was.done === arrow.done) return + viewContents(s.view)?.send('orbit:swipe', arrow) + } + + const hide = (windowId: WindowId, s: WindowSwipe) => { + clearTimeout(s.fade) + s.fade = undefined + s.tabId = null + s.travel = 0 + if (viewContents(s.view) && s.view) windows.hideUiView(windowId, s.view) + } + + /** The arrow `travel` px in from its edge of the card, the part past the edge cut off. */ + const place = (windowId: WindowId, s: WindowSwipe) => { + const card = s.tabId ? windows.tabViewBounds(s.tabId) : undefined + if (!card || !s.view) return hide(windowId, s) + const inner = Math.min(s.travel, distance) + const shown = Math.min(size, inner) + if (shown < 1) return windows.hideUiView(windowId, s.view) + const offset = inner - shown + const x = s.direction === 'back' ? card.x + offset : card.x + card.width - offset - shown + const y = card.y + Math.round((card.height - size) / 2) + windows.showUiView(windowId, s.view, { x, y, width: shown, height: size }) + } + + const move = (windowId: WindowId, tabId: TabId, page: WebContents, direction: SwipeDirection, travel: number) => { + const s = stateOf(windowId) + if (!s) return + if (s.tabId !== tabId || s.direction !== direction || s.fade) { + // A new swipe: only with somewhere to go. + const history = page.navigationHistory + const canGo = direction === 'back' ? history.canGoBack() : history.canGoForward() + if (!canGo || !swipesBetweenPages()) return + hide(windowId, s) + s.tabId = tabId + s.direction = direction + if (!viewContents(s.view)) s.view = windows.createUiView(windowId, 'swipe') + } + s.travel = Math.max(0, Math.min(distance, travel)) + tell(s, { direction, armed: s.travel >= distance, done: false }) + place(windowId, s) + } + + const end = (windowId: WindowId, tabId: TabId, direction: SwipeDirection, commit: boolean) => { + const s = perWindow.get(windowId) + if (!s || s.tabId !== tabId || s.direction !== direction || s.fade) return + const go = commit && s.travel >= distance + s.last = { direction, travel: s.travel, went: go } + if (go) void commands.run(direction === 'back' ? 'tabs.back' : 'tabs.forward', { tabId }) + tell(s, { direction, armed: go, done: true }) + s.fade = setTimeout(() => { + if (perWindow.get(windowId) === s && s.tabId === tabId) hide(windowId, s) + }, HISTORY_SWIPE.fadeMs) + } + + // The view asks when it loads: what was sent before it listened is lost. + commands.register('pages.swipeArrow', (_args, c) => (c.windowId ? (perWindow.get(c.windowId)?.arrow ?? null) : null)) + + ipcMain.on(PAGE_CHANNEL.swipe, (event: IpcMainEvent, kind: unknown, direction: unknown, value: unknown) => { + const page = event.sender + const frame = event.senderFrame + if (!frame || frame.parent || page.isDestroyed()) return + if (direction !== 'back' && direction !== 'forward') return + const tabId = tabs.tabIdFor(page) + const windowId = tabId ? windows.windowOfTab(tabId) : undefined + if (!tabId || !windowId || !shownTabs(store.state, windowId).has(tabId)) return + if (kind === 'move' && typeof value === 'number' && Number.isFinite(value)) { + move(windowId, tabId, page, direction, value) + } else if (kind === 'end') end(windowId, tabId, direction, value === true) + }) + + // Another tab shown: its arrow goes. The page card moved: the arrow follows. + store.subscribe((next) => { + for (const [windowId, s] of perWindow) if (s.tabId && !shownTabs(next, windowId).has(s.tabId)) hide(windowId, s) + }) + windows.onLayout((windowId) => { + const s = perWindow.get(windowId) + if (s?.tabId) place(windowId, s) + }) + + return { + info: (windowId) => { + const s = perWindow.get(windowId) + const view = viewContents(s?.view) ? s?.view : undefined + return { + visible: !!view?.getVisible(), + tabId: s?.tabId ?? null, + direction: s?.tabId ? s.direction : null, + travel: s?.travel ?? 0, + armed: !!s?.tabId && s.travel >= distance, + ...(view ? { bounds: view.getBounds() } : {}), + last: s?.last ?? null, + } + }, + } +} diff --git a/src/main/pages/view-source.ts b/src/main/pages/view-source.ts new file mode 100644 index 00000000..81ac42e5 --- /dev/null +++ b/src/main/pages/view-source.ts @@ -0,0 +1,43 @@ +import type { WebContents } from 'electron' +import type { SpaceId, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { setPageEntries } from './entry' + +/** Whether a page at `url` has a source to show: not a blank page, nor a source view itself. */ +export function hasSource(url: string): boolean { + return url !== '' && url !== 'about:blank' && !url.startsWith('view-source:') +} + +/** + * View source (DT-04): the page's own address, as `view-source:`, in a new tab, shown at once. The + * engine's source view is read-only, coloured and numbered by line. A Peek's page opens its own + * source in the Peek's window; a Mini Window's, in a main window (one showing its Space if there + * is one), brought to the front. Only the page's own address goes, so D49 holds: a web page's + * address is never a `file:` one. + */ +export function registerViewSource(ctx: MainContext): void { + const { commands, store, tabs, windows } = ctx + + /** Where a page's source opens: the page's own window, or a main window for a Mini Window's page. */ + const windowFor = (windowId: WindowId | undefined, spaceId: SpaceId | undefined): WindowId | undefined => { + const { state } = store + if (!windowId || state.windows[windowId]?.kind !== 'mini') return windowId + const mains = windows.ids().filter((id) => state.windows[id]?.kind === 'main') + return mains.find((id) => state.windows[id]?.spaceId === spaceId) ?? mains[0] ?? windows.open(undefined, spaceId) + } + + const open = (page: WebContents) => { + const url = page.isDestroyed() ? '' : page.getURL() + if (!hasSource(url)) return + const tabId = tabs.tabIdFor(page) + const spaceId = tabId ? store.state.tabs[tabId]?.spaceId : undefined + const from = (tabId ? windows.windowOfTab(tabId) : undefined) ?? windows.focusedWindowId() + const windowId = windowFor(from, spaceId) + // In the Space the window shows: the page's own, unless it was a Mini Window's. + void commands + .run('tabs.create', { url: `view-source:${url}`, activate: true }, { windowId }) + .catch((err: unknown) => console.error('[pages] view source', err)) + if (windowId && windowId !== from) windows.get(windowId)?.focus() + } + setPageEntries({ viewSource: open }) +} diff --git a/src/main/peek/page-actions.ts b/src/main/peek/page-actions.ts index 5d20c366..1309bcd3 100644 --- a/src/main/peek/page-actions.ts +++ b/src/main/peek/page-actions.ts @@ -5,6 +5,7 @@ import type { TabId, WindowId } from '../../shared/state' import { cleanUrl, markdownLink } from '../../shared/tabs' import { fallbackTitle } from '../../shared/url' import { runDevtoolsAction } from '../devtools/control' +import { printPage, savePage, viewSource } from '../pages/entry' /** Zooms a page one step in (1) or out (-1), or back to 100% (0), and returns its new zoom factor. */ export type PageZoom = (contents: WebContents, direction: 1 | -1 | 0) => number @@ -70,8 +71,17 @@ export function runPageAction(id: string, contents: WebContents, toast: (message toast(`Zoom ${zoomPercent(factor)}%`) return true } + // Orbit's print dialog over the page (pages lane, NV-07). case 'page.print': - contents.print() + printPage(contents) + return true + // Its own page, where the save dialog says (pages lane, NV-21). + case 'page.save': + savePage(contents) + return true + // Its own page's source, in a new tab (pages lane, DT-04). + case 'page.viewSource': + viewSource(contents) return true // DevTools dock inside the page's own view, as for a tab (DT-01). case 'devtools.toggle': diff --git a/src/main/spaces/commands.ts b/src/main/spaces/commands.ts index 07541013..e6f4a252 100644 --- a/src/main/spaces/commands.ts +++ b/src/main/spaces/commands.ts @@ -147,7 +147,12 @@ export function registerSpaces(ctx: MainContext): void { let lastDeleted: { deleted: DeletedSpace; histories: Map } | undefined - commands.register('spaces.delete', ({ spaceId }, c) => { + commands.register('spaces.delete', async ({ spaceId }, c) => { + const asked = store.state.spaces[spaceId] + if (!asked || store.state.spaceOrder.length <= 1) return false + // Each page that warns before it goes asks first, over itself (NV-24): Cancel keeps the Space. + const pages = [...asked.pinned, ...asked.today].flatMap((id) => tabsInItem(store.state, id)) + if (ctx.tabs.letGo && !(await ctx.tabs.letGo(pages))) return false const state = store.state const space = state.spaces[spaceId] if (!space || state.spaceOrder.length <= 1) return false diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index e1997536..04a2e538 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -26,6 +26,7 @@ import { engineForWindow, parseInput } from '../../shared/url' import type { MainContext } from '../context' import { KEEP_KEYBOARD } from '../keys/focus' import { TAB_MENU_KEYS, withKeyHints } from '../keys/menu-hints' +import { printPage, savePage, viewSource } from '../pages/entry' import type { CallContext } from '../registry' import { tabSplitMenuItems } from '../split/menu' import { TabMaintenance } from './maintenance' @@ -783,17 +784,12 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const contents = tabs.contents(tabId) if (contents) fn(contents) }) - actions.register( - 'page.print', - onContents((w) => w.print()), - ) - actions.register( - 'page.viewSource', - withActive((tabId, windowId) => { - const url = store.state.tabs[tabId]?.url - if (url) ops.createTab({ url: `view-source:${url}`, windowId }) - }), - ) + // Orbit's print dialog over the page (pages lane, NV-07). + actions.register('page.print', onContents(printPage)) + // The OS save dialog, then the page saved there (pages lane, NV-21). + actions.register('page.save', onContents(savePage)) + // The page's source in a new tab (pages lane, DT-04). + actions.register('page.viewSource', onContents(viewSource)) // DevTools keys (devtools.*) are registered by src/main/devtools. // Favicons in state are small data URLs on tabs only (lane review MF-2, F13): remote URLs (which diff --git a/src/main/tabs/context-menu.ts b/src/main/tabs/context-menu.ts index a16aa96f..82706ea3 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -128,7 +128,7 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We }, { label: 'Reload', click: () => contents.reload() }, { type: 'separator' }, - ...pageItems(ctx, tabId, contents), + ...pageItems(contents), { type: 'separator' }, { label: 'Copy Page URL', click: () => clipboard.writeText(contents.getURL()) }, { type: 'separator' }, diff --git a/src/main/tabs/maintenance.ts b/src/main/tabs/maintenance.ts index c7e3cab9..d77758f7 100644 --- a/src/main/tabs/maintenance.ts +++ b/src/main/tabs/maintenance.ts @@ -65,13 +65,18 @@ export class TabMaintenance { /** One pass: archive idle Today items, then put idle background tabs to sleep. */ run(now = this.now()): { archived: ItemId[]; slept: TabId[] } { - const archived = autoArchiveCandidates(this.ctx.store.state, now) + // A page that warns before it goes stays, and isn't tried again until it navigates or gets a + // new user gesture (NV-24). + const mayGo = (tabId: TabId) => this.ops.mayDiscard?.(tabId) ?? true + const archived = autoArchiveCandidates(this.ctx.store.state, now).filter((id) => + tabsInItem(this.ctx.store.state, id).every(mayGo), + ) if (archived.length > 0) { const tabIds = archived.flatMap((id) => tabsInItem(this.ctx.store.state, id)) - this.ops.closeTabs(tabIds, 'auto', { undoLabel: false }) + this.ops.closeTabs(tabIds, 'auto', { undoLabel: false, idle: true }) } - const slept = sleepCandidates(this.ctx.store.state, now, this.ctx.tabs.liveTabIds()) - for (const id of slept) this.ctx.tabs.sleep(id) + const slept = sleepCandidates(this.ctx.store.state, now, this.ctx.tabs.liveTabIds()).filter(mayGo) + for (const id of slept) if (!this.ops.sleepGuard?.(id)) this.ctx.tabs.sleep(id) return { archived, slept } } } diff --git a/src/main/tabs/ops.ts b/src/main/tabs/ops.ts index 02c3f15c..7ad45ce9 100644 --- a/src/main/tabs/ops.ts +++ b/src/main/tabs/ops.ts @@ -119,9 +119,37 @@ function unarchive(d: OrbitState, tabId: TabId, archiveId: string | undefined): } } +/** How `closeTabs` closes. */ +export interface CloseTabsOptions { + remove?: boolean + reselect?: 'neighbor' | 'pinned' + undoLabel?: string | false + /** The user was asked already: no `closeGuard`. */ + confirmed?: boolean + /** Auto-archive of idle tabs: a page that warns before it goes stays, and nothing asks (NV-24). */ + idle?: boolean +} + export class TabOps { readonly recentlyClosed: ClosedTab[] = [] readonly undo = new UndoStack() + /** + * Asks before closing a page that warns before it goes (NV-24, pages lane): returns true when it + * takes the close over (it closes the tabs itself once the user answers, `confirmed`), false to + * close now. + */ + closeGuard?: (tabIds: TabId[], reason: ArchivedTab['reason'], opts: CloseTabsOptions) => boolean + /** + * Whether auto-archive and sleep may try to let a live tab's page go (NV-24, pages lane): not one + * that waits on the user, or that warned when they last tried. + */ + mayDiscard?: (tabId: TabId) => boolean + /** + * Puts an idle tab to sleep through the engine when its page may warn before it goes (NV-24, + * pages lane): returns true when it takes the sleep over (a page that warns stays awake), false + * to sleep it now. + */ + sleepGuard?: (tabId: TabId) => boolean constructor( private ctx: MainContext, @@ -289,11 +317,8 @@ export class TabOps { * and Favorites stay and unload, resetting to their pinned URL (PN-06, FV-06). * `remove` also removes pinned tabs (delete from sidebar). */ - closeTabs( - tabIds: TabId[], - reason: ArchivedTab['reason'], - opts: { remove?: boolean; reselect?: 'neighbor' | 'pinned'; undoLabel?: string | false } = {}, - ): void { + closeTabs(tabIds: TabId[], reason: ArchivedTab['reason'], opts: CloseTabsOptions = {}): void { + if (!opts.confirmed && this.closeGuard?.(tabIds, reason, opts)) return const state = this.state const removing: TabId[] = [] const unloading: TabId[] = [] diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index d49416a1..6731c8bd 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -16,6 +16,7 @@ import type { Tab, TabId, WindowId } from '../../shared/state' import { isAwayFromPinned, sameDocUrl } from '../../shared/tabs' import type { MainContext } from '../context' import { KEEP_KEYBOARD } from '../keys/focus' +import { onGesture } from '../pages/gestures' import { showPageContextMenu } from './context-menu' import { FaviconStore } from './favicon-store' import { isIco, readIco } from './ico' @@ -33,7 +34,7 @@ function liveContents(view: WebContentsView | undefined): WebContents | undefine const NON_ACTIVATING_KEYS = new Set(['Escape', 'Shift', 'Control', 'Alt', 'Meta', 'Command', 'Cmd', 'CapsLock']) /** Input that counts as a user activation for opening tabs and pop-ups (NV-09, HTML user activation). */ -function isActivation(input: Electron.InputEvent): boolean { +export function isActivation(input: Electron.InputEvent): boolean { if (input.type === 'mouseDown' || input.type === 'touchEnd' || input.type === 'gestureTap') return true if (input.type !== 'rawKeyDown' && input.type !== 'keyDown') return false if (input.modifiers?.includes('isautorepeat')) return false @@ -156,6 +157,11 @@ export class TabRuntime { onUndoShortcut?: (tabId: TabId, redo: boolean) => void /** A page closed itself (window.close()); treated like closing the tab (TB-06). */ onPageClosed?: (tabId: TabId) => void + /** + * Lets tabs' pages go once each that warns before it goes was asked about (NV-24, pages lane): + * false when the user keeps one (Cancel). Deleting a Space asks it first. + */ + letGo?: (tabIds: TabId[]) => Promise /** Control was released in a page (ends recent-tab switching, TB-05). */ onControlUp?: () => void /** Recent-tab switching in a window (TB-05): whether it is going on, and closing its highlighted tab (W, KS-06). */ @@ -171,6 +177,8 @@ export class TabRuntime { allowsPopups?: (tabId: TabId) => boolean /** Last user gesture per tab, for the pop-up rule (NV-09). */ private gestures = new Map() + /** Pages `unload` is closing through the engine: their history, and who waits for the answer. */ + private unloading = new Map void }>() /** Links pages were asked to open, by the asking page's contents id (`openFromPage`). */ private pageOpens = new Map() /** Options for a tab's first load, used once: the page a link came from (review B-F1). */ @@ -304,12 +312,12 @@ export class TabRuntime { const icon = favicons.find((f) => /^(https?|data):/.test(f)) if (icon) void this.storeFavicon(tabId, contents, icon) }) - // Pop-ups need a recent user gesture in the opener (NV-09). - contents.on('input-event', (_e, input) => { - if (!isActivation(input)) return + // Pop-ups need a recent user gesture in the opener (NV-09): a click or a key in any of its + // frames, one from another site's too (D77). + onGesture(contents, () => { const now = Date.now() const last = this.gestures.get(tabId) - // One physical press can arrive as two events (raw key down + key down): one activation. + // Two presses within 30 ms are one activation. if (last && now - last.at < 30) last.at = now else this.gestures.set(tabId, { at: now, used: false }) }) @@ -352,10 +360,23 @@ export class TabRuntime { this.pageOpens.delete(contents.id) this.gestures.delete(tabId) this.wantedFavicon.delete(tabId) - if (this.views.get(tabId) !== view) return - // Destroyed outside sleep()/destroy(): the page closed itself (window.close()). + const asked = this.unloading.get(tabId) + const unloaded = asked?.contentsId === contents.id ? asked : undefined + if (unloaded) this.unloading.delete(tabId) + if (this.views.get(tabId) !== view) { + // Slept or closed by Orbit meanwhile: gone all the same. + unloaded?.answer(true) + return + } this.views.delete(tabId) this.ctx.windows.detachView(tabId, view) + if (unloaded) { + // Closed through the engine (unload()): the caller closes the tab or puts it to sleep. + if (unloaded.saved) this.savedHistory.set(tabId, unloaded.saved) + unloaded.answer(true) + return + } + // Destroyed outside sleep(), destroy() and unload(): the page closed itself (window.close()). this.update(tabId, { asleep: true, loading: false, audible: false }) if (!this.ctx.windows.quitting) this.onPageClosed?.(tabId) }) @@ -366,7 +387,11 @@ export class TabRuntime { /** Puts a tab to sleep: remembers its history and frees its renderer. */ sleep(tabId: TabId): void { const view = this.views.get(tabId) - if (!view) return + if (!view) { + // Its page has gone already (unload()): the tab is asleep. + this.update(tabId, { asleep: true, loading: false, audible: false }) + return + } const contents = liveContents(view) if (contents) { const entries = contents.navigationHistory.getAllEntries() @@ -394,6 +419,51 @@ export class TabRuntime { liveContents(view)?.close() } + /** + * Closes a tab's page through the engine (`close({ waitForBeforeUnload: true })`), which first + * runs the `beforeunload` of every frame, as Chromium's closing a tab does (NV-24, D73). A frame + * that warns makes the engine say so (`will-prevent-unload`) and keep the page unless told there + * to go on; `kept` then ends the wait with `then(false)`. Once the page has gone, `then(true)`: + * its history is kept, and the tab is the caller's to close or put to sleep (`sleep`). + */ + unload(tabId: TabId, then: (gone: boolean) => void): void { + const contents = this.contents(tabId) + if (!contents) { + then(true) + return + } + const asked = this.unloading.get(tabId) + if (asked) { + // Asked already: both hear the one answer. + const first = asked.answer + asked.answer = (gone) => { + first(gone) + then(gone) + } + return + } + const entries = contents.navigationHistory.getAllEntries() + this.unloading.set(tabId, { + contentsId: contents.id, + ...(entries.length > 0 ? { saved: { entries, index: contents.navigationHistory.getActiveIndex() } } : {}), + answer: then, + }) + contents.close({ waitForBeforeUnload: true }) + } + + /** Whether `unload` is waiting on a tab's page. */ + isUnloading(tabId: TabId): boolean { + return this.unloading.has(tabId) + } + + /** The engine kept a page `unload` was closing (a frame warned): `then(false)`. */ + kept(tabId: TabId): void { + const asked = this.unloading.get(tabId) + if (!asked) return + this.unloading.delete(tabId) + asked.answer(false) + } + /** * True once per user gesture in the tab within the last second: a page may open one * tab or pop-up per click or key press, never on its own (NV-09). diff --git a/src/main/windows.ts b/src/main/windows.ts index f6e313e1..b0ecad1e 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -223,6 +223,11 @@ export class WindowManager { /** Tabs whose page closed its own web contents (window.close()): not reopened until selected again. */ private selfClosed = new Set() private floatingOwners: ((tabId: TabId) => WindowId | undefined)[] = [] + /** + * Views that stay above the page tools wherever either goes: a page's own dialog or print dialog + * over its page, in a tab or a Peek (pages lane, NV-24, NV-07; keepAboveTools()). + */ + private aboveTools = new WeakSet() /** Main window that most recently had focus (fallback when no window has focus). */ private lastFocused: WindowId | undefined /** The last main window, closed while the app keeps running (macOS) or is about to quit: its state is kept. */ @@ -686,8 +691,8 @@ export class WindowManager { view.setBorderRadius(corner) rec.corners.set(pane.tabId, corner) view.setVisible(true) - // A tab whose page crashed while in the background reloads when it is shown again. - if (!rec.visible.has(pane.tabId) && view.webContents.isCrashed()) view.webContents.reload() + // A tab whose page crashed while in the background shows its crash screen when it is shown + // again, until the user reloads it (NV-14: the pages lane's screen over the page). } rec.visible = wanted if (panes.length === 1) rec.lastSingleRect = panes[0]?.rect @@ -736,9 +741,14 @@ export class WindowManager { const parent = rec.win.contentView const children = parent.children const at = children.indexOf(view) - const above = opts.top - ? [] - : [rec.overlay, rec.floating, rec.cover].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const band = [rec.overlay, rec.floating, rec.cover].map((v) => (v ? children.indexOf(v) : -1)).filter((i) => i >= 0) + const above = opts.top ? [] : [...band] + // A page's own dialog over its page stays above them (keepAboveTools()): with `top`, one over a + // Peek's page, above the overlay. + const floor = opts.top && band.length > 0 ? Math.max(...band) : -1 + children.forEach((child, i) => { + if (i > floor && this.aboveTools.has(child)) above.push(i) + }) const ceiling = above.length > 0 ? Math.min(...above) : -1 if (ceiling < 0) { if (at !== children.length - 1) parent.addChildView(view) @@ -855,6 +865,15 @@ export class WindowManager { return { exists: true, placed, covered: placed && !!rec.cover, bounds: view.getBounds() } } + /** + * Keeps `view` above the window's page tools (the find bar, the zoom indicator) wherever either + * goes: a page's own dialog or print dialog over its page (pages lane, NV-24, NV-07), as a + * createUiView() view or a view of its own over a Peek's page. + */ + keepAboveTools(view: WebContentsView): void { + this.aboveTools.add(view) + } + /** * A small transparent Orbit UI view for another module (the loading indicator, SB-28; the link * pill, SB-27; page dialogs, NV-24): out of the window until showUiView(), then above the window's @@ -948,6 +967,10 @@ export class WindowManager { for (const entry of this.attached.values()) { if (entry.windowId === rec.id) topPage = Math.max(topPage, children.indexOf(entry.view)) } + // A view kept above the page tools (keepAboveTools()) goes above them too, where they are under + // the ceiling (over a Peek's page they are above it, and so is a dialog over that page). + const tools = rec.tools && this.aboveTools.has(view) ? children.indexOf(rec.tools) : -1 + if (tools < ceiling) topPage = Math.max(topPage, tools) if (at > topPage && at < ceiling) return if (ceiling >= children.length) parent.addChildView(view) else parent.addChildView(view, at >= 0 && at < ceiling ? ceiling - 1 : ceiling) @@ -1521,12 +1544,25 @@ export class WindowManager { rec.promptShown = false rec.promptReady = false rec.promptSaid = undefined + const contents = view.webContents // Into the window once its page has loaded, if it is still wanted then (fix check A, F1). - view.webContents.once('did-finish-load', () => { + contents.on('did-finish-load', () => { if (rec.prompt !== view || rec.win.isDestroyed()) return rec.promptReady = true if (rec.promptShown) this.placePrompt(rec, view) }) + // A crash takes its page away until it has loaded again, as for the page tools (chrome fix check 4, + // N4): it leaves the window until then, still wanted, and the keyboard goes back to the page if it + // had it. Its new page asks what to show when it loads. + contents.on('render-process-gone', () => { + if (rec.prompt !== view || rec.win.isDestroyed()) return + rec.promptReady = false + rec.promptSaid = undefined + 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/src/preload/page.ts b/src/preload/page.ts index f5f5df1a..d33c3428 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -14,13 +14,23 @@ * nothing. See `exposeInternalPage`. * * In every page's main frame, it also tells main whether the page used an Esc (D59, `watchEscape`), - * and how many of the page's shared-screen tracks are live (D69: for its tab's sharing indicators - * only, MD-11, TB-15). + * of a two-finger swipe the page doesn't take (NV-02, `watchSwipe`), and how many of the page's + * shared-screen tracks are live (D69: for its tab's sharing indicators only, MD-11, TB-15). + * + * In a web page's main frame, the page's `alert`, `confirm` and `prompt` ask main, which shows + * Orbit's dialog over that page only (NV-24): Electron has no `prompt`, and shows the others as + * app-wide boxes. A frame has no preload (D40), so a dialog a frame asks for itself is the engine's + * own box (and a frame's `prompt` returns null). Its `window.print()` opens Orbit's print dialog over + * the page (NV-07), and its `window.close()` waits while that dialog is open (`holdForPrint`); a + * frame's `print()` is the engine's, the system's own dialog. It also answers main about the + * leave-page warning (`exposeLeave`). */ import { contextBridge, ipcRenderer } from 'electron' +import { isWheelNotch } from '../renderer/src/shell/spaces/physics' import type { OrbitBridge, StatePatch } from '../shared/rpc' import { PAGE_CHANNEL } from '../shared/site' import type { OrbitState } from '../shared/state' +import { HistorySwipe, RELEASE_GAP_MS, type SwipeUpdate, scrollsToward } from './swipe' type StateOf = (name: string) => string | null @@ -46,7 +56,14 @@ const screenShared = (count: number) => { * 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, shared: (count: number) => void) { +function patchPage( + stateOf: StateOf, + clicked: () => void, + ask: AskDialog, + print: () => void, + holdClose: () => boolean, + shared: (count: number) => 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 @@ -139,6 +156,71 @@ function patchPage(stateOf: StateOf, clicked: () => void, shared: (count: number } } + // alert, confirm and prompt: Orbit's dialog over this page (NV-24). The arguments convert as the + // engine's do (a template literal throws on a Symbol, as DOMString does); `confirm` and `prompt` + // take "" for a missing or undefined argument, `alert` only for a missing one. + const asText = (args: unknown[], at: number, optional: boolean): string => + args.length <= at || (optional && args[at] === undefined) ? '' : `${args[at]}` + // The engine shows no dialog while the page is going: in its `beforeunload`, `pagehide` or + // `unload` handlers. Orbit tells from the event the page's handler runs for, read through the + // engine's own getters (the page can't hide it), and adds no listener of its own: with a + // `beforeunload` listener the engine would ask every page before each navigation, so a busy page + // would hold up Back (D60), and an `unload` one would keep every page out of the back-forward cache. + const currentEvent = describe(window, 'event')?.get + const eventType = describe(Event.prototype, 'type')?.get + const going = (): boolean => { + if (!currentEvent || !eventType) return false + try { + const type: unknown = apply(eventType, apply(currentEvent, window, []), []) + return type === 'beforeunload' || type === 'pagehide' || type === 'unload' + } catch { + // No event: the page isn't in a handler. + return false + } + } + const askNow: AskDialog = (kind, message, value) => (going() ? null : ask(kind, message, value)) + const answers: Record<'alert' | 'confirm' | 'prompt', (args: unknown[]) => unknown> = { + alert: (args) => { + askNow('alert', asText(args, 0, false), '') + return undefined + }, + confirm: (args) => askNow('confirm', asText(args, 0, true), '') === true, + prompt: (args) => { + const value = askNow('prompt', asText(args, 0, true), asText(args, 1, true)) + return typeof value === 'string' ? value : null + }, + } + for (const name of ['alert', 'confirm', 'prompt'] as const) { + const native = window[name] + const own = describe(window, name) + if (typeof native !== 'function' || !own?.configurable) continue + const answer = answers[name] + // A Proxy keeps the engine's name, length and toString. + define(window, name, { ...own, value: new Proxy(native, { apply: (_target, _self, args) => answer(args) }) }) + } + + // window.print(): Orbit's print dialog over this page (NV-07), where the engine's would be the + // system's own dialog. It returns at once, and the page goes on while the dialog shows; what + // would take the page away waits for the dialog, as after the engine's `print()`, which returns + // only once its dialog closes (`holdForPrint`). + const nativePrint = window.print + const ownPrint = describe(window, 'print') + if (typeof nativePrint === 'function' && ownPrint?.configurable) { + const printNow = () => { + if (!going()) print() + } + define(window, 'print', { ...ownPrint, value: new Proxy(nativePrint, { apply: printNow }) }) + } + // window.close() while the print dialog the page asked for is open: the page closes once the + // dialog does (`holdForPrint`). Otherwise it's the engine's. + const nativeClose = window.close + const ownClose = describe(window, 'close') + if (typeof nativeClose === 'function' && ownClose?.configurable) { + const closeNow = (target: () => void, self: unknown, args: unknown[]) => + holdClose() ? undefined : apply(target, self, args) + define(window, 'close', { ...ownClose, value: new Proxy(nativeClose, { apply: closeNow }) }) + } + // Screen sharing (D69; MD-11, TB-15): main learns how many of the page's shared-screen tracks are // live, for the tab's sharing indicators only. One stops being shared when the page stops it or // its source ends (the window closed, or the system's own Stop sharing); a clone of it shares too. @@ -225,6 +307,183 @@ function patchPage(stateOf: StateOf, clicked: () => void, shared: (count: number } } +type AskDialog = (kind: 'alert' | 'confirm' | 'prompt', message: string, value: string) => unknown + +/** A navigation the page started, as the Navigation API's `navigate` event told it. */ +interface PageNavigation { + url: string + type: string + /** The form a submission came from, and its submit button. */ + form?: WeakRef + submitter?: WeakRef +} + +/** + * The print dialog the page asked for (NV-07): whether it's open, and what the page started while it + * was (`holdForPrint`). + */ +const printing: { open: boolean; close: boolean; navigation: PageNavigation | null } = { + open: false, + close: false, + navigation: null, +} + +const printDialog = () => { + try { + if (ipcRenderer.sendSync(PAGE_CHANNEL.print) === true) printing.open = true + } catch { + // No dialog: nothing waits for one. + } +} + +/** The page's `window.close()`: true when it waits for the print dialog. */ +const holdClose = (): boolean => { + if (!printing.open) return false + printing.close = true + return true +} + +const askDialog: AskDialog = (kind, message, value) => { + try { + return ipcRenderer.sendSync(PAGE_CHANNEL.dialog, kind, message, value) + } catch { + return null + } +} + +/** Main asks which navigation a warning stopped just after it; one the page started longer ago isn't it. */ +const STOPPED_MS = 2_000 + +/** The Navigation API's `navigate` event, as far as this reads it. */ +type NavigateEventLike = Event & { + destination?: { url?: string; sameDocument?: boolean } + navigationType?: string + formData?: FormData | null + sourceElement?: Element | null + downloadRequest?: string | null +} + +/** + * Starts a navigation of the page's again, from here: a link or `location` loads its URL (as a new + * history entry or in place of this one, as it did), a form is submitted again, a reload reloads. + * False when it can't be: a move through history, or a form that's gone or changed. + */ +function restart(again: PageNavigation): boolean { + if (again.type === 'traverse') return false + if (again.type === 'reload') location.reload() + else if (again.form) { + const form = again.form.deref() + const submitter = again.submitter?.deref() + if (!form?.isConnected) return false + try { + form.requestSubmit((submitter as HTMLButtonElement | undefined)?.form === form ? submitter : null) + } catch { + // The form changed: the user submits it again. + return false + } + } else if (again.type === 'replace') location.replace(again.url) + else location.assign(again.url) + return true +} + +/** + * The leave-page warning (NV-24), asked from main in this preload's own world, where the page can't + * see or replace it (src/main/pages/leave.ts): `globalThis.__orbitLeave` there. + * + * - `stopped()`: what the page's own navigation that a warning just stopped was (`reload`, `push`, + * `replace`, `traverse`), or "" when there is none this frame knows of. A navigation is known + * here from the Navigation API's `navigate` event, which comes in the same task as the + * `beforeunload` the warning comes from; main asks right after the warning, so the page's last + * navigation counts if it started within STOPPED_MS. (A `beforeunload` listener of Orbit's + * would tell for sure, but would cost every page: see `going` in `patchPage`.) + * - `redo()`: starts that navigation again (the user chose Leave; main lets the next warning + * through): a link or `location` loads its URL, a form is submitted again, a reload reloads. + * + * The same `navigate` event tells main when the page sends a form (`PAGE_CHANNEL.formSent`): it may + * be uploading, so Orbit's time limit for a server's answer leaves that navigation alone (NV-13). + */ +function exposeLeave(): void { + const nav = (window as { navigation?: EventTarget }).navigation + /** The last cross-document navigation the page started, its event, and when (`performance.now()`). */ + let starting: (PageNavigation & { event: Event; at: number }) | null = null + /** The navigation main last asked about: the one a warning stopped. */ + let stopped: PageNavigation | null = null + nav?.addEventListener('navigate', (event) => { + const e = event as NavigateEventLike + if (!e.destination?.url || e.destination.sameDocument) return + if (e.formData) ipcRenderer.send(PAGE_CHANNEL.formSent, e.destination.url) + const source = e.sourceElement ?? null + const form = e.formData + ? source instanceof HTMLFormElement + ? source + : (source as HTMLButtonElement | null)?.form + : null + starting = { + url: e.destination.url, + type: e.navigationType ?? 'push', + ...(form ? { form: new WeakRef(form) } : {}), + ...(form && source instanceof HTMLElement && source !== form ? { submitter: new WeakRef(source) } : {}), + event, + at: performance.now(), + } + }) + // The page's own same-document navigation that finished isn't one a warning stops. + nav?.addEventListener('navigatesuccess', () => { + starting = null + }) + const redo = (): boolean => { + const again = stopped + stopped = null + return !!again && restart(again) + } + Object.defineProperty(globalThis, '__orbitLeave', { + value: Object.freeze({ + stopped: () => { + const last = starting + starting = null + // A navigation the page cancelled itself didn't reach its `beforeunload`. + stopped = last && !last.event.defaultPrevented && performance.now() - last.at <= STOPPED_MS ? last : null + return stopped?.type ?? '' + }, + redo, + }), + }) +} + +/** + * While the print dialog the page asked for is open (NV-07), what would take the page away waits, + * as it would behind the engine's `print()`, which returns only once its dialog has closed: the + * page's `window.close()` (patched in `patchPage`), and a navigation it starts that the Navigation + * API lets this stop and that can start again as it was (a link, `location`, a form that gets its + * page, a reload: the same address, and the same kind of history entry). Once main says the dialog + * closed (`PAGE_CHANNEL.printClosed`), the close goes on, or else the page's last such navigation + * starts again. A form the page posts goes at once (sending it again would run the page's own + * submit handlers twice), and so does a move through history, which the engine doesn't let a + * script stop: either takes the dialog with it. + */ +function holdForPrint(): void { + const nav = (window as { navigation?: EventTarget }).navigation + nav?.addEventListener('navigate', (event) => { + const e = event as NavigateEventLike + if (!printing.open || !e.cancelable || !e.destination?.url || e.destination.sameDocument) return + // A download leaves the page where it is; a posted form isn't sent twice. + if (e.downloadRequest != null || e.formData) return + const type = e.navigationType ?? 'push' + if (type !== 'push' && type !== 'replace' && type !== 'reload') return + e.preventDefault() + printing.navigation = { url: e.destination.url, type } + }) + ipcRenderer.on(PAGE_CHANNEL.printClosed, () => { + if (!printing.open) return + const { close, navigation } = printing + printing.open = false + printing.close = false + printing.navigation = null + if (close) window.close() + else if (navigation) restart(navigation) + }) +} + type PatchListener = (seq: number, patches: StatePatch[]) => void /** @@ -304,6 +563,42 @@ function watchEscape(): void { watchEscape() +/** + * Two-finger swipe back and forward over the page (NV-02): a trusted horizontal trackpad scroll that + * nothing under the pointer can scroll that way, and that no handler of the page used + * (preventDefault), tells main how far it has gone, then whether it was let go with the arrow fully + * in (`HistorySwipe`). A mouse wheel's notches and ⌃-scrolls (a pinch) never do. Runs in this + * preload's own world, after the page's handlers: nothing is added to the page. + */ +function watchSwipe(): void { + if (window.top !== window) return + const swipe = new HistorySwipe() + let lifted: ReturnType | undefined + const tell = (update: SwipeUpdate | undefined) => { + if (!update) return + const value = update.kind === 'move' ? update.travel : update.commit + ipcRenderer.send(PAGE_CHANNEL.swipe, update.kind, update.direction, value) + } + window.addEventListener( + 'wheel', + (event) => { + if (!event.isTrusted || event.ctrlKey || event.deltaMode !== 0) return + const { deltaX, deltaY, target, timeStamp } = event + const wheelDeltaX = (event as WheelEvent & { wheelDeltaX?: number }).wheelDeltaX + if (isWheelNotch({ deltaMode: 0, deltaX, deltaY, wheelDeltaX })) return + // After the whole dispatch: the page's handlers have all run. + setTimeout(() => { + tell(swipe.push(deltaX, deltaY, timeStamp, (side) => event.defaultPrevented || scrollsToward(target, side))) + clearTimeout(lifted) + lifted = setTimeout(() => tell(swipe.release()), RELEASE_GAP_MS) + }, 0) + }, + { capture: true, passive: true }, + ) +} + +watchSwipe() + if (location.protocol === 'orbit:') { // An internal page. Main says no (null) to anything but a tab's main frame showing one. if (window.top === window) { @@ -313,8 +608,15 @@ if (location.protocol === 'orbit:') { } } else { try { - contextBridge.executeInMainWorld({ func: patchPage, args: [permissionState, notificationClicked, screenShared] }) + contextBridge.executeInMainWorld({ + func: patchPage, + args: [permissionState, notificationClicked, askDialog, printDialog, holdClose, screenShared], + }) } catch { // A page without its own world to patch keeps the engine's answers. } + if (window.top === window) { + exposeLeave() + holdForPrint() + } } diff --git a/src/preload/swipe.ts b/src/preload/swipe.ts new file mode 100644 index 00000000..84c2ff09 --- /dev/null +++ b/src/preload/swipe.ts @@ -0,0 +1,107 @@ +import { classify, MomentumDetector, RELEASE_GAP_MS } from '../renderer/src/shell/spaces/physics' +import { HISTORY_SWIPE, type SwipeDirection } from '../shared/swipe' + +export { RELEASE_GAP_MS } + +export type SwipeUpdate = + | { kind: 'move'; direction: SwipeDirection; travel: number } + | { kind: 'end'; direction: SwipeDirection; commit: boolean } + +/** + * A two-finger horizontal swipe over the page (NV-02), from its wheel samples (DOM deltas: a negative + * x scrolls toward the left, which goes back). It recognises a swipe by the Space swipe's own rules + * (FL-13): the first 9 px decide the axis, and the fingers have lifted when no sample comes for + * RELEASE_GAP_MS or the OS's momentum starts. A vertical scroll, or one the page takes, is left + * alone until the fingers lift. While it tracks, `travel` is how far it has gone toward its side, + * and it goes only if let go with the arrow fully in. + */ +export class HistorySwipe { + private phase: 'undecided' | 'tracking' | 'ignored' | undefined + private lastAt = Number.NEGATIVE_INFINITY + private ax = 0 + private ay = 0 + private direction: SwipeDirection = 'back' + private travel = 0 + private readonly momentum = new MomentumDetector() + + /** One sample at `t` (ms). `pageTakes(direction)`: the page scrolls that way, or uses the scroll. */ + push(dx: number, dy: number, t: number, pageTakes: (direction: SwipeDirection) => boolean): SwipeUpdate | undefined { + // A sample after a pause starts a new gesture. + const ended = t - this.lastAt > RELEASE_GAP_MS ? this.release() : undefined + this.lastAt = t + if (ended) return ended + if (this.phase === 'ignored') return undefined + if (this.phase === 'tracking') { + if (this.momentum.push(dx, t)) { + const end = this.release() + this.phase = 'ignored' + return end + } + if (pageTakes(this.direction)) { + this.phase = 'ignored' + return { kind: 'end', direction: this.direction, commit: false } + } + return this.move(dx) + } + this.phase = 'undecided' + this.ax += dx + this.ay += dy + const axis = classify(this.ax, this.ay) + if (!axis) return undefined + if (axis === 'y') { + this.phase = 'ignored' + return undefined + } + this.direction = this.ax < 0 ? 'back' : 'forward' + if (pageTakes(this.direction)) { + this.phase = 'ignored' + return undefined + } + this.phase = 'tracking' + this.travel = 0 + return this.move(this.ax) + } + + /** The fingers lifted: a tracked swipe ends, and goes if its arrow was fully in. */ + release(): SwipeUpdate | undefined { + const tracked = this.phase === 'tracking' + this.phase = undefined + this.ax = 0 + this.ay = 0 + this.momentum.reset() + if (!tracked) return undefined + return { kind: 'end', direction: this.direction, commit: this.travel >= HISTORY_SWIPE.distance } + } + + private move(dx: number): SwipeUpdate { + this.travel = Math.max(0, this.travel + (this.direction === 'back' ? -dx : dx)) + return { kind: 'move', direction: this.direction, travel: this.travel } + } +} + +/** + * Whether the page gets a swipe toward `direction`'s side first: the element under the pointer, or + * one it sits in (its document's scrolling element included), can still scroll that way. The swipe + * goes back or forward only at the edge, as Chromium's does. + */ +export function scrollsToward(target: EventTarget | null, direction: SwipeDirection): boolean { + let node: Node | null = target instanceof Node ? target : null + while (node) { + if (node instanceof Element && canScrollX(node, direction)) return true + node = node.parentNode ?? (node instanceof ShadowRoot ? node.host : null) + } + return false +} + +function canScrollX(el: Element, direction: SwipeDirection): boolean { + const max = el.scrollWidth - el.clientWidth + if (max < 1) return false + const style = getComputedStyle(el) + const overflow = style.overflowX + if (el === el.ownerDocument.scrollingElement) { + if (overflow === 'hidden' || overflow === 'clip') return false + } else if (overflow !== 'auto' && overflow !== 'scroll') return false + // Right to left, `scrollLeft` runs from 0 (the start, at the right) down to -max. + const fromLeft = style.direction === 'rtl' ? el.scrollLeft + max : el.scrollLeft + return direction === 'back' ? fromLeft > 0.5 : fromLeft < max - 0.5 +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 5f9d680c..df0cd9f0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ Orbit diff --git a/src/renderer/src/library/HistorySection.tsx b/src/renderer/src/library/HistorySection.tsx index e30237ee..c89c098a 100644 --- a/src/renderer/src/library/HistorySection.tsx +++ b/src/renderer/src/library/HistorySection.tsx @@ -12,6 +12,8 @@ import { useState, useSyncExternalStore, } from 'react' +import type { LibraryHistoryQuery } from '../../../shared/commands/archive' +import type { HistoryVisit } from '../../../shared/commands/history' import { displayHost } from '../../../shared/url' import { invoke } from '../api' import { useOrbit, useWindow } from '../store' @@ -21,6 +23,26 @@ import { HistoryListModel, type HistoryRow, historyKey } from './history-model' import { libraryToast } from './LibraryToasts' import type { LibrarySectionProps } from './sections' +/** + * What the list asks of main: the Library's commands, or the History page's (AR-10, pages lane), + * which are about its own tab's profile only. `profileId` is the profile shown, '' if not known yet. + */ +export interface HistoryCalls { + visits: (q: LibraryHistoryQuery) => Promise + open: (url: string, background: boolean) => Promise + remove: (url: string, profileId: string) => Promise + menu: (url: string, profileId: string, x: number, y: number) => Promise +} + +const profileArg = (profileId: string) => (profileId ? { profileId } : {}) + +const LIBRARY_CALLS: HistoryCalls = { + visits: (q) => invoke('library.history', q), + open: (url, background) => invoke('tabs.create', { url, activate: !background }), + remove: (url, profileId) => invoke('history.delete', { ...profileArg(profileId), url }), + menu: (url, profileId, x, y) => invoke('library.historyMenu', { ...profileArg(profileId), url, x, y }), +} + const timeFormat = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) /** Typing settles this long before the history is searched (a search reads every visit). */ const SEARCH_DELAY_MS = 60 @@ -97,9 +119,10 @@ function DayHeading({ item, now }: { item: Extract; n * The History section of the Library (AR-10): every page visit of the current profile, newest * first, by day, with search, in a virtualised list. A row opens its page in a new tab (⌘-click or * the middle button: behind the Library); its × or its menu removes the page from history. The - * Archive is separate: it lists the tabs that left the sidebar. + * Archive is separate: it lists the tabs that left the sidebar. The History page (`orbit://history/`) + * shows it too, with its own `calls`. */ -export function HistorySection({ show }: LibrarySectionProps) { +export function HistorySection({ show, calls = LIBRARY_CALLS }: LibrarySectionProps & { calls?: HistoryCalls }) { const win = useWindow() const spaceId = win?.spaceId const profileId = useOrbit((s) => (spaceId ? s.spaces[spaceId]?.profileId : undefined)) ?? '' @@ -115,7 +138,7 @@ export function HistorySection({ show }: LibrarySectionProps) { return () => clearTimeout(timer) }, [text, search]) - const model = useMemo(() => new HistoryListModel((q) => invoke('library.history', q)), []) + const model = useMemo(() => new HistoryListModel((q) => calls.visits(q)), [calls]) const data = useSyncExternalStore(model.subscribe, model.getSnapshot) useEffect(() => { if (profileId) model.setSearch(profileId, search) @@ -214,33 +237,30 @@ export function HistorySection({ show }: LibrarySectionProps) { ) // ---- Open and remove ---- - const shownProfile = useMemo(() => (profileId ? { profileId } : {}), [profileId]) - const onOpen = useCallback((row: HistoryRow, background: boolean) => { - // Shown: the Library makes way for the new tab. Behind: the Library stays for more. - void invoke('tabs.create', { url: row.url, activate: !background }).then(() => { - if (background) libraryToast('Opened in a new tab') - }) - }, []) + const onOpen = useCallback( + (row: HistoryRow, background: boolean) => { + // Shown: the Library makes way for the new tab. Behind: the Library stays for more. + void calls.open(row.url, background).then(() => { + if (background) libraryToast('Opened in a new tab') + }) + }, + [calls], + ) const onRemove = useCallback( (row: HistoryRow) => { // Main deletes before it answers the next page the list may ask for. - void invoke('history.delete', { ...shownProfile, url: row.url }) + void calls.remove(row.url, profileId) model.remove(row.url) }, - [model, shownProfile], + [calls, model, profileId], ) const onMenu = useCallback( (offset: number, row: HistoryRow, e: MouseEvent) => { e.preventDefault() setHighlight(offset) - void invoke('library.historyMenu', { - ...shownProfile, - url: row.url, - x: Math.round(e.clientX), - y: Math.round(e.clientY), - }) + void calls.menu(row.url, profileId, Math.round(e.clientX), Math.round(e.clientY)) }, - [shownProfile], + [calls, profileId], ) // The row menu's Remove from History (main removed the page). useEffect( diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index c331586b..d50ba7dc 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -30,6 +30,64 @@ async function boot(): Promise { ) return } + // A page's dialog over that page (pages lane, NV-24): alert, confirm, prompt, leave-page, sign-in. + if (view === 'dialog') { + const app = import('./pages/DialogApp') + await initStore() + installShortcutFallback() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { DialogApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } + // What a page's area shows when its page couldn't load or crashed (pages lane, NV-13, NV-14). + if (view === 'screen') { + const app = import('./pages/ScreenApp') + await initStore() + installShortcutFallback() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { ScreenApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } + // The print dialog over a page (pages lane, NV-07). + if (view === 'print') { + const app = import('./pages/PrintApp') + await initStore() + installShortcutFallback() + document.documentElement.dataset.view = view + document.documentElement.dataset.platform = window.orbit.platform + const { PrintApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } + // The arrow a two-finger swipe over a page slides in (pages lane, NV-02). + if (view === 'swipe') { + const app = import('./pages/SwipeApp') + await initStore() + document.documentElement.dataset.view = view + const { SwipeApp } = await app + createRoot(document.getElementById('root') as HTMLElement).render( + + + , + ) + return + } // An internal page (DT-13): Orbit's UI in a normal tab. Keys go to it as to any page. if (view === 'internal') { const app = import('./internal/InternalApp') diff --git a/src/renderer/src/pages/DialogApp.tsx b/src/renderer/src/pages/DialogApp.tsx new file mode 100644 index 00000000..122b9352 --- /dev/null +++ b/src/renderer/src/pages/DialogApp.tsx @@ -0,0 +1,271 @@ +import { + type CSSProperties, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' +import type { PageDialogContent } from '../../../shared/pages' +import { invoke } from '../api' +import { useWindow } from '../store' +import { paletteVars, usePalette } from '../theme' +import './dialog.css' + +/** + * A page's dialog (NV-24), in a transparent view main keeps over that page only: the page's alert, + * confirm or prompt, "Leave site?", HTTP sign-in, "Page unresponsive" (NV-14), or "Open Mail?" for + * a link to another app (NV-19). It takes the keyboard while it shows (KS-19): Enter answers with + * the accepting button (with Cancel, where Cancel is the safe answer), Esc cancels (or gives the + * safe answer: an alert's OK, Wait), and Tab stays inside the card. Main gives the keyboard back to + * the page when it goes (KS-16). + */ +export function DialogApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [content, setContent] = useState(null) + /** The dialog answered last: its card is gone until main sends the next one. */ + const answered = useRef(null) + + useEffect(() => { + let heard = false + const take = (next: PageDialogContent | null) => { + if (next && next.id === answered.current) return + setContent(next) + } + const off = window.orbit.on('dialog:content', (payload) => { + heard = true + take(payload as PageDialogContent) + }) + // What main sent before this page listened is lost: ask for the dialog now. + void invoke('pages.dialogContent', {}).then((first) => { + if (!heard) take(first) + }) + return off + }, []) + + const answer = useCallback((dialog: PageDialogContent, ok: boolean, fields: Fields = {}) => { + if (answered.current === dialog.id) return + answered.current = dialog.id + // Gone at once: the view may show another page's dialog next, and must not show this one first. + setContent(null) + void invoke('pages.dialogAnswer', { id: dialog.id, ok, ...fields }) + }, []) + + return ( +
+ {content && } +
+ ) +} + +interface Fields { + value?: string + user?: string + password?: string + checked?: boolean +} + +function DialogCard({ + content, + answer, +}: { + content: PageDialogContent + answer: (dialog: PageDialogContent, ok: boolean, fields?: Fields) => void +}) { + const card = useRef(null) + const firstField = useRef(null) + const okButton = useRef(null) + const cancelButton = useRef(null) + const [value, setValue] = useState(content.value ?? '') + const [user, setUser] = useState('') + const [password, setPassword] = useState('') + const [checked, setChecked] = useState(false) + const titleId = `page-dialog-title-${content.id}` + const messageId = `page-dialog-message-${content.id}` + + // The prompt's text is selected, sign-in starts at the username, a dialog whose safe answer is + // Cancel at Cancel, the rest at the accepting button. + const startButton = content.defaultCancel ? cancelButton : okButton + useLayoutEffect(() => { + const field = firstField.current + if (field) { + field.focus() + if (content.kind === 'prompt') field.select() + } else startButton.current?.focus() + }, [content.kind, startButton]) + + // The view can get the keyboard after the card shows (main focuses it when the page is current). + useEffect(() => { + const onFocus = () => { + if (card.current?.contains(document.activeElement)) return + const field = firstField.current + if (field) field.focus() + else startButton.current?.focus() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [startButton]) + + const accept = () => { + if (content.kind === 'prompt') answer(content, true, { value }) + else if (content.kind === 'auth') answer(content, true, { user, password }) + else if (content.check) answer(content, true, { checked }) + else answer(content, true) + } + const cancel = () => answer(content, false) + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + accept() + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + // An alert has one button: Esc closes it as OK does; "Page unresponsive" waits. + if (content.noCancel || content.escapeAccepts) accept() + else cancel() + return + } + // Where Cancel is the safe answer, Enter gives it anywhere but on the accepting button. + if (e.key === 'Enter' && content.defaultCancel && document.activeElement !== okButton.current) { + e.preventDefault() + e.stopPropagation() + cancel() + return + } + if (e.key !== 'Tab' || !card.current) return + // Tab goes round the card's own controls. + const controls = [...card.current.querySelectorAll('input, button')].filter( + (el) => !(el as HTMLButtonElement).disabled, + ) + if (controls.length === 0) return + const at = controls.indexOf(document.activeElement as HTMLElement) + const next = e.shiftKey + ? at <= 0 + ? controls.length - 1 + : at - 1 + : at < 0 || at >= controls.length - 1 + ? 0 + : at + 1 + e.preventDefault() + controls[next]?.focus() + } + + const alert = + content.kind === 'alert' || + content.kind === 'confirm' || + content.kind === 'leave' || + content.kind === 'reload' || + content.kind === 'hang' || + content.kind === 'app' + const title = ( +

+ {content.title} +

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

+ {content.message} +

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

+ {content.note} +

+ )} + {content.check && ( + + )} +
+ {!content.noCancel && ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/pages/PrintApp.tsx b/src/renderer/src/pages/PrintApp.tsx new file mode 100644 index 00000000..2d78c6c1 --- /dev/null +++ b/src/renderer/src/pages/PrintApp.tsx @@ -0,0 +1,294 @@ +import { type CSSProperties, type KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { type PrintContent, type PrintSettings, pagesProblem, paperText, SAVE_AS_PDF } from '../../../shared/print' +import { invoke } from '../api' +import { useWindow } from '../store' +import { paletteVars, usePalette } from '../theme' +import './print.css' + +/** + * The print dialog (NV-07), in a transparent view main keeps over the page being printed: the page + * as it prints (the engine's PDF of it, in the PDF viewer, without its toolbar) beside the settings, + * and Print (Save, for Save as PDF) and Cancel. Main closes it on Esc wherever its keyboard is, the + * preview included (KS-19); Enter prints; Tab stays inside it. Main gives the keyboard back to the + * page when it goes (KS-16). + */ +export function PrintApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [content, setContent] = useState(null) + /** The preview's PDF as a blob URL, kept while the next one is made. */ + const [pdf, setPdf] = useState<{ version: number; url: string } | null>(null) + + useEffect(() => { + let heard = false + const take = (next: PrintContent | null) => { + setContent(next) + const preview = next?.preview + if (preview?.state !== 'ready') return + setPdf((was) => { + if (was?.version === preview.version) return was + if (was) URL.revokeObjectURL(was.url) + const url = URL.createObjectURL(new Blob([preview.pdf as Uint8Array], { type: 'application/pdf' })) + return { version: preview.version, url } + }) + } + const off = window.orbit.on('print:content', (payload) => { + heard = true + take(payload as PrintContent) + }) + // What main sent before this page listened is lost: ask for the dialog now. + void invoke('pages.printContent', {}).then((first) => { + if (!heard) take(first) + }) + return off + }, []) + + return ( +
+ {content && } +
+ ) +} + +function PrintDialog({ content, pdfUrl }: { content: PrintContent; pdfUrl?: string }) { + const form = useRef(null) + const primary = useRef(null) + // The dialog's own copy, so typing isn't held up by main's answer; main's comes back the same. + const [settings, setSettings] = useState(content.settings) + const [copies, setCopies] = useState(String(content.settings.copies)) + const { preview, busy } = content + const total = preview.state === 'ready' ? preview.pages : undefined + const problem = total === undefined ? undefined : pagesProblem(settings, total) + const pdfDestination = settings.destination === SAVE_AS_PDF + const ready = total !== undefined && !problem && !busy + + // Main may have changed what the dialog shows: a printer gone, the destination back to the PDF. + useEffect(() => { + setSettings((was) => + was.destination === content.settings.destination ? was : { ...was, destination: content.settings.destination }, + ) + }, [content.settings.destination]) + + const change = (next: Partial) => { + setSettings((was) => ({ ...was, ...next })) + void invoke('pages.printChange', { dialog: content.dialog, settings: next }) + } + const answer = (ok: boolean) => void invoke('pages.printAnswer', { dialog: content.dialog, ok }) + + // The keyboard starts on Print, once there is something to print; till then on the dialog. + useLayoutEffect(() => { + if (ready && (document.activeElement === document.body || document.activeElement === form.current)) { + primary.current?.focus() + } else if (!form.current?.contains(document.activeElement)) form.current?.focus() + }, [ready]) + // The view can get the keyboard after the dialog shows (main focuses it when the page is current). + useEffect(() => { + const onFocus = () => { + if (form.current?.contains(document.activeElement) && document.activeElement !== form.current) return + if (primary.current && !primary.current.disabled) primary.current.focus() + else form.current?.focus() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, []) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab' || !form.current) return + // Tab goes round the dialog's own controls. + const controls = [...form.current.querySelectorAll('select, input, button')].filter( + (el) => !(el as HTMLButtonElement).disabled, + ) + if (controls.length === 0) return + const at = controls.indexOf(document.activeElement as HTMLElement) + const next = e.shiftKey + ? at <= 0 + ? controls.length - 1 + : at - 1 + : at < 0 || at >= controls.length - 1 + ? 0 + : at + 1 + e.preventDefault() + controls[next]?.focus() + } + + return ( +
{ + e.preventDefault() + if (ready) answer(true) + }} + autoComplete="off" + > +
+ {pdfUrl && preview.state !== 'error' && ( +
`, + ), + ) + return + } + if (u.pathname.startsWith('/page/')) { + const name = decodeURIComponent(u.pathname.slice('/page/'.length)) + send(html(name, `

${name}

`)) + return + } + if (u.pathname.startsWith('/video/')) { + const name = decodeURIComponent(u.pathname.slice('/video/'.length)) + send(html(name, VIDEO)) + return + } + if (u.pathname === '/apps') { + const frame = u.searchParams.get('frame') + send( + html( + 'Apps', + `

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

+${frame ? `` : ''}
+`, + ), + ) + return + } + if (u.pathname === '/app-frame') { + send(html('Frame', 'Mail from the frame')) + return + } + if (u.pathname === '/to-app') { + res.writeHead(302, { + location: u.searchParams.get('to') ?? 'mailto:someone@example.com', + 'cache-control': 'no-store', + }) + res.end() + return + } + if (u.pathname === '/upload') { + send( + html( + 'Upload', + ` +
`, + ), + ) + return + } + send('not found', 404) + } +} + +/** Whether this machine can make a certificate for the secure fixture. */ +export function canMakeCertificate(): boolean { + try { + execFileSync('openssl', ['version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** + * The fixture pages over HTTPS, with a certificate for localhost that no one trusts (made now by + * openssl, and valid for a day): every page gets Chromium's ERR_CERT_AUTHORITY_INVALID. + */ +export async function startSecureFixtures(base = 3365): Promise { + const dir = mkdtempSync(join(tmpdir(), 'orbit-e2e-cert-')) + let key: Buffer + let cert: Buffer + try { + const config = join(dir, 'cert.cnf') + writeFileSync( + config, + `[req] +distinguished_name = dn +x509_extensions = ext +prompt = no +[dn] +CN = localhost +[ext] +subjectAltName = DNS:localhost, IP:127.0.0.1 +basicConstraints = CA:FALSE +`, + ) + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-days', + '1', + '-config', + config, + '-keyout', + join(dir, 'key.pem'), + '-out', + join(dir, 'cert.pem'), + ], + { stdio: 'ignore' }, + ) + key = readFileSync(join(dir, 'key.pem')) + cert = readFileSync(join(dir, 'cert.pem')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + const seen: string[] = [] + const server = createSecureServer({ key, cert }, pagesHandler(seen)) + const port = await listenInRange(server as unknown as Server, base) + return { + port, + url: (path) => `https://localhost:${port}${path}`, + seen: () => [...seen], + close: () => closeServer(server as unknown as Server), + } +} + +export interface SilentServer { + port: number + url: (path: string) => string + /** How many requests for a path reached it. */ + hits: (path: string) => number + close: () => Promise +} + +/** A server that takes every request and never answers one (NV-13's time limit). */ +export async function startSilentServer(base = 3360): Promise { + const seen: string[] = [] + const server = createServer((req) => { + seen.push(new URL(req.url ?? '/', 'http://localhost').pathname) + }) + const port = await listenInRange(server, base) + return { + port, + url: (path) => `http://localhost:${port}${path}`, + hits: (path) => seen.filter((p) => p === path).length, + close: () => closeServer(server), + } +} + +type PagesGlobal = { + __orbitPages: { + dialogs: () => PageDialogInfo[] + leave: (tabId: string) => LeaveInfo | null + screens: () => PageScreenInfo[] + launched: string[] + appLinks: { url: string; outcome: string }[] + fullScreen: (windowId: string) => FullScreenInfo + print: () => PrintInfo[] + printed: PrintRecord[] + swipe: (windowId: string) => SwipeInfo + } +} + +/** The icon test runs' OS gives its mail app (NV-19). */ +export const APP_ICON = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232a7de1'/%3E%3C/svg%3E" + +/** + * The apps a test run's OS has (ORBIT_E2E_APPS, for launchOrbit's `env`): Mail opens `mailto:` + * links, with an icon; any other kind's app goes unnamed, as on Linux (NV-19). + */ +export const APPS_ENV = { ORBIT_E2E_APPS: JSON.stringify({ mailto: { name: 'Mail', icon: APP_ICON } }) } + +/** Links Orbit opened in another app, oldest first: test runs record each and start none (NV-19). */ +export function launchedLinks(app: ElectronApplication): Promise { + return app.evaluate(() => [...(globalThis as unknown as PagesGlobal).__orbitPages.launched]) +} + +/** Links to other apps that pages followed, oldest first, and what came of each (NV-19). */ +export function appLinks(app: ElectronApplication): Promise<{ url: string; outcome: string }[]> { + return app.evaluate(() => [...(globalThis as unknown as PagesGlobal).__orbitPages.appLinks]) +} + +/** + * Clicks a button whose click closes the page it's in: a Peek's, a Mini Window's or a pop-up's + * dialog has a view of its own, which goes with the dialog, sometimes before the click returns. + */ +export async function clickClosing(target: Locator): Promise { + try { + await target.click() + } catch (error) { + if (!String(error).includes('has been closed')) throw error + } +} + +/** Every page's dialog on show or waiting (the first of each page's). */ +export function pageDialogs(app: ElectronApplication): Promise { + return app.evaluate(() => (globalThis as unknown as PagesGlobal).__orbitPages.dialogs()) +} + +/** Waits for a dialog that matches, and returns it. */ +export async function waitDialog( + app: ElectronApplication, + match: (d: PageDialogInfo) => boolean, + timeout = 15_000, +): Promise { + let found: PageDialogInfo | undefined + await expect + .poll( + async () => { + found = (await pageDialogs(app)).find(match) + return !!found + }, + { timeout }, + ) + .toBe(true) + return found as PageDialogInfo +} + +/** Runs a script in the frame (`#frame`) of the page whose URL is `url`; null when there's none yet. */ +export function inFrame(app: ElectronApplication, url: string, code: string): Promise { + return app.evaluate( + async ({ webContents }, [u, c]) => { + const frame = webContents.getAllWebContents().find((w) => w.getURL() === u)?.mainFrame.frames[0] + return frame ? frame.executeJavaScript(c).catch(() => null) : null + }, + [url, code] as const, + ) +} + +/** + * Clicks in the middle of a `/framed` page's frame, a `/warn` page, the way a person does (through + * the browser process), once the frame has loaded, and waits until the frame got the press. + */ +export async function clickInFrame(app: ElectronApplication, url: string): Promise { + await expect.poll(() => inFrame(app, url, 'document.readyState')).toBe('complete') + const before = Number(await inFrame(app, url, 'window.__clicks || 0')) + await clickInPage(app, url, '#frame') + await expect.poll(async () => Number(await inFrame(app, url, 'window.__clicks || 0'))).toBeGreaterThan(before) +} + +/** A fixture page's address on 127.0.0.1: another site than `fx.url`'s (localhost), so a frame from it has a process of its own. */ +export function otherSite(fx: PagesFixtures, path: string): string { + return `http://127.0.0.1:${fx.port}${path}` +} + +/** + * Clicks in a `/framed` page's frame from another site (a `/warn` page) the way a person does, once + * the frame has loaded, and waits until the frame got the press. Playwright's click goes through the + * browser, which sends it to the frame's own process; `sendInputEvent` (clickInFrame) reaches only + * the page's. + */ +export async function clickInOtherSiteFrame(app: ElectronApplication, url: string): Promise { + await expect.poll(() => inFrame(app, url, 'document.readyState')).toBe('complete') + const before = Number(await inFrame(app, url, 'window.__clicks || 0')) + await (await tabPage(app, url)).frameLocator('#frame').locator('#area').click() + await expect.poll(async () => Number(await inFrame(app, url, 'window.__clicks || 0'))).toBeGreaterThan(before) +} + +export function leaveInfo(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.leave(id), tabId) +} + +/** The dialog view (a Playwright page) showing the dialog with this id. */ +export async function dialogPage(app: ElectronApplication, id: number): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + for (const page of app.windows()) { + if (!page.url().includes('view=dialog')) continue + const shows = await page + .locator(`[data-dialog-id="${id}"]`) + .count() + .catch(() => 0) + if (shows > 0) return page + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error(`no dialog view shows dialog ${id}`) +} + +/** What a `/ask` page's call returned (JSON text), once the page is free to answer. */ +export async function askResult(page: Page): Promise { + return (await page.locator('#result').textContent()) ?? '' +} + +/** Every page's error or crash screen (shown or waiting for its page to show). */ +export function pageScreens(app: ElectronApplication): Promise { + return app.evaluate(() => (globalThis as unknown as PagesGlobal).__orbitPages.screens()) +} + +/** Waits for a screen that matches, and returns it. */ +export async function waitScreen( + app: ElectronApplication, + match: (s: PageScreenInfo) => boolean, + timeout = 15_000, +): Promise { + let found: PageScreenInfo | undefined + await expect + .poll( + async () => { + found = (await pageScreens(app)).find(match) + return !!found + }, + { timeout }, + ) + .toBe(true) + return found as PageScreenInfo +} + +/** Waits until a tab has no screen. */ +export async function waitNoScreen(app: ElectronApplication, tabId: string): Promise { + await expect.poll(async () => (await pageScreens(app)).filter((s) => s.tabId === tabId).length).toBe(0) +} + +/** The screen view (a Playwright page) showing the screen with this id. */ +export async function screenPage(app: ElectronApplication, id: number): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + for (const page of app.windows()) { + if (!page.url().includes('view=screen')) continue + const shows = await page + .locator(`[data-screen-id="${id}"]`) + .count() + .catch(() => 0) + if (shows > 0) return page + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error(`no screen view shows screen ${id}`) +} + +type TabsGlobal = { __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } } + +/** Crashes a tab's page, as a renderer that dies does. */ +export async function crashTab(app: ElectronApplication, tabId: string): Promise { + await app.evaluate((_e, id) => { + ;(globalThis as unknown as TabsGlobal).__orbit.ctx.tabs.contents(id)?.forcefullyCrashRenderer() + }, tabId) +} + +export function tabCrashed(app: ElectronApplication, tabId: string): Promise { + return app.evaluate( + (_e, id) => (globalThis as unknown as TabsGlobal).__orbit.ctx.tabs.contents(id)?.isCrashed() ?? null, + tabId, + ) +} + +/** + * Emits one of a tab's page's events as the engine does (`unresponsive`, `responsive`). The engine + * itself says nothing of a hung page while a debugger is attached to it, and Playwright attaches + * to every page. + */ +export async function emitOnTab(app: ElectronApplication, tabId: string, name: string): Promise { + await app.evaluate( + (_e, [id, n]) => { + ;(globalThis as unknown as TabsGlobal).__orbit.ctx.tabs.contents(id)?.emit(n) + }, + [tabId, name] as const, + ) +} + +/** Starts a script in a tab's page and doesn't wait for it (it may never end). */ +export async function startInTab(app: ElectronApplication, tabId: string, code: string): Promise { + await app.evaluate( + (_e, [id, c]) => { + void (globalThis as unknown as TabsGlobal).__orbit.ctx.tabs + .contents(id) + ?.executeJavaScript(c) + .catch(() => {}) + }, + [tabId, code] as const, + ) +} + +/** + * Counts a tab's main-frame navigations from now on (a listener of the test's own on the page). + * Returns a reader. + */ +export async function countNavigations(app: ElectronApplication, tabId: string): Promise<() => Promise> { + const key = `__pagesNavigations_${tabId}` + await app.evaluate( + (_e, [id, k]) => { + const g = globalThis as unknown as TabsGlobal & Record + g[k] = 0 + g.__orbit.ctx.tabs.contents(id)?.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) g[k] = (g[k] ?? 0) + 1 + }) + }, + [tabId, key] as const, + ) + return () => app.evaluate((_e, k) => (globalThis as unknown as Record)[k] ?? 0, key) +} + +export interface NetworkSwitch { + /** Takes the page off the network (its loads fail with ERR_INTERNET_DISCONNECTED), or puts it back. */ + offline(on: boolean): Promise + close(): Promise +} + +/** + * Takes one tab's page off the network and puts it back, as DevTools' Offline does (the profile's + * own network emulation doesn't reach a page's navigations). `urlPart` finds the page. + */ +export async function networkSwitch(app: ElectronApplication, urlPart: string): Promise { + const page = await tabPage(app, urlPart) + const cdp = await page.context().newCDPSession(page) + await cdp.send('Network.enable') + return { + offline: async (on) => { + await cdp.send('Network.emulateNetworkConditions', { + offline: on, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + }, + close: () => cdp.detach().catch(() => {}), + } +} + +/** A tab's page area (window coordinates). */ +export function pageBounds(app: ElectronApplication, tabId: string): Promise { + return app.evaluate((_e, id) => { + const g = globalThis as unknown as { __orbit: { tabView: (id: string) => { bounds: unknown } | null } } + return g.__orbit.tabView(id)?.bounds + }, tabId) +} + +/** A window's full screen as Orbit keeps it (NV-20). */ +export function fullScreenInfo(app: ElectronApplication, windowId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.fullScreen(id), windowId) +} + +/** The printers a test run's OS has (ORBIT_E2E_PRINTERS, for launchOrbit's `env`): one office printer (NV-07). */ +export const PRINTERS_ENV = { ORBIT_E2E_PRINTERS: JSON.stringify([{ id: 'Office', name: 'Office Printer' }]) } + +/** Every page's print dialog (NV-07). */ +export function printDialogs(app: ElectronApplication): Promise { + return app.evaluate(() => (globalThis as unknown as PagesGlobal).__orbitPages.print()) +} + +/** Print jobs, oldest first: test runs record each, and print or save none (NV-07). */ +export function printedJobs(app: ElectronApplication): Promise { + return app.evaluate(() => [...(globalThis as unknown as PagesGlobal).__orbitPages.printed]) +} + +/** Waits for a print dialog that matches (by default, one on screen with its preview), and returns it. */ +export async function waitPrint( + app: ElectronApplication, + match: (d: PrintInfo) => boolean = (d) => d.onScreen && d.preview === 'ready', +): Promise { + let found: PrintInfo | undefined + await expect + .poll(async () => { + found = (await printDialogs(app)).find(match) + return !!found + }) + .toBe(true) + return found as PrintInfo +} + +/** The print view (a Playwright page) showing the print dialog with this id. */ +export async function printPage(app: ElectronApplication, id: number): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + for (const page of app.windows()) { + if (!page.url().includes('view=print')) continue + const shows = await page + .locator(`[data-dialog-id="${id}"]`) + .count() + .catch(() => 0) + if (shows > 0) return page + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error(`no print view shows dialog ${id}`) +} + +/** A window's two-finger swipe over a page and its arrow (NV-02). */ +export function swipeInfo(app: ElectronApplication, windowId: string): Promise { + return app.evaluate((_e, id) => (globalThis as unknown as PagesGlobal).__orbitPages.swipe(id), windowId) +} + +/** + * A two-finger horizontal swipe over the page showing `url`, at `x`, `y` (page coordinates): one + * trackpad scroll event for each of `deltas` (px, as the page's `WheelEvent.deltaX` reads them: a + * negative one scrolls toward the left, which is back), sent at once through the browser process, + * as the trackpad's are. Returns the window's swipe as soon as main has heard all of them, before + * the fingers count as lifted (RELEASE_GAP_MS after the last), or null if it didn't in time. + */ +export function swipeOverPage( + app: ElectronApplication, + windowId: string, + url: string, + at: { x: number; y: number }, + deltas: number[], +): Promise { + return app.evaluate( + async ({ webContents }, { windowId, url, at, deltas }) => { + const page = webContents.getAllWebContents().find((w) => w.getURL() === url) + const hooks = (globalThis as unknown as PagesGlobal).__orbitPages + for (const d of deltas) { + page?.sendInputEvent({ + type: 'mouseWheel', + x: at.x, + y: at.y, + deltaX: -d, + deltaY: 0, + hasPreciseScrollingDeltas: true, + canScroll: true, + }) + } + const total = Math.abs(deltas.reduce((a, b) => a + b, 0)) + const deadline = Date.now() + 1000 + while (Date.now() < deadline) { + const info = hooks.swipe(windowId) + if (info.tabId && info.travel >= Math.min(total, 112) - 0.5) return info + await new Promise((r) => setTimeout(r, 2)) + } + return null + }, + { windowId, url, at, deltas }, + ) +} diff --git a/tests/e2e/pages-swipe.spec.ts b/tests/e2e/pages-swipe.spec.ts new file mode 100644 index 00000000..bab02114 --- /dev/null +++ b/tests/e2e/pages-swipe.spec.ts @@ -0,0 +1,146 @@ +import { expect, type Page, test } from '@playwright/test' +import { launchOrbit, type Orbit, state, tabPage } from './helpers' +import { type PagesFixtures, pageBounds, startPagesFixtures, swipeInfo, swipeOverPage } from './pages-support' +import { activeSpace, createSpace, hoverSidebar, swipe } from './spaces-support' +import { newTab, waitTab } from './tabs-support' + +/** + * Two-finger swipe back and forward over the page (NV-02), with synthetic trackpad scroll events + * sent through the browser process. A real trackpad's gesture, with the OS's momentum after the + * fingers lift, is for a person to check on macOS. + */ + +let fx: PagesFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startPagesFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +/** The arrow's view: its circle and the room for its shadow (HISTORY_SWIPE.size). */ +const SIZE = 56 +/** The travel that brings the arrow fully in (HISTORY_SWIPE.distance). */ +const DISTANCE = 112 + +/** A tab that went from `first` to `then`, so it can go back; its window. */ +async function tabWithHistory(first: string, then: string) { + const { app } = orbit + const tabId = await newTab(app, fx.url(first)) + await waitTab(app, tabId, first) + await (await tabPage(app, first)).evaluate((to) => location.assign(to), then) + await waitTab(app, tabId, then) + const windowId = Object.keys((await state(app)).windows)[0] as string + const card = (await pageBounds(app, tabId)) as Electron.Rectangle + return { tabId, windowId, card } +} + +/** The arrow's own view. */ +async function arrowView() { + let found: Page | undefined + await expect + .poll(() => { + found = orbit.app.windows().find((p) => p.url().includes('view=swipe')) + return !!found + }) + .toBe(true) + return found as Page +} + +/** The swipe has ended and its arrow has gone. */ +async function arrowGone(windowId: string) { + await expect.poll(async () => (await swipeInfo(orbit.app, windowId)).visible).toBe(false) + await expect.poll(async () => (await swipeInfo(orbit.app, windowId)).tabId).toBeNull() +} + +test('[NV-02] a two-finger swipe right over the page goes back once its arrow is fully in; let go sooner, it stays; the other way goes forward', async () => { + const { app } = orbit + const { tabId, windowId, card } = await tabWithHistory('/page/One', '/page/Two') + const two = fx.url('/page/Two') + const at = { x: 300, y: 300 } + const top = card.y + Math.round((card.height - SIZE) / 2) + + // 40 px: the arrow comes that far in from the page's left edge, the rest of it past the edge. + const short = await swipeOverPage(app, windowId, two, at, [-10, -10, -10, -10]) + expect(short).toMatchObject({ visible: true, tabId, direction: 'back', travel: 40, armed: false }) + expect(short?.bounds).toEqual({ x: card.x, y: top, width: 40, height: SIZE }) + const arrow = (await arrowView()).getByTestId('swipe-arrow') + await expect(arrow).toHaveAttribute('data-direction', 'back') + await expect(arrow).toHaveAttribute('data-armed', 'false') + // Let go there: nothing happens. + await arrowGone(windowId) + expect((await swipeInfo(app, windowId)).last).toEqual({ direction: 'back', travel: 40, went: false }) + expect((await state(app)).tabs[tabId]?.url).toBe(two) + + // Further: the arrow is fully in and stops there; let go, the page goes back. + const full = await swipeOverPage(app, windowId, two, at, Array(8).fill(-16)) + expect(full).toMatchObject({ visible: true, direction: 'back', travel: DISTANCE, armed: true }) + expect(full?.bounds).toEqual({ x: card.x + DISTANCE - SIZE, y: top, width: SIZE, height: SIZE }) + await waitTab(app, tabId, '/page/One') + expect((await swipeInfo(app, windowId)).last).toEqual({ direction: 'back', travel: DISTANCE, went: true }) + // The arrow filled once it was fully in. + await expect(arrow).toHaveAttribute('data-armed', 'true') + await arrowGone(windowId) + + // The other way, from the right edge: forward. + const forward = await swipeOverPage(app, windowId, fx.url('/page/One'), at, Array(8).fill(16)) + expect(forward).toMatchObject({ visible: true, direction: 'forward', armed: true }) + expect(forward?.bounds).toEqual({ x: card.x + card.width - DISTANCE, y: top, width: SIZE, height: SIZE }) + await waitTab(app, tabId, '/page/Two') + await arrowGone(windowId) + + // Nowhere further forward: no arrow, and nothing happens. + expect(await swipeOverPage(app, windowId, two, at, Array(8).fill(16))).toBeNull() + expect((await swipeInfo(app, windowId)).visible).toBe(false) + expect((await state(app)).tabs[tabId]?.url).toBe(two) +}) + +test('[NV-02] the page gets the swipe first: a strip that can still scroll that way, or a page that uses the wheel, keeps it; over the sidebar the swipe switches Space and the page stays', async () => { + const { app, shell } = orbit + const { tabId, windowId } = await tabWithHistory('/page/Start', '/swipe') + const url = fx.url('/swipe') + const page = await tabPage(app, '/swipe') + const centre = (id: string) => + page.evaluate((el) => { + const r = (document.getElementById(el) as HTMLElement).getBoundingClientRect() + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) } + }, id) + const stripAt = () => page.evaluate(() => (document.getElementById('strip') as HTMLElement).scrollLeft) + + // The strip can scroll left: it does, and there's no arrow. + const strip = await centre('strip') + expect(await swipeOverPage(app, windowId, url, strip, Array(8).fill(-16))).toBeNull() + await expect.poll(stripAt).toBeLessThan(1000) + // At its left edge, the same swipe is the page's back. + await page.evaluate(() => { + ;(document.getElementById('strip') as HTMLElement).scrollLeft = 0 + }) + await arrowGone(windowId) + expect(await swipeOverPage(app, windowId, url, strip, [-10, -10, -10, -10])).toMatchObject({ + visible: true, + direction: 'back', + }) + await arrowGone(windowId) + + // An area that uses the wheel itself keeps the swipe. + expect(await swipeOverPage(app, windowId, url, await centre('grab'), Array(8).fill(-16))).toBeNull() + expect((await state(app)).tabs[tabId]?.url).toBe(url) + + // Over the sidebar the swipe switches Space, and the page stays where it is. + const space = await activeSpace(app) + const next = await createSpace(app, { name: 'Beta', activate: false }) + expect(next).not.toBe(space) + await hoverSidebar(shell) + await swipe(shell, 10, 13, 10) + await expect.poll(() => activeSpace(app), { timeout: 3000 }).toBe(next) + expect((await swipeInfo(app, windowId)).last).toEqual({ direction: 'back', travel: 40, went: false }) + expect((await state(app)).tabs[tabId]?.url).toBe(url) +}) diff --git a/tests/unit/keymap-defaults.test.ts b/tests/unit/keymap-defaults.test.ts index 02c325a0..4cce80af 100644 --- a/tests/unit/keymap-defaults.test.ts +++ b/tests/unit/keymap-defaults.test.ts @@ -5,8 +5,8 @@ import { bindingProblem, canonicalAccelerator, type KeyInput } from '../../src/s /** * The default keymap against the shortcut tables of the spec (KS-03 audit): every row Orbit binds, * on macOS and on Windows/Linux. Rows for features no lane has built yet (blank window, top - * toolbar, save page, full screen keys, reader mode, developer mode, notes, canvases, extensions) - * are left out; so are keys that belong to an open overlay (KS-04's Esc, Tab, arrows). + * toolbar, reader mode, developer mode, notes, canvases, extensions) are left out; so + * are keys that belong to an open overlay (KS-04's Esc, Tab, arrows). */ const TABLE: [id: string, mac: string[], other: string[]][] = [ // KS-04 @@ -68,7 +68,11 @@ const TABLE: [id: string, mac: string[], other: string[]][] = [ ['page.zoomOut', ['Cmd+-'], ['Ctrl+-']], ['page.zoomReset', ['Cmd+0'], ['Ctrl+0']], ['page.print', ['Cmd+P'], ['Ctrl+P']], + ['page.save', ['Cmd+Shift+S'], ['Ctrl+Shift+S']], ['page.viewSource', ['Cmd+Alt+U'], ['Ctrl+U']], + // fn+F is the system's own key on macOS; Esc leaving full screen is no binding (NV-20). + ['window.toggleFullScreen', ['Cmd+Ctrl+F'], ['F11', 'Alt+Shift+F']], + ['window.exitFullScreen', [], []], ['site.controls', [], []], // KS-09 ['split.add', ['Ctrl+Shift+='], ['Ctrl+Shift+=']], diff --git a/tests/unit/link-routing.test.ts b/tests/unit/link-routing.test.ts index 6a373a37..46b5836a 100644 --- a/tests/unit/link-routing.test.ts +++ b/tests/unit/link-routing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + altClickOpens, decideLink, framesMayOpen, isInternalPageUrl, @@ -166,6 +167,25 @@ describe('local files a page may have Orbit open (D49)', () => { expect(pageMayOpen(from, file)).toBe(false) } }) + it('⌥-click: the link at the press opens as the page may open it; a frame’s link as a web page’s, even in a local page (round 2 fix check F2)', () => { + const local = 'file:///tmp/orbit/index.html' + const web = 'https://example.com/' + const link = 'https://example.org/b' + expect(altClickOpens(web, { href: link }, undefined)).toEqual({ url: link, split: false }) + expect(altClickOpens(local, { href: file }, undefined)).toEqual({ url: file, split: false }) + expect(altClickOpens(web, { href: file }, file)).toBeNull() + // A press on a frame: the link the page last hovered, once the engine's download names it. + expect(altClickOpens(web, { frame: true }, link)).toEqual({ url: link, split: true }) + expect(altClickOpens(local, { frame: true }, link)).toEqual({ url: link, split: true }) + // No frame opens a local file, whatever the page is. + expect(altClickOpens(local, { frame: true }, file)).toBeNull() + expect(altClickOpens(web, { frame: true }, file)).toBeNull() + // Nothing hovered, nothing found, or no page to open: nothing opens. + expect(altClickOpens(web, { frame: true }, undefined)).toBeNull() + expect(altClickOpens(web, null, link)).toBeNull() + expect(altClickOpens(web, { href: 'javascript:void 0' }, link)).toBeNull() + expect(altClickOpens(web, { frame: true }, 'data:text/html,x')).toBeNull() + }) 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) diff --git a/tests/unit/pages-apps.test.ts b/tests/unit/pages-apps.test.ts new file mode 100644 index 00000000..a374c93c --- /dev/null +++ b/tests/unit/pages-apps.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { appDialog, appName, appScheme, launchableUrl, noAppMessage } from '../../src/shared/pages' + +describe('[NV-19] links that open other apps', () => { + it('knows a link to another app by its scheme', () => { + expect(appScheme('mailto:someone@example.com')).toBe('mailto') + expect(appScheme('MAILTO:someone@example.com')).toBe('mailto') + expect(appScheme('tel:+15550100')).toBe('tel') + expect(appScheme('zoommtg://join?confno=123')).toBe('zoommtg') + expect(appScheme('ms-teams:/l/meetup-join')).toBe('ms-teams') + }) + + it('never hands the OS a web address, a local file, a script or a scheme Chromium refuses', () => { + for (const url of [ + 'https://example.com/', + 'http://localhost:3341/', + 'file:///etc/hosts', + 'about:blank', + 'data:text/html,hi', + 'blob:https://example.com/x', + 'javascript:alert(1)', + 'view-source:https://example.com/', + 'orbit://history', + 'chrome://gpu', + 'devtools://devtools/bundled/inspector.html', + 'shell:startup', + 'vbscript:msgbox', + 'ms-msdt:/id', + 'search-ms:query=x', + 'hcp://system', + '', + 'not a link', + ]) { + expect(appScheme(url), url).toBe('') + } + expect(appScheme(undefined)).toBe('') + }) + + it('names a Linux desktop entry as its app, and leaves other systems’ names as they are', () => { + expect(appName('thunderbird.desktop', 'linux')).toBe('Thunderbird') + expect(appName('org.gnome.Evolution.desktop', 'linux')).toBe('Evolution') + expect(appName('mozilla-thunderbird.desktop', 'linux')).toBe('Mozilla Thunderbird') + expect(appName('org.kde.kmail2.desktop', 'linux')).toBe('Kmail2') + expect(appName('Zoom.desktop', 'linux')).toBe('Zoom') + expect(appName('', 'linux')).toBe('') + expect(appName('zoom.us', 'darwin')).toBe('zoom.us') + expect(appName(' Mail ', 'win32')).toBe('Mail') + }) + + it('escapes what an app’s handler could take for more than one argument, as Chromium does', () => { + expect(launchableUrl('mailto:a@b.c?subject=Hi there"x"')).toBe('mailto:a@b.c?subject=Hi%20there%22x%22') + expect(launchableUrl('app:\\b^c`d{e|f}g')).toBe('app:%3Ca%3E%5Cb%5Ec%60d%7Be%7Cf%7Dg') + expect(launchableUrl('tel:+1\n555\t0100')).toBe('tel:+1%0A555%090100') + expect(launchableUrl('mailto:a@b.c?subject=Hello%20there')).toBe('mailto:a@b.c?subject=Hello%20there') + }) + + it('asks “Open Mail?” naming the site, with its icon and Always allow; Cancel is the default', () => { + expect(appDialog({ name: 'Mail', icon: 'data:image/png;base64,AA==' }, 'https://example.com')).toEqual({ + kind: 'app', + title: 'Open Mail?', + message: 'example.com wants to open this application.', + icon: 'data:image/png;base64,AA==', + check: 'Always allow example.com to open links of this type in the associated app', + okLabel: 'Open Mail', + defaultCancel: true, + }) + expect(appDialog({ name: 'Zoom' }, 'http://localhost:3341').check).toBe( + 'Always allow localhost:3341 to open links of this type in the associated app', + ) + }) + + it('names no app the OS can’t name', () => { + expect(appDialog(undefined, 'https://example.com')).toMatchObject({ + title: 'Open this link in another app?', + message: 'example.com wants to open a link in another app.', + okLabel: 'Open link', + }) + }) + + it('offers Always allow to a web site only', () => { + const file = appDialog({ name: 'Mail' }, 'file://') + expect(file.message).toBe('This file wants to open this application.') + expect(file.check).toBeUndefined() + const unnamed = appDialog({ name: 'Mail' }, null) + expect(unnamed.message).toBe('A website wants to open this application.') + expect(unnamed.check).toBeUndefined() + }) + + it('says so when no app opens a kind of link', () => { + expect(noAppMessage('zoommtg')).toBe('No app on this device opens zoommtg: links') + }) +}) diff --git a/tests/unit/pages-dialogs.test.ts b/tests/unit/pages-dialogs.test.ts new file mode 100644 index 00000000..9605f38e --- /dev/null +++ b/tests/unit/pages-dialogs.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + authDialog, + DIALOG_TEXT_MAX, + dialogText, + dialogTitle, + leaveDialog, + scriptAnswer, + scriptDialog, +} from '../../src/shared/pages' + +describe('[NV-24] page dialogs: who asks', () => { + it('names a web page by its host and port', () => { + expect(dialogTitle('http://localhost:3341/ask')).toBe('localhost:3341 says') + expect(dialogTitle('https://example.com/a?b')).toBe('example.com says') + expect(dialogTitle('https://[::1]:8443/')).toBe('[::1]:8443 says') + }) + + it('names a blob: page by the page that made it', () => { + expect(dialogTitle('blob:https://example.com/5f0c0d2e-1111-2222-3333-444455556666')).toBe('example.com says') + }) + + it('says "This page says" for anything else', () => { + for (const url of ['file:///home/me/a.html', 'data:text/html,hi', 'about:blank', '', undefined, 'not a url']) { + expect(dialogTitle(url)).toBe('This page says') + } + }) + + it('names the site of an origin: an about:blank page’s is its opener’s', () => { + expect(dialogTitle('http://localhost:3341')).toBe('localhost:3341 says') + expect(dialogTitle('https://example.com')).toBe('example.com says') + // An opaque origin (a data: page, a sandboxed one) or a file's names no site. + for (const origin of ['null', 'file://']) expect(dialogTitle(origin)).toBe('This page says') + }) +}) + +describe('[NV-24] page dialogs: the page’s alert, confirm and prompt', () => { + it('alert has one button, confirm two, prompt a field with the page’s text', () => { + expect(scriptDialog('alert', 'https://a.test/', 'Saved', '')).toEqual({ + kind: 'alert', + title: 'a.test says', + message: 'Saved', + okLabel: 'OK', + noCancel: true, + }) + expect(scriptDialog('confirm', 'https://a.test/', 'Sure?', '')).toEqual({ + kind: 'confirm', + title: 'a.test says', + message: 'Sure?', + okLabel: 'OK', + }) + expect(scriptDialog('prompt', 'https://a.test/', 'Name', 'Ada')).toMatchObject({ kind: 'prompt', value: 'Ada' }) + }) + + it('cuts a very long text', () => { + const long = 'x'.repeat(DIALOG_TEXT_MAX + 5) + expect(dialogText(long)).toBe(`${'x'.repeat(DIALOG_TEXT_MAX)}…`) + expect(dialogText('short')).toBe('short') + expect(scriptDialog('prompt', undefined, long, long).value?.length).toBe(DIALOG_TEXT_MAX + 1) + }) + + it('gives the page what Chromium’s calls return', () => { + expect(scriptAnswer('alert', { id: 1, ok: true })).toBeUndefined() + expect(scriptAnswer('alert', null)).toBeUndefined() + expect(scriptAnswer('confirm', { id: 1, ok: true })).toBe(true) + expect(scriptAnswer('confirm', { id: 1, ok: false })).toBe(false) + // The dialog went away (its page navigated, the tab closed): Cancel. + expect(scriptAnswer('confirm', null)).toBe(false) + expect(scriptAnswer('prompt', { id: 1, ok: true, value: 'typed' })).toBe('typed') + expect(scriptAnswer('prompt', { id: 1, ok: true })).toBe('') + expect(scriptAnswer('prompt', { id: 1, ok: false, value: 'typed' })).toBeNull() + expect(scriptAnswer('prompt', null)).toBeNull() + }) +}) + +describe('[NV-24] page dialogs: leaving and signing in', () => { + it('"Leave site?" and, for a reload, "Reload site?"', () => { + expect(leaveDialog(false)).toEqual({ + kind: 'leave', + title: 'Leave site?', + message: 'Changes you made may not be saved.', + okLabel: 'Leave', + }) + expect(leaveDialog(true)).toMatchObject({ kind: 'reload', title: 'Reload site?', okLabel: 'Reload' }) + }) + + it('names the server or proxy asking, and says when the connection isn’t private', () => { + const server = { isProxy: false, scheme: 'basic', host: 'intranet.test', port: 80, realm: 'Staff' } + expect(authDialog(server, 'http://intranet.test/wiki')).toEqual({ + kind: 'auth', + title: 'Sign in', + message: 'http://intranet.test requires a username and password.', + note: 'Your connection to this site is not private.', + okLabel: 'Sign in', + }) + const secure = authDialog({ ...server, port: 8443 }, 'https://intranet.test:8443/wiki') + expect(secure.message).toBe('https://intranet.test:8443 requires a username and password.') + expect(secure.note).toBeUndefined() + const proxy = authDialog({ ...server, isProxy: true, host: 'proxy.test', port: 3128 }, 'http://site.test/') + expect(proxy.message).toBe('The proxy proxy.test:3128 requires a username and password.') + expect(proxy.note).toBeUndefined() + // No address to go by: not private. + expect(authDialog(server, undefined).note).toBe('Your connection to this site is not private.') + }) +}) diff --git a/tests/unit/pages-fullscreen.test.ts b/tests/unit/pages-fullscreen.test.ts new file mode 100644 index 00000000..03b34619 --- /dev/null +++ b/tests/unit/pages-fullscreen.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { type EscapeMemory, escapeInFullScreen } from '../../src/main/pages/fullscreen' +import type { ShortcutPriority } from '../../src/shared/commands/keys' + +/** Presses in order through the rule, as one page gets them; returns each verdict. */ +function presses( + list: { type?: 'down' | 'up'; repeat?: boolean; at: number; fullScreen?: boolean }[], + priority: ShortcutPriority = 'websites', + start: EscapeMemory = {}, +) { + let memory = start + return list.map((p) => { + const next = escapeInFullScreen( + memory, + { type: p.type ?? 'down', repeat: p.repeat ?? false, now: p.at, fullScreen: p.fullScreen ?? true }, + priority, + ) + memory = next.memory + return next.verdict + }) +} + +/** A press and its release at `at`. */ +const tap = (at: number, fullScreen = true) => [ + { at, fullScreen }, + { type: 'up' as const, at: at + 50, fullScreen }, +] + +describe('[NV-20][KS-02] Esc in a page of a window in full screen', () => { + it('prefers websites: the page gets one press, and full screen stays', () => { + expect(presses(tap(0))).toEqual(['page', 'page']) + }) + + it('a second press within about a second leaves, and the page gets neither it nor its release', () => { + expect(presses([...tap(0), ...tap(900)])).toEqual(['page', 'page', 'exit', 'swallow']) + expect(presses([...tap(0), ...tap(1_000)])).toEqual(['page', 'page', 'exit', 'swallow']) + }) + + it('presses more than a second apart each go to the page', () => { + expect(presses([...tap(0), ...tap(1_100), ...tap(2_300)])).toEqual(['page', 'page', 'page', 'page', 'page', 'page']) + }) + + it('a held press leaves at its first repeat: the page keeps the press and its release, not the repeats', () => { + expect( + presses([ + { at: 0 }, + { at: 500, repeat: true }, + { at: 530, repeat: true }, + { at: 560, repeat: true }, + { type: 'up', at: 600 }, + ]), + ).toEqual(['page', 'exit', 'swallow', 'swallow', 'page']) + }) + + it('prefers Orbit when set so: one press leaves, and the page gets nothing of it', () => { + expect( + presses([{ at: 0 }, { at: 500, repeat: true }, { at: 530, repeat: true }, { type: 'up', at: 600 }], 'orbit'), + ).toEqual(['exit', 'swallow', 'swallow', 'swallow']) + }) + + it('once full screen has ended, Esc is the page’s alone', () => { + // Left by the second press; the next ones, out of full screen, all go to the page. + expect( + presses([ + ...tap(0), + ...tap(300), + ...tap(600, false), + ...tap(700, false), + { at: 800, fullScreen: false }, + { at: 830, repeat: true, fullScreen: false }, + ]), + ).toEqual(['page', 'page', 'exit', 'swallow', 'page', 'page', 'page', 'page', 'page', 'page']) + }) + + it('a press the page didn’t get (the engine took it to end a video’s full screen) is no first press, and its repeats leave nothing', () => { + // Only its release and repeats are seen; then a quick press goes to the page. + expect(presses([{ at: 0, repeat: true }, { type: 'up', at: 50 }, ...tap(200)])).toEqual([ + 'page', + 'page', + 'page', + 'page', + ]) + }) + + it('a press out of full screen is no first press for one in it', () => { + expect(presses([...tap(0, false), ...tap(300)])).toEqual(['page', 'page', 'page', 'page']) + }) +}) diff --git a/tests/unit/pages-gestures.test.ts b/tests/unit/pages-gestures.test.ts new file mode 100644 index 00000000..d96aa5bf --- /dev/null +++ b/tests/unit/pages-gestures.test.ts @@ -0,0 +1,143 @@ +import { EventEmitter } from 'node:events' +import type { WebContents } from 'electron' +import { describe, expect, it } from 'vitest' +import { gestureCount, onGesture, watchGestures } from '../../src/main/pages/gestures' + +/** A page's web contents, as far as the gesture count uses it. */ +class FakePage extends EventEmitter { + destroyed = false + isDestroyed = () => this.destroyed + get contents(): WebContents { + return this as unknown as WebContents + } +} + +/** An Electron event, which a listener may prevent. */ +function event() { + const e = { + defaultPrevented: false, + preventDefault() { + e.defaultPrevented = true + }, + } + return e +} + +const at = { x: 10, y: 10 } + +function setup() { + const page = new FakePage() + watchGestures(page.contents) + const count = () => gestureCount(page.contents) + /** A mouse-down in the page's own frame: the engine reports it twice, in one task. */ + const clickPage = () => { + page.emit('before-mouse-event', event(), { type: 'mouseDown', ...at }) + page.emit('input-event', event(), { type: 'mouseDown', ...at }) + } + /** A mouse-down in a frame from another site, which has a widget of its own. */ + const clickFrame = () => page.emit('before-mouse-event', event(), { type: 'mouseDown', ...at }) + /** A key down, in whichever frame has focus. */ + const key = (key: string, more: Record = {}) => + page.emit('before-input-event', event(), { type: 'keyDown', key, isAutoRepeat: false, ...more }) + return { page, count, clickPage, clickFrame, key } +} + +/** Lets the events of one task settle. */ +const nextTask = () => new Promise((resolve) => setImmediate(resolve)) + +describe('A page’s user gestures, from any of its frames (D77)', () => { + it('[NV-24][NV-19][NV-09] a mouse-down counts once, in the page’s own frame or in a frame from another site', async () => { + const { page, count, clickPage, clickFrame } = setup() + clickPage() + await nextTask() + expect(count()).toBe(1) + clickFrame() + await nextTask() + expect(count()).toBe(2) + // Two presses, each in a task of its own, are two. + clickPage() + await nextTask() + clickPage() + await nextTask() + expect(count()).toBe(4) + // A mouse-down the engine reports only as input counts too; a move or a release doesn't. + page.emit('input-event', event(), { type: 'mouseDown', ...at }) + page.emit('before-mouse-event', event(), { type: 'mouseUp', ...at }) + page.emit('before-mouse-event', event(), { type: 'mouseMove', ...at }) + page.emit('input-event', event(), { type: 'mouseUp', ...at }) + await nextTask() + expect(count()).toBe(5) + }) + + it('[NV-24][NV-19][NV-09] a key that reaches the page counts; one Orbit takes as its own shortcut doesn’t, so ⌘W never counts for the page it closes', async () => { + const { page, count, key } = setup() + key('a') + await nextTask() + expect(count()).toBe(1) + // Orbit's shortcuts: a listener prevents the key, before or after the count's own. + const shortcut = (e: ReturnType, input: { key: string; control?: boolean; meta?: boolean }) => { + if ((input.control || input.meta) && ['w', 't'].includes(input.key)) e.preventDefault() + } + page.on('before-input-event', shortcut) + key('w', { meta: true }) + await nextTask() + expect(count()).toBe(1) + page.off('before-input-event', shortcut) + page.prependListener('before-input-event', shortcut) + key('t', { control: true }) + await nextTask() + expect(count()).toBe(1) + // A key with a modifier that Orbit leaves to the page counts. + key('b', { control: true }) + await nextTask() + expect(count()).toBe(2) + }) + + it('[NV-24][NV-19][NV-09] as in Chromium, Esc, a modifier alone, a key held down and a key’s release count for nothing', async () => { + const { page, count, key } = setup() + for (const alone of ['Escape', 'Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock']) key(alone) + key('a', { isAutoRepeat: true }) + page.emit('before-input-event', event(), { type: 'keyUp', key: 'a', isAutoRepeat: false }) + // The engine's own report of a key that reached the main frame: the key was counted already. + page.emit('input-event', event(), { type: 'rawKeyDown', keyCode: 'a' }) + page.emit('input-event', event(), { type: 'keyDown', keyCode: 'a' }) + page.emit('input-event', event(), { type: 'char', keyCode: 'a' }) + await nextTask() + expect(count()).toBe(0) + // A key that closes the page as it comes counts for no page. + key('a') + page.destroyed = true + await nextTask() + expect(count()).toBe(0) + }) + + it('[NV-09] a tap and a touch’s end count', async () => { + const { page, count } = setup() + page.emit('input-event', event(), { type: 'gestureTap', ...at }) + page.emit('input-event', event(), { type: 'touchEnd' }) + page.emit('input-event', event(), { type: 'touchStart' }) + await nextTask() + expect(count()).toBe(2) + }) + + it('[NV-24][NV-19][NV-09] each page counts its own, from when it was watched, once however often it was; a listener hears each gesture', async () => { + const { page, count, clickFrame, key } = setup() + const other = new FakePage() + expect(gestureCount(other.contents)).toBe(0) + // Watched again (the leave guard, links to other apps, the pop-up rule): still one count. + watchGestures(page.contents) + const heard: number[] = [] + onGesture(page.contents, () => heard.push(count())) + clickFrame() + key('x') + await nextTask() + expect(count()).toBe(2) + expect(heard).toEqual([1, 2]) + expect(gestureCount(other.contents)).toBe(0) + // A listener on a page not watched yet starts its count. + onGesture(other.contents, () => heard.push(-1)) + other.emit('before-mouse-event', event(), { type: 'mouseDown', ...at }) + expect(gestureCount(other.contents)).toBe(1) + expect(heard).toEqual([1, 2, -1]) + }) +}) diff --git a/tests/unit/pages-print.test.ts b/tests/unit/pages-print.test.ts new file mode 100644 index 00000000..643558af --- /dev/null +++ b/tests/unit/pages-print.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { + cleanSettings, + countPdfPages, + DEFAULT_PRINT_SETTINGS, + type PrintSettings, + pageCount, + pagesProblem, + paperText, + parsePageRanges, + pdfFileName, + pdfRanges, + printerRanges, + printersFromJson, + SAVE_AS_PDF, + selectedPages, +} from '../../src/shared/print' + +const settings = (more: Partial = {}): PrintSettings => ({ ...DEFAULT_PRINT_SETTINGS, ...more }) +const custom = (ranges: string, more: Partial = {}) => settings({ allPages: false, ranges, ...more }) + +describe('[NV-07] the pages a person types in the print dialog', () => { + it('reads pages and ranges, with spaces anywhere', () => { + expect(parsePageRanges('1-5, 8, 11-13')).toEqual([ + { from: 1, to: 5 }, + { from: 8, to: 8 }, + { from: 11, to: 13 }, + ]) + expect(parsePageRanges(' 2 - 4 ')).toEqual([{ from: 2, to: 4 }]) + expect(parsePageRanges('3,,4,')).toEqual([ + { from: 3, to: 3 }, + { from: 4, to: 4 }, + ]) + }) + + it('reads open ranges: from the first page, or to the last', () => { + expect(parsePageRanges('-3')).toEqual([{ from: 1, to: 3 }]) + expect(parsePageRanges('9-')).toEqual([{ from: 9 }]) + }) + + it('reads nothing it can’t be sure of', () => { + for (const text of ['', ' ', ',', '-', 'two', '1 2', '5-3', '0', '1-0', '1-2-3', '1.5', '+2']) { + expect(parsePageRanges(text), text).toBeUndefined() + } + }) + + it('picks each page once, in order, and none past the last', () => { + expect( + selectedPages( + [ + { from: 4, to: 6 }, + { from: 1, to: 2 }, + { from: 5, to: 5 }, + ], + 10, + ), + ).toEqual([1, 2, 4, 5, 6]) + expect(selectedPages([{ from: 3 }], 5)).toEqual([3, 4, 5]) + expect(selectedPages([{ from: 4, to: 9 }], 5)).toEqual([4, 5]) + }) + + it('says why pages can’t be printed, as Chromium does', () => { + expect(pagesProblem(settings(), 5)).toBeUndefined() + expect(pagesProblem(custom('2-3'), 5)).toBeUndefined() + expect(pagesProblem(custom('3-'), 5)).toBeUndefined() + expect(pagesProblem(custom('6'), 5)).toBe('Out of bounds page reference, limit is 5') + expect(pagesProblem(custom('4-6'), 5)).toBe('Out of bounds page reference, limit is 5') + expect(pagesProblem(custom('9-'), 5)).toBe('Out of bounds page reference, limit is 5') + expect(pagesProblem(custom('two'), 5)).toBe('Invalid page range, use e.g. 1-5, 8, 11-13') + expect(pagesProblem(custom(''), 5)).toBe('Invalid page range, use e.g. 1-5, 8, 11-13') + }) +}) + +describe('[NV-07] what the print dialog says it will use', () => { + it('counts pages for a PDF, and sheets (times the copies) for a printer', () => { + expect(pageCount(settings(), 5)).toBe(5) + expect(pageCount(custom('1-2, 4'), 5)).toBe(3) + expect(pageCount(custom('two'), 5)).toBe(0) + expect(paperText(settings(), 1)).toBe('1 page') + expect(paperText(custom('2-3'), 5)).toBe('2 pages') + expect(paperText(settings({ destination: 'Office' }), 1)).toBe('1 sheet of paper') + expect(paperText(custom('1-2', { destination: 'Office', copies: 2 }), 5)).toBe('4 sheets of paper') + }) +}) + +describe('[NV-07] the pages a print job gets', () => { + it('gives a printer ranges counted from 0, joined where they meet; none for every page', () => { + expect(printerRanges(settings(), 5)).toBeUndefined() + expect(printerRanges(custom('1-2, 4, 5'), 6)).toEqual([ + { from: 0, to: 1 }, + { from: 3, to: 4 }, + ]) + expect(printerRanges(custom('3-'), 5)).toEqual([{ from: 2, to: 4 }]) + expect(printerRanges(custom('5, 1'), 5)).toEqual([ + { from: 0, to: 0 }, + { from: 4, to: 4 }, + ]) + }) + + it('gives a PDF the same pages as the engine reads them', () => { + expect(pdfRanges(settings(), 5)).toBe('') + expect(pdfRanges(custom('1-3, 5'), 5)).toBe('1-3,5') + expect(pdfRanges(custom('2-3'), 5)).toBe('2-3') + }) + + it('counts a PDF’s pages, not its page tree', () => { + const pdf = '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> << /Type /Page >> <>' + expect(countPdfPages(pdf)).toBe(2) + expect(countPdfPages('nothing here')).toBe(0) + }) + + it('names a saved PDF after the page, without what file systems refuse', () => { + expect(pdfFileName('Print')).toBe('Print.pdf') + expect(pdfFileName('A/B: c? "d" |f*')).toBe('A B c d e f.pdf') + expect(pdfFileName('line\none\ttab')).toBe('line one tab.pdf') + expect(pdfFileName('..hidden')).toBe('hidden.pdf') + expect(pdfFileName(' ')).toBe('Page.pdf') + expect(pdfFileName('')).toBe('Page.pdf') + expect(pdfFileName('x'.repeat(300))).toBe(`${'x'.repeat(120)}.pdf`) + }) +}) + +describe('[NV-07] settings a print dialog view sends', () => { + const destinations = [ + { id: SAVE_AS_PDF, name: 'Save as PDF' }, + { id: 'Office', name: 'Office Printer' }, + ] + + it('takes what they can be, and keeps the rest as it was', () => { + const was = settings() + expect(cleanSettings({ destination: 'Office', copies: 3, landscape: true }, was, destinations)).toEqual({ + ...was, + destination: 'Office', + copies: 3, + landscape: true, + }) + expect(cleanSettings({}, was, destinations)).toEqual(was) + }) + + it('refuses a printer that isn’t there, copies out of range, and values of the wrong kind', () => { + const was = settings({ destination: 'Office', copies: 2 }) + const next = cleanSettings( + { destination: 'Elsewhere', copies: 1000, color: 'no', allPages: 0, ranges: 5, background: null }, + was, + destinations, + ) + expect(next).toEqual(was) + expect(cleanSettings({ copies: 0 }, was, destinations).copies).toBe(2) + expect(cleanSettings({ copies: 1.5 }, was, destinations).copies).toBe(2) + expect(cleanSettings({ copies: 999 }, was, destinations).copies).toBe(999) + expect(cleanSettings({ ranges: '1'.repeat(500) }, was, destinations).ranges).toHaveLength(200) + }) +}) + +describe('[NV-07] the printers a test run names', () => { + it('reads them from JSON, and none from anything else', () => { + expect(printersFromJson('[{"id":"Office","name":"Office Printer"}]')).toEqual([ + { id: 'Office', name: 'Office Printer' }, + ]) + expect(printersFromJson(undefined)).toEqual([]) + expect(printersFromJson('not json')).toEqual([]) + expect(printersFromJson('{"id":"Office"}')).toEqual([]) + // Not the PDF's own id, and only whole entries. + expect(printersFromJson('[{"id":"pdf","name":"Fake"},{"id":"A"},{"id":"B","name":"B"}]')).toEqual([ + { id: 'B', name: 'B' }, + ]) + }) +}) diff --git a/tests/unit/pages-screens.test.ts b/tests/unit/pages-screens.test.ts new file mode 100644 index 00000000..e1754df2 --- /dev/null +++ b/tests/unit/pages-screens.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { crashScreen, errorScreen, hangDialog, isCertError, screenSite, timeoutScreen } from '../../src/shared/pages' + +describe('[NV-13] error screens: the site', () => { + it('names a web page by its host and port, a file by its name, a page’s source by its page', () => { + expect(screenSite('http://nonexistent.invalid/')).toBe('nonexistent.invalid') + expect(screenSite('https://localhost:3341/a?b#c')).toBe('localhost:3341') + expect(screenSite('file:///home/me/My%20notes.html')).toBe('My notes.html') + expect(screenSite('view-source:https://example.com/x')).toBe('example.com') + }) + + it('names nothing it can’t read', () => { + for (const url of ['', undefined, 'not a url', 'about:blank']) expect(screenSite(url)).toBe('') + }) +}) + +describe('[NV-13] error screens: what went wrong', () => { + it('says each failure in one plain sentence, with the site, the engine’s name for it and Try again', () => { + expect(errorScreen(-105, 'net::ERR_NAME_NOT_RESOLVED', 'http://nonexistent.invalid/')).toEqual({ + kind: 'error', + title: 'This site can’t be reached', + message: 'Orbit couldn’t find the server at this address.', + site: 'nonexistent.invalid', + code: 'ERR_NAME_NOT_RESOLVED', + action: 'retry', + actionLabel: 'Try again', + }) + expect(errorScreen(-102, 'ERR_CONNECTION_REFUSED', 'http://localhost:3360/').message).toBe( + 'The server refused to connect.', + ) + expect(errorScreen(-324, 'ERR_EMPTY_RESPONSE', 'http://a.test/')).toMatchObject({ + title: 'This page isn’t working', + message: 'The server closed the connection without sending anything.', + }) + }) + + it('has a screen of its own for being offline', () => { + expect(errorScreen(-106, 'ERR_INTERNET_DISCONNECTED', 'https://a.test/')).toMatchObject({ + kind: 'offline', + title: 'No internet', + message: 'Your device isn’t connected to the internet.', + action: 'retry', + }) + }) + + it('says Orbit’s own time limit as Chromium says a timeout', () => { + expect(timeoutScreen('http://localhost:3360/never')).toMatchObject({ + kind: 'error', + message: 'The server took too long to respond.', + site: 'localhost:3360', + code: 'ERR_TIMED_OUT', + action: 'retry', + }) + }) + + it('names a code it doesn’t know by number, and still offers Try again', () => { + expect(errorScreen(-999, 'something odd', 'https://a.test/')).toMatchObject({ + message: 'Something went wrong while loading it.', + code: 'ERR_999', + action: 'retry', + }) + }) +}) + +describe('[NV-13] certificate screens', () => { + it('knows Chromium’s certificate errors', () => { + expect(isCertError(-200)).toBe(true) + expect(isCertError(-202)).toBe(true) + expect(isCertError(-299)).toBe(true) + expect(isCertError(-300)).toBe(false) + expect(isCertError(-105)).toBe(false) + }) + + it('leads with Back to safety, and keeps Proceed behind Advanced', () => { + expect(errorScreen(-202, 'net::ERR_CERT_AUTHORITY_INVALID', 'https://localhost:3365/x')).toEqual({ + kind: 'cert', + title: 'Your connection is not private', + message: + 'Someone might be trying to steal your information from this site, such as passwords, messages or card numbers.', + site: 'localhost:3365', + code: 'ERR_CERT_AUTHORITY_INVALID', + action: 'back', + actionLabel: 'Back to safety', + advanced: { + text: 'The server couldn’t prove that it’s localhost:3365: its security certificate isn’t trusted by your device.', + proceedLabel: 'Proceed to localhost:3365 (unsafe)', + }, + }) + expect(errorScreen(-201, 'ERR_CERT_DATE_INVALID', 'https://a.test/').advanced?.text).toBe( + 'The server couldn’t prove that it’s a.test: its security certificate has expired or isn’t valid yet.', + ) + }) + + it('offers no way on past a revoked certificate', () => { + const revoked = errorScreen(-206, 'ERR_CERT_REVOKED', 'https://a.test/') + expect(revoked.action).toBe('back') + expect(revoked.advanced?.proceedLabel).toBeUndefined() + }) +}) + +describe('[NV-14] crash screens and hung pages', () => { + it('says the page crashed, with Reload', () => { + expect(crashScreen('crashed', 'https://a.test/x')).toEqual({ + kind: 'crash', + title: 'This page crashed', + message: 'Something went wrong while showing it.', + site: 'a.test', + code: 'CRASHED', + action: 'reload', + actionLabel: 'Reload', + }) + expect(crashScreen('oom', 'https://a.test/')).toMatchObject({ message: 'It ran out of memory.', code: 'OOM' }) + expect(crashScreen('killed', 'https://a.test/')).toMatchObject({ title: 'This page crashed', code: 'KILLED' }) + expect(crashScreen('memory-eviction', 'https://a.test/').code).toBe('MEMORY_EVICTION') + }) + + it('says a page closed from “Page unresponsive” was closed', () => { + expect(crashScreen('hung', 'https://a.test/')).toMatchObject({ + title: 'This page was closed', + message: 'It stopped responding, so Orbit closed it.', + action: 'reload', + }) + }) + + it('“Page unresponsive” offers Wait (Enter and Esc) or Close page', () => { + expect(hangDialog('https://a.test/x')).toEqual({ + kind: 'hang', + title: 'Page unresponsive', + message: 'a.test isn’t responding. You can wait for it, or close the page.', + okLabel: 'Wait', + cancelLabel: 'Close page', + escapeAccepts: true, + }) + expect(hangDialog(undefined).message).toBe('This page isn’t responding. You can wait for it, or close the page.') + }) +}) diff --git a/tests/unit/pages-senders.test.ts b/tests/unit/pages-senders.test.ts new file mode 100644 index 00000000..8a60a95f --- /dev/null +++ b/tests/unit/pages-senders.test.ts @@ -0,0 +1,159 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' + +/** What each module listens for on ipcMain: the handlers under test. */ +const handlers = new Map void>() + +vi.mock('electron', () => ({ + app: { on: () => {}, getLocaleCountryCode: () => 'CH', getPath: () => '/tmp' }, + BrowserWindow: { fromWebContents: () => null, getAllWindows: () => [] }, + dialog: {}, + ipcMain: { + on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => handlers.set(channel, handler), + handle: () => {}, + }, + systemPreferences: { isSwipeTrackingFromScrollEventsEnabled: () => true }, + webContents: { getFocusedWebContents: () => null, getAllWebContents: () => [] }, + WebContentsView: class {}, +})) +// The windows module pulls in the whole shell; these modules use only these of it. +vi.mock('../../src/main/windows', () => ({ + activeTabOf: () => 't1', + loadRenderer: () => {}, + rendererEntry: () => '', + shownTabs: () => new Set(['t1']), + uiWebPreferences: () => ({}), + viewContents: () => undefined, +})) + +import type { MainContext } from '../../src/main/context' +import { type PageDialogs$, registerDialogs } from '../../src/main/pages/dialogs' +import type { FullScreen$ } from '../../src/main/pages/fullscreen' +import { registerPrint } from '../../src/main/pages/print' +import { registerScreens } from '../../src/main/pages/screens' +import { registerSwipe } from '../../src/main/pages/swipe' +import type { TabOps } from '../../src/main/tabs/ops' +import { PAGE_CHANNEL } from '../../src/shared/site' + +const MAIN_FRAME = { frameTreeNodeId: 1, parent: null, url: 'https://a.test/', origin: 'https://a.test' } +/** A frame in the page, from another site: it has no preload, but a compromised renderer can send. */ +const SUB_FRAME = { frameTreeNodeId: 2, parent: MAIN_FRAME, url: 'https://b.test/', origin: 'https://b.test' } + +/** A tab's page (tab `t1`, in window `w1`), as far as these modules look at it. */ +function fakePage() { + return Object.assign(new EventEmitter(), { + id: 7, + mainFrame: MAIN_FRAME, + session: {}, + navigationHistory: { canGoBack: () => true, canGoForward: () => true }, + isDestroyed: () => false, + isLoading: () => false, + getURL: () => 'https://a.test/', + getTitle: () => 'A', + printToPDF: async () => Buffer.from('%PDF-1.4\n'), + send: () => {}, + focus: () => {}, + }) +} + +/** + * The context the modules get: one window `w1` showing tab `t1`, and nothing drawn (its page has + * no place, unless `windows` gives one). + */ +function fakeContext(windows: Record = {}) { + const win = Object.assign(new EventEmitter(), { isDestroyed: () => false, isFocused: () => true }) + const ctx = { + store: { state: { tabs: { t1: { id: 't1', title: 'A' } }, windows: {}, splits: {} }, subscribe: () => () => {} }, + tabs: { tabIdFor: () => 't1', get: () => undefined, contents: () => undefined }, + windows: { + get: () => win, + windowOfTab: () => 'w1', + onLayout: () => {}, + isUi: () => false, + focusedWindowId: () => 'w1', + ids: () => ['w1'], + hasPageCover: () => false, + tabViewBounds: () => undefined, + createUiView: () => undefined, + showUiView: () => {}, + hideUiView: () => {}, + ...windows, + }, + commands: { register: () => {}, run: async () => {} }, + hooks: { tabCreated: [] as ((tabId: string, page: unknown) => void)[] }, + } + return ctx as unknown as MainContext & { hooks: { tabCreated: ((tabId: string, page: unknown) => void)[] } } +} + +/** A message from the page, sent by `frame`. */ +function from(page: ReturnType, frame: typeof MAIN_FRAME | typeof SUB_FRAME) { + return { sender: page, senderFrame: frame, returnValue: undefined as unknown } +} + +function send(channel: string, event: ReturnType, ...args: unknown[]) { + const handler = handlers.get(channel) + if (!handler) throw new Error(`nothing listens on ${channel}`) + handler(event, ...args) +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('[NV-24][NV-07][NV-02][NV-13] the page channels take a page’s main frame only (D40, review follow-up 13)', () => { + it('alert, confirm and prompt: a frame gets the engine’s answer at once, and no dialog shows', () => { + const dialogs = registerDialogs(fakeContext()) + const page = fakePage() + const framed = from(page, SUB_FRAME) + send(PAGE_CHANNEL.dialog, framed, 'confirm', 'Sure?', '') + expect(framed.returnValue).toBe(false) + expect(dialogs.pending(page as unknown as Electron.WebContents)).toBe(false) + // The page's own: it waits for the user's answer. + const own = from(page, MAIN_FRAME) + send(PAGE_CHANNEL.dialog, own, 'confirm', 'Sure?', '') + expect(own.returnValue).toBeUndefined() + expect(dialogs.pending(page as unknown as Electron.WebContents)).toBe(true) + }) + + it('print: a frame’s print() opens no print dialog', () => { + const print = registerPrint(fakeContext(), { deliver: async () => ({ ok: true }), printers: async () => [] }) + const page = fakePage() + const framed = from(page, SUB_FRAME) + send(PAGE_CHANNEL.print, framed) + expect(framed.returnValue).toBe(false) + expect(print.info()).toEqual([]) + const own = from(page, MAIN_FRAME) + send(PAGE_CHANNEL.print, own) + expect(own.returnValue).toBe(true) + expect(print.info()).toHaveLength(1) + }) + + it('swipe: a frame’s swipe shows no arrow', () => { + const swipe = registerSwipe( + fakeContext({ tabViewBounds: () => ({ x: 0, y: 0, width: 800, height: 600 }), createUiView: () => ({}) }), + ) + const page = fakePage() + send(PAGE_CHANNEL.swipe, from(page, SUB_FRAME), 'move', 'back', 40) + expect(swipe.info('w1').tabId).toBeNull() + send(PAGE_CHANNEL.swipe, from(page, MAIN_FRAME), 'move', 'back', 40) + expect(swipe.info('w1').tabId).toBe('t1') + }) + + it('a form on its way: a frame can’t take the page’s navigation off the load timer', () => { + vi.useFakeTimers() + const timedAfter = (frame: typeof MAIN_FRAME | typeof SUB_FRAME) => { + const ctx = fakeContext() + const dialogs = { dismiss: () => {}, pending: () => false, hostable: () => true } as unknown as PageDialogs$ + registerScreens(ctx, {} as TabOps, dialogs, { onKey: () => {} } as unknown as FullScreen$, 30_000) + const page = fakePage() + for (const hook of ctx.hooks.tabCreated) hook('t1', page) + send(PAGE_CHANNEL.formSent, from(page, frame), 'https://a.test/upload') + const before = vi.getTimerCount() + page.emit('did-start-navigation', { url: 'https://a.test/upload', isMainFrame: true, isSameDocument: false }) + return vi.getTimerCount() - before + } + // The page's own form isn't timed (it may be a large upload); one a frame claims is. + expect(timedAfter(MAIN_FRAME)).toBe(0) + expect(timedAfter(SUB_FRAME)).toBe(1) + }) +}) diff --git a/tests/unit/pages-swipe.test.ts b/tests/unit/pages-swipe.test.ts new file mode 100644 index 00000000..361f19b7 --- /dev/null +++ b/tests/unit/pages-swipe.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { HistorySwipe, RELEASE_GAP_MS, type SwipeUpdate } from '../../src/preload/swipe' +import { HISTORY_SWIPE } from '../../src/shared/swipe' + +/** Feeds `deltas` (x, or [x, y]) 16 ms apart from `t0`; returns every update and the last time. */ +function feed( + swipe: HistorySwipe, + deltas: (number | [number, number])[], + pageTakes: () => boolean = () => false, + t0 = 1000, +) { + const updates: SwipeUpdate[] = [] + let t = t0 + for (const d of deltas) { + const [dx, dy] = typeof d === 'number' ? [d, 0] : d + const update = swipe.push(dx, dy, t, pageTakes) + if (update) updates.push(update) + t += 16 + } + return { updates, t } +} + +const { distance } = HISTORY_SWIPE + +describe('[NV-02] a two-finger swipe over the page', () => { + it('is a swipe back once it has gone 9 px toward the left, and follows the fingers 1:1', () => { + const swipe = new HistorySwipe() + const { updates } = feed(swipe, [-4, -4, -4, -10]) + expect(updates).toEqual([ + { kind: 'move', direction: 'back', travel: 12 }, + { kind: 'move', direction: 'back', travel: 22 }, + ]) + }) + + it('goes only if let go with the arrow fully in', () => { + const short = new HistorySwipe() + feed(short, Array(5).fill(-20)) + expect(short.release()).toEqual({ kind: 'end', direction: 'back', commit: false }) + + const full = new HistorySwipe() + const { updates } = feed(full, Array(6).fill(-20)) + expect(updates.at(-1)).toEqual({ kind: 'move', direction: 'back', travel: 120 }) + expect(120).toBeGreaterThanOrEqual(distance) + expect(full.release()).toEqual({ kind: 'end', direction: 'back', commit: true }) + }) + + it('toward the right is forward; turning back takes the arrow back, never past its edge', () => { + const swipe = new HistorySwipe() + const { updates } = feed(swipe, [10, 30, -50, 5]) + expect(updates.map((u) => (u.kind === 'move' ? u.travel : u))).toEqual([10, 40, 0, 5]) + expect(updates[0]).toMatchObject({ direction: 'forward' }) + }) + + it('leaves a vertical scroll alone until the fingers lift', () => { + const swipe = new HistorySwipe() + const { updates } = feed(swipe, [ + [0, 6], + [-2, 6], + [-40, 0], + [-40, 0], + ]) + expect(updates).toEqual([]) + expect(swipe.release()).toBeUndefined() + }) + + it('leaves the page a swipe it takes: from the start, or as soon as it does', () => { + const taken = new HistorySwipe() + expect(feed(taken, Array(6).fill(-20), () => true).updates).toEqual([]) + expect(taken.release()).toBeUndefined() + + let takes = false + const later = new HistorySwipe() + const first = feed(later, Array(3).fill(-20), () => takes) + expect(first.updates.at(-1)).toEqual({ kind: 'move', direction: 'back', travel: 60 }) + takes = true + expect(feed(later, Array(4).fill(-20), () => takes, first.t).updates).toEqual([ + { kind: 'end', direction: 'back', commit: false }, + ]) + }) + + it('ends when the OS momentum starts after the fingers lift; the rest of it moves nothing', () => { + const swipe = new HistorySwipe() + const fingers = feed(swipe, Array(4).fill(-20)) + // Momentum: deltas that fall by the same share at the display's pace. + const momentum = Array.from({ length: 12 }, (_, i) => -Math.round(30 * 0.9 ** i * 100) / 100) + const { updates } = feed(swipe, momentum, () => false, fingers.t) + const end = updates.findIndex((u) => u.kind === 'end') + expect(end).toBeGreaterThan(0) + expect(updates[end]).toEqual({ kind: 'end', direction: 'back', commit: true }) + expect(updates.slice(end + 1)).toEqual([]) + }) + + it('a sample after a pause starts a new swipe, ending one the fingers left', () => { + const swipe = new HistorySwipe() + const { t } = feed(swipe, Array(3).fill(-20)) + expect(swipe.push(-20, 0, t + RELEASE_GAP_MS + 1, () => false)).toEqual({ + kind: 'end', + direction: 'back', + commit: false, + }) + expect(feed(swipe, [20, 20], () => false, t + RELEASE_GAP_MS + 20).updates).toEqual([ + { kind: 'move', direction: 'forward', travel: 20 }, + { kind: 'move', direction: 'forward', travel: 40 }, + ]) + }) +})