feat(ui,webview): compact tab bar, hide inactive tabs, harden CEF link handling (#868)

This commit is contained in:
Steven Enamakel
2026-04-23 17:31:34 -07:00
committed by GitHub
parent e0eb3c47be
commit 643ec33dec
7 changed files with 152 additions and 45 deletions
+14
View File
@@ -135,6 +135,20 @@ Thin desktop host: window management, daemon health, **core process lifecycle**
Registered IPC (see [`docs/src-tauri/02-commands.md`](docs/src-tauri/02-commands.md)): `greet`, `write_ai_config_file`, `ai_get_config`, `ai_refresh_config`, `core_rpc_relay`, window commands, `openhuman_*` daemon helpers.
### CEF child webviews — no new JS injection
Embedded provider webviews (`acct_*`, loading third-party origins like `web.telegram.org`, `linkedin.com`, `slack.com`, …) **must not** grow any new JavaScript injection. Do not add new `.js` files under `app/src-tauri/src/webview_accounts/`, do not append new blocks to `build_init_script` / `RUNTIME_JS`, and do not dispatch scripts via CDP `Page.addScriptToEvaluateOnNewDocument` / `Runtime.evaluate` for these webviews. The migrated providers (whatsapp, telegram, slack, discord, browserscan) load with **zero** injected JS under CEF by design — all scraping and observability runs natively via CDP in the per-provider scanner modules, and anything host-controlled that runs inside a third-party origin is a scraping/attack-surface liability.
New behavior for these webviews lives in:
- **CEF handlers** — `on_navigation`, `on_new_window`, `LoadHandler::OnLoadStart`, `CefRequestHandler::*` (wired in `webview_accounts/mod.rs`).
- **CDP from the scanner side** — `Network.*`, `Emulation.*`, `Input.*`, `Page.*` driven by the per-provider `*_scanner/` modules.
- **Rust-side notification/IPC hooks** — never cross into the renderer.
If a feature truly cannot be built this way (e.g. intercepting a click the page's JS preventDefaults), the correct answer is to **surface the limitation**, not to ship an init script. Legacy injection that already exists for non-migrated providers (`gmail`, `linkedin`, `google-meet` recipe files, `ua_spoof.js`, `runtime.js` bridge) is grandfathered but should shrink, not grow.
Watch out for Tauri plugins that inject JS by default. `tauri-plugin-opener` ships `init-iife.js` (a global click listener that calls `plugin:opener|open_url` via HTTP-IPC) unless you build it with `.open_js_links_on_click(false)`. Any new plugin added to `app/src-tauri/src/lib.rs` must be audited for a `js_init_script` call — if found, opt out or configure around it.
---
## Rust core (`src/`)
+15 -12
View File
@@ -7,9 +7,12 @@
<p align="center">
<a href="https://discord.tinyhumans.ai/">Discord</a> •
<a href="https://www.reddit.com/r/tinyhumansai/">Reddit</a> •
<a href="https://x.com/tinyhumansai">X/Twitter</a> •
<a href="https://x.com/intent/follow?screen_name=tinyhumansai">X/Twitter</a> •
<a href="https://tinyhumans.gitbook.io/openhuman/">Docs</a>
</p>
<p align="center">
<a href="https://x.com/intent/follow?screen_name=senamakel">Follow @senamakel (Creator)</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/status-early%20beta-orange" alt="Early Beta" />
@@ -72,17 +75,17 @@ Architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Contributor orient
High-level comparison (products evolve—verify against each vendor). OpenHuman is built to **minimize vendor sprawl**, keep **workflow knowledge on-device**, and ship **deep desktop** features—not only chat.
| | Claude Code/Cowork | OpenClaw | Hermes Agent | OpenHuman |
| ----------------------- | ------------------- | ------------------ | ------------------ | ------------------------ |
| **Open-source** | 🚫 Proprietary | ✅ MIT | ✅ MIT | ✅ GNU |
| **Simple to start** | ✅ Desktop + CLI | ⚠️ Terminal-first | ⚠️ Terminal-first | ✅ Clean UI, minutes |
| **Cost** | ⚠️ Sub + add-ons | ⚠️ BYO models | ⚠️ BYO models | ✅ Local-friendly |
| **Memory & KB** | ✅ Chat-scoped | ⚠️ Plugin-reliant | ✅ Self-learning | 🚀 Local KB + learning |
| **API sprawl** | 🚫 Extra keys | 🚫 BYOK | 🚫 Multi-vendor | ✅ One account |
| **Extensibility** | ✅ MCP | ✅ SKILL.md | ✅ SKILL.md | 🚀 Rich Skills |
| **Desktop integration** | ⚠️ Basic | ⚠️ Light | ⚠️ Light | ✅ STT/TTS/screen/more |
| | Claude Code/Cowork | OpenClaw | Hermes Agent | OpenHuman |
| ----------------------- | ------------------ | ----------------- | ----------------- | ---------------------- |
| **Open-source** | 🚫 Proprietary | ✅ MIT | ✅ MIT | ✅ GNU |
| **Simple to start** | ✅ Desktop + CLI | ⚠️ Terminal-first | ⚠️ Terminal-first | ✅ Clean UI, minutes |
| **Cost** | ⚠️ Sub + add-ons | ⚠️ BYO models | ⚠️ BYO models | ✅ Local-friendly |
| **Memory & KB** | ✅ Chat-scoped | ⚠️ Plugin-reliant | ✅ Self-learning | 🚀 Local KB + learning |
| **API sprawl** | 🚫 Extra keys | 🚫 BYOK | 🚫 Multi-vendor | ✅ One account |
| **Extensibility** | ✅ MCP | ✅ SKILL.md | ✅ SKILL.md | 🚀 Rich Skills |
| **Desktop integration** | ⚠️ Basic | ⚠️ Light | ⚠️ Light | ✅ STT/TTS/screen/more |
<!-- # Star us on GitHub
# Star us on GitHub
_Building toward AGI and artificial consciousness? Star the repo and help others find the path._
@@ -94,7 +97,7 @@ _Building toward AGI and artificial consciousness? Star the repo and help others
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=tinyhumansai/openhuman&type=date&legend=top-left" />
</picture>
</a>
</p> -->
</p>
# Contributors Hall of Fame
+16 -1
View File
@@ -598,7 +598,22 @@ pub fn run() {
};
let builder = builder
.plugin(tauri_plugin_opener::init())
// Explicitly disable `open_js_links_on_click`: tauri-plugin-opener
// defaults to injecting `init-iife.js` into *every* webview — a
// global click listener that invokes `plugin:opener|open_url` via
// HTTP-IPC. That violates our "no JS injection into CEF child
// webviews" rule (see CLAUDE.md) and also fails in practice
// because third-party origins (web.telegram.org, linkedin, …)
// trip Tauri's Origin header check and return 500. External link
// handling for `acct_*` webviews runs natively via
// `on_navigation` / `on_new_window` in webview_accounts/mod.rs;
// the main window uses `openUrl()` from `utils/openUrl.ts` when
// it needs to hand off a URL.
.plugin(
tauri_plugin_opener::Builder::default()
.open_js_links_on_click(false)
.build(),
)
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
+77 -16
View File
@@ -191,13 +191,55 @@ fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool {
_ => false,
}
}
/// Unwrap provider-side "link safety" redirects so the system browser
/// lands on the real destination.
///
/// These wrappers (LinkedIn's `/safety/go/?url=…`, etc.) require the
/// user to be logged into the provider in the destination browser. In
/// our setup the session lives inside the embedded CEF webview's cookie
/// jar, not the user's default browser — opening the wrapper URL there
/// shows a broken safety page instead of completing the redirect.
/// Extract the `url` query param and return the resolved destination.
fn unwrap_provider_redirect(url: &Url) -> Option<Url> {
let host = url.host_str()?;
let path = url.path();
let matches_linkedin = (host == "www.linkedin.com" || host == "linkedin.com")
&& (path == "/safety/go/" || path == "/safety/go" || path == "/redir/redirect");
if !matches_linkedin {
return None;
}
let (_, raw) = url.query_pairs().find(|(k, _)| k == "url")?;
Url::parse(&raw).ok()
}
/// Fire-and-forget handoff to the OS default URL handler. Any error is
/// logged but not propagated — we've already cancelled the in-app
/// navigation so there's nowhere to surface a failure to.
///
/// On macOS we shell out to `/usr/bin/open` directly rather than via
/// `tauri_plugin_opener::open_url`: the plugin returned Ok but no browser
/// actually launched in the CEF runtime (suspected sandbox/launch-service
/// interaction with the `open` crate's detached spawn). The direct
/// Command call is equivalent to what a user would type in Terminal and
/// works reliably.
fn open_in_system_browser(url: &str) {
match tauri_plugin_opener::open_url(url, None::<&str>) {
Ok(()) => log::info!("[webview-accounts] opened externally: {}", url),
Err(e) => log::warn!("[webview-accounts] open_url({}) failed: {}", url, e),
#[cfg(target_os = "macos")]
{
match std::process::Command::new("/usr/bin/open").arg(url).spawn() {
Ok(_) => log::info!("[webview-accounts] opened externally (macos open): {}", url),
Err(e) => log::warn!(
"[webview-accounts] /usr/bin/open {} failed: {} — falling back to opener plugin",
url,
e
),
}
}
#[cfg(not(target_os = "macos"))]
{
match tauri_plugin_opener::open_url(url, None::<&str>) {
Ok(()) => log::info!("[webview-accounts] opened externally: {}", url),
Err(e) => log::warn!("[webview-accounts] open_url({}) failed: {}", url, e),
}
}
}
@@ -552,9 +594,6 @@ fn build_init_script(account_id: &str, provider: &str) -> String {
} else {
""
};
// Migrated providers have no recipe under wry either (recipe.js
// files were deleted with the cef migration), but the UA shim is
// still worth shipping so fingerprint gates pass.
let Some(recipe_js) = provider_recipe_js(provider) else {
return spoof.to_string();
};
@@ -686,11 +725,22 @@ pub async fn webview_account_open<R: Runtime>(
if url_is_internal(&nav_provider, url) {
true
} else {
log::info!(
"[webview-accounts] external navigation {} → system browser",
url
);
open_in_system_browser(url.as_str());
let target = unwrap_provider_redirect(url)
.map(|u| u.to_string())
.unwrap_or_else(|| url.to_string());
if target != url.as_str() {
log::info!(
"[webview-accounts] external navigation {} → (unwrapped) {} → system browser",
url,
target
);
} else {
log::info!(
"[webview-accounts] external navigation {} → system browser",
url
);
}
open_in_system_browser(&target);
false
}
});
@@ -714,11 +764,22 @@ pub async fn webview_account_open<R: Runtime>(
);
NewWindowResponse::Allow
} else {
log::info!(
"[webview-accounts] new-window request {} → system browser",
url
);
open_in_system_browser(url.as_str());
let target = unwrap_provider_redirect(&url)
.map(|u| u.to_string())
.unwrap_or_else(|| url.to_string());
if target != url.as_str() {
log::info!(
"[webview-accounts] new-window request {} → (unwrapped) {} → system browser",
url,
target
);
} else {
log::info!(
"[webview-accounts] new-window request {} → system browser",
url
);
}
open_in_system_browser(&target);
NewWindowResponse::Deny
}
});
+2 -2
View File
@@ -14,8 +14,8 @@
{
"label": "main",
"title": "OpenHuman",
"width": 800,
"height": 720,
"width": 1000,
"height": 800,
"visible": true,
"decorations": true,
"resizable": true,
+12 -5
View File
@@ -54,7 +54,7 @@ const tabs = [
},
{
id: 'intelligence',
label: 'Intelligence',
label: 'Memory',
path: '/intelligence',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -181,7 +181,7 @@ const BottomTabBar = () => {
onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
}}>
<nav className="pointer-events-auto inline-flex items-center gap-2 rounded-sm border border-stone-300 bg-stone-200 shadow-soft px-1 py-1">
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 bg-stone-200 shadow-soft px-1 py-1">
{tabs.map(tab => {
const active = isActive(tab.path);
const showBadge = tab.id === 'notifications' && unreadCount > 0;
@@ -189,7 +189,7 @@ const BottomTabBar = () => {
<button
key={tab.id}
onClick={() => navigate(tab.path)}
className={`relative flex items-center gap-2 px-4 py-2 rounded-sm text-sm transition-colors duration-150 cursor-pointer ${
className={`group relative flex items-center px-2 py-2 rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
active
? 'bg-white text-stone-900 font-semibold shadow-sm'
: 'bg-transparent text-stone-500 hover:bg-stone-300/50 hover:text-stone-700'
@@ -199,7 +199,7 @@ const BottomTabBar = () => {
? `${tab.label} (${unreadCount} unread)`
: tab.label
}>
<span className="relative inline-flex">
<span className="relative inline-flex flex-shrink-0">
{tab.icon}
{showBadge && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-coral-500 text-[9px] font-bold text-white flex items-center justify-center leading-none">
@@ -207,7 +207,14 @@ const BottomTabBar = () => {
</span>
)}
</span>
<span>{tab.label}</span>
<span
className={`overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
active
? 'max-w-[160px] ml-2 opacity-100'
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
}`}>
{tab.label}
</span>
</button>
);
})}
+16 -9
View File
@@ -122,15 +122,22 @@ function composioStatusColor(connection: ComposioConnection | undefined): string
// ─── Built-in skill definitions ────────────────────────────────────────────────
const BUILT_IN_SKILLS = [
{
id: 'screen-intelligence',
title: 'Screen Intelligence',
description:
'Capture windows, summarize what is on screen, and feed useful context into memory.',
route: '/settings/screen-intelligence',
icon: BUILT_IN_SKILL_ICONS.screenIntelligence,
},
const BUILT_IN_SKILLS: Array<{
id: string;
title: string;
description: string;
route: string;
icon: React.ReactNode;
}> = [
// Hidden — not active yet. Uncomment to re-enable.
// {
// id: 'screen-intelligence',
// title: 'Screen Intelligence',
// description:
// 'Capture windows, summarize what is on screen, and feed useful context into memory.',
// route: '/settings/screen-intelligence',
// icon: BUILT_IN_SKILL_ICONS.screenIntelligence,
// },
// text-autocomplete + voice-stt hidden per #717 (modals/status hooks retained for re-enable).
];