diff --git a/FEATURES.md b/FEATURES.md index 29f779fc..e72428c1 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 · 296 built (60 partial) · 360 tested · 115 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 312 built (63 partial) · 381 tested · 115 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 160 | 173 | -| P1 | 219 | 119 | 166 | -| P2 | 79 | 17 | 21 | +| P1 | 219 | 132 | 182 | +| P2 | 79 | 20 | 26 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -365,7 +365,7 @@ Spec: `spec/shortcuts.md` | 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 (the sidebar's row menus are Orbit's own since TB-14 and draw the same keys) (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 | ✅ | ✅ 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), the sidebar's row menus (arrows, Home/End, letters, Enter, Space, Esc, Tab; a key bound to an Orbit action closes the menu and does nothing else, so ⌘W closes it and not the tab; TB-14); 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) | +| 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; keys given to Tweak actions act from it too (BO-21): View Tweaks opens the Library there, and the others say there why its page takes no Tweak. 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) @@ -436,27 +436,27 @@ Spec: `spec/boosts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| BO-01 | Create a site customization for the current site | P1 | – | – | – | | -| BO-02 | Editor panel: layout, live preview, close | P1 | – | – | – | | -| BO-03 | Colour wheel (theme tint) | P1 | – | – | – | | -| BO-04 | Invert lightness ("make this site dark") | P1 | – | – | – | | -| BO-05 | Advanced colour sliders | P2 | – | – | – | | -| BO-06 | Reset colours | P1 | – | – | – | | -| BO-07 | Font override presets | P1 | – | – | – | | -| BO-08 | Page size (zoom) control | P1 | – | – | – | | -| BO-09 | Text case control | P2 | – | – | – | | -| BO-10 | Hide-element mode | P1 | – | – | – | | -| BO-11 | Code editor: custom CSS and JavaScript per site | P1 | – | – | – | | -| BO-12 | Name, rename and reset all edits | P1 | – | – | – | | -| BO-13 | Shuffle (random style) | P2 | – | – | – | | -| BO-14 | Enable / disable a customization (per site) | P1 | – | – | – | | -| BO-15 | Manage all customizations (list view in the Library) | P1 | – | – | – | | -| BO-16 | Global on/off switch for all customizations | P1 | – | – | – | | -| BO-17 | Scope: which pages a customization applies to | P1 | – | – | – | | -| BO-18 | How customizations apply on page load (timing, safety) | P1 | – | – | – | | -| BO-19 | Use Space theme colours inside customization CSS | P2 | – | – | – | | -| BO-20 | Sharing customizations and the customization gallery | P2 | – | – | – | | -| BO-21 | Customization commands in the command bar | P1 | – | – | – | | +| BO-01 | Create a site customization for the current site | P1 | ✅ | ✅ e2e+unit | – | New Tweak… from the command bar (also "customize", "customization", "site style"), the sidebar's + menu, the site controls panel, a paintbrush in the address field (shown on hover, in the accent colour once the site has a Tweak on) and the tab's menu (Tweak this Site…); none bound to a key. Opens the editor over the page with a new Tweak named after the host; a second one adds a second Tweak. A one-screen intro the first time only. Internal pages, error pages, a Peek and a Mini Window take none: the entries are greyed or gone, or say why. A Mini Window has no Tweak entry of its own; a key given to one there says why in the browser window, as a Mini Window shows no notes (with no browser window open, nothing shows). Nothing reloads | +| BO-02 | Editor panel: layout, live preview, close | P1 | ✅ | ✅ e2e+unit | – | a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change to the look shows at once, while the page is still loading too, and is saved (JavaScript runs with BO-11's Run Now, or the next load); Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Top to bottom: the name and its ▾ menu (BO-12), the colour area (BO-03 to BO-06), fonts (BO-07), size (BO-08), text case (BO-09), Hide elements (BO-10), the code area (BO-11, which widens the panel to the left), "Also apply to subdomains" (BO-17) and Done. Dragging the wheel recolours the page as fast as the page could itself (BO-03 has the numbers) | +| BO-03 | Colour wheel (theme tint) | P1 | ✅ | ✅ e2e+unit | – | a wheel with two linked handles: the big one anywhere inside (its angle the hue, its distance how strong), the small one on the rim (the accent: it turns the page's own colours by its distance from the main hue, and turns with the big one). One filter on the page root tints it: white becomes a light tint of the hue, black stays black, text keeps its contrast; images and video keep their colours (an inverse filter), the top layer is tinted too. Arrow keys move the focused handle (⇧ further). Measured with the frame sampler against the page changing the same colours itself, one a frame: the drag keeps up, and main takes an edit for every move (60 of 60, one a frame; an editor that took every other move fails). Under a virtual display with no GPU, 60 a second at 1024×768 (slowest frame 17 ms); at 1440×900 the page itself manages only about 35 a second there, and the drag matches it | +| BO-04 | Invert lightness ("make this site dark") | P1 | ✅ | ✅ e2e+unit | – | the lightbulb next to the wheel inverts lightness and keeps hues (white page dark, text light), with the tint on the inverted result; photos and video keep their own look; again restores it | +| BO-05 | Advanced colour sliders | P2 | ✅ | ✅ e2e+unit | – | the sliders button opens Contrast, Brightness and Original saturation, live as they move; each has a marked neutral middle (100%) that changes nothing, and a double-click puts it back there | +| BO-06 | Reset colours | P1 | ✅ | ✅ e2e+unit | – | the circle-with-a-cross button puts the wheel, invert and sliders back to neutral, so the page shows its own colours; fonts, size, case, hidden elements and code stay | +| BO-07 | Font override presets | P1 | 🟡 partial | ✅ e2e+unit | – | eight "Aa" presets, each in its own typeface: Sans, Serif, Classic, Humanist, Rounded, Mono, Typewriter and Hand. One applies to all the site's text but icon fonts, glyphs hidden from screen readers, code and math; the active one again gives the site its fonts back. The presets are font stacks of what the system has, so nothing is bundled and their look varies by system (Rounded and Hand fall back on Linux); shipping freely licensed fonts is open | +| BO-08 | Page size (zoom) control | P1 | ✅ | ✅ e2e+unit | – | − and + step the page through 90, 100, 110, 125 and 150%, saved with the Tweak, so every visit gets it; it multiplies with the tab's own zoom (⌘+ / ⌘−) and leaves that alone | +| BO-09 | Text case control | P2 | ✅ | ✅ e2e+unit | – | one button cycles As written, lowercase, UPPERCASE, Capitalized Words and back; fields, editable text and code keep their own case | +| BO-10 | Hide-element mode | P1 | ✅ | ✅ e2e+unit | – | the lightning button: the element under the pointer gets an outline with its kind and size, and the page's links and buttons don't act: the picker stops each click and press on its way to them, except the page's own listeners on its window, which can still hear them first; and if the page takes the picker's layer away, the layer comes back (a page that keeps taking it ends the mode); a click picks. The panel then offers Hide this, or Hide all similar with a slider from narrow to broad, counting what it would hide, and how many of those show now where some don't ("5 (4 shown)"), and outlining those in the page. Gone at once and saved as plain CSS selectors, so hidden from the first frame on every page of the site where it matches. Done or Esc leaves the mode (Esc drops a pick first); another document ends it. On a page still loading, the mode starts when the load ends: a click on the button shows it starting at once, the button pressed and the panel saying it starts when the page has loaded, and the page acts as usual until then; Done or Esc, in the panel or the page, cancels the start (the page still gets its Esc), and another document ends it. The picker runs in an isolated world, never the page's own. Each hide has a restore marker in the panel (not over the page's corner): hovered it shows the element again, clicked it brings it back | +| BO-11 | Code editor: custom CSS and JavaScript per site | P1 | ✅ | ✅ e2e+unit | – | the { } button opens a code area with CSS and JavaScript tabs, empty to start. CSS applies as it is typed (about 90 ms from the keystroke to the page's frame, measured) and is saved as typed, sent before the panel closes; a reload keeps it. JavaScript runs in the page's own world once per load at document ready, from main; Run now runs it at once, Reload page loads the page again. The editor: monospace, syntax colouring (text only, never markup), line numbers, the platform's undo (⌘Z / Ctrl+Z through Orbit's key handling), Tab and ⇧Tab indent and outdent, Enter keeps the indentation. Errors, thrown or syntax, go to the page's console; no dialog, nothing in main. JavaScript from elsewhere stays off until Allow on this device, and editing it doesn't allow it. A JS badge in the site controls list and the code tab (the Library's list comes with BO-15) | +| BO-12 | Name, rename and reset all edits | P1 | ✅ | ✅ e2e+unit | – | the title is the Tweak's name with a ▾ menu: Rename… edits it in place (Enter keeps, Esc cancels), Reset all edits… asks first, then clears colours, font, size, case, hidden elements and code; the name and switches stay. The menu works from the keyboard; Esc closes it, not the panel | +| BO-13 | Shuffle (random style) | P2 | ✅ | ✅ e2e+unit | – | Shuffle in the name ▾ menu applies a random look live: any hue at a moderate strength, an accent a pleasing step away, sometimes inverted, one of the fonts, the site's size or a little larger, mostly the site's case. The menu stays open to shuffle again; hidden elements and code are never touched | +| BO-14 | Enable / disable a customization (per site) | P1 | ✅ | ✅ e2e+unit | – | the site controls panel lists the site's Tweaks by name with a paintbrush, coloured when on and grey when off; the name turns one off or on at once, without a reload, while the page is still loading too, each on its own, keeping all its settings; the › opens it in the editor. The address field's paintbrush shows in the accent colour while one is on and grey when all are off, and turns them all off or on. Off, a Tweak's styles leave the page at once; what its JavaScript already did stays until the page loads again | +| BO-15 | Manage all customizations (list view in the Library) | P1 | ✅ | ✅ e2e+unit | – | the Library's Tweaks section lists every Tweak on this device by site, a site's oldest first: its name, its site (where the name doesn't say it, and "and its subdomains"), a JS badge where it has JavaScript (struck through where that isn't allowed on this device) and a switch that turns it off or on at once. Right-click or ⋯ (the Library's own menu): Edit shows the site's tab in the Space (never a Peek's or a Mini Window's page), or opens the site, and opens the editor once the page can take it; a site that doesn't load stops the wait as its load fails, with a note saying so; Open Site shows that tab, else opens a new tab where the Tweak was made (scheme, host and port); Rename… in place; Delete… asks first, and its styles leave open pages at once (its JavaScript's effects with their next load). Keys: up and down, Enter edits, Space switches, F2 renames, Delete asks, Shift+F10 opens the menu. The command bar's View Tweaks opens it. With none, a short explanation and Tweak This Site for the page underneath | +| BO-16 | Global on/off switch for all customizations | P1 | ✅ | ✅ e2e+unit | – | Settings → Advanced: "Apply Tweaks on websites I visit", on by default. Off, every open page loses its Tweaks' styles at once, while still loading too, and the editor closes; what a Tweak's JavaScript already did stays until the page loads again, as the setting says; the address field's paintbrush and the + menu's and the tab menu's New Tweak go; the command bar's says Tweaks are off; the site controls panel and the Library say so, the Library with Turn On. Each Tweak keeps its own on or off for when they are back on. The organisation policy is P2 and not built | +| BO-17 | Scope: which pages a customization applies to | P1 | ✅ | ✅ e2e+unit | – | a Tweak is for its host's pages: any path, query and in-page navigation, not another host, the parent domain or a subdomain. "Also apply to subdomains" (off by default, at the panel's foot) brings in subdomains' open pages at once; it isn't offered for an address by number and is fixed while the page is on a subdomain it brings in. Every profile gets the same Tweaks; in a private window the CSS applies and the JavaScript runs only once "Also run in private windows" is ticked. Matching is a map lookup per label of the host | +| BO-18 | How customizations apply on page load (timing, safety) | P1 | 🟡 partial | ✅ e2e+unit | – | the page preload asks main for the site's sheets before the page's first paint (D40) and puts them in as injected sheets (D85), which the page can't read or remove, and which its own layered !important rules can't outrank: no frame shows the page without its Tweak (frame-capture tests on a slow page's hard reload and first visit, on a page that sets its own adopted sheets in its head, and on one with a layered !important background). Main puts in its own copies at document ready, or at the first change before it, then tells the preload to take its copies out, by a message that carries only the document's number and that main never waits for. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (BO-11, BO-17), and checks the page's host itself as it runs: not again on an in-page navigation, never in a frame from another site; what it did stays until the next load. The Space colours come with BO-19 | +| BO-19 | Use Space theme colours inside customization CSS | P2 | – | ✅ unit | – | | +| BO-20 | Sharing customizations and the customization gallery | P2 | – | ✅ e2e | – | | +| BO-21 | Customization commands in the command bar | P1 | 🟡 partial | ✅ e2e | – | in the command bar, none bound to a key: New Tweak… (also "customize", "customization", "site style"), Edit Tweak (the site's one in the editor; with several, the site controls list them to pick from; with none, a note says so), Turn Off and Turn On Tweaks for This Site (with a note), View Tweaks (the Library's list, BO-15). In a Mini Window, View Tweaks opens the Library in the browser window (one that isn't private), and the others say there why its page takes no Tweak; from a Mini Window with no browser window open, they show nothing. Export Tweak… comes with BO-20 | | BO-22 | Legacy code-only customization builder | OOS | ⛔ out of scope | – | – | the spec leaves it out | ## Chrome extension support diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3fe28a49..a9e3efa3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -151,6 +151,8 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:swipe` (NV-02): Electron reports no scroll phases to main, so a two-finger swipe over the page is told from the page's own wheel events. A passive listener in the preload's own world takes trusted pixel-delta wheel events in the main frame, after the page's handlers have run, and recognises a horizontal swipe (`src/preload/swipe.ts`). Unless something under the pointer can still scroll that way or the page called `preventDefault`, it sends how far the swipe has gone (`move`, a direction and px) and whether it was let go with the arrow fully in (`end`); nothing else. Main takes it only from a main frame of a page in a window's page card, clamps the travel, shows the arrow only when that page can go that way, and goes back or forward as the Back button does: no more than the page could do itself (`history.back()`). A new use under D40 (4), for the lead to log. - `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the 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. +- `orbit-page:tweak-sheets` (BO-18, D85): a synchronous read, before the page's first paint, of the stylesheets of the Tweaks that apply to the sending frame's own site; the message carries nothing. Main answers only a main frame (same process and routing id as `mainFrame`) of a page in a profile's session, so never one of Orbit's own views, from the frame's own URL: `http:` and `https:` only, so never an `orbit:` page, and only with the CSS of the Tweaks on for that host while Tweaks are on (BO-16), with a number for this document's read. The preload inserts the sheets from its own world as injected author sheets (`webFrame.insertCSS`, `src/preload/tweak-sheets.ts`), as main inserts its own copies, and keeps their keys there: no global, and the page can neither read nor remove them. An injected sheet comes before the page's own, so the Tweaks' cascade layer is the first declared, and the page's `!important` rules, in a layer or not, don't outrank the Tweak's. A PDF in the engine's viewer asks nothing. At document ready, or at the first change to the page's Tweaks before then, main puts in its own copies with `insertCSS` and tells the preload to take its sheets out (`orbit-page:tweak-retire`, below); from then on a change replaces only the sheets it changed, the new one in before the old one comes out (`src/main/tweaks/pages.ts`). A change waits only on `insertCSS`, never on a script in the page or in an isolated world, so it shows while the page is still loading; and only while its document is there: once another document asks, or the renderer goes, it stops waiting, and a key that comes back late goes back only to the renderer that gave it, as keys count from 1 in each renderer (`src/main/tweaks/page-sheets.ts`). A Tweak's JavaScript never goes through the preload (below). Read-only. +- `orbit-page:tweak-retire` (BO-18, D85; main → page): main's own copies of a page's Tweak sheets are in, so the preload takes out the sheets it inserted before the first paint. Main sends it only to the main frame whose sheets its copies replace, and never waits for it. It carries only the number main gave that document's read; the preload's listener, in its own world, acts on it only when the number is its own document's, so a late one never takes a newer document's sheets. 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. @@ -158,13 +160,13 @@ Print (NV-07, `src/main/pages/print.ts`): Electron has no print preview, and its 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`. A shortcut Orbit takes first is none, so ⌘W doesn't count for the page it closes unless the user has set it to go to websites first; a shortcut that goes to the page first reaches it and counts (by default Ctrl+S and Ctrl+Shift+C on Windows and Linux, and Back and Forward in a text field). 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. That limit holds only while no DevTools client is attached to the page: with one attached (DevTools open on it), the engine waits out the page's whole busy run. +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. Tweaks' Hide-element picker (BO-10, `src/main/tweaks/hide.ts`) runs in isolated world 1040 while the mode is on, and takes its layer and listeners away when the mode ends; on a page still loading, main sends it once the load ends, as Electron holds main's isolated-world calls until then (D89). 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`. A shortcut Orbit takes first is none, so ⌘W doesn't count for the page it closes unless the user has set it to go to websites first; a shortcut that goes to the page first reaches it and counts (by default Ctrl+S and Ctrl+Shift+C on Windows and Linux, and Back and Forward in a text field). 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. That limit holds only while no DevTools client is attached to the page: with one attached (DevTools open on it), the engine waits out the page's whole busy run. 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). Screen sharing (MD-11, D69): when Orbit's screen chooser grants a share to any frame of a tab, its own page included, main puts a script in that frame's own world (`src/main/media/screen-share.ts`) before the stream reaches the page, on a hidden property named at random for each document. Each grant counts one; the script reports the shared tracks the page stops, the clones it makes and the sources that end, and the share ends with the document. Only these grants keep a tab awake and unarchived (`screenGranted`, TB-16, TD-02). Like media's frame agent, its reports are the page's word and change only that tab's indicators and busy mark. An indicator shows while this or the preload's count says a share is live. -The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JS (user-authored, per site) is injected only through `webFrame.executeJavaScript` in an isolated world, never with Node. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). +The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JavaScript (user-written, per site; BO-11, BO-18) runs from main in the page's own world, in its main frame only, once per load at document ready (`webFrameMain.executeJavaScript`), and only where the user allows it on this device (an imported one arrives off; in private windows only if allowed): never through the preload, never on `orbit:` pages or in Orbit's own views, never with Node. The script itself checks, as it starts, that the frame still shows the Tweak's host (or, with the box ticked, a subdomain of it), and does nothing anywhere else. An error it throws goes to the page's console. What a script has done stays in the page until its next load: turning the Tweak off, or all Tweaks, takes away only its CSS. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). ## 11. Packaging and updates diff --git a/docs/feature-status.json b/docs/feature-status.json index 467488ac..912795c3 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -241,7 +241,7 @@ "KS-13": {"built": "partial", "note": "⌥⌘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-10": {"built": "yes", "note": "⌘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-15": {"built": "yes", "feel": true, "note": "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-20": {"built": "partial", "note": "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)"}, + "KS-20": {"built": "partial", "note": "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; keys given to Tweak actions act from it too (BO-21): View Tweaks opens the Library there, and the others say there why its page takes no Tweak. 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)"}, "SB-10": {"built": "partial", "note": "media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes"}, "PK-01": {"built": "yes", "feel": true, "note": "floating card over the source tab; one Peek per window; links inside navigate it"}, "PK-02": {"built": "yes", "feel": true, "note": "other-site (eTLD+1) link clicks, Enter on a focused link, and target=_blank in pinned tabs/Favorites; same-site navigates. Only a navigation to the clicked link counts, so form posts, script navigations and auto-posted sign-ins stay in the tab"}, @@ -361,6 +361,25 @@ "FL-25": {"built": "yes", "note": "folder search and icon picker: drawn in the first frame, from 0.97 with the snappy timing (150 ms); close with the quick fade; keys go back to the sidebar at once"}, "FL-28": {"built": "yes", "note": "follows the OS setting, with a setting to force it on (AP-17); springs, slides and scales become short fades or instant changes, toasts included, also for what is already on screen when it turns on; a unit guard checks that every motion component sits under the config or reads the setting, and that every element with a layout animation, an entrance, an exit, a press or a moving animate asks useReducedMotion(), since motion reads the config only as an element mounts; drags still follow the pointer and settle with a short ease"}, "AP-17": {"built": "partial", "note": "one set of motion tokens for every surface (FL-01): ease-out curves of 90–270 ms and springs of 0.25–0.32 s; buttons and the × press to 0.9 (FL-08); the loading pill at the top centre of the card (FL-23); reduced motion from the OS setting or Orbit's own switch in Appearance, turning slides, flights and springs into short fades (FL-28). No shimmer when the app starts"}, + "BO-01": {"built": "yes", "note": "New Tweak… from the command bar (also \"customize\", \"customization\", \"site style\"), the sidebar's + menu, the site controls panel, a paintbrush in the address field (shown on hover, in the accent colour once the site has a Tweak on) and the tab's menu (Tweak this Site…); none bound to a key. Opens the editor over the page with a new Tweak named after the host; a second one adds a second Tweak. A one-screen intro the first time only. Internal pages, error pages, a Peek and a Mini Window take none: the entries are greyed or gone, or say why. A Mini Window has no Tweak entry of its own; a key given to one there says why in the browser window, as a Mini Window shows no notes (with no browser window open, nothing shows). Nothing reloads"}, + "BO-02": {"built": "yes", "note": "a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change to the look shows at once, while the page is still loading too, and is saved (JavaScript runs with BO-11's Run Now, or the next load); Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Top to bottom: the name and its ▾ menu (BO-12), the colour area (BO-03 to BO-06), fonts (BO-07), size (BO-08), text case (BO-09), Hide elements (BO-10), the code area (BO-11, which widens the panel to the left), \"Also apply to subdomains\" (BO-17) and Done. Dragging the wheel recolours the page as fast as the page could itself (BO-03 has the numbers)"}, + "BO-03": {"built": "yes", "note": "a wheel with two linked handles: the big one anywhere inside (its angle the hue, its distance how strong), the small one on the rim (the accent: it turns the page's own colours by its distance from the main hue, and turns with the big one). One filter on the page root tints it: white becomes a light tint of the hue, black stays black, text keeps its contrast; images and video keep their colours (an inverse filter), the top layer is tinted too. Arrow keys move the focused handle (⇧ further). Measured with the frame sampler against the page changing the same colours itself, one a frame: the drag keeps up, and main takes an edit for every move (60 of 60, one a frame; an editor that took every other move fails). Under a virtual display with no GPU, 60 a second at 1024×768 (slowest frame 17 ms); at 1440×900 the page itself manages only about 35 a second there, and the drag matches it"}, + "BO-04": {"built": "yes", "note": "the lightbulb next to the wheel inverts lightness and keeps hues (white page dark, text light), with the tint on the inverted result; photos and video keep their own look; again restores it"}, + "BO-05": {"built": "yes", "note": "the sliders button opens Contrast, Brightness and Original saturation, live as they move; each has a marked neutral middle (100%) that changes nothing, and a double-click puts it back there"}, + "BO-06": {"built": "yes", "note": "the circle-with-a-cross button puts the wheel, invert and sliders back to neutral, so the page shows its own colours; fonts, size, case, hidden elements and code stay"}, + "BO-07": {"built": "partial", "note": "eight \"Aa\" presets, each in its own typeface: Sans, Serif, Classic, Humanist, Rounded, Mono, Typewriter and Hand. One applies to all the site's text but icon fonts, glyphs hidden from screen readers, code and math; the active one again gives the site its fonts back. The presets are font stacks of what the system has, so nothing is bundled and their look varies by system (Rounded and Hand fall back on Linux); shipping freely licensed fonts is open"}, + "BO-08": {"built": "yes", "note": "− and + step the page through 90, 100, 110, 125 and 150%, saved with the Tweak, so every visit gets it; it multiplies with the tab's own zoom (⌘+ / ⌘−) and leaves that alone"}, + "BO-09": {"built": "yes", "note": "one button cycles As written, lowercase, UPPERCASE, Capitalized Words and back; fields, editable text and code keep their own case"}, + "BO-10": {"built": "yes", "note": "the lightning button: the element under the pointer gets an outline with its kind and size, and the page's links and buttons don't act: the picker stops each click and press on its way to them, except the page's own listeners on its window, which can still hear them first; and if the page takes the picker's layer away, the layer comes back (a page that keeps taking it ends the mode); a click picks. The panel then offers Hide this, or Hide all similar with a slider from narrow to broad, counting what it would hide, and how many of those show now where some don't (\"5 (4 shown)\"), and outlining those in the page. Gone at once and saved as plain CSS selectors, so hidden from the first frame on every page of the site where it matches. Done or Esc leaves the mode (Esc drops a pick first); another document ends it. On a page still loading, the mode starts when the load ends: a click on the button shows it starting at once, the button pressed and the panel saying it starts when the page has loaded, and the page acts as usual until then; Done or Esc, in the panel or the page, cancels the start (the page still gets its Esc), and another document ends it. The picker runs in an isolated world, never the page's own. Each hide has a restore marker in the panel (not over the page's corner): hovered it shows the element again, clicked it brings it back"}, + "BO-11": {"built": "yes", "note": "the { } button opens a code area with CSS and JavaScript tabs, empty to start. CSS applies as it is typed (about 90 ms from the keystroke to the page's frame, measured) and is saved as typed, sent before the panel closes; a reload keeps it. JavaScript runs in the page's own world once per load at document ready, from main; Run now runs it at once, Reload page loads the page again. The editor: monospace, syntax colouring (text only, never markup), line numbers, the platform's undo (⌘Z / Ctrl+Z through Orbit's key handling), Tab and ⇧Tab indent and outdent, Enter keeps the indentation. Errors, thrown or syntax, go to the page's console; no dialog, nothing in main. JavaScript from elsewhere stays off until Allow on this device, and editing it doesn't allow it. A JS badge in the site controls list and the code tab (the Library's list comes with BO-15)"}, + "BO-12": {"built": "yes", "note": "the title is the Tweak's name with a ▾ menu: Rename… edits it in place (Enter keeps, Esc cancels), Reset all edits… asks first, then clears colours, font, size, case, hidden elements and code; the name and switches stay. The menu works from the keyboard; Esc closes it, not the panel"}, + "BO-13": {"built": "yes", "note": "Shuffle in the name ▾ menu applies a random look live: any hue at a moderate strength, an accent a pleasing step away, sometimes inverted, one of the fonts, the site's size or a little larger, mostly the site's case. The menu stays open to shuffle again; hidden elements and code are never touched"}, + "BO-14": {"built": "yes", "note": "the site controls panel lists the site's Tweaks by name with a paintbrush, coloured when on and grey when off; the name turns one off or on at once, without a reload, while the page is still loading too, each on its own, keeping all its settings; the › opens it in the editor. The address field's paintbrush shows in the accent colour while one is on and grey when all are off, and turns them all off or on. Off, a Tweak's styles leave the page at once; what its JavaScript already did stays until the page loads again"}, + "BO-15": {"built": "yes", "note": "the Library's Tweaks section lists every Tweak on this device by site, a site's oldest first: its name, its site (where the name doesn't say it, and \"and its subdomains\"), a JS badge where it has JavaScript (struck through where that isn't allowed on this device) and a switch that turns it off or on at once. Right-click or ⋯ (the Library's own menu): Edit shows the site's tab in the Space (never a Peek's or a Mini Window's page), or opens the site, and opens the editor once the page can take it; a site that doesn't load stops the wait as its load fails, with a note saying so; Open Site shows that tab, else opens a new tab where the Tweak was made (scheme, host and port); Rename… in place; Delete… asks first, and its styles leave open pages at once (its JavaScript's effects with their next load). Keys: up and down, Enter edits, Space switches, F2 renames, Delete asks, Shift+F10 opens the menu. The command bar's View Tweaks opens it. With none, a short explanation and Tweak This Site for the page underneath"}, + "BO-16": {"built": "yes", "note": "Settings → Advanced: \"Apply Tweaks on websites I visit\", on by default. Off, every open page loses its Tweaks' styles at once, while still loading too, and the editor closes; what a Tweak's JavaScript already did stays until the page loads again, as the setting says; the address field's paintbrush and the + menu's and the tab menu's New Tweak go; the command bar's says Tweaks are off; the site controls panel and the Library say so, the Library with Turn On. Each Tweak keeps its own on or off for when they are back on. The organisation policy is P2 and not built"}, + "BO-17": {"built": "yes", "note": "a Tweak is for its host's pages: any path, query and in-page navigation, not another host, the parent domain or a subdomain. \"Also apply to subdomains\" (off by default, at the panel's foot) brings in subdomains' open pages at once; it isn't offered for an address by number and is fixed while the page is on a subdomain it brings in. Every profile gets the same Tweaks; in a private window the CSS applies and the JavaScript runs only once \"Also run in private windows\" is ticked. Matching is a map lookup per label of the host"}, + "BO-18": {"built": "partial", "note": "the page preload asks main for the site's sheets before the page's first paint (D40) and puts them in as injected sheets (D85), which the page can't read or remove, and which its own layered !important rules can't outrank: no frame shows the page without its Tweak (frame-capture tests on a slow page's hard reload and first visit, on a page that sets its own adopted sheets in its head, and on one with a layered !important background). Main puts in its own copies at document ready, or at the first change before it, then tells the preload to take its copies out, by a message that carries only the document's number and that main never waits for. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (BO-11, BO-17), and checks the page's host itself as it runs: not again on an in-page navigation, never in a frame from another site; what it did stays until the next load. The Space colours come with BO-19"}, + "BO-21": {"built": "partial", "note": "in the command bar, none bound to a key: New Tweak… (also \"customize\", \"customization\", \"site style\"), Edit Tweak (the site's one in the editor; with several, the site controls list them to pick from; with none, a note says so), Turn Off and Turn On Tweaks for This Site (with a note), View Tweaks (the Library's list, BO-15). In a Mini Window, View Tweaks opens the Library in the browser window (one that isn't private), and the others say there why its page takes no Tweak; from a Mini Window with no browser window open, they show nothing. Export Tweak… comes with BO-20"}, "FL-29": {"built": "partial", "note": "measured by the feel specs against each budget (docs/FEEL.md); press, ⌘W, ⌘S, Space switch (settles in ~290 ms), command bar and drag budgets met; still missed: p99 at two intervals in some animations under load (⌘S hide, long lists, a Peek opening), the hover sidebar and a Peek's close start in the second frame, and the first ⌘T or ⌘W after a launch can show in the second frame. The rubber band comes back in ~480 ms on FL-13's own spring: the 350 ms settle is for swipes that land on a Space (D70)"}, "FL-30": {"built": "partial", "note": "the nine points as a checklist in docs/FEEL.md: pass, gap or fixed, each with the test that shows it"}, "FL-18": {"built": "partial", "note": "a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop"}, diff --git a/src/main/miniwindow/index.ts b/src/main/miniwindow/index.ts index 9c2d4937..0819d6bd 100644 --- a/src/main/miniwindow/index.ts +++ b/src/main/miniwindow/index.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { app, BrowserWindow, globalShortcut, Menu, nativeTheme, screen, type WebContents } from 'electron' import { toElectronAccelerator } from '../../shared/actions' +import { TWEAK_ACTIONS } from '../../shared/commands/tweaks' import { createWindowState } from '../../shared/defaults' import { newId } from '../../shared/ids' import { routeFor } from '../../shared/link-routing' @@ -944,7 +945,9 @@ export function registerMiniWindow(ctx: MainContext): void { // In a Mini Window, keys act on its page; sidebar, Space and tab-list keys do nothing (KS-20). // Help → Keyboard Shortcuts, File → Open File… and the Library's keys (⌘Y / Ctrl+H for History, - // ⌘⇧L) act in the browser window behind (OB-12, KS-12, AR-10, keys lane). + // ⌘⇧L) act in the browser window behind (OB-12, KS-12, AR-10, keys lane). So do the Tweak + // actions' own handlers (BO-21): View Tweaks opens the Library there, and the others say there + // why a Mini Window's page takes no Tweak (BO-01; tweaks review F7). const APP_ACTIONS = new Set([ 'window.new', 'window.newBlank', @@ -956,6 +959,7 @@ export function registerMiniWindow(ctx: MainContext): void { 'app.library', 'help.shortcuts', 'file.open', + ...TWEAK_ACTIONS.map((a) => a.id), ]) actions.intercept((id, c) => { const mini = miniOf(c) diff --git a/src/main/persist.ts b/src/main/persist.ts index 35b238f5..15bc65ed 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -18,6 +18,7 @@ import { withoutPrivate } from '../shared/private' import type { ItemId, OrbitState, ProfileId, Space, SpaceId, SplitLayout, TabId } from '../shared/state' import { STATE_SCHEMA } from '../shared/state' import { normalizeSpaceTheme } from '../shared/theme' +import { normalizeTweakSettings, normalizeTweaks } from '../shared/tweaks' import { fallbackTitle } from '../shared/url' import { normalizeKeySettings } from './keys/bindings' import { normalizeSettingsPrefs } from './settings/normalize' @@ -103,10 +104,11 @@ export function normalizeState(input: Partial): OrbitState { archive: Array.isArray(input.archive) ? input.archive.filter(isRecord) : [], settings: { ...DEFAULT_SETTINGS, ...(isRecord(input.settings) ? input.settings : {}) }, downloads: record(input.downloads), - tweaks: record(input.tweaks), + tweaks: normalizeTweaks(input.tweaks), } if (!['system', 'light', 'dark'].includes(state.settings.appearance)) state.settings.appearance = 'system' normalizeMediaSettings(state.settings, Date.now()) + normalizeTweakSettings(state.settings) seedAppearanceFromOldThemes(state) normalizeEntities(state, base) // Site searches (CB-11): keep well-formed http(s) entries; a missing list gets the defaults. diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index c6b70dc8..37c06ee0 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -32,6 +32,7 @@ import { onGesture } from '../pages/gestures' import type { CallContext } from '../registry' import { registerSpaceOrderUndo } from '../spaces/order-undo' import { tabSplitMenuItems } from '../split/menu' +import { TWEAK_TAB_MENU_KEYS, tabTweakMenuItems } from '../tweaks/menu' import { registerBlankWindows } from './blank' import { registerLinkDrop } from './link-drop' import { TabMaintenance } from './maintenance' @@ -246,6 +247,7 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM pasteUrlInto: (folderId) => void pasteUrlAsTab(windowId, { kind: 'folder', folderId }), deleteFolder: (folderId) => void confirmDeleteFolder(windowId, folderId), splitItems: (tabIds) => tabSplitMenuItems(ctx, tabIds, windowId), + tweakItems: (tabId) => tabTweakMenuItems(ctx, tabId, windowId), }) const rowMenus = registerRowMenus(ctx) @@ -434,7 +436,8 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const state = store.state const mo = menuOps(windowId) // Keys lane: an item that a key also does shows the key (KS-18). - const forTab = (tabId: TabId) => withKeyHints(tabMenu(state, tabId, selection, mo), TAB_MENU_KEYS) + const forTab = (tabId: TabId) => + withKeyHints(tabMenu(state, tabId, selection, mo), [...TAB_MENU_KEYS, ...TWEAK_TAB_MENU_KEYS]) const all = isFolderId(id) ? folderMenu(state, id, mo) : isTabId(id) ? forTab(id) : [] // Private and blank windows have no pinned tabs, Favorites, folders or other Spaces (NV-11, SB-25, TB-17). const own = isTabId(id) && (isPrivateTab(state, id) || isBlankSpace(state, state.tabs[id]?.spaceId)) diff --git a/src/main/tabs/menus.ts b/src/main/tabs/menus.ts index e529a3de..229eefe0 100644 --- a/src/main/tabs/menus.ts +++ b/src/main/tabs/menus.ts @@ -32,6 +32,8 @@ export interface MenuOps { deleteFolder(folderId: FolderId): void /** Split View items for these tabs ("Open in Split View", SV-06/SV-09), from the split lane. */ splitItems?(tabIds: TabId[]): MenuItemConstructorOptions[] + /** "Tweak this Site…" for a tab (BO-01), from the tweaks lane. */ + tweakItems?(tabId: TabId): MenuItemConstructorOptions[] } /** Folders of a Space's pinned area, depth-first, with indentation for the Move to menu. */ @@ -151,6 +153,7 @@ export function tabMenu( { label: 'Duplicate', click: () => ops.duplicate(tabId) }, ...(ops.splitItems?.([tabId]) ?? []), { label: tab.muted ? 'Unmute Tab' : 'Mute Tab', click: () => ops.setMuted(tabId, !tab.muted) }, + ...(ops.tweakItems?.(tabId) ?? []), { label: 'Move to', submenu: moveToMenu(state, [item], ops, { favoritesProfileId: section === 'favorite' ? undefined : profileId }), diff --git a/src/main/tweaks/editor.ts b/src/main/tweaks/editor.ts new file mode 100644 index 00000000..9dcf9944 --- /dev/null +++ b/src/main/tweaks/editor.ts @@ -0,0 +1,337 @@ +import type { WebContentsView } from 'electron' +import { HIDE_OFF, TWEAK_EDITOR, type TweakEditorState, type TweakHideState } from '../../shared/commands/tweaks' +import { peekShown } from '../../shared/nav' +import { isPrivateTab } from '../../shared/private' +import type { Rect } from '../../shared/rpc' +import type { OrbitState, TabId, WindowId } from '../../shared/state' +import { isEmptyTweak, tweakAppliesTo, tweakHostOf } from '../../shared/tweaks' +import type { MainContext } from '../context' +import { shownTabs, viewContents } from '../windows' + +/** One window's editor panel (BO-02). */ +interface Editor { + view?: WebContentsView + /** The view's page has loaded: only then does it go into the window (a loading view takes the keyboard). */ + ready: boolean + state: TweakEditorState + /** The panel's size as its page lays it out, without the shadow's margin; null until it has. */ + size: { width: number; height: number } | null + /** Where the user dragged the panel, from its place at the page's top-right corner. */ + offset: { x: number; y: number } + /** A drag by the panel's title: where it started (screen) and the offset then. */ + drag?: { x: number; y: number; ox: number; oy: number } + /** Tweaks this panel made (BO-01): one still empty and unnamed when the panel leaves it is removed. */ + created: Set + /** The panel takes the keyboard once it is in the window (an opening, not a move). */ + focusPending: boolean + /** What the panel's page was told last. */ + sent?: string +} + +export interface TweakEditorInfo { + open: boolean + tabId: TabId | null + tweakId: string | null + ready: boolean + /** The view is in the window. */ + placed: boolean + bounds?: Rect + /** The view's page has the keyboard. */ + focused: boolean + /** The view's web contents id, for tests that look at it. */ + contentsId?: number +} + +export interface TweakEditor { + /** Opens the panel in a window for a Tweak over a tab's page; `created`: the Tweak was just made for it. */ + open(windowId: WindowId, tabId: TabId, tweakId: string, created?: boolean): void + /** Closes a window's panel; every edit is already saved (BO-02). */ + close(windowId: WindowId): void + state(windowId: WindowId | undefined): TweakEditorState + /** The window whose panel is open on a Tweak, if any. */ + windowEditing(tweakId: string): WindowId | undefined + /** The Tweak a window's panel shows, and the tab, while it is open. */ + editing(windowId: WindowId): { tabId: TabId; tweakId: string } | null + /** What the panel shows of Hide-element mode (BO-10). */ + setHide(windowId: WindowId, hide: TweakHideState): void + /** Called as a window's panel leaves its Tweak and page: it closes, or opens on another. */ + onLeave(listener: (windowId: WindowId) => void): void + layout(windowId: WindowId, size: { width: number; height: number }): void + drag(windowId: WindowId, phase: 'start' | 'move' | 'end', x: number, y: number): void + info(windowId: WindowId): TweakEditorInfo +} + +const CLOSED: Omit = { + open: false, + tabId: null, + tweakId: null, + host: null, + private: false, + hide: HIDE_OFF, +} + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max)) + +/** + * The Tweak editor panel (BO-02): a small view of its own over the top-right corner of the page it + * edits, sized to the panel, so the rest of the page stays live and takes clicks and keys as ever. + * One per window, made the first time it opens there. It closes when its tab is no longer shown + * (another tab, Space or window), when the tab leaves the Tweak's site, when the Tweak is deleted + * and when Tweaks are turned off; it steps aside while a Peek or a video in full screen covers the + * page, and comes back after. + */ +export function registerTweakEditor(ctx: MainContext): TweakEditor { + const { store, windows } = ctx + const editors = new Map() + const leaving: ((windowId: WindowId) => void)[] = [] + let nonce = 0 + + const leave = (windowId: WindowId) => { + for (const listener of leaving) listener(windowId) + } + + const live = (view: WebContentsView | undefined): view is WebContentsView => !!viewContents(view) + + const editorOf = (windowId: WindowId): Editor | undefined => { + let e = editors.get(windowId) + if (e) return e + const win = windows.get(windowId) + if (!win || win.isDestroyed()) return undefined + e = { + ready: false, + state: { ...CLOSED, nonce }, + size: null, + offset: { x: 0, y: 0 }, + created: new Set(), + focusPending: false, + } + editors.set(windowId, e) + const record = e + win.once('closed', () => { + // The window's panel goes with it: a Tweak it made and left empty goes too. + if (record.state.open) leave(windowId) + editors.delete(windowId) + if (record.state.open) dropIfEmpty(record, record.state.tweakId) + }) + return e + } + + /** Tells the panel's page what to show, once per change. */ + const push = (e: Editor) => { + const json = JSON.stringify(e.state) + if (e.sent === json) return + const contents = viewContents(e.view) + if (!contents || !e.ready) return + e.sent = json + contents.send('orbit:tweakEditor', e.state) + } + + /** The window's panel view, made out of the window if it has none yet. */ + const viewFor = (windowId: WindowId, e: Editor): WebContentsView | undefined => { + if (live(e.view)) return e.view + e.ready = false + e.sent = undefined + e.size = null + const view = windows.createUiView(windowId, 'tweak') + e.view = view + viewContents(view)?.once('did-finish-load', () => { + if (e.view !== view) return + e.ready = true + push(e) + place(windowId) + }) + return view + } + + /** The page the panel goes over now, or undefined while it can't show (the state still says open). */ + const pageOf = (state: OrbitState, windowId: WindowId, e: Editor): Rect | undefined => { + const tabId = e.state.tabId + if (!e.state.open || !tabId) return undefined + if (windows.inHtmlFullscreen(windowId) || peekShown(state, windowId)) return undefined + if (!shownTabs(state, windowId).has(tabId)) return undefined + const page = windows.tabViewBounds(tabId) + if (!page || page.width < TWEAK_EDITOR.minPage || page.height < TWEAK_EDITOR.minPage) return undefined + return page + } + + /** The panel's view at its place over the page, kept inside the page; the drag offset kept with it. */ + const boundsFor = (e: Editor, page: Rect, size: { width: number; height: number }): Rect => { + const { margin, inset } = TWEAK_EDITOR + const width = Math.min(size.width, page.width) + const height = Math.min(size.height, page.height - inset) + const homeX = page.x + page.width - inset - width + const homeY = page.y + inset + const x = clamp(homeX + e.offset.x, page.x, page.x + page.width - width) + const y = clamp(homeY + e.offset.y, page.y, page.y + page.height - height) + e.offset = { x: x - homeX, y: y - homeY } + return { x: x - margin, y: y - margin, width: width + 2 * margin, height: height + 2 * margin } + } + + /** Shows, moves or hides a window's panel to match its state. */ + const place = (windowId: WindowId) => { + const e = editors.get(windowId) + if (!e) return + const page = pageOf(store.state, windowId, e) + if (!page) { + if (live(e.view)) windows.hideUiView(windowId, e.view) + return + } + const view = viewFor(windowId, e) + // Not before its page has loaded and laid the panel out: place() comes back here then. + if (!view || !e.ready || !e.size) return + windows.showUiView(windowId, view, boundsFor(e, page, e.size), e.focusPending) + e.focusPending = false + } + + /** + * A Tweak the panel made is removed as the panel leaves it if it is still as it was made: empty + * and named after its site (BO-01). The panel has left it already, so its removal closes nothing. + */ + const dropIfEmpty = (e: Editor, id: string | null) => { + if (!id || !e.created.has(id)) return + e.created.delete(id) + const tweak = store.state.tweaks[id] + if (!tweak || !isEmptyTweak(tweak) || tweak.name !== tweak.host) return + store.update((draft) => { + delete draft.tweaks[id] + }) + } + + const close = (windowId: WindowId) => { + const e = editors.get(windowId) + if (!e?.state.open) return + const left = e.state.tweakId + leave(windowId) + e.drag = undefined + e.focusPending = false + e.state = { ...CLOSED, nonce: e.state.nonce } + // The keyboard goes back to the page if the panel had it (KS-19). + if (live(e.view)) windows.hideUiView(windowId, e.view) + push(e) + dropIfEmpty(e, left) + } + + const open = (windowId: WindowId, tabId: TabId, tweakId: string, created = false) => { + const e = editorOf(windowId) + const tweak = store.state.tweaks[tweakId] + if (!e || !tweak) return + const left = e.state.open ? e.state.tweakId : null + if (e.state.open) leave(windowId) + if (created) e.created.add(tweakId) + nonce++ + // Reopened over the same page, it stays where the user dragged it. + if (!(e.state.open && e.state.tabId === tabId)) e.offset = { x: 0, y: 0 } + e.state = { + open: true, + tabId, + tweakId, + host: tweakHostOf(store.state.tabs[tabId]?.url) ?? tweak.host, + nonce, + private: isPrivateTab(store.state, tabId), + hide: HIDE_OFF, + } + e.focusPending = true + viewFor(windowId, e) + push(e) + place(windowId) + // Another Tweak in the same panel: the one it left goes if it was made here and left empty. + if (left !== tweakId) dropIfEmpty(e, left) + } + + // Close or step aside as the window's state changes. + store.subscribe((next, prev) => { + for (const [windowId, e] of editors) { + if (!e.state.open) continue + const { tabId, tweakId } = e.state + const tweak = tweakId ? next.tweaks[tweakId] : undefined + const tab = tabId ? next.tabs[tabId] : undefined + if ( + !tweak || + !tab || + tab.error || + !next.settings.tweaksEnabled || + !shownTabs(next, windowId).has(tab.id) || + !tweakAppliesTo(tweak, tweakHostOf(tab.url)) + ) { + close(windowId) + continue + } + if ( + peekShown(next, windowId) !== peekShown(prev, windowId) || + next.windows[windowId] !== prev.windows[windowId] + ) { + place(windowId) + } + } + }) + // The page moved or was resized, or a video went full screen: the panel follows. + windows.onLayout((windowId) => { + if (editors.get(windowId)?.state.open) place(windowId) + }) + + return { + open, + close, + state: (windowId) => (windowId ? editors.get(windowId)?.state : undefined) ?? { ...CLOSED, nonce }, + windowEditing: (tweakId) => { + for (const [windowId, e] of editors) if (e.state.open && e.state.tweakId === tweakId) return windowId + return undefined + }, + editing: (windowId) => { + const s = editors.get(windowId)?.state + return s?.open && s.tabId && s.tweakId ? { tabId: s.tabId, tweakId: s.tweakId } : null + }, + setHide: (windowId, hide) => { + const e = editors.get(windowId) + if (!e?.state.open || e.state.hide === hide) return + e.state = { ...e.state, hide } + push(e) + }, + onLeave: (listener) => { + leaving.push(listener) + }, + layout: (windowId, size) => { + const e = editors.get(windowId) + if (!e) return + const width = clamp(Math.round(size.width), 120, TWEAK_EDITOR.wideWidth) + const height = clamp(Math.round(size.height), 40, 2000) + if (e.size?.width === width && e.size.height === height) return + e.size = { width, height } + place(windowId) + }, + drag: (windowId, phase, x, y) => { + const e = editors.get(windowId) + if (!e?.state.open || !Number.isFinite(x) || !Number.isFinite(y)) return + if (phase === 'start') { + e.drag = { x, y, ox: e.offset.x, oy: e.offset.y } + return + } + const d = e.drag + if (!d) return + // The panel stays where the last move put it: a release where the pointer was then may be + // reported after the view moved, at a point that never was (synthetic input). + if (phase === 'end') { + e.drag = undefined + return + } + e.offset = { x: d.ox + x - d.x, y: d.oy + y - d.y } + place(windowId) + }, + info: (windowId) => { + const e = editors.get(windowId) + const view = live(e?.view) ? e?.view : undefined + const contents = viewContents(view) + const win = windows.get(windowId) + return { + open: !!e?.state.open, + tabId: e?.state.tabId ?? null, + tweakId: e?.state.tweakId ?? null, + ready: !!e?.ready, + placed: !!view && !!win && !win.isDestroyed() && win.contentView.children.includes(view), + bounds: view?.getBounds(), + focused: !!contents && contents.isFocused(), + contentsId: contents?.id, + } + }, + } +} diff --git a/src/main/tweaks/hide.ts b/src/main/tweaks/hide.ts new file mode 100644 index 00000000..e03dbbc2 --- /dev/null +++ b/src/main/tweaks/hide.ts @@ -0,0 +1,544 @@ +import type { WebContents } from 'electron' +import { HIDE_OFF, type TweakHideChoice, type TweakHidePick, type TweakHideState } from '../../shared/commands/tweaks' +import type { TabId, WindowId } from '../../shared/state' +import { addHidden, isSafeSelector } from '../../shared/tweaks' +import type { MainContext } from '../context' +import type { TweakEditor } from './editor' +import { TWEAK_WORLD, type TweakPages } from './pages' + +/** + * The picker (BO-10). It runs in the page's isolated world TWEAK_WORLD, never the page's own world, + * so the page can't call it or read its state. It covers the page with a layer of its own: an + * element under the page's root, which the page sees as an empty `
`, whose closed shadow root + * draws the outlines. While the mode is on, its listeners on the window, first in the capture phase + * except the page's own listeners on its window, take every press and click (and the menu key's), so + * the page's links and buttons don't act, even where the page covers or moves the layer. A capture + * listener the page added on its window before the mode started still hears them first; even so, + * their default actions are stopped, and nothing from the document inward hears them. If the page + * takes the layer out, the picker puts it back; if the page keeps taking it out, the mode ends, and + * main and the panel hear so. + * It outlines what is under the pointer, and on a click describes that element: a selector for it + * alone, and selectors for it and elements like it, narrow to broad, each with how many it matches + * and how many of those have a box to outline. Main reads what happens with `__orbitHide.next()`; it + * lives in one document, and goes with it. + */ +const PICKER = String.raw`(() => { + if (window.__orbitHide) return true + const doc = document + const root = doc.documentElement + const MAX = 300 + const all = (sel) => { + try { + return Array.from(doc.querySelectorAll(sel)) + } catch (e) { + return [] + } + } + const esc = (s) => CSS.escape(s) + const STATE = /^(is-|has-)|^(active|hover|focus|focused|selected|open|opened|visible|show|shown|hidden|current|disabled|expanded|collapsed)$/i + // Classes a page keeps from one load to the next: not a state, not generated. + const stable = (c) => c.length <= 40 && !/\d{3,}/.test(c) && !/^(css|sc|jsx|emotion|svelte)-/i.test(c) && !STATE.test(c) + const classes = (el) => Array.from(el.classList).filter(stable).slice(0, 3) + const tag = (el) => esc(el.localName) + const idOf = (el) => { + const id = el.id + if (!id || id.length > 60 || /\d{3,}/.test(id)) return null + const sel = '#' + esc(id) + return all(sel).length === 1 ? sel : null + } + const nth = (el) => { + let i = 1 + let twin = false + for (let s = el.previousElementSibling; s; s = s.previousElementSibling) { + if (s.localName === el.localName) { + i++ + twin = true + } + } + for (let s = el.nextElementSibling; s && !twin; s = s.nextElementSibling) if (s.localName === el.localName) twin = true + return twin ? ':nth-of-type(' + i + ')' : '' + } + // This element alone: its place under the nearest ancestor with an id of its own, or the body. + const path = (el) => { + const parts = [] + for (let n = el; n && n !== root; n = n.parentElement) { + if (n === doc.body) { + parts.unshift('body') + break + } + const id = idOf(n) + if (id) { + parts.unshift(id) + break + } + parts.unshift(tag(n) + nth(n)) + } + return parts.join(' > ') + } + const kind = (el) => { + const c = classes(el)[0] + return el.localName + (c ? '.' + c : '') + } + // Whether an element has a box to outline: displayed, with a size. + const boxed = (el) => { + const r = el.getBoundingClientRect() + return r.width !== 0 || r.height !== 0 + } + const words = (s) => (s || '').replace(/\s+/g, ' ').trim() + const labelOf = (el) => + (words(el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title')) || + words(el.innerText).slice(0, 48) || + kind(el)).slice(0, 60) + // This element and others like it, narrow to broad: never the page itself or an ancestor of it. + const similar = (el) => { + const t = tag(el) + const cs = classes(el).map((c) => '.' + esc(c)) + const own = t + cs.join('') + const parent = el.parentElement + const under = parent && parent !== root ? path(parent) + ' > ' : '' + const list = [] + if (cs.length) { + if (under) list.push(under + own) + if (parent && parent !== doc.body && parent !== root) { + const pc = classes(parent).map((c) => '.' + esc(c)).join('') + if (pc) list.push(tag(parent) + pc + ' > ' + own) + } + list.push(own) + for (const c of cs) list.push(t + c) + for (const c of cs) list.push(c) + } + if (under) list.push(under + t) + const out = [] + for (const selector of new Set(list)) { + const found = all(selector) + if (found.length < 2 || found.length > MAX || !found.includes(el)) continue + if (found.some((f) => f === doc.body || f === root || (f !== el && f.contains(el)))) continue + out.push({ selector, count: found.length, shown: found.filter(boxed).length }) + } + out.sort((a, b) => a.count - b.count) + return out.filter((c, i) => i === 0 || c.count !== out[i - 1].count) + } + + const host = doc.createElement('div') + const shadow = host.attachShadow({ mode: 'closed' }) + const style = doc.createElement('style') + style.textContent = + ':host{all:initial!important;position:fixed!important;inset:0!important;z-index:2147483647!important;display:block!important;cursor:crosshair!important;pointer-events:auto!important;visibility:visible!important;opacity:1!important;filter:none!important;transform:none!important}' + + '.box{position:fixed;box-sizing:border-box;pointer-events:none;border:2px solid #4f6bed;border-radius:3px;background:rgba(79,107,237,.16)}' + + '.box.pick,.box.many{border-color:#e5484d;background:rgba(229,72,77,.18)}.box.many{border-style:dashed}' + + '.tag{position:fixed;pointer-events:none;max-width:60vw;overflow:hidden;padding:1px 6px;border-radius:4px;background:#4f6bed;color:#fff;font:600 11px/16px system-ui,sans-serif;white-space:nowrap;text-overflow:ellipsis}' + const layer = doc.createElement('div') + shadow.append(style, layer) + + let hovered = null + let picked = null + let many = null + let x = -1 + let y = -1 + let frame = 0 + const waiting = [] + const queue = [] + const emit = (ev) => { + const wake = waiting.shift() + if (wake) wake(ev) + else if (queue.push(ev) > 8) queue.shift() + } + const under = (px, py) => { + for (const el of doc.elementsFromPoint(px, py)) if (el !== host && el !== root && el !== doc.body) return el + return null + } + const box = (el, cls, text) => { + const r = el.getBoundingClientRect() + if (!boxed(el)) return + // The page's size (BO-08) zooms this layer too: its own pixels are that much larger. + const z = host.currentCSSZoom || 1 + const b = doc.createElement('div') + b.className = 'box ' + cls + b.style.cssText = + 'left:' + r.left / z + 'px;top:' + r.top / z + 'px;width:' + r.width / z + 'px;height:' + r.height / z + 'px' + layer.append(b) + if (!text) return + const t = doc.createElement('div') + t.className = 'tag' + t.textContent = text + const top = r.top / z >= 20 ? r.top / z - 20 : r.bottom / z + 2 + t.style.cssText = 'left:' + Math.max(0, r.left / z) + 'px;top:' + top + 'px' + layer.append(t) + } + const draw = () => { + frame = 0 + layer.replaceChildren() + if (many) { + for (const el of all(many).slice(0, MAX)) box(el, 'many') + return + } + if (picked) { + if (picked.isConnected) box(picked, 'pick') + return + } + hovered = x < 0 ? null : under(x, y) + if (!hovered) return + const r = hovered.getBoundingClientRect() + box(hovered, 'hover', kind(hovered) + ' ' + Math.round(r.width) + ' × ' + Math.round(r.height)) + } + const redraw = () => { + if (!frame) frame = requestAnimationFrame(draw) + } + // Neither the page nor its default actions get these while the mode is on. + const block = (e) => { + e.preventDefault() + e.stopImmediatePropagation() + } + const onMove = (e) => { + if (!e.isTrusted) return + x = e.clientX + y = e.clientY + redraw() + } + const onClick = (e) => { + block(e) + // A pointer's click picks; one the keyboard makes (detail 0) only doesn't reach the page. + if (!e.isTrusted || e.button !== 0 || e.detail === 0) return + const el = under(e.clientX, e.clientY) + if (!el) return + picked = el + many = null + redraw() + emit({ type: 'pick', label: labelOf(el), kind: kind(el), exact: path(el), similar: similar(el) }) + } + const onKey = (e) => { + if (!e.isTrusted || e.key !== 'Escape') return + e.preventDefault() + e.stopImmediatePropagation() + emit({ type: 'escape' }) + } + const BLOCKED = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'dblclick', 'auxclick', 'contextmenu'] + const listeners = [ + ['pointermove', onMove, true], + ['click', onClick, true], + ...BLOCKED.map((type) => [type, block, true]), + ['keydown', onKey, true], + ['scroll', redraw, true], + ['resize', redraw, false], + ] + for (const [type, fn, capture] of listeners) addEventListener(type, fn, capture) + + // The page took the layer out, or moved it, or marked it: it goes back as it was. A page that + // keeps taking it out (more than 20 times a second), or replaces its root, ends the mode: main + // hears it from next(). + let restored = 0 + let since = 0 + const keep = () => { + const now = performance.now() + if (now - since > 1000) { + since = now + restored = 0 + } + if (doc.documentElement !== root || ++restored > 20) { + stop() + return + } + for (const name of host.getAttributeNames()) host.removeAttribute(name) + if (host.parentNode !== root) root.append(host) + } + const watcher = new MutationObserver(() => { + if (doc.documentElement !== root || host.parentNode !== root || host.attributes.length > 0) keep() + }) + watcher.observe(doc, { childList: true }) + watcher.observe(root, { childList: true }) + watcher.observe(host, { attributes: true }) + root.append(host) + + const stop = () => { + watcher.disconnect() + for (const [type, fn, capture] of listeners) removeEventListener(type, fn, capture) + if (frame) cancelAnimationFrame(frame) + host.remove() + window.__orbitHide = undefined + for (const wake of waiting.splice(0)) wake({ type: 'stopped' }) + return true + } + + window.__orbitHide = { + next: () => + new Promise((resolve) => { + if (queue.length) resolve(queue.shift()) + else waiting.push(resolve) + }), + outline: (selector) => { + many = selector || null + redraw() + return many ? all(many).slice(0, MAX).filter(boxed).length : picked ? 1 : 0 + }, + clear: () => { + picked = null + many = null + redraw() + }, + stop, + info: () => ({ + hovered: hovered && !picked ? path(hovered) : null, + picked: picked ? path(picked) : null, + outlined: layer.querySelectorAll('.box').length, + }), + } + return true +})()` + +const NEXT = `window.__orbitHide ? window.__orbitHide.next() : { type: 'gone' }` + +/** What the picker in a page says, for tests (BO-10). */ +export interface PickerInfo { + /** The element under the pointer (a selector for it), while nothing is picked. */ + hovered: string | null + picked: string | null + /** How many elements are outlined. */ + outlined: number +} + +/** One window's Hide-element mode: over its editor's page, for its Tweak. */ +interface Session { + windowId: WindowId + tabId: TabId + tweakId: string + contents: WebContents + pick: TweakHidePick | null + /** The picker was sent to the page (at once, or once its load ended: D89). */ + sent: boolean + ended: boolean + /** Ends the wait for the page's load or its next event, and stops watching the page. */ + release: () => void +} + +export interface TweakHide { + /** Starts or ends a window's Hide-element mode; false where it can't start. */ + setMode(windowId: WindowId, on: boolean): Promise + /** Outlines what a choice would hide; the number outlined. */ + preview(windowId: WindowId, choice: TweakHideChoice | null): Promise + /** Hides what a choice picks, in the edited Tweak. */ + hide(windowId: WindowId, choice: TweakHideChoice): boolean + cancel(windowId: WindowId): void + /** Test view: what the picker in a tab's page says. */ + info(tabId: TabId): Promise +} + +const text = (value: unknown, max: number): string => + typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '' + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** A pick as the page described it, checked: selectors that can't reach outside their own rule. */ +function checkPick(ev: Record): TweakHidePick | null { + const exact = text(ev.exact, 2_000) + if (!isSafeSelector(exact)) return null + const similar = (Array.isArray(ev.similar) ? ev.similar : []) + .filter(isRecord) + .map((c) => ({ selector: text(c.selector, 2_000), count: Number(c.count), shown: Number(c.shown) })) + .filter((c) => isSafeSelector(c.selector) && Number.isInteger(c.count) && c.count >= 2) + .map((c) => ({ ...c, shown: Number.isInteger(c.shown) ? Math.min(Math.max(c.shown, 0), c.count) : c.count })) + .slice(0, 12) + return { label: text(ev.label, 60) || text(ev.kind, 60) || 'Element', exact, similar } +} + +let hiddenCount = 0 +const hiddenId = () => `h-${Date.now().toString(36)}${(++hiddenCount).toString(36)}` + +/** + * Hide-element mode (BO-10): main puts the picker in the editor's page, reads what the user picks + * there, shows it in the panel, and saves what the user hides as plain CSS selectors in the Tweak + * (its sheet hides them, before first paint on every later load). On a page still loading, the mode + * starts when the load ends, and the panel says so at once (D89). The mode ends with Done or Esc, + * when the panel leaves the page, and when the page loads another document. + */ +export function registerTweakHide(ctx: MainContext, editor: TweakEditor, pages: TweakPages): TweakHide { + const { store, tabs } = ctx + const sessions = new Map() + + const run = (contents: WebContents, code: string): Promise => + contents.executeJavaScriptInIsolatedWorld(TWEAK_WORLD, [{ code }]) + + const show = (s: Session) => { + const state: TweakHideState = { on: true, starting: false, pick: s.pick } + editor.setHide(s.windowId, state) + } + + const end = (windowId: WindowId) => { + const s = sessions.get(windowId) + if (!s) return + sessions.delete(windowId) + s.ended = true + s.release() + if (s.sent && !s.contents.isDestroyed()) { + run(s.contents, 'window.__orbitHide && window.__orbitHide.stop()').catch(() => {}) + } + pages.setShowHidden(s.tabId, undefined) + editor.setHide(windowId, HIDE_OFF) + } + + const handle = (s: Session, ev: unknown) => { + if (!isRecord(ev) || ev.type === 'stopped' || ev.type === 'gone') { + end(s.windowId) + return + } + if (ev.type === 'escape') { + // Esc in the page drops the pick first, then leaves the mode. + if (s.pick) cancel(s.windowId) + else end(s.windowId) + return + } + if (ev.type !== 'pick') return + const pick = checkPick(ev) + if (!pick) return + s.pick = pick + show(s) + } + + /** Reads the page's events until the mode ends. */ + const listen = async (s: Session, ended: Promise) => { + while (!s.ended) { + let ev: unknown = null + try { + ev = await Promise.race([run(s.contents, NEXT), ended]) + } catch { + ev = null + } + if (s.ended) return + handle(s, ev) + } + } + + const start = async (windowId: WindowId): Promise => { + if (sessions.has(windowId)) return true + const at = editor.editing(windowId) + const contents = at ? tabs.contents(at.tabId) : undefined + if (!at || !contents || contents.isDestroyed() || !pages.tweakable(at.tabId)) return false + let wake = () => {} + const ended = new Promise((resolve) => { + wake = () => resolve(null) + }) + // Another document in the tab ends the mode: the picker went with the old one. So does one that + // comes, while the mode starts, from a navigation that began before it (D89). + const onNavigate = (details: Electron.Event) => { + if (details.isMainFrame && !details.isSameDocument) end(windowId) + } + const onGone = () => end(windowId) + // While the mode starts, an Esc pressed in the page cancels the start too, as the panel's does; + // the page still gets it (D89 (5)). Once the picker is in, the picker takes the page's Esc. + const onKey = (_event: Electron.Event, input: Electron.Input) => { + if (input.type !== 'keyDown' || input.key !== 'Escape' || input.isComposing) return + if (input.control || input.alt || input.meta) return + end(windowId) + } + let onLoaded = () => {} + const loaded = new Promise((resolve) => { + onLoaded = resolve + }) + contents.on('did-start-navigation', onNavigate) + contents.on('did-navigate', onGone) + contents.on('render-process-gone', onGone) + contents.once('destroyed', onGone) + const s: Session = { + windowId, + tabId: at.tabId, + tweakId: at.tweakId, + contents, + pick: null, + sent: false, + ended: false, + release: () => { + contents.off('did-start-navigation', onNavigate) + contents.off('did-navigate', onGone) + contents.off('render-process-gone', onGone) + contents.off('destroyed', onGone) + contents.off('did-stop-loading', onLoaded) + contents.off('before-input-event', onKey) + wake() + }, + } + sessions.set(windowId, s) + // A page still loading takes the picker only once its load ends: Electron holds main's calls in + // an isolated world until then (D89). The panel shows the mode starting meanwhile, the page acting + // as usual; Done, Esc (in the panel or the page) or another document cancels it, and the picker + // never goes in. + if (contents.isLoadingMainFrame()) { + contents.once('did-stop-loading', onLoaded) + contents.on('before-input-event', onKey) + editor.setHide(windowId, { on: false, starting: true, pick: null }) + await Promise.race([loaded, ended]) + contents.off('before-input-event', onKey) + if (s.ended) return false + if (!pages.tweakable(s.tabId)) { + end(windowId) + return false + } + } + s.sent = true + try { + await run(contents, PICKER) + } catch { + end(windowId) + return false + } + if (s.ended) return false + // On once the picker is in the page: a click there picks from now on. + show(s) + void listen(s, ended) + return true + } + + const cancel = (windowId: WindowId) => { + const s = sessions.get(windowId) + if (!s?.pick) return + s.pick = null + show(s) + run(s.contents, 'window.__orbitHide && window.__orbitHide.clear()').catch(() => {}) + } + + const selectorOf = (pick: TweakHidePick, choice: TweakHideChoice): string | undefined => + choice === 'this' ? pick.exact : typeof choice === 'number' ? pick.similar[choice]?.selector : undefined + + // The panel leaves its page, or closes: the mode ends with it. + editor.onLeave((windowId) => end(windowId)) + + return { + setMode: async (windowId, on) => { + if (on) return start(windowId) + end(windowId) + return true + }, + preview: async (windowId, choice) => { + const s = sessions.get(windowId) + if (!s?.pick) return 0 + const selector = choice === null ? null : selectorOf(s.pick, choice) + if (choice !== null && !selector) return 0 + const code = `window.__orbitHide ? window.__orbitHide.outline(${JSON.stringify(selector)}) : 0` + const n = await run(s.contents, code).catch(() => 0) + return typeof n === 'number' ? n : 0 + }, + hide: (windowId, choice) => { + const s = sessions.get(windowId) + const pick = s?.pick + const selector = pick ? selectorOf(pick, choice) : undefined + if (!s || !pick || !selector || !isSafeSelector(selector)) return false + const count = choice === 'this' ? 1 : (pick.similar[choice as number]?.count ?? 0) + const label = choice === 'this' ? pick.label : `${pick.label} and similar (${count})` + let added = false + store.update((draft) => { + const tweak = draft.tweaks[s.tweakId] + if (tweak) added = addHidden(tweak, { id: hiddenId(), selector, label }, Date.now()) + }) + s.pick = null + show(s) + run(s.contents, 'window.__orbitHide && window.__orbitHide.clear()').catch(() => {}) + return added + }, + cancel, + info: async (tabId) => { + const contents = tabs.contents(tabId) + if (!contents || contents.isDestroyed()) return null + const info = await run(contents, 'window.__orbitHide ? window.__orbitHide.info() : null').catch(() => null) + return (info as PickerInfo | null) ?? null + }, + } +} diff --git a/src/main/tweaks/index.ts b/src/main/tweaks/index.ts index 0c88ccf4..ffd0b4d2 100644 --- a/src/main/tweaks/index.ts +++ b/src/main/tweaks/index.ts @@ -1,4 +1,335 @@ +import { newId } from '../../shared/ids' +import { peekShown } from '../../shared/nav' +import type { Tab, TabId, Tweak, WindowId } from '../../shared/state' +import { + applyTweakPatch, + clearTweak, + isEmptyTweak, + newTweak, + removeHidden, + tweakAppliesTo, + tweakHostOf, + tweakOriginOf, + tweaksFor, +} from '../../shared/tweaks' import type { MainContext } from '../context' +import type { CallContext } from '../registry' +import { activeTabOf, shownTabs } from '../windows' +import { registerTweakEditor, type TweakEditorInfo } from './editor' +import { type PickerInfo, registerTweakHide } from './hide' +import { registerTweakPages, type TweakPages } from './pages' -/** Tweaks feature (main process). Owned by its build lane; see docs/ARCHITECTURE.md. */ -export function registerTweaks(_ctx: MainContext): void {} +/** What a window says when Tweaks can't go where they were asked to (BO-01, BO-16). */ +export const TWEAK_MESSAGES = { + web: 'Tweaks work on websites only', + off: 'Tweaks are turned off in Settings', + peek: 'Open the page in a tab to tweak it', + mini: 'Move the Mini Window’s page into a Space to tweak it', + none: 'This site has no Tweaks', + unloaded: 'The site didn’t load, so the Tweak can’t open', +} as const + +/** + * How long the Library's Edit waits for the Tweak's site to load before giving up (BO-15); a load + * that fails ends the wait at once. + */ +const OPEN_WAIT_MS = 20_000 + +/** Test-only handle (ORBIT_E2E=1): globalThis.__orbitTweaks. */ +export interface TweakTestHooks { + editor: (windowId?: WindowId) => TweakEditorInfo + page: (tabId: TabId) => ReturnType + /** What Hide-element mode's picker in a tab's page says (BO-10); null where there is none. */ + picker: (tabId: TabId) => Promise +} + +/** + * Tweaks (BO-01 to BO-21): per-site restyling the user makes in the editor panel. Main keeps the + * Tweaks in state (they never leave the device), applies them to pages (pages.ts) and places the + * editor panel (editor.ts); the panel's page is src/renderer/src/tweaks/. + */ +export function registerTweaks(ctx: MainContext): void { + const { store, commands, events, actions, windows, tabs } = ctx + const pages = registerTweakPages(ctx) + const editor = registerTweakEditor(ctx) + const hiding = registerTweakHide(ctx, editor, pages) + // A hidden element shown while its restore marker was hovered hides again as the panel leaves. + editor.onLeave((windowId) => { + const at = editor.editing(windowId) + if (at) pages.setShowHidden(at.tabId, undefined) + }) + + const windowOf = (c: CallContext): WindowId | undefined => c.windowId ?? windows.focusedWindowId() + + /** + * The host a tab's Tweaks are for: its page's, once that page asked for its Tweaks (a website, not + * a PDF or an error page); a sleeping tab's from its address. + */ + const hostOf = (tabId: TabId): string | null => + tabs.contents(tabId) ? pages.hostOf(tabId) : tweakHostOf(store.state.tabs[tabId]?.url) + + /** + * Shows a note for a window: in it; for a Mini Window, which shows none, in the window where View + * Tweaks opens the Library (the Library's `mainWindowOf`, src/main/archive/index.ts): the focused + * browser window that isn't private, else the last such one opened; with none, nowhere (BO-21; + * tweaks fix check 2, follow-up 1). + */ + const say = (windowId: WindowId, message: string) => { + const kindOf = (id: WindowId | undefined) => (id ? store.state.windows[id]?.kind : undefined) + const isMain = (id: WindowId | undefined): id is WindowId => kindOf(id) === 'main' + const focused = windows.focusedWindowId() + const to = + isMain(windowId) || kindOf(windowId) === 'private' + ? windowId + : isMain(focused) + ? focused + : windows.ids().filter(isMain).pop() + if (to) ctx.toast(to, message) + } + + /** + * The tab an entry point acts on, shown in its window so the panel can go over it; null (with a + * note saying why) where Tweaks can't open: the panel goes over a page in a browser window only, + * never a Peek's or a Mini Window's. + */ + const target = async ( + asked: TabId | undefined, + c: CallContext, + ): Promise<{ windowId: WindowId; tabId: TabId; host: string } | null> => { + const state = store.state + const windowId = (asked ? windows.windowOfTab(asked) : undefined) ?? windowOf(c) + if (!windowId) return null + const tabId = asked ?? activeTabOf(state, windowId) + if (!tabId || !state.tabs[tabId]) return null + if (!state.settings.tweaksEnabled) { + say(windowId, TWEAK_MESSAGES.off) + return null + } + const floating = state.tabs[tabId]?.floating + if (floating === 'mini' || state.windows[windowId]?.kind === 'mini') { + say(windowId, TWEAK_MESSAGES.mini) + return null + } + if (floating === 'peek' || peekShown(state, windowId)) { + say(windowId, TWEAK_MESSAGES.peek) + return null + } + const host = hostOf(tabId) + if (!host) { + say(windowId, TWEAK_MESSAGES.web) + return null + } + // The tab menu's tab may be another one: it is shown first, as the panel goes over its page. + if (!shownTabs(state, windowId).has(tabId)) await commands.run('tabs.activate', { tabId }, { windowId }) + return { windowId, tabId, host } + } + + // ---- Commands ---- + + commands.register('tweaks.create', async ({ tabId: asked }, c) => { + const at = await target(asked, c) + if (!at) return null + const { windowId, tabId, host } = at + // The panel already shows a Tweak it just made for this page, still empty: that one it is. + const editing = editor.editing(windowId) + const current = editing?.tabId === tabId ? store.state.tweaks[editing.tweakId] : undefined + if (current && current.host === host && isEmptyTweak(current) && current.name === current.host) { + editor.open(windowId, tabId, current.id) + return current.id + } + const id = newId('x') + const tweak = newTweak(id, host, Date.now(), tweakOriginOf(store.state.tabs[tabId]?.url, host)) + store.update((draft) => { + draft.tweaks[id] = tweak + }) + editor.open(windowId, tabId, id, true) + return id + }) + + commands.register('tweaks.edit', async ({ id, tabId: asked }, c) => { + const tweak = store.state.tweaks[id] + if (!tweak) return false + const at = await target(asked, c) + if (!at || !tweakAppliesTo(tweak, at.host)) return false + editor.open(at.windowId, at.tabId, id) + return true + }) + + commands.register('tweaks.update', ({ id, patch }) => { + if (!store.state.tweaks[id]) return false + store.update((draft) => { + const tweak = draft.tweaks[id] + if (tweak) applyTweakPatch(tweak, patch, Date.now()) + }) + return true + }) + + commands.register('tweaks.runNow', ({ id, tabId }) => pages.runNow(tabId, id)) + + commands.register('tweaks.reset', ({ id }) => { + store.update((draft) => { + const tweak = draft.tweaks[id] + if (tweak) clearTweak(tweak, Date.now()) + }) + }) + + // ---- Hide-element mode (BO-10): in the calling window's editor ---- + + commands.register('tweaks.hideMode', async ({ on }, c) => { + const windowId = windowOf(c) + return windowId ? hiding.setMode(windowId, on === true) : false + }) + commands.register('tweaks.hidePreview', async ({ choice }, c) => { + const windowId = windowOf(c) + return windowId ? hiding.preview(windowId, choice) : 0 + }) + commands.register('tweaks.hide', ({ choice }, c) => { + const windowId = windowOf(c) + return windowId ? hiding.hide(windowId, choice) : false + }) + commands.register('tweaks.hideCancel', (_args, c) => { + const windowId = windowOf(c) + if (windowId) hiding.cancel(windowId) + }) + commands.register('tweaks.showHidden', ({ hiddenId }, c) => { + const windowId = windowOf(c) + const at = windowId ? editor.editing(windowId) : null + if (at) pages.setShowHidden(at.tabId, typeof hiddenId === 'string' ? hiddenId : undefined) + }) + commands.register('tweaks.unhide', ({ id, hiddenId }) => { + store.update((draft) => { + const tweak = draft.tweaks[id] + if (tweak) removeHidden(tweak, hiddenId, Date.now()) + }) + }) + + commands.register('tweaks.setEnabled', ({ id, enabled }) => { + if (typeof enabled !== 'boolean') return + store.update((draft) => { + const tweak = draft.tweaks[id] + if (tweak) applyTweakPatch(tweak, { enabled }, Date.now()) + }) + }) + + commands.register('tweaks.delete', ({ id }) => { + store.update((draft) => { + delete draft.tweaks[id] + }) + }) + + /** + * A tab of the window's Space on a Tweak's site: one shown, else the one used last. Never a Peek's + * or a Mini Window's page, which the window can't show. + */ + const siteTab = (windowId: WindowId, tweak: Tweak): TabId | null => { + const state = store.state + const spaceId = state.windows[windowId]?.spaceId + const shown = shownTabs(state, windowId) + let last: Tab | undefined + for (const tab of Object.values(state.tabs)) { + if (tab.spaceId !== spaceId || tab.floating || tab.error || !tweakAppliesTo(tweak, tweakHostOf(tab.url))) continue + if (shown.has(tab.id)) return tab.id + if (!last || tab.lastActiveAt > last.lastActiveAt) last = tab + } + return last?.id ?? null + } + + commands.register('tweaks.open', async ({ id, edit }, c) => { + const tweak = store.state.tweaks[id] + const windowId = windowOf(c) + if (!tweak || !windowId || !store.state.windows[windowId]) return false + if (edit && !store.state.settings.tweaksEnabled) { + ctx.toast(windowId, TWEAK_MESSAGES.off) + return false + } + let tabId = siteTab(windowId, tweak) + if (!tabId) tabId = await commands.run('tabs.create', { url: `${tweak.origin}/` }, { windowId }) + else if (!shownTabs(store.state, windowId).has(tabId)) await commands.run('tabs.activate', { tabId }, { windowId }) + await commands.run('library.close', {}, { windowId }) + if (!edit) return true + // A tab just made, woken or still waiting for its server: the editor opens once its page can take + // it. Unless the user moved on meanwhile; a site that didn't load says so. + const ready = await pages.whenTweakable(tabId, OPEN_WAIT_MS) + if (!store.state.tweaks[id] || !shownTabs(store.state, windowId).has(tabId)) return false + if (!ready) { + say(windowId, TWEAK_MESSAGES.unloaded) + return false + } + return commands.run('tweaks.edit', { id, tabId }, { windowId }) + }) + + commands.register('tweaks.setGlobal', ({ enabled }) => { + if (typeof enabled !== 'boolean') return + store.update((draft) => { + draft.settings.tweaksEnabled = enabled + }) + }) + + commands.register('tweaks.editorState', (_args, c) => editor.state(c.windowId)) + commands.register('tweaks.closeEditor', (_args, c) => { + if (c.windowId) editor.close(c.windowId) + }) + commands.register('tweaks.editorLayout', ({ width, height }, c) => { + if (c.windowId && Number.isFinite(width) && Number.isFinite(height)) editor.layout(c.windowId, { width, height }) + }) + commands.register('tweaks.dismissIntro', () => { + store.update((draft) => { + draft.settings.tweakIntroSeen = true + }) + }) + + events.on('tweaks.editorDrag', ({ phase, x, y }, c) => { + if (c.windowId && (phase === 'start' || phase === 'move' || phase === 'end')) editor.drag(c.windowId, phase, x, y) + }) + + // ---- Actions (BO-21): none bound by default ---- + + actions.register('tweak.new', async ({ windowId }) => { + await commands.run('tweaks.create', {}, { windowId }) + }) + + // The shown page's Tweak in the panel; with several, the site controls panel lists them to pick from. + actions.register('tweak.edit', async ({ windowId }) => { + const at = await target(undefined, { windowId }) + if (!at) return + const list = tweaksFor(store.state.tweaks, at.host) + const only = list.length === 1 ? list[0] : undefined + if (only) editor.open(at.windowId, at.tabId, only.id) + else if (list.length === 0) ctx.toast(at.windowId, TWEAK_MESSAGES.none) + else await commands.run('site.openControls', {}, { windowId: at.windowId }) + }) + + /** Turns every Tweak of the shown page's site on or off (BO-21, BO-14). */ + const setSite = async (windowId: WindowId | undefined, enabled: boolean) => { + const at = await target(undefined, { windowId }) + if (!at) return + const ids = tweaksFor(store.state.tweaks, at.host).map((t) => t.id) + if (ids.length === 0) { + ctx.toast(at.windowId, TWEAK_MESSAGES.none) + return + } + const now = Date.now() + store.update((draft) => { + for (const id of ids) { + const tweak = draft.tweaks[id] + if (tweak) applyTweakPatch(tweak, { enabled }, now) + } + }) + ctx.toast(at.windowId, `Tweaks ${enabled ? 'on' : 'off'} for ${at.host}`) + } + actions.register('tweak.siteOff', ({ windowId }) => setSite(windowId, false)) + actions.register('tweak.siteOn', ({ windowId }) => setSite(windowId, true)) + // The Library's list of every Tweak (BO-15). + actions.register('tweak.view', async ({ windowId }) => { + await commands.run('library.open', { section: 'tweaks' }, { windowId }) + }) + + if (ctx.e2e) { + const hooks: TweakTestHooks = { + editor: (windowId) => editor.info(windowId ?? windows.ids()[0] ?? ''), + page: (tabId) => pages.info(tabId), + picker: (tabId) => hiding.info(tabId), + } + ;(globalThis as unknown as { __orbitTweaks: TweakTestHooks }).__orbitTweaks = hooks + } +} diff --git a/src/main/tweaks/menu.ts b/src/main/tweaks/menu.ts new file mode 100644 index 00000000..4e1d2220 --- /dev/null +++ b/src/main/tweaks/menu.ts @@ -0,0 +1,32 @@ +import type { MenuItemConstructorOptions } from 'electron' +import type { TabId, WindowId } from '../../shared/state' +import { tweakHostOf } from '../../shared/tweaks' +import type { MainContext } from '../context' +import type { KeyHints } from '../keys/menu-hints' + +/** The tab menu's item (BO-01). */ +export const TAB_MENU_TWEAK = 'Tweak this Site…' + +/** Its key hint (KS-18): New Tweak's key, once the user gives it one (none by default). */ +export const TWEAK_TAB_MENU_KEYS: KeyHints = [[TAB_MENU_TWEAK, 'tweak.new']] + +/** + * "Tweak this Site…" in a tab's menu (src/main/tabs/menus.ts, BO-01): a new Tweak for the tab's + * site, the tab shown first. Only on a website, and while Tweaks are on (BO-16). It shows the key + * of New Tweak when the user gave it one (TWEAK_TAB_MENU_KEYS, KS-18). + */ +export function tabTweakMenuItems( + ctx: MainContext, + tabId: TabId, + windowId: WindowId | undefined, +): MenuItemConstructorOptions[] { + const state = ctx.store.state + const tab = state.tabs[tabId] + if (!tab || tab.error || !state.settings.tweaksEnabled || !tweakHostOf(tab.url)) return [] + return [ + { + label: TAB_MENU_TWEAK, + click: () => void ctx.commands.run('tweaks.create', { tabId }, { windowId }).catch(() => {}), + }, + ] +} diff --git a/src/main/tweaks/page-sheets.ts b/src/main/tweaks/page-sheets.ts new file mode 100644 index 00000000..cf281bf3 --- /dev/null +++ b/src/main/tweaks/page-sheets.ts @@ -0,0 +1,79 @@ +/** + * Main's own Tweak sheets in a page (BO-18, D85; tweaks fix check, must-fix 1). Each goes in with + * `insertCSS`, a round trip to the renderer that holds the page as it is sent, and comes out with + * `removeInsertedCSS` and the key that renderer gave it. + * - Keys count 1, 2, … in each renderer, so a key means nothing to another: it goes back only to the + * renderer that gave it. One from a renderer that no longer holds the page went with its document. + * - A renderer may answer late, or never: it crashed, or another site's page took its place while it + * was busy. The wait for it ends as its document goes, so it holds up nothing for the next one. + */ + +/** What main uses of a page's web contents here. */ +export interface SheetPage { + insertCSS(css: string, options: { cssOrigin: 'author' }): Promise + removeInsertedCSS(key: string): Promise + isDestroyed(): boolean + readonly mainFrame: { readonly processId: number; readonly routingId: number } +} + +/** A sheet main put in a page: its key, and the renderer that gave it. */ +export interface PlacedSheet { + key: string + renderer: string +} + +/** The renderer that holds a page's main frame now. */ +export const rendererOf = (page: SheetPage): string => `${page.mainFrame.processId}:${page.mainFrame.routingId}` + +/** Takes a sheet out of its page, if the renderer that gave its key still holds the page. */ +export function removeSheet(page: SheetPage, sheet: PlacedSheet): void { + try { + if (page.isDestroyed() || rendererOf(page) !== sheet.renderer) return + page.removeInsertedCSS(sheet.key).catch(() => {}) + } catch { + // The page's frame went, and the sheet with it. + } +} + +/** + * Puts a sheet in a page. Resolves with it once it is in, or with null: + * - where it didn't go in; + * - where `wanted()` says no as its key comes back (its document went meanwhile); + * - as soon as `gone` settles (its document went: a new one asked, or the renderer went), without + * waiting for the renderer's answer. + * A key that isn't kept comes out again, whenever it comes back, as `removeSheet` takes one out. + */ +export function insertSheet( + page: SheetPage, + css: string, + gone: Promise, + wanted: () => boolean, +): Promise { + return new Promise((resolve) => { + let waiting = true + const done = (sheet: PlacedSheet | null) => { + if (!waiting) return + waiting = false + resolve(sheet) + } + void gone.then(() => done(null)) + let renderer: string + let sent: Promise + try { + if (page.isDestroyed()) return done(null) + renderer = rendererOf(page) + sent = page.insertCSS(css, { cssOrigin: 'author' }) + } catch { + return done(null) + } + sent.then( + (key) => { + const sheet = { key, renderer } + if (waiting && wanted()) return done(sheet) + done(null) + removeSheet(page, sheet) + }, + () => done(null), + ) + }) +} diff --git a/src/main/tweaks/pages.ts b/src/main/tweaks/pages.ts new file mode 100644 index 00000000..64fb6c67 --- /dev/null +++ b/src/main/tweaks/pages.ts @@ -0,0 +1,369 @@ +import { type IpcMainEvent, ipcMain, type Session, type WebContents } from 'electron' +import { isPrivateProfileId } from '../../shared/private' +import { PAGE_CHANNEL, type TweakSheetsAnswer } from '../../shared/site' +import type { OrbitState, ProfileId, TabId, Tweak } from '../../shared/state' +import { + enabledTweaksFor, + type SheetOptions, + type SheetPart, + tweakHostOf, + tweakScript, + tweakSheets, +} from '../../shared/tweaks' +import type { MainContext } from '../context' +import { insertSheet, type PlacedSheet, removeSheet } from './page-sheets' + +/** The isolated world main's own scripts for Tweaks run in (never the page's world). */ +export const TWEAK_WORLD = 1040 + +/** Chromium's net::ERR_ABORTED: a navigation stopped or replaced, which leaves the page as it was. */ +const ERR_ABORTED = -3 + +/** What main knows of a page that may have Tweaks: one per web contents whose preload asked. */ +interface PageRecord { + contents: WebContents + /** The page's URL when its preload asked (BO-18), followed through in-page navigations. */ + url: string + /** Its host; null once it shows something Tweaks don't reach. */ + host: string | null + /** Bumped by each new document's read: the number its answer carries (`TweakSheetsAnswer.doc`). */ + doc: number + /** The preload's first-paint sheets are in the page, not yet replaced by main's own. */ + preloaded: boolean + /** Main's sheets in the page, by part (`SheetPart.key`), each with the renderer that gave its key. */ + inserted: Map + /** The JavaScript of this document's Tweaks has run (once per load, BO-18). */ + scripted: boolean + /** A change is being applied; `again` asks for another pass once it is done. */ + busy: boolean + again: boolean + /** + * Settles once this document's passes stop waiting on its renderer (`leave()`): a new document + * asked, or the renderer went. An answer that never comes then holds up nothing. + */ + gone: Promise + leave: () => void + /** Sheet options for this page only: a hidden element shown while its restore marker is hovered. */ + showHidden?: string +} + +export interface TweakPages { + /** Whether a tab shows a page Tweaks can change now: a website whose preload asked (BO-01). */ + tweakable(tabId: TabId): boolean + /** The host of a tab's page, where tweakable. */ + hostOf(tabId: TabId): string | null + /** + * True once a tab shows a page Tweaks can change (now, or as its preload asks); false as soon as + * its page fails to load (an error page, a crash) or the tab goes, else after `ms`. + */ + whenTweakable(tabId: TabId, ms: number): Promise + /** Runs a Tweak's JavaScript in a tab's page now (BO-11's "Run now"); false where it may not run. */ + runNow(tabId: TabId, tweakId: string): boolean + /** Shows one hidden element again for now, or none (BO-10's restore marker preview). */ + setShowHidden(tabId: TabId, hiddenId: string | undefined): void + /** Test view: the sheets main has in a tab's page, and whether the preload's are still there. */ + info(tabId: TabId): { host: string | null; preloaded: boolean; parts: string[]; scripted: boolean } | null +} + +/** + * Applies Tweaks to pages (BO-18; D40, D85): + * - before first paint, the page preload asks main for its sheets (`PAGE_CHANNEL.tweakSheets`, a + * sync message that carries nothing): main answers from the frame's own URL, for a main frame, + * with the sheets and a number for this document's read; the preload inserts them as injected + * author sheets, which the page can't see or remove; + * - at document ready, or at the first change before it, main puts its own copies in (`insertCSS`) + * and tells the preload to take its sheets out (`PAGE_CHANNEL.tweakRetire`, with that number): + * a message to the page's main frame that main never waits for. At document ready main also runs + * the JavaScript Tweaks allowed here in the page's main world (`executeJavaScript` on its main + * frame, never through the preload); + * - while the page is open, each change to its Tweaks replaces only the sheets that changed: + * the new one goes in before the old one comes out, so nothing flashes. A change waits only on + * `insertCSS`, never on a script in the page or an isolated world, so it shows while the page is + * still loading; and only while its document is there: a renderer that never answers (it crashed, + * or another site's page took its place while it was busy) holds up nothing for the next document + * (page-sheets.ts). + */ +export function registerTweakPages(ctx: MainContext): TweakPages { + const { store, sessions, tabs } = ctx + const records = new Map() + /** Waiting for a page to become tweakable (`whenTweakable`): each checks its tab as a page asks. */ + const waiting = new Set<() => void>() + const profiles = new WeakMap() + sessions.onSessionCreated((ses, profileId) => profiles.set(ses, profileId)) + + const profileOf = (contents: WebContents): ProfileId | undefined => { + const tabId = tabs.tabIdFor(contents) + return (tabId ? store.state.tabs[tabId]?.profileId : undefined) ?? profiles.get(contents.session) + } + + /** The record still describes the page on screen: no other document has come since it asked. */ + const current = (record: PageRecord): boolean => + !!record.host && !record.contents.isDestroyed() && record.contents.getURL() === record.url + + const sheetOptions = (record: PageRecord): SheetOptions => ({ showHidden: record.showHidden }) + + const partsFor = (state: OrbitState, record: PageRecord): SheetPart[] => + tweakSheets(enabledTweaksFor(state.tweaks, state.settings, record.host), sheetOptions(record)) + + /** + * Puts one part's sheet in a document. A key that comes back once a later document came, or + * the renderer went, comes out again, but only from the renderer that gave it (page-sheets.ts). + */ + const insert = async (record: PageRecord, doc: number, gone: Promise, part: SheetPart): Promise => { + const sheet = await insertSheet(record.contents, part.css, gone, () => record.doc === doc && current(record)) + if (!sheet) return + const old = record.inserted.get(part.key) + record.inserted.set(part.key, { ...sheet, css: part.css }) + if (old) removeSheet(record.contents, old) + } + + /** + * The preload takes out its first-paint sheets (D85): a message to the page's main frame that + * carries only the number of the document's read, and that main never waits for. + */ + const retire = (record: PageRecord, doc: number) => { + try { + record.contents.mainFrame.send(PAGE_CHANNEL.tweakRetire, doc) + } catch { + // The frame went, and the document's sheets with it. + } + } + + /** Brings a page's sheets in line with its Tweaks now. */ + const syncOnce = async (record: PageRecord): Promise => { + if (!current(record)) return + const { doc, gone } = record + const want = partsFor(store.state, record) + const keys = new Set(want.map((p) => p.key)) + await Promise.all( + want.filter((p) => record.inserted.get(p.key)?.css !== p.css).map((p) => insert(record, doc, gone, p)), + ) + // Its document went meanwhile (a new one asked, or the renderer went): the pass ends here. + if (record.doc !== doc || !current(record)) return + for (const [part, have] of record.inserted) { + if (keys.has(part)) continue + record.inserted.delete(part) + removeSheet(record.contents, have) + } + // Main's own sheets are all in: the preload's go. + if (record.preloaded && want.every((p) => record.inserted.get(p.key)?.css === p.css)) { + record.preloaded = false + retire(record, doc) + } + } + + /** One pass at a time per page; changes that come meanwhile make one more. */ + const sync = async (record: PageRecord): Promise => { + if (record.busy) { + record.again = true + return + } + record.busy = true + try { + do { + record.again = false + await syncOnce(record) + } while (record.again) + } catch (err) { + console.error('[tweaks] page sheets', err) + } finally { + record.busy = false + } + } + + /** + * A Tweak's JavaScript may run in a page: it has some, the user allowed it on this device, and + * in a private window allowed it there too (BO-11, BO-17, BO-18). + */ + const mayRun = (record: PageRecord, tweak: Tweak): boolean => + !!tweak.js.trim() && tweak.jsAllowed && (tweak.jsInPrivate || !isPrivateProfileId(profileOf(record.contents))) + + /** Runs the JavaScript of a page's Tweaks once per load, where allowed (BO-11, BO-17, BO-18). */ + const runScripts = (record: PageRecord) => { + if (record.scripted || !current(record)) return + record.scripted = true + const state = store.state + for (const tweak of enabledTweaksFor(state.tweaks, state.settings, record.host)) { + if (!mayRun(record, tweak)) continue + // A script error goes to the page's console; a rejection here is only that (D28). + record.contents.mainFrame.executeJavaScript(tweakScript(tweak)).catch(() => {}) + } + } + + /** A document's end, for its passes to stop waiting on (`PageRecord.gone`), and its trigger. */ + const parting = (): Pick => { + let leave = () => {} + const gone = new Promise((resolve) => { + leave = resolve + }) + return { gone, leave } + } + + const track = (contents: WebContents): PageRecord => { + const record: PageRecord = { + contents, + url: '', + host: null, + doc: 0, + preloaded: false, + inserted: new Map(), + scripted: false, + busy: false, + again: false, + ...parting(), + } + records.set(contents.id, record) + const id = contents.id + contents.on('dom-ready', () => { + if (!current(record)) return + void sync(record) + runScripts(record) + }) + contents.on('did-navigate-in-page', (_event, url, isMainFrame) => { + // Same document, same origin: the page keeps its sheets (BO-17: in-page navigations). + if (isMainFrame && record.host && tweakHostOf(url) === record.host) record.url = url + }) + // An error page took the page's place, or the page crashed: no Tweak goes there, until a page + // asks again (a stopped or replaced navigation leaves the page as it was). + contents.on('did-fail-load', (_event, code, _description, _url, isMainFrame) => { + if (isMainFrame && code !== ERR_ABORTED) record.host = null + }) + contents.on('render-process-gone', () => { + record.host = null + record.leave() + }) + contents.once('destroyed', () => { + records.delete(id) + record.leave() + }) + return record + } + + ipcMain.on(PAGE_CHANNEL.tweakSheets, (event: IpcMainEvent) => { + let sheets: TweakSheetsAnswer | null = null + try { + sheets = answer(event) + } catch (err) { + console.error('[tweaks] page ask', err) + } + event.returnValue = sheets + }) + + /** + * The page preload's ask, before the page's first paint: the sheets, from the frame's own URL, and + * the number of this document's read; null where none apply. + */ + const answer = (event: IpcMainEvent): TweakSheetsAnswer | null => { + const contents = event.sender + const frame = event.senderFrame + const main = contents.mainFrame + if (!frame || frame.detached || frame.processId !== main.processId || frame.routingId !== main.routingId) + return null + // Only pages of a profile's session: never Orbit's own views. + if (!profiles.has(contents.session)) return null + const url = frame.url + const host = tweakHostOf(url) + let record = records.get(contents.id) + if (!host && !record) return null + record ??= track(contents) + // A pass for the document before this one stops waiting on its renderer, which may never answer. + record.leave() + Object.assign(record, parting()) + record.doc++ + record.url = url + record.host = host + record.inserted.clear() + record.preloaded = false + record.scripted = false + record.showHidden = undefined + if (!host) return null + // After the answer: the page paints first. + if (waiting.size > 0) { + setImmediate(() => { + for (const check of [...waiting]) check() + }) + } + const parts = partsFor(store.state, record) + if (parts.length === 0) return null + record.preloaded = true + return { doc: record.doc, sheets: parts.map((p) => p.css) } + } + + // A change to the Tweaks, or the switch for all of them (BO-14, BO-16): open pages follow at once. + store.subscribe((next, prev) => { + if (next.tweaks === prev.tweaks && next.settings.tweaksEnabled === prev.settings.tweaksEnabled) return + for (const record of records.values()) { + if (!record.host) continue + const before = enabledTweaksFor(prev.tweaks, prev.settings, record.host) + const after = enabledTweaksFor(next.tweaks, next.settings, record.host) + if (before.length === 0 && after.length === 0) continue + void sync(record) + } + }) + + const recordOf = (tabId: TabId): PageRecord | undefined => { + const contents = tabs.contents(tabId) + const record = contents ? records.get(contents.id) : undefined + return record && current(record) ? record : undefined + } + + return { + tweakable: (tabId) => !!recordOf(tabId), + hostOf: (tabId) => recordOf(tabId)?.host ?? null, + whenTweakable: (tabId, ms) => + new Promise((resolve) => { + if (recordOf(tabId)) { + resolve(true) + return + } + // The tab runtime marks a failed load on the tab (`error`), and clears it as a load starts. + const failed = (state: OrbitState) => { + const tab = state.tabs[tabId] + return !tab || !!tab.error + } + if (failed(store.state)) { + resolve(false) + return + } + const finish = (ok: boolean) => { + waiting.delete(check) + clearTimeout(timer) + unsubscribe() + resolve(ok) + } + const check = () => { + if (recordOf(tabId)) finish(true) + } + const timer = setTimeout(() => finish(false), ms) + const unsubscribe = store.subscribe((next) => { + if (failed(next)) finish(false) + }) + waiting.add(check) + }), + runNow: (tabId, tweakId) => { + const record = recordOf(tabId) + const state = store.state + const tweak = record && enabledTweaksFor(state.tweaks, state.settings, record.host).find((t) => t.id === tweakId) + if (!record || !tweak || !mayRun(record, tweak)) return false + record.contents.mainFrame.executeJavaScript(tweakScript(tweak)).catch(() => {}) + return true + }, + setShowHidden: (tabId, hiddenId) => { + const record = recordOf(tabId) + if (!record || record.showHidden === hiddenId) return + record.showHidden = hiddenId + void sync(record) + }, + info: (tabId) => { + const contents = tabs.contents(tabId) + const record = contents ? records.get(contents.id) : undefined + if (!record) return null + return { + host: current(record) ? record.host : null, + preloaded: record.preloaded, + parts: [...record.inserted.keys()], + scripted: record.scripted, + } + }, + } +} diff --git a/src/preload/page.ts b/src/preload/page.ts index d33c3428..445802ce 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -24,13 +24,17 @@ * 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`). + * + * In a website's main frame, before the page's first paint, it puts in the stylesheets of the site's + * Tweaks, and takes them out when main's own copies are in (BO-18, D85: `applyTweakSheets`). */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, type IpcRendererEvent, ipcRenderer, webFrame } 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' +import { insertTweakSheets, tweakAnswer } from './tweak-sheets' type StateOf = (name: string) => string | null @@ -51,6 +55,34 @@ const screenShared = (count: number) => { ipcRenderer.send(PAGE_CHANNEL.screenShare, count) } +/** + * Tweaks (BO-18; D40, D85): the stylesheets of the site's Tweaks go into the page before its first + * paint. Main answers from the frame's own URL, for a website's main frame only. The sheets go in as + * injected author sheets (src/preload/tweak-sheets.ts), which the page can neither read nor remove, + * and nothing is added to the page's globals. Once main's own copies are in, main's retire message, + * with this document's number, takes them out (src/main/tweaks/pages.ts). A PDF shows in the + * engine's viewer, which no Tweak changes (BO-01). + */ +function applyTweakSheets(): void { + if (window.top !== window || !/^https?:$/.test(location.protocol) || document.contentType === 'application/pdf') + return + let value: unknown + try { + value = ipcRenderer.sendSync(PAGE_CHANNEL.tweakSheets) + } catch { + return + } + const answer = tweakAnswer(value) + if (!answer) return + const retire = insertTweakSheets(webFrame, answer) + const onRetire = (_event: IpcRendererEvent, doc: unknown) => { + if (retire(doc)) ipcRenderer.removeListener(PAGE_CHANNEL.tweakRetire, onRetire) + } + ipcRenderer.on(PAGE_CHANNEL.tweakRetire, onRetire) +} + +applyTweakSheets() + /** * Runs in the page's own world before its scripts (serialized: it may use only its arguments and * the page's globals). Built-ins are taken up front, so a page that replaces them later can't reach diff --git a/src/preload/tweak-sheets.ts b/src/preload/tweak-sheets.ts new file mode 100644 index 00000000..bd902975 --- /dev/null +++ b/src/preload/tweak-sheets.ts @@ -0,0 +1,56 @@ +/** + * The page preload's first-paint Tweak sheets (BO-18; D40 (3), D85), kept apart from Electron so a + * unit test can drive them with a stand-in frame. + * + * The sheets go into the page as injected author sheets (`webFrame.insertCSS`), as main's own copies + * do: the page can't read them, can't remove them, and can't outrank them with layered `!important` + * rules (an injected sheet comes before the page's own, so the Tweaks' layer is the first declared). + * Their keys stay in the preload's own world. Once main's copies are in, main says so with the number + * it gave this document's read (`PAGE_CHANNEL.tweakRetire`), and the sheets go; a number from any + * other document's read takes nothing. + */ +import type { TweakSheetsAnswer } from '../shared/site' + +/** What of Electron's `webFrame` the sheets need. */ +export interface SheetFrame { + insertCSS(css: string, options: { cssOrigin: 'author' }): string + removeInsertedCSS(key: string): void +} + +/** Main's answer to the read, checked: null when it brings nothing to insert. */ +export function tweakAnswer(value: unknown): TweakSheetsAnswer | null { + if (!value || typeof value !== 'object') return null + const { doc, sheets } = value as Record + if (typeof doc !== 'number' || !Number.isSafeInteger(doc) || !Array.isArray(sheets)) return null + const css = sheets.filter((sheet): sheet is string => typeof sheet === 'string') + return css.length > 0 ? { doc, sheets: css } : null +} + +/** + * Inserts the answer's sheets into the frame's document. Returns what main's retire message does: + * with this document's number it takes the sheets out and says true (once); with any other it does + * nothing and says false. + */ +export function insertTweakSheets(frame: SheetFrame, answer: TweakSheetsAnswer): (doc: unknown) => boolean { + const keys: string[] = [] + for (const css of answer.sheets) { + try { + keys.push(frame.insertCSS(css, { cssOrigin: 'author' })) + } catch { + // A sheet the engine refused: the others still go in. + } + } + let retired = false + return (doc) => { + if (retired || doc !== answer.doc) return false + retired = true + for (const key of keys.splice(0)) { + try { + frame.removeInsertedCSS(key) + } catch { + // The document is going: its sheets go with it. + } + } + return true + } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6e61d3e7..fc9b11d7 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -186,6 +186,14 @@ async function boot(): Promise { , ) + } else if (view === 'tweak') { + // The Tweak editor panel over the page's corner (tweaks lane, BO-02). + const { TweakEditorApp } = await import('./tweaks/TweakEditorApp') + root.render( + + + , + ) } else if (view === 'strip') { // Split view pane strip (split lane, SV-12): a tiny transparent view over a pane's top edge. const { PaneStripApp } = await import('./shell/split/PaneStripApp') diff --git a/src/renderer/src/overlay/site/SiteControls.tsx b/src/renderer/src/overlay/site/SiteControls.tsx index a44e8d8e..b786ca67 100644 --- a/src/renderer/src/overlay/site/SiteControls.tsx +++ b/src/renderer/src/overlay/site/SiteControls.tsx @@ -25,6 +25,7 @@ import type { OverlayState } from '../../../../shared/state' import { invoke } from '../../api' import { DEVICE_ICON, PERMISSION_ICON } from '../../shell/chrome/PermissionPrompt' import { useOrbit } from '../../store' +import { BUILT_IN_ORDER, SITE_CONTROLS_SECTIONS } from './registry' import './site.css' type PanelOverlay = Extract @@ -63,7 +64,7 @@ function connectionLine(connection: Connection): { icon: ReactNode; text: string * permissions the site was given, refused or asked for with a switch each (pop-ups too), devices * it may use, zoom with a reset, and clearing its cookies and data. Changes apply at once; when the * page only sees them after a reload, a Reload button says so. Esc or a click outside closes it; - * arrows move between its controls. + * arrows move between its controls. Other features add their sections through `./registry`. */ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; dark?: boolean }) { const params = overlay.params ?? {} @@ -156,6 +157,14 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; refresh() } + // Other features' sections (./registry), between the built-in ones by their `order`. + const registered = (from: number, to: number) => + info + ? SITE_CONTROLS_SECTIONS.filter((s) => s.order >= from && s.order < to).map((s) => ( + + )) + : null + const conn = info ? connectionLine(info.connection) : null return (
)} + {registered(Number.NEGATIVE_INFINITY, BUILT_IN_ORDER.permissions)} {info.web && (

Permissions

@@ -227,6 +237,7 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay;
)} + {registered(BUILT_IN_ORDER.permissions, BUILT_IN_ORDER.devices)} {info.devices.length > 0 && (

Devices

@@ -258,7 +269,9 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay;
)} + {registered(BUILT_IN_ORDER.devices, BUILT_IN_ORDER.pip)} {info.web && } + {registered(BUILT_IN_ORDER.pip, BUILT_IN_ORDER.zoom)}
@@ -294,6 +307,7 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; )}
+ {registered(BUILT_IN_ORDER.zoom, Number.POSITIVE_INFINITY)} {info.web && (
diff --git a/src/renderer/src/overlay/site/registry.ts b/src/renderer/src/overlay/site/registry.ts new file mode 100644 index 00000000..e6f0326a --- /dev/null +++ b/src/renderer/src/overlay/site/registry.ts @@ -0,0 +1,94 @@ +import type { ComponentType } from 'react' +import type { SiteControlsInfo } from '../../../../shared/site' +import type { TabId } from '../../../../shared/state' + +/** What every registered section's component gets. */ +export interface SiteSectionProps { + /** The tab the panel is for. */ + tabId: TabId + /** What main said about its page (`site.info`): URL, host, connection, `web`, `private`… */ + info: SiteControlsInfo + /** Asks main for `info` again, e.g. after a change that the built-in rows show. */ + refresh(): void + /** Closes the panel, e.g. before opening something of the section's own in its place. */ + close(): Promise +} + +/** + * The site controls panel's section registry (NV-16): other features' rows in the panel, such as + * a site's Tweaks (BO-14) or the extensions that may read it (extensions lane). + * + * A section is a block of rows between the panel's built-in ones. Sections are found by file name: + * every module under `src/renderer/src/` whose name ends in `.site-controls.tsx` and exports + * `section: SiteControlsSection` is in the panel, so adding one edits no panel file: + * + * ```tsx + * // src/renderer/src/tweaks/tweaks.site-controls.tsx + * import type { SiteControlsSection, SiteSectionProps } from '../overlay/site/registry' + * + * function TweakRows({ tabId, info }: SiteSectionProps) { + * if (!info.web) return null + * return ( + *
+ *

Tweaks

+ *
    …
+ *
+ * ) + * } + * + * export const section: SiteControlsSection = { id: 'tweaks', order: 45, component: TweakRows } + * ``` + * + * - `id`: stable and unique (the first module of a duplicate id wins); the section's React key. + * - `order`: its place among the built-in sections, low first. The panel's own are Permissions 20, + * Devices 30, Picture in Picture 40 and Zoom 50, between the header (site, connection, Reload) + * and the footer (reset permissions, clear site data). Pick a number between two to slot in. + * - `component`: rendered with `SiteSectionProps` once `site.info` has answered, on every kind of + * page; it returns null where it has nothing to say (`info.web` is false on internal, file and + * blank pages). Build it from the panel's classes (`site.css`): `site-panel__section`, + * `site-panel__title`, `site-panel__rows`, `site-panel__row`, `site-panel__name` with a + * `site-panel__state` line, `site-switch` (a `role="switch"` button) and + * `site-panel__icon-button`. Use ` )} + {page && !peek && } {page && ( + ))} +
+ {language === 'css' ? ( + + ) : ( + <> + + {hasJs && !tweak.jsAllowed && ( +
+ + This JavaScript came from elsewhere. It doesn't run until you allow it on this device. + +
+ )} +
+ + +
+ + + )} +
+ ) +} diff --git a/src/renderer/src/tweaks/CodeEditor.tsx b/src/renderer/src/tweaks/CodeEditor.tsx new file mode 100644 index 00000000..dcc6f8cb --- /dev/null +++ b/src/renderer/src/tweaks/CodeEditor.tsx @@ -0,0 +1,153 @@ +import { type KeyboardEvent, useLayoutEffect, useMemo, useRef } from 'react' +import { type CodeLanguage, highlight } from './highlight' + +/** One level of indentation. */ +const INDENT = ' ' +/** Past this many characters code shows without colours: colouring it on each key would slow typing. */ +const COLOUR_LIMIT = 30_000 + +interface Props { + value: string + onChange: (value: string) => void + language: CodeLanguage + label: string + testId: string + placeholder?: string + maxLength?: number +} + +/** + * A small code editor (BO-11): a plain text field over a coloured copy of its text, with line + * numbers beside. The field does the editing, so selection, the clipboard, IME input and ⌘Z / Ctrl+Z + * are the platform's own; Tab and ⇧Tab indent and outdent, Enter keeps the line's indentation. + * The coloured copy is text only, never markup. + */ +export function CodeEditor({ value, onChange, language, label, testId, placeholder, maxLength }: Props) { + const input = useRef(null) + const layer = useRef(null) + const gutter = useRef(null) + const tokens = useMemo(() => (value.length <= COLOUR_LIMIT ? highlight(value, language) : null), [value, language]) + const numbers = useMemo(() => { + let lines = 1 + for (let i = value.indexOf('\n'); i >= 0; i = value.indexOf('\n', i + 1)) lines++ + return Array.from({ length: lines }, (_, i) => i + 1).join('\n') + }, [value]) + + /** The coloured copy and the line numbers follow the field's scrolling. */ + const follow = () => { + const el = input.current + if (!el) return + if (layer.current) layer.current.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)` + if (gutter.current) gutter.current.style.transform = `translateY(${-el.scrollTop}px)` + } + // Each change of the text may scroll the field. + useLayoutEffect(follow) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing || e.metaKey || e.ctrlKey || e.altKey) return + if (e.key === 'Tab') { + e.preventDefault() + indent(e.currentTarget, e.shiftKey) + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + newLine(e.currentTarget) + } + } + + return ( +
+
+
+ {numbers} +
+
+
+ {tokens && ( +
+            {tokens.map((t, i) =>
+              t.kind === 'plain' ? (
+                t.text
+              ) : (
+                // biome-ignore lint/suspicious/noArrayIndexKey: tokens have no identity but their place
+                
+                  {t.text}
+                
+              ),
+            )}
+            {'\n'}
+          
+ )} +