Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d21ff85
feat(pages): page dialogs over the page (NV-24)
siyabendoezdemir Oct 11, 2026
1ddfac5
fix(pages): no beforeunload or unload listener in every page (NV-24, …
siyabendoezdemir Oct 11, 2026
9eacd5e
feat(pages): error and crash screens over the page (NV-13, NV-14)
siyabendoezdemir Oct 11, 2026
75355bb
feat(pages): links that open other apps ask first (NV-19)
siyabendoezdemir Oct 11, 2026
21869ca
feat(pages): window full screen with the sidebar hidden and Esc that …
siyabendoezdemir Oct 11, 2026
774da17
feat(pages): Orbit's print dialog over the page (NV-07)
siyabendoezdemir Oct 11, 2026
aea4bfe
feat(pages): view source in a new tab (DT-04)
siyabendoezdemir Oct 11, 2026
7c27cda
feat(pages): two-finger swipe back and forward over the page (NV-02)
siyabendoezdemir Oct 11, 2026
51a5e03
feat(pages): History as a page in a tab (AR-10)
siyabendoezdemir Oct 11, 2026
2f626a0
feat(pages): Save Page As… on ⇧⌘S (Ctrl+Shift+S), File → Save As… and…
siyabendoezdemir Oct 11, 2026
307dc62
fix: chrome and keys follow-ups (N4, F4, K1; N1, N3, K6 notes; F11 ch…
siyabendoezdemir Oct 11, 2026
5e3dce5
fix(pages): a frame's leave warning counts when closing a tab, a wind…
siyabendoezdemir Oct 11, 2026
f5fe657
fix(pages): a page's close or navigation right after print() waits fo…
siyabendoezdemir Oct 11, 2026
cf8ed96
fix(pages): a page's dialog takes the keyboard only where the page ha…
siyabendoezdemir Oct 11, 2026
0258890
fix(pages): an always-allowed site opens another app once per user ge…
siyabendoezdemir Oct 11, 2026
0754dd3
fix(pages): a dialog from an about:blank page names its opener's site…
siyabendoezdemir Oct 11, 2026
bfb3aa9
test(pages): the four page channels refuse a sender that isn't a page…
siyabendoezdemir Oct 11, 2026
bde5a0e
test(pages): full screen's paired Esc presses go in one call; the bus…
siyabendoezdemir Oct 11, 2026
4418050
docs(pages): NV-05's held-keys limits and NV-02's hosts in the notes …
siyabendoezdemir Oct 11, 2026
e65f412
Merge origin/lane/finish-views (05b7eb9, with main at 3bcb521) into l…
siyabendoezdemir Oct 11, 2026
512a0a8
fix(pages): the player keeps clear of a page's dialogs and screens, a…
siyabendoezdemir Oct 11, 2026
325b76a
fix(pages): a page's dialog and the print dialog stay above the find …
siyabendoezdemir Oct 11, 2026
a79b505
fix(pages): print-closed and the page's hold only for the page whose …
siyabendoezdemir Oct 11, 2026
28fe136
fix(pages): after Cancel, a page asks to open another app again only …
siyabendoezdemir Oct 11, 2026
54a2866
docs(pages): covers and the player, dialogs above the find bar, D76's…
siyabendoezdemir Oct 11, 2026
08b41a9
fix(pages): a press in a frame from another site counts as a user ges…
siyabendoezdemir Oct 11, 2026
d00f879
fix(pages): a link to another app uses up the gesture whatever the an…
siyabendoezdemir Oct 11, 2026
b39cb16
test(pages): Orbit's Reduce Motion stops the page dialog's and the pr…
siyabendoezdemir Oct 11, 2026
6f02b9d
docs(pages): NV-24 and NV-19 notes and ARCHITECTURE after D77; the tw…
siyabendoezdemir Oct 11, 2026
dc24331
Merge origin/main (PR #26, finish-views fix round 2) into lane/pages
siyabendoezdemir Oct 11, 2026
6a99a04
docs: MD-01 names what a frame's page can notice while its video is i…
siyabendoezdemir Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 29 additions & 29 deletions FEATURES.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

41 changes: 25 additions & 16 deletions docs/feature-status.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/main/archive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ export function registerArchive(ctx: MainContext): ArchiveApi {
ctx.actions.register('app.archive', (c) => openLibrary(c, { section: 'archive' }))
// The menu and the command bar ask in the Library before anything goes (AR-07).
ctx.actions.register('archive.clear', (c) => openLibrary(c, { section: 'archive', confirmClear: true }))
// ⌘Y / Ctrl+H, Archive → View History and the command bar (AR-10).
ctx.actions.register('app.history', (c) => openLibrary(c, { section: 'history' }))
// ⌘Y / Ctrl+H, Archive → View History and the command bar show History in a tab (AR-10, pages
// lane: src/main/pages/history.ts); the Library keeps its History section.
// Help → Restore Data… and the command bar: the backups to choose from (AR-11).
ctx.actions.register('app.restoreData', (c) => openLibrary(c, { section: 'backups' }))

Expand Down
2 changes: 2 additions & 0 deletions src/main/devtools/internal-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function isInternalUrl(url: string | undefined): boolean {
*/
export const INTERNAL_PAGE_COMMANDS: Readonly<Record<string, readonly CommandName[]>> = {
version: ['settings.buildInfo', 'settings.copyBuildInfo'],
// History (AR-10): its own tab's profile only (src/main/pages/history.ts).
history: ['pages.history', 'pages.historyOpen', 'pages.historyDelete', 'pages.historyMenu'],
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/main/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerMedia } from './media'
import { registerMiniWindow } from './miniwindow'
import { registerMotion } from './motion'
import { registerNav } from './nav'
import { registerPages } from './pages'
import { registerPeek } from './peek'
import { registerSettings } from './settings'
import { registerSpaces } from './spaces/commands'
Expand All @@ -29,6 +30,7 @@ export function registerFeatures(ctx: MainContext): void {
registerMotion(ctx)
const { ops } = registerTabs(ctx)
registerNav(ctx, ops)
registerPages(ctx, ops)
// Before Peek and Mini Windows: a DevTools key held down is ignored before they act on it.
registerDevtools(ctx)
registerSpaces(ctx)
Expand Down
1 change: 1 addition & 0 deletions src/main/keys/menu-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const PAGE_MENU_KEYS: KeyHints = [
['Back', 'nav.back'],
['Forward', 'nav.forward'],
['Reload', 'nav.reload'],
['Save Page As…', 'page.save'],
['Print…', 'page.print'],
['View Page Source', 'page.viewSource'],
]
Expand Down
4 changes: 3 additions & 1 deletion src/main/keys/page-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ export const KEYS_WORLD = 1020

/**
* Whether an element is a text field: one that types or edits text (page script source, shared by
* the watcher here and the focus mirror in page-focus.ts).
* the watcher here and the focus mirror in page-focus.ts). A web component's field counts: the
* element focused inside open shadow roots, not their host (keys fix check K1).
*/
export const EDITABLE = `(el) => {
while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement
if (!el || el === document.body || el === document.documentElement) return false
if (el.isContentEditable) return true
const tag = el.tagName
Expand Down
5 changes: 4 additions & 1 deletion src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export function installMenu(ctx: MainContext): void {
// Anti-lock-in (import lane, IM-17).
item('app.exportPinned', 'Export Pinned Items as Bookmarks…'),
{ type: 'separator' },
// The page saved where the save dialog says (pages lane, NV-21).
item('page.save', 'Save As…'),
item('page.print'),
],
},
Expand Down Expand Up @@ -125,7 +127,8 @@ export function installMenu(ctx: MainContext): void {
item('page.zoomIn'),
item('page.zoomOut'),
{ type: 'separator' },
{ role: 'togglefullscreen' },
// Orbit's own (NV-20), so the menu shows its keys as the user set them.
item('window.toggleFullScreen'),
{ type: 'separator' },
// View → Developer (DT-01, DT-02, DT-04).
{
Expand Down
10 changes: 7 additions & 3 deletions src/main/nav/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import type { WebContents } from 'electron'
/** Isolated world for the chrome lane's small page scripts: pages can't see or tamper with them. */
export const NAV_WORLD = 1002

/** Is keyboard focus in something that edits text (or in a frame Orbit can't see into)? */
const FOCUS_IS_EDITABLE = `(() => {
const el = document.activeElement
/**
* Is keyboard focus in something that edits text (or in a frame Orbit can't see into)? Inside open
* shadow roots: a web component's field counts (keys fix check K1). Page script source.
*/
export const FOCUS_IS_EDITABLE = `(() => {
let el = document.activeElement
while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement
if (!el || el === document.body || el === document.documentElement) return false
if (el.isContentEditable) return true
const tag = el.tagName
Expand Down
7 changes: 4 additions & 3 deletions src/main/nav/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,10 @@ export function registerNav(ctx: MainContext, ops: TabOps): void {
// Esc stops a loading page (NV-04), unless the page uses it: the page gets the key first, and
// Orbit stops the load once the page's preload says no handler took it (D59, keys lane). An
// open overlay (command bar, find bar, Peek) holds keyboard focus and takes Esc before the page
// could see it (KS-19).
contents.on('before-input-event', (_event, input) => {
if (input.type !== 'keyDown' || input.key !== 'Escape') return
// could see it (KS-19). An Esc that left full screen (NV-20, src/main/pages/fullscreen.ts) is
// taken from the page: it stops nothing.
contents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown' || input.key !== 'Escape' || event.defaultPrevented) return
if (input.meta || input.control || input.alt || input.shift) return
if (!store.state.tabs[tabId]?.loading) return
const windowId = windows.windowOfTab(tabId)
Expand Down
35 changes: 16 additions & 19 deletions src/main/nav/links.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { type BrowserWindow, Menu, type MenuItemConstructorOptions, type Referrer, type WebContents } from 'electron'
import { framesMayOpen, pageMayOpen } from '../../shared/link-routing'
import {
type AltClickFound,
altClickOpens,
framesMayOpen,
isOpenableLink,
pageMayOpen,
} from '../../shared/link-routing'
import { type PageReferrer, popupTitle, referrerPolicyName, shownTabId } from '../../shared/nav'
import type { TabId, WindowId } from '../../shared/state'
import { sectionOf } from '../../shared/tree'
Expand Down Expand Up @@ -60,10 +66,8 @@ const linkAt = (x: number, y: number) => `(() => {
return isFrame(el) || el.shadowRoot ? { frame: true } : null
})()`

interface PageLink {
href?: string
interface PageLink extends AltClickFound {
policy?: string
frame?: boolean
}

/** An ⌥-click Orbit took over: the engine's download for the same link is cancelled when it comes. */
Expand All @@ -74,9 +78,6 @@ interface AltClick {
split: boolean
}

/** Links Orbit opens: web and file pages (not script, data or blob URLs). */
const openable = (url: string | undefined): url is string => !!url && /^(https?|file):/i.test(url)

const withoutHash = (url: string) => url.split('#')[0] ?? url

function hostOf(url: string | undefined): string {
Expand Down Expand Up @@ -133,7 +134,7 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks
// ---- ⇧-click: a new window (NV-08). Peek's own hook comes first and takes it when it's on. ----
ctx.hooks.windowOpen.push((openerTabId, details) => {
if (details.disposition !== 'new-window' || details.features) return undefined
if (!openable(details.url)) return undefined
if (!isOpenableLink(details.url)) return undefined
const from = { tabId: openerTabId, windowId: windows.windowOfTab(openerTabId) }
const load = { httpReferrer: details.referrer }
// The engine leaves the link to the browser: the page opens it itself (review B-F1).
Expand Down Expand Up @@ -177,18 +178,14 @@ export function registerLinks(ctx: MainContext, deps: LinksDeps): LinksTestHooks
}
if (contents.isDestroyed()) return
const until = Date.now() + ALT_CLICK_DOWNLOAD_MS
// A local file opens from a page that is a local file too, never from a web page (D49).
// A local file opens from a page that is a local file too, never from a web page or a frame (D49).
const page = contents.getURL()
if (found?.href) {
if (!openable(found.href) || !pageMayOpen(page, found.href)) return
altClicks.set(tabId, { url: found.href, until, split: false })
// Sent with the page as referrer, as the link's policy or the page's allows (review B-F1).
openInSplit(tabId, found.href, { url: page, policy: referrerPolicyName(found.policy) })
} else if (found?.frame && openable(fallback) && pageMayOpen(undefined, fallback)) {
// A link in a frame: the engine's download of it says which, and opens the split. The frame
// counts as a web page, whatever the page is: a local file opens from no frame (round 2, F2).
altClicks.set(tabId, { url: fallback, until, split: true })
}
const opens = altClickOpens(page, found, fallback)
if (!opens) return
altClicks.set(tabId, { url: opens.url, until, split: opens.split })
// A link in a frame waits for the engine's download of it, which says which and opens the split.
// Sent with the page as referrer, as the link's policy or the page's allows (review B-F1).
if (!opens.split) openInSplit(tabId, opens.url, { url: page, policy: referrerPolicyName(found?.policy) })
}

ctx.hooks.tabCreated.push((tabId, contents) => {
Expand Down
60 changes: 10 additions & 50 deletions src/main/nav/page-menu.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { join } from 'node:path'
import { app, type ContextMenuParams, dialog, type MenuItemConstructorOptions, type WebContents } from 'electron'
import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron'
import { framesMayOpen } from '../../shared/link-routing'
import { type PageReferrer, referrerPolicyName } from '../../shared/nav'
import { isPrivateTab } from '../../shared/private'
import type { TabId, WindowId } from '../../shared/state'
import type { MainContext } from '../context'
import { withDialog } from '../media/dialog-watch'
import { printPage, savePage, viewSource } from '../pages/entry'
import type { TabPage } from '../tabs/ops'
import { NAV_WORLD } from './focus'

Expand Down Expand Up @@ -134,17 +133,6 @@ export function writingDirectionItem(contents: WebContents): MenuItemConstructor
}
}

/** A file name from a page title, without characters file systems refuse. */
function fileName(title: string): string {
const printable = Array.from(title, (c) => (c.charCodeAt(0) < 32 ? ' ' : c)).join('')
const clean = printable
.replace(/[\\/:*?"<>|]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)
return clean || 'Page'
}

/** Any local file's address: what a complete save would read for a frame, asked of `framesMayOpen`. */
const LOCAL_FILE = 'file:///'

Expand All @@ -168,44 +156,16 @@ export function saveFormat(complete: boolean, filePath: string): 'HTMLComplete'
return /\.html?$/i.test(filePath) ? 'HTMLOnly' : 'MHTML'
}

/** Save Page As… (complete for a local page, else a single file; NV-21), Print… and View Page Source. */
export function pageItems(ctx: MainContext, tabId: TabId, contents: WebContents): MenuItemConstructorOptions[] {
/** Save Page As… (NV-21), Print… and View Page Source: the pages lane's, as their keys are. */
export function pageItems(contents: WebContents): MenuItemConstructorOptions[] {
return [
{
label: 'Save Page As…',
click: () => {
const win = ctx.windows.get(ctx.windows.windowOfTab(tabId) ?? '')
const complete = savesComplete(contents)
const name = join(app.getPath('downloads'), fileName(contents.getTitle()))
const single = { name: 'Web Page, Single File', extensions: ['mhtml'] }
const html = { name: complete ? 'Web Page, Complete' : 'Web Page, HTML Only', extensions: ['html', 'htm'] }
const options = complete
? { defaultPath: `${name}.html`, filters: [html, single] }
: { defaultPath: `${name}.mhtml`, filters: [single, html] }
void (win ? withDialog(win, dialog.showSaveDialog(win, options)) : dialog.showSaveDialog(options)).then(
({ canceled, filePath }) => {
if (canceled || !filePath || contents.isDestroyed()) return
// Asked again: the page may have changed while the dialog was open.
const format = saveFormat(complete && savesComplete(contents), filePath)
contents.savePage(filePath, format).catch((err) => console.error('[nav] save page', err))
},
)
},
},
{ label: 'Print…', click: () => contents.print() },
// Complete for a local page, else a single file (pages lane, src/main/pages/save-page.ts).
{ label: 'Save Page As…', click: () => savePage(contents) },
// Orbit's print dialog over the page (pages lane, NV-07).
{ label: 'Print…', click: () => printPage(contents) },
{ type: 'separator' },
{
label: 'View Page Source',
click: () => {
const url = contents.getURL()
if (!url || url.startsWith('view-source:')) return
const tab = ctx.store.state.tabs[tabId]
const windowId = ctx.windows.windowOfTab(tabId)
void ctx.commands
.run('tabs.create', { url: `view-source:${url}`, spaceId: tab?.spaceId, activate: true }, { windowId })
.catch(() => {})
},
},
// The page's source in a new tab (pages lane, DT-04).
{ label: 'View Page Source', click: () => viewSource(contents) },
]
}

Expand Down
19 changes: 19 additions & 0 deletions src/main/nav/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ const ALWAYS_ALLOW = new Set([
/** Device APIs: a site may ask, and the chooser asks the user for each device. */
const DEVICE_APIS = new Set(['serial', 'usb', 'hid'])

/** Who takes a page's link to another app (`openExternal`): the pages lane asks the user (NV-19). */
export type AppLinkHandler = (profileId: ProfileId, contents: WebContents, details: PermissionRequestDetails) => void
let appLinkHandler: AppLinkHandler | undefined

/**
* Sets who handles a page's links to other apps (`mailto:`, `zoommtg:`…; pages lane, NV-19). The
* engine never launches one itself: until a handler is set, each is refused.
*/
export function setAppLinkHandler(handler: AppLinkHandler): void {
appLinkHandler = handler
}

/** The prompt's card in its own view (sidebar hidden): width, and the shadow's margin around it. */
export const PROMPT_VIEW = { width: 320, margin: 12, inset: 10, estimate: 168 }

Expand Down Expand Up @@ -418,6 +430,13 @@ export function registerPermissions(ctx: MainContext, prefs: SitePrefs): Permiss
return kinds.every((k) => allowedNow(profileId, origin, k))
},
request: (profileId, contents, permission, callback, details) => {
if (permission === 'openExternal') {
// A link to another app: refused to the engine, which would launch it unasked; the pages
// lane asks the user and launches it itself (NV-19).
callback(false)
appLinkHandler?.(profileId, contents, details)
return
}
if (ALWAYS_ALLOW.has(permission)) return callback(true)
if (permission === 'fileSystem') return askFile(profileId, contents, details, callback)
const media = 'mediaTypes' in details ? details.mediaTypes : undefined
Expand Down
Loading