Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,27 @@
"main": "./out/main/index.js",
"type": "module",
"scripts": {
"dev": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && node scripts/dev-launch.mjs concurrently -k -n hub,electron -c blue,green \"pnpm run dev:hub\" \"pnpm run dev:electron:raw\"",
"dev:electron": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && node scripts/dev-launch.mjs electron-vite dev",
"dev": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && pnpm spatial:build && node scripts/dev-launch.mjs concurrently -k -n hub,electron -c blue,green \"pnpm run dev:hub\" \"pnpm run dev:electron:raw\"",
"dev:electron": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && pnpm spatial:build && node scripts/dev-launch.mjs electron-vite dev",
"dev:electron:raw": "electron-vite dev",
"dev:hub": "HUB_AUTH=false HUB_STORAGE=memory node scripts/run-dev-hub.mjs",
"dev:clean": "node scripts/dev-clean.mjs",
"dev:scope": "node scripts/dev-scope.mjs",
"dev:user2": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && cross-env XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 node scripts/dev-launch.mjs electron-vite dev",
"dev:both": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && concurrently -k -n hub,user1,user2 -c blue,green,yellow \"pnpm run dev:hub\" \"pnpm run dev:electron\" \"sleep 3 && pnpm run dev:user2\"",
"build": "electron-vite build",
"dev:user2": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && pnpm spatial:build && cross-env XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 node scripts/dev-launch.mjs electron-vite dev",
"dev:both": "node scripts/rebuild-if-stale.mjs && node scripts/build-workspace-deps.mjs && pnpm spatial:build && concurrently -k -n hub,user1,user2 -c blue,green,yellow \"pnpm run dev:hub\" \"pnpm run dev:electron\" \"sleep 3 && pnpm run dev:user2\"",
"build": "pnpm spatial:build && electron-vite build",
"deps:electron": "pnpm dlx @electron/rebuild -f -w better-sqlite3,usearch,sharp",
"deps:node": "pnpm rebuild better-sqlite3 usearch sharp && node scripts/rebuild-if-stale.mjs --invalidate",
"preview": "electron-vite preview",
"dist": "npm run build && electron-builder",
"dist:mac:self-signed": "./scripts/build-macos-self-signed.sh",
"dist:mac:self-signed:arm64": "./scripts/build-macos-self-signed.sh arm64",
"test": "pnpm run deps:node && vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"spatial:dev": "vite --config spatial.vite.config.ts",
"spatial:build": "vite build --config spatial.vite.config.ts",
"spatial:typecheck": "tsc -p tsconfig.spatial.json",
"typecheck": "pnpm spatial:typecheck && tsc -p tsconfig.node.json --noEmit --incremental false --composite false"
},
"dependencies": {
"@xnetjs/canvas": "workspace:*",
Expand Down Expand Up @@ -71,6 +75,7 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/three": "0.186.0",
"@types/webxr": "0.5.24",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
Expand Down
7 changes: 7 additions & 0 deletions apps/electron/spatial.vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
root: resolve(__dirname, 'src/spatial'),
build: { outDir: resolve(__dirname, 'out/spatial'), emptyOutDir: true, target: 'es2022' },
server: { host: '127.0.0.1', port: 5189, strictPort: true }
})
24 changes: 24 additions & 0 deletions apps/electron/src/library/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ const resource = (id: string, values: Partial<GraphResource> = {}): GraphResourc
...values
})

it('excludes conversations and archive text even when they have ordinary web URLs', () => {
const builder = createGraphBuilder(
[
resource('link'),
resource('private-conversation', {
resourceKind: 'conversation',
hashtags: ['private-topic'],
author: 'Private person'
}),
resource('archive-body', { resourceKind: 'archive-text' })
],
3
)
builder.collection('private-list', { title: 'Private collection' })
builder.membership({ collection: 'private-list', item: 'private-conversation' })
builder.content('private-conversation', { metadataJson: JSON.stringify({ tags: ['secret'] }) })
expect(builder.finish()).toMatchObject({
nodes: [expect.objectContaining({ id: 'link' })],
edges: [],
linkCount: 1,
resourceCount: 3
})
})

it('keeps every link, including orphans, and deduplicates repeated memberships', () => {
const builder = createGraphBuilder(
Array.from({ length: 1200 }, (_, i) => resource(`${i}`)),
Expand Down
6 changes: 5 additions & 1 deletion apps/electron/src/library/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

export type GraphResource = {
id: string
resourceKind?: string | null
url: string
title: string
platform: string
Expand Down Expand Up @@ -42,7 +43,10 @@ export const hashtagsIn = (value: string): string[] => [
]

/** Hubs preserve source relationships without expanding a playlist into a quadratic clique. */
export function createGraphBuilder(resources: GraphResource[], resourceCount: number) {
export function createGraphBuilder(allResources: GraphResource[], resourceCount: number) {
// A conversation can carry an HTTP source URL. A URL alone is not permission
// to include its title, hashtags or relationships in the saved-link projection.
const resources = allResources.filter((resource) => !resource.resourceKind)
const graph: LibraryGraph = {
nodes: resources.map((resource) => ({
id: resource.id,
Expand Down
9 changes: 9 additions & 0 deletions apps/electron/src/library/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const resource = (id = 'video-one'): LibraryResource => ({
privacy: 'private',
addedAt: 1
})

it('preserves text-resource kinds even when their source URL is HTTP', () => {
store.put({ ...resource('conversation'), kind: 'conversation', sourceText: 'private body' })
const row = store.graphResources().find((item) => item.id === 'conversation')
expect(row?.resourceKind).toBe('conversation')
expect(row).not.toHaveProperty('body')
expect(store.get('conversation')?.sourceText).toBe('private body')
})
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'xnet-library-'))
path = join(root, 'library.db')
Expand Down Expand Up @@ -384,6 +392,7 @@ it('projects every web link for the graph without source bodies or caption paylo
expect(rows.find((row) => row.id === 'local')).toBeUndefined()
expect(rows.find((row) => row.id === 'tagged')).toEqual({
id: 'tagged',
resourceKind: null,
title: 'Fetched title',
url: resource().url,
platform: 'youtube',
Expand Down
2 changes: 1 addition & 1 deletion apps/electron/src/library/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export class LibraryStore {
// source bodies or provider evidence with the overview's compact nodes.
const rows = this.db
.prepare(
`SELECT id,url,title,platform,
`SELECT id,url,title,platform,json_extract(payload,'$.kind') AS resourceKind,
COALESCE(json_extract(payload,'$.networkPlatform'),platform) AS provider,
COALESCE(NULLIF(json_extract(payload,'$.metadata.author'),''),json_extract(payload,'$.actor'),'') AS author,
COALESCE(json_extract(payload,'$.sourceText'),'') || char(10) ||
Expand Down
3 changes: 3 additions & 0 deletions apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { checkpointWorkspace, recoveryPath, recoveryIsBusy, setupRecovery } from
import { flushRenderers, resumeRenderers, setupRendererFlush } from './renderer-flush'
import { setupServiceIPC, cleanupServices } from './service-ipc'
import { setupSocialImportIPC, hasActiveSocialImports } from './social-import-ipc'
import { setupSpatialLibraryIPC, stopSpatialLibrary } from './spatial-library-ipc'
import { showStartupRecovery } from './startup-recovery'
import { setupStorybookIPC, stopStorybook } from './storybook-ipc'
import { hasDownloadedUpdate, initAutoUpdater, installDownloadedUpdate } from './updater'
Expand Down Expand Up @@ -77,6 +78,7 @@ let writersStopped = false
async function stopWorkspaceWriters(): Promise<void> {
await shutdownRecordingCapture()
await stopAgentBridge()
await stopSpatialLibrary()
await stopLocalAPI()
await cleanupServices()
await stopCloudflareTunnel()
Expand Down Expand Up @@ -486,6 +488,7 @@ app
bootTrace('data process ready')
await configureLibrary()
setupLibraryIPC(() => mainWindow)
setupSpatialLibraryIPC()

// Setup IPC handlers for main process operations
setupIPC()
Expand Down
92 changes: 92 additions & 0 deletions apps/electron/src/main/spatial-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { fixtureAdapter } from '../spatial/fixtures'
import { createSpatialBridge } from './spatial-bridge'

const origin = 'https://mac.example:8443'
async function setup() {
let clock = 1000
const adapter = fixtureAdapter(10),
signal = new AbortController().signal
const reader = {
snapshot: await adapter.snapshot(signal),
detail: vi.fn((id: string) => adapter.detail(id, signal)),
thumbnail: vi.fn(async () => ({ data: new Uint8Array([1]), contentType: 'image/png' }))
}
const original = JSON.stringify(reader.snapshot)
const bridge = createSpatialBridge(reader, origin, () => clock)
const request = (path: string, token = '', method = 'GET', source = origin) =>
bridge.handle(
new Request(origin + path, {
method,
headers: { Origin: source, Authorization: `Bearer ${token}` }
})
)
const paired = await request('/pair', bridge.invitation, 'POST')
const { token } = (await paired.json()) as { token: string }
return {
bridge,
reader,
request,
token,
original,
time: (n: number) => {
clock += n
}
}
}
describe('scoped spatial pairing', () => {
it('requires a single-use invitation and never accepts the general API token', async () => {
const { bridge, request, token } = await setup()
expect((await request('/pair', bridge.invitation, 'POST')).status).toBe(401)
expect((await request('/api/snapshot')).status).toBe(401)
expect((await request('/api/snapshot', 'general-api-token')).status).toBe(401)
expect((await request('/api/snapshot', token)).status).toBe(200)
expect((await request('/api/snapshot', token, 'GET', 'https://evil.example')).status).toBe(403)
})
it('has no writes, arbitrary blob lookup, SQL or outside-scope details', async () => {
const { request, token, reader, original } = await setup()
expect((await request('/api/detail/demo%3A0', token, 'POST')).status).toBe(405)
expect((await request('/api/detail/private-conversation', token)).status).toBe(404)
expect((await request('/api/thumbnail/arbitrary-cid', token)).status).toBe(404)
expect((await request('/sql', token)).status).toBe(404)
expect(reader.detail).not.toHaveBeenCalled()
expect(reader.thumbnail).not.toHaveBeenCalled()
expect(JSON.stringify(reader.snapshot)).toBe(original)
})
it('supports scoped snapshot, search, detail and image reads', async () => {
const { request, token } = await setup()
for (const path of [
'/api/snapshot',
'/api/search?q=resource',
'/api/detail/demo%3A0',
'/api/thumbnail/demo%3A0'
]) {
const response = await request(path, token)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('access-control-allow-origin')).toBeNull()
}
})
it('revokes in-flight reads as well as subsequent requests', async () => {
const { bridge, reader, request, token } = await setup()
let finish: (value: null) => void = () => undefined
reader.detail.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const pending = request('/api/detail/demo%3A0', token)
bridge.revoke()
finish(null)
expect((await pending).status).toBe(401)
expect((await request('/api/snapshot', token)).status).toBe(401)
})
it('expires the reader and surfaces storage errors without plausible empty data', async () => {
const { time, request, token, reader } = await setup()
reader.detail.mockRejectedValue(new Error('database unavailable'))
expect((await request('/api/detail/demo%3A0', token)).status).toBe(503)
time(60 * 60_000)
expect((await request('/api/snapshot', token)).status).toBe(401)
})
})
102 changes: 102 additions & 0 deletions apps/electron/src/main/spatial-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { SpatialDetail, SpatialSnapshot } from '../shared/spatial-library'
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { graphSearchIndex, searchGraph } from '../shared/library-graph/navigation'

type SpatialReader = {
snapshot: SpatialSnapshot
detail(id: string, text: boolean): Promise<SpatialDetail | null>
thumbnail(id: string): Promise<{ data: Uint8Array; contentType: string } | null>
}
const secret = () => randomBytes(32).toString('base64url')
const same = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b))
const headers = {
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Type': 'application/json'
}
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers })

/** One owner-approved snapshot, one invitation, one short-lived reader. No write routes. */
export function createSpatialBridge(reader: SpatialReader, origin: string, now = Date.now) {
const invitation = secret(),
token = secret()
const issued = now(),
expiresAt = issued + 60 * 60_000
const scope = new Set(
reader.snapshot.graph.nodes.filter((node) => node.kind === 'link').map((node) => node.id)
)
const snapshot = JSON.stringify(reader.snapshot)
const search = graphSearchIndex(reader.snapshot.graph)
let redeemed = false,
revoked = false,
active = 0
let requests: number[] = []
const valid = () => !revoked && now() < expiresAt
return {
invitation,
expiresAt,
revoke() {
revoked = true
},
async handle(request: Request): Promise<Response> {
const url = new URL(request.url)
if (
url.origin !== origin ||
(request.headers.has('origin') && request.headers.get('origin') !== origin)
)
return json({ error: 'Origin denied' }, 403)
if (!valid()) return json({ error: 'Pairing expired or revoked' }, 401)
const auth = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''
if (!/^[A-Za-z0-9_-]{43}$/.test(auth)) return json({ error: 'Unauthorized' }, 401)
if (url.pathname === '/pair') {
if (request.method !== 'POST') return json({ error: 'Method denied' }, 405)
if (redeemed || now() - issued > 5 * 60_000 || !same(auth, invitation))
return json({ error: 'Invitation unavailable' }, 401)
redeemed = true
return json({ token, expiresAt })
}
if (!redeemed || !same(auth, token)) return json({ error: 'Unauthorized' }, 401)
if (request.method !== 'GET') return json({ error: 'Read-only viewer' }, 405)
requests = requests.filter((time) => time > now() - 60_000)
if (active >= 4 || requests.length >= 120)
return new Response('Read limit reached', {
status: 429,
headers: { ...headers, 'Retry-After': '60' }
})
requests.push(now())
active++
try {
if (url.pathname === '/api/snapshot') return new Response(snapshot, { headers })
if (url.pathname === '/api/search')
return json(searchGraph(search, (url.searchParams.get('q') ?? '').slice(0, 300), 30))
const match = url.pathname.match(/^\/api\/(detail|thumbnail)\/([^/]+)$/)
if (!match) return json({ error: 'Not found' }, 404)
const id = decodeURIComponent(match[2])
if (!scope.has(id)) return json({ error: 'Outside paired scope' }, 404)
if (match[1] === 'detail') {
const detail = await reader.detail(id, url.searchParams.get('text') === '1')
if (!valid()) return json({ error: 'Pairing expired or revoked' }, 401)
return detail ? json(detail) : json({ error: 'Resource unavailable' }, 404)
}
const blob = await reader.thumbnail(id)
if (!valid()) return json({ error: 'Pairing expired or revoked' }, 401)
if (!blob) return json({ error: 'Thumbnail unavailable' }, 404)
if (
blob.data.byteLength > 8 * 1024 * 1024 ||
!['image/png', 'image/jpeg', 'image/webp'].includes(blob.contentType)
)
return json({ error: 'Unsupported thumbnail' }, 415)
return new Response(Buffer.from(blob.data), {
headers: { ...headers, 'Content-Type': blob.contentType }
})
} catch {
return json({ error: 'The Mac could not read this item' }, 503)
} finally {
active--
}
}
}
}
Loading
Loading