feat: add device sharing metadata application
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
# 设备发现与共享
|
# 设备发现与共享
|
||||||
|
|
||||||
本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接
|
设备共享从“应用 → 设备共享”或“管理应用 → 设备共享 → 打开”进入。当前首期连接本地未认证
|
||||||
方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。
|
ShareServer 元数据服务,默认地址为 `http://127.0.0.1:8787`,可注册本机、浏览设备与发布目录、
|
||||||
|
发布能力或知识的元数据并撤销本机 ID 的条目。设备 ID 只用于识别,不是所有权认证。
|
||||||
|
|
||||||
|
当前实现不执行远程能力,不读取、搜索或下载知识正文;来源模式和权限均为元数据声明。
|
||||||
|
局域网发现、配对、组织网关和远程调用仍是后续设计,不是首期交付内容。
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
@@ -9,12 +13,13 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
|
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
|
||||||
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
|
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
|
||||||
| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 |
|
| [UI 设计](./ui-design.md) | 当前应用入口、单页表单与列表,以及后续发现、配对界面 |
|
||||||
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
|
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
|
||||||
|
| [实施进度](./progress.md) | 首期实现、检查命令、真实 Electron 联调证据及未测范围 |
|
||||||
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
|
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
|
||||||
|
|
||||||
当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑
|
当前没有单独的 User Stories:首期验收见 PRD 的 `FR-M1` 至 `FR-M5`;后续场景仍在原有
|
||||||
决策表。功能尚未实施,因此不创建 `progress.md`。
|
需求与逻辑决策表中保留。下列 Peer、配对、网关术语仅用于后续设计。
|
||||||
|
|
||||||
## 术语
|
## 术语
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
|
|
||||||
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
|
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
|
||||||
|
|
||||||
|
## 当前首期规则
|
||||||
|
|
||||||
|
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文发现、配对、授权和任务状态均为
|
||||||
|
后续设计,不适用于本地未认证元数据服务。
|
||||||
|
|
||||||
|
本机 ID 在首次读取设置时生成并原子保存;并发首次读取共用一次加载。名称与服务 URL 更新
|
||||||
|
串行保存,不接受 Renderer 写入设备 ID。已存在设置无法读取或解析时直接报错,避免静默
|
||||||
|
更换身份。切换服务保持本机 ID,但目标服务必须有本机注册记录才能发布。
|
||||||
|
|
||||||
|
页面初次加载读取真实目录,空列表、加载中和读取失败分别展示。刷新失败保留旧列表并标记
|
||||||
|
可能过期,禁用发布和撤销;用户可修改配置或重试。每次请求固定使用开始时读取的服务地址。
|
||||||
|
页面操作期间锁定重复提交。保存失败保留设置草稿,发布失败保留条目草稿。
|
||||||
|
|
||||||
|
发布状态只有 `published` 与 `revoked`。撤销入口匹配本机 ID,Main 再查询本机发布列表并确认
|
||||||
|
条目 ID;这只是防止误操作,不是认证。撤销只改变元数据状态。能力类型发送三个 `false`
|
||||||
|
权限;知识类型按独立选择发送。目录读取无需注册,注册记录不表示在线。
|
||||||
|
|
||||||
## 1. 核心不变量
|
## 1. 核心不变量
|
||||||
|
|
||||||
### INV-1 连接方式独立
|
### INV-1 连接方式独立
|
||||||
|
|||||||
@@ -11,6 +11,30 @@
|
|||||||
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||||
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.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. 背景
|
## 1. 背景
|
||||||
|
|
||||||
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
|
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
|
||||||
|
|||||||
@@ -11,6 +11,37 @@
|
|||||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||||
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.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. 进程边界
|
## 1. 进程边界
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -14,6 +14,24 @@
|
|||||||
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
|
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
|
||||||
全局设计系统为准。
|
全局设计系统为准。
|
||||||
|
|
||||||
|
## 当前首期页面
|
||||||
|
|
||||||
|
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据),后文四页签、配对及调用界面为后续设计。
|
||||||
|
|
||||||
|
入口在应用菜单和应用中心,主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。
|
||||||
|
标题下常显未认证、ID 识别用途和无执行/内容访问的边界。内容依次为本机设置、已注册设备、
|
||||||
|
发布表单和发布目录。服务 URL 与设备名称可编辑,设备 ID 只读;注册、保存与刷新为次操作,
|
||||||
|
“发布元数据”为唯一主按钮。
|
||||||
|
|
||||||
|
类型与来源使用短标签原生选择框;知识的搜索、读取、下载使用三个独立 Checkbox。服务器来源
|
||||||
|
不上传或同步内容的说明常显。发布按钮附近显示已保存的目标服务及设备名称,尚未注册或
|
||||||
|
配置未保存时给出操作提示。列表展示服务返回的名称、标识、平台/类型、更新时间和发布状态,
|
||||||
|
不展示在线灯或执行按钮。
|
||||||
|
|
||||||
|
撤销使用共享危险入口和就地确认,说明保留记录且不删除本机内容。成功与操作失败通过应用
|
||||||
|
通知反馈;目录加载失败在页面显示并由刷新按钮恢复。宽度收窄时按钮换行,长 URL 和 ID 换行;
|
||||||
|
表单复用 `.field`,颜色与间距使用共享令牌。页面文案提供中英文。
|
||||||
|
|
||||||
## 1. 信息架构
|
## 1. 信息架构
|
||||||
|
|
||||||
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
|
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
const settings = await store.get()
|
const settings = await store.get()
|
||||||
expect(settings.warnings).toBeUndefined()
|
expect(settings.warnings).toBeUndefined()
|
||||||
expect(settings.magicNotesEnabled).toBe(false)
|
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)
|
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
|
||||||
await store.update({ checkUpdatesOnStartup: false })
|
await store.update({ checkUpdatesOnStartup: false })
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
|
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 () => {
|
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
|
||||||
const { store, filePath } = await createStore()
|
const { store, filePath } = await createStore()
|
||||||
const applicationNavigation = {
|
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 },
|
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false },
|
||||||
}
|
}
|
||||||
const changed = vi.fn()
|
const changed = vi.fn()
|
||||||
@@ -275,7 +275,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
pinned: { 'magic-notes': false, 'local-inference': pinned }
|
pinned: { 'magic-notes': false, 'local-inference': pinned }
|
||||||
}
|
}
|
||||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
|
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)
|
expect((await store.get()).applicationNavigation).toEqual(normalized)
|
||||||
await store.update({ checkUpdatesOnStartup: false })
|
await store.update({ checkUpdatesOnStartup: false })
|
||||||
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
|
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
|
||||||
|
|||||||
@@ -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<string, (event: unknown, input?: unknown) => 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)
|
||||||
|
})
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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<unknown>)[] = []
|
||||||
|
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<string, unknown> }[] = []
|
||||||
|
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<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
cleanup.push(() => new Promise<void>(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)
|
||||||
|
})
|
||||||
@@ -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<SharingSettings>
|
||||||
|
private updateQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string, private readonly appVersion: string, private readonly timeoutMs = 10_000) {}
|
||||||
|
|
||||||
|
getSettings(): Promise<SharingSettings> {
|
||||||
|
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<SharingSettings> {
|
||||||
|
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<T>(settings: SharingSettings, path: string, schema: z.ZodType<T>, body?: unknown): Promise<T> {
|
||||||
|
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<SharingCatalog> {
|
||||||
|
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, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ import {
|
|||||||
} from './feedback/feedback-http-client'
|
} from './feedback/feedback-http-client'
|
||||||
import { FeedbackService } from './feedback/feedback-service'
|
import { FeedbackService } from './feedback/feedback-service'
|
||||||
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
|
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 { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
||||||
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
|
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
|
||||||
import {
|
import {
|
||||||
@@ -211,6 +213,7 @@ let tray: Tray | undefined
|
|||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
let removeIpcHandlers: (() => Promise<void>) | undefined
|
let removeIpcHandlers: (() => Promise<void>) | undefined
|
||||||
let removeFeedbackIpcHandler: (() => void) | undefined
|
let removeFeedbackIpcHandler: (() => void) | undefined
|
||||||
|
let removeDeviceSharingIpc: (() => void) | undefined
|
||||||
let runtime: AgentRuntimeController | undefined
|
let runtime: AgentRuntimeController | undefined
|
||||||
let selectedRuntimeManager: SelectedRuntimeManager | undefined
|
let selectedRuntimeManager: SelectedRuntimeManager | undefined
|
||||||
const localRuntimeRegistry = new LocalRuntimeRegistry()
|
const localRuntimeRegistry = new LocalRuntimeRegistry()
|
||||||
@@ -1576,6 +1579,8 @@ if (hasSingleInstanceLock) {
|
|||||||
startupFeedbackService
|
startupFeedbackService
|
||||||
)
|
)
|
||||||
loadMainWindow(mainWindow)
|
loadMainWindow(mainWindow)
|
||||||
|
removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow,
|
||||||
|
new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion()))
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
void repairStaleWindowsNotificationShortcuts({
|
void repairStaleWindowsNotificationShortcuts({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -1664,6 +1669,7 @@ app.on('before-quit', (event) => {
|
|||||||
() => storageUpgrade,
|
() => storageUpgrade,
|
||||||
() => feedbackService?.dispose(),
|
() => feedbackService?.dispose(),
|
||||||
() => removeFeedbackIpcHandler?.(),
|
() => removeFeedbackIpcHandler?.(),
|
||||||
|
() => removeDeviceSharingIpc?.(),
|
||||||
() => dshExtensionInstaller?.dispose(),
|
() => dshExtensionInstaller?.dispose(),
|
||||||
() => imageGenerationService?.dispose(),
|
() => imageGenerationService?.dispose(),
|
||||||
() => knowledgeService?.beginShutdown(),
|
() => knowledgeService?.beginShutdown(),
|
||||||
|
|||||||
@@ -808,6 +808,14 @@ const desktopApi: DesktopApi = {
|
|||||||
act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input),
|
act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input),
|
||||||
cancel: (taskId) => ipcRenderer.invoke(ipcChannels.localInferenceCancel, { taskId })
|
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: {
|
speechModels: {
|
||||||
getSnapshot: () =>
|
getSnapshot: () =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ const terminalSnapshot: TerminalSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const api: DesktopApi & RuntimeNativeClientApi = {
|
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 })),
|
openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })),
|
||||||
getRuntimeNativeClient: vi.fn(async () => null),
|
getRuntimeNativeClient: vi.fn(async () => null),
|
||||||
stopRuntimeNativeClient: vi.fn(async () => undefined),
|
stopRuntimeNativeClient: vi.fn(async () => undefined),
|
||||||
@@ -14322,24 +14328,40 @@ describe("App", () => {
|
|||||||
await waitFor(() => expect(move).toBeEnabled())
|
await waitFor(() => expect(move).toBeEnabled())
|
||||||
fireEvent.click(move)
|
fireEvent.click(move)
|
||||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
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())
|
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
|
||||||
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
||||||
fireEvent.dragStart(cards[3]!)
|
fireEvent.dragStart(cards[3]!)
|
||||||
fireEvent.drop(cards[1]!)
|
fireEvent.drop(cards[1]!)
|
||||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
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: '关闭应用中心' }))
|
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
|
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
|
||||||
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
|
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
|
||||||
])
|
])
|
||||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
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: '管理应用' }))
|
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(<App />)
|
||||||
|
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 () => {
|
it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice';
|
|||||||
import { DocumentConversationContext } from './DocumentConversationContext';
|
import { DocumentConversationContext } from './DocumentConversationContext';
|
||||||
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
|
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
|
||||||
import { buildRuntimeHistory } from '../../shared/runtime-history';
|
import { buildRuntimeHistory } from '../../shared/runtime-history';
|
||||||
|
import { DeviceSharingPage } from './DeviceSharingPage';
|
||||||
import {
|
import {
|
||||||
ApplicationAvailability,
|
ApplicationAvailability,
|
||||||
ApplicationCenter,
|
ApplicationCenter,
|
||||||
@@ -542,7 +543,7 @@ type ActiveRun = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceView =
|
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<
|
const intentRoutePreloaders: Partial<
|
||||||
Record<WorkspaceView, () => Promise<unknown>>
|
Record<WorkspaceView, () => Promise<unknown>>
|
||||||
@@ -10923,6 +10924,7 @@ function App(): React.JSX.Element {
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
</KeepAliveRoute>
|
</KeepAliveRoute>
|
||||||
)}
|
)}
|
||||||
|
{view === 'device-sharing' && <DeviceSharingPage notify={notify} />}
|
||||||
{localInferenceOpen && (
|
{localInferenceOpen && (
|
||||||
<LocalInferencePage
|
<LocalInferencePage
|
||||||
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
|
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe('Application Center', () => {
|
|||||||
fireEvent.drop(card)
|
fireEvent.drop(card)
|
||||||
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
|
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
|
||||||
...defaultApplicationNavigation,
|
...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.dragStart(notes)
|
||||||
fireEvent.drop(inference)
|
fireEvent.drop(inference)
|
||||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
|
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: '打开' }))
|
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
|
||||||
expect(handlers.onOpen).not.toHaveBeenCalled()
|
expect(handlers.onOpen).not.toHaveBeenCalled()
|
||||||
@@ -238,9 +238,9 @@ describe('Application Center', () => {
|
|||||||
const handlers = props()
|
const handlers = props()
|
||||||
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||||
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
|
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()
|
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||||
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
||||||
expect(moveUp.closest('article')).not.toBeNull()
|
expect(moveUp.closest('article')).not.toBeNull()
|
||||||
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
||||||
@@ -248,18 +248,18 @@ describe('Application Center', () => {
|
|||||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||||
applicationNavigation: {
|
applicationNavigation: {
|
||||||
...pinnedSettings.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(await screen.findByRole('status')).not.toBeEmptyDOMElement()
|
||||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
|
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
|
||||||
rerender(<ApplicationCenter {...handlers} settings={{
|
rerender(<ApplicationCenter {...handlers} settings={{
|
||||||
...pinnedSettings,
|
...pinnedSettings,
|
||||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
|
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
|
||||||
}} />)
|
}} />)
|
||||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记'])
|
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享'])
|
||||||
expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
||||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
||||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||||
@@ -281,7 +281,7 @@ describe('Application Center', () => {
|
|||||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||||
applicationNavigation: {
|
applicationNavigation: {
|
||||||
...pinnedSettings.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: '推理' } })
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } })
|
||||||
const card = screen.getByRole('article')
|
const card = screen.getByRole('article')
|
||||||
expect(card).toHaveAttribute('draggable', 'false')
|
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: '上移 本机推理监控' }))
|
fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' }))
|
||||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||||
applicationNavigation: {
|
applicationNavigation: {
|
||||||
...pinnedSettings.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()
|
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
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', () => {
|
it('edits all notes preferences through the shared settings form', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
Network,
|
||||||
HeartPulse,
|
HeartPulse,
|
||||||
Library,
|
Library,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -31,6 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
|||||||
import './application-center.css'
|
import './application-center.css'
|
||||||
|
|
||||||
export const applicationDefinitions = {
|
export const applicationDefinitions = {
|
||||||
|
'device-sharing': { icon: Network, title: 'sharing.title' },
|
||||||
'magic-notes': {
|
'magic-notes': {
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
title: 'navigation.magicNotes',
|
title: 'navigation.magicNotes',
|
||||||
@@ -56,7 +58,7 @@ export function isApplicationEnabled(
|
|||||||
settings: ApplicationSettings | undefined,
|
settings: ApplicationSettings | undefined,
|
||||||
id: BuiltInApplicationId,
|
id: BuiltInApplicationId,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (id === 'knowledge') return true
|
if (id === 'knowledge' || id === 'device-sharing') return true
|
||||||
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
|
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
|
||||||
return (
|
return (
|
||||||
settings !== undefined &&
|
settings !== undefined &&
|
||||||
@@ -449,7 +451,7 @@ export function ApplicationCenter({
|
|||||||
<div className="application-center__title">
|
<div className="application-center__title">
|
||||||
<strong>{t(definition.title)}</strong>
|
<strong>{t(definition.title)}</strong>
|
||||||
<small>
|
<small>
|
||||||
{alwaysShown
|
{id === 'device-sharing' ? t('applications.builtin') : alwaysShown
|
||||||
? t('applications.alwaysShown')
|
? t('applications.alwaysShown')
|
||||||
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
|
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
|
||||||
</small>
|
</small>
|
||||||
@@ -466,7 +468,7 @@ export function ApplicationCenter({
|
|||||||
>
|
>
|
||||||
{t('applications.open')}
|
{t('applications.open')}
|
||||||
</button>
|
</button>
|
||||||
{!alwaysShown && (
|
{id !== 'knowledge' && id !== 'device-sharing' && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
|
import { DeviceSharingPage } from './DeviceSharingPage'
|
||||||
|
import type { DeviceSharingApi, SharingCatalog, SharingPublicationDraft } from '../../shared/device-sharing-contracts'
|
||||||
|
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||||
|
const settings = { deviceId: 'mine', name: 'My desktop', serverUrl: 'http://127.0.0.1:8787' }
|
||||||
|
function setup() {
|
||||||
|
const catalog: SharingCatalog = { devices: [], publications: [] }
|
||||||
|
const sharing = {
|
||||||
|
getSettings: vi.fn(async () => settings), saveSettings: vi.fn(async input => ({ ...settings, ...input })),
|
||||||
|
getCatalog: vi.fn(async () => structuredClone(catalog)),
|
||||||
|
registerDevice: vi.fn(async () => {
|
||||||
|
const device = { id: 'mine', name: 'My desktop', platform: 'win32', appVersion: '1', registeredAt: 'today', updatedAt: 'today' }
|
||||||
|
catalog.devices.push(device); return device
|
||||||
|
}),
|
||||||
|
publish: vi.fn(async (input: SharingPublicationDraft) => {
|
||||||
|
const item = { ...input, id: 'publication', deviceId: 'mine', status: 'published' as const, createdAt: 'today', updatedAt: 'today' }
|
||||||
|
catalog.publications.push(item); return item
|
||||||
|
}),
|
||||||
|
revoke: vi.fn(async () => { catalog.publications[0]!.status = 'revoked'; return catalog.publications[0]! })
|
||||||
|
} satisfies DeviceSharingApi
|
||||||
|
vi.stubGlobal('goodbuddy', { sharing })
|
||||||
|
const notify = vi.fn()
|
||||||
|
render(<DeviceSharingPage notify={notify} />)
|
||||||
|
return { sharing, catalog, notify }
|
||||||
|
}
|
||||||
|
it('registers, publishes independent knowledge flags, revokes and refreshes actual returned rows', async () => {
|
||||||
|
const { sharing, notify } = setup()
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
|
||||||
|
expect(screen.getByRole('button', { name: '发布元数据' })).toBeDisabled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '注册本机' }))
|
||||||
|
await waitFor(() => expect(screen.queryByText('暂无注册设备')).not.toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByLabelText('条目名称'), { target: { value: 'Shared knowledge' } })
|
||||||
|
fireEvent.change(screen.getByLabelText('类型'), { target: { value: 'knowledge' } })
|
||||||
|
fireEvent.change(screen.getByLabelText('来源模式'), { target: { value: 'server' } })
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '搜索' }))
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '下载' }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '发布元数据' })).toBeEnabled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发布元数据' }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '撤销发布' })).toBeEnabled())
|
||||||
|
expect(sharing.publish).toHaveBeenCalledWith(expect.objectContaining({ sourceMode: 'server', permissions: { search: true, read: false, download: true } }))
|
||||||
|
const row = screen.getByText('Shared knowledge').closest('article')!
|
||||||
|
expect(within(row).getByText(/搜索: 允许 · 读取: 不允许 · 下载: 允许/)).toBeInTheDocument()
|
||||||
|
fireEvent.click(within(row).getByRole('button', { name: '撤销发布' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认撤销发布' }))
|
||||||
|
await waitFor(() => { expect(screen.getByText('已撤销')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '刷新列表' })).toBeEnabled() })
|
||||||
|
expect(sharing.revoke).toHaveBeenCalledWith('publication')
|
||||||
|
expect(notify).toHaveBeenCalledWith({ tone: 'success', message: '发布条目已撤销' })
|
||||||
|
})
|
||||||
|
it('preserves unsaved input on failure and does not offer revocation for other device IDs', async () => {
|
||||||
|
const { sharing, catalog, notify } = setup()
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
|
||||||
|
catalog.publications.push({ name: 'Other', id: 'other', deviceId: 'theirs', description: '', kind: 'capability', sourceMode: 'device', permissions: { search: false, read: false, download: false }, status: 'published', createdAt: 'today', updatedAt: 'today' })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }))
|
||||||
|
await screen.findByText('Other')
|
||||||
|
expect(screen.queryByRole('button', { name: '撤销发布' })).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('设备名称')).toBeEnabled())
|
||||||
|
sharing.saveSettings.mockRejectedValueOnce(new Error('Disk full'))
|
||||||
|
fireEvent.change(screen.getByLabelText('设备名称'), { target: { value: 'My edit' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||||
|
await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'Error: Disk full' })))
|
||||||
|
expect(screen.getByLabelText('设备名称')).toHaveValue('My edit')
|
||||||
|
expect(screen.getByRole('button', { name: '注册本机' })).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { SharingCatalog, SharingPublicationDraft, SharingSettings } from '../../shared/device-sharing-contracts'
|
||||||
|
import { sharingSettingsInputSchema } from '../../shared/device-sharing-contracts'
|
||||||
|
import { EmptyState, PageHeader, PageShell } from './WorkspacePrimitives'
|
||||||
|
import type { AppNotificationInput } from './notifications'
|
||||||
|
import './device-sharing.css'
|
||||||
|
|
||||||
|
const emptyDraft: SharingPublicationDraft = {
|
||||||
|
name: '', description: '', kind: 'capability', sourceMode: 'device',
|
||||||
|
permissions: { search: false, read: false, download: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceSharingPage({ notify }: { notify: (input: AppNotificationInput) => void }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('app')
|
||||||
|
const [settings, setSettings] = useState<SharingSettings>()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [serverUrl, setServerUrl] = useState('')
|
||||||
|
const [catalog, setCatalog] = useState<SharingCatalog>()
|
||||||
|
const [draft, setDraft] = useState(emptyDraft)
|
||||||
|
const [busy, setBusy] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [reload, setReload] = useState(0)
|
||||||
|
const [revokeId, setRevokeId] = useState<string>()
|
||||||
|
const pending = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.goodbuddy.sharing.getSettings().then(async next => {
|
||||||
|
if (!active) return
|
||||||
|
setSettings(next); setName(next.name); setServerUrl(next.serverUrl)
|
||||||
|
const snapshot = await window.goodbuddy.sharing.getCatalog()
|
||||||
|
if (active) { setCatalog(snapshot); setError('') }
|
||||||
|
}).catch((reason: unknown) => { if (active) setError(String(reason)) })
|
||||||
|
.finally(() => { if (active) setBusy(false) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setCatalog(await window.goodbuddy.sharing.getCatalog())
|
||||||
|
setError('')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(String(reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
|
||||||
|
if (pending.current) return
|
||||||
|
pending.current = true; setBusy(true)
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
if (message) notify({ tone: 'success', message })
|
||||||
|
} catch (reason) {
|
||||||
|
notify({ tone: 'error', message: String(reason), dedupeKey: 'device-sharing-action' })
|
||||||
|
} finally { pending.current = false; setBusy(false) }
|
||||||
|
}
|
||||||
|
const dirty = settings && (name !== settings.name || serverUrl !== settings.serverUrl)
|
||||||
|
const registered = Boolean(settings && catalog?.devices.some(device => device.id === settings.deviceId))
|
||||||
|
const canPublish = registered && !dirty && !error && !busy
|
||||||
|
const validSettings = sharingSettingsInputSchema.safeParse({ name, serverUrl }).success
|
||||||
|
|
||||||
|
return <PageShell variant="standard">
|
||||||
|
<PageHeader headingId="device-sharing-title" title={t('sharing.title')} scope={{ kind: 'global' }} description={t('sharing.boundary')}
|
||||||
|
actions={<button className="secondary-button" disabled={busy} onClick={() => {
|
||||||
|
if (!settings) { setBusy(true); setReload(value => value + 1) }
|
||||||
|
else void run(refresh)
|
||||||
|
}}>{t('sharing.refresh')}</button>} />
|
||||||
|
<div className="device-sharing" aria-busy={busy}>
|
||||||
|
{busy && <p role="status">{t('sharing.working')}</p>}
|
||||||
|
{error && <div role="alert" className="settings-warning">{t('sharing.loadFailed')} {error}</div>}
|
||||||
|
{settings && <section aria-labelledby="sharing-settings">
|
||||||
|
<h2 id="sharing-settings">{t('sharing.thisDevice')}</h2>
|
||||||
|
<p>{t('sharing.deviceId')}: <code>{settings.deviceId}</code></p>
|
||||||
|
<form onSubmit={event => { 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')) }}>
|
||||||
|
<label className="field"><span>{t('sharing.serverUrl')}</span><input type="url" required value={serverUrl} disabled={busy} onChange={event => setServerUrl(event.target.value)} /></label>
|
||||||
|
<label className="field"><span>{t('sharing.deviceName')}</span><input required maxLength={200} value={name} disabled={busy} onChange={event => setName(event.target.value)} /></label>
|
||||||
|
<div className="device-sharing__actions">
|
||||||
|
<button className="secondary-button" disabled={busy || !dirty || !validSettings}>{t('sharing.save')}</button>
|
||||||
|
<button type="button" className="secondary-button" disabled={busy || Boolean(dirty)} onClick={() => void run(async () => {
|
||||||
|
await window.goodbuddy.sharing.registerDevice(); await refresh()
|
||||||
|
}, t('sharing.registered'))}>{t('sharing.register')}</button>
|
||||||
|
</div>
|
||||||
|
{dirty && <p>{t('sharing.saveFirst')}</p>}
|
||||||
|
</form>
|
||||||
|
</section>}
|
||||||
|
<section aria-labelledby="sharing-devices">
|
||||||
|
<h2 id="sharing-devices">{t('sharing.devices')}</h2>
|
||||||
|
{catalog?.devices.length === 0 && <EmptyState title={t('sharing.noDevices')} description={t('sharing.registerFirst')} />}
|
||||||
|
{catalog?.devices.map(device => <article key={device.id}>
|
||||||
|
<h3>{device.name} {device.id === settings?.deviceId && <small>({t('sharing.thisDevice')})</small>}</h3>
|
||||||
|
<p>{device.platform} · {device.appVersion} · <code>{device.id}</code></p>
|
||||||
|
<p>{t('sharing.updatedAt')}: {device.updatedAt}</p>
|
||||||
|
</article>)}
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="sharing-publish">
|
||||||
|
<h2 id="sharing-publish">{t('sharing.publishMetadata')}</h2>
|
||||||
|
<p>{t('sharing.sourceHelp')}</p>
|
||||||
|
{!registered && <p>{t('sharing.registerFirst')}</p>}
|
||||||
|
<form onSubmit={event => { event.preventDefault(); if (!canPublish) return; void run(async () => {
|
||||||
|
await window.goodbuddy.sharing.publish(draft); setDraft(emptyDraft); await refresh()
|
||||||
|
}, t('sharing.publishedNotice')) }}>
|
||||||
|
<fieldset disabled={busy}>
|
||||||
|
<label className="field"><span>{t('sharing.name')}</span><input required maxLength={200} value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} /></label>
|
||||||
|
<label className="field"><span>{t('sharing.description')}</span><textarea maxLength={4000} value={draft.description} onChange={event => setDraft({ ...draft, description: event.target.value })} /></label>
|
||||||
|
<label className="field"><span>{t('sharing.kind')}</span><select value={draft.kind} onChange={event => setDraft({ ...draft, kind: event.target.value as SharingPublicationDraft['kind'] })}>
|
||||||
|
<option value="capability">{t('sharing.capability')}</option><option value="knowledge">{t('sharing.knowledge')}</option>
|
||||||
|
</select></label>
|
||||||
|
<label className="field"><span>{t('sharing.sourceMode')}</span><select value={draft.sourceMode} onChange={event => setDraft({ ...draft, sourceMode: event.target.value as SharingPublicationDraft['sourceMode'] })}>
|
||||||
|
<option value="device">{t('sharing.device')}</option><option value="server">{t('sharing.server')}</option>
|
||||||
|
</select></label>
|
||||||
|
{draft.kind === 'knowledge' && <fieldset><legend>{t('sharing.permissions')}</legend>
|
||||||
|
{(['search', 'read', 'download'] as const).map(permission => <label key={permission} className="device-sharing__permission"><input type="checkbox" checked={draft.permissions[permission]} onChange={event => setDraft({ ...draft, permissions: { ...draft.permissions, [permission]: event.target.checked } })} />{t(`sharing.${permission}`)}</label>)}
|
||||||
|
</fieldset>}
|
||||||
|
</fieldset>
|
||||||
|
<p>{t('sharing.target')}: {settings?.serverUrl} · {settings?.name}</p>
|
||||||
|
<button className="primary-button" disabled={!canPublish || !draft.name.trim()}>{t('sharing.publishMetadata')}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="sharing-publications">
|
||||||
|
<h2 id="sharing-publications">{t('sharing.publications')}</h2>
|
||||||
|
{catalog?.publications.length === 0 && <EmptyState title={t('sharing.noPublications')} description={t('sharing.publishHelp')} />}
|
||||||
|
{catalog?.publications.map(item => <article key={item.id}>
|
||||||
|
<h3>{item.name} <small>{t(`sharing.${item.status}`)}</small></h3>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p>{t(`sharing.${item.kind}`)} · {t('sharing.sourceMode')}: {t(`sharing.${item.sourceMode}`)}</p>
|
||||||
|
<p>{t('sharing.deviceId')}: <code>{item.deviceId}</code> · {t('sharing.updatedAt')}: {item.updatedAt}</p>
|
||||||
|
{item.kind === 'knowledge' && <p>{(['search', 'read', 'download'] as const).map(key => `${t(`sharing.${key}`)}: ${t(item.permissions[key] ? 'sharing.yes' : 'sharing.no')}`).join(' · ')}</p>}
|
||||||
|
{item.deviceId === settings?.deviceId && item.status === 'published' && (revokeId === item.id ? <div>
|
||||||
|
<p>{t('sharing.revokeHelp')}</p><div className="device-sharing__actions">
|
||||||
|
<button className="secondary-button" disabled={busy} onClick={() => setRevokeId(undefined)}>{t('sharing.cancel')}</button>
|
||||||
|
<button className="danger-solid" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => void run(async () => {
|
||||||
|
await window.goodbuddy.sharing.revoke(item.id); setRevokeId(undefined); await refresh()
|
||||||
|
}, t('sharing.revokedNotice'))}>{t('sharing.confirmRevoke')}</button>
|
||||||
|
</div>
|
||||||
|
</div> : <button className="danger-ghost" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => setRevokeId(item.id)}>{t('sharing.revoke')}</button>)}
|
||||||
|
</article>)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.device-sharing { display: grid; gap: var(--space-6); overflow-wrap: anywhere; }
|
||||||
|
.device-sharing section { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }
|
||||||
|
.device-sharing h2, .device-sharing h3 { font-size: var(--font-section-title); }
|
||||||
|
.device-sharing small, .device-sharing p { color: var(--text-secondary); }
|
||||||
|
.device-sharing form, .device-sharing fieldset { display: grid; gap: var(--space-3); min-width: 0; }
|
||||||
|
.device-sharing fieldset { border: 0; padding: 0; margin: 0; }
|
||||||
|
.device-sharing form > button { justify-self: start; }
|
||||||
|
.device-sharing article { padding-block: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.device-sharing__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
|
.device-sharing__permission { display: flex; align-items: center; gap: var(--space-2); }
|
||||||
@@ -2,6 +2,19 @@ import type { TranslationShape } from '../../resource-types'
|
|||||||
import type { app as chineseApp } from '../zh-CN/app'
|
import type { app as chineseApp } from '../zh-CN/app'
|
||||||
|
|
||||||
export const app = {
|
export const app = {
|
||||||
|
sharing: {
|
||||||
|
title: 'Device Sharing', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
|
||||||
|
refresh: 'Refresh lists', working: 'Working…', loadFailed: 'Could not load the lists. Previous results may be stale. Check the service URL and refresh.',
|
||||||
|
thisDevice: 'This device', deviceId: 'Device ID', serverUrl: 'Service URL', deviceName: 'Device name', save: 'Save settings', saved: 'Sharing settings saved',
|
||||||
|
register: 'Register this device', registered: 'Device registration saved to the service', saveFirst: 'Save settings before registering or publishing.',
|
||||||
|
devices: 'Registered devices', noDevices: 'No registered devices', registerFirst: 'Register this device to publish metadata.', updatedAt: 'Updated at',
|
||||||
|
publishMetadata: 'Publish metadata', sourceHelp: 'Source mode describes the entry only. Selecting Server does not upload or sync content; knowledge permissions are declarations only.',
|
||||||
|
name: 'Entry name', description: 'Description', kind: 'Kind', capability: 'Capability', knowledge: 'Knowledge', sourceMode: 'Source mode', device: 'Device', server: 'Server',
|
||||||
|
permissions: 'Knowledge permission declarations', search: 'Search', read: 'Read', download: 'Download', target: 'Publish to', publishedNotice: 'Metadata published',
|
||||||
|
publications: 'Publication catalog', noPublications: 'No publications', publishHelp: 'Enter a name, kind and source to publish metadata.',
|
||||||
|
published: 'Published', revoked: 'Revoked', yes: 'Allowed', no: 'Not allowed', revoke: 'Revoke publication', confirmRevoke: 'Confirm revocation', cancel: 'Cancel',
|
||||||
|
revokeHelp: 'The catalog record will remain with a revoked status. This changes metadata only and does not delete local content.', revokedNotice: 'Publication revoked'
|
||||||
|
},
|
||||||
runtimeChecklist: {
|
runtimeChecklist: {
|
||||||
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
|
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
|
||||||
finished: 'Request completed', failed: 'Request failed',
|
finished: 'Request completed', failed: 'Request failed',
|
||||||
@@ -22,6 +35,7 @@ export const app = {
|
|||||||
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
|
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
|
||||||
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
||||||
descriptions: {
|
descriptions: {
|
||||||
|
'device-sharing': 'Register devices, publish and revoke capability or knowledge metadata.',
|
||||||
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
||||||
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
export const app = {
|
export const app = {
|
||||||
|
sharing: {
|
||||||
|
title: '设备共享', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
|
||||||
|
refresh: '刷新列表', working: '正在处理…', loadFailed: '列表读取失败,已有列表可能过期。请检查服务地址并刷新。',
|
||||||
|
thisDevice: '本机', deviceId: '设备 ID', serverUrl: '服务地址', deviceName: '设备名称', save: '保存设置', saved: '共享设置已保存',
|
||||||
|
register: '注册本机', registered: '本机注册信息已保存到服务', saveFirst: '请先保存设置,再注册或发布。',
|
||||||
|
devices: '已注册设备', noDevices: '暂无注册设备', registerFirst: '注册本机后即可发布元数据。', updatedAt: '更新时间',
|
||||||
|
publishMetadata: '发布元数据', sourceHelp: '来源模式只描述条目来源。选择“服务器”不会上传或同步内容;知识权限只保存声明。',
|
||||||
|
name: '条目名称', description: '描述', kind: '类型', capability: '能力', knowledge: '知识', sourceMode: '来源模式', device: '设备', server: '服务器',
|
||||||
|
permissions: '知识权限声明', search: '搜索', read: '读取', download: '下载', target: '发布到', publishedNotice: '元数据已发布',
|
||||||
|
publications: '发布目录', noPublications: '暂无发布条目', publishHelp: '填写名称、类型与来源后发布一条元数据。',
|
||||||
|
published: '已发布', revoked: '已撤销', yes: '允许', no: '不允许', revoke: '撤销发布', confirmRevoke: '确认撤销发布', cancel: '取消',
|
||||||
|
revokeHelp: '撤销后保留目录记录并标记为已撤销。此操作只修改元数据,不删除本机内容。', revokedNotice: '发布条目已撤销'
|
||||||
|
},
|
||||||
runtimeChecklist: {
|
runtimeChecklist: {
|
||||||
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
|
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
|
||||||
finished: '请求已完成', failed: '请求失败',
|
finished: '请求已完成', failed: '请求失败',
|
||||||
@@ -19,6 +32,7 @@ export const app = {
|
|||||||
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
|
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
|
||||||
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
||||||
descriptions: {
|
descriptions: {
|
||||||
|
'device-sharing': '注册设备,发布和撤销能力或知识的元数据。',
|
||||||
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
||||||
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
|
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ export const defaultSupervisorModelConcurrency = 1
|
|||||||
export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
|
export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
|
||||||
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
|
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
|
||||||
|
|
||||||
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'] as const
|
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'] as const
|
||||||
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
|
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
|
||||||
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference'] as const
|
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference'] as const
|
||||||
export type EditableApplicationId = typeof editableApplicationIds[number]
|
export type EditableApplicationId = typeof editableApplicationIds[number]
|
||||||
export const defaultApplicationNavigation = {
|
export const defaultApplicationNavigation = {
|
||||||
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] as BuiltInApplicationId[],
|
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[],
|
||||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false }
|
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false }
|
||||||
}
|
}
|
||||||
export const applicationNavigationSchema = z.object({
|
export const applicationNavigationSchema = z.object({
|
||||||
|
|||||||
@@ -1767,6 +1767,7 @@ export type KnowledgeSearchReference = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DesktopApi = {
|
export type DesktopApi = {
|
||||||
|
sharing: import('./device-sharing-contracts').DeviceSharingApi
|
||||||
openRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientResult>
|
openRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientResult>
|
||||||
getRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientService | null>
|
getRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientService | null>
|
||||||
stopRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientService) => Promise<void>
|
stopRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientService) => Promise<void>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const sharingIdSchema = z.string().trim().min(1).max(200)
|
||||||
|
export const sharingSettingsInputSchema = z.object({
|
||||||
|
serverUrl: z.url().refine(value => {
|
||||||
|
if (!URL.canParse(value)) return false
|
||||||
|
const url = new URL(value)
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash
|
||||||
|
}, 'Use an HTTP(S) service URL without credentials, query or fragment'),
|
||||||
|
name: z.string().trim().min(1).max(200)
|
||||||
|
}).strict()
|
||||||
|
export const sharingSettingsSchema = sharingSettingsInputSchema.extend({ deviceId: sharingIdSchema })
|
||||||
|
export const sharingDeviceSchema = z.object({
|
||||||
|
id: sharingIdSchema, name: z.string(), platform: z.string(), appVersion: z.string(),
|
||||||
|
registeredAt: z.string(), updatedAt: z.string()
|
||||||
|
})
|
||||||
|
export const sharingPublicationDraftSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
kind: z.enum(['capability', 'knowledge']),
|
||||||
|
description: z.string().trim().max(4000),
|
||||||
|
sourceMode: z.enum(['device', 'server']),
|
||||||
|
permissions: z.object({ search: z.boolean(), read: z.boolean(), download: z.boolean() }).strict()
|
||||||
|
}).strict()
|
||||||
|
export const sharingPublicationSchema = sharingPublicationDraftSchema.extend({
|
||||||
|
id: sharingIdSchema, deviceId: sharingIdSchema,
|
||||||
|
status: z.enum(['published', 'revoked']), createdAt: z.string(), updatedAt: z.string()
|
||||||
|
})
|
||||||
|
export type SharingSettings = z.infer<typeof sharingSettingsSchema>
|
||||||
|
export type SharingSettingsInput = z.infer<typeof sharingSettingsInputSchema>
|
||||||
|
export type SharingDevice = z.infer<typeof sharingDeviceSchema>
|
||||||
|
export type SharingPublicationDraft = z.infer<typeof sharingPublicationDraftSchema>
|
||||||
|
export type SharingPublication = z.infer<typeof sharingPublicationSchema>
|
||||||
|
export type SharingCatalog = { devices: SharingDevice[]; publications: SharingPublication[] }
|
||||||
|
export type DeviceSharingApi = {
|
||||||
|
getSettings: () => Promise<SharingSettings>
|
||||||
|
saveSettings: (input: SharingSettingsInput) => Promise<SharingSettings>
|
||||||
|
registerDevice: () => Promise<SharingDevice>
|
||||||
|
getCatalog: () => Promise<SharingCatalog>
|
||||||
|
publish: (input: SharingPublicationDraft) => Promise<SharingPublication>
|
||||||
|
revoke: (id: string) => Promise<SharingPublication>
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
export const ipcChannels = {
|
export const ipcChannels = {
|
||||||
|
sharingSettingsGet: 'sharing:settings-get',
|
||||||
|
sharingSettingsSave: 'sharing:settings-save',
|
||||||
|
sharingRegister: 'sharing:register',
|
||||||
|
sharingCatalog: 'sharing:catalog',
|
||||||
|
sharingPublish: 'sharing:publish',
|
||||||
|
sharingRevoke: 'sharing:revoke',
|
||||||
localInferenceGet: 'local-inference:get',
|
localInferenceGet: 'local-inference:get',
|
||||||
localInferenceAct: 'local-inference:act',
|
localInferenceAct: 'local-inference:act',
|
||||||
localInferenceCancel: 'local-inference:cancel',
|
localInferenceCancel: 'local-inference:cancel',
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { createServer as createHttpServer } from 'node:http'
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { createServer } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
|
||||||
|
it.skipIf(!process.env.GOODBUDDY_SHARING_SERVER_ROOT)('uses the real ShareServer through Electron preload and trusted Main IPC', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-sharing-electron-'))
|
||||||
|
const portProbe = createHttpServer()
|
||||||
|
await new Promise<void>(resolve => portProbe.listen(0, '127.0.0.1', resolve))
|
||||||
|
const port = (portProbe.address() as { port: number }).port
|
||||||
|
await new Promise<void>(resolve => portProbe.close(() => resolve()))
|
||||||
|
const backend = spawn(process.execPath, ['--import', 'tsx', 'server/index.ts'], {
|
||||||
|
cwd: process.env.GOODBUDDY_SHARING_SERVER_ROOT,
|
||||||
|
env: { ...process.env, SHARESERVER_PORT: String(port), SHARESERVER_DATA_DIR: join(directory, 'server-data') },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
let backendOutput = ''
|
||||||
|
backend.stdout.on('data', data => { backendOutput += data })
|
||||||
|
backend.stderr.on('data', data => { backendOutput += data })
|
||||||
|
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||||
|
plugins: [react(), { name: 'sharing-smoke', configureServer(server) {
|
||||||
|
server.middlewares.use('/sharing.html', async (_request, response) => {
|
||||||
|
response.setHeader('Content-Type', 'text/html')
|
||||||
|
response.end(await server.transformIndexHtml('/sharing.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/device-sharing-renderer.tsx"></script></body></html>'))
|
||||||
|
})
|
||||||
|
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(backendOutput || 'ShareServer startup timed out')), 15000)
|
||||||
|
backend.once('error', reject)
|
||||||
|
backend.once('exit', code => { clearTimeout(timer); reject(new Error(`ShareServer exit ${code}: ${backendOutput}`)) })
|
||||||
|
backend.stdout.on('data', () => { if (backendOutput.includes('listening on')) { clearTimeout(timer); resolve() } })
|
||||||
|
})
|
||||||
|
await build({ entryPoints: ['tests/support/device-sharing-main.ts'], outfile: join(directory, 'main.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||||
|
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||||
|
await server.listen()
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env, GB_SHARING_DIRECTORY: directory, GB_SHARING_URL: `http://127.0.0.1:${port}`,
|
||||||
|
GB_SHARING_RENDERER: server.resolvedUrls!.local[0] + 'sharing.html' }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
const child = spawn(createRequire(import.meta.url)('electron'), [join(directory, 'main.cjs')], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let output = ''
|
||||||
|
child.stdout.on('data', data => { output += data })
|
||||||
|
child.stderr.on('data', data => { output += data })
|
||||||
|
const timer = setTimeout(() => child.kill(), 60000)
|
||||||
|
try {
|
||||||
|
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
|
||||||
|
process.stdout.write(output)
|
||||||
|
} finally { clearTimeout(timer) }
|
||||||
|
} finally {
|
||||||
|
const stopped = new Promise(resolve => backend.once('exit', resolve))
|
||||||
|
if (backend.exitCode === null) { backend.kill(); await stopped }
|
||||||
|
await server.close()
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 90000)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
import { DeviceSharingService } from '../../src/main/device-sharing-service'
|
||||||
|
import { registerDeviceSharingIpc } from '../../src/main/device-sharing-ipc'
|
||||||
|
|
||||||
|
const directory = process.env.GB_SHARING_DIRECTORY!
|
||||||
|
app.setPath('userData', join(directory, 'profile'))
|
||||||
|
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||||
|
void app.whenReady().then(async () => {
|
||||||
|
const file = join(directory, 'sharing-settings.json')
|
||||||
|
const service = new DeviceSharingService(file, 'electron-smoke')
|
||||||
|
await service.saveSettings({ name: 'Sharing smoke device', serverUrl: process.env.GB_SHARING_URL! })
|
||||||
|
const win = new BrowserWindow({ show: true, width: 1100, height: 850, webPreferences: {
|
||||||
|
preload: join(directory, 'preload.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false
|
||||||
|
} })
|
||||||
|
const dispose = registerDeviceSharingIpc(win, service)
|
||||||
|
const errors: string[] = []
|
||||||
|
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||||
|
const js = (code: string) => win.webContents.executeJavaScript(code)
|
||||||
|
const wait = async (code: string): Promise<void> => {
|
||||||
|
for (let count = 0; count < 300; count++) { if (await js(code)) return; await setTimeout(25) }
|
||||||
|
throw new Error(`Timed out: ${code}; ${errors.join('\n')}`)
|
||||||
|
}
|
||||||
|
const click = async (text: string): Promise<void> => {
|
||||||
|
await wait(`[...document.querySelectorAll('button')].some(e => e.textContent === ${JSON.stringify(text)} && !e.disabled)`)
|
||||||
|
const rect = await js(`(() => { const e = [...document.querySelectorAll('button')].find(e=>e.textContent===${JSON.stringify(text)}); e.scrollIntoView({block:'center'}); const r=e.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)} })()`)
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseDown', ...rect, button: 'left', clickCount: 1 })
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseUp', ...rect, button: 'left', clickCount: 1 })
|
||||||
|
}
|
||||||
|
const field = async (label: string, value: string, tag = 'input'): Promise<void> => {
|
||||||
|
await js(`(() => { const e = [...document.querySelectorAll('label')].find(e => e.querySelector('span')?.textContent === ${JSON.stringify(label)}).querySelector(${JSON.stringify(tag)}); Object.getOwnPropertyDescriptor(${tag === 'select' ? 'HTMLSelectElement' : 'HTMLInputElement'}.prototype,'value').set.call(e,${JSON.stringify(value)}); e.dispatchEvent(new Event('${tag === 'select' ? 'change' : 'input'}',{bubbles:true})); })()`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await win.loadURL(process.env.GB_SHARING_RENDERER!)
|
||||||
|
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
|
||||||
|
await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').click()`)
|
||||||
|
await click('Register this device')
|
||||||
|
await wait(`!!document.querySelector('#sharing-devices + article') || document.querySelector('[aria-labelledby="sharing-devices"]').textContent.includes('Sharing smoke device')`)
|
||||||
|
await field('Entry name', 'Smoke knowledge')
|
||||||
|
await field('Kind', 'knowledge', 'select')
|
||||||
|
await field('Source mode', 'server', 'select')
|
||||||
|
await wait(`document.querySelectorAll('input[type=checkbox]').length === 3`)
|
||||||
|
await js(`document.querySelectorAll('input[type=checkbox]')[0].click(); document.querySelectorAll('input[type=checkbox]')[2].click()`)
|
||||||
|
await click('Publish metadata')
|
||||||
|
await wait(`document.querySelector('[aria-labelledby="sharing-publications"]').textContent.includes('Smoke knowledge')`)
|
||||||
|
let catalog = await service.getCatalog()
|
||||||
|
assert.equal(catalog.devices.length, 1)
|
||||||
|
assert.equal(catalog.publications.length, 1)
|
||||||
|
assert.deepEqual(catalog.publications[0]!.permissions, { search: true, read: false, download: true })
|
||||||
|
assert.equal(catalog.publications[0]!.sourceMode, 'server')
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await js(`document.documentElement.dataset.theme='${theme}'`)
|
||||||
|
win.setContentSize(480, 700)
|
||||||
|
await wait('window.innerWidth === 480')
|
||||||
|
assert.equal(await js('document.documentElement.scrollWidth <= window.innerWidth'), true, 'narrow layout overflow')
|
||||||
|
}
|
||||||
|
await click('Revoke publication')
|
||||||
|
await click('Confirm revocation')
|
||||||
|
await wait(`document.querySelector('[aria-labelledby="sharing-publications"]').textContent.includes('Revoked')`)
|
||||||
|
await click('Refresh lists')
|
||||||
|
await wait(`!document.querySelector('.device-sharing').getAttribute('aria-busy') || document.querySelector('.device-sharing').getAttribute('aria-busy') === 'false'`)
|
||||||
|
catalog = await service.getCatalog()
|
||||||
|
assert.equal(catalog.publications[0]!.status, 'revoked')
|
||||||
|
assert.deepEqual(await new DeviceSharingService(file, 'restart').getSettings(), await service.getSettings())
|
||||||
|
assert.equal(errors.length, 0, errors.join('\n'))
|
||||||
|
console.log('SHARING_ELECTRON_OK: application center -> register -> knowledge/server metadata (search=true, read=false, download=true) -> revoke -> refresh; persisted identity; light/dark 480px; real ShareServer')
|
||||||
|
} finally { dispose(); win.destroy() }
|
||||||
|
}).then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
|
||||||
|
import { DeviceSharingPage } from '../../src/renderer/src/DeviceSharingPage'
|
||||||
|
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../src/shared/application-settings-contracts'
|
||||||
|
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||||
|
import '../../src/renderer/src/styles.css'
|
||||||
|
|
||||||
|
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||||
|
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||||
|
function Harness(): React.JSX.Element {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [page, setPage] = useState(false)
|
||||||
|
return <UiLocaleProvider initialPreference="en-US">
|
||||||
|
{open && <ApplicationCenter settings={settings} pending={false} onClose={() => setOpen(false)}
|
||||||
|
onOpen={id => { if (id === 'device-sharing') { setPage(true); setOpen(false) } }}
|
||||||
|
onUpdate={async () => false} onRetry={() => undefined} />}
|
||||||
|
{page && <DeviceSharingPage notify={input => { document.documentElement.dataset.notification = input.message }} />}
|
||||||
|
</UiLocaleProvider>
|
||||||
|
}
|
||||||
|
createRoot(document.getElementById('root')!).render(<Harness />)
|
||||||
Reference in New Issue
Block a user