diff --git a/docs/features/device-sharing/README.md b/docs/features/device-sharing/README.md index 67ad2636..a860f670 100644 --- a/docs/features/device-sharing/README.md +++ b/docs/features/device-sharing/README.md @@ -1,7 +1,11 @@ # 设备发现与共享 -本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接 -方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。 +设备共享从“应用 → 设备共享”或“管理应用 → 设备共享 → 打开”进入。当前首期连接本地未认证 +ShareServer 元数据服务,默认地址为 `http://127.0.0.1:8787`,可注册本机、浏览设备与发布目录、 +发布能力或知识的元数据并撤销本机 ID 的条目。设备 ID 只用于识别,不是所有权认证。 + +当前实现不执行远程能力,不读取、搜索或下载知识正文;来源模式和权限均为元数据声明。 +局域网发现、配对、组织网关和远程调用仍是后续设计,不是首期交付内容。 ## 文档导航 @@ -9,12 +13,13 @@ | --- | --- | | [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 | | [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 | -| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 | +| [UI 设计](./ui-design.md) | 当前应用入口、单页表单与列表,以及后续发现、配对界面 | | [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 | +| [实施进度](./progress.md) | 首期实现、检查命令、真实 Electron 联调证据及未测范围 | | [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 | -当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑 -决策表。功能尚未实施,因此不创建 `progress.md`。 +当前没有单独的 User Stories:首期验收见 PRD 的 `FR-M1` 至 `FR-M5`;后续场景仍在原有 +需求与逻辑决策表中保留。下列 Peer、配对、网关术语仅用于后续设计。 ## 术语 diff --git a/docs/features/device-sharing/logic-design.md b/docs/features/device-sharing/logic-design.md index 896f953d..9880c9f2 100644 --- a/docs/features/device-sharing/logic-design.md +++ b/docs/features/device-sharing/logic-design.md @@ -12,6 +12,23 @@ 本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。 +## 当前首期规则 + +本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文发现、配对、授权和任务状态均为 +后续设计,不适用于本地未认证元数据服务。 + +本机 ID 在首次读取设置时生成并原子保存;并发首次读取共用一次加载。名称与服务 URL 更新 +串行保存,不接受 Renderer 写入设备 ID。已存在设置无法读取或解析时直接报错,避免静默 +更换身份。切换服务保持本机 ID,但目标服务必须有本机注册记录才能发布。 + +页面初次加载读取真实目录,空列表、加载中和读取失败分别展示。刷新失败保留旧列表并标记 +可能过期,禁用发布和撤销;用户可修改配置或重试。每次请求固定使用开始时读取的服务地址。 +页面操作期间锁定重复提交。保存失败保留设置草稿,发布失败保留条目草稿。 + +发布状态只有 `published` 与 `revoked`。撤销入口匹配本机 ID,Main 再查询本机发布列表并确认 +条目 ID;这只是防止误操作,不是认证。撤销只改变元数据状态。能力类型发送三个 `false` +权限;知识类型按独立选择发送。目录读取无需注册,注册记录不表示在线。 + ## 1. 核心不变量 ### INV-1 连接方式独立 diff --git a/docs/features/device-sharing/prd.md b/docs/features/device-sharing/prd.md index 0980fabe..26346e13 100644 --- a/docs/features/device-sharing/prd.md +++ b/docs/features/device-sharing/prd.md @@ -11,6 +11,30 @@ | 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) | | 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) | +## 当前首期:本地元数据 + +2026-10-01 首期范围由 `FR-M1` 至 `FR-M5` 定义。后文 `FR-1` 至 `FR-14` 保留为后续共享网络 +设计,不能用来推断首期已有发现、认证、调用或内容传输能力。 + +- **FR-M1 入口与设置**:应用中心及应用菜单提供“设备共享”。本机设备 ID 稳定保存,用户可 + 修改服务 URL 和设备名称。设置保存后重启仍保留;新设备默认连接 `http://127.0.0.1:8787`。 +- **FR-M2 注册与浏览**:用户显式注册本机,随后查看服务实际返回的设备及发布列表。注册 + 包含 ID、名称、平台、应用版本;时间来自服务记录,不从更新时间推断在线状态。 +- **FR-M3 元数据发布**:注册本机后可发布名称、描述、能力或知识类型、设备或服务器来源。 + 知识的搜索、读取、下载三个标志独立选择。服务器来源不会上传、同步或托管内容;权限 + 标志不提供内容访问实现。 +- **FR-M4 撤销与刷新**:只给匹配本机 ID 的已发布条目提供撤销入口,确认后更新服务记录并 + 刷新目录,显示已撤销状态。该筛选不构成认证,服务本身没有所有权鉴权。 +- **FR-M5 失败恢复**:请求有超时,错误可见;失败保留用户输入,列表失败不伪装为空目录。 + 修改服务 URL 后必须先保存,再向新服务注册或发布。既有应用设置、排序和常驻偏好保留。 + +验收顺序为保存配置、注册本机、浏览返回列表、发布知识元数据、检查三个独立标志、撤销、 +刷新并重启读取设备 ID。网络请求由 Main 发出,Renderer 只调用显式 preload 方法。 + +## 后续设计 + +以下章节为完整共享网络计划,尚未实施。 + ## 1. 背景 GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织 diff --git a/docs/features/device-sharing/technical-design.md b/docs/features/device-sharing/technical-design.md index 41d573fb..ac2386ad 100644 --- a/docs/features/device-sharing/technical-design.md +++ b/docs/features/device-sharing/technical-design.md @@ -11,6 +11,37 @@ | 功能逻辑 | [功能逻辑设计](./logic-design.md) | | 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) | +## 当前首期实现 + +本节实现 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文模块树、SQLite 表、共享协议包、 +局域网与网关协议都是后续设计,当前不创建这些基础设施。 + +`src/shared/device-sharing-contracts.ts` 定义严格输入和返回类型;`DesktopApi.sharing` 暴露 +`getSettings`、`saveSettings`、`registerDevice`、`getCatalog`、`publish`、`revoke` 六个方法。 +生产 preload 对应六个固定 IPC 通道,`device-sharing-ipc.ts` 对每次调用检查可信主窗口主帧, +对变更输入进行 Zod 校验,不暴露通用网络入口。 + +`DeviceSharingService` 在 Main 内使用 `fetch`,每个请求含响应体读取采用 10 秒超时,非成功 +响应保留 HTTP 状态;响应再经 Zod 校验。请求只在页面读取或用户动作时发出,不启动后台发现。 +URL 接受 HTTP/HTTPS,禁止内嵌凭据、查询和片段。没有令牌或 Agent/Runtime 改动。 + +| 方法 | HTTP 请求 | +| --- | --- | +| 注册 | `POST /api/v1/sharing/devices/register`,字段为 `id,name,platform,appVersion` | +| 设备列表 | `GET /api/v1/sharing/devices`,读取 `{devices:[]}` | +| 发布列表 | `GET /api/v1/sharing/publications`,读取 `{publications:[]}` | +| 发布 | `POST /api/v1/sharing/publications`,Main 生成条目 ID 并填入本机设备 ID | +| 撤销 | 先按 `?deviceId=` 获取本机条目,再 `POST /api/v1/sharing/publications/:id/revoke` | + +设备 ID、名称与服务 URL 存在用户数据目录的 `device-sharing-settings.json`,版本为 1; +复用 `writeJsonFileAtomically`,加载 Promise 去重、保存串行。损坏设置报错而不重置 ID。 +此文件不保存目录副本或凭据。导航沿用已有存储归一化,把 `device-sharing` 追加到旧顺序, +保留既有应用相对顺序与设置;新入口可排序,无启用或常驻开关,始终可从应用菜单进入。 + +服务测试使用真实 loopback HTTP,验证契约、独立权限、识别范围、持久化、错误和超时; +Electron 测试通过环境变量选择相邻真实 ShareServer 源码,使用临时服务数据与桌面设置。 +具体命令和实测边界见[实施进度](./progress.md)。 + ## 1. 进程边界 ```text diff --git a/docs/features/device-sharing/ui-design.md b/docs/features/device-sharing/ui-design.md index b7d2db27..c2df94d6 100644 --- a/docs/features/device-sharing/ui-design.md +++ b/docs/features/device-sharing/ui-design.md @@ -14,6 +14,24 @@ 本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以 全局设计系统为准。 +## 当前首期页面 + +本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据),后文四页签、配对及调用界面为后续设计。 + +入口在应用菜单和应用中心,主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。 +标题下常显未认证、ID 识别用途和无执行/内容访问的边界。内容依次为本机设置、已注册设备、 +发布表单和发布目录。服务 URL 与设备名称可编辑,设备 ID 只读;注册、保存与刷新为次操作, +“发布元数据”为唯一主按钮。 + +类型与来源使用短标签原生选择框;知识的搜索、读取、下载使用三个独立 Checkbox。服务器来源 +不上传或同步内容的说明常显。发布按钮附近显示已保存的目标服务及设备名称,尚未注册或 +配置未保存时给出操作提示。列表展示服务返回的名称、标识、平台/类型、更新时间和发布状态, +不展示在线灯或执行按钮。 + +撤销使用共享危险入口和就地确认,说明保留记录且不删除本机内容。成功与操作失败通过应用 +通知反馈;目录加载失败在页面显示并由刷新按钮恢复。宽度收窄时按钮换行,长 URL 和 ID 换行; +表单复用 `.field`,颜色与间距使用共享令牌。页面文案提供中英文。 + ## 1. 信息架构 设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell: diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index 3acbb219..d503bf9a 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -220,7 +220,7 @@ describe('ApplicationSettingsStore', () => { const settings = await store.get() expect(settings.warnings).toBeUndefined() expect(settings.magicNotesEnabled).toBe(false) - expect(settings.applicationNavigation).toEqual({ order: expected, pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } }) + expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } }) expect(applicationSettingsSchema.parse(settings)).toEqual(settings) await store.update({ checkUpdatesOnStartup: false }) expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation) @@ -230,7 +230,7 @@ describe('ApplicationSettingsStore', () => { it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => { const { store, filePath } = await createStore() const applicationNavigation = { - order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], + order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false }, } const changed = vi.fn() @@ -275,7 +275,7 @@ describe('ApplicationSettingsStore', () => { pinned: { 'magic-notes': false, 'local-inference': pinned } } await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8') - const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] } + const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] } expect((await store.get()).applicationNavigation).toEqual(normalized) await store.update({ checkUpdatesOnStartup: false }) expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized) diff --git a/src/main/device-sharing-ipc.test.ts b/src/main/device-sharing-ipc.test.ts new file mode 100644 index 00000000..bc9b482e --- /dev/null +++ b/src/main/device-sharing-ipc.test.ts @@ -0,0 +1,25 @@ +import { expect, it, vi } from 'vitest' +import { registerDeviceSharingIpc } from './device-sharing-ipc' +import { ipcChannels } from '../shared/ipc-channels' +const handlers = vi.hoisted(() => new Map unknown>()) +vi.mock('electron', () => ({ ipcMain: { + handle: (channel: string, handler: (event: unknown, input?: unknown) => unknown) => handlers.set(channel, handler), + removeHandler: (channel: string) => handlers.delete(channel) +} })) +it('checks the trusted main frame on every explicit method and validates mutation input', () => { + const webContents = { mainFrame: { url: 'file:///app.html' }, getURL: () => 'file:///app.html' } + const service = { getSettings: vi.fn(), saveSettings: vi.fn(), registerDevice: vi.fn(), getCatalog: vi.fn(), publish: vi.fn(), revoke: vi.fn() } + const dispose = registerDeviceSharingIpc({ webContents } as never, service as never) + const event = { sender: webContents, senderFrame: webContents.mainFrame } + expect(handlers.size).toBe(6) + for (const handler of handlers.values()) { + expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame })).toThrow('IPC') + expect(() => handler({ sender: webContents, senderFrame: { url: 'file:///app.html' } })).toThrow('IPC') + } + expect(() => handlers.get(ipcChannels.sharingSettingsSave)!(event, { deviceId: 'forged', name: 'x', serverUrl: 'http://localhost' })).toThrow() + expect(() => handlers.get(ipcChannels.sharingPublish)!(event, { name: 'x' })).toThrow() + expect(() => handlers.get(ipcChannels.sharingRevoke)!(event, '')).toThrow() + handlers.get(ipcChannels.sharingRegister)!(event) + expect(service.registerDevice).toHaveBeenCalledOnce() + dispose(); expect(handlers.size).toBe(0) +}) diff --git a/src/main/device-sharing-ipc.ts b/src/main/device-sharing-ipc.ts new file mode 100644 index 00000000..e7727a5d --- /dev/null +++ b/src/main/device-sharing-ipc.ts @@ -0,0 +1,24 @@ +import { ipcMain, type BrowserWindow } from 'electron' +import { ipcChannels } from '../shared/ipc-channels' +import { sharingIdSchema, sharingPublicationDraftSchema, sharingSettingsInputSchema } from '../shared/device-sharing-contracts' +import { assertTrustedSender } from './trusted-ipc-sender' +import type { DeviceSharingService } from './device-sharing-service' + +export function registerDeviceSharingIpc(window: BrowserWindow, service: DeviceSharingService): () => void { + const handlers = { + [ipcChannels.sharingSettingsGet]: () => service.getSettings(), + [ipcChannels.sharingSettingsSave]: (input: unknown) => service.saveSettings(sharingSettingsInputSchema.parse(input)), + [ipcChannels.sharingRegister]: () => service.registerDevice(), + [ipcChannels.sharingCatalog]: () => service.getCatalog(), + [ipcChannels.sharingPublish]: (input: unknown) => service.publish(sharingPublicationDraftSchema.parse(input)), + [ipcChannels.sharingRevoke]: (input: unknown) => service.revoke(sharingIdSchema.parse(input)) + } + for (const [channel, handler] of Object.entries(handlers)) { + ipcMain.removeHandler(channel) + ipcMain.handle(channel, (event, input: unknown) => { + assertTrustedSender(event, window) + return handler(input) + }) + } + return () => { for (const channel of Object.keys(handlers)) ipcMain.removeHandler(channel) } +} diff --git a/src/main/device-sharing-service.test.ts b/src/main/device-sharing-service.test.ts new file mode 100644 index 00000000..14c4eca0 --- /dev/null +++ b/src/main/device-sharing-service.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { DeviceSharingService } from './device-sharing-service' +import type { SharingDevice, SharingPublication } from '../shared/device-sharing-contracts' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn() }) +async function setup(timeout = 1000) { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-sharing-')) + cleanup.push(() => rm(directory, { recursive: true, force: true })) + const devices: SharingDevice[] = [] + const publications: SharingPublication[] = [] + const requests: { path: string; method: string; body: Record }[] = [] + let failure = 0 + let stall = false + const server = createServer(async (request, response) => { + if (stall) return + if (failure) { response.writeHead(failure).end(); return } + const buffers: Buffer[] = [] + for await (const chunk of request) buffers.push(Buffer.from(chunk)) + const body = JSON.parse(Buffer.concat(buffers).toString() || '{}') + const path = request.url! + requests.push({ path, method: request.method!, body }) + let value: unknown + if (path.endsWith('/devices/register')) { + value = { ...body, registeredAt: '2026-10-01', updatedAt: '2026-10-01' } + devices.splice(0, devices.length, value as SharingDevice) + } else if (path.endsWith('/devices')) value = { devices } + else if (request.method === 'GET') value = { publications } + else if (path.endsWith('/revoke')) { + const entry = publications.find(item => path.includes(item.id))! + entry.status = 'revoked'; value = entry + } else { + value = { ...body, status: 'published', createdAt: '2026-10-01', updatedAt: '2026-10-01' } + publications.push(value as SharingPublication) + } + response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(value)) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + cleanup.push(() => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()) })) + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}` + const file = join(directory, 'settings.json') + const service = new DeviceSharingService(file, 'test-version', timeout) + await service.saveSettings({ name: 'Desktop', serverUrl: url }) + return { service, file, url, requests, publications, fail: (code: number) => { failure = code }, stall: () => { stall = true } } +} + +it('persists one identity across concurrent first reads, saves and restarts without accepting renderer identity', async () => { + const { service, file, url } = await setup() + const [first, second] = await Promise.all([service.getSettings(), service.getSettings()]) + expect(first.deviceId).toBe(second.deviceId) + await service.saveSettings({ name: 'Renamed', serverUrl: `${url}/` }) + expect(await new DeviceSharingService(file, 'v2').getSettings()).toEqual({ ...first, name: 'Renamed' }) + await expect(service.saveSettings({ name: 'Bad', serverUrl: url, deviceId: 'forged' })).rejects.toThrow() + expect(JSON.parse(await readFile(file, 'utf8')).deviceId).toBe(first.deviceId) + await writeFile(file, '{broken') + await expect(new DeviceSharingService(file, 'v2').getSettings()).rejects.toThrow() + expect(await readFile(file, 'utf8')).toBe('{broken') +}) + +it('uses the exact HTTP contract for registration, catalog, both publication kinds and own-device revocation', async () => { + const { service, requests, publications } = await setup() + const device = await service.registerDevice() + expect(requests[0]).toEqual({ path: '/api/v1/sharing/devices/register', method: 'POST', body: { + id: device.id, name: 'Desktop', platform: process.platform, appVersion: 'test-version' + } }) + const draft = { name: 'Knowledge', description: 'Metadata only', kind: 'knowledge', sourceMode: 'server', permissions: { search: true, read: false, download: true } } + const publication = await service.publish(draft) + expect(publication).toMatchObject({ ...draft, deviceId: device.id, status: 'published' }) + expect(await service.getCatalog()).toEqual({ devices: [device], publications: [publication] }) + expect(await service.publish({ ...draft, kind: 'capability', sourceMode: 'device' })).toMatchObject({ permissions: { search: false, read: false, download: false } }) + publications.push({ ...publication, id: 'other-publication', deviceId: 'other-device' }) + await expect(service.revoke('other-publication')).rejects.toThrow('does not belong') + expect(requests.some(item => item.path.includes('other-publication/revoke'))).toBe(false) + expect(await service.revoke(publication.id)).toMatchObject({ status: 'revoked' }) + expect(requests.at(-2)?.path).toBe(`/api/v1/sharing/publications?deviceId=${device.id}`) + expect(requests.at(-1)).toMatchObject({ path: `/api/v1/sharing/publications/${publication.id}/revoke`, method: 'POST', body: {} }) +}) + +it('preserves HTTP failure status and bounds a stalled request', async () => { + const { service, fail, stall } = await setup(50) + fail(503) + await expect(service.getCatalog()).rejects.toThrow('HTTP 503') + fail(0); stall() + await expect(service.registerDevice()).rejects.toThrow(/timeout/i) +}) diff --git a/src/main/device-sharing-service.ts b/src/main/device-sharing-service.ts new file mode 100644 index 00000000..c29cbc63 --- /dev/null +++ b/src/main/device-sharing-service.ts @@ -0,0 +1,100 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { hostname } from 'node:os' +import { z } from 'zod' +import { + sharingDeviceSchema, sharingIdSchema, sharingPublicationDraftSchema, sharingPublicationSchema, + sharingSettingsInputSchema, sharingSettingsSchema, + type SharingCatalog, type SharingSettings +} from '../shared/device-sharing-contracts' +import { assertSupportedSettingsVersion, isMissingFileError, writeJsonFileAtomically } from './settings-file-utils' + +const storedSchema = sharingSettingsSchema.extend({ version: z.literal(1) }).strict() + +export class DeviceSharingService { + private settings?: Promise + private updateQueue: Promise = Promise.resolve() + + constructor(private readonly filePath: string, private readonly appVersion: string, private readonly timeoutMs = 10_000) {} + + getSettings(): Promise { + if (!this.settings) { + this.settings = (async () => { + try { + const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8')) + assertSupportedSettingsVersion(value, 1, version => `Unsupported sharing settings version: ${version}`) + const stored = storedSchema.parse(value) + return { deviceId: stored.deviceId, name: stored.name, serverUrl: stored.serverUrl } + } catch (error) { + // Never silently replace a saved device identity on a read/parse failure. + if (!isMissingFileError(error)) throw error + const settings = { deviceId: randomUUID(), name: hostname(), serverUrl: 'http://127.0.0.1:8787' } + await writeJsonFileAtomically(this.filePath, { ...settings, version: 1 }) + return settings + } + })().catch(error => { this.settings = undefined; throw error }) + } + return this.settings + } + + saveSettings(input: unknown): Promise { + const operation = this.updateQueue.then(async () => { + const patch = sharingSettingsInputSchema.parse(input) + const current = await this.getSettings() + const next = { ...current, ...patch, serverUrl: patch.serverUrl.replace(/\/+$/, '') } + await writeJsonFileAtomically(this.filePath, { ...next, version: 1 }) + this.settings = Promise.resolve(next) + return next + }) + this.updateQueue = operation.catch(() => undefined) + return operation + } + + private async request(settings: SharingSettings, path: string, schema: z.ZodType, body?: unknown): Promise { + const response = await fetch(`${settings.serverUrl.replace(/\/+$/, '')}/api/v1/sharing/${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.timeoutMs) + }) + if (!response.ok) throw new Error(`Sharing HTTP ${response.status} ${response.statusText}`) + return schema.parse(await response.json()) + } + + async registerDevice() { + const settings = await this.getSettings() + return this.request(settings, 'devices/register', sharingDeviceSchema, { + id: settings.deviceId, name: settings.name, platform: process.platform, appVersion: this.appVersion + }) + } + + async getCatalog(): Promise { + const settings = await this.getSettings() + const [devices, publications] = await Promise.all([ + this.request(settings, 'devices', z.object({ devices: z.array(sharingDeviceSchema) })), + this.request(settings, 'publications', z.object({ publications: z.array(sharingPublicationSchema) })) + ]) + return { ...devices, ...publications } + } + + async publish(input: unknown) { + const draft = sharingPublicationDraftSchema.parse(input) + const settings = await this.getSettings() + return this.request(settings, 'publications', sharingPublicationSchema, { + ...draft, id: randomUUID(), deviceId: settings.deviceId, + permissions: draft.kind === 'knowledge' ? draft.permissions : { search: false, read: false, download: false } + }) + } + + async revoke(input: unknown) { + const id = sharingIdSchema.parse(input) + const settings = await this.getSettings() + const { publications } = await this.request(settings, + `publications?deviceId=${encodeURIComponent(settings.deviceId)}`, + z.object({ publications: z.array(sharingPublicationSchema) })) + if (!publications.some(item => item.id === id && item.deviceId === settings.deviceId)) { + throw new Error('Publication does not belong to this device ID') + } + return this.request(settings, `publications/${encodeURIComponent(id)}/revoke`, sharingPublicationSchema, {}) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0aa97c28..1717e63a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -86,6 +86,8 @@ import { } from './feedback/feedback-http-client' import { FeedbackService } from './feedback/feedback-service' import { registerFeedbackIpcHandler } from './feedback/feedback-ipc' +import { registerDeviceSharingIpc } from './device-sharing-ipc' +import { DeviceSharingService } from './device-sharing-service' import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import type { SystemModelUsageInput } from '../shared/assistant-contracts' import { @@ -211,6 +213,7 @@ let tray: Tray | undefined let isQuitting = false let removeIpcHandlers: (() => Promise) | undefined let removeFeedbackIpcHandler: (() => void) | undefined +let removeDeviceSharingIpc: (() => void) | undefined let runtime: AgentRuntimeController | undefined let selectedRuntimeManager: SelectedRuntimeManager | undefined const localRuntimeRegistry = new LocalRuntimeRegistry() @@ -1576,6 +1579,8 @@ if (hasSingleInstanceLock) { startupFeedbackService ) loadMainWindow(mainWindow) + removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow, + new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion())) setImmediate(() => { void repairStaleWindowsNotificationShortcuts({ platform: process.platform, @@ -1664,6 +1669,7 @@ app.on('before-quit', (event) => { () => storageUpgrade, () => feedbackService?.dispose(), () => removeFeedbackIpcHandler?.(), + () => removeDeviceSharingIpc?.(), () => dshExtensionInstaller?.dispose(), () => imageGenerationService?.dispose(), () => knowledgeService?.beginShutdown(), diff --git a/src/preload/index.ts b/src/preload/index.ts index 33f79386..8d29aab3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -808,6 +808,14 @@ const desktopApi: DesktopApi = { act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input), cancel: (taskId) => ipcRenderer.invoke(ipcChannels.localInferenceCancel, { taskId }) }, + sharing: { + getSettings: () => ipcRenderer.invoke(ipcChannels.sharingSettingsGet), + saveSettings: input => ipcRenderer.invoke(ipcChannels.sharingSettingsSave, input), + registerDevice: () => ipcRenderer.invoke(ipcChannels.sharingRegister), + getCatalog: () => ipcRenderer.invoke(ipcChannels.sharingCatalog), + publish: input => ipcRenderer.invoke(ipcChannels.sharingPublish, input), + revoke: id => ipcRenderer.invoke(ipcChannels.sharingRevoke, id) + }, speechModels: { getSnapshot: () => ipcRenderer.invoke( diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 3bd1b614..557dfc74 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -245,6 +245,12 @@ const terminalSnapshot: TerminalSnapshot = { }; const api: DesktopApi & RuntimeNativeClientApi = { + sharing: { + getSettings: vi.fn(async () => ({ deviceId: 'test-device', name: 'Test device', serverUrl: 'http://127.0.0.1:8787' })), + saveSettings: vi.fn(), registerDevice: vi.fn(), + getCatalog: vi.fn(async () => ({ devices: [], publications: [] })), + publish: vi.fn(), revoke: vi.fn() + }, openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })), getRuntimeNativeClient: vi.fn(async () => null), stopRuntimeNativeClient: vi.fn(async () => undefined), @@ -14322,24 +14328,40 @@ describe("App", () => { await waitFor(() => expect(move).toBeEnabled()) fireEvent.click(move) await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ - '监督者', '知识库', '魔法笔记', '本机推理监控', + '监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享', ])) await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled()) const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article') fireEvent.dragStart(cards[3]!) fireEvent.drop(cards[1]!) await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ - '监督者', '本机推理监控', '知识库', '魔法笔记', + '监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享', ])) - expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes']) + expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing']) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([ '对话', '监督者', '知识库', '魔法笔记', '运行记录', ]) fireEvent.click(screen.getByRole('button', { name: '应用' })) - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用']) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享', '管理应用']) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) - expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记']) + expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享']) + }) + + it('opens device sharing from Application Center while keeping the Settings entry available', async () => { + render() + await screen.findByRole('button', { name: '监督者' }) + fireEvent.click(screen.getByRole('button', { name: '应用' })) + fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) + const row = screen.getByText('设备共享', { exact: true }).closest('article')! + fireEvent.click(within(row).getByRole('button', { name: '打开' })) + await waitFor(() => { + expect(screen.getByRole('heading', { name: '设备共享' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled() + }) + expect(screen.queryByRole('dialog', { name: '应用中心' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '设置' })) + expect(await screen.findByRole('dialog', { name: '设置中心' })).toBeInTheDocument() }) it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 453f52b2..59e36f69 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice'; import { DocumentConversationContext } from './DocumentConversationContext'; import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits'; import { buildRuntimeHistory } from '../../shared/runtime-history'; +import { DeviceSharingPage } from './DeviceSharingPage'; import { ApplicationAvailability, ApplicationCenter, @@ -542,7 +543,7 @@ type ActiveRun = { }; type WorkspaceView = - "chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings"; + "chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings"; const intentRoutePreloaders: Partial< Record Promise> @@ -10923,6 +10924,7 @@ function App(): React.JSX.Element { )} + {view === 'device-sharing' && } {localInferenceOpen && ( { fireEvent.drop(card) expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { ...defaultApplicationNavigation, - order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'], + order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'], } }) }) @@ -214,7 +214,7 @@ describe('Application Center', () => { fireEvent.dragStart(notes) fireEvent.drop(inference) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { - ...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], + ...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], } }) fireEvent.click(within(inference).getByRole('button', { name: '打开' })) expect(handlers.onOpen).not.toHaveBeenCalled() @@ -238,9 +238,9 @@ describe('Application Center', () => { const handlers = props() const { rerender } = render() const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent) - expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控']) + expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享']) expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled() - expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled() + expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled() const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' }) expect(moveUp.closest('article')).not.toBeNull() expect(moveUp).toHaveAttribute('title', '上移 本机推理监控') @@ -248,18 +248,18 @@ describe('Application Center', () => { expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { ...pinnedSettings.applicationNavigation, - order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], }, }) - expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled() + expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled() expect(await screen.findByRole('status')).not.toBeEmptyDOMElement() - expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控']) + expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享']) rerender() - expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记']) - expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled() + expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享']) + expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' })) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation }) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) @@ -281,7 +281,7 @@ describe('Application Center', () => { expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { ...pinnedSettings.applicationNavigation, - order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], }, }) }) @@ -322,12 +322,12 @@ describe('Application Center', () => { fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } }) const card = screen.getByRole('article') expect(card).toHaveAttribute('draggable', 'false') - expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled() + expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeEnabled() fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' })) expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { ...pinnedSettings.applicationNavigation, - order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], }, }) }) @@ -342,7 +342,7 @@ describe('Application Center', () => { }) expect(screen.getByText('未找到相关应用')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) - expect(screen.getAllByRole('article')).toHaveLength(4) + expect(screen.getAllByRole('article')).toHaveLength(5) }) it('edits all notes preferences through the shared settings form', () => { diff --git a/src/renderer/src/ApplicationCenter.tsx b/src/renderer/src/ApplicationCenter.tsx index 059c0bf0..b7f352a2 100644 --- a/src/renderer/src/ApplicationCenter.tsx +++ b/src/renderer/src/ApplicationCenter.tsx @@ -11,6 +11,7 @@ import { ArrowLeft, ArrowUp, Cpu, + Network, HeartPulse, Library, Settings, @@ -31,6 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives' import './application-center.css' export const applicationDefinitions = { + 'device-sharing': { icon: Network, title: 'sharing.title' }, 'magic-notes': { icon: Sparkles, title: 'navigation.magicNotes', @@ -56,7 +58,7 @@ export function isApplicationEnabled( settings: ApplicationSettings | undefined, id: BuiltInApplicationId, ): boolean { - if (id === 'knowledge') return true + if (id === 'knowledge' || id === 'device-sharing') return true if (id === 'heartbeat') return settings?.heartbeatEnabled === true return ( settings !== undefined && @@ -449,7 +451,7 @@ export function ApplicationCenter({
{t(definition.title)} - {alwaysShown + {id === 'device-sharing' ? t('applications.builtin') : alwaysShown ? t('applications.alwaysShown') : `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`} @@ -466,7 +468,7 @@ export function ApplicationCenter({ > {t('applications.open')} - {!alwaysShown && ( + {id !== 'knowledge' && id !== 'device-sharing' && ( } /> +
+ {busy &&

{t('sharing.working')}

} + {error &&
{t('sharing.loadFailed')} {error}
} + {settings &&
+

{t('sharing.thisDevice')}

+

{t('sharing.deviceId')}: {settings.deviceId}

+
{ event.preventDefault(); void run(async () => { + const next = await window.goodbuddy.sharing.saveSettings({ name, serverUrl }) + setSettings(next); setName(next.name); setServerUrl(next.serverUrl); setCatalog(undefined) + await refresh() + }, t('sharing.saved')) }}> + + +
+ + +
+ {dirty &&

{t('sharing.saveFirst')}

} +
+
} +
+

{t('sharing.devices')}

+ {catalog?.devices.length === 0 && } + {catalog?.devices.map(device =>
+

{device.name} {device.id === settings?.deviceId && ({t('sharing.thisDevice')})}

+

{device.platform} · {device.appVersion} · {device.id}

+

{t('sharing.updatedAt')}: {device.updatedAt}

+
)} +
+
+

{t('sharing.publishMetadata')}

+

{t('sharing.sourceHelp')}

+ {!registered &&

{t('sharing.registerFirst')}

} +
{ event.preventDefault(); if (!canPublish) return; void run(async () => { + await window.goodbuddy.sharing.publish(draft); setDraft(emptyDraft); await refresh() + }, t('sharing.publishedNotice')) }}> +
+ +