+
+
+
+ Good morning, Alex
+
+
+ Where focus finds flow.
+
+
+
+
+ Search the web or ELY
+
+
+
+
+ {[['Search Tabs', ], ['Switch Workspace', ], ['Open Notion', ]].map(([t,i],idx)=>(
+
+ {i}{t}
+
+ ))}
+
+
+
+ {/* favorites */}
+
+
+ Favorites
+
+
+ {[
+ [
, 'Notion'],
+ [, 'YouTube'],
+ [, 'Dribbble'],
+ [, 'X'],
+ [, 'Vercel'],
+ [, 'Behance'],
+ ].map(([g,n], i)=>(
+
+ ))}
+
+
+
+
+
+
+ {/* lower row */}
+
+
+
+
Continue where you left off
+
+ {[
+ [
, 'Design System – Figma', 'figma.com', '2 min ago'],
+ [, 'Roadmap 2024', 'linear.app', '1 hour ago'],
+ [
, 'Marketing Site', 'elybrowser.com', '3 hours ago'],
+ ].map(([icon, name, host, when], i)=>(
+
+ {icon}
+
+
{name}
+
{host} · {when}
+
+
+ ))}
+
+
+
+
+
+ Reading List · 5
+
+
+
The aesthetics of calm technology
+
Inside ELY Journal · 6 min read
+
+
+
+
+
+
Recent activity
+
+ {[
+ ['viewed', 'Pricing Page', 'elybrowser.com/pricing', '2m',
],
+ ['opened', 'Style Guide', 'figma.com/file/12345', '1h',
],
+ ['commented on', 'Roadmap 2024', 'linear.app/roadmap', '3h', ],
+ ].map(([verb, name, url, when, ic], i)=>(
+
+
{ic}
+
+
You {verb}
+
{name}
+
{url}
+
+
{when}
+
+ ))}
+
+
+ View all history
+
+
+
+
+
+);
+window.HomeScreen = HomeScreen;
diff --git a/ELY Browser Design/screens/plugins.jsx b/ELY Browser Design/screens/plugins.jsx
new file mode 100644
index 0000000..43dc923
--- /dev/null
+++ b/ELY Browser Design/screens/plugins.jsx
@@ -0,0 +1,159 @@
+// plugins.jsx — Plugin Marketplace
+const PluginsScreen = () => {
+ const cats = ['All', 'Productivity', 'Reading', 'Privacy', 'Developer', 'AI', 'Writing', 'Design'];
+ const plugins = [
+ { name:'Reader+', author:'ELY Studio', desc:'Distraction-free reading mode with serif typography & ambient backgrounds.', accent:'linear-gradient(135deg,#ffd1d8,#b9c7ff)', glyph:'¶', installs:'24.5k', rating:4.9, tags:['Featured','Reading'] },
+ { name:'Tab Capsules', author:'orbit.dev', desc:'Hibernate tabs into searchable capsules. Restore in one click — even months later.', accent:'linear-gradient(135deg,#c5f0db,#7c6cf7)', glyph:'◐', installs:'18.2k', rating:4.8, tags:['Productivity'] },
+ { name:'Coast Privacy Shield', author:'Coast Labs', desc:'Per-site tracker counts and one-tap container tabs.', accent:'linear-gradient(135deg,#1d1c1a,#7c6cf7)', glyph:'◊', dark:true, installs:'31.0k', rating:4.7, tags:['Privacy'] },
+ { name:'Quill', author:'paperhouse', desc:'Annotate any page. Notes sync to Notion, Obsidian, Bear.', accent:'linear-gradient(135deg,#fff3ee,#f4c8b8)', glyph:'✎', installs:'12.4k', rating:4.6, tags:['Writing'] },
+ { name:'ELY AI · Summon', author:'ELY Studio', desc:'Ask anything about the page. Beautiful inline citations.', accent:'linear-gradient(135deg,#d6c4ff,#ffd1d8)', glyph:'✦', installs:'47.3k', rating:4.9, tags:['AI','Featured'] },
+ { name:'Pomodoro Glass', author:'flux.app', desc:'Ambient timer that lives in your sidebar. Subtle, focused.', accent:'linear-gradient(135deg,#ffe5b8,#ec6a8e)', glyph:'◷', installs:'9.1k', rating:4.5, tags:['Productivity'] },
+ { name:'Localhost Console', author:'devhabit', desc:'Inline preview & logs for any dev port. Toggle from omnibar.', accent:'linear-gradient(135deg,#0e0d0c,#4fb59a)', glyph:'❯_', dark:true, installs:'7.6k', rating:4.7, tags:['Developer'] },
+ { name:'Lookbook', author:'studio.bento', desc:'Collect screenshots & swatches as you browse. Boards live in ELY.', accent:'linear-gradient(135deg,#ec6a8e,#7c6cf7)', glyph:'▥', installs:'14.0k', rating:4.8, tags:['Design'] },
+ ];
+ return (
+
+
+
+
+
ely://plugins /marketplace
+
+
+
+
+
+ {/* Hero */}
+
+
+
Plugins · Native to ELY
+
Quiet tools. Everyday magic.
+
A small, curated marketplace built for ELY — sandboxed, theme-aware, no Chrome extension framework required. Each plugin ships with its own tweakable controls in your sidebar.
+
+
+ Search 184 plugins…
+ ⌘K
+
+
+
+
Editor's pick
+
+
✦
+
+
ELY AI · Summon
+
by ELY Studio · 47.3k installs
+
Ask anything about the current page with citations.
+
+
+
+ Install
+ Open detail
+ ⭐︎ 4.9 · 1,241 reviews
+
+
+
+
+ {/* Cats */}
+
+ {cats.map((c,i)=>(
+ {c}
+ ))}
+ Sort: Most installed
+
+
+ {/* Plugin grid */}
+
+ {plugins.map((p,i)=>(
+
+
+
+
{p.glyph}
+ {p.tags.includes('Featured') &&
FEATURED }
+
+
+
+
{p.name}
+
{p.author}
+
+
Get
+
+
{p.desc}
+
+ ★ {p.rating}
+ ·
+ {p.installs} installs
+ Sandboxed
+
+
+ ))}
+
+
+ {/* Plugin detail (preview / featured below) */}
+
+
+
+
¶
+
+ Install · Free
+
+
+
Permissions
+
+ {[['Read content of pages you choose','✓'],['Store reading preferences locally','✓'],['Access network',' '],['Read browsing history',' ']].map(([t,c],i)=>(
+
+ {c}
+ {t}
+
+ ))}
+
+
+
+
+
+
+
Reading · Featured
+
Reader+
+
by ELY Studio · v2.4 · updated 3 days ago
+
+
+
★ 4.9
+
1,894 reviews
+
+
+
Reader+ pulls the article out of the noise. A serif type system, gentle ambient backgrounds, and a focus mode that quietly hides everything you don't need. Built native to ELY's plugin runtime — themes follow your wallpaper, settings live in the sidebar.
+
+
+ {[
+ ['Installs','24,521'],['Rating','4.9 / 5'],['Size','340 kB'],['Sandboxed','Yes']
+ ].map(([k,v])=>(
+
+ ))}
+
+
+
+
What it adds to ELY
+
+ {[
+ ['Reader sidebar', 'Outline, time-to-read, table of contents — themed to your wallpaper.'],
+ ['Ambient mode', 'Subtle background motion sourced from the article\'s lead image.'],
+ ['Pocket-style queue', 'Save for later that lives in ELY\'s reading list, not a third-party app.'],
+ ].map(([t,d],i)=>(
+
+ ))}
+
+
+
+
+
+
+
+);};
+window.PluginsScreen = PluginsScreen;
diff --git a/ELY Browser Design/screens/settings.jsx b/ELY Browser Design/screens/settings.jsx
new file mode 100644
index 0000000..0c2acc2
--- /dev/null
+++ b/ELY Browser Design/screens/settings.jsx
@@ -0,0 +1,138 @@
+// settings.jsx — Settings page
+const SettingsScreen = () => {
+ const SectionLabel = ({ children, k='label' }) => k==='label' ?
+
{children}
+ :
{children}
;
+ const NavItem = ({ icon, label, active }) => (
+
+ {icon} {label}
+
+ );
+ const Toggle = ({ on }) => (
+
+
+
+ );
+ const Row = ({ title, desc, children, last }) => (
+
+
+
{title}
+ {desc &&
{desc}
}
+
+ {children}
+
+ );
+ const Select = ({ value }) => (
+
{value}
+ );
+ return (
+
+
+
+
+
ely://settings /appearance
+
+
+
+
+
+
+
+
+
Settings
+
ELY 0.42 · macOS 14.4
+
+
General
+
} label="Appearance" active/>
+ } label="Sidebar"/>
+ } label="Tabs & Workspaces"/>
+ } label="Shortcuts"/>
+ Account
+ } label="Sync"/>
+ } label="Privacy & Security"/>
+ } label="Profiles"/>
+ Power
+ } label="Plugins"/>
+ } label="ELY AI"/>
+ } label="Developer"/>
+ About
+ } label="What's new"/>
+ } label="Help & feedback"/>
+
+
+
+
+
General
+
Appearance
+
Tune the atmosphere of your browser. ELY's theme follows the time of day by default — pick a wallpaper, then let it breathe.
+
+
+ {[
+ ['Dawn','#ffd1d8','#b9c7ff', true],
+ ['Violet','#d6c4ff','#ffe0c0'],
+ ['Mint','#c5f0db','#ffe5b8'],
+ ['Slate','#cfd6e2','#1d1c1a']
+ ].map(([n, a, b, sel])=>(
+
+
+
+ {n}
+ {sel && }
+
+
+ ))}
+
+
+
+
+
+ {['System','Light','Dark'].map((t,i)=>(
+ {t}
+ ))}
+
+
+
+
+ {['#c96442','#ec6a8e','#7c6cf7','#4fb59a','#1d1c1a'].map((c,i)=>(
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sidebar
+
Layout
+
+ {['Single column','Compact','Hidden on hover'].map((n,i)=>(
+
+
+
{n}
+
{['Default · workspace + tabs','Icons-only with tooltips','Slide in on cursor reach'][i]}
+
+ ))}
+
+
+
+
+
+
+);};
+window.SettingsScreen = SettingsScreen;
diff --git a/ELY Browser Design/screens/split.jsx b/ELY Browser Design/screens/split.jsx
new file mode 100644
index 0000000..91f4b41
--- /dev/null
+++ b/ELY Browser Design/screens/split.jsx
@@ -0,0 +1,105 @@
+// split.jsx — Split-view browsing
+const SplitScreen = () => {
+ const Pane = ({ title, host, children, accent }) => (
+
+
+
+
+ {host}
+ {title}
+
+
+
+
+
{children}
+
+ );
+ return (
+
, active:true, closable:true },
+ { id:'lin', label:'Roadmap 2024 — Linear', icon:, active:true },
+ { id:'gh', label:'elybrowser/core', icon: },
+ ] }}>
+
+
+
+
+
+ figma.com
+ /file/12345/Style-Guide
+
+
+
+
+
+ linear.app
+ /elybrowser/roadmap
+
+
+
+
+
+
+ {/* Left: Figma */}
+
+
+
+
+ Style Guide
+ / Typography
+
+ Share
+
+
+
+
+
+
The aesthetics of calm tech.
+
A type system tuned for long-form reading and slow, deliberate interfaces. Designed for ambient use.
+
+ {['#1d1c1a','#c96442','#ec6a8e','#7c6cf7','#4fb59a'].map(c=>(
))}
+
+
+
+
+
+ {/* Right: Linear */}
+
+
+
+
ELY
+ {['Inbox','My Issues','Triage'].map(l=>(
{l}
))}
+
Cycles
+ {['24 · Now','24 · Next','25 · Q1','25 · Q2'].map((l,i)=>(
{l}
))}
+
+
+
+ Roadmap 2024
+ · 18 issues
+ + New
+
+
+ {[
+ ['ELY-101','In Progress', 'Universal command bar', '#f5a623'],
+ ['ELY-102','In Progress', 'Vertical tabs · sticky pinning', '#f5a623'],
+ ['ELY-103','Todo', 'Cloudflare sync · device pairing', '#5e6ad2'],
+ ['ELY-104','Todo', 'Plugin SDK · v0', '#5e6ad2'],
+ ['ELY-105','Backlog', 'Reader mode · ambient theme', '#6b6660'],
+ ['ELY-106','Done', 'Workspace switching', '#4fb59a'],
+ ].map(([id,st,t,c],i)=>(
+
+
+ {id}
+ {t}
+ {st}
+
+
+ ))}
+
+
+
+
+
+
+);};
+window.SplitScreen = SplitScreen;
diff --git a/ELY Browser Design/screens/sync.jsx b/ELY Browser Design/screens/sync.jsx
new file mode 100644
index 0000000..8b67935
--- /dev/null
+++ b/ELY Browser Design/screens/sync.jsx
@@ -0,0 +1,111 @@
+// sync.jsx — Sync / Sign In page
+const SyncScreen = () => {
+ const Device = ({ name, model, when, current, icon }) => (
+
+
{icon}
+
+
{name}{current && This device }
+
{model} · {when}
+
+
Synced
+
+ );
+ return (
+
+
+
+
+
ely://settings /sync
+
+
+
+
+
+
+
+ Cloudflare Sync · end-to-end encrypted
+
+
Your tabs, on every device.
+
Sign in once and ELY keeps your tabs, workspaces, pinned items and history in sync — encrypted in your hands, mirrored at the edge.
+
+
+
+ Better Auth · ELY Account
+
+
Email
+
alex@elybrowser.com
+
+
Password
+
••••••••••
+
+
+ Sign in
+
+
+
+ {['Google','GitHub','Apple'].map(p=>(
+
+ {p==='GitHub' && }
+ {p==='Google' && }
+ {p==='Apple' && }
+ {p}
+
+ ))}
+
+
By signing in you accept the ELY terms & privacy .
+
+
+
+
+
+
+
+
Devices
+
3 connected
+
+
}/>
+ }/>
+ }/>
+
+ Pair a device
+ or scan QR with the ELY app
+
+
+
+
+
+
+
+
Recovery key
+
The only way to restore an end-to-end encrypted account. We don't keep a copy.
+
+
+
+ river · slate · candle · lantern orchid · meadow · whistle · ember
+
+
+ Copy
+ Download .txt
+ I've saved it
+
+
+
+
+
What syncs
+
+ {[['Tabs & pinned',true],['Workspaces',true],['History',true],['Bookmarks',true],['Reading list',true],['Plugins',true],['Passwords',false],['Open windows',false]].map(([n,on],i)=>(
+
+ {on? : }
+
+
+ ))}
+
+
+
+
+
+
+);};
+window.SyncScreen = SyncScreen;
diff --git a/ELY Browser Design/shared.jsx b/ELY Browser Design/shared.jsx
new file mode 100644
index 0000000..e91e1d2
--- /dev/null
+++ b/ELY Browser Design/shared.jsx
@@ -0,0 +1,272 @@
+// shared.jsx — icons + sidebar + browser shell shared across screens
+// All Lucide-style 1.5px stroke SVG.
+
+const Icon = ({ d, size = 16, fill = 'none', stroke = 'currentColor', sw = 1.5, children, style }) => (
+
+ {d ? : children}
+
+);
+
+const I = {
+ ArrowLeft: (p)=>
,
+ ArrowRight: (p)=>
,
+ ArrowUpRight:(p)=>
,
+ Plus: (p)=>
,
+ X: (p)=>
,
+ Search: (p)=>
,
+ Star: (p)=>
,
+ Sparkle: (p)=>
,
+ Sun: (p)=>
,
+ Moon: (p)=>
,
+ Sunrise: (p)=>
,
+ Settings: (p)=>
,
+ Menu: (p)=>
,
+ Filters: (p)=>
,
+ Bookmark: (p)=>
,
+ Copy: (p)=>
,
+ Download: (p)=>
,
+ ChevronDown:(p)=>
,
+ ChevronRight:(p)=>
,
+ ChevronUp: (p)=>
,
+ Layers: (p)=>
,
+ Home: (p)=>
,
+ Calendar: (p)=>
,
+ Globe: (p)=>
,
+ Lock: (p)=>
,
+ Sidebar: (p)=>
,
+ Split: (p)=>
,
+ Cmd: (p)=>
,
+ Reload: (p)=>
,
+ Pin: (p)=>
,
+ Tab: (p)=>
,
+ Inbox: (p)=>
,
+ Trash: (p)=>
,
+ Eye: (p)=>
,
+ Mic: (p)=>
,
+ Lightning: (p)=>
,
+ Shield: (p)=>
,
+ Cloud: (p)=>
,
+ Plug: (p)=>
,
+ Heart: (p)=>
,
+ Check: (p)=>
,
+ Dot: (p)=>
,
+ Key: (p)=>
,
+ Devices: (p)=>
,
+ User: (p)=>
,
+ Bell: (p)=>
,
+ Palette: (p)=>
,
+ Code: (p)=>
,
+ Filter: (p)=>
,
+};
+
+/* ===== little brand glyphs (faux app icons) ===== */
+const Brand = {
+ Notion: ({s=22}) => (
+
N
+ ),
+ YT: ({s=22}) => (
+
+ ),
+ Dribbble: ({s=22}) => (
+
·
+ ),
+ X: ({s=22}) => (
+
𝕏
+ ),
+ Vercel: ({s=22}) => (
+
+ ),
+ Behance: ({s=22}) => (
+
Bē
+ ),
+ Figma: ({s=18}) => (
+
+ ),
+ Slack: ({s=18}) => (
+
+ ),
+ GitHub: ({s=18}) => (
+
+ ),
+ Linear: ({s=18}) => (
+
L
+ ),
+};
+
+/* ============== SIDEBAR ============== */
+const Sidebar = ({ activeId = 'home', mode = 'home', tabs, withTrafficTitle = true, workspace='Work', accentEmoji='🌸' }) => {
+ const defaultTabs = [
+ { id:'figma', label:'Design System – Figma', icon:
, closable:true },
+ { id:'roadmap', label:'Roadmap 2024', icon:
},
+ { id:'mkt', label:'Marketing Site', icon: },
+ ];
+ const t = tabs || defaultTabs;
+ return (
+
+ {withTrafficTitle && (
+
+
+ ELY Browser
+
+ )}
+
+
+
+
+
+
+
+
+
+ {accentEmoji}
+ {workspace}
+
+
+
+
+
+
+
+
+ Home
+
+
+ 31
+ Calendar
+
+
+
+ Slack
+ 12
+
+
+
+ GitHub
+
+
+
+ Linear
+ 3
+
+
+
+ Pinned
+
+
+
+ Design System – Figma
+
+
+
+
ELY Browser – Home
+
+
+
+
+ Release Notes
+
+
+
+
+
+ Tabs · {t.length}
+
+ {t.map(tab => (
+
+ {tab.icon}
+ {tab.label}
+ {tab.audio && }
+
+
+ ))}
+
+
+ New Tab
+
+
+
+
+
+ );
+};
+
+/* Browser shell that hosts a sidebar + main area */
+const ElyShell = ({ wallpaper='dawn', children, sidebarProps={}, fakeTraffic=true }) => (
+
+);
+
+/* topbar */
+const TopBar = ({ url, host, path, secure=true, right, canBack=true, canForward=false }) => (
+
+
+
+
+ {secure ? : }
+ {host ? (
+ <>
+ {host}
+ {path && {path} }
+ >
+ ) : (
+ {url || 'Search ELY or type a command…'}
+ )}
+
+
+
+
+
+ {right || (
+ <>
+
+
+
+
+ >
+ )}
+
+);
+
+window.Sidebar = Sidebar;
+window.ElyShell = ElyShell;
+window.TopBar = TopBar;
+window.Icon = Icon;
+window.I = I;
+window.Brand = Brand;
diff --git a/ELY Browser Design/styles.css b/ELY Browser Design/styles.css
new file mode 100644
index 0000000..c2de864
--- /dev/null
+++ b/ELY Browser Design/styles.css
@@ -0,0 +1,302 @@
+/* ELY Browser – global tokens & shared chrome */
+
+@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;450;500;600;700&family=Geist+Mono:wght@400;500&display=swap');
+
+:root {
+ --ely-ink: #1d1c1a;
+ --ely-ink-2: #3a3733;
+ --ely-ink-3: #6b6660;
+ --ely-ink-4: #9a948c;
+ --ely-ink-5: #c8c2b8;
+
+ --ely-glass: rgba(255,255,255,0.62);
+ --ely-glass-2: rgba(255,255,255,0.78);
+ --ely-glass-3: rgba(255,255,255,0.92);
+ --ely-tint: rgba(255,255,255,0.42);
+ --ely-stroke: rgba(40,30,20,0.08);
+ --ely-stroke-2: rgba(40,30,20,0.04);
+ --ely-divider: rgba(40,30,20,0.06);
+
+ --ely-accent: #c96442; /* warm clay */
+ --ely-accent-2: #d97757;
+ --ely-violet: #7c6cf7;
+ --ely-rose: #ec6a8e;
+ --ely-mint: #4fb59a;
+
+ --ely-radius-card: 18px;
+ --ely-radius-pane: 14px;
+ --ely-radius-pill: 999px;
+
+ --ely-shadow-window: 0 30px 70px -20px rgba(40,30,20,0.35), 0 0 0 1px rgba(255,255,255,0.5) inset, 0 1px 0 rgba(255,255,255,0.7) inset;
+ --ely-shadow-card: 0 8px 24px -10px rgba(40,30,20,0.18), 0 0 0 1px var(--ely-stroke) inset;
+ --ely-shadow-soft: 0 1px 0 rgba(255,255,255,0.7) inset, 0 0 0 1px var(--ely-stroke);
+
+ --ely-font-sans: 'Geist', ui-sans-serif, sans-serif;
+ --ely-font-serif: 'Newsreader', 'Source Serif 4', Georgia, serif;
+ --ely-font-mono: 'Geist Mono', ui-monospace, monospace;
+}
+
+/* ============== BROWSER FRAME ============== */
+
+.ely-window {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ font-family: var(--ely-font-sans);
+ color: var(--ely-ink);
+ font-feature-settings: 'cv11', 'ss01';
+ letter-spacing: -0.01em;
+ overflow: hidden;
+ border-radius: 14px;
+ isolation: isolate;
+}
+
+/* the dreamy gradient wallpaper */
+.ely-wallpaper {
+ position: absolute; inset: 0;
+ background:
+ radial-gradient(60% 50% at 85% 18%, #ffd1d8 0%, rgba(255,209,216,0) 70%),
+ radial-gradient(55% 60% at 15% 95%, #b9c7ff 0%, rgba(185,199,255,0) 75%),
+ radial-gradient(80% 80% at 50% 50%, #fff3ee 0%, #efe8e1 100%);
+ z-index: 0;
+}
+.ely-wallpaper.ely-wp-dawn {
+ background:
+ radial-gradient(60% 50% at 85% 18%, #ffd1d8 0%, rgba(255,209,216,0) 70%),
+ radial-gradient(55% 60% at 15% 95%, #b9c7ff 0%, rgba(185,199,255,0) 75%),
+ radial-gradient(80% 80% at 50% 50%, #fff3ee 0%, #efe8e1 100%);
+}
+.ely-wallpaper.ely-wp-violet {
+ background:
+ radial-gradient(50% 50% at 80% 20%, #d6c4ff 0%, rgba(214,196,255,0) 70%),
+ radial-gradient(50% 50% at 12% 90%, #ffe0c0 0%, rgba(255,224,192,0) 70%),
+ radial-gradient(80% 80% at 50% 50%, #f4eef9 0%, #e9e2ee 100%);
+}
+.ely-wallpaper.ely-wp-mint {
+ background:
+ radial-gradient(50% 50% at 85% 25%, #c5f0db 0%, rgba(197,240,219,0) 70%),
+ radial-gradient(55% 55% at 10% 95%, #ffe5b8 0%, rgba(255,229,184,0) 70%),
+ radial-gradient(80% 80% at 50% 50%, #f0f5ee 0%, #e6ece2 100%);
+}
+
+.ely-shell {
+ position: absolute;
+ inset: 16px;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: 256px 1fr;
+ gap: 12px;
+}
+
+/* ============== SIDEBAR ============== */
+
+.ely-sidebar {
+ background: var(--ely-glass);
+ backdrop-filter: blur(28px) saturate(140%);
+ -webkit-backdrop-filter: blur(28px) saturate(140%);
+ border-radius: var(--ely-radius-card);
+ box-shadow: var(--ely-shadow-window);
+ display: flex;
+ flex-direction: column;
+ padding: 10px 10px 12px;
+ gap: 10px;
+ position: relative;
+}
+
+.ely-traffic {
+ display: flex; gap: 8px; padding: 6px 6px 4px;
+ align-items: center;
+}
+.ely-traffic .dot { width: 12px; height: 12px; border-radius: 50%; box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.18); }
+.ely-traffic .dot.red { background: #ff5f57; }
+.ely-traffic .dot.yellow { background: #febc2e; }
+.ely-traffic .dot.green { background: #28c840; }
+.ely-traffic .title {
+ margin-left: 8px;
+ font-size: 12.5px; font-weight: 500;
+ color: var(--ely-ink-2);
+ display: inline-flex; align-items: center; gap: 4px;
+}
+.ely-traffic .title svg { opacity: .6; }
+
+.ely-ws-row {
+ display: flex; align-items: center; gap: 6px;
+ padding: 4px 2px;
+}
+.ely-ws-tile {
+ width: 32px; height: 32px;
+ border-radius: 9px;
+ background: linear-gradient(135deg, #fff 0%, #ffe9e0 100%);
+ display: flex; align-items: center; justify-content: center;
+ box-shadow: var(--ely-shadow-soft);
+ color: var(--ely-accent);
+ flex: 0 0 auto;
+}
+.ely-ws-pick {
+ flex: 1; min-width: 0;
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 8px;
+ border-radius: 9px;
+ background: rgba(255,255,255,0.6);
+ box-shadow: var(--ely-shadow-soft);
+ font-size: 13px; font-weight: 500; color: var(--ely-ink);
+}
+.ely-ws-pick .ws-glyph {
+ width: 18px; height: 18px; border-radius: 5px;
+ background: linear-gradient(135deg, #ec6a8e, #c96442);
+ display: inline-flex; align-items: center; justify-content: center;
+ color: #fff; font-size: 10px;
+}
+.ely-ws-pick .grow { flex: 1; }
+.ely-ws-pick svg { color: var(--ely-ink-3); }
+.ely-ws-add {
+ width: 32px; height: 32px;
+ border-radius: 9px;
+ background: rgba(255,255,255,0.4);
+ box-shadow: var(--ely-shadow-soft);
+ display: flex; align-items: center; justify-content: center;
+ color: var(--ely-ink-3);
+ flex: 0 0 auto;
+}
+
+.ely-nav { display: flex; flex-direction: column; gap: 1px; padding: 4px 0 0; }
+.ely-nav-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 7px 10px;
+ border-radius: 8px;
+ font-size: 13px;
+ color: var(--ely-ink-2);
+ position: relative;
+}
+.ely-nav-item .ico {
+ width: 18px; height: 18px; flex: 0 0 auto;
+ display: inline-flex; align-items: center; justify-content: center;
+ color: var(--ely-ink-3);
+}
+.ely-nav-item .ico-tile {
+ width: 18px; height: 18px; flex: 0 0 auto;
+ border-radius: 4px;
+ background: #fff; box-shadow: 0 0 0 1px var(--ely-stroke);
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 10px; font-weight: 600;
+}
+.ely-nav-item .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ely-nav-item .badge {
+ font-size: 10px;
+ background: rgba(40,30,20,0.06);
+ color: var(--ely-ink-3);
+ padding: 1px 6px;
+ border-radius: 999px;
+}
+.ely-nav-item .dot-unread {
+ width: 6px; height: 6px; border-radius: 50%;
+ background: var(--ely-accent);
+}
+.ely-nav-item.active {
+ background: rgba(255,255,255,0.85);
+ color: var(--ely-ink);
+ box-shadow: var(--ely-shadow-soft);
+}
+.ely-nav-item .close {
+ width: 16px; height: 16px;
+ display: inline-flex; align-items: center; justify-content: center;
+ border-radius: 4px;
+ color: var(--ely-ink-4);
+ opacity: 0;
+}
+.ely-nav-item:hover .close { opacity: 1; }
+
+.ely-sec-label {
+ font-size: 10.5px;
+ font-weight: 500;
+ color: var(--ely-ink-4);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ display: flex; align-items: center; gap: 6px;
+ padding: 8px 10px 4px;
+}
+
+.ely-sidebar-foot {
+ margin-top: auto;
+ display: flex; flex-direction: column; gap: 2px;
+ padding-top: 8px;
+ border-top: 1px solid var(--ely-divider);
+}
+.ely-profile-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 6px 10px;
+ border-radius: 9px;
+}
+.ely-avatar {
+ width: 26px; height: 26px; border-radius: 50%;
+ background: linear-gradient(135deg, #f4c8b8, #c96442);
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 10px; font-weight: 600; color: #fff;
+ flex: 0 0 auto;
+}
+.ely-profile-row .name { font-size: 13px; font-weight: 500; flex: 1; }
+
+/* ============== MAIN PANE ============== */
+
+.ely-main {
+ background: var(--ely-glass);
+ backdrop-filter: blur(28px) saturate(140%);
+ -webkit-backdrop-filter: blur(28px) saturate(140%);
+ border-radius: var(--ely-radius-card);
+ box-shadow: var(--ely-shadow-window);
+ display: flex; flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+
+.ely-topbar {
+ height: 54px;
+ display: flex; align-items: center; gap: 8px;
+ padding: 0 14px;
+ border-bottom: 1px solid var(--ely-divider);
+ flex: 0 0 auto;
+}
+.ely-iconbtn {
+ width: 30px; height: 30px;
+ border-radius: 8px;
+ display: inline-flex; align-items: center; justify-content: center;
+ color: var(--ely-ink-3);
+ background: transparent;
+ border: 0;
+}
+.ely-iconbtn:hover { background: rgba(255,255,255,0.55); color: var(--ely-ink); }
+.ely-iconbtn.is-on { background: rgba(255,255,255,0.85); color: var(--ely-ink); box-shadow: var(--ely-shadow-soft); }
+.ely-iconbtn.disabled { color: var(--ely-ink-5); }
+
+.ely-omnibar {
+ flex: 1;
+ height: 36px;
+ display: flex; align-items: center; gap: 10px;
+ background: rgba(255,255,255,0.55);
+ border-radius: var(--ely-radius-pill);
+ padding: 0 14px;
+ font-size: 13px;
+ color: var(--ely-ink-3);
+ box-shadow: var(--ely-shadow-soft);
+}
+.ely-omnibar .url-host { color: var(--ely-ink); font-weight: 500; }
+.ely-omnibar .url-path { color: var(--ely-ink-3); }
+.ely-omnibar .right { margin-left: auto; display: flex; align-items: center; gap: 4px; color: var(--ely-ink-4); }
+
+/* generic glass card */
+.ely-card {
+ background: var(--ely-glass-2);
+ border-radius: 16px;
+ box-shadow: var(--ely-shadow-card);
+}
+
+/* utility */
+.ely-row { display: flex; align-items: center; }
+.ely-col { display: flex; flex-direction: column; }
+.ely-grow { flex: 1; min-width: 0; }
+.ely-mono { font-family: var(--ely-font-mono); }
+.ely-serif { font-family: var(--ely-font-serif); }
+
+/* scrollbar invisibilize within artboards (already done by canvas) */
+.ely-scroll::-webkit-scrollbar { display: none; }
diff --git a/ELY Browser Design/uploads/pasted-1778197597361-0.png b/ELY Browser Design/uploads/pasted-1778197597361-0.png
new file mode 100644
index 0000000..c15251a
Binary files /dev/null and b/ELY Browser Design/uploads/pasted-1778197597361-0.png differ
diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs
index 3fa3b39..0a7da45 100644
--- a/crates/ely_app/src/services/mod.rs
+++ b/crates/ely_app/src/services/mod.rs
@@ -4,10 +4,9 @@ pub mod http_downloads;
pub mod plugin_package_store;
pub mod plugin_packages;
pub mod plugin_signatures;
-mod servo_profile_data;
-pub mod servo_sidecar;
+pub mod servo_live;
+pub(crate) mod servo_profile_data;
mod servo_sidecar_command;
-mod servo_sidecar_request;
pub(crate) use servo_profile_data::ProfileDataMode;
diff --git a/crates/ely_app/src/services/servo_profile_data.rs b/crates/ely_app/src/services/servo_profile_data.rs
index 3f1c50f..ba577bf 100644
--- a/crates/ely_app/src/services/servo_profile_data.rs
+++ b/crates/ely_app/src/services/servo_profile_data.rs
@@ -11,16 +11,16 @@ const ELY_QUALIFIER: &str = "com";
const ELY_ORGANIZATION: &str = "elydora";
const ELY_APPLICATION: &str = "ELY Browser";
-pub(super) fn default_profile_data_root() -> Option {
+pub(crate) fn default_profile_data_root() -> Option {
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
.map(|project_dirs| project_dirs.data_dir().join("profiles"))
}
-pub(super) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
+pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
profile_data_root.join(profile_id.as_str()).join("servo")
}
-pub(super) fn transient_profile_data_dir(
+pub(crate) fn transient_profile_data_dir(
profile_id: &ProfileId,
) -> Result {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs
deleted file mode 100644
index ea55e51..0000000
--- a/crates/ely_app/src/services/servo_sidecar.rs
+++ /dev/null
@@ -1,472 +0,0 @@
-use std::{
- env, fs, io,
- path::{Path, PathBuf},
- process::{Command, Output, Stdio},
- thread,
- time::{Duration, Instant, SystemTime, SystemTimeError, UNIX_EPOCH},
-};
-
-use ely_domain::ProfileId;
-use serde::Deserialize;
-use thiserror::Error;
-
-pub use super::servo_sidecar_request::{SidecarSitePermission, SidecarSnapshotRequest};
-
-use super::{
- servo_profile_data::{
- ProfileDataMode, default_profile_data_root, profile_data_dir, transient_profile_data_dir,
- },
- servo_sidecar_command::{SidecarCommandTarget, default_sidecar_command},
-};
-
-const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20);
-const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250);
-const SIDECAR_NAVIGATION_ATTEMPTS: usize = 3;
-const SIDECAR_INTERACTION_ATTEMPTS: usize = 1;
-
-#[derive(Clone, Debug)]
-pub struct ServoSidecarClient {
- command_target: SidecarCommandTarget,
- profile_data_root: PathBuf,
-}
-
-impl ServoSidecarClient {
- pub fn new() -> Result {
- Ok(Self {
- command_target: default_sidecar_command()?,
- profile_data_root: default_profile_data_root()
- .ok_or(ServoSidecarError::ProfileDataRootUnavailable)?,
- })
- }
-
- pub fn snapshot(
- &self,
- request: SidecarSnapshotRequest,
- ) -> Result {
- if let Some(path) = self.command_target.missing_binary_path() {
- return Err(ServoSidecarError::SidecarBinaryUnavailable { path: path.to_path_buf() });
- }
-
- let mut attempt = 0;
- loop {
- match self.snapshot_once(&request) {
- Ok(snapshot) => return Ok(snapshot),
- Err(error) => {
- attempt += 1;
- if attempt >= request.max_attempts() {
- return Err(error);
- }
- thread::sleep(SIDECAR_RETRY_INTERVAL);
- }
- }
- }
- }
-
- fn snapshot_once(
- &self,
- request: &SidecarSnapshotRequest,
- ) -> Result {
- let rgba_path = temporary_rgba_path()?;
- let profile_data_dir = request.profile_data_dir(&self.profile_data_root)?;
- let output = match self.run_snapshot_command(request, &rgba_path, &profile_data_dir) {
- Ok(output) => output,
- Err(error) => {
- return cleanup_failed_snapshot(request, &rgba_path, &profile_data_dir, error);
- }
- };
-
- if !output.status.success() {
- let error = ServoSidecarError::SidecarFailed {
- status: output.status.to_string(),
- stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
- };
- return cleanup_failed_snapshot(request, &rgba_path, &profile_data_dir, error);
- }
-
- let snapshot = read_sidecar_snapshot(&output.stdout, &rgba_path, &request.profile_id);
- let cleanup = remove_temporary_file(&rgba_path);
- let profile_cleanup = request.cleanup_profile_data_dir(&profile_data_dir);
- match (snapshot, cleanup, profile_cleanup) {
- (Ok(snapshot), Ok(()), Ok(())) => Ok(snapshot),
- (Err(error), Ok(()), Ok(())) => Err(error),
- (Ok(_), Err(error), _) | (Err(_), Err(error), _) => Err(error),
- (Ok(_), Ok(()), Err(error)) | (Err(_), Ok(()), Err(error)) => Err(error),
- }
- }
-
- fn run_snapshot_command(
- &self,
- request: &SidecarSnapshotRequest,
- rgba_path: &Path,
- profile_data_dir: &Path,
- ) -> Result {
- let mut command = self.command_target.command();
- append_snapshot_args(&mut command, request, rgba_path, profile_data_dir);
- if let Some(click_point) = request.click_point {
- command
- .arg("--click-x")
- .arg(click_point.x.to_string())
- .arg("--click-y")
- .arg(click_point.y.to_string());
- }
- if let Some(typed_text) = request.typed_text.as_deref() {
- command.arg("--type-text").arg(typed_text);
- }
- for permission in &request.site_permissions {
- command.arg("--site-permission").arg(permission.to_arg());
- }
-
- let mut child = command
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .map_err(ServoSidecarError::Command)?;
-
- let started_at = Instant::now();
- loop {
- if child.try_wait().map_err(ServoSidecarError::Command)?.is_some() {
- return child.wait_with_output().map_err(ServoSidecarError::Command);
- }
-
- let timeout = self.command_target.timeout();
- if started_at.elapsed() >= timeout {
- terminate_child(child)?;
- return Err(ServoSidecarError::SidecarTimedOut {
- url: request.url.as_str().to_string(),
- seconds: timeout.as_secs(),
- });
- }
-
- thread::sleep(SIDECAR_POLL_INTERVAL);
- }
- }
-}
-
-fn append_snapshot_args(
- command: &mut Command,
- request: &SidecarSnapshotRequest,
- rgba_path: &Path,
- profile_data_dir: &Path,
-) {
- command
- .arg("snapshot")
- .arg("--url")
- .arg(request.url.as_str())
- .arg("--profile-id")
- .arg(request.profile_id.as_str())
- .arg("--profile-data-dir")
- .arg(profile_data_dir)
- .arg("--rgba-out")
- .arg(rgba_path)
- .arg("--width")
- .arg(request.width.to_string())
- .arg("--height")
- .arg(request.height.to_string())
- .arg("--scroll-x")
- .arg(request.scroll_x.to_string())
- .arg("--scroll-y")
- .arg(request.scroll_y.to_string())
- .arg("--page-zoom-percent")
- .arg(request.page_zoom_percent.to_string());
-}
-
-impl SidecarSnapshotRequest {
- fn profile_data_dir(&self, profile_data_root: &Path) -> Result {
- match self.profile_data_mode {
- ProfileDataMode::Persistent => {
- Ok(profile_data_dir(profile_data_root, &self.profile_id))
- }
- ProfileDataMode::Transient => {
- transient_profile_data_dir(&self.profile_id).map_err(ServoSidecarError::SystemClock)
- }
- }
- }
-
- fn cleanup_profile_data_dir(&self, profile_data_dir: &Path) -> Result<(), ServoSidecarError> {
- if self.profile_data_mode == ProfileDataMode::Persistent {
- return Ok(());
- }
-
- remove_temporary_directory(profile_data_dir)
- }
-
- fn max_attempts(&self) -> usize {
- if self.click_point.is_some() || self.typed_text.is_some() {
- return SIDECAR_INTERACTION_ATTEMPTS;
- }
-
- SIDECAR_NAVIGATION_ATTEMPTS
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct SidecarSnapshot {
- loaded_url: Option,
- title: Option,
- render_state: String,
- width: u32,
- height: u32,
- #[cfg(test)]
- non_white_pixel_count: u64,
- #[cfg(test)]
- content_pixel_count: u64,
- #[cfg(test)]
- sample_hash: u64,
- rgba_bytes: Vec,
-}
-
-impl SidecarSnapshot {
- fn from_report(
- report: SidecarReport,
- expected_profile_id: &ProfileId,
- rgba_bytes: Vec,
- ) -> Result {
- let expected_byte_count = expected_rgba_byte_count(report.width, report.height)?;
- if report.profile_id != expected_profile_id.as_str() {
- return Err(ServoSidecarError::ProfileMismatch {
- expected: expected_profile_id.as_str().to_string(),
- actual: report.profile_id,
- });
- }
- if !is_renderable_state(&report.state) {
- return Err(ServoSidecarError::IncompleteRender { state: report.state });
- }
- if report.rgba_byte_count != expected_byte_count || rgba_bytes.len() != expected_byte_count
- {
- return Err(ServoSidecarError::RgbaByteCountMismatch {
- expected: expected_byte_count,
- reported: report.rgba_byte_count,
- actual: rgba_bytes.len(),
- });
- }
- if report.non_white_pixel_count == 0 {
- return Err(ServoSidecarError::BlankRenderedFrame {
- requested_url: report.requested_url,
- });
- }
- if report.content_pixel_count == 0 {
- return Err(ServoSidecarError::ContentlessRenderedFrame {
- requested_url: report.requested_url,
- });
- }
-
- Ok(Self {
- loaded_url: report.loaded_url,
- title: report.title,
- render_state: report.state,
- width: report.width,
- height: report.height,
- #[cfg(test)]
- non_white_pixel_count: report.non_white_pixel_count,
- #[cfg(test)]
- content_pixel_count: report.content_pixel_count,
- #[cfg(test)]
- sample_hash: report.sample_hash,
- rgba_bytes,
- })
- }
-
- #[must_use]
- pub fn loaded_url(&self) -> Option<&str> {
- self.loaded_url.as_deref()
- }
-
- #[must_use]
- pub fn title(&self) -> Option<&str> {
- self.title.as_deref()
- }
-
- #[must_use]
- pub fn render_state(&self) -> &str {
- self.render_state.as_str()
- }
-
- #[must_use]
- pub fn width(&self) -> u32 {
- self.width
- }
-
- #[must_use]
- pub fn height(&self) -> u32 {
- self.height
- }
-
- #[cfg(all(test, feature = "live-site-smoke"))]
- #[must_use]
- pub(crate) fn non_white_pixel_count(&self) -> u64 {
- self.non_white_pixel_count
- }
-
- #[cfg(all(test, feature = "live-site-smoke"))]
- #[must_use]
- pub(crate) fn content_pixel_count(&self) -> u64 {
- self.content_pixel_count
- }
-
- #[cfg(all(test, feature = "live-site-smoke"))]
- #[must_use]
- pub(crate) fn sample_hash(&self) -> u64 {
- self.sample_hash
- }
-
- #[must_use]
- pub fn into_rgba_bytes(self) -> Vec {
- self.rgba_bytes
- }
-}
-
-#[derive(Debug, Error)]
-pub enum ServoSidecarError {
- #[error("current executable path is unavailable: {0}")]
- CurrentExecutable(#[source] io::Error),
-
- #[error("current executable directory is unavailable for {path}")]
- CurrentExecutableDirectoryUnavailable { path: PathBuf },
-
- #[error("servo sidecar binary is unavailable at {path}")]
- SidecarBinaryUnavailable { path: PathBuf },
-
- #[error("temporary frame directory is unavailable: {0}")]
- TempDirectory(#[source] io::Error),
-
- #[error("profile data root is unavailable")]
- ProfileDataRootUnavailable,
-
- #[error("system clock is before UNIX epoch")]
- SystemClock(#[from] SystemTimeError),
-
- #[error("failed to run servo sidecar: {0}")]
- Command(#[source] io::Error),
-
- #[error("servo sidecar exited with {status}: {stderr}")]
- SidecarFailed { status: String, stderr: String },
-
- #[error("servo sidecar timed out after {seconds}s while rendering {url}")]
- SidecarTimedOut { url: String, seconds: u64 },
-
- #[error("failed to parse servo sidecar report: {0}")]
- Report(#[from] serde_json::Error),
-
- #[error("failed to read servo frame file: {0}")]
- FrameRead(#[source] io::Error),
-
- #[error("failed to remove servo frame file: {0}")]
- FrameCleanup(#[source] io::Error),
-
- #[error("failed to remove transient servo profile data at {path}: {source}")]
- ProfileDataCleanup { path: PathBuf, source: io::Error },
-
- #[error("servo sidecar returned incomplete render state: {state}")]
- IncompleteRender { state: String },
-
- #[error("servo sidecar returned profile {actual}, expected {expected}")]
- ProfileMismatch { expected: String, actual: String },
-
- #[error(
- "servo frame byte count mismatch: expected {expected}, reported {reported}, actual {actual}"
- )]
- RgbaByteCountMismatch { expected: usize, reported: usize, actual: usize },
-
- #[error("servo frame dimensions overflow byte count: {width}x{height}")]
- RgbaByteCountOverflow { width: u32, height: u32 },
-
- #[error("servo rendered a blank frame for {requested_url}")]
- BlankRenderedFrame { requested_url: String },
-
- #[error("servo rendered a frame without visible content for {requested_url}")]
- ContentlessRenderedFrame { requested_url: String },
-}
-
-fn is_renderable_state(state: &str) -> bool {
- matches!(state, "complete" | "loading")
-}
-
-#[derive(Deserialize)]
-struct SidecarReport {
- requested_url: String,
- profile_id: String,
- loaded_url: Option,
- title: Option,
- state: String,
- width: u32,
- height: u32,
- rgba_byte_count: usize,
- non_white_pixel_count: u64,
- content_pixel_count: u64,
- #[cfg(test)]
- sample_hash: u64,
-}
-
-fn temporary_rgba_path() -> Result {
- let directory = env::temp_dir().join("ely-browser-servo");
- fs::create_dir_all(&directory).map_err(ServoSidecarError::TempDirectory)?;
- let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
- Ok(directory.join(format!("frame-{}-{timestamp}.rgba", std::process::id())))
-}
-
-fn remove_temporary_file(path: &Path) -> Result<(), ServoSidecarError> {
- match fs::remove_file(path) {
- Ok(()) => Ok(()),
- Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
- Err(error) => Err(ServoSidecarError::FrameCleanup(error)),
- }
-}
-
-fn remove_temporary_directory(path: &Path) -> Result<(), ServoSidecarError> {
- match fs::remove_dir_all(path) {
- Ok(()) => Ok(()),
- Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
- Err(error) => {
- Err(ServoSidecarError::ProfileDataCleanup { path: path.to_path_buf(), source: error })
- }
- }
-}
-
-fn cleanup_failed_snapshot(
- request: &SidecarSnapshotRequest,
- rgba_path: &Path,
- profile_data_dir: &Path,
- snapshot_error: ServoSidecarError,
-) -> Result {
- let frame_cleanup = remove_temporary_file(rgba_path);
- let profile_cleanup = request.cleanup_profile_data_dir(profile_data_dir);
- match (frame_cleanup, profile_cleanup) {
- (Ok(()), Ok(())) => Err(snapshot_error),
- (Err(error), _) => Err(error),
- (Ok(()), Err(error)) => Err(error),
- }
-}
-
-fn terminate_child(mut child: std::process::Child) -> Result<(), ServoSidecarError> {
- match child.kill() {
- Ok(()) => {
- let _output = child.wait_with_output().map_err(ServoSidecarError::Command)?;
- Ok(())
- }
- Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
- Err(error) => Err(ServoSidecarError::Command(error)),
- }
-}
-
-fn read_sidecar_snapshot(
- stdout: &[u8],
- rgba_path: &Path,
- expected_profile_id: &ProfileId,
-) -> Result {
- let report: SidecarReport = serde_json::from_slice(stdout)?;
- let rgba_bytes = fs::read(rgba_path).map_err(ServoSidecarError::FrameRead)?;
- SidecarSnapshot::from_report(report, expected_profile_id, rgba_bytes)
-}
-
-fn expected_rgba_byte_count(width: u32, height: u32) -> Result {
- let byte_count = u64::from(width)
- .checked_mul(u64::from(height))
- .and_then(|pixels| pixels.checked_mul(4))
- .ok_or(ServoSidecarError::RgbaByteCountOverflow { width, height })?;
- usize::try_from(byte_count)
- .map_err(|_| ServoSidecarError::RgbaByteCountOverflow { width, height })
-}
-
-#[cfg(test)]
-#[path = "servo_sidecar_tests.rs"]
-mod tests;
diff --git a/crates/ely_app/src/services/servo_sidecar_command.rs b/crates/ely_app/src/services/servo_sidecar_command.rs
index 1bef907..79dd687 100644
--- a/crates/ely_app/src/services/servo_sidecar_command.rs
+++ b/crates/ely_app/src/services/servo_sidecar_command.rs
@@ -1,14 +1,11 @@
use std::{
- env,
+ env, io,
path::{Path, PathBuf},
process::Command,
- time::Duration,
};
-use super::servo_sidecar::ServoSidecarError;
+use thiserror::Error;
-const SIDECAR_BINARY_TIMEOUT: Duration = Duration::from_secs(45);
-const SIDECAR_CARGO_TIMEOUT: Duration = Duration::from_secs(180);
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
#[derive(Clone, Debug)]
@@ -40,13 +37,6 @@ impl SidecarCommandTarget {
}
}
- pub(super) fn timeout(&self) -> Duration {
- match self {
- Self::Binary(_) => SIDECAR_BINARY_TIMEOUT,
- Self::Cargo { .. } => SIDECAR_CARGO_TIMEOUT,
- }
- }
-
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
match self {
Self::Binary(path) if !path.is_file() => Some(path.as_path()),
@@ -55,14 +45,23 @@ impl SidecarCommandTarget {
}
}
-pub(super) fn default_sidecar_command() -> Result {
+#[derive(Debug, Error)]
+pub(crate) enum SidecarCommandError {
+ #[error("current executable path is unavailable: {0}")]
+ CurrentExecutable(#[source] io::Error),
+
+ #[error("current executable directory is unavailable for {path}")]
+ CurrentExecutableDirectoryUnavailable { path: PathBuf },
+}
+
+pub(super) fn default_sidecar_command() -> Result {
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
return Ok(SidecarCommandTarget::Binary(PathBuf::from(path)));
}
- let current_exe = env::current_exe().map_err(ServoSidecarError::CurrentExecutable)?;
+ let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
let exe_dir = current_exe.parent().ok_or_else(|| {
- ServoSidecarError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
+ SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
})?;
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
if adjacent_sidecar.is_file() {
diff --git a/crates/ely_app/src/services/servo_sidecar_request.rs b/crates/ely_app/src/services/servo_sidecar_request.rs
deleted file mode 100644
index 2807711..0000000
--- a/crates/ely_app/src/services/servo_sidecar_request.rs
+++ /dev/null
@@ -1,145 +0,0 @@
-use ely_domain::{
- DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
- UrlText,
-};
-
-use super::ProfileDataMode;
-
-#[derive(Clone, Debug)]
-pub struct SidecarSnapshotRequest {
- pub(in crate::services) url: UrlText,
- pub(in crate::services) profile_id: ProfileId,
- pub(in crate::services) profile_data_mode: ProfileDataMode,
- pub(in crate::services) width: u32,
- pub(in crate::services) height: u32,
- pub(in crate::services) scroll_x: i32,
- pub(in crate::services) scroll_y: i32,
- pub(in crate::services) page_zoom_percent: u16,
- pub(in crate::services) click_point: Option,
- pub(in crate::services) typed_text: Option,
- pub(in crate::services) site_permissions: Vec,
-}
-
-impl SidecarSnapshotRequest {
- #[must_use]
- pub fn new(url: UrlText, profile_id: ProfileId, width: u32, height: u32) -> Self {
- Self {
- url,
- profile_id,
- profile_data_mode: ProfileDataMode::Persistent,
- width,
- height,
- scroll_x: 0,
- scroll_y: 0,
- page_zoom_percent: DEFAULT_ZOOM_PERCENT,
- click_point: None,
- typed_text: None,
- site_permissions: Vec::new(),
- }
- }
-
- #[must_use]
- pub fn with_profile_data_mode(mut self, profile_data_mode: ProfileDataMode) -> Self {
- self.profile_data_mode = profile_data_mode;
- self
- }
-
- #[must_use]
- pub fn with_scroll_offset(mut self, scroll_x: i32, scroll_y: i32) -> Self {
- self.scroll_x = scroll_x;
- self.scroll_y = scroll_y;
- self
- }
-
- #[must_use]
- pub fn with_page_zoom_percent(mut self, page_zoom_percent: u16) -> Self {
- self.page_zoom_percent = page_zoom_percent;
- self
- }
-
- #[must_use]
- pub fn with_click_point(mut self, x: u32, y: u32) -> Self {
- self.click_point = Some(SidecarClickPoint { x, y });
- self
- }
-
- #[must_use]
- pub fn with_typed_text(mut self, typed_text: String) -> Self {
- self.typed_text = Some(typed_text);
- self
- }
-
- #[must_use]
- pub fn with_site_permissions(mut self, site_permissions: Vec) -> Self {
- self.site_permissions = site_permissions;
- self
- }
-
- #[cfg(test)]
- pub(crate) fn typed_text_for_test(&self) -> Option<&str> {
- self.typed_text.as_deref()
- }
-
- #[cfg(test)]
- pub(crate) fn profile_id_for_test(&self) -> &ProfileId {
- &self.profile_id
- }
-
- #[cfg(test)]
- pub(crate) fn profile_data_mode_for_test(&self) -> ProfileDataMode {
- self.profile_data_mode
- }
-
- #[cfg(test)]
- pub(crate) fn page_zoom_percent_for_test(&self) -> u16 {
- self.page_zoom_percent
- }
-}
-
-#[derive(Clone, Copy, Debug)]
-pub(in crate::services) struct SidecarClickPoint {
- pub(in crate::services) x: u32,
- pub(in crate::services) y: u32,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct SidecarSitePermission {
- origin: SiteOrigin,
- feature: SitePermissionFeature,
- decision: SitePermissionDecision,
-}
-
-impl SidecarSitePermission {
- #[must_use]
- pub fn new(
- origin: SiteOrigin,
- feature: SitePermissionFeature,
- decision: SitePermissionDecision,
- ) -> Self {
- Self { origin, feature, decision }
- }
-
- pub(in crate::services) fn to_arg(&self) -> String {
- serde_json::json!({
- "origin": self.origin.as_str(),
- "feature": self.feature.as_str(),
- "decision": self.decision.as_str(),
- })
- .to_string()
- }
-
- #[cfg(test)]
- pub(crate) fn origin(&self) -> &SiteOrigin {
- &self.origin
- }
-
- #[cfg(test)]
- pub(crate) fn feature(&self) -> SitePermissionFeature {
- self.feature
- }
-
- #[cfg(test)]
- pub(crate) fn decision(&self) -> SitePermissionDecision {
- self.decision
- }
-}
diff --git a/crates/ely_app/src/services/servo_sidecar_tests.rs b/crates/ely_app/src/services/servo_sidecar_tests.rs
deleted file mode 100644
index bfbe4e8..0000000
--- a/crates/ely_app/src/services/servo_sidecar_tests.rs
+++ /dev/null
@@ -1,260 +0,0 @@
-use std::error::Error;
-
-use ely_domain::UrlText;
-
-use super::*;
-
-#[cfg(feature = "live-site-smoke")]
-use crate::services::prd_live_sites::{
- LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES,
- assert_prd_reference_urls_are_covered,
-};
-
-#[cfg(feature = "live-site-smoke")]
-const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
-#[cfg(feature = "live-site-smoke")]
-const LIVE_SITE_WIDTH: u32 = 934;
-#[cfg(feature = "live-site-smoke")]
-const LIVE_SITE_HEIGHT: u32 = 657;
-#[cfg(feature = "live-site-smoke")]
-const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
-
-#[test]
-fn accepts_loading_report_with_visible_content() -> Result<(), ServoSidecarError> {
- let profile_id = ProfileId::new();
- let snapshot = SidecarSnapshot::from_report(
- report_with_state("loading", &profile_id),
- &profile_id,
- visible_frame(),
- )?;
-
- assert_eq!(snapshot.loaded_url(), Some("https://example.com/"));
- assert_eq!(snapshot.title(), Some("Example Domain"));
- assert_eq!(snapshot.render_state(), "loading");
- assert_eq!(snapshot.width(), 2);
- assert_eq!(snapshot.height(), 1);
- assert_eq!(snapshot.non_white_pixel_count, 1);
- assert_eq!(snapshot.content_pixel_count, 1);
- assert_eq!(snapshot.sample_hash, 42);
- Ok(())
-}
-
-#[test]
-fn rejects_created_report_with_visible_content() {
- let profile_id = ProfileId::new();
- let result = SidecarSnapshot::from_report(
- report_with_state("created", &profile_id),
- &profile_id,
- visible_frame(),
- );
-
- assert!(
- matches!(result, Err(ServoSidecarError::IncompleteRender { state }) if state == "created")
- );
-}
-
-#[test]
-fn rejects_report_from_different_profile() {
- let expected_profile_id = ProfileId::new();
- let actual_profile_id = ProfileId::new();
- let result = SidecarSnapshot::from_report(
- report_with_state("loading", &actual_profile_id),
- &expected_profile_id,
- visible_frame(),
- );
-
- assert!(matches!(result, Err(ServoSidecarError::ProfileMismatch { expected, actual })
- if expected == expected_profile_id.as_str() && actual == actual_profile_id.as_str()));
-}
-
-#[test]
-fn retries_navigation_snapshots() -> Result<(), Box> {
- let request =
- SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, ProfileId::new(), 2, 1);
-
- assert_eq!(request.max_attempts(), SIDECAR_NAVIGATION_ATTEMPTS);
- Ok(())
-}
-
-#[test]
-fn keeps_page_interactions_single_attempt() -> Result<(), Box> {
- let request =
- SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, ProfileId::new(), 2, 1)
- .with_click_point(1, 1)
- .with_typed_text("ely".to_string());
-
- assert_eq!(request.max_attempts(), SIDECAR_INTERACTION_ATTEMPTS);
- Ok(())
-}
-
-#[test]
-fn persistent_profile_data_uses_profile_root() -> Result<(), Box> {
- let profile_id = ProfileId::new();
- let request = SidecarSnapshotRequest::new(
- UrlText::parse("https://example.com")?,
- profile_id.clone(),
- 2,
- 1,
- );
- let root = std::env::temp_dir().join("ely-browser-profile-root-test");
-
- assert_eq!(request.profile_data_mode_for_test(), ProfileDataMode::Persistent);
- assert_eq!(request.profile_data_dir(&root)?, root.join(profile_id.as_str()).join("servo"));
- Ok(())
-}
-
-#[test]
-fn transient_profile_data_uses_temporary_directory_and_cleans_up() -> Result<(), Box> {
- let profile_id = ProfileId::new();
- let request = SidecarSnapshotRequest::new(
- UrlText::parse("https://example.com")?,
- profile_id.clone(),
- 2,
- 1,
- )
- .with_profile_data_mode(ProfileDataMode::Transient);
- let root = std::env::temp_dir().join("ely-browser-profile-root-test");
- let profile_data_dir = request.profile_data_dir(&root)?;
-
- assert!(profile_data_dir.starts_with(std::env::temp_dir().join("ely-browser-servo-profiles")));
- assert!(profile_data_dir.to_string_lossy().contains(profile_id.as_str()));
- std::fs::create_dir_all(&profile_data_dir)?;
- std::fs::write(profile_data_dir.join("probe"), b"private")?;
-
- request.cleanup_profile_data_dir(&profile_data_dir)?;
-
- assert!(!profile_data_dir.exists());
- Ok(())
-}
-
-#[cfg(feature = "live-site-smoke")]
-#[test]
-fn desktop_sidecar_opens_prd_top_sites() -> Result<(), Box> {
- assert_live_sites_render(PRD_TOP_SITE_CASES)
-}
-
-#[cfg(feature = "live-site-smoke")]
-#[test]
-fn desktop_sidecar_opens_prd_reference_sites() -> Result<(), Box> {
- assert_live_sites_render(PRD_REFERENCE_SITE_CASES)
-}
-
-#[cfg(feature = "live-site-smoke")]
-#[test]
-fn prd_reference_live_site_cases_cover_prd_urls() -> Result<(), Box> {
- assert_prd_reference_urls_are_covered()
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box> {
- let client = ServoSidecarClient::new()?;
- for case in cases {
- let snapshot = render_live_site_snapshot(&client, case)?;
- let rgba_bytes = snapshot.into_rgba_bytes();
- assert_eq!(
- rgba_bytes.len(),
- expected_rgba_byte_count(LIVE_SITE_WIDTH, LIVE_SITE_HEIGHT)?,
- "{}",
- case.url
- );
- }
- Ok(())
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn render_live_site_snapshot(
- client: &ServoSidecarClient,
- case: &LiveSiteCase,
-) -> Result> {
- let mut last_error = String::new();
-
- for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS {
- let request = SidecarSnapshotRequest::new(
- UrlText::parse(case.url)?,
- ProfileId::new(),
- LIVE_SITE_WIDTH,
- LIVE_SITE_HEIGHT,
- );
-
- match client.snapshot(request) {
- Ok(snapshot) => match validate_live_site_snapshot(&snapshot, case) {
- Ok(()) => return Ok(snapshot),
- Err(error) => last_error = error,
- },
- Err(error) => last_error = error.to_string(),
- }
-
- if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
- std::thread::sleep(std::time::Duration::from_millis(250));
- }
- }
-
- Err(last_error.into())
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn validate_live_site_snapshot(
- snapshot: &SidecarSnapshot,
- case: &LiveSiteCase,
-) -> Result<(), String> {
- require(
- snapshot.width() == LIVE_SITE_WIDTH,
- format!("{} width: {}", case.url, snapshot.width()),
- )?;
- require(
- snapshot.height() == LIVE_SITE_HEIGHT,
- format!("{} height: {}", case.url, snapshot.height()),
- )?;
- require_render_state_is_open(snapshot.render_state(), case.url)?;
- require_loaded_url_contains(snapshot, case.url)?;
- require_title_contains(snapshot, case.title_fragment)?;
- require(snapshot.non_white_pixel_count > 0, case.url.to_string())?;
- require(
- snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS,
- format!("{} content pixels: {}", case.url, snapshot.content_pixel_count),
- )?;
- require(snapshot.sample_hash > 0, case.url.to_string())
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> {
- require(matches!(state, "complete" | "loading"), format!("{url} state: {state}"))
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn require_loaded_url_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
- let loaded_url =
- snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?;
- require(loaded_url.contains(fragment), format!("loaded_url: {loaded_url}"))
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn require_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
- let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?;
- require(title.contains(fragment), format!("title: {title}"))
-}
-
-#[cfg(feature = "live-site-smoke")]
-fn require(condition: bool, message: String) -> Result<(), String> {
- if condition { Ok(()) } else { Err(message) }
-}
-
-fn report_with_state(state: &str, profile_id: &ProfileId) -> SidecarReport {
- SidecarReport {
- requested_url: "https://example.com".to_string(),
- profile_id: profile_id.as_str().to_string(),
- loaded_url: Some("https://example.com/".to_string()),
- title: Some("Example Domain".to_string()),
- state: state.to_string(),
- width: 2,
- height: 1,
- rgba_byte_count: 8,
- non_white_pixel_count: 1,
- content_pixel_count: 1,
- sample_hash: 42,
- }
-}
-
-fn visible_frame() -> Vec {
- vec![0, 0, 0, 255, 255, 255, 255, 255]
-}
diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs
index 8082829..fff6bd5 100644
--- a/crates/ely_app/src/shell/mod.rs
+++ b/crates/ely_app/src/shell/mod.rs
@@ -26,15 +26,17 @@ mod web_surface;
mod web_surface_controller;
mod web_surface_frame;
mod web_surface_geometry;
-mod web_surface_image;
mod web_surface_keyboard;
mod web_surface_permissions;
+mod web_surface_runtime;
mod web_surface_state;
mod web_surface_view;
+use std::time::Duration;
+
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{ProfileId, SpaceId, TabId};
-use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window};
+use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Timer, Window};
use gpui_component::input::{InputEvent, InputState};
use crate::shortcuts::ShortcutProfile;
@@ -144,7 +146,7 @@ impl ElyShell {
Err(error) => ShellState::StartupError(error.to_string()),
};
- Self {
+ let shell = Self {
state,
focus_handle: cx.focus_handle(),
command_input,
@@ -172,7 +174,9 @@ impl ElyShell {
pending_plugin_uninstall: None,
web_surfaces: WebSurfaceStore::new(),
_command_subscription: command_subscription,
- }
+ };
+ start_external_web_surface_timer(cx);
+ shell
}
fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context) {
@@ -432,3 +436,20 @@ impl ElyShell {
self.zoom_active_tab_out(cx);
}
}
+
+fn start_external_web_surface_timer(cx: &mut Context) {
+ cx.spawn(async move |shell, cx| {
+ loop {
+ Timer::after(Duration::from_millis(16)).await;
+ let result = shell.update(cx, |shell, cx| {
+ if shell.tick_external_web_surfaces() {
+ cx.notify();
+ }
+ });
+ if result.is_err() {
+ break;
+ }
+ }
+ })
+ .detach();
+}
diff --git a/crates/ely_app/src/shell/web_surface_frame.rs b/crates/ely_app/src/shell/web_surface_frame.rs
index b661d85..533041b 100644
--- a/crates/ely_app/src/shell/web_surface_frame.rs
+++ b/crates/ely_app/src/shell/web_surface_frame.rs
@@ -4,14 +4,14 @@ use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
-use crate::services::servo_sidecar::SidecarSnapshot;
+use crate::services::servo_live::ServoLiveFrame;
-use super::{
- web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset, WebSurfaceSize},
- web_surface_image::renderable_image_buffer,
-};
+#[cfg(all(test, feature = "live-site-smoke"))]
+use super::web_surface_geometry::WebSurfaceSize;
+use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
#[derive(Clone)]
+#[cfg_attr(not(all(test, feature = "live-site-smoke")), allow(dead_code))]
pub(super) struct WebSurfaceFrame {
pub(super) requested_url: String,
loaded_url: Option,
@@ -33,62 +33,75 @@ pub(super) struct WebSurfaceFrame {
}
impl WebSurfaceFrame {
- pub(super) fn from_snapshot(
+ pub(super) fn from_live_frame(
requested_url: String,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
- click_point: Option,
- typed_text: Option,
- snapshot: SidecarSnapshot,
+ frame: ServoLiveFrame,
) -> Result {
- let width = snapshot.width();
- let height = snapshot.height();
- let loaded_url = snapshot.loaded_url().map(str::to_string);
- let title = snapshot.title().map(str::to_string);
- let render_state = snapshot.render_state().to_string();
- #[cfg(all(test, feature = "live-site-smoke"))]
- let non_white_pixel_count = snapshot.non_white_pixel_count();
- #[cfg(all(test, feature = "live-site-smoke"))]
- let content_pixel_count = snapshot.content_pixel_count();
- #[cfg(all(test, feature = "live-site-smoke"))]
- let sample_hash = snapshot.sample_hash();
- let rgba_bytes = snapshot.into_rgba_bytes();
-
- let Some(buffer) = ImageBuffer::, _>::from_raw(width, height, rgba_bytes) else {
- return Err(WebSurfaceError::InvalidFrameBuffer { width, height });
- };
-
- let image_buffer = renderable_image_buffer(buffer);
-
- Ok(Self {
+ Self::from_parts(WebSurfaceFrameParts {
requested_url,
- loaded_url,
- title,
- render_state,
- width,
- height,
+ loaded_url: frame.loaded_url().map(str::to_string),
+ title: frame.title().map(str::to_string),
+ render_state: frame.render_state().to_string(),
+ width: frame.width(),
+ height: frame.height(),
scroll_offset,
zoom_percent,
- click_point,
- typed_text,
+ click_point: None,
+ typed_text: None,
#[cfg(all(test, feature = "live-site-smoke"))]
- non_white_pixel_count,
+ non_white_pixel_count: frame.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
- content_pixel_count,
+ content_pixel_count: frame.content_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
- sample_hash,
+ sample_hash: frame.sample_hash(),
+ rgba_bytes: frame.into_rgba_bytes(),
+ })
+ }
+
+ fn from_parts(parts: WebSurfaceFrameParts) -> Result {
+ let Some(image_buffer) =
+ ImageBuffer::, _>::from_raw(parts.width, parts.height, parts.rgba_bytes)
+ else {
+ return Err(WebSurfaceError::InvalidFrameBuffer {
+ width: parts.width,
+ height: parts.height,
+ });
+ };
+
+ Ok(Self {
+ requested_url: parts.requested_url,
+ loaded_url: parts.loaded_url,
+ title: parts.title,
+ render_state: parts.render_state,
+ width: parts.width,
+ height: parts.height,
+ scroll_offset: parts.scroll_offset,
+ zoom_percent: parts.zoom_percent,
+ click_point: parts.click_point,
+ typed_text: parts.typed_text,
+ #[cfg(all(test, feature = "live-site-smoke"))]
+ non_white_pixel_count: parts.non_white_pixel_count,
+ #[cfg(all(test, feature = "live-site-smoke"))]
+ content_pixel_count: parts.content_pixel_count,
+ #[cfg(all(test, feature = "live-site-smoke"))]
+ sample_hash: parts.sample_hash,
image: Arc::new(RenderImage::new([image::Frame::new(image_buffer)])),
})
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn title_label(&self) -> String {
self.title.clone().unwrap_or_else(|| self.requested_url.clone())
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn url_label(&self) -> &str {
self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str())
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn detail_label(&self) -> String {
let mut detail =
format!("{} {}", self.render_state(), self.scroll_offset.detail_label(self.size()));
@@ -104,14 +117,17 @@ impl WebSurfaceFrame {
}
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn size(&self) -> WebSurfaceSize {
WebSurfaceSize { width: self.width, height: self.height }
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn render_state(&self) -> &str {
self.render_state.as_str()
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn scroll_offset(&self) -> WebSurfaceScrollOffset {
self.scroll_offset
}
@@ -120,10 +136,12 @@ impl WebSurfaceFrame {
self.zoom_percent
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn click_point(&self) -> Option {
self.click_point
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn typed_text(&self) -> Option<&str> {
self.typed_text.as_deref()
}
@@ -144,6 +162,26 @@ impl WebSurfaceFrame {
}
}
+struct WebSurfaceFrameParts {
+ requested_url: String,
+ loaded_url: Option,
+ title: Option,
+ render_state: String,
+ width: u32,
+ height: u32,
+ scroll_offset: WebSurfaceScrollOffset,
+ zoom_percent: u16,
+ click_point: Option,
+ typed_text: Option,
+ #[cfg(all(test, feature = "live-site-smoke"))]
+ non_white_pixel_count: u64,
+ #[cfg(all(test, feature = "live-site-smoke"))]
+ content_pixel_count: u64,
+ #[cfg(all(test, feature = "live-site-smoke"))]
+ sample_hash: u64,
+ rgba_bytes: Vec,
+}
+
#[derive(Debug, Error)]
pub(super) enum WebSurfaceError {
#[error("invalid servo frame buffer for {width}x{height}")]
diff --git a/crates/ely_app/src/shell/web_surface_geometry.rs b/crates/ely_app/src/shell/web_surface_geometry.rs
index 8cb7842..b089b86 100644
--- a/crates/ely_app/src/shell/web_surface_geometry.rs
+++ b/crates/ely_app/src/shell/web_surface_geometry.rs
@@ -1,6 +1,6 @@
use gpui::{Bounds, Pixels, Point};
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) struct WebSurfaceSize {
pub(super) width: u32,
pub(super) height: u32,
@@ -32,6 +32,7 @@ impl WebSurfaceClickPoint {
})
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn detail_label(self) -> String {
format!("click={},{}", self.x, self.y)
}
@@ -59,6 +60,7 @@ impl WebSurfaceScrollOffset {
}
}
+ #[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn detail_label(self, size: WebSurfaceSize) -> String {
match (self.x, self.y) {
(0, 0) => format!("{}x{}", size.width, size.height),
@@ -67,10 +69,7 @@ impl WebSurfaceScrollOffset {
}
}
- pub(super) fn x(self) -> i32 {
- self.x
- }
-
+ #[cfg_attr(not(test), allow(dead_code))]
pub(super) fn y(self) -> i32 {
self.y
}
@@ -92,6 +91,18 @@ impl WebSurfaceScrollDelta {
Some(Self { x, y })
}
+
+ pub(super) fn x(self) -> i32 {
+ self.x
+ }
+
+ pub(super) fn y(self) -> i32 {
+ self.y
+ }
+
+ pub(super) fn combined_with(self, next: Self) -> Self {
+ Self { x: combined_scroll_delta(self.x, next.x), y: combined_scroll_delta(self.y, next.y) }
+ }
}
fn viewport_dimension(pixels: Pixels) -> Option {
@@ -133,3 +144,8 @@ fn positive_scroll_component(current: i32, delta: i32) -> i32 {
let clamped = value.clamp(0, i64::from(i32::MAX));
clamped as i32
}
+
+fn combined_scroll_delta(current: i32, next: i32) -> i32 {
+ let value = i64::from(current) + i64::from(next);
+ value.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
+}
diff --git a/crates/ely_app/src/shell/web_surface_image.rs b/crates/ely_app/src/shell/web_surface_image.rs
deleted file mode 100644
index 22a8cb5..0000000
--- a/crates/ely_app/src/shell/web_surface_image.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-use image::{ImageBuffer, Rgba, imageops::FilterType};
-
-const WEB_SURFACE_IMAGE_MAX_EDGE: u32 = 1024;
-
-pub(super) fn renderable_image_buffer(
- buffer: ImageBuffer, Vec>,
-) -> ImageBuffer, Vec> {
- let largest_edge = buffer.width().max(buffer.height());
- if largest_edge <= WEB_SURFACE_IMAGE_MAX_EDGE {
- return buffer;
- }
-
- image::imageops::resize(
- &buffer,
- scaled_image_dimension(buffer.width(), largest_edge),
- scaled_image_dimension(buffer.height(), largest_edge),
- FilterType::Triangle,
- )
-}
-
-fn scaled_image_dimension(dimension: u32, largest_edge: u32) -> u32 {
- let numerator = u64::from(dimension) * u64::from(WEB_SURFACE_IMAGE_MAX_EDGE);
- let rounded = (numerator + u64::from(largest_edge / 2)) / u64::from(largest_edge);
- match u32::try_from(rounded.max(1)) {
- Ok(value) => value,
- Err(_) => WEB_SURFACE_IMAGE_MAX_EDGE,
- }
-}
diff --git a/crates/ely_app/src/shell/web_surface_live_site_tests.rs b/crates/ely_app/src/shell/web_surface_live_site_tests.rs
index 09ddc77..b177815 100644
--- a/crates/ely_app/src/shell/web_surface_live_site_tests.rs
+++ b/crates/ely_app/src/shell/web_surface_live_site_tests.rs
@@ -1,13 +1,21 @@
-use std::error::Error;
+use std::{
+ env,
+ error::Error,
+ process::{Command, Stdio},
+ thread,
+ time::{Duration, Instant},
+};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size};
use crate::{
- services::ProfileDataMode,
- services::prd_live_sites::{
- LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES,
- assert_prd_reference_urls_are_covered,
+ services::{
+ ProfileDataMode,
+ prd_live_sites::{
+ LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES,
+ assert_prd_reference_urls_are_covered,
+ },
},
shell::{
web_surface_frame::WebSurfaceFrame,
@@ -22,6 +30,9 @@ const LIVE_SURFACE_WIDTH: u32 = 934;
const LIVE_SURFACE_HEIGHT: u32 = 657;
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
+const LIVE_SITE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
+const LIVE_SITE_WAIT_INTERVAL: Duration = Duration::from_millis(2);
+const LIVE_SITE_CHILD_ENV: &str = "ELY_APP_WEB_SURFACE_LIVE_CHILD";
#[test]
fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box> {
@@ -30,63 +41,110 @@ fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box> {
#[test]
fn web_surface_opens_and_renders_prd_top_sites() -> Result<(), Box> {
- assert_web_surfaces_render(PRD_TOP_SITE_CASES)
+ run_isolated_live_site_test("web_surface_opens_and_renders_prd_top_sites", || {
+ assert_web_surfaces_render(PRD_TOP_SITE_CASES)
+ })
}
#[test]
fn web_surface_opens_and_renders_prd_reference_sites() -> Result<(), Box> {
- assert_web_surfaces_render(PRD_REFERENCE_SITE_CASES)
+ run_isolated_live_site_test("web_surface_opens_and_renders_prd_reference_sites", || {
+ assert_web_surfaces_render(PRD_REFERENCE_SITE_CASES)
+ })
+}
+
+fn run_isolated_live_site_test(
+ test_name: &str,
+ test: impl FnOnce() -> Result<(), Box>,
+) -> Result<(), Box> {
+ if env::var_os(LIVE_SITE_CHILD_ENV).is_some() {
+ return test();
+ }
+
+ let output = Command::new(env::current_exe()?)
+ .arg(test_name)
+ .env(LIVE_SITE_CHILD_ENV, "1")
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()?;
+
+ if output.status.success() {
+ return Ok(());
+ }
+
+ Err(format!(
+ "isolated web surface live-site test failed\nstatus: {}\nstdout: {}\nstderr: {}",
+ output.status,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ )
+ .into())
}
fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box> {
+ let mut store = WebSurfaceStore::new();
+ let profile_id = ProfileId::new();
+
for case in cases {
- let frame = render_web_surface_frame(case)?;
+ let frame = render_web_surface_frame(&mut store, &profile_id, case)?;
log_prd_frame("web-surface", &frame, case);
}
Ok(())
}
-fn render_web_surface_frame(case: &LiveSiteCase) -> Result> {
+fn render_web_surface_frame(
+ store: &mut WebSurfaceStore,
+ profile_id: &ProfileId,
+ case: &LiveSiteCase,
+) -> Result> {
let mut last_error = String::new();
for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS {
- let mut store = WebSurfaceStore::new();
- let tab = web_tab(case.url)?;
+ let tab = web_tab(profile_id.clone(), case.url)?;
assert!(store.record_viewport_size(tab.id(), live_surface_bounds()), "{}", case.url);
- let request = store
- .prepare_request(&tab, ProfileDataMode::Persistent)
- .ok_or_else(|| format!("missing web surface request for {}", case.url))?;
- let tab_id = request.tab_id.clone();
- let snapshot = request.client.snapshot(request.snapshot_request)?;
- let frame = WebSurfaceFrame::from_snapshot(
- request.requested_url,
- request.scroll_offset,
- request.zoom_percent,
- request.click_point,
- request.typed_text,
- snapshot,
- )?;
+ store.ensure_surface(&tab, ProfileDataMode::Transient, &[]);
- match validate_prd_frame(&frame, case) {
- Ok(()) => {
- store.finish(tab_id, WebSurfaceState::Ready(frame.clone()));
- let Some(WebSurfaceState::Ready(stored_frame)) = store.state(tab.id()) else {
- return Err(format!("web surface state is not ready for {}", case.url).into());
- };
- validate_prd_frame(stored_frame, case)?;
- return Ok(frame);
- }
+ match wait_for_ready_frame(store, tab.id(), case) {
+ Ok(frame) => return Ok(frame),
Err(error) => last_error = error,
}
if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
- std::thread::sleep(std::time::Duration::from_millis(250));
+ thread::sleep(Duration::from_millis(250));
}
}
Err(last_error.into())
}
+fn wait_for_ready_frame(
+ store: &mut WebSurfaceStore,
+ tab_id: &TabId,
+ case: &LiveSiteCase,
+) -> Result {
+ let started_at = Instant::now();
+
+ loop {
+ if started_at.elapsed() >= LIVE_SITE_WAIT_TIMEOUT {
+ return Err(format!("timed out rendering {}", case.url));
+ }
+
+ store.tick();
+ match store.state(tab_id) {
+ Some(WebSurfaceState::Ready(frame)) => {
+ validate_prd_frame(frame, case)?;
+ return Ok(frame.clone());
+ }
+ Some(WebSurfaceState::Failed { message, .. }) => {
+ return Err(format!("{} failed: {message}", case.url));
+ }
+ Some(WebSurfaceState::Loading { .. }) | None => {}
+ }
+
+ thread::sleep(LIVE_SITE_WAIT_INTERVAL);
+ }
+}
+
fn validate_prd_frame(frame: &WebSurfaceFrame, case: &LiveSiteCase) -> Result<(), String> {
require(
frame.size() == WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT },
@@ -147,8 +205,8 @@ fn live_surface_bounds() -> Bounds {
)
}
-fn web_tab(url: &str) -> Result> {
- Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
+fn web_tab(profile_id: ProfileId, url: &str) -> Result> {
+ Ok(BrowserTab::new(TabId::new(), SpaceId::new(), profile_id, "Web", UrlText::parse(url)?))
}
fn normalized_url(url: &str) -> &str {
diff --git a/crates/ely_app/src/shell/web_surface_permissions.rs b/crates/ely_app/src/shell/web_surface_permissions.rs
index ff264a0..603d141 100644
--- a/crates/ely_app/src/shell/web_surface_permissions.rs
+++ b/crates/ely_app/src/shell/web_surface_permissions.rs
@@ -1,12 +1,39 @@
use ely_browser_core::BrowserSnapshot;
-use ely_domain::{BrowserTab, SiteOrigin};
+use ely_domain::{BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature};
-use crate::services::servo_sidecar::SidecarSitePermission;
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(super) struct WebSurfaceSitePermission {
+ origin: SiteOrigin,
+ feature: SitePermissionFeature,
+ decision: SitePermissionDecision,
+}
-pub(super) fn sidecar_site_permissions_for_tab(
+impl WebSurfaceSitePermission {
+ pub(super) fn new(
+ origin: SiteOrigin,
+ feature: SitePermissionFeature,
+ decision: SitePermissionDecision,
+ ) -> Self {
+ Self { origin, feature, decision }
+ }
+
+ pub(super) fn origin(&self) -> &SiteOrigin {
+ &self.origin
+ }
+
+ pub(super) fn feature(&self) -> SitePermissionFeature {
+ self.feature
+ }
+
+ pub(super) fn decision(&self) -> SitePermissionDecision {
+ self.decision
+ }
+}
+
+pub(super) fn web_surface_site_permissions_for_tab(
tab: &BrowserTab,
snapshot: &BrowserSnapshot,
-) -> Vec {
+) -> Vec {
let Ok(Some(origin)) = SiteOrigin::from_url(tab.url()) else {
return Vec::new();
};
@@ -17,7 +44,7 @@ pub(super) fn sidecar_site_permissions_for_tab(
.filter(|entry| entry.profile_id() == tab.profile_id())
.filter(|entry| entry.origin() == &origin)
.map(|entry| {
- SidecarSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision())
+ WebSurfaceSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision())
})
.collect()
}
@@ -31,7 +58,7 @@ mod tests {
BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText,
};
- use super::sidecar_site_permissions_for_tab;
+ use super::web_surface_site_permissions_for_tab;
#[test]
fn includes_matching_profile_and_origin_permissions() -> Result<(), Box> {
@@ -45,7 +72,7 @@ mod tests {
let snapshot = core.snapshot()?;
let tab = active_tab(&snapshot)?;
- let permissions = sidecar_site_permissions_for_tab(tab, &snapshot);
+ let permissions = web_surface_site_permissions_for_tab(tab, &snapshot);
assert_eq!(permissions.len(), 1);
assert_eq!(permissions[0].origin().as_str(), "https://example.com");
@@ -66,7 +93,7 @@ mod tests {
let snapshot = core.snapshot()?;
let tab = active_tab(&snapshot)?;
- assert!(sidecar_site_permissions_for_tab(tab, &snapshot).is_empty());
+ assert!(web_surface_site_permissions_for_tab(tab, &snapshot).is_empty());
Ok(())
}
diff --git a/crates/ely_app/src/shell/web_surface_tests.rs b/crates/ely_app/src/shell/web_surface_tests.rs
index b893c71..c8b7d8a 100644
--- a/crates/ely_app/src/shell/web_surface_tests.rs
+++ b/crates/ely_app/src/shell/web_surface_tests.rs
@@ -3,68 +3,60 @@ use std::error::Error;
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size};
-use super::{ProfileDataMode, WebSurfaceStore};
+use super::WebSurfaceStore;
#[test]
-fn typed_text_enters_snapshot_request_after_clicked_viewport() -> Result<(), Box> {
+fn typed_text_enters_pending_input_after_clicked_viewport() -> Result<(), Box> {
let mut store = WebSurfaceStore::new();
let tab = web_tab("https://example.com/form")?;
- let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)));
- assert!(store.record_viewport_size(tab.id(), bounds));
+ assert!(store.record_viewport_size(tab.id(), web_bounds()));
assert!(store.record_click_point(tab.id(), tab.url().as_str(), point(px(160.0), px(120.0))));
assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "e"));
assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "l"));
- let request = store
- .prepare_request(&tab, ProfileDataMode::Persistent)
- .ok_or("missing web surface request")?;
+ let input = store.take_pending_input(tab.id(), tab.url().as_str());
- assert_eq!(request.typed_text.as_deref(), Some("el"));
- assert_eq!(request.snapshot_request.typed_text_for_test(), Some("el"));
- assert_eq!(request.snapshot_request.profile_id_for_test(), tab.profile_id());
- assert_eq!(request.zoom_percent, ely_domain::DEFAULT_ZOOM_PERCENT);
- assert_eq!(
- request.snapshot_request.page_zoom_percent_for_test(),
- ely_domain::DEFAULT_ZOOM_PERCENT
- );
+ assert_eq!(input.click_point.map(|point| (point.x(), point.y())), Some((160, 120)));
+ assert_eq!(input.typed_text.as_deref(), Some("el"));
Ok(())
}
#[test]
-fn private_profile_enters_transient_snapshot_request() -> Result<(), Box> {
+fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box> {
let mut store = WebSurfaceStore::new();
- let tab = web_tab("https://example.com/private")?;
+ let tab = web_tab("https://example.com/list")?;
- let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)));
- assert!(store.record_viewport_size(tab.id(), bounds));
+ assert!(store.record_viewport_size(tab.id(), web_bounds()));
+ assert!(store.record_scroll_delta(tab.id(), tab.url().as_str(), point(px(0.0), px(140.0))));
+ assert!(store.record_scroll_delta(tab.id(), tab.url().as_str(), point(px(0.0), px(60.0))));
- let request = store
- .prepare_request(&tab, ProfileDataMode::Transient)
- .ok_or("missing web surface request")?;
+ let input = store.take_pending_input(tab.id(), tab.url().as_str());
- assert_eq!(request.snapshot_request.profile_data_mode_for_test(), ProfileDataMode::Transient);
+ assert_eq!(input.scroll_offset.y(), 200);
+ assert_eq!(input.scroll_delta.map(|delta| (delta.x(), delta.y())), Some((0, 200)));
Ok(())
}
#[test]
-fn tab_zoom_enters_snapshot_request() -> Result<(), Box> {
+fn viewport_size_changes_after_stable_second_measurement() -> Result<(), Box> {
let mut store = WebSurfaceStore::new();
- let mut tab = web_tab("https://example.com/zoom")?;
- tab.set_zoom_percent(125)?;
+ let tab = web_tab("https://example.com/resize")?;
- let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)));
- assert!(store.record_viewport_size(tab.id(), bounds));
-
- let request = store
- .prepare_request(&tab, ProfileDataMode::Persistent)
- .ok_or("missing web surface request")?;
-
- assert_eq!(request.zoom_percent, 125);
- assert_eq!(request.snapshot_request.page_zoom_percent_for_test(), 125);
+ assert!(store.record_viewport_size(tab.id(), web_bounds()));
+ assert!(!store.record_viewport_size(tab.id(), resized_once_bounds()));
+ assert!(store.record_viewport_size(tab.id(), resized_once_bounds()));
Ok(())
}
+fn web_bounds() -> Bounds {
+ Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
+}
+
+fn resized_once_bounds() -> Bounds {
+ Bounds::new(point(px(0.0), px(0.0)), size(px(934.0), px(657.0)))
+}
+
fn web_tab(url: &str) -> Result> {
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
}
diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs
index 8cba947..ad26c1b 100644
--- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs
+++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs
@@ -14,6 +14,8 @@ use thiserror::Error;
#[path = "ely_servo_sidecar/args.rs"]
mod args;
+#[path = "ely_servo_sidecar/live.rs"]
+mod live;
#[path = "ely_servo_sidecar/report.rs"]
mod report;
@@ -28,6 +30,7 @@ const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
fn main() -> Result<(), SidecarError> {
match args::parse_env_command()? {
+ SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live),
SidecarCommand::Snapshot(args) => run_snapshot(args),
}
}
@@ -43,6 +46,9 @@ enum SidecarError {
#[error(transparent)]
Host(#[from] ServoHostError),
+ #[error(transparent)]
+ Live(#[from] live::LiveSidecarError),
+
#[error(transparent)]
Io(#[from] std::io::Error),
diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs
index 4767914..b6b152e 100644
--- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs
+++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs
@@ -8,9 +8,14 @@ use serde::Deserialize;
use thiserror::Error;
pub(super) enum SidecarCommand {
+ Live(LiveArgs),
Snapshot(SnapshotArgs),
}
+pub(super) struct LiveArgs {
+ pub(super) profile_data_dir: PathBuf,
+}
+
pub(super) struct SnapshotArgs {
pub(super) url: UrlText,
pub(super) profile_id: ProfileId,
@@ -109,11 +114,34 @@ fn parse_command(
let command = args.next().ok_or(SidecarArgsError::MissingCommand)?;
match command.as_str() {
+ "live" => parse_live_args(args).map(SidecarCommand::Live),
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
_ => Err(SidecarArgsError::UnknownCommand { value: command }),
}
}
+fn parse_live_args(args: impl IntoIterator- ) -> Result
{
+ let mut args = args.into_iter();
+ let mut profile_data_dir = None;
+
+ while let Some(name) = args.next() {
+ match name.as_str() {
+ "--profile-data-dir" => {
+ profile_data_dir = Some(parse_path(
+ "--profile-data-dir",
+ next_argument(&mut args, "--profile-data-dir")?,
+ )?)
+ }
+ _ => return Err(SidecarArgsError::UnknownArgument { value: name }),
+ }
+ }
+
+ Ok(LiveArgs {
+ profile_data_dir: profile_data_dir
+ .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
+ })
+}
+
fn parse_snapshot_args(
args: impl IntoIterator- ,
) -> Result
{
@@ -344,9 +372,8 @@ mod tests {
fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
- let command = parse_snapshot_command(&profile_id, profile_data_dir.clone())?;
+ let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?;
- let SidecarCommand::Snapshot(args) = command;
assert_eq!(args.profile_id, profile_id);
assert_eq!(args.profile_data_dir, profile_data_dir);
assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT);
@@ -393,7 +420,7 @@ mod tests {
.to_string(),
);
- let SidecarCommand::Snapshot(args) = parse_command(command)?;
+ let args = snapshot_args(parse_command(command)?);
assert_eq!(args.site_permissions.len(), 1);
let permission = &args.site_permissions[0];
assert_eq!(permission.origin.as_str(), "https://example.com");
@@ -410,7 +437,7 @@ mod tests {
command.push("--page-zoom-percent".to_string());
command.push("125".to_string());
- let SidecarCommand::Snapshot(args) = parse_command(command)?;
+ let args = snapshot_args(parse_command(command)?);
assert_eq!(args.page_zoom_percent, 125);
Ok(())
}
@@ -432,8 +459,15 @@ mod tests {
fn parse_snapshot_command(
profile_id: &ProfileId,
profile_data_dir: PathBuf,
- ) -> Result {
- parse_command(snapshot_command_args(profile_id, profile_data_dir))
+ ) -> Result {
+ Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?))
+ }
+
+ fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs {
+ match command {
+ SidecarCommand::Snapshot(args) => args,
+ SidecarCommand::Live(_) => unreachable!("expected snapshot command"),
+ }
}
fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec {
diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs
index d232d45..c886ac5 100644
--- a/crates/ely_servo_host/src/runtime_webview.rs
+++ b/crates/ely_servo_host/src/runtime_webview.rs
@@ -1,7 +1,7 @@
use std::{cell::Cell, cell::RefCell, rc::Rc};
use ely_domain::{ProfileId, TabId, WebViewId};
-use servo::{LoadStatus, WebView, WebViewDelegate};
+use servo::{LoadStatus, RenderingContext, WebView, WebViewDelegate};
use url::Url;
use crate::{
@@ -12,6 +12,7 @@ use crate::{
pub(super) struct HostWebView {
pub(super) tab_id: TabId,
pub(super) profile_id: ProfileId,
+ pub(super) rendering_context: Rc,
pub(super) webview: WebView,
pub(super) delegate: Rc,
pub(super) requested_url: Option,
diff --git a/实用, b/实用,
new file mode 100644
index 0000000..e69de29