feat(desktop): consent gate for inline embeds (per-embed / per-service)

Embeds reach out to third parties on render, so default to a placeholder that
mirrors the tool-approval UX: "Load <service>" (this embed) or "Always allow
<service>" (persisted). A desktop-local store ($embedMode ask|always|off +
per-service allowlist) gates the fetch with zero gateway round-trip; an
Appearance setting controls the global default. Local renderers (mermaid, svg,
alerts) are never gated. Addresses review feedback on outbound third-party
requests.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 13:02:58 -05:00
parent da0ed979fa
commit db6ced4712
9 changed files with 189 additions and 6 deletions

View file

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
@ -10,6 +11,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
@ -215,6 +217,8 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
@ -266,6 +270,12 @@ export function AppearanceSettings() {
{ id: 'technical', label: a.technical }
] as const
const embedOptions = [
{ id: 'ask', label: a.embedsAsk },
{ id: 'always', label: a.embedsAlways },
{ id: 'off', label: a.embedsOff }
] as const satisfies readonly { id: EmbedMode; label: string }[]
return (
<SettingsContent>
<div>
@ -425,6 +435,35 @@ export function AppearanceSettings() {
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
<ListRow
action={
<div className="flex flex-col items-end gap-1.5">
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setEmbedMode(id)
}}
options={embedOptions}
value={embedMode}
/>
{embedAllowed.length > 0 && (
<Button
onClick={() => {
triggerHaptic('selection')
clearEmbedAllowed()
}}
size="inline"
variant="text"
>
{a.embedsReset(embedAllowed.length)}
</Button>
)}
</div>
}
description={a.embedsDesc}
title={a.embedsTitle}
/>
</div>
</div>

View file

@ -0,0 +1,55 @@
'use client'
import { type CSSProperties, useState } from 'react'
import { SplitButton } from '@/components/ui/split-button'
import { Play } from '@/lib/icons'
import { allowProvider } from '@/store/embed-consent'
import type { EmbedDescriptor } from './providers/types'
// Privacy placeholder shown before an embed reaches out to a third party. Sized
// to the embed's footprint (no layout shift). The split control mirrors the
// commit button: primary "Load" (this embed) with a caret for "Always allow
// <service>" (persisted). Global off lives in Appearance settings.
export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) {
const [choice, setChoice] = useState('once')
const style: CSSProperties = descriptor.aspectRatio
? { aspectRatio: descriptor.aspectRatio }
: { height: descriptor.height ?? 320 }
const actions = [
{ id: 'once', label: `Load ${descriptor.label}` },
{ id: 'always', label: `Always allow ${descriptor.label}` }
]
return (
<span
className="flex size-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)/30"
style={style}
>
<SplitButton
actions={actions}
onTrigger={id => (id === 'always' ? allowProvider(descriptor.provider) : onLoad())}
onValueChange={setChoice}
primaryIcon={<Play className="size-3 translate-x-px fill-current" />}
value={choice}
/>
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{hostOf(descriptor)}</span>
</span>
)
}
function hostOf(descriptor: EmbedDescriptor): string {
// x.com posts often arrive as twitter.com links — show the current brand.
if (descriptor.provider === 'twitter') {
return 'x.com'
}
try {
return new URL(descriptor.sourceUrl).hostname.replace(/^www\./, '')
} catch {
return descriptor.label
}
}

View file

@ -1,7 +1,12 @@
'use client'
import { type CSSProperties, lazy, Suspense } from 'react'
import { useStore } from '@nanostores/react'
import { type CSSProperties, lazy, Suspense, useState } from 'react'
import { PrettyLink } from '@/lib/external-link'
import { $embedAllowed, $embedMode } from '@/store/embed-consent'
import { EmbedFacade } from './embed-consent'
import { EMBED_MAX_H } from './embed-size'
import { EmbedFail } from './fail'
import type { EmbedDescriptor } from './providers/types'
@ -39,11 +44,22 @@ function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
}
export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
const mode = useStore($embedMode)
const allowed = useStore($embedAllowed)
const [loaded, setLoaded] = useState(false)
// Privacy gate: don't reach out to the provider until consented. `off` keeps
// it a plain link; otherwise the placeholder shows until "Load" (this embed)
// or "Always allow" / global `always` permits the fetch.
if (mode === 'off') {
return <PrettyLink className="wrap-anywhere" href={descriptor.sourceUrl} />
}
const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider)
const aspect = descriptor.aspectRatio
// Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
// scaling. Non-ratio embeds own their own height (scale-to-fit / measured /
// fixed) — no maxHeight here or it would clip what the renderer just sized.
// scaling. Non-ratio embeds own their own height (measured / fixed).
const style: CSSProperties = {
containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
contentVisibility: 'auto',
@ -55,9 +71,13 @@ export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
return (
<span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
<RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
<Suspense fallback={null}>
<LazyRenderer descriptor={descriptor} />
</Suspense>
{consented ? (
<Suspense fallback={null}>
<LazyRenderer descriptor={descriptor} />
</Suspense>
) : (
<EmbedFacade descriptor={descriptor} onLoad={() => setLoaded(true)} />
)}
</RichBoundary>
</span>
)

View file

@ -376,6 +376,13 @@ export const en: Translations = {
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
translucencyTitle: 'Window Translucency',
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
embedsTitle: 'Inline Embeds',
embedsDesc:
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
embedsAsk: 'Ask',
embedsAlways: 'Always',
embedsOff: 'Off',
embedsReset: (count: number) => `Reset ${count} allowed ${count === 1 ? 'service' : 'services'}`,
product: 'Product',
productDesc: 'Human-friendly tool activity with concise summaries.',
technical: 'Technical',

View file

@ -286,6 +286,13 @@ export const ja = defineLocale({
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
translucencyTitle: 'ウィンドウの透過',
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
embedsTitle: 'インライン埋め込み',
embedsDesc:
'リッチプレビューは第三者サイトYouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
embedsAsk: '確認',
embedsAlways: '常に',
embedsOff: 'オフ',
embedsReset: (count: number) => `許可した${count}件のサービスをリセット`,
product: 'プロダクト',
productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
technical: 'テクニカル',

View file

@ -301,6 +301,12 @@ export interface Translations {
toolViewDesc: string
translucencyTitle: string
translucencyDesc: string
embedsTitle: string
embedsDesc: string
embedsAsk: string
embedsAlways: string
embedsOff: string
embedsReset: (count: number) => string
product: string
productDesc: string
technical: string

View file

@ -278,6 +278,12 @@ export const zhHant = defineLocale({
toolViewDesc: '產品模式會隱藏原始工具 payload技術模式會顯示完整輸入/輸出。',
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
embedsTitle: '內嵌預覽',
embedsDesc: '豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
embedsAsk: '詢問',
embedsAlways: '一律',
embedsOff: '關閉',
embedsReset: (count: number) => `重設 ${count} 個已允許的服務`,
product: '產品',
productDesc: '易讀的工具活動與精簡摘要。',
technical: '技術',

View file

@ -369,6 +369,12 @@ export const zh: Translations = {
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
embedsTitle: '内嵌预览',
embedsDesc: '富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
embedsAsk: '询问',
embedsAlways: '总是',
embedsOff: '关闭',
embedsReset: (count: number) => `重置 ${count} 个已允许的服务`,
product: '产品',
productDesc: '易读的工具活动与简洁摘要。',
technical: '技术',

View file

@ -0,0 +1,37 @@
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
// Privacy gate for inline embeds. Loading an embed reaches out to a third party
// (IP, referrer, cookies), so by default we render a placeholder until the user
// consents — per embed ("Load once") or per service ("Always allow YouTube").
// Mirrors the tool-approval model, but purely client-side (the renderer is what
// makes the request) so it never touches the gateway/config.yaml.
export type EmbedMode = 'always' | 'ask' | 'off'
const MODE_KEY = 'hermes.desktop.embed-mode'
const ALLOWED_KEY = 'hermes.desktop.embed-allowed'
const modeCodec: Codec<EmbedMode> = {
decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'),
encode: value => value
}
/** Global default: ask (placeholder), always (auto-load), off (plain link). */
export const $embedMode = persistentAtom<EmbedMode>(MODE_KEY, 'ask', modeCodec)
/** Providers granted a standing "always allow" (e.g. `youtube`, `twitter`). */
export const $embedAllowed = persistentAtom<string[]>(ALLOWED_KEY, [], Codecs.stringArray)
export function allowProvider(provider: string) {
const current = $embedAllowed.get()
if (!current.includes(provider)) {
$embedAllowed.set([...current, provider])
}
}
export function setEmbedMode(mode: EmbedMode) {
$embedMode.set(mode)
}
export function clearEmbedAllowed() {
$embedAllowed.set([])
}