diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 583a6fdcd0..085e6a2118 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -21,6 +21,7 @@ import { setDataUrlReadMaxMb } from '@/store/data-url-read-max' import { $disableF12, setDisableF12 } from '@/store/disable-f12' +import { $alwaysExternalLinks, setAlwaysExternalLinks } from '@/store/external-links' import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' import { normalizeProfileKey } from '@/store/profile' @@ -101,6 +102,7 @@ function ConfigSettingsInner({ const c = t.settings.config const keepAwake = useStore($keepAwake) const disableF12 = useStore($disableF12) + const alwaysExternalLinks = useStore($alwaysExternalLinks) // The editable draft is local (debounced autosave watches it), but it's seeded // from — and saved back through — the shared config cache, so edits are visible // in the MCP/model surfaces and reopening the page doesn't reload-flash. @@ -453,6 +455,12 @@ function ConfigSettingsInner({ label={c.disableF12Title} onChange={setDisableF12} /> + diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d1c50181f3..cd42efd002 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1313,6 +1313,9 @@ export const en: Translations = { keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.', disableF12Title: 'Disable F12 DevTools', disableF12Desc: 'Block F12 from opening Developer Tools. Ctrl+Shift+I (or Cmd+Opt+I on Mac) still works.', + alwaysExternalLinksTitle: 'Always open links in external browser', + alwaysExternalLinksDesc: + 'Open every link you click in your system browser instead of the in-app browser. "Open in in-app browser" in the right-click menu still works.', attachmentSizeTitle: 'Max preview / image load size', attachmentSizeDesc: 'How big a local file Desktop will load for previews and image attach, in MB. Default is 16. Remote non-image attach uses a separate 256 MB cap. Setting this very high loads the whole file into memory and can freeze or crash the app.', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index cc8072320a..3595bf23ab 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -1054,6 +1054,9 @@ export const ru = defineLocale({ disableF12Title: 'Отключить F12 DevTools', disableF12Desc: 'Блокирует открытие Developer Tools по F12. Ctrl+Shift+I (на Mac — Cmd+Opt+I) продолжает работать.', + alwaysExternalLinksTitle: 'Всегда открывать ссылки во внешнем браузере', + alwaysExternalLinksDesc: + 'Открывать каждую ссылку в системном браузере вместо встроенного. Пункт «Открыть во встроенном браузере» в контекстном меню продолжает работать.', attachmentSizeTitle: 'Макс. размер превью / загрузки изображений', attachmentSizeDesc: 'Насколько большой локальный файл приложение будет загружать для превью и вложений, в МБ. По умолчанию 16. Для удалённых неграфических вложений действует отдельный лимит 256 МБ. Слишком большое значение загружает весь файл в память и может подвесить или уронить приложение.', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 71c64d97d9..1e63640160 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1092,6 +1092,8 @@ export interface Translations { keepAwakeDesc: string disableF12Title: string disableF12Desc: string + alwaysExternalLinksTitle: string + alwaysExternalLinksDesc: string attachmentSizeTitle: string attachmentSizeDesc: string attachmentSizeUnit: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 86c5f1baeb..94fabba438 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1277,6 +1277,9 @@ export const zh = defineLocale({ keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。', disableF12Title: '禁用 F12 开发者工具', disableF12Desc: '阻止 F12 打开开发者工具。Ctrl+Shift+I(Mac 上为 Cmd+Opt+I)仍然可用。', + alwaysExternalLinksTitle: '始终在外部浏览器中打开链接', + alwaysExternalLinksDesc: + '点击的每个链接都在系统浏览器中打开,而不是应用内浏览器。右键菜单中的“在应用内浏览器中打开”仍然可用。', attachmentSizeTitle: '预览 / 图片加载大小上限', attachmentSizeDesc: '桌面端为预览和图片附件加载本地文件的大小上限(MB)。默认为 16。远程非图片附件使用单独的 256 MB 上限。设置过大会将整个文件读入内存,可能导致应用卡死或崩溃。', diff --git a/apps/desktop/src/lib/external-link.test.tsx b/apps/desktop/src/lib/external-link.test.tsx index 14d99d80af..141b8f54d8 100644 --- a/apps/desktop/src/lib/external-link.test.tsx +++ b/apps/desktop/src/lib/external-link.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import { IS_MAC } from '@/lib/keybinds/combo' +import { setAlwaysExternalLinks } from '@/store/external-links' import { $previewTabs, closeRightRail } from '@/store/preview' import { @@ -41,6 +42,7 @@ function installTitleBridge(title: string) { afterEach(() => { __resetLinkTitleCache() closeRightRail() + setAlwaysExternalLinks(false) vi.restoreAllMocks() cleanup() @@ -136,6 +138,19 @@ describe('external link helpers', () => { expect($previewTabs.get()).toHaveLength(0) }) + it('sends a plain click to the OS browser when "always external" is on', () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] }) + setAlwaysExternalLinks(true) + + render(Example link) + + fireEvent.click(screen.getByRole('link', { name: 'Example link' })) + + expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource') + expect($previewTabs.get()).toHaveLength(0) + }) + it('treats only the HUD renderer as a native-link surface', () => { expect(hudForcesNativeLinks('')).toBe(false) expect(hudForcesNativeLinks('?win=secondary')).toBe(false) diff --git a/apps/desktop/src/lib/external-link.tsx b/apps/desktop/src/lib/external-link.tsx index 26b0b69de8..5314ecafd8 100644 --- a/apps/desktop/src/lib/external-link.tsx +++ b/apps/desktop/src/lib/external-link.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { ArrowUpRight } from '@/lib/icons' import { IS_MAC } from '@/lib/keybinds/combo' +import { $alwaysExternalLinks } from '@/store/external-links' import { resolveBrandIcon } from './brand-icon' import { cn } from './utils' @@ -244,7 +245,8 @@ export function hudForcesNativeLinks(search = typeof window === 'undefined' ? '' * * Everything that ISN'T a web page — `mailto:`, `file:`, a custom scheme — has * no business in the webview and always hands off to the OS. The HUD has no - * browser pane, so it always takes the OS path. + * browser pane, so it always takes the OS path. The "Always open links in + * external browser" setting (`$alwaysExternalLinks`) sends every click there. */ export function openLink(href: string, options: { native?: boolean } = {}): void { const target = normalizeExternalUrl(href) @@ -255,6 +257,7 @@ export function openLink(href: string, options: { native?: boolean } = {}): void if ( options.native || + $alwaysExternalLinks.get() || isConnectorAuthorizationLink(target) || hudForcesNativeLinks() || !/^https?:$/i.test(parseUrl(target)?.protocol ?? '') diff --git a/apps/desktop/src/store/external-links.test.ts b/apps/desktop/src/store/external-links.test.ts new file mode 100644 index 0000000000..f111ea8265 --- /dev/null +++ b/apps/desktop/src/store/external-links.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { storedBoolean } from '@/lib/storage' + +import { $alwaysExternalLinks, setAlwaysExternalLinks } from './external-links' + +const KEY = 'hermes.desktop.alwaysExternalLinks.v1' + +afterEach(() => { + setAlwaysExternalLinks(false) +}) + +describe('always-external-links store', () => { + it('defaults off and persists the pref', () => { + expect($alwaysExternalLinks.get()).toBe(false) + + setAlwaysExternalLinks(true) + expect(storedBoolean(KEY, false)).toBe(true) + + setAlwaysExternalLinks(false) + expect(storedBoolean(KEY, true)).toBe(false) + }) + + it('follows a change made in another window', () => { + window.localStorage.setItem(KEY, 'true') + window.dispatchEvent(new StorageEvent('storage', { key: KEY })) + expect($alwaysExternalLinks.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/external-links.ts b/apps/desktop/src/store/external-links.ts new file mode 100644 index 0000000000..f22c8c0c08 --- /dev/null +++ b/apps/desktop/src/store/external-links.ts @@ -0,0 +1,34 @@ +/** + * Always open links in the system browser — a device-local preference. + * + * Off (the default): a clicked web link opens in the in-app browser, and + * ⌘/Ctrl-click or middle-click escapes to the OS browser. On: every clicked + * link goes to the OS browser (see `openLink` in `@/lib/external-link`). + * + * Renderer-owned: it only decides where THIS machine's link clicks land. The + * `storage` listener keeps every open window in step when one window flips it. + * Explicit "Open in in-app browser" menu actions and agent-driven previews are + * not link clicks and stay unaffected. + */ + +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +const KEY = 'hermes.desktop.alwaysExternalLinks.v1' + +export const $alwaysExternalLinks = atom(typeof window === 'undefined' ? false : storedBoolean(KEY, false)) + +export function setAlwaysExternalLinks(on: boolean): void { + $alwaysExternalLinks.set(on) +} + +if (typeof window !== 'undefined') { + $alwaysExternalLinks.subscribe(on => persistBoolean(KEY, on)) + + window.addEventListener('storage', event => { + if (event.key === KEY) { + $alwaysExternalLinks.set(storedBoolean(KEY, false)) + } + }) +}