feat: add device sharing metadata application
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
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<void>) | 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(),
|
||||
|
||||
Reference in New Issue
Block a user