feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关

基于 PR #74 的颗粒度讨论向前兼容拓展服务化 API:
- TabDescriptor 新增可选 urlTarget(url) 谓词:聊天/界面外链被拦截时按注册
  顺序路由到第一个命中的插件 tab 类型(openTab({ type, url, title })),
  内置 browser 退居隐式兜底;SIDEBAR_FEATURES 追加 'urlTarget'
- 浏览器链接设置改为二级菜单(齿轮弹窗):新增 browserInterceptHttp
  (默认开)+ browserInterceptHttps(默认关);browserInterceptLinks
  保留为总闸(默认开,旧文档零迁移)
- 拦截管线:registerLinkInterception 的 takeoverEnabled 改收解析后的
  URL,协议/同源纯判定(shouldInterceptLink)签名不变
- 测试:matchUrlTarget(注册顺序/抛错吞掉/browser 永不命中)、jsdom
  点击接管门控、parsePrefs 新字段默认值;全默认字面量同步
- 文档:设计文档 + AGENTS.md(字段/示例/设置键)+ README(外链分流)
This commit is contained in:
Menghuan1918
2026-08-15 15:05:06 +08:00
parent f842fa0029
commit 29f162dac0
18 changed files with 425 additions and 49 deletions
+45
View File
@@ -169,6 +169,22 @@ export interface TabDescriptor {
* When omitted, a default `{ id, type, title }` tab is created.
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* External-link target claim (v0.13.0+): when a GUI external-link click
* is taken over (the `browserInterceptLinks` master AND the URL's
* protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
* are on), the first registered tab whose `urlTarget(url)` returns true
* is opened with `openTab({ type, url, title: hostname })` — the URL is
* the whole payload (the tab reads it from `tab.path`). Registration
* order wins (first claim first served); a disabled tab type is skipped;
* a throwing predicate is swallowed (console.error, the type is skipped).
* The built-in browser tab declares NO urlTarget — it stays the implicit
* fallback target, so plugins can never be shadowed by it. To host more
* than one URL at a time, mint per-URL ids through `createTab` (the
* browser builtin's pattern); otherwise the id safety net focuses the
* existing tab of the same type and the new URL is not applied.
*/
urlTarget?: (url: URL) => boolean
/**
* Declarative settings shown in the Side card settings page: every
* registered tab gets an enable/disable switch (icon + title + id), and
@@ -373,6 +389,33 @@ function baseNameOf(path: string): string {
return at === -1 ? path : path.slice(at + 1)
}
/**
* Find the tab type that claims an intercepted external-link URL (v0.13.0+).
* Walks the descriptors in REGISTRATION order and returns the first one
* that declares `urlTarget` and matches `url`; a throwing predicate is
* swallowed (console.error, type skipped) so one broken plugin can never
* break the whole link pipeline. The caller passes the ENABLED tab
* descriptors (enablement is the caller's prefs domain — filter
* `service.getTabs()` through `tabsEnabled` before matching) and falls
* back to the built-in browser tab when nothing claims the URL (the
* browser never declares `urlTarget` itself, so it can never shadow a
* plugin claim).
*/
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
for (const tab of tabs) {
if (tab.urlTarget === undefined) continue
let claimed = false
try {
claimed = tab.urlTarget(url) === true
} catch (error) {
console.error('[dsh-better-sidebar] urlTarget error:', error)
continue
}
if (claimed) return tab
}
return undefined
}
/**
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
@@ -390,6 +433,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.1'
* - 'stateSubscription': getSnapshot/subscribeState
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -400,6 +444,7 @@ export const SIDEBAR_FEATURES = [
'stateSubscription',
'tabMeta',
'pluginSettings',
'urlTarget',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */