From d5bee1dd159c51d737b3da38a9ab9e82559e622d Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:00:25 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(deeplink):=20vixel://open=20=E6=B7=B1?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=20=E2=80=94=E2=80=94=20=E5=8F=B3=E9=94=AE/?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8=E6=8B=B7=E8=B4=9D=EF=BC=88Markdown?= =?UTF-8?q?=EF=BC=89=E9=93=BE=E6=8E=A5=EF=BC=8COS=20=E7=BA=A7=20scheme=20?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=EF=BC=8Chash=20=E5=AE=9A=E4=BD=8D=E6=B4=BB?= =?UTF-8?q?=E8=BF=87=E9=87=8D=E5=BB=BA=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/deeplink.ts:构建/解析纯函数(hash 优先,id 兜底;t 秒级时间点),单元测试 - main/deeplink.ts:macOS open-url(含冷启动队列)+ Windows/Linux 单实例锁 second-instance argv - 解析命中复用快速面板的"在主窗口打开结果"通道(含未加载完的暂存);时间点落到包含它的已索引片段 - 右键菜单 / 检查器新增"拷贝链接""拷贝 Markdown 链接"(网页版隐藏) - electron-builder protocols + dev-bundle.mjs 给开发期 Electron.app 写 CFBundleURLTypes --- electron-builder.yml | 6 + scripts/dev-bundle.mjs | 12 ++ src/main/deeplink.ts | 97 ++++++++++++++++ src/main/index.ts | 37 ++++++- src/main/menu.ts | 5 + src/main/quickPanel.ts | 3 + src/renderer/src/App.tsx | 15 ++- .../src/components/inspector/Inspector.tsx | 11 ++ src/shared/deeplink.test.ts | 67 +++++++++++ src/shared/deeplink.ts | 104 ++++++++++++++++++ src/shared/i18n/main.ts | 5 + src/shared/i18n/menu.ts | 4 + src/shared/types.ts | 2 +- 13 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 src/main/deeplink.ts create mode 100644 src/shared/deeplink.test.ts create mode 100644 src/shared/deeplink.ts diff --git a/electron-builder.yml b/electron-builder.yml index 632d7a9..d6c483f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -24,6 +24,12 @@ files: - '!node_modules/onnxruntime-node/bin/napi-v*/!(${platform})/**' asar: true afterPack: build/adhoc-sign.cjs +# 深链接(vixel://open?…,见 src/shared/deeplink.ts):注册成系统级 URL scheme, +# 笔记里的链接能直接拉起 / 聚焦 Vixel(macOS 写进 Info.plist 的 CFBundleURLTypes,Windows 写注册表) +protocols: + - name: Vixel Deep Link + schemes: + - vixel # asarIntegrity: 防止打包后 asar 被替换;Electron 启动时校验摘要 asarUnpack: - resources/** diff --git a/scripts/dev-bundle.mjs b/scripts/dev-bundle.mjs index a578331..cb112b7 100644 --- a/scripts/dev-bundle.mjs +++ b/scripts/dev-bundle.mjs @@ -54,6 +54,18 @@ try { changed = true } + // 深链接(vixel://open?…)开发期也要能用:macOS 的 URL scheme 必须声明在 Info.plist 里, + // setAsDefaultProtocolClient 才会生效。打包版由 electron-builder.yml 的 protocols 写入,与此无关。 + if (read('CFBundleURLTypes:0:CFBundleURLSchemes:0') !== 'vixel') { + rewrite(plist, readFileSync(plist)) + if (read('CFBundleURLTypes') === null) execFileSync(pb, ['-c', 'Add :CFBundleURLTypes array', plist]) + execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0 dict', plist]) + execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0:CFBundleURLName string Vixel Deep Link', plist]) + execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0:CFBundleURLSchemes array', plist]) + execFileSync(pb, ['-c', 'Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string vixel', plist]) + changed = true + } + if (existsSync(ourIcns)) { const want = readFileSync(ourIcns) if (!readFileSync(icns).equals(want)) { diff --git a/src/main/deeplink.ts b/src/main/deeplink.ts new file mode 100644 index 0000000..ad486f5 --- /dev/null +++ b/src/main/deeplink.ts @@ -0,0 +1,97 @@ +/** + * 深链接的 OS 侧(vixel://open?…,格式见 shared/deeplink.ts): + * + * - macOS:open-url 事件(必须在 app ready 之前就监听好,冷启动的那个链接才接得住) + * - Windows / Linux:OS 把链接放在新实例的 argv 里 —— 单实例锁 + second-instance 转交, + * 冷启动时扫自己的 process.argv + * + * 链接可能先于服务就绪到达(冷启动 / 启动中连点),统一进队列,activate() 之后再解析。 + * 解析按 hash → video → id 的顺序查库(hash 是内容哈希,重建索引后依然有效), + * 命中后复用快速搜索面板的"在主窗口打开结果"通道(含主窗口未建 / 未加载完的暂存逻辑)。 + * + * 与内部资源协议不冲突:protocol.handle('vixel') 只响应渲染进程发起的 thumb/image/media/ + * sprite/face 请求;OS 转交的外部链接走这里的事件,永远不会进 protocol.handle。 + */ +import { app, dialog, type BrowserWindow } from 'electron' +import { resolve as resolvePath } from 'path' +import { parseDeepLink, type DeepLinkTarget } from '../shared/deeplink' +import type { SearchResult } from '../shared/types' +import { t } from '../shared/i18n' + +export interface DeepLinkDeps { + /** 按 target 查库拼出结果(含命中片段);找不到返回 null */ + resolve(target: DeepLinkTarget): SearchResult | null + /** 在主窗口打开(quickPanel.openResult:窗口不存在时创建,加载完前暂存) */ + open(result: SearchResult): void + /** 链接指向的内容不在库里:把主窗口带到前台再说明 */ + ensureMainWindow(): BrowserWindow +} + +let pendingUrls: string[] = [] +let deps: DeepLinkDeps | null = null + +function isDeepLink(arg: string): boolean { + return typeof arg === 'string' && arg.startsWith('vixel://') +} + +function handle(url: string): void { + if (!deps) { + pendingUrls.push(url) + return + } + const target = parseDeepLink(url) + if (!target) { + console.warn(`[deeplink] ignoring malformed link: ${url}`) + return + } + const result = deps.resolve(target) + if (result) { + deps.open(result) + return + } + console.warn(`[deeplink] target not found in library: ${url}`) + const win = deps.ensureMainWindow() + void dialog.showMessageBox(win, { type: 'info', message: t.main.deepLinkMissingTitle, detail: t.main.deepLinkMissingDetail }) +} + +/** + * 进程启动早期调用(app ready 之前):挂 open-url / second-instance 监听。 + * Windows / Linux 需要单实例锁(第二个实例只负责把 argv 递过来);拿不到锁时返回 false, + * 调用方应直接退出 —— 本实例的使命已由 second-instance 完成。 + */ +export function initDeepLinks(): boolean { + if (process.platform === 'darwin') { + app.on('open-url', (event, url) => { + event.preventDefault() + if (isDeepLink(url)) handle(url) + }) + return true + } + // Windows / Linux:深链接到达的是一个新实例的 argv + if (!app.requestSingleInstanceLock()) return false + app.on('second-instance', (_event, argv) => { + const url = argv.find(isDeepLink) + if (url) handle(url) + else deps?.ensureMainWindow() + }) + return true +} + +/** 服务就绪后调用:登记为 vixel:// 的系统默认处理者,并冲掉启动期间攒下的链接 */ +export function activateDeepLinks(d: DeepLinkDeps): void { + deps = d + // 开发模式(process.defaultApp:electron . 启动)要把入口脚本一起登记,否则 OS 只会裸启 Electron + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('vixel', process.execPath, [resolvePath(process.argv[1])]) + } + } else { + app.setAsDefaultProtocolClient('vixel') + } + // Windows / Linux 冷启动:链接在自己的 argv 里 + const argvUrl = process.argv.find(isDeepLink) + if (argvUrl && !pendingUrls.includes(argvUrl)) pendingUrls.push(argvUrl) + const queued = pendingUrls + pendingUrls = [] + for (const url of queued) handle(url) +} diff --git a/src/main/index.ts b/src/main/index.ts index f5626e3..1c083f6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,9 +30,11 @@ import { registerAutoTags } from './autotags' import { registerPdfRenderer, disposePdfRenderer, releasePdfWindowIfLast, PDF_ASSET_SCHEME } from './pdfRender' import { timed, watchEventLoop, perfSnapshot, PROFILE } from '../core/perf' import { setInferenceTransport } from '../core/inference/transport' +import { initDeepLinks, activateDeepLinks } from './deeplink' +import type { DeepLinkTarget } from '../shared/deeplink' import { createServices, META_KEY_INDEX_ON_BATTERY, type VixelServices } from '../services/api' import { createServiceEvents } from '../services/events' -import { IPC_CHANNELS, type MediaFilters, type ThemeMode, type IndexProgress, type EmbeddingConfig, type ImageQueryInput } from '../shared/types' +import { IPC_CHANNELS, type MediaFilters, type ThemeMode, type IndexProgress, type EmbeddingConfig, type ImageQueryInput, type SearchResult } from '../shared/types' import { TAG_CHANNELS } from '../shared/tags' import { AUTOTAG_CHANNELS } from '../shared/autotags' import { CLEANUP_CHANNELS } from '../shared/cleanup' @@ -51,6 +53,12 @@ if (process.env.VIXEL_RECORD) app.commandLine.appendSwitch('force-device-scale-f } app.setAboutPanelOptions({ applicationName: APP_NAME, applicationVersion: app.getVersion() }) +// 深链接(vixel://open?…):open-url / 单实例锁必须在 app ready 之前就绪,冷启动的链接才接得住。 +// Windows / Linux 上拿不到锁说明已有实例在跑 —— 链接已经由 second-instance 转交,这个实例直接退出 +if (!initDeepLinks()) { + app.quit() +} + /** 截图标记回填完成的标记(老库一次性补 photos.is_screenshot) */ const META_KEY_SCREENSHOT_BACKFILL = 'screenshot.backfill' /** @@ -190,6 +198,27 @@ function ensureMainWindow(): BrowserWindow { return win } +/** + * 深链接 → 结果:hash(内容哈希,清库重建 / 移动文件后仍有效)优先,video / photo id 兜底。 + * 时间点尽量落到包含它的已索引片段(详情页时间轴整段高亮),不在任何片段里就按原时间点 seek。 + */ +function resolveDeepLinkTarget(target: DeepLinkTarget): SearchResult | null { + let photo = target.hash ? db.getRepresentativeByHash(target.hash) : undefined + if (!photo && target.videoId != null) { + const video = db.getVideoById(target.videoId) + if (video) photo = db.getRepresentativeByHash(video.fileHash) + } + if (!photo && target.photoId != null) photo = db.getPhoto(target.photoId) + if (!photo) return null + const result: SearchResult = { photo, score: 1 } + if (target.timeMs != null && photo.videoId != null) { + const ms = target.timeMs + const seg = db.getMediaDetail(photo.videoId)?.segments.find((s) => ms >= s.startMs && ms < s.endMs) + result.segment = seg ?? { startMs: ms, endMs: ms } + } + return result +} + /** * Windows / Linux:最后一个"真窗口"(主窗口 / 设置)关掉后,若启用了快速搜索快捷键, * 留在后台并挂一个托盘图标(打开 Vixel / 快速搜索 / 退出),否则按老行为退出。 @@ -760,6 +789,12 @@ app.whenReady().then(async () => { }) rebuildMenu() createWindow() + // 深链接:服务和窗口通道就绪,开始处理(启动期间收到的链接在 deeplink.ts 的队列里等到这一刻) + activateDeepLinks({ + resolve: resolveDeepLinkTarget, + open: (r) => quickPanel!.openResult(r), + ensureMainWindow, + }) startDebugControl() // 只剩设置窗口时点 Dock 图标也要把主窗口找回来 diff --git a/src/main/menu.ts b/src/main/menu.ts index 66709eb..42fe86d 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -142,6 +142,11 @@ export function popupItemMenu( ] as MenuItemConstructorOptions[]), { label: multi ? t.menu.revealMany(revealLabel(), count) : revealLabel(), accelerator: 'CmdOrCtrl+Shift+R', click: pick('reveal') }, { label: multi ? t.menu.copyPaths(count) : t.common.copyPath, click: pick('copy-path') }, + // 深链接(vixel://open?…,见 shared/deeplink.ts):贴进笔记,点击回到 Vixel 定位这一项 + ...(multi ? [] : [ + { label: t.menu.copyLink, click: pick('copy-link') }, + { label: t.menu.copyMarkdownLink, click: pick('copy-md-link') }, + ] as MenuItemConstructorOptions[]), { label: favorite ? t.organize.unfavorite : t.organize.favorite, accelerator: 'F', registerAccelerator: false, click: pick('toggle-favorite') }, ...(multi ? [] : [ { type: 'separator' }, diff --git a/src/main/quickPanel.ts b/src/main/quickPanel.ts index 768800d..0448a48 100644 --- a/src/main/quickPanel.ts +++ b/src/main/quickPanel.ts @@ -53,6 +53,8 @@ export interface QuickPanelController { getWindow(): BrowserWindow | null settings(): QuickPanelSettings isEnabled(): boolean + /** 在主窗口打开一条结果(窗口不存在时创建、加载完前暂存);深链接(main/deeplink.ts)复用这条通道 */ + openResult(result: unknown): void /** Windows:主窗口关闭后是否应留在后台(启用了快捷键);留下时建托盘 */ keepAliveOnWindows(): boolean ensureTray(): void @@ -373,6 +375,7 @@ export function initQuickPanel(deps: QuickPanelDeps): QuickPanelController { getWindow: () => (panel && !panel.isDestroyed() ? panel : null), settings, isEnabled: enabled, + openResult: (result) => deliverToMain({ type: 'result', result }), keepAliveOnWindows: () => process.platform !== 'darwin' && enabled(), ensureTray, hasTray: () => tray != null, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b81c5ee..0f25fee 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -20,6 +20,7 @@ import { thumbUrl } from './lib/mediaUrl' import { sourcePathOf } from './lib/resultActions' import { useSearchHistory, normalizeQuery } from './lib/searchHistory' import { t } from '../../shared/i18n' +import { deepLinkForItem, markdownLinkForItem } from '../../shared/deeplink' import { FilterBar, hasExtraFilters, countExtraFilters, describeExtraFilters, useTags, type ExtraFilters } from './components/shell/FilterBar' import { useSmartAlbums, sameAlbumState, splitAlbumFilters } from './lib/smartAlbums' import type { Tag } from '../../shared/tags' @@ -852,6 +853,16 @@ function App(): JSX.Element { try { await navigator.clipboard.writeText(paths.join('\n')) } catch (e) { console.error('Copy failed:', e) } }, []) + // 深链接(vixel://open?…,见 shared/deeplink.ts):贴进笔记后点击回到 Vixel 定位; + // 命中片段的起点作为时间点,Markdown 形态是 [文件名 @ 0:42](vixel://…) + const copyDeepLinks = useCallback(async (items: SearchResult[], markdown: boolean) => { + const lines = items.map((r) => { + const url = deepLinkForItem(r.photo, r.segment?.startMs) + return markdown ? markdownLinkForItem(r.photo.fileName, url, r.segment?.startMs) : url + }) + try { await navigator.clipboard.writeText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) } + }, []) + // 以此图搜索:直接用库里已存的向量(不再过模型),连视频片段一起搜;芯片缩略图用现成的 vixel://thumb const searchWithItem = useCallback((r: SearchResult) => { startImageQuery({ fileHash: r.photo.fileHash }, r.photo.fileName, thumbUrl(r.photo)) @@ -883,11 +894,13 @@ function App(): JSX.Element { case 'quick-look': setQuickLookId(first.photo.id); break case 'reveal': revealItems(items); break case 'copy-path': copyPaths(items); break + case 'copy-link': copyDeepLinks(items, false); break + case 'copy-md-link': copyDeepLinks(items, true); break case 'search-by-image': searchWithItem(first); break case 'open-external': if (first.photo.videoId != null) window.api.openSourceVideo(first.photo.videoId); break case 'toggle-favorite': toggleFavorite(items); break } - }, [revealItems, copyPaths, searchWithItem, toggleFavorite]) + }, [revealItems, copyPaths, copyDeepLinks, searchWithItem, toggleFavorite]) const handleItemContextMenu = useCallback(async (index: number, e: React.MouseEvent) => { e.preventDefault() diff --git a/src/renderer/src/components/inspector/Inspector.tsx b/src/renderer/src/components/inspector/Inspector.tsx index f68227b..c248d99 100644 --- a/src/renderer/src/components/inspector/Inspector.tsx +++ b/src/renderer/src/components/inspector/Inspector.tsx @@ -4,6 +4,7 @@ import type { ItemSource } from '../../../../shared/collect' import { formatDuration, formatFileSize, mediaKindOf } from '../../lib/format' import { downloadOriginal, imageUrl, mediaSrcUrl, thumbUrl } from '../../lib/mediaUrl' import { IS_WEB, revealLabel } from '../../lib/platform' +import { deepLinkForItem, markdownLinkForItem } from '../../../../shared/deeplink' import { Icon } from '../shell/icons' import { TagEditor } from './TagEditor' import { designBadgeOf } from '../media/MediaCard' @@ -83,6 +84,13 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: const resolution = resolutionOverride ?? (media?.width && media.height ? { w: media.width, h: media.height } : null) const sourcePath = isMedia ? media?.filePath : photo.filePath const dup = photo.duplicateCount && photo.duplicateCount > 1 ? photo.duplicateCount : 0 + + // 深链接:hash 优先(重建索引后仍有效);媒体带上命中片段的起点 + const copyDeepLink = (markdown: boolean): void => { + const url = deepLinkForItem(photo, segment?.startMs) + const text = markdown ? markdownLinkForItem(media?.fileName ?? photo.fileName, url, segment?.startMs) : url + void navigator.clipboard.writeText(text).catch((e) => console.error('Copy failed:', e)) + } // 音频代表图没有图片向量,embed_status 永远 pending,不能算"索引中" const indexState = kind === 'audio' || photo.embedStatus === 'done' ? { label: t.inspector.indexed, cls: 'text-ink-2' } @@ -269,6 +277,9 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: {t.inspector.systemPlayer} )} + {/* 深链接(vixel://open?…):贴进笔记后点击回到 Vixel 定位;网页版隐藏(等价物是 http 链接) */} + copyDeepLink(false)}>{t.menu.copyLink} + copyDeepLink(true)}>{t.menu.copyMarkdownLink} )} diff --git a/src/shared/deeplink.test.ts b/src/shared/deeplink.test.ts new file mode 100644 index 0000000..0754bf9 --- /dev/null +++ b/src/shared/deeplink.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { buildDeepLink, deepLinkForItem, formatLinkTime, markdownLinkForItem, parseDeepLink } from './deeplink' + +describe('buildDeepLink / parseDeepLink', () => { + it('round-trips a photo link (hash + id)', () => { + const url = buildDeepLink({ hash: 'a1b2c3d4e5f60718', photoId: 42 }) + expect(url).toBe('vixel://open?hash=a1b2c3d4e5f60718&id=42') + expect(parseDeepLink(url)).toEqual({ hash: 'a1b2c3d4e5f60718', photoId: 42 }) + }) + + it('round-trips a video link with a timestamp (seconds in URL, ms inside)', () => { + const url = buildDeepLink({ hash: 'deadbeef00112233', videoId: 7, timeMs: 42_400 }) + expect(url).toBe('vixel://open?hash=deadbeef00112233&video=7&t=42') + expect(parseDeepLink(url)).toEqual({ hash: 'deadbeef00112233', videoId: 7, timeMs: 42_000 }) + }) + + it('omits t when zero and accepts fractional seconds when parsing', () => { + expect(buildDeepLink({ videoId: 3, timeMs: 0 })).toBe('vixel://open?video=3') + expect(parseDeepLink('vixel://open?video=3&t=1.5')).toEqual({ videoId: 3, timeMs: 1500 }) + }) + + it('survives re-indexing: hash alone is a valid target', () => { + expect(parseDeepLink('vixel://open?hash=00ff00ff00ff00ff')).toEqual({ hash: '00ff00ff00ff00ff' }) + }) + + it('rejects other hosts, bad ids and hash-less empty links', () => { + expect(parseDeepLink('vixel://thumb/5')).toBeNull() // 内部资源协议,不是深链接 + expect(parseDeepLink('https://open?id=5')).toBeNull() + expect(parseDeepLink('vixel://open')).toBeNull() + expect(parseDeepLink('vixel://open?id=-3')).toBeNull() + expect(parseDeepLink('vixel://open?id=abc')).toBeNull() + expect(parseDeepLink('vixel://open?hash=../etc')).toBeNull() + expect(parseDeepLink('not a url')).toBeNull() + }) + + it('ignores invalid t but keeps the target', () => { + expect(parseDeepLink('vixel://open?video=3&t=NaN')).toEqual({ videoId: 3 }) + expect(parseDeepLink('vixel://open?video=3&t=-5')).toEqual({ videoId: 3 }) + }) +}) + +describe('deepLinkForItem', () => { + it('uses video form for media rows and photo form otherwise', () => { + expect(deepLinkForItem({ id: 9, fileHash: 'aa11bb22cc33dd44', videoId: 5 }, 32_000)) + .toBe('vixel://open?hash=aa11bb22cc33dd44&video=5&t=32') + expect(deepLinkForItem({ id: 9, fileHash: 'aa11bb22cc33dd44', videoId: null })) + .toBe('vixel://open?hash=aa11bb22cc33dd44&id=9') + }) + + it('falls back to bare id when hash is missing', () => { + expect(deepLinkForItem({ id: 9, fileHash: '', videoId: null })).toBe('vixel://open?id=9') + }) +}) + +describe('markdown link', () => { + it('formats timestamps', () => { + expect(formatLinkTime(42_000)).toBe('0:42') + expect(formatLinkTime(3_723_000)).toBe('1:02:03') + }) + + it('builds a markdown link with escaped brackets and optional time', () => { + expect(markdownLinkForItem('beach.mp4', 'vixel://open?video=1&t=42', 42_000)) + .toBe('[beach.mp4 @ 0:42](vixel://open?video=1&t=42)') + expect(markdownLinkForItem('a[b].jpg', 'vixel://open?id=1')) + .toBe('[a\\[b\\].jpg](vixel://open?id=1)') + }) +}) diff --git a/src/shared/deeplink.ts b/src/shared/deeplink.ts new file mode 100644 index 0000000..1021896 --- /dev/null +++ b/src/shared/deeplink.ts @@ -0,0 +1,104 @@ +/** + * 深链接(vixel://open?…):把库里的一条内容变成可以贴进笔记 / 文档的链接, + * 点击后回到 Vixel 并定位到它(音视频跳到时间点)。构建(渲染进程"拷贝链接"、 + * MCP 工具)和解析(主进程 open-url / second-instance)共用这一个模块,纯函数、无依赖。 + * + * 形态(host 固定为 open,与内部资源协议 vixel://thumb|image|media|sprite|face 不冲突 —— + * 那些只在渲染进程内由 protocol.handle 响应,OS 转交的外部链接走 open-url / argv,两条路互不相见): + * + * vixel://open?hash=&id= 图片 + * vixel://open?hash=&video=&t=<秒> 视频 / 音频(t 可省略) + * + * hash 是内容哈希(xxHash64):清库重建、移动文件、换文件夹后 id 都会变,hash 只随内容变 —— + * 解析时 hash 优先,id 只作内容已变 / 旧版链接的兜底,所以链接能活过重建索引。 + */ +import type { Photo } from './types' + +export const DEEPLINK_SCHEME = 'vixel' +export const DEEPLINK_HOST = 'open' + +/** 解析出的目标(都可缺省;resolve 方按 hash → video → id 的顺序找) */ +export interface DeepLinkTarget { + /** 内容哈希(优先;重建索引后依然有效) */ + hash?: string + /** photos.id(图片) */ + photoId?: number + /** videos.id(视频 / 音频) */ + videoId?: number + /** 跳到的时间点(毫秒;仅音视频有意义) */ + timeMs?: number +} + +const posInt = (v: string | null): number | undefined => { + if (!v) return undefined + const n = Number(v) + return Number.isInteger(n) && n > 0 ? n : undefined +} + +/** 内容哈希:xxHash64 的 16 位十六进制(别把任意字符串塞进 SQL 查询参数) */ +const isHash = (v: string): boolean => /^[0-9a-f]{8,64}$/i.test(v) + +/** 构建 vixel://open 链接;timeMs 只在有 videoId 时写入(取整到秒,链接可读) */ +export function buildDeepLink(target: DeepLinkTarget): string { + const params = new URLSearchParams() + if (target.hash) params.set('hash', target.hash) + if (target.videoId != null) { + params.set('video', String(target.videoId)) + if (target.timeMs != null && target.timeMs > 0) params.set('t', String(Math.round(target.timeMs / 1000))) + } else if (target.photoId != null) { + params.set('id', String(target.photoId)) + } + return `${DEEPLINK_SCHEME}://${DEEPLINK_HOST}?${params.toString()}` +} + +/** 结果项 → 链接:媒体(videoId 非空)带时间点,图片不带 */ +export function deepLinkForItem(photo: Pick, timeMs?: number | null): string { + return buildDeepLink( + photo.videoId != null + ? { hash: photo.fileHash || undefined, videoId: photo.videoId, timeMs: timeMs ?? undefined } + : { hash: photo.fileHash || undefined, photoId: photo.id } + ) +} + +/** + * 解析 vixel://open 链接;不是本形态(其他 host、缺所有定位参数、数值非法)返回 null。 + * t 接受小数秒("42" / "42.5"),内部统一毫秒。 + */ +export function parseDeepLink(url: string): DeepLinkTarget | null { + let u: URL + try { + u = new URL(url) + } catch { + return null + } + if (u.protocol !== `${DEEPLINK_SCHEME}:` || u.host !== DEEPLINK_HOST) return null + const target: DeepLinkTarget = {} + const hash = u.searchParams.get('hash') + if (hash && isHash(hash)) target.hash = hash.toLowerCase() + const photoId = posInt(u.searchParams.get('id')) + if (photoId) target.photoId = photoId + const videoId = posInt(u.searchParams.get('video')) + if (videoId) target.videoId = videoId + if (!target.hash && !target.photoId && !target.videoId) return null + const t = u.searchParams.get('t') + if (t) { + const sec = Number(t) + if (Number.isFinite(sec) && sec >= 0) target.timeMs = Math.round(sec * 1000) + } + return target +} + +/** 0:42、1:02:03(Markdown 链接文字里的时间点) */ +export function formatLinkTime(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = String(total % 60).padStart(2, '0') + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}` +} + +/** `[文件名 @ 0:42](vixel://open?…)`;名字里的 [ ] 转义,别破坏 Markdown 结构 */ +export function markdownLinkForItem(fileName: string, url: string, timeMs?: number | null): string { + const label = `${fileName.replace(/([[\]\\])/g, '\\$1')}${timeMs != null && timeMs > 0 ? ` @ ${formatLinkTime(timeMs)}` : ''}` + return `[${label}](${url})` +} diff --git a/src/shared/i18n/main.ts b/src/shared/i18n/main.ts index 39d30bd..c68d033 100644 --- a/src/shared/i18n/main.ts +++ b/src/shared/i18n/main.ts @@ -4,6 +4,9 @@ const zh = { videoMissing: '视频文件已不存在', startupFailedTitle: 'Vixel 无法启动', startupFailedDetail: (msg: string, dataDir: string) => `${msg}\n\n数据目录:${dataDir}`, + // 深链接(vixel://open?…)指向的内容不在库里 + deepLinkMissingTitle: '链接指向的内容不在媒体库中', + deepLinkMissingDetail: '它可能已被移除,或这个链接来自另一个媒体库。', } const en: typeof zh = { @@ -11,6 +14,8 @@ const en: typeof zh = { videoMissing: 'The video file no longer exists', startupFailedTitle: 'Vixel could not start', startupFailedDetail: (msg, dataDir) => `${msg}\n\nData folder: ${dataDir}`, + deepLinkMissingTitle: 'The linked item is not in this library', + deepLinkMissingDetail: 'It may have been removed, or the link points to a different library.', } export default { zh, en } diff --git a/src/shared/i18n/menu.ts b/src/shared/i18n/menu.ts index 61f51e5..299b131 100644 --- a/src/shared/i18n/menu.ts +++ b/src/shared/i18n/menu.ts @@ -36,6 +36,8 @@ const zh = { // 右键菜单 revealMany: (reveal: string, n: number) => `${reveal} ${n} 项`, copyPaths: (n: number) => `拷贝 ${n} 个路径`, + copyLink: '拷贝链接', + copyMarkdownLink: '拷贝 Markdown 链接', openInPlayer: '在系统播放器中打开', } @@ -75,6 +77,8 @@ const en: typeof zh = { homepage: 'Vixel Homepage', revealMany: (reveal, n) => `${reveal} (${n} Items)`, copyPaths: (n) => `Copy ${n} Paths`, + copyLink: 'Copy Link', + copyMarkdownLink: 'Copy Markdown Link', openInPlayer: 'Open in System Player', } diff --git a/src/shared/types.ts b/src/shared/types.ts index 2c66aa0..8dc57da 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,7 +30,7 @@ export type MenuCommand = | 'source:cleanup' /** 网格右键菜单(原生 Menu.popup)里用户选中的动作 */ -export type ItemMenuAction = 'open' | 'quick-look' | 'reveal' | 'copy-path' | 'search-by-image' | 'open-external' | 'toggle-favorite' +export type ItemMenuAction = 'open' | 'quick-look' | 'reveal' | 'copy-path' | 'copy-link' | 'copy-md-link' | 'search-by-image' | 'open-external' | 'toggle-favorite' /** 画面方向:全景 = 长宽比 ≥ 2:1(横竖都算),方形 = 长短边相差 10% 以内 */ export type Orientation = 'landscape' | 'portrait' | 'square' | 'panorama' From 887f639744792feb3eacfad05d3501a7a33abfbd Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:15:25 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(localapi):=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=86=85=E5=B5=8C=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=A0=88=EF=BC=88?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=8E=A5=E5=8F=A3=EF=BC=89+=20/mcp=20?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20MCP=20=E7=AB=AF=E7=82=B9=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E4=B8=8E=20Docker=20=E5=85=B1=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/server/app.ts:把 HTTP 应用从 server/index.ts 抽成 createServerApp(services, 配置), 认证/API/媒体/网页/WS 原样搬移(scripts/server-security.mjs 16 项全过,行为未变) - src/core/mcp/*:无依赖的 MCP 核心(JSON-RPC + initialize/tools),三个只读工具 search_media / get_media / find_similar(结果带 vixel:// 深链接与 http 媒体链接),单元测试 - src/server/mcp.ts:Streamable HTTP 传输(POST JSON、通知 202、GET 405),Bearer 常数时间 比较 + Origin 校验(防 DNS rebinding),单元测试 - 服务器版:VIXEL_MCP_TOKEN(≥16 位)开启 /mcp,docs/server/README.md 中英文档 - 桌面:设置 › 通用 › 本地接口(默认关、127.0.0.1、端口可配;允许局域网访问需口令、绑定 0.0.0.0), MCP 令牌生成/拷贝/重新生成;LAN 地址列表;i18n zh/en - 打包:dist/web 进 extraResources(prepackage 构建网页前端) --- docs/server/README.md | 51 +++ electron-builder.yml | 3 + package.json | 1 + src/core/mcp/server.test.ts | 120 +++++++ src/core/mcp/server.ts | 121 +++++++ src/core/mcp/tools.ts | 221 +++++++++++++ src/main/index.ts | 25 ++ src/main/localApi.ts | 199 ++++++++++++ src/preload/index.ts | 9 + .../components/settings/GeneralSettings.tsx | 4 + .../components/settings/LocalApiSettings.tsx | 153 +++++++++ src/server/apiSurface.ts | 3 + src/server/app.ts | 298 ++++++++++++++++++ src/server/config.ts | 11 + src/server/index.ts | 266 +++------------- src/server/mcp.test.ts | 109 +++++++ src/server/mcp.ts | 102 ++++++ src/shared/i18n/index.ts | 3 +- src/shared/i18n/localapi.ts | 44 +++ src/shared/localapi.ts | 34 ++ src/web/api.ts | 7 + 21 files changed, 1558 insertions(+), 226 deletions(-) create mode 100644 src/core/mcp/server.test.ts create mode 100644 src/core/mcp/server.ts create mode 100644 src/core/mcp/tools.ts create mode 100644 src/main/localApi.ts create mode 100644 src/renderer/src/components/settings/LocalApiSettings.tsx create mode 100644 src/server/app.ts create mode 100644 src/server/mcp.test.ts create mode 100644 src/server/mcp.ts create mode 100644 src/shared/i18n/localapi.ts create mode 100644 src/shared/localapi.ts diff --git a/docs/server/README.md b/docs/server/README.md index 9a6cdda..edc7fa1 100644 --- a/docs/server/README.md +++ b/docs/server/README.md @@ -119,6 +119,31 @@ volumes: | `VIXEL_PORT` | `8080` | 容器内监听端口 | | `VIXEL_COOKIE_SECURE` | 关 | `=1` 会话 cookie 永远带 `Secure`(全程 HTTPS 时用) | | `VIXEL_TRUST_PROXY` | 关 | `=1` 信任反代头:`X-Forwarded-Proto` 为 https 时自动加 `Secure`,登录限速按 `X-Forwarded-For` 计 | +| `VIXEL_MCP_TOKEN` | (不设 = 关闭) | 开启 `/mcp` 端点(AI 客户端的只读 MCP 接口)的 Bearer 令牌,≥ 16 位。生成:`openssl rand -base64 24` | + +## MCP(AI 客户端接口) + +设置 `VIXEL_MCP_TOKEN` 后,服务器在 `/mcp` 提供一个 [MCP](https://modelcontextprotocol.io) +端点(Streamable HTTP),AI 客户端 / Agent 可以用自然语言查询媒体库。工具**全部只读**: +`search_media`(语义 + 图内文字 + 文件名搜索,命中视频时间段,带 vixel:// 深链接和 http 媒体链接)、 +`get_media`(单项详情:OCR 文字、标签、人物等)、`find_similar`(找相似)。 + +```yaml + environment: + VIXEL_MCP_TOKEN: "用 openssl rand -base64 24 生成" +``` + +客户端配置(以 Claude Desktop / Cursor 的远程 MCP 为例):URL `http://<主机>:8080/mcp`, +HTTP 头 `Authorization: Bearer <令牌>`。验证: + +```bash +curl -s http://<主机>:8080/mcp -H "Authorization: Bearer <令牌>" \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +注意:搜索结果(文件名、路径、识别出的文字等)会发给你连接的那个 AI 客户端;令牌与登录口令 +相互独立,泄露任何一个都应立即更换。 ## 反向代理 / HTTPS @@ -251,6 +276,32 @@ search instant. Lower `VIXEL_THREADS` (e.g. `2`) for a gentler, slower index. | `VIXEL_PORT` | `8080` | Port inside the container | | `VIXEL_COOKIE_SECURE` | off | `=1` always sets `Secure` on the session cookie (all-HTTPS setups) | | `VIXEL_TRUST_PROXY` | off | `=1` trusts proxy headers: `Secure` cookie when `X-Forwarded-Proto` is https, login rate limit keyed by `X-Forwarded-For` | +| `VIXEL_MCP_TOKEN` | (unset = disabled) | Bearer token that enables the `/mcp` endpoint (read-only MCP interface for AI clients), min 16 chars. Generate: `openssl rand -base64 24` | + +## MCP (AI client interface) + +With `VIXEL_MCP_TOKEN` set, the server exposes an [MCP](https://modelcontextprotocol.io) +endpoint (Streamable HTTP) at `/mcp`, so AI clients and agents can query the library in +natural language. All tools are **read-only**: `search_media` (semantic + text-in-image + +filename search, with matched video time ranges, a vixel:// deep link and an http media link), +`get_media` (details of one item: OCR text, tags, people, …) and `find_similar`. + +```yaml + environment: + VIXEL_MCP_TOKEN: "generate with: openssl rand -base64 24" +``` + +Client setup (remote MCP in Claude Desktop / Cursor): URL `http://:8080/mcp` with the +HTTP header `Authorization: Bearer `. To verify: + +```bash +curl -s http://:8080/mcp -H "Authorization: Bearer " \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +Note: search results (file names, paths, recognized text, …) are sent to whichever AI client +you connect. The token is independent of the login password; rotate either immediately if leaked. ## Reverse proxy / HTTPS diff --git a/electron-builder.yml b/electron-builder.yml index d6c483f..74d8a04 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -53,6 +53,9 @@ extraResources: # LiteRT-LM 原生库:只带目标平台那一份(npm run models:download 按平台解压到这里) - from: resources/litert/${platform}-${arch} to: litert + # 网页前端(本地接口的浏览器界面;prepackage 跑 npm run build:web 保证它存在) + - from: dist/web + to: web mac: icon: build/icon.icns diff --git a/package.json b/package.json index bd8264c..9bb170f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", + "prepackage": "npm run build:web", "package": "electron-builder", "postinstall": "electron-builder install-app-deps", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", diff --git a/src/core/mcp/server.test.ts b/src/core/mcp/server.test.ts new file mode 100644 index 0000000..c6b61d1 --- /dev/null +++ b/src/core/mcp/server.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import { createMcpServer, JSONRPC_INVALID_PARAMS, JSONRPC_METHOD_NOT_FOUND, MCP_PROTOCOL_VERSION } from './server' +import type { McpToolContext } from './tools' +import type { Photo, SearchResult } from '../../shared/types' + +const photo = (over: Partial = {}): Photo => ({ + id: 1, folderId: 1, filePath: '/lib/beach.jpg', fileName: 'beach.jpg', fileSize: 1000, + fileMtime: 0, fileHash: 'aa11bb22cc33dd44', embedStatus: 'done', createdAt: '', updatedAt: '', + mediaKind: 'image', ...over, +}) + +/** 假上下文:一条图片 + 一条带片段的视频结果 */ +function fakeCtx(over: Partial> = {}): McpToolContext { + const results: SearchResult[] = [ + { photo: photo(), score: 0.91, matchedBy: ['visual'] }, + { + photo: photo({ id: 2, fileName: 'surf.mp4', filePath: '/lib/surf.mp4', fileHash: 'deadbeef00112233', mediaKind: 'video', videoId: 7 }), + score: 0.8, + segment: { startMs: 32_000, endMs: 64_000 }, + matchedBy: ['segment'], + }, + ] + return { + db: { + getPhoto: (id: number) => (id === 1 ? photo() : undefined), + getRepresentativeByHash: (h: string) => (h === 'aa11bb22cc33dd44' ? photo() : undefined), + getVideoById: () => undefined, + getPhotoDetail: () => ({ ...photo(), caption: 'a beach', ocrText: 'SALE 2024' }), + getMediaDetail: () => undefined, + getFacesByHash: () => [{ personName: 'Ann' }, { personName: null }], + ...over, + } as never, + searchEngine: { + search: async () => results, + findSimilar: () => [results[0]], + ...(over.searchEngine as object ?? {}), + } as never, + tagStore: { forHashes: () => ({ all: [{ name: '海滩' }], some: [] }) } as never, + } +} + +const server = () => createMcpServer(fakeCtx(), { name: 'vixel', version: '0.5.1' }) +const req = (id: number, method: string, params?: unknown) => ({ jsonrpc: '2.0', id, method, params }) + +describe('MCP server core', () => { + it('initialize negotiates a supported protocol version and announces tools', async () => { + const res = await server().handleMessage(req(1, 'initialize', { protocolVersion: '2025-03-26' })) + expect(res?.result).toMatchObject({ + protocolVersion: '2025-03-26', + capabilities: { tools: {} }, + serverInfo: { name: 'vixel', version: '0.5.1' }, + }) + // 不认识的版本 → 报自己的最新版 + const res2 = await server().handleMessage(req(2, 'initialize', { protocolVersion: '1999-01-01' })) + expect((res2?.result as { protocolVersion: string }).protocolVersion).toBe(MCP_PROTOCOL_VERSION) + }) + + it('notifications and bad no-id messages produce no response', async () => { + const s = server() + expect(await s.handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull() + expect(await s.handleMessage({ jsonrpc: '1.0', method: 'x' })).toBeNull() + }) + + it('tools/list returns the three read-only tools with schemas', async () => { + const res = await server().handleMessage(req(3, 'tools/list')) + const tools = (res?.result as { tools: Array<{ name: string; inputSchema: unknown }> }).tools + expect(tools.map((t) => t.name)).toEqual(['search_media', 'get_media', 'find_similar']) + for (const t of tools) expect(t.inputSchema).toBeTruthy() + }) + + it('tools/call search_media returns items with deep links and time ranges', async () => { + const res = await server().handleMessage( + req(4, 'tools/call', { name: 'search_media', arguments: { query: '海边' } }), + { httpBase: 'http://127.0.0.1:41721' } + ) + const result = res?.result as { content: Array<{ type: string; text: string }>; structuredContent: { items: Array> } } + expect(result.content[0].type).toBe('text') + const items = result.structuredContent.items + expect(items[0]).toMatchObject({ + id: 1, name: 'beach.jpg', type: 'image', + link: 'vixel://open?hash=aa11bb22cc33dd44&id=1', + httpUrl: 'http://127.0.0.1:41721/media/image/1', + }) + expect(items[1]).toMatchObject({ + videoId: 7, + timeRange: { startSec: 32, endSec: 64 }, + link: 'vixel://open?hash=deadbeef00112233&video=7&t=32', + httpUrl: 'http://127.0.0.1:41721/media/media/7', + }) + }) + + it('tools/call get_media returns OCR text, tags and people', async () => { + const res = await server().handleMessage(req(5, 'tools/call', { name: 'get_media', arguments: { id: 1 } })) + expect((res?.result as { structuredContent: unknown }).structuredContent).toMatchObject({ + found: true, id: 1, ocrText: 'SALE 2024', caption: 'a beach', tags: ['海滩'], people: ['Ann'], + }) + const miss = await server().handleMessage(req(6, 'tools/call', { name: 'get_media', arguments: { id: 999 } })) + expect((miss?.result as { structuredContent: unknown }).structuredContent).toEqual({ found: false }) + }) + + it('rejects bad input as Invalid params, unknown tools and unknown methods', async () => { + const s = server() + const noQuery = await s.handleMessage(req(7, 'tools/call', { name: 'search_media', arguments: {} })) + expect(noQuery?.error?.code).toBe(JSONRPC_INVALID_PARAMS) + const badTool = await s.handleMessage(req(8, 'tools/call', { name: 'delete_everything', arguments: {} })) + expect(badTool?.error?.code).toBe(JSONRPC_INVALID_PARAMS) + const badMethod = await s.handleMessage(req(9, 'resources/list')) + expect(badMethod?.error?.code).toBe(JSONRPC_METHOD_NOT_FOUND) + }) + + it('tool execution failure is reported as an in-band isError result, not a protocol error', async () => { + const boom = createMcpServer( + fakeCtx({ searchEngine: { search: async () => { throw new Error('db locked') } } }), + { name: 'vixel', version: '0.5.1' } + ) + const res = await boom.handleMessage(req(10, 'tools/call', { name: 'search_media', arguments: { query: 'x' } })) + expect(res?.error).toBeUndefined() + expect(res?.result).toMatchObject({ isError: true }) + }) +}) diff --git a/src/core/mcp/server.ts b/src/core/mcp/server.ts new file mode 100644 index 0000000..ef91ea7 --- /dev/null +++ b/src/core/mcp/server.ts @@ -0,0 +1,121 @@ +/** + * 极小的 MCP 服务器核心(不引 SDK):JSON-RPC 2.0 消息进、消息出,与传输无关 —— + * HTTP(Streamable HTTP 的 POST 形态,src/server/mcp.ts)和 stdio(CLI `vixel mcp`)共用。 + * + * 不用官方 @modelcontextprotocol/sdk 的原因:它是 ESM-only 且带 express/zod 等运输层依赖, + * 塞进 electron-vite 主进程包和 CommonJS 的 tsconfig.server/cli 都要折腾;我们只需要 + * 无状态的 initialize / tools,两百行以内自己写完反而可审计(协议面小、全部只读)。 + * + * 覆盖 MCP 规范(2025-06-18;也接受 2025-03-26 / 2024-11-05 的客户端): + * initialize / notifications/initialized / ping / tools/list / tools/call + * 其余方法回 -32601。服务器无状态:不发 server→client 请求,不需要会话。 + */ +import { callTool, TOOL_DEFINITIONS, type McpCallExtra, type McpToolContext } from './tools' + +export const MCP_PROTOCOL_VERSION = '2025-06-18' +const SUPPORTED_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) + +// JSON-RPC 2.0 错误码 +export const JSONRPC_PARSE_ERROR = -32700 +export const JSONRPC_INVALID_REQUEST = -32600 +export const JSONRPC_METHOD_NOT_FOUND = -32601 +export const JSONRPC_INVALID_PARAMS = -32602 +export const JSONRPC_INTERNAL_ERROR = -32603 + +export interface JsonRpcResponse { + jsonrpc: '2.0' + id: string | number | null + result?: unknown + error?: { code: number; message: string } +} + +export interface McpServerInfo { + name: string + version: string + /** initialize 返回的使用说明(客户端会给模型看) */ + instructions?: string +} + +export interface McpServer { + /** + * 处理一条已解析的 JSON-RPC 消息。返回 null 表示没有要回的(通知 / 无 id 的坏消息)。 + * extra:本次调用的传输层环境(http 链接基址等)。 + */ + handleMessage(msg: unknown, extra?: McpCallExtra): Promise +} + +export function errorResponse(id: string | number | null, code: number, message: string): JsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message } } +} + +export function createMcpServer(ctx: McpToolContext, info: McpServerInfo): McpServer { + async function handleRequest(id: string | number, method: string, params: Record, extra: McpCallExtra): Promise { + switch (method) { + case 'initialize': { + const asked = typeof params.protocolVersion === 'string' ? params.protocolVersion : '' + return { + jsonrpc: '2.0', + id, + result: { + // 客户端要的版本我们认识就按它的来,否则报自己的最新版(按规范由客户端决定去留) + protocolVersion: SUPPORTED_VERSIONS.has(asked) ? asked : MCP_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: info.name, version: info.version }, + ...(info.instructions ? { instructions: info.instructions } : {}), + }, + } + } + case 'ping': + return { jsonrpc: '2.0', id, result: {} } + case 'tools/list': + return { jsonrpc: '2.0', id, result: { tools: TOOL_DEFINITIONS } } + case 'tools/call': { + const name = typeof params.name === 'string' ? params.name : '' + const args = (params.arguments && typeof params.arguments === 'object' ? params.arguments : {}) as Record + let outcome + try { + outcome = await callTool(ctx, name, args, extra) + } catch (err) { + // 工具执行失败按规范放进 result(isError),模型能看到并自行调整,而不是协议层错误 + const msg = err instanceof Error ? err.message : String(err) + return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `Tool failed: ${msg}` }], isError: true } } + } + if (!outcome) return errorResponse(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`) + if (outcome.invalidParams) return errorResponse(id, JSONRPC_INVALID_PARAMS, outcome.invalidParams) + return { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: JSON.stringify(outcome.result, null, 2) }], + structuredContent: outcome.result, + }, + } + } + default: + return errorResponse(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${method}`) + } + } + + return { + async handleMessage(msg, extra = {}) { + if (!msg || typeof msg !== 'object' || Array.isArray(msg)) { + return errorResponse(null, JSONRPC_INVALID_REQUEST, 'Expected a JSON-RPC message object') + } + const m = msg as { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown } + const id = typeof m.id === 'string' || typeof m.id === 'number' ? m.id : null + if (m.jsonrpc !== '2.0' || typeof m.method !== 'string') { + // 坏通知(无 id)没有可回的对象 + return id === null ? null : errorResponse(id, JSONRPC_INVALID_REQUEST, 'Not a JSON-RPC 2.0 request') + } + // 通知:notifications/initialized / notifications/cancelled 等都不需要回应 + if (id === null || m.method.startsWith('notifications/')) return null + const params = (m.params && typeof m.params === 'object' ? m.params : {}) as Record + try { + return await handleRequest(id, m.method, params, extra) + } catch (err) { + console.error(`[mcp] ${m.method} failed:`, err) + return errorResponse(id, JSONRPC_INTERNAL_ERROR, err instanceof Error ? err.message : String(err)) + } + }, + } +} diff --git a/src/core/mcp/tools.ts b/src/core/mcp/tools.ts new file mode 100644 index 0000000..20d04c9 --- /dev/null +++ b/src/core/mcp/tools.ts @@ -0,0 +1,221 @@ +/** + * MCP 工具集(只读):AI 客户端用自然语言查本地媒体库。 + * 三个宿主共用:桌面"本地接口"(Electron 主进程里的 HTTP /mcp)、服务器版 Docker 的 /mcp、 + * CLI 的 `vixel mcp`(stdio)—— 所以只依赖 core(db / search / tagStore),不碰服务层。 + * + * 全部工具只读:不提供任何写库 / 写文件 / 执行类工具,连接的 AI 客户端最多看到库的元数据。 + * 返回里带 vixel:// 深链接(shared/deeplink.ts,可贴进笔记)和可选的 http 媒体链接 + * (宿主给出基址时;浏览器里打开仍需登录会话)。 + */ +import type { DatabaseInstance } from '../db' +import type { SearchEngine } from '../search' +import { TagStore } from '../tagStore' +import { deepLinkForItem } from '../../shared/deeplink' +import type { MediaKind, Photo, SearchResult } from '../../shared/types' + +export interface McpToolContext { + db: DatabaseInstance + searchEngine: SearchEngine + tagStore: TagStore +} + +/** 单次调用的环境:http 媒体链接的基址(desktop/服务器给,stdio 没有) */ +export interface McpCallExtra { + httpBase?: string | null +} + +/** MCP tools/list 返回的工具定义(inputSchema 是 JSON Schema) */ +export interface McpToolDefinition { + name: string + description: string + inputSchema: Record +} + +const SEARCH_LIMIT_DEFAULT = 20 +const SEARCH_LIMIT_MAX = 100 + +export const TOOL_DEFINITIONS: McpToolDefinition[] = [ + { + name: 'search_media', + description: + 'Search the local photo/video/audio library with a natural-language query (Chinese and English both work). ' + + 'Combines semantic vectors, text found inside images (OCR), filenames and tags. ' + + 'Returns matching items with scores, the matched video/audio time range, a vixel:// deep link ' + + '(opens the item in the Vixel app, jumping to the timestamp for media) and, when available, an http link to the media.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'What to look for, e.g. "海边的日落" or "waterfall"' }, + kind: { type: 'string', enum: ['image', 'video', 'audio'], description: 'Only return this media type' }, + limit: { type: 'number', description: `Max results (default ${SEARCH_LIMIT_DEFAULT}, max ${SEARCH_LIMIT_MAX})` }, + }, + required: ['query'], + }, + }, + { + name: 'get_media', + description: + 'Full details of one library item by photo id, video id or content hash: file path, dimensions, duration, ' + + 'capture date, caption, text recognized inside the image (OCR), tags, people, favorite flag and links.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Photo id (from search_media results)' }, + video: { type: 'number', description: 'Video/audio id (from search_media results, field videoId)' }, + hash: { type: 'string', description: 'Content hash (field hash in results); survives re-indexing' }, + }, + }, + }, + { + name: 'find_similar', + description: 'Find items visually similar to a library photo (by photo id). Returns the same shape as search_media.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Photo id to find similar items for' }, + limit: { type: 'number', description: `Max results (default 12, max ${SEARCH_LIMIT_MAX})` }, + }, + required: ['id'], + }, + }, +] + +class ToolInputError extends Error {} + +const clampLimit = (v: unknown, def: number): number => { + const n = Number(v) + if (!Number.isFinite(n) || n <= 0) return def + return Math.min(Math.floor(n), SEARCH_LIMIT_MAX) +} + +const posInt = (v: unknown): number | undefined => { + const n = Number(v) + return Number.isInteger(n) && n > 0 ? n : undefined +} + +/** http 媒体链接:图片用原图路由,音视频用源文件路由(支持 Range),浏览器里打开仍需登录 */ +function httpUrlFor(photo: Photo, base: string | null | undefined): string | undefined { + if (!base) return undefined + const root = base.replace(/\/$/, '') + return photo.videoId != null ? `${root}/media/media/${photo.videoId}` : `${root}/media/image/${photo.id}` +} + +function resultItem(r: SearchResult, extra: McpCallExtra): Record { + const p = r.photo + return { + id: p.id, + ...(p.videoId != null ? { videoId: p.videoId } : {}), + name: p.fileName, + path: p.filePath, + type: p.mediaKind ?? 'image', + hash: p.fileHash, + score: Math.round(r.score * 1000) / 1000, + ...(r.matchedBy ? { matchedBy: r.matchedBy } : {}), + ...(r.lowConfidence ? { lowConfidence: true } : {}), + ...(r.segment ? { timeRange: { startSec: r.segment.startMs / 1000, endSec: r.segment.endMs / 1000 } } : {}), + ...(p.takenAt ? { takenAt: p.takenAt } : {}), + link: deepLinkForItem(p, r.segment?.startMs), + ...(httpUrlFor(p, extra.httpBase) ? { httpUrl: httpUrlFor(p, extra.httpBase) } : {}), + } +} + +async function searchMedia(ctx: McpToolContext, args: Record, extra: McpCallExtra): Promise { + const query = typeof args.query === 'string' ? args.query.trim() : '' + if (!query) throw new ToolInputError('query is required') + const kind = args.kind === 'image' || args.kind === 'video' || args.kind === 'audio' ? (args.kind as MediaKind) : undefined + const limit = clampLimit(args.limit, SEARCH_LIMIT_DEFAULT) + const results = await ctx.searchEngine.search(query, limit, kind ? { kind } : undefined) + return { + query, + count: results.length, + // 整组低置信 = 库里没有明显相关的内容,这些只是"最接近"的 + ...(results.length > 0 && results.every((r) => r.lowConfidence) ? { noConfidentMatch: true } : {}), + items: results.map((r) => resultItem(r, extra)), + } +} + +function getMedia(ctx: McpToolContext, args: Record, extra: McpCallExtra): unknown { + const { db, tagStore } = ctx + let photo: Photo | undefined + const hash = typeof args.hash === 'string' && /^[0-9a-f]{8,64}$/i.test(args.hash) ? args.hash.toLowerCase() : undefined + if (hash) photo = db.getRepresentativeByHash(hash) + const videoId = posInt(args.video) + if (!photo && videoId) { + const video = db.getVideoById(videoId) + if (video) photo = db.getRepresentativeByHash(video.fileHash) + } + const photoId = posInt(args.id) + if (!photo && photoId) photo = db.getPhoto(photoId) + if (!photo) { + if (!hash && !videoId && !photoId) throw new ToolInputError('pass one of: id (photo), video (video/audio id) or hash') + return { found: false } + } + + const detail = db.getPhotoDetail(photo.id) + const media = photo.videoId != null ? db.getMediaDetail(photo.videoId) : undefined + const tags = tagStore.forHashes([photo.fileHash]).all.map((t) => t.name) + const people = [...new Set(db.getFacesByHash(photo.fileHash).map((f) => f.personName).filter((n): n is string => !!n))] + return { + found: true, + id: photo.id, + ...(photo.videoId != null ? { videoId: photo.videoId } : {}), + name: media?.fileName ?? photo.fileName, + path: media?.filePath ?? photo.filePath, + type: photo.mediaKind ?? 'image', + hash: photo.fileHash, + sizeBytes: media?.fileSize ?? photo.fileSize, + ...(photo.width && photo.height ? { width: photo.width, height: photo.height } : {}), + ...(media?.durationMs ? { durationMs: media.durationMs } : {}), + ...(photo.takenAt ? { takenAt: photo.takenAt } : {}), + ...(detail?.caption ? { caption: detail.caption } : {}), + ...(detail?.ocrText ? { ocrText: detail.ocrText } : {}), + tags, + people, + favorite: !!photo.favorite, + ...(media ? { segments: media.segments.map((s) => ({ startSec: s.startMs / 1000, endSec: s.endMs / 1000 })) } : {}), + link: deepLinkForItem(photo), + ...(httpUrlFor(photo, extra.httpBase) ? { httpUrl: httpUrlFor(photo, extra.httpBase) } : {}), + } +} + +function findSimilar(ctx: McpToolContext, args: Record, extra: McpCallExtra): unknown { + const photoId = posInt(args.id) + if (!photoId) throw new ToolInputError('id (photo id) is required') + const photo = ctx.db.getPhoto(photoId) + if (!photo?.fileHash) return { found: false, items: [] } + const limit = clampLimit(args.limit, 12) + const results = ctx.searchEngine.findSimilar(photo.fileHash, limit) + return { found: true, count: results.length, items: results.map((r) => resultItem(r, extra)) } +} + +export interface McpToolOutcome { + /** 给纯文本客户端的 JSON 文本 + 给新客户端的结构化结果 */ + result?: unknown + /** 入参不合法(JSON-RPC Invalid params,而不是工具执行失败) */ + invalidParams?: string +} + +/** 执行一个工具;未知工具名返回 null(调用方回 JSON-RPC 错误) */ +export async function callTool( + ctx: McpToolContext, + name: string, + args: Record, + extra: McpCallExtra = {} +): Promise { + try { + switch (name) { + case 'search_media': return { result: await searchMedia(ctx, args, extra) } + case 'get_media': return { result: getMedia(ctx, args, extra) } + case 'find_similar': return { result: findSimilar(ctx, args, extra) } + default: return null + } + } catch (err) { + if (err instanceof ToolInputError) return { invalidParams: err.message } + throw err + } +} + +/** 宿主只有 db 时的便捷构造(CLI / 服务器复用现成实例时不走这里) */ +export function createToolContext(db: DatabaseInstance, searchEngine: SearchEngine): McpToolContext { + return { db, searchEngine, tagStore: new TagStore(db.raw) } +} diff --git a/src/main/index.ts b/src/main/index.ts index 1c083f6..ead76ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,8 @@ import { registerPdfRenderer, disposePdfRenderer, releasePdfWindowIfLast, PDF_AS import { timed, watchEventLoop, perfSnapshot, PROFILE } from '../core/perf' import { setInferenceTransport } from '../core/inference/transport' import { initDeepLinks, activateDeepLinks } from './deeplink' +import { LocalApiServer } from './localApi' +import { LOCALAPI_CHANNELS, type LocalApiPatch } from '../shared/localapi' import type { DeepLinkTarget } from '../shared/deeplink' import { createServices, META_KEY_INDEX_ON_BATTERY, type VixelServices } from '../services/api' import { createServiceEvents } from '../services/events' @@ -75,7 +77,9 @@ let watcher: FileWatcher let indexer: Indexer let searchEngine: SearchEngine let services: VixelServices +let serviceEvents: ReturnType let inference: InferenceProcess | null = null +let localApi: LocalApiServer | null = null let mainWindow: BrowserWindow | null = null let settingsWindow: BrowserWindow | null = null let quickPanel: QuickPanelController | null = null @@ -309,6 +313,7 @@ async function initServices(): Promise { // 服务层事件总线 → 各窗口的 IPC 事件(频道与 renderer 的 window.api.on* 对应) const events = createServiceEvents() + serviceEvents = events events.on((channel, payload) => { switch (channel) { case 'library-changed': return broadcast(IPC_CHANNELS.LIBRARY_CHANGED) @@ -597,6 +602,11 @@ function registerIpcHandlers(): void { // 备份:手动触发 + 状态查询 handle(IPC_CHANNELS.TRIGGER_BACKUP, async () => api.triggerBackup()) handle(IPC_CHANNELS.GET_BACKUP_STATUS, async () => api.getBackupStatus()) + + // 本地接口(设置 › 通用):HTTP API + 网页界面 + /mcp(见 ./localApi.ts) + handle(LOCALAPI_CHANNELS.STATUS, async () => localApi!.status()) + handle(LOCALAPI_CHANNELS.SET, async (_event, patch: LocalApiPatch) => localApi!.update(patch)) + handle(LOCALAPI_CHANNELS.NEW_TOKEN, async () => localApi!.regenerateToken()) } /** @@ -748,6 +758,19 @@ app.whenReady().then(async () => { if (process.env.VIXEL_RECORD) await session.defaultSession.clearStorageData({ storages: ['localstorage'] }) enforceOfflinePolicy() registerVixelProtocol() + // 本地接口(默认关闭):开着时在主进程内跑服务器版同一套 HTTP 栈,复用上面这套 services。 + // 只监听、不发起任何对外请求,渲染进程的离线白名单不受影响 + const prodWebDir = join(process.resourcesPath, 'web') + localApi = new LocalApiServer({ + db, + services, + events: serviceEvents, + searchEngine, + dataDir: app.getPath('userData'), + webDir: existsSync(prodWebDir) ? prodWebDir : join(process.cwd(), 'dist', 'web'), + version: app.getVersion(), + }) + localApi.startIfEnabled() // PDF / AI 的 pdf.js 栅格化服务(隐藏窗口按需创建;Windows/Linux 主路径,macOS 兜底) registerPdfRenderer() registerIpcHandlers() @@ -816,6 +839,8 @@ app.on('before-quit', () => { isQuitting = true // 全局快捷键 / 托盘 / 常驻面板窗口:退出前注销并销毁 quickPanel?.dispose() + // 本地接口:先掐连接再关库(残余请求别打在已关闭的库上) + void localApi?.stop() // VIXEL_PROFILE=1:退出时打印本次会话最慢的操作(p50 / p95 / max) if (PROFILE) console.table(perfSnapshot().slice(0, 25).map((r) => ({ ...r, p50: +r.p50.toFixed(1), p95: +r.p95.toFixed(1), max: +r.max.toFixed(1) }))) watcher?.stopAll() diff --git a/src/main/localApi.ts b/src/main/localApi.ts new file mode 100644 index 0000000..90896be --- /dev/null +++ b/src/main/localApi.ts @@ -0,0 +1,199 @@ +/** + * 本地接口(设置 › 通用 › 本地接口):在 Electron 主进程里跑服务器版同一套 HTTP 栈 + * (src/server/app.ts:/api、/media、/mcp、网页前端、WebSocket 事件),复用桌面已经在跑的 + * services / 数据库实例 —— 手机浏览器看桌面的库,AI 客户端经 /mcp 查询,GitHub issue #3 的 + * "本地网络服务"就是它。 + * + * 安全模型(默认关闭,开了也先只给本机): + * - 默认只监听 127.0.0.1,不要求登录(和浏览器扩展收集服务同一姿势;CSRF 头照常要求) + * - "允许局域网访问" 绑定 0.0.0.0,强制要求口令(没口令直接拒绝开启),此时本机访问也要登录 + * - /mcp 永远要求 bearer 令牌(随机生成、设置页可看可换),与会话 cookie 无关 + * - 本服务只监听、从不发起任何对外请求,渲染进程的离线白名单不受影响 + */ +import { createServer, type Server } from 'http' +import { randomBytes } from 'crypto' +import { networkInterfaces } from 'os' +import type { DatabaseInstance } from '../core/db' +import type { SearchEngine } from '../core/search' +import { createServerApp, type VixelServerApp } from '../server/app' +import { createMcpServer } from '../core/mcp/server' +import { createToolContext } from '../core/mcp/tools' +import type { VixelServices } from '../services/api' +import type { createServiceEvents } from '../services/events' +import { DEFAULT_LOCALAPI_PORT, type LocalApiPatch, type LocalApiStatus } from '../shared/localapi' + +const KEY = { + enabled: 'localapi.enabled', + port: 'localapi.port', + lan: 'localapi.lan', + password: 'localapi.password', + mcpToken: 'localapi.mcp_token', +} as const + +export interface LocalApiDeps { + db: DatabaseInstance + services: VixelServices + events: ReturnType + searchEngine: SearchEngine + /** userData:server-secret、library.db 都在这里 */ + dataDir: string + /** 网页前端构建产物(打包版 /web,开发 dist/web) */ + webDir: string + version: string +} + +export class LocalApiServer { + private server: Server | null = null + private app: VixelServerApp | null = null + private error: string | null = null + + constructor(private deps: LocalApiDeps) {} + + private get enabled(): boolean { return this.deps.db.getMetaState(KEY.enabled) === '1' } + private get lan(): boolean { return this.deps.db.getMetaState(KEY.lan) === '1' } + private get password(): string { return this.deps.db.getMetaState(KEY.password) ?? '' } + private get port(): number { + const p = Number(this.deps.db.getMetaState(KEY.port)) + return Number.isInteger(p) && p >= 1024 && p <= 65535 ? p : DEFAULT_LOCALAPI_PORT + } + + /** /mcp 的 bearer 令牌:首次用到时随机生成并持久化 */ + private get mcpToken(): string { + let tok = this.deps.db.getMetaState(KEY.mcpToken) + if (!tok) { + tok = randomBytes(24).toString('base64url') + this.deps.db.setMetaState(KEY.mcpToken, tok) + } + return tok + } + + /** 正在监听的可访问地址:回环 + (LAN 时)各网卡的 IPv4 */ + private urls(): string[] { + if (!this.server?.listening) return [] + const out = [`http://127.0.0.1:${this.port}`] + if (this.lan) { + for (const addrs of Object.values(networkInterfaces())) { + for (const a of addrs ?? []) { + if (a.family === 'IPv4' && !a.internal) out.push(`http://${a.address}:${this.port}`) + } + } + } + return out + } + + status(): LocalApiStatus { + return { + enabled: this.enabled, + running: this.server?.listening ?? false, + port: this.port, + lan: this.lan, + hasPassword: this.password.length > 0, + mcpToken: this.mcpToken, + urls: this.urls(), + error: this.error, + } + } + + async update(patch: unknown): Promise { + const p = (patch ?? {}) as LocalApiPatch + const { db } = this.deps + if (typeof p.port === 'number' && Number.isInteger(p.port) && p.port >= 1024 && p.port <= 65535) { + db.setMetaState(KEY.port, String(p.port)) + } + if (typeof p.password === 'string') { + const pw = p.password.trim() + if (pw === '' && this.lan) { + // 局域网开着不许清口令;先关 LAN + this.error = 'password-required' + return this.status() + } + if (pw === '' || pw.length >= 4) db.setMetaState(KEY.password, pw) + } + if (typeof p.lan === 'boolean') { + if (p.lan && !this.password) { + this.error = 'password-required' + return this.status() + } + db.setMetaState(KEY.lan, p.lan ? '1' : '0') + } + if (typeof p.enabled === 'boolean') db.setMetaState(KEY.enabled, p.enabled ? '1' : '0') + await this.stop() + if (this.enabled) await this.start() + return this.status() + } + + async regenerateToken(): Promise { + this.deps.db.setMetaState(KEY.mcpToken, randomBytes(24).toString('base64url')) + // 正在跑的话重启,让新令牌立即生效(app 持有的是旧值) + if (this.server) { + await this.stop() + await this.start() + } + return this.status() + } + + startIfEnabled(): void { + if (this.enabled) void this.start() + } + + private start(): Promise { + this.error = null + const { db, services, events, searchEngine, dataDir, webDir, version } = this.deps + const lan = this.lan + const port = this.port + // LAN 必须有口令(update 已拦;这里兜底防直接改库) + if (lan && !this.password) { + this.error = 'password-required' + return Promise.resolve() + } + const app = createServerApp({ + services, + events, + dataDir, + webDir, + // 仅回环时不要求登录(与浏览器扩展收集服务一致);一开局域网就强制口令,连本机也要登录 + password: lan ? this.password : null, + cookieSecure: false, + trustProxy: false, + allowedRoots: () => [dataDir, ...db.getFolders().map((f) => f.path)], + mcp: { + server: createMcpServer(createToolContext(db, searchEngine), { + name: 'vixel', + version, + instructions: 'Read-only access to the user\'s local Vixel photo/video/audio library. Links in results: "link" opens the item in the Vixel desktop app (vixel:// deep link), "httpUrl" serves the media over this local HTTP API.', + }), + token: this.mcpToken, + httpBase: () => `http://127.0.0.1:${port}`, + }, + }) + + return new Promise((resolve) => { + const server = createServer(app.requestListener) + server.on('upgrade', app.handleUpgrade) + server.on('error', (err: NodeJS.ErrnoException) => { + this.error = err.code === 'EADDRINUSE' ? 'port-in-use' : err.message + console.warn('[localapi] server error:', err.message) + this.server = null + app.close() + this.app = null + resolve() + }) + server.listen(port, lan ? '0.0.0.0' : '127.0.0.1', () => { + this.server = server + this.app = app + console.log(`[localapi] serving library on ${lan ? '0.0.0.0' : '127.0.0.1'}:${port} (lan: ${lan}, auth: ${lan ? 'password' : 'none'}, /mcp: bearer token)`) + resolve() + }) + }) + } + + stop(): Promise { + const server = this.server + this.server = null + this.app?.close() // 断开 WebSocket、退订事件总线 + this.app = null + // keep-alive 连接会拖住 close 回调(设置页开关要立等生效):直接掐掉 + server?.closeAllConnections?.() + return new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c9c4a03..b9bdf93 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import { COLLECT_CHANNELS, type CollectResult, type CollectServerStatus, type In import { TAG_CHANNELS, type Tag, type TagMenuAction, type TagsForSelection } from '../shared/tags' import { AUTOTAG_CHANNELS, type AutoTagStatus } from '../shared/autotags' import { QUICKPANEL_CHANNELS, type QuickPanelSettings } from '../shared/quickpanel' +import { LOCALAPI_CHANNELS, type LocalApiPatch, type LocalApiStatus } from '../shared/localapi' /** 面板 ↩ / ⌘O 时主窗口还没加载完的暂存动作(App 挂载后拉取) */ export type QuickPanelPendingAction = { type: 'result'; result: SearchResult } | { type: 'query'; query: string } @@ -339,6 +340,14 @@ const api = { }, }, + // 本地接口(HTTP API + 网页界面 + /mcp;设置 › 通用,桌面专属) + localApi: { + status: (): Promise => ipcRenderer.invoke(LOCALAPI_CHANNELS.STATUS), + set: (patch: LocalApiPatch): Promise => ipcRenderer.invoke(LOCALAPI_CHANNELS.SET, patch), + /** 重新生成 /mcp 的 bearer 令牌(旧令牌立即失效) */ + newToken: (): Promise => ipcRenderer.invoke(LOCALAPI_CHANNELS.NEW_TOKEN), + }, + // 清理相似照片(近重复) cleanup: { scan: (): Promise => ipcRenderer.invoke(CLEANUP_CHANNELS.SCAN), diff --git a/src/renderer/src/components/settings/GeneralSettings.tsx b/src/renderer/src/components/settings/GeneralSettings.tsx index e48a31d..2202dc5 100644 --- a/src/renderer/src/components/settings/GeneralSettings.tsx +++ b/src/renderer/src/components/settings/GeneralSettings.tsx @@ -5,6 +5,7 @@ import { defaultShortcut, eventToAccelerator, formatShortcut, type QuickPanelSet import { IS_MAC, IS_WEB } from '../../lib/platform' import { AutoTagSettings } from './AutoTagSettings' import { CollectSettings } from './CollectSettings' +import { LocalApiSettings } from './LocalApiSettings' /** 外观选项的迷你窗口示意:侧栏 + 内容区两块,颜色写死(预览的就是另一种外观) */ const SWATCH = { @@ -136,6 +137,9 @@ export function GeneralSettings(): JSX.Element { {!IS_WEB && } + {/* 本地接口(HTTP API + 网页界面 + MCP):桌面专属 —— 网页版本身就跑在这个服务上 */} + {!IS_WEB && } + {IS_WEB && } ) diff --git a/src/renderer/src/components/settings/LocalApiSettings.tsx b/src/renderer/src/components/settings/LocalApiSettings.tsx new file mode 100644 index 0000000..6b89451 --- /dev/null +++ b/src/renderer/src/components/settings/LocalApiSettings.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from 'react' +import type { LocalApiStatus } from '../../../../shared/localapi' +import { t } from '../../../../shared/i18n' + +/** + * 设置 › 通用 › 本地接口:桌面内嵌的服务器栈(HTTP API + 网页界面 + /mcp)的开关与配置。 + * 默认关闭;默认只监听 127.0.0.1,"允许局域网访问"强制要求口令(见 main/localApi.ts)。 + */ +export function LocalApiSettings(): JSX.Element { + const [status, setStatus] = useState(null) + const [port, setPort] = useState('') + const [password, setPassword] = useState('') + const [copied, setCopied] = useState(false) + const copiedTimer = useRef | null>(null) + + useEffect(() => { + window.api.localApi.status().then(apply).catch(() => {}) + return () => { if (copiedTimer.current) clearTimeout(copiedTimer.current) } + }, []) + + function apply(s: LocalApiStatus): void { + setStatus(s) + setPort(String(s.port)) + } + + const toggle = (): void => { + if (!status) return + setStatus({ ...status, enabled: !status.enabled }) + window.api.localApi.set({ enabled: !status.enabled }).then(apply).catch(() => {}) + } + + const toggleLan = (): void => { + if (!status) return + window.api.localApi.set({ lan: !status.lan }).then(apply).catch(() => {}) + } + + const commitPort = (): void => { + if (!status) return + const p = Number(port) + if (Number.isInteger(p) && p >= 1024 && p <= 65535 && p !== status.port) { + window.api.localApi.set({ port: p }).then(apply).catch(() => {}) + } else { + setPort(String(status.port)) + } + } + + const commitPassword = (): void => { + const pw = password.trim() + if (!pw) return + window.api.localApi.set({ password: pw }).then((s) => { apply(s); setPassword('') }).catch(() => {}) + } + + const copyToken = (): void => { + if (!status) return + navigator.clipboard.writeText(status.mcpToken).then(() => { + setCopied(true) + if (copiedTimer.current) clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), 1500) + }).catch(() => {}) + } + + const state = !status ? '' : !status.enabled ? t.localapi.stateStopped + : status.error === 'port-in-use' ? t.localapi.portInUse(status.port) + : status.error === 'password-required' ? t.localapi.passwordRequired + : status.error ? t.localapi.startError(status.error) + : status.running ? t.localapi.stateRunning(status.port) + : t.localapi.stateStopped + const bad = !!status?.error && status.enabled + + return ( +
+

{t.localapi.sectionTitle}

+
+ +

{t.localapi.note}

+ + {status?.enabled && ( +
+ {status.urls.length > 0 && ( +
+ {t.localapi.addresses} +
+ {status.urls.map((u) => ( + {u} + ))} +
+
+ )} +
+ {t.localapi.port} + setPort(e.target.value.replace(/\D/g, ''))} + onBlur={commitPort} + onKeyDown={(e) => { if (e.key === 'Enter') commitPort() }} + className="w-20 h-6 px-2 rounded bg-raised border border-line text-caption text-ink tabular-nums focus:outline-none focus:border-accent/50" + /> +
+
+ {t.localapi.passwordLabel} + setPassword(e.target.value)} + onBlur={commitPassword} + onKeyDown={(e) => { if (e.key === 'Enter') commitPassword() }} + autoComplete="new-password" + data-localapi-password + className="w-40 h-6 px-2 rounded bg-raised border border-line text-caption text-ink focus:outline-none focus:border-accent/50" + /> +
+ + {status.error === 'password-required' && ( +

{t.localapi.passwordRequired}

+ )} +

{t.localapi.lanNote}

+ +
+ {t.localapi.mcpEndpoint} + + {`http://127.0.0.1:${status.port}/mcp`} + +
+
+ {t.localapi.mcpToken} + + {status.mcpToken} + + + +
+

{t.localapi.mcpNote}

+
+ )} +
+
+ ) +} diff --git a/src/server/apiSurface.ts b/src/server/apiSurface.ts index 7d3f4e0..cdd0c7b 100644 --- a/src/server/apiSurface.ts +++ b/src/server/apiSurface.ts @@ -116,6 +116,9 @@ export const DESKTOP_ONLY_METHODS = [ 'collect.newToken', 'cleanup.trash', // shell.trashItem + 原生确认框;服务器上原文件以 :ro 挂载 'tags.showMenu', // 原生右键菜单 + 'localApi.status', // 本地接口(桌面在设置里开的那个 HTTP/MCP 服务):服务器版本身就是这个服务 + 'localApi.set', + 'localApi.newToken', ] as const /** diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..0ffdd16 --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,298 @@ +/** + * 与宿主无关的 Vixel HTTP 应用:认证、/api/*、/media/*、/mcp、网页前端与 WebSocket 事件, + * 由 (services, 配置) 直接构造 —— 不碰 core 初始化、不碰进程生命周期。两个宿主共用: + * + * - 服务器版(src/server/index.ts):纯 Node 进程,配置来自环境变量 + * - 桌面"本地接口"(src/main/localApi.ts):Electron 主进程内嵌,配置来自设置页, + * 复用桌面已经在跑的同一套 services / 数据库实例 + * + * 原先这些都长在 index.ts 里;抽出来是为了桌面能开同一套栈而不复制代码。 + * 行为与抽取前完全一致(安全集成测试 scripts/server-security.mjs 锁定)。 + */ +import type { IncomingMessage, ServerResponse } from 'http' +import type { Duplex } from 'stream' +import { loadOrCreateSecret, passwordMatches, LoginRateLimiter, SessionManager, SESSION_COOKIE, SESSION_TTL_MS } from './auth' +import { readJsonBody, readRawBody, parseCookies, sendJson, clientIp, acceptLanguages, HttpError } from './http' +import { ApiDispatcher } from './api' +import { createEventHub } from './events' +import { handleMediaRequest } from './media' +import { handleWebRequest } from './webapp' +import { handleMcpRequest } from './mcp' +import type { McpServer } from '../core/mcp/server' +import { resolveLocale, setLocale } from '../shared/i18n' +import type { VixelServices } from '../services/api' +import type { createServiceEvents } from '../services/events' +import type { ImageQueryInput, MediaFilters } from '../shared/types' + +/** JSON 请求体上限 */ +const JSON_BODY_LIMIT = 10 * 1024 * 1024 +/** 以图搜图上传的图片字节上限 */ +const IMAGE_UPLOAD_LIMIT = 64 * 1024 * 1024 + +export interface ServerAppOptions { + services: VixelServices + /** 服务层事件总线(订阅端):桥接到 WebSocket 广播 */ + events: ReturnType + /** 会话签名密钥所在目录(server-secret;桌面 = userData,服务器 = VIXEL_DATA_DIR) */ + dataDir: string + /** 网页前端构建产物目录(dist/web) */ + webDir: string + /** 单用户口令;null = 不要求登录(服务器版的 VIXEL_ALLOW_NO_AUTH / 桌面的仅回环模式) */ + password: string | null + cookieSecure: boolean + trustProxy: boolean + /** 媒体路由允许读取的根:数据目录 + 监视文件夹(每次现取,增删文件夹立即生效) */ + allowedRoots(): string[] + /** + * /mcp 端点;null = 不开。token 是 bearer 令牌;httpBase 决定结果里的 http 媒体链接基址 + * (桌面给固定地址,Docker 按请求的 Host 头)。 + */ + mcp: { server: McpServer; token: string; httpBase(req: IncomingMessage): string | null } | null + /** + * "跟随系统"的语言跟随浏览器(服务器版:与 createServices 共享同一个数组,原地更新)。 + * 桌面宿主不传 —— 手机浏览器的语言不该改掉桌面应用的界面语言。 + */ + preferredLanguages?: string[] +} + +export interface VixelServerApp { + /** http.createServer 的监听器(内部已兜住异常) */ + requestListener(req: IncomingMessage, res: ServerResponse): void + /** server.on('upgrade'):/events 的 WebSocket(要求有效会话 cookie) */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void + /** 断开所有 WebSocket、退订事件总线(HTTP server 本身由宿主关) */ + close(): void +} + +interface AppState extends ServerAppOptions { + dispatcher: ApiDispatcher + sessions: SessionManager + rateLimiter: LoginRateLimiter + secret: Buffer + requireAuth: boolean +} + +/** + * 界面语言"跟随系统"在服务器上 = 跟随浏览器:服务端生成的文案(自动标签名等)要和网页界面同语言。 + * 浏览器语言变了才重新解析(同一台设备的请求不做任何事)。 + */ +function syncClientLocale(state: AppState, req: IncomingMessage): void { + if (!state.preferredLanguages) return + const langs = acceptLanguages(req.headers['accept-language']) + if (langs.length === 0 || langs[0] === state.preferredLanguages[0]) return + state.preferredLanguages.splice(0, state.preferredLanguages.length, ...langs) + setLocale(resolveLocale(state.services.loadLocaleSetting(), state.preferredLanguages)) +} + +// ─── 认证 ────────────────────────────────────────────────────────────────── + +/** + * 会话 cookie 要不要加 Secure: + * - VIXEL_COOKIE_SECURE=1 → 永远加(只在全程 HTTPS 时用,纯 HTTP 下浏览器会丢弃 cookie) + * - VIXEL_TRUST_PROXY=1 → 看反代头 X-Forwarded-Proto 是否 https(HTTPS 反代 + 内网 HTTP 直连并存时自动分流) + * - 默认不加:NAS 场景常见纯 HTTP 内网;SameSite=Strict + HttpOnly + 自定义头已经双保险 + */ +function cookieSecure(state: AppState, req: IncomingMessage): boolean { + if (state.cookieSecure) return true + if (!state.trustProxy) return false + const proto = req.headers['x-forwarded-proto'] + const first = (Array.isArray(proto) ? proto[0] : proto)?.split(',')[0].trim().toLowerCase() + return first === 'https' +} + +function sessionCookie(state: AppState, req: IncomingMessage, token: string): string { + const secure = cookieSecure(state, req) ? '; Secure' : '' + return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}` +} + +function isAuthed(state: AppState, req: IncomingMessage): boolean { + if (!state.requireAuth) return true + return state.sessions.verify(parseCookies(req.headers.cookie)[SESSION_COOKIE]) +} + +async function handleLogin(state: AppState, req: IncomingMessage, res: ServerResponse): Promise { + const ip = clientIp(req, state.trustProxy) + if (state.rateLimiter.isLimited(ip)) { + sendJson(res, 429, { ok: false, error: 'Too many failed login attempts, try again later' }) + return + } + const body = await readJsonBody<{ password?: unknown }>(req, 64 * 1024) + if (!state.password || !passwordMatches(state.secret, state.password, body.password)) { + state.rateLimiter.recordFailure(ip) + sendJson(res, 401, { ok: false, error: 'Wrong password' }) + return + } + state.rateLimiter.recordSuccess(ip) + res.setHeader('Set-Cookie', sessionCookie(state, req, state.sessions.issue())) + sendJson(res, 200, { ok: true, result: { authenticated: true } }) +} + +// ─── 以图搜图(上传)──────────────────────────────────────────────────────── + +/** + * POST /api/searchByImage 两种形态: + * - application/json:{ args: [input, limit?, filters?] },input 只接受 { fileHash } + * (服务器不接受 path —— 那是宿主机文件系统;bytes 走下面的原始上传) + * - 其他 content-type(application/octet-stream / image/*):body 即图片字节, + * query 参数 ?limit= &text= &filters=(与桌面 searchByImage 的参数对应) + */ +async function handleSearchByImage(state: AppState, req: IncomingMessage, res: ServerResponse, url: URL): Promise { + const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() + if (contentType === 'application/json') { + const body = await readJsonBody<{ args?: unknown[] }>(req, JSON_BODY_LIMIT) + const args = Array.isArray(body.args) ? body.args : [] + const input = (args[0] ?? {}) as Record + if (typeof input.path === 'string' && input.path) { + sendJson(res, 400, { ok: false, error: 'searchByImage by local path is not available on the server; upload the image bytes or pass a fileHash' }) + return + } + if (input.bytes != null) { + sendJson(res, 400, { ok: false, error: 'searchByImage bytes must be uploaded as a raw request body (application/octet-stream), not JSON' }) + return + } + const outcome = await state.dispatcher.call('searchByImage', args) + sendJson(res, outcome.status, outcome.body) + return + } + + // 原始字节上传 + const bytes = await readRawBody(req, IMAGE_UPLOAD_LIMIT) + if (bytes.length === 0) { + sendJson(res, 400, { ok: false, error: 'Empty image upload' }) + return + } + const limitParam = url.searchParams.get('limit') + const limit = limitParam ? Number(limitParam) : undefined + const text = url.searchParams.get('text') ?? undefined + let filters: MediaFilters | undefined + const filtersParam = url.searchParams.get('filters') + if (filtersParam) { + try { filters = JSON.parse(filtersParam) as MediaFilters } catch { throw new HttpError(400, 'filters must be URL-encoded JSON') } + } + const input: ImageQueryInput = { bytes, ...(text ? { text } : {}) } + const result = await state.services.api.searchByImage(input, Number.isFinite(limit) ? limit : undefined, filters) + sendJson(res, 200, { ok: true, result }) +} + +// ─── 请求路由 ────────────────────────────────────────────────────────────── + +async function handleRequest(state: AppState, req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'http://localhost') + const pathname = url.pathname + + // Docker healthcheck:不鉴权,也不暴露任何信息 + if (req.method === 'GET' && pathname === '/healthz') { + sendJson(res, 200, { ok: true }) + return + } + + // ─ MCP(bearer 令牌认证,与会话 cookie 无关;未配置令牌时该路由不存在)─ + if (pathname === '/mcp') { + if (!state.mcp) { + sendJson(res, 404, { ok: false, error: 'not found' }) + return + } + await handleMcpRequest(req, res, { mcp: state.mcp.server, token: state.mcp.token, httpBase: state.mcp.httpBase }) + return + } + + // ─ API ─ + if (pathname.startsWith('/api/')) { + if (req.method !== 'POST') { + sendJson(res, 405, { ok: false, error: 'API methods are POST-only' }) + return + } + // CSRF:跨站发不出自定义头(无 CORS 头 → 预检必败) + if (!req.headers['x-vixel']) { + sendJson(res, 403, { ok: false, error: 'Missing X-Vixel header' }) + return + } + const method = decodeURIComponent(pathname.slice('/api/'.length)) + + if (method === 'login') { + await handleLogin(state, req, res) + return + } + if (!isAuthed(state, req)) { + sendJson(res, 401, { ok: false, error: 'Not authenticated' }) + return + } + if (method === 'logout') { + const secure = cookieSecure(state, req) ? '; Secure' : '' + res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=0`) + sendJson(res, 200, { ok: true, result: null }) + return + } + if (method === 'searchByImage') { + await handleSearchByImage(state, req, res, url) + return + } + syncClientLocale(state, req) + const body = await readJsonBody<{ args?: unknown[] }>(req, JSON_BODY_LIMIT) + const outcome = await state.dispatcher.call(method, body.args ?? []) + sendJson(res, outcome.status, outcome.body) + return + } + + // ─ 媒体 ─ + if (pathname.startsWith('/media/')) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + sendJson(res, 405, { ok: false, error: 'media routes are GET-only' }) + return + } + if (!isAuthed(state, req)) { + sendJson(res, 401, { ok: false, error: 'Not authenticated' }) + return + } + const handled = await handleMediaRequest(pathname, res, req.headers.range ?? null, { + services: state.services, + allowedRoots: state.allowedRoots, + }) + if (!handled) sendJson(res, 404, { ok: false, error: 'not found' }) + return + } + + // ─ 网页前端(dist/web 静态文件 + /login + SPA fallback)─ + if (req.method === 'GET' || req.method === 'HEAD') { + await handleWebRequest(pathname, req, res, { + webDir: state.webDir, + isAuthed: (r) => isAuthed(state, r), + }) + return + } + + sendJson(res, 404, { ok: false, error: 'not found' }) +} + +// ─── 构造 ────────────────────────────────────────────────────────────────── + +export function createServerApp(options: ServerAppOptions): VixelServerApp { + // 会话签名密钥随机生成并持久化(/server-secret):重启后已登录的浏览器不掉线 + const secret = loadOrCreateSecret(options.dataDir) + const state: AppState = { + ...options, + dispatcher: new ApiDispatcher(options.services.api), + secret, + sessions: new SessionManager(secret), + rateLimiter: new LoginRateLimiter(), + requireAuth: options.password != null, + } + + const hub = createEventHub(state.sessions, state.requireAuth) + const unsubscribe = options.events.on((channel, payload) => hub.broadcast(channel, payload)) + + return { + requestListener(req, res) { + handleRequest(state, req, res).catch((err) => { + const status = err instanceof HttpError ? err.status : 500 + if (!res.headersSent) sendJson(res, status, { ok: false, error: err instanceof Error ? err.message : String(err) }) + else res.destroy() + }) + }, + handleUpgrade: (req, socket, head) => hub.handleUpgrade(req, socket, head), + close() { + unsubscribe() + hub.close() + }, + } +} diff --git a/src/server/config.ts b/src/server/config.ts index 8036923..9eccc4e 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -14,6 +14,8 @@ * VIXEL_COOKIE_SECURE =1 会话 cookie 加 Secure(仅 HTTPS 反代场景;纯 HTTP 下登录会失效) * VIXEL_TRUST_PROXY =1 信任反代头:X-Forwarded-Proto=https 时自动加 Secure, * 登录限速按 X-Forwarded-For 的客户端 IP 计 + * VIXEL_MCP_TOKEN /mcp 端点(AI 客户端的只读 MCP 接口)的 bearer 令牌,≥ 16 位; + * 不设则 /mcp 关闭。生成:openssl rand -base64 24 */ import { isAbsolute, join, resolve } from 'path' @@ -31,6 +33,8 @@ export interface ServerConfig { cookieSecure: boolean /** 信任反代头(VIXEL_TRUST_PROXY=1):X-Forwarded-Proto → Secure;X-Forwarded-For → 限速 IP */ trustProxy: boolean + /** /mcp 的 bearer 令牌(VIXEL_MCP_TOKEN);null = /mcp 关闭 */ + mcpToken: string | null } export class ConfigError extends Error {} @@ -62,6 +66,12 @@ export function parseServerConfig(env: Record, repoR throw new ConfigError('VIXEL_PASSWORD must be at least 4 characters') } + // MCP 令牌是机器配置、不用人记:短令牌没有理由存在,直接拒绝 + const mcpToken = env.VIXEL_MCP_TOKEN?.trim() || null + if (mcpToken && mcpToken.length < 16) { + throw new ConfigError('VIXEL_MCP_TOKEN must be at least 16 characters (try: openssl rand -base64 24)') + } + return { dataDir: resolve(dataDir), libraryDirs, @@ -74,5 +84,6 @@ export function parseServerConfig(env: Record, repoR webDir: env.VIXEL_WEB_DIR?.trim() || join(repoRoot, 'dist', 'web'), cookieSecure: env.VIXEL_COOKIE_SECURE === '1', trustProxy: env.VIXEL_TRUST_PROXY === '1', + mcpToken, } } diff --git a/src/server/index.ts b/src/server/index.ts index ab5e95a..d13865b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,17 +11,14 @@ import './bootstrap' // 必须第一个 import:普通 Node 下重定向 better-sqlite3 到 Node ABI 构建 import '../core/inference/noTelemetry' // 在加载 onnxruntime 之前关掉它的遥测 import '../core/stdioGuard' // 日志管道断了不让进程崩 -import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { createServer, type IncomingMessage } from 'http' import { join } from 'path' import { existsSync, mkdirSync } from 'fs' import { parseServerConfig, ConfigError, type ServerConfig } from './config' -import { loadOrCreateSecret, passwordMatches, LoginRateLimiter, SessionManager, SESSION_COOKIE, SESSION_TTL_MS } from './auth' -import { readJsonBody, readRawBody, parseCookies, sendJson, clientIp, acceptLanguages, HttpError } from './http' -import { ApiDispatcher } from './api' -import { createEventHub } from './events' -import { handleMediaRequest } from './media' -import { handleWebRequest } from './webapp' +import { createServerApp } from './app' import { createServerInference } from './inference' +import { createMcpServer } from '../core/mcp/server' +import { createToolContext } from '../core/mcp/tools' import { initDatabase } from '../core/db' import { FileWatcher } from '../core/watcher' import { Indexer } from '../core/indexer' @@ -35,212 +32,33 @@ import { restrictDesignExts } from '../core/image/design' import { registerServerPdfRenderer } from './pdfRender' import { detectScreenshot } from '../core/screenshot' import { resolveLocale, setLocale } from '../shared/i18n' -import { createServices, folderReadable, type VixelServices } from '../services/api' +import { createServices, folderReadable } from '../services/api' import { createServiceEvents } from '../services/events' -import type { ImageQueryInput, MediaFilters } from '../shared/types' /** 编译产物在 dist/server/server/,仓库根在三级之上 */ const REPO_ROOT = join(__dirname, '..', '..', '..') -/** JSON 请求体上限 */ -const JSON_BODY_LIMIT = 10 * 1024 * 1024 -/** 以图搜图上传的图片字节上限 */ -const IMAGE_UPLOAD_LIMIT = 64 * 1024 * 1024 -const META_KEY_SCREENSHOT_BACKFILL = 'screenshot.backfill' - -interface ServerState { - config: ServerConfig - services: VixelServices - dispatcher: ApiDispatcher - sessions: SessionManager - rateLimiter: LoginRateLimiter - secret: Buffer - requireAuth: boolean - /** 媒体路由允许读取的根:数据目录 + 监视文件夹(每次现取,增删文件夹立即生效) */ - allowedRoots: () => string[] - /** "跟随系统"时用的语言:最近一个 API 请求的浏览器语言(单用户,原地更新,服务层共用同一个数组) */ - preferredLanguages: string[] -} - -/** - * 界面语言"跟随系统"在服务器上 = 跟随浏览器:服务端生成的文案(自动标签名等)要和网页界面同语言。 - * 浏览器语言变了才重新解析(同一台设备的请求不做任何事)。 - */ -function syncClientLocale(state: ServerState, req: IncomingMessage): void { - const langs = acceptLanguages(req.headers['accept-language']) - if (langs.length === 0 || langs[0] === state.preferredLanguages[0]) return - state.preferredLanguages.splice(0, state.preferredLanguages.length, ...langs) - setLocale(resolveLocale(state.services.loadLocaleSetting(), state.preferredLanguages)) -} - -// ─── 认证 ────────────────────────────────────────────────────────────────── - -/** - * 会话 cookie 要不要加 Secure: - * - VIXEL_COOKIE_SECURE=1 → 永远加(只在全程 HTTPS 时用,纯 HTTP 下浏览器会丢弃 cookie) - * - VIXEL_TRUST_PROXY=1 → 看反代头 X-Forwarded-Proto 是否 https(HTTPS 反代 + 内网 HTTP 直连并存时自动分流) - * - 默认不加:NAS 场景常见纯 HTTP 内网;SameSite=Strict + HttpOnly + 自定义头已经双保险 - */ -function cookieSecure(state: ServerState, req: IncomingMessage): boolean { - if (state.config.cookieSecure) return true - if (!state.config.trustProxy) return false - const proto = req.headers['x-forwarded-proto'] - const first = (Array.isArray(proto) ? proto[0] : proto)?.split(',')[0].trim().toLowerCase() - return first === 'https' -} - -function sessionCookie(state: ServerState, req: IncomingMessage, token: string): string { - const secure = cookieSecure(state, req) ? '; Secure' : '' - return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}` -} - -function isAuthed(state: ServerState, req: IncomingMessage): boolean { - if (!state.requireAuth) return true - return state.sessions.verify(parseCookies(req.headers.cookie)[SESSION_COOKIE]) -} - -async function handleLogin(state: ServerState, req: IncomingMessage, res: ServerResponse): Promise { - const ip = clientIp(req, state.config.trustProxy) - if (state.rateLimiter.isLimited(ip)) { - sendJson(res, 429, { ok: false, error: 'Too many failed login attempts, try again later' }) - return - } - const body = await readJsonBody<{ password?: unknown }>(req, 64 * 1024) - if (!state.config.password || !passwordMatches(state.secret, state.config.password, body.password)) { - state.rateLimiter.recordFailure(ip) - sendJson(res, 401, { ok: false, error: 'Wrong password' }) - return - } - state.rateLimiter.recordSuccess(ip) - res.setHeader('Set-Cookie', sessionCookie(state, req, state.sessions.issue())) - sendJson(res, 200, { ok: true, result: { authenticated: true } }) -} - -// ─── 以图搜图(上传)──────────────────────────────────────────────────────── - -/** - * POST /api/searchByImage 两种形态: - * - application/json:{ args: [input, limit?, filters?] },input 只接受 { fileHash } - * (服务器不接受 path —— 那是宿主机文件系统;bytes 走下面的原始上传) - * - 其他 content-type(application/octet-stream / image/*):body 即图片字节, - * query 参数 ?limit= &text= &filters=(与桌面 searchByImage 的参数对应) - */ -async function handleSearchByImage(state: ServerState, req: IncomingMessage, res: ServerResponse, url: URL): Promise { - const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() - if (contentType === 'application/json') { - const body = await readJsonBody<{ args?: unknown[] }>(req, JSON_BODY_LIMIT) - const args = Array.isArray(body.args) ? body.args : [] - const input = (args[0] ?? {}) as Record - if (typeof input.path === 'string' && input.path) { - sendJson(res, 400, { ok: false, error: 'searchByImage by local path is not available on the server; upload the image bytes or pass a fileHash' }) - return - } - if (input.bytes != null) { - sendJson(res, 400, { ok: false, error: 'searchByImage bytes must be uploaded as a raw request body (application/octet-stream), not JSON' }) - return - } - const outcome = await state.dispatcher.call('searchByImage', args) - sendJson(res, outcome.status, outcome.body) - return - } - - // 原始字节上传 - const bytes = await readRawBody(req, IMAGE_UPLOAD_LIMIT) - if (bytes.length === 0) { - sendJson(res, 400, { ok: false, error: 'Empty image upload' }) - return - } - const limitParam = url.searchParams.get('limit') - const limit = limitParam ? Number(limitParam) : undefined - const text = url.searchParams.get('text') ?? undefined - let filters: MediaFilters | undefined - const filtersParam = url.searchParams.get('filters') - if (filtersParam) { - try { filters = JSON.parse(filtersParam) as MediaFilters } catch { throw new HttpError(400, 'filters must be URL-encoded JSON') } - } - const input: ImageQueryInput = { bytes, ...(text ? { text } : {}) } - const result = await state.services.api.searchByImage(input, Number.isFinite(limit) ? limit : undefined, filters) - sendJson(res, 200, { ok: true, result }) -} - -// ─── 请求路由 ────────────────────────────────────────────────────────────── - -async function handleRequest(state: ServerState, req: IncomingMessage, res: ServerResponse): Promise { - const url = new URL(req.url ?? '/', 'http://localhost') - const pathname = url.pathname - - // Docker healthcheck:不鉴权,也不暴露任何信息 - if (req.method === 'GET' && pathname === '/healthz') { - sendJson(res, 200, { ok: true }) - return - } - - // ─ API ─ - if (pathname.startsWith('/api/')) { - if (req.method !== 'POST') { - sendJson(res, 405, { ok: false, error: 'API methods are POST-only' }) - return - } - // CSRF:跨站发不出自定义头(无 CORS 头 → 预检必败) - if (!req.headers['x-vixel']) { - sendJson(res, 403, { ok: false, error: 'Missing X-Vixel header' }) - return - } - const method = decodeURIComponent(pathname.slice('/api/'.length)) - - if (method === 'login') { - await handleLogin(state, req, res) - return - } - if (!isAuthed(state, req)) { - sendJson(res, 401, { ok: false, error: 'Not authenticated' }) - return - } - if (method === 'logout') { - const secure = cookieSecure(state, req) ? '; Secure' : '' - res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=0`) - sendJson(res, 200, { ok: true, result: null }) - return - } - if (method === 'searchByImage') { - await handleSearchByImage(state, req, res, url) - return - } - syncClientLocale(state, req) - const body = await readJsonBody<{ args?: unknown[] }>(req, JSON_BODY_LIMIT) - const outcome = await state.dispatcher.call(method, body.args ?? []) - sendJson(res, outcome.status, outcome.body) - return +/** MCP serverInfo 用的应用版本 */ +const VERSION = ((): string => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return (require(join(REPO_ROOT, 'package.json')) as { version?: string }).version ?? '0.0.0' + } catch { + return '0.0.0' } +})() - // ─ 媒体 ─ - if (pathname.startsWith('/media/')) { - if (req.method !== 'GET' && req.method !== 'HEAD') { - sendJson(res, 405, { ok: false, error: 'media routes are GET-only' }) - return - } - if (!isAuthed(state, req)) { - sendJson(res, 401, { ok: false, error: 'Not authenticated' }) - return - } - const handled = await handleMediaRequest(pathname, res, req.headers.range ?? null, { - services: state.services, - allowedRoots: state.allowedRoots, - }) - if (!handled) sendJson(res, 404, { ok: false, error: 'not found' }) - return - } +const META_KEY_SCREENSHOT_BACKFILL = 'screenshot.backfill' - // ─ 网页前端(dist/web 静态文件 + /login + SPA fallback)─ - if (req.method === 'GET' || req.method === 'HEAD') { - await handleWebRequest(pathname, req, res, { - webDir: state.config.webDir, - isAuthed: (r) => isAuthed(state, r), - }) - return +/** /mcp 结果里的 http 媒体链接:按请求的 Host / 反代头拼(服务器自己不知道外部地址) */ +function mcpHttpBase(config: ServerConfig) { + return (req: IncomingMessage): string | null => { + const host = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host + if (!host) return null + const fwd = req.headers['x-forwarded-proto'] + const proto = config.trustProxy && (Array.isArray(fwd) ? fwd[0] : fwd)?.split(',')[0].trim() === 'https' ? 'https' : 'http' + return `${proto}://${host}` } - - sendJson(res, 404, { ok: false, error: 'not found' }) } // ─── 启动 ────────────────────────────────────────────────────────────────── @@ -267,11 +85,7 @@ async function main(): Promise { } mkdirSync(config.dataDir, { recursive: true }) - const secret = loadOrCreateSecret(config.dataDir) - const sessions = new SessionManager(secret) - const rateLimiter = new LoginRateLimiter() - const requireAuth = !!config.password - if (!requireAuth) { + if (!config.password) { console.warn('[server] VIXEL_ALLOW_NO_AUTH=1 — running WITHOUT authentication (local development only)') } @@ -385,23 +199,25 @@ async function main(): Promise { services.backup.schedule() - // ─ HTTP + WebSocket ─ - const dispatcher = new ApiDispatcher(services.api) - const state: ServerState = { - config, services, dispatcher, sessions, rateLimiter, secret, requireAuth, preferredLanguages, + // ─ HTTP + WebSocket + /mcp(与桌面"本地接口"共用的应用工厂,见 ./app.ts)─ + if (config.mcpToken) console.log('[server] MCP endpoint enabled at /mcp (bearer token from VIXEL_MCP_TOKEN)') + const app = createServerApp({ + services, + events, + dataDir: config.dataDir, + webDir: config.webDir, + password: config.password, + cookieSecure: config.cookieSecure, + trustProxy: config.trustProxy, allowedRoots: () => [config.dataDir, ...db.getFolders().map((f) => f.path)], - } - const hub = createEventHub(sessions, requireAuth) - events.on((channel, payload) => hub.broadcast(channel, payload)) - - const server = createServer((req, res) => { - handleRequest(state, req, res).catch((err) => { - const status = err instanceof HttpError ? err.status : 500 - if (!res.headersSent) sendJson(res, status, { ok: false, error: err instanceof Error ? err.message : String(err) }) - else res.destroy() - }) + mcp: config.mcpToken + ? { server: createMcpServer(createToolContext(db, searchEngine), { name: 'vixel', version: VERSION }), token: config.mcpToken, httpBase: mcpHttpBase(config) } + : null, + preferredLanguages, }) - server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head)) + + const server = createServer(app.requestListener) + server.on('upgrade', app.handleUpgrade) server.listen(config.port, config.host, () => { console.log(`[server] Vixel server listening on http://${config.host}:${config.port} (data: ${config.dataDir})`) @@ -413,7 +229,7 @@ async function main(): Promise { if (shuttingDown) return shuttingDown = true console.log(`[server] ${signal} — shutting down`) - hub.close() + app.close() server.close() watcher.stopAll() indexer.stop() diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts new file mode 100644 index 0000000..edcb03e --- /dev/null +++ b/src/server/mcp.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest' +import { Readable } from 'stream' +import type { IncomingMessage, ServerResponse } from 'http' +import { handleMcpRequest, mcpTokenMatches, originAllowed, parseBearer } from './mcp' +import type { McpServer } from '../core/mcp/server' + +const TOKEN = 'test-token-0123456789abcdef' + +/** 假请求:Readable + headers / method(够 readJsonBody 用) */ +function fakeReq(opts: { method?: string; headers?: Record; body?: unknown }): IncomingMessage { + const req = Readable.from(opts.body === undefined ? [] : [Buffer.from(JSON.stringify(opts.body))]) as unknown as IncomingMessage + req.method = opts.method ?? 'POST' + req.headers = { host: '127.0.0.1:41721', ...(opts.headers ?? {}) } + return req +} + +function fakeRes(): { res: ServerResponse; done: Promise<{ status: number; body: unknown }> } { + let status = 0 + const headers: Record = {} + let resolve: (v: { status: number; body: unknown }) => void + const done = new Promise<{ status: number; body: unknown }>((r) => { resolve = r }) + const res = { + setHeader: (k: string, v: unknown) => { headers[k.toLowerCase()] = v }, + writeHead(code: number) { status = code; return this }, + end: (data?: unknown) => { + let body: unknown = null + if (typeof data === 'string' && data) { try { body = JSON.parse(data) } catch { body = data } } + resolve({ status, body }) + }, + } as unknown as ServerResponse + return { res, done } +} + +/** 回显式 MCP 核心:验证传输层转交消息、汇集响应 */ +const echoServer: McpServer = { + async handleMessage(msg) { + const m = msg as { id?: number | string; method?: string } + if (m.id === undefined) return null // 通知 + return { jsonrpc: '2.0', id: m.id ?? null, result: { echo: m.method } } + }, +} + +const deps = { mcp: echoServer, token: TOKEN, httpBase: () => 'http://127.0.0.1:41721' } + +describe('mcpTokenMatches / parseBearer / originAllowed', () => { + it('matches only the exact token', () => { + expect(mcpTokenMatches(TOKEN, TOKEN)).toBe(true) + expect(mcpTokenMatches(TOKEN, TOKEN + 'x')).toBe(false) + expect(mcpTokenMatches(TOKEN, '')).toBe(false) + expect(mcpTokenMatches(TOKEN, undefined)).toBe(false) + expect(mcpTokenMatches(TOKEN, 'x'.repeat(2000))).toBe(false) + }) + + it('parses the Authorization header case-insensitively', () => { + expect(parseBearer(`Bearer ${TOKEN}`)).toBe(TOKEN) + expect(parseBearer(`bearer ${TOKEN}`)).toBe(TOKEN) + expect(parseBearer('Basic abc')).toBeUndefined() + expect(parseBearer(undefined)).toBeUndefined() + }) + + it('allows absent Origin and same-host / localhost origins only', () => { + expect(originAllowed(undefined, '192.168.1.5:41721')).toBe(true) + expect(originAllowed('http://192.168.1.5:41721', '192.168.1.5:41721')).toBe(true) + expect(originAllowed('http://localhost:5173', '127.0.0.1:41721')).toBe(true) + expect(originAllowed('https://evil.example', '127.0.0.1:41721')).toBe(false) + expect(originAllowed('not a url', '127.0.0.1:41721')).toBe(false) + }) +}) + +describe('handleMcpRequest', () => { + const call = async (opts: Parameters[0], d = deps) => { + const { res, done } = fakeRes() + await handleMcpRequest(fakeReq(opts), res, d) + return done + } + + it('rejects a missing or wrong bearer token with 401', async () => { + expect((await call({ body: { jsonrpc: '2.0', id: 1, method: 'ping' } })).status).toBe(401) + expect((await call({ headers: { authorization: 'Bearer wrong' }, body: { jsonrpc: '2.0', id: 1, method: 'ping' } })).status).toBe(401) + }) + + it('rejects foreign origins with 403 even with a valid token', async () => { + const out = await call({ headers: { authorization: `Bearer ${TOKEN}`, origin: 'https://evil.example' }, body: {} }) + expect(out.status).toBe(403) + }) + + it('answers a single JSON-RPC request with application/json', async () => { + const out = await call({ headers: { authorization: `Bearer ${TOKEN}` }, body: { jsonrpc: '2.0', id: 7, method: 'tools/list' } }) + expect(out.status).toBe(200) + expect(out.body).toEqual({ jsonrpc: '2.0', id: 7, result: { echo: 'tools/list' } }) + }) + + it('answers a batch with an array and pure notifications with 202', async () => { + const batch = await call({ headers: { authorization: `Bearer ${TOKEN}` }, body: [ + { jsonrpc: '2.0', id: 1, method: 'a' }, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', id: 2, method: 'b' }, + ] }) + expect(batch.status).toBe(200) + expect(batch.body).toHaveLength(2) + const note = await call({ headers: { authorization: `Bearer ${TOKEN}` }, body: { jsonrpc: '2.0', method: 'notifications/initialized' } }) + expect(note.status).toBe(202) + }) + + it('rejects non-POST methods with 405 (stateless server, no SSE stream)', async () => { + const out = await call({ method: 'GET', headers: { authorization: `Bearer ${TOKEN}` } }) + expect(out.status).toBe(405) + }) +}) diff --git a/src/server/mcp.ts b/src/server/mcp.ts new file mode 100644 index 0000000..b2f7cbc --- /dev/null +++ b/src/server/mcp.ts @@ -0,0 +1,102 @@ +/** + * /mcp 路由:MCP Streamable HTTP 传输(规范 2025-06-18)的最小合规实现。 + * + * POST /mcp body 为一条 JSON-RPC 消息(或旧版客户端的批量数组)→ application/json 响应; + * 纯通知回 202。服务器无状态、不发起 server→client 请求,所以不开 SSE 流 —— + * GET /mcp 按规范允许的方式回 405(不提供服务器推送流)。 + * + * 认证:Authorization: Bearer (设置页生成 / Docker 的 VIXEL_MCP_TOKEN),常数时间比较; + * 不走会话 cookie,浏览器脚本拿不到令牌,天然免 CSRF。按规范校验 Origin(防 DNS rebinding): + * 带 Origin 的请求必须来自本服务自身地址,其余 403 —— MCP 客户端(Claude/Cursor 等)不发 Origin。 + */ +import type { IncomingMessage, ServerResponse } from 'http' +import { createHash, timingSafeEqual } from 'crypto' +import { HttpError, readJsonBody, sendJson } from './http' +import { + errorResponse, JSONRPC_INVALID_REQUEST, JSONRPC_PARSE_ERROR, type JsonRpcResponse, type McpServer, +} from '../core/mcp/server' + +/** JSON-RPC 请求体上限:工具入参都是短文本,1 MB 绰绰有余 */ +const MCP_BODY_LIMIT = 1024 * 1024 + +/** 常数时间的令牌比较(先 hash 到定长,长度差异不泄露) */ +export function mcpTokenMatches(expected: string, provided: unknown): boolean { + if (typeof provided !== 'string' || provided.length === 0 || provided.length > 1024) return false + const h = (s: string): Buffer => createHash('sha256').update(s).digest() + return timingSafeEqual(h(expected), h(provided)) +} + +/** Authorization: Bearer xxx → xxx */ +export function parseBearer(header: string | string[] | undefined): string | undefined { + const raw = Array.isArray(header) ? header[0] : header + const m = /^Bearer\s+(.+)$/i.exec(raw ?? '') + return m?.[1] +} + +/** 带 Origin 的请求必须指向本服务自身(防浏览器里的 DNS rebinding);没有 Origin 的照常放行 */ +export function originAllowed(origin: string | string[] | undefined, host: string | string[] | undefined): boolean { + const o = Array.isArray(origin) ? origin[0] : origin + if (!o) return true + let originHost: string + try { + originHost = new URL(o).host + } catch { + return false + } + const h = (Array.isArray(host) ? host[0] : host) ?? '' + return originHost === h || /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(originHost) +} + +export interface McpHttpDeps { + mcp: McpServer + token: string + /** http 媒体链接的基址(结果里的 httpUrl);按请求现算,null = 不带 http 链接 */ + httpBase(req: IncomingMessage): string | null +} + +/** 处理已路由到 /mcp 的请求(任何方法)。总是响应。 */ +export async function handleMcpRequest(req: IncomingMessage, res: ServerResponse, deps: McpHttpDeps): Promise { + if (!originAllowed(req.headers.origin, req.headers.host)) { + sendJson(res, 403, { ok: false, error: 'Origin not allowed' }) + return + } + if (!mcpTokenMatches(deps.token, parseBearer(req.headers.authorization))) { + res.setHeader('WWW-Authenticate', 'Bearer realm="vixel-mcp"') + sendJson(res, 401, { ok: false, error: 'Missing or invalid bearer token' }) + return + } + if (req.method !== 'POST') { + // 无状态服务器不提供 server→client 推送流;405 是规范允许的回答 + res.setHeader('Allow', 'POST') + sendJson(res, 405, { ok: false, error: 'MCP endpoint is POST-only (no server-initiated stream)' }) + return + } + + let body: unknown + try { + body = await readJsonBody(req, MCP_BODY_LIMIT) + } catch (err) { + if (err instanceof HttpError && err.status === 413) throw err + sendJson(res, 400, errorResponse(null, JSONRPC_PARSE_ERROR, 'Parse error')) + return + } + + const extra = { httpBase: deps.httpBase(req) } + // 2025-03-26 及更早的客户端可能发批量数组;逐条处理,保持顺序 + const messages = Array.isArray(body) ? body : [body] + if (messages.length === 0) { + sendJson(res, 400, errorResponse(null, JSONRPC_INVALID_REQUEST, 'Empty batch')) + return + } + const responses: JsonRpcResponse[] = [] + for (const msg of messages) { + const out = await deps.mcp.handleMessage(msg, extra) + if (out) responses.push(out) + } + if (responses.length === 0) { + // 纯通知(如 notifications/initialized):202 Accepted,无响应体 + res.writeHead(202).end() + return + } + sendJson(res, 200, Array.isArray(body) ? responses : responses[0]) +} diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts index c55a69a..e31862d 100644 --- a/src/shared/i18n/index.ts +++ b/src/shared/i18n/index.ts @@ -33,9 +33,10 @@ import imagesearch from './imagesearch' import collect from './collect' import design from './design' import quickpanel from './quickpanel' +import localapi from './localapi' import web from './web' -const DICTS = { common, shell, settings, menu, main, core, grid, inspector, detail, people, map, tasks, model, organize, albums, cleanup, tags, autotags, imagesearch, collect, design, quickpanel, web } +const DICTS = { common, shell, settings, menu, main, core, grid, inspector, detail, people, map, tasks, model, organize, albums, cleanup, tags, autotags, imagesearch, collect, design, quickpanel, localapi, web } export type Locale = 'zh-CN' | 'en' export type LocaleSetting = 'system' | Locale diff --git a/src/shared/i18n/localapi.ts b/src/shared/i18n/localapi.ts new file mode 100644 index 0000000..93c1818 --- /dev/null +++ b/src/shared/i18n/localapi.ts @@ -0,0 +1,44 @@ +/** 设置 › 通用 › 本地接口(HTTP API + 网页界面 + MCP;桌面专属) */ +const zh = { + sectionTitle: '本地接口', + enable: '启用本地接口', + stateRunning: (port: number) => `运行中 · 端口 ${port}`, + stateStopped: '已停止', + note: '在本机开一个 HTTP 接口和网页界面:浏览器里浏览和搜索整个资料库,脚本与 AI 客户端通过 /mcp 查询。该服务只接受连接,从不对外发起任何请求。', + addresses: '访问地址', + port: '端口', + lan: '允许局域网访问', + lanNote: '同一网络里的手机 / 电脑可以用上面的地址访问;开启后所有访问(包括本机浏览器)都需要口令登录。', + passwordLabel: '访问口令', + passwordPlaceholder: '至少 4 位', + passwordSet: '已设置', + passwordRequired: '先设置访问口令,再开启局域网访问。', + portInUse: (port: number) => `端口 ${port} 被占用,换一个试试`, + startError: (msg: string) => `启动失败:${msg}`, + mcpEndpoint: 'MCP 地址', + mcpToken: 'MCP 令牌', + mcpNote: '在 AI 客户端(Claude Desktop、Cursor 等)里添加上面的 MCP 地址,鉴权用 Bearer 令牌。工具只读;搜索结果(文件名、路径、识别出的文字等)会发送给你连接的那个 AI 客户端。', +} + +const en: typeof zh = { + sectionTitle: 'Local API', + enable: 'Enable local API', + stateRunning: (port) => `Running · port ${port}`, + stateStopped: 'Stopped', + note: 'Serves an HTTP API and web UI on this machine: browse and search the whole library from a browser, and let scripts or AI clients query it via /mcp. The service only accepts connections and never makes outbound requests.', + addresses: 'Addresses', + port: 'Port', + lan: 'Allow LAN access', + lanNote: 'Phones and computers on the same network can use the addresses above; once enabled, every visit (including this machine) requires the password.', + passwordLabel: 'Password', + passwordPlaceholder: 'At least 4 characters', + passwordSet: 'Set', + passwordRequired: 'Set a password before allowing LAN access.', + portInUse: (port) => `Port ${port} is in use — try another`, + startError: (msg) => `Failed to start: ${msg}`, + mcpEndpoint: 'MCP endpoint', + mcpToken: 'MCP token', + mcpNote: 'Add the MCP endpoint above to an AI client (Claude Desktop, Cursor, …) with the bearer token for auth. Tools are read-only; search results (file names, paths, recognized text, …) are sent to whichever AI client you connect.', +} + +export default { zh, en } diff --git a/src/shared/localapi.ts b/src/shared/localapi.ts new file mode 100644 index 0000000..1635954 --- /dev/null +++ b/src/shared/localapi.ts @@ -0,0 +1,34 @@ +/** 本地接口(设置 › 通用):主进程 ↔ 渲染进程共用的类型和 IPC 通道(桌面专属) */ + +export const LOCALAPI_CHANNELS = { + STATUS: 'localapi:status', + SET: 'localapi:set', + NEW_TOKEN: 'localapi:new-token', +} as const + +/** 默认端口:紧挨浏览器扩展收集服务(41720),同样取自动态端口段之外 */ +export const DEFAULT_LOCALAPI_PORT = 41721 + +export interface LocalApiStatus { + enabled: boolean + running: boolean + port: number + /** 允许局域网访问(绑定 0.0.0.0);开着时必须有口令 */ + lan: boolean + /** 已设置口令(口令本身不回传渲染进程) */ + hasPassword: boolean + /** /mcp 的 bearer 令牌(设置页显示 / 拷贝 / 重新生成) */ + mcpToken: string + /** 正在监听时的可访问地址;lan 开着时包含各网卡的局域网地址 */ + urls: string[] + /** 启动 / 配置失败:'port-in-use' | 'password-required',其他为原始错误信息 */ + error: string | null +} + +export interface LocalApiPatch { + enabled?: boolean + port?: number + lan?: boolean + /** 设置 / 修改口令(≥ 4 位;空串 = 清除,局域网开着时会被拒绝) */ + password?: string +} diff --git a/src/web/api.ts b/src/web/api.ts index e2612db..4373a68 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -275,6 +275,13 @@ export async function createWebApi(): Promise { onCollected: noopEvent as (cb: (r: CollectResult) => void) => () => void, }, + // 本地接口:桌面在设置里开的 HTTP/MCP 服务 —— 网页版本身就跑在这个服务上,没有可配置的 + localApi: { + status: desktopOnly('localApi.status'), + set: desktopOnly('localApi.set'), + newToken: desktopOnly('localApi.newToken'), + }, + // 清理相似照片(trash 除外:原文件在服务器上建议只读挂载) cleanup: { scan: () => post('cleanup.scan'), From 83388aa660e075bfbe9ddaca71e3bdb1182a3d2b Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:19:13 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(cli):=20vixel=20mcp=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20stdio=20=E5=BD=A2=E6=80=81=E7=9A=84=20MCP=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=EF=BC=88=E5=8F=AA=E8=AF=BB=EF=BC=89?= =?UTF-8?q?=EF=BC=8CREADME/CHANGELOG=20=E4=B8=AD=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/cli/mcp.ts:stdin 每行一条 JSON-RPC,响应写 stdout;日志全走 stderr(mcp 命令下 console.log 重定向),库按 CLI 惯例只读打开,工具与 /mcp 完全相同(core/mcp 共用) - README zh/en:Claude Desktop / Cursor 配置片段(Electron 作运行时 + ELECTRON_RUN_AS_NODE=1, 解释 better-sqlite3 ABI 的原因),新特性条目;CHANGELOG Unreleased --- CHANGELOG.md | 18 +++++++++++++++ README.en.md | 29 +++++++++++++++++++++++ README.md | 31 ++++++++++++++++++++++++- src/cli/index.ts | 12 +++++++++- src/cli/mcp.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/cli/mcp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e1fef..fbb3989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Deep links for your notes (`vixel://open?…`).** Right-click any item (or use the + inspector) → Copy Link / Copy Markdown Link, paste into Obsidian / Notes / anywhere; + clicking the link opens Vixel at that item, and video/audio jumps to the timestamp + (`[clip.mp4 @ 0:42](vixel://…)`). Links are addressed by content hash, so they keep + working after re-indexing or moving files. The `vixel` scheme is registered with the + OS (macOS `open-url` incl. cold start; Windows/Linux single-instance argv hand-off). +- **Local API (Settings → General, off by default).** Runs the server edition's HTTP + stack inside the desktop app against the same library: web UI + JSON API on + `127.0.0.1:41721` (port configurable), optional LAN access (binds 0.0.0.0, password + required — browse your desktop library from a phone), and a read-only MCP endpoint + at `/mcp` (Streamable HTTP, bearer token with copy/regenerate in Settings). +- **MCP for AI clients.** Read-only tools `search_media` / `get_media` / `find_similar` + (results carry scores, matched video time ranges, `vixel://` deep links and http media + links). Available three ways: desktop Local API `/mcp`, server edition `/mcp` + (enable with `VIXEL_MCP_TOKEN`), and `vixel mcp` on stdio for Claude Desktop / + Cursor / Claude Code without any network service (setup snippet in the README). + ## [0.5.1] - 2026-10-15 ### Fixed diff --git a/README.en.md b/README.en.md index 9ea4d36..f508215 100644 --- a/README.en.md +++ b/README.en.md @@ -29,6 +29,8 @@ Local AI search for photos, videos and audio. Fully offline, no account, nothing - **Clean up similar photos.** Finds burst shots, re-exports and lightly edited copies, suggests which one to keep, and hides the rest in Vixel or moves them to the Trash after you confirm. - **Offline map, similar photos, de-duplication, HEIC / RAW, quick look, dark mode.** - **CLI for scripts and agents:** `vixel search "sunset at the beach" --json`. +- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link gives a `vixel://` deep link to paste into Obsidian, Notes or any editor; clicking it opens Vixel at that item (video/audio jumps to the timestamp). Links are addressed by content hash, so they survive re-indexing. +- **Local API (off by default).** Settings → General → Local API serves a web UI and HTTP API for your desktop library — enable LAN access (password required) to browse it from your phone — plus a read-only MCP endpoint at `/mcp` for AI clients (see below). Video segment search @@ -113,6 +115,33 @@ means full support (ARM: `grep -o asimddp /proc/cpuinfo`). Full deployment guide (compose, Synology walkthrough, permissions, reverse proxy / HTTPS, backup, limitations): [docs/server/README.md](docs/server/README.md). +## MCP (let AI clients query your library) + +`vixel mcp` runs a **stdio MCP server** with read-only tools (`search_media` / `get_media` / +`find_similar`), so Claude Desktop, Cursor or Claude Code can search your library **without +any network service**. Results include `vixel://` deep links that open the item in the app +(videos jump to the matched timestamp). It needs a source checkout (`npm install && +npm run build:cli`; models inside an installed Vixel.app are found automatically): + +```json +{ + "mcpServers": { + "vixel": { + "command": "/absolute/path/Vixel/node_modules/.bin/electron", + "args": ["/absolute/path/Vixel/dist/cli/cli/index.js", "mcp"], + "env": { "ELECTRON_RUN_AS_NODE": "1" } + } + } +} +``` + +> Why Electron as the runtime: the repo's better-sqlite3 is compiled for Electron's ABI; +> with `ELECTRON_RUN_AS_NODE=1` Electron behaves as plain Node and opens no window. + +No source checkout? The desktop app's Settings → General → **Local API** exposes the same +tools over Streamable HTTP at `http://127.0.0.1:41721/mcp` (bearer token shown in Settings); +the server edition enables it with `VIXEL_MCP_TOKEN` (see [docs/server/README.md](docs/server/README.md)). + ## Build from source Node.js 20+. diff --git a/README.md b/README.md index 4cac266..d4b22f8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ SCRFD 人脸检测 + MobileFaceNet 512 维特征 + sqlite-vec 近邻索引。 - **HEIC / RAW** —— macOS 上通过系统 `sips` 解码 CR2 / CR3 / NEF / ARW / DNG / RAF / ORF / RW2,保留 EXIF。 - **快速查看** —— 空格预览,⌘1–5 切换 全部 / 图片 / 视频 / 音频 / 地图。 - **命令行** —— `vixel search "海边" --json`,方便脚本和 Agent 调用(见下文);结果里的 `matchedBy` 说明是画面、图中文字还是文件名命中。 +- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接」得到 `vixel://` 深链接,贴进 Obsidian、备忘录等任意笔记,点击回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点)。链接按内容哈希定位,清库重建、移动文件后仍然有效。 +- **本地接口(默认关闭)** —— 设置 → 通用 → 本地接口:在本机开出网页界面和 HTTP API,手机浏览器也能访问桌面的资料库(局域网访问必须设口令);同一服务的 `/mcp` 是给 AI 客户端的只读 MCP 接口(见下文)。 - **中英文界面** —— 跟随系统语言,也可在 设置 → 通用 里切换。 - **诊断** —— `vixel doctor` / `vixel bench` 体检和测速,`VIXEL_PROFILE=1` 打印慢操作,见 [docs/debugging.md](docs/debugging.md)。 @@ -252,9 +254,36 @@ npm run cli -- people npm run cli -- stats ``` -支持 `search` · `info` · `similar` · `people` · `stats` · `caption` · `folders`, +支持 `search` · `info` · `similar` · `people` · `stats` · `caption` · `folders` · `mcp`, `--json` 输出便于脚本和 AI Agent 调用。需要先运行一次应用以创建照片库。 +### MCP(让 AI 客户端查你的库) + +`vixel mcp` 把同一套查询变成 **stdio 形态的 MCP 服务器**(工具全部只读:`search_media` / +`get_media` / `find_similar`),Claude Desktop、Cursor、Claude Code 等本地 AI 客户端 +**不开任何网络服务**就能搜你的库;结果里的 `vixel://` 链接点开直接回到应用并定位(视频跳到时间点)。 +需要源码仓库(`npm install && npm run build:cli`;已安装的 Vixel.app 里的语义模型会被自动找到), +客户端里这样配置(Claude Desktop 的 `claude_desktop_config.json`、Cursor 的 `mcp.json` 同形): + +```json +{ + "mcpServers": { + "vixel": { + "command": "/绝对路径/Vixel/node_modules/.bin/electron", + "args": ["/绝对路径/Vixel/dist/cli/cli/index.js", "mcp"], + "env": { "ELECTRON_RUN_AS_NODE": "1" } + } + } +} +``` + +> 为什么用 Electron 当运行时:仓库的 better-sqlite3 按 Electron 的 ABI 构建,普通 `node` 加载不了; +> `ELECTRON_RUN_AS_NODE=1` 下 Electron 就是一个纯 Node,不会弹窗口。 + +不想碰源码:桌面应用 设置 → 通用 → **本地接口** 打开后,同样的工具在 `http://127.0.0.1:41721/mcp` +(Streamable HTTP + Bearer 令牌,设置页里拷贝);服务器版设 `VIXEL_MCP_TOKEN` 同理, +见 [docs/server/README.md](docs/server/README.md)。 + ## 开发 ``` diff --git a/src/cli/index.ts b/src/cli/index.ts index c3e64d5..908e851 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ import { initDatabase, type DatabaseInstance } from '../core/db' import { SearchEngine } from '../core/search' import { initEmbeddingServicePath, getEmbeddingService } from '../core/embedding' import { cmdDoctor, cmdBench } from './doctor' +import { cmdMcp } from './mcp' import { litertPlatformDir } from '../core/embedding/providers/litert/native' // 跨平台 userData 路径。打包版目录名取 productName(Vixel),开发版取 package.json name(vixel); @@ -366,6 +367,7 @@ Usage: vixel folders List watched folders vixel doctor Health check: counts, queue errors, query plans vixel bench Time hot queries and searches on this library + vixel mcp MCP server on stdio for AI clients (read-only; see README) Flags: --json JSON output (for scripts/agents) @@ -387,6 +389,11 @@ Environment: const { command, positional, flags } = parseArgs(args) + // MCP:stdout 是 JSON-RPC 协议通道,任何日志(迁移提示、模型加载)都改走 stderr + if (command === 'mcp') { + console.log = (...a: unknown[]) => console.error(...a) + } + // 检查数据库 const dbPath = getDbPath() if (!existsSync(dbPath)) { @@ -398,7 +405,7 @@ Environment: // 初始化。只有改 caption 需要写库;其余只读打开,不跑迁移,应用开着也安全 const modelsDir = getModelsDir() initEmbeddingServicePath(getUserDataPath(), modelsDir, getLiteRtDir()) - if (!modelsDir && (command === 'search' || command === 'similar' || command === 'bench')) { + if (!modelsDir && (command === 'search' || command === 'similar' || command === 'bench' || command === 'mcp')) { console.error('Note: models not found — semantic search disabled (set VIXEL_MODELS_DIR). Text and filename matching still work.') } const writes = command === 'caption' && flags.set !== undefined @@ -440,6 +447,9 @@ Environment: case 'bench': await cmdBench(db, flags) break + case 'mcp': + await cmdMcp(db) + break default: console.error(`Unknown command: ${command}`) console.error('Run "vixel --help" for usage.') diff --git a/src/cli/mcp.ts b/src/cli/mcp.ts new file mode 100644 index 0000000..ecf9f7e --- /dev/null +++ b/src/cli/mcp.ts @@ -0,0 +1,60 @@ +/** + * `vixel mcp` —— stdio 传输的 MCP 服务器:本地 AI 客户端(Claude Desktop / Cursor / Claude Code) + * 不开任何网络服务就能查询媒体库。工具与桌面"本地接口"、服务器版 /mcp 完全相同 + * (core/mcp:search_media / get_media / find_similar,全部只读),数据库按 CLI 惯例只读打开。 + * + * 传输即 MCP 规范的 stdio 形态:stdin 每行一条 JSON-RPC 消息,响应单行 JSON 写 stdout; + * 日志一律走 stderr(cli/index.ts 在 mcp 命令下已把 console.log 重定向过去)。 + * stdin 关闭(客户端退出)即结束。 + */ +import { createInterface } from 'readline' +import { join } from 'path' +import type { DatabaseInstance } from '../core/db' +import { SearchEngine } from '../core/search' +import { getEmbeddingService } from '../core/embedding' +import { createMcpServer, errorResponse, JSONRPC_PARSE_ERROR } from '../core/mcp/server' +import { createToolContext } from '../core/mcp/tools' + +/** serverInfo 用的应用版本(dist/cli/cli → 仓库根在三级之上) */ +function appVersion(): string { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return (require(join(__dirname, '..', '..', '..', 'package.json')) as { version?: string }).version ?? '0.0.0' + } catch { + return '0.0.0' + } +} + +export async function cmdMcp(db: DatabaseInstance): Promise { + // 语义模型尽力加载(找不到模型时 OCR / 文件名 / 标签搜索照常);失败原因已在 index.ts 提示过 + try { + await getEmbeddingService().init() + } catch { /* BM25-only */ } + + const mcp = createMcpServer(createToolContext(db, new SearchEngine(db)), { + name: 'vixel', + version: appVersion(), + instructions: + 'Read-only access to the user\'s local Vixel photo/video/audio library. ' + + 'The "link" field in results is a vixel:// deep link that opens the item in the Vixel app ' + + '(for video/audio it jumps to the matched timestamp).', + }) + + console.error('[mcp] vixel stdio server ready (read-only); waiting for JSON-RPC on stdin') + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }) + const write = (msg: unknown): void => { process.stdout.write(JSON.stringify(msg) + '\n') } + for await (const line of rl) { + const s = line.trim() + if (!s) continue + let msg: unknown + try { + msg = JSON.parse(s) + } catch { + write(errorResponse(null, JSONRPC_PARSE_ERROR, 'Parse error')) + continue + } + // stdio 没有 http 基址:结果只带 vixel:// 深链接 + const out = await mcp.handleMessage(msg) + if (out) write(out) + } +} From 2810b82c34de24cf2423c2f9a903d2041652ab43 Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:30:46 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20MCP=20=E7=BB=93=E6=9E=9C=E7=BB=99?= =?UTF-8?q?=E9=9F=B3=E8=A7=86=E9=A2=91=E6=BA=90=E6=96=87=E4=BB=B6=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=9B=E6=B7=B1=E9=93=BE=E6=8E=A5=E4=BB=BB=E6=84=8F?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=82=B9=E7=B2=BE=E7=A1=AE=20seek=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=90=B8=E9=99=84=E7=89=87=E6=AE=B5=E8=B5=B7?= =?UTF-8?q?=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测桌面全链路后修正两处: - core/mcp/tools.ts:音视频的 photo 行是代表帧,path/name 改查 videos 表给源文件 - main/index.ts:t 恰为片段起点(拷贝链接的产物)才用整段(时间轴整段高亮), 其余时间点起点保持精确,播放器按 t seek(之前被吸附到片段起点,t 丢失) - deeplink 成功打开记一条日志;localapi 无口令拒开 LAN 时记警告 --- src/core/mcp/tools.ts | 12 +++++++----- src/main/deeplink.ts | 1 + src/main/index.ts | 4 +++- src/main/localApi.ts | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/mcp/tools.ts b/src/core/mcp/tools.ts index 20d04c9..c95f3ff 100644 --- a/src/core/mcp/tools.ts +++ b/src/core/mcp/tools.ts @@ -100,13 +100,15 @@ function httpUrlFor(photo: Photo, base: string | null | undefined): string | und return photo.videoId != null ? `${root}/media/media/${photo.videoId}` : `${root}/media/image/${photo.id}` } -function resultItem(r: SearchResult, extra: McpCallExtra): Record { +function resultItem(ctx: McpToolContext, r: SearchResult, extra: McpCallExtra): Record { const p = r.photo + // 音视频的 photo 行是代表图(抽帧 / 封面):路径要给源文件的 + const source = p.videoId != null ? ctx.db.getVideoById(p.videoId) : undefined return { id: p.id, ...(p.videoId != null ? { videoId: p.videoId } : {}), - name: p.fileName, - path: p.filePath, + name: source?.fileName ?? p.fileName, + path: source?.filePath ?? p.filePath, type: p.mediaKind ?? 'image', hash: p.fileHash, score: Math.round(r.score * 1000) / 1000, @@ -130,7 +132,7 @@ async function searchMedia(ctx: McpToolContext, args: Record, e count: results.length, // 整组低置信 = 库里没有明显相关的内容,这些只是"最接近"的 ...(results.length > 0 && results.every((r) => r.lowConfidence) ? { noConfidentMatch: true } : {}), - items: results.map((r) => resultItem(r, extra)), + items: results.map((r) => resultItem(ctx, r, extra)), } } @@ -185,7 +187,7 @@ function findSimilar(ctx: McpToolContext, args: Record, extra: if (!photo?.fileHash) return { found: false, items: [] } const limit = clampLimit(args.limit, 12) const results = ctx.searchEngine.findSimilar(photo.fileHash, limit) - return { found: true, count: results.length, items: results.map((r) => resultItem(r, extra)) } + return { found: true, count: results.length, items: results.map((r) => resultItem(ctx, r, extra)) } } export interface McpToolOutcome { diff --git a/src/main/deeplink.ts b/src/main/deeplink.ts index ad486f5..7cac99c 100644 --- a/src/main/deeplink.ts +++ b/src/main/deeplink.ts @@ -46,6 +46,7 @@ function handle(url: string): void { } const result = deps.resolve(target) if (result) { + console.log(`[deeplink] opening ${url}`) deps.open(result) return } diff --git a/src/main/index.ts b/src/main/index.ts index ead76ff..2eb35cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -217,8 +217,10 @@ function resolveDeepLinkTarget(target: DeepLinkTarget): SearchResult | null { const result: SearchResult = { photo, score: 1 } if (target.timeMs != null && photo.videoId != null) { const ms = target.timeMs + // 正好是某个已索引片段的起点("拷贝链接"产出的就是)→ 用整段,详情页时间轴能整段高亮; + // 任意时间点 → 起点保持精确(播放器按 startMs seek),终点借所在片段的 const seg = db.getMediaDetail(photo.videoId)?.segments.find((s) => ms >= s.startMs && ms < s.endMs) - result.segment = seg ?? { startMs: ms, endMs: ms } + result.segment = seg && seg.startMs === ms ? seg : { startMs: ms, endMs: Math.max(ms, seg?.endMs ?? ms) } } return result } diff --git a/src/main/localApi.ts b/src/main/localApi.ts index 90896be..3f46232 100644 --- a/src/main/localApi.ts +++ b/src/main/localApi.ts @@ -144,6 +144,7 @@ export class LocalApiServer { // LAN 必须有口令(update 已拦;这里兜底防直接改库) if (lan && !this.password) { this.error = 'password-required' + console.warn('[localapi] LAN access requires a password — not starting') return Promise.resolve() } const app = createServerApp({ From 46c1fcb75f9dbb12e10abcdf76139f11005e94de Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:40:18 +0800 Subject: [PATCH 5/5] fix(localapi): reject non-loopback Host in password-less mode (DNS rebinding) The desktop local API binds 127.0.0.1 without a password and relied on the X-Vixel header against CSRF. A page that rebinds its own domain to 127.0.0.1 is same-origin, can send that header and would read the whole library. Without a password only accept Host localhost / 127.0.0.1 / [::1] (HTTP and WebSocket). Verified live: normal request 200, forged Host 403 for /api and /media. --- src/server/app.test.ts | 15 +++++++++++++++ src/server/app.ts | 23 ++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/server/app.test.ts diff --git a/src/server/app.test.ts b/src/server/app.test.ts new file mode 100644 index 0000000..f725954 --- /dev/null +++ b/src/server/app.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { loopbackHost } from './app' + +describe('loopbackHost (DNS rebinding guard for the password-less local API)', () => { + it('accepts loopback hosts with or without a port', () => { + for (const h of ['localhost', 'localhost:41721', '127.0.0.1:41721', '[::1]:41721', 'LOCALHOST:41721']) { + expect(loopbackHost(h), h).toBe(true) + } + }) + it('rejects rebinding hosts, LAN addresses and missing Host', () => { + for (const h of ['evil.com:41721', 'localhost.evil.com', '127.0.0.1.nip.io:41721', '192.168.1.15:41721', '', undefined]) { + expect(loopbackHost(h as string | undefined), String(h)).toBe(false) + } + }) +}) diff --git a/src/server/app.ts b/src/server/app.ts index 0ffdd16..2a08ff0 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -105,6 +105,16 @@ function sessionCookie(state: AppState, req: IncomingMessage, token: string): st return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}` } +/** + * 免口令模式(桌面"本地接口"只绑 127.0.0.1)只接受 Host 为回环地址的请求:防 DNS rebinding —— + * 恶意网页把自己的域名解析到 127.0.0.1 后,Origin / Host 都是它自己的域名,同源请求能带上 X-Vixel, + * 不校验 Host 就能读走整个库。有口令时(局域网 / Docker)由会话认证兜底,不做这层限制 + */ +export function loopbackHost(host: string | string[] | undefined): boolean { + const h = Array.isArray(host) ? host[0] : host + return !!h && /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(h) +} + function isAuthed(state: AppState, req: IncomingMessage): boolean { if (!state.requireAuth) return true return state.sessions.verify(parseCookies(req.headers.cookie)[SESSION_COOKIE]) @@ -186,6 +196,11 @@ async function handleRequest(state: AppState, req: IncomingMessage, res: ServerR return } + if (!state.requireAuth && !loopbackHost(req.headers.host)) { + sendJson(res, 403, { ok: false, error: 'Host not allowed' }) + return + } + // ─ MCP(bearer 令牌认证,与会话 cookie 无关;未配置令牌时该路由不存在)─ if (pathname === '/mcp') { if (!state.mcp) { @@ -289,7 +304,13 @@ export function createServerApp(options: ServerAppOptions): VixelServerApp { else res.destroy() }) }, - handleUpgrade: (req, socket, head) => hub.handleUpgrade(req, socket, head), + handleUpgrade: (req, socket, head) => { + if (!state.requireAuth && !loopbackHost(req.headers.host)) { + socket.destroy() + return + } + hub.handleUpgrade(req, socket, head) + }, close() { unsubscribe() hub.close()