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
57 changes: 39 additions & 18 deletions packages/app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
loadSyncState, saveSyncState,
loadConnectors, makeFetchCapability, makeChromeCookiesCapability, makeLogCapabilityFor, makeSqliteCapability,
TrustStore, downloadAndInstall, uninstallConnector, resolveNpmPackage, checkForUpdates,
fetchRegistry,
} from '@spool/core'
import type { UpdateInfo } from '@spool/core'
import type { AuthStatus, ConnectorStatus, FragmentResult, SchedulerEvent, SearchResult, SessionSource } from '@spool/core'
Expand Down Expand Up @@ -169,6 +170,32 @@ function runSyncWorker(): Promise<{ added: number; updated: number; errors: numb

const VALID_NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/

async function installConnectorPackage(
packageName: string,
): Promise<{ ok: true; name: string; version: string } | { ok: false; error: string }> {
try {
const connectorsDir = join(spoolDir, 'connectors')
const result = await downloadAndInstall(packageName, connectorsDir, fetch)

const isFirstParty = packageName.startsWith('@spool-lab/')
if (!isFirstParty && trustStore) {
trustStore.add(packageName)
}

await reloadConnectors()

mainWindow?.webContents.send('connector:event', {
type: 'installed',
name: result.name,
version: result.version,
})

return { ok: true, name: result.name, version: result.version }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}

function parseSpoolUrl(url: string): { action: string; packageName: string } | null {
const match = url.match(/^spool:\/\/connector\/install\/(.+)$/)
if (!match) return null
Expand Down Expand Up @@ -251,31 +278,17 @@ async function handleSpoolUrl(url: string): Promise<void> {

if (response !== 0) return

// Show progress on window title bar
mainWindow?.setProgressBar(0.5)

try {
const connectorsDir = join(spoolDir, 'connectors')
const result = await downloadAndInstall(parsed.packageName, connectorsDir, fetch)
const installResult = await installConnectorPackage(parsed.packageName)

if (!isFirstParty && trustStore) {
trustStore.add(parsed.packageName)
}
mainWindow?.setProgressBar(-1)

await reloadConnectors()

mainWindow?.setProgressBar(-1) // clear progress
mainWindow?.webContents.send('connector:event', {
type: 'installed',
name: result.name,
version: result.version,
})
} catch (err) {
mainWindow?.setProgressBar(-1)
if (!installResult.ok) {
dialog.showMessageBox(mainWindow!, {
type: 'error',
message: `Failed to install ${displayName}`,
detail: err instanceof Error ? err.message : String(err),
detail: installResult.error,
})
}
}
Expand Down Expand Up @@ -786,3 +799,11 @@ ipcMain.handle('connector:get-capture-count', (_e, { connectorId }: { connectorI
).get(connector.platform, connectorId) as { cnt: number }
return row.cnt
})

ipcMain.handle('connector:fetch-registry', async () => {
return fetchRegistry({ fetchFn: (input, init) => net.fetch(input as any, init), cacheDir: spoolDir })
})

ipcMain.handle('connector:install', async (_e, { packageName }: { packageName: string }) => {
return installConnectorPackage(packageName)
})
8 changes: 7 additions & 1 deletion packages/app/src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { FragmentResult, Session, Message, StatusInfo, SyncResult, SearchResult, ConnectorStatus, AuthStatus, SchedulerStatus } from '@spool/core'
import type { FragmentResult, Session, Message, StatusInfo, SyncResult, SearchResult, ConnectorStatus, AuthStatus, SchedulerStatus, RegistryConnector } from '@spool/core'
import type { SearchSortOrder } from '../shared/searchSort.js'
import type { ThemeEditorStateV1 } from '../renderer/theme/editorTypes.js'

Expand Down Expand Up @@ -164,6 +164,12 @@ const api = {
ipcRenderer.on('connector:event', handler)
return () => ipcRenderer.removeListener('connector:event', handler)
},

fetchRegistry: (): Promise<RegistryConnector[]> =>
ipcRenderer.invoke('connector:fetch-registry'),

install: (packageName: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('connector:install', { packageName }),
},

// Auto-update
Expand Down
85 changes: 81 additions & 4 deletions packages/app/src/renderer/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import type { ConnectorStatus } from '@spool/core'
import type { ConnectorStatus, RegistryConnector } from '@spool/core'
import type { AgentInfo, AgentsConfig, SdkAgentConfig } from '../../preload/index.js'
import { DEFAULT_SEARCH_SORT_ORDER, SEARCH_SORT_OPTIONS, type SearchSortOrder } from '../../shared/searchSort.js'
import type { ThemeEditorStateV1 } from '../theme/editorTypes.js'
Expand Down Expand Up @@ -321,6 +321,11 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
const [availableUpdates, setAvailableUpdates] = useState<Record<string, { current: string; latest: string }>>({})
const [updatingConnector, setUpdatingConnector] = useState<string | null>(null)
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({})
const [registryConnectors, setRegistryConnectors] = useState<RegistryConnector[]>([])
const [registryLoading, setRegistryLoading] = useState(true)
const [registryError, setRegistryError] = useState(false)
const [installingPackage, setInstallingPackage] = useState<string | null>(null)
const [installErrors, setInstallErrors] = useState<Record<string, string>>({})

const loadConnectors = useCallback(async () => {
if (!window.spool?.connectors) return
Expand Down Expand Up @@ -356,11 +361,25 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
} else if (event.type === 'updated') {
loadConnectors()
window.spool?.connectors.checkUpdates().then(setAvailableUpdates).catch(() => {})
} else if (event.type === 'installed') {
loadConnectors()
}
})
return off
}, [loadConnectors])

useEffect(() => {
if (!window.spool?.connectors?.fetchRegistry) return
setRegistryLoading(true)
window.spool.connectors.fetchRegistry()
.then(setRegistryConnectors)
.catch(() => setRegistryError(true))
.finally(() => setRegistryLoading(false))
}, [])

const installedIds = new Set(connectors.map(c => c.id))
const discoverConnectors = registryConnectors.filter(rc => !installedIds.has(rc.id))

const handleSync = async (connectorId: string) => {
if (!window.spool?.connectors) return
setSyncingConnector(connectorId)
Expand Down Expand Up @@ -391,6 +410,22 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
}
}

const handleInstall = async (packageName: string) => {
if (!window.spool?.connectors?.install) return
setInstallingPackage(packageName)
setInstallErrors(prev => { const next = { ...prev }; delete next[packageName]; return next })
try {
const result = await window.spool.connectors.install(packageName)
if (!result.ok) {
setInstallErrors(prev => ({ ...prev, [packageName]: result.error ?? 'Install failed' }))
}
} catch (err) {
setInstallErrors(prev => ({ ...prev, [packageName]: err instanceof Error ? err.message : String(err) }))
} finally {
setInstallingPackage(null)
}
}

const selected = connectors.find(c => c.id === selectedId)

// ── Detail view (drill-down) ──
Expand Down Expand Up @@ -549,7 +584,7 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:

<Section title="Data Sources">
{connectors.length === 0 && (
<p className="text-xs text-warm-faint dark:text-dark-muted">No connectors available.</p>
<p className="text-xs text-warm-faint dark:text-dark-muted">No connectors installed yet</p>
)}
{connectors.map(c => {
const isSyncing = syncingConnector === c.id || c.syncing
Expand All @@ -558,13 +593,13 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
<button
key={c.id}
onClick={() => setSelectedId(c.id)}
className="w-full flex items-center gap-3 py-2.5 px-2 rounded-[6px] text-left hover:bg-warm-surface/50 dark:hover:bg-dark-surface/50 transition-colors"
className="w-full flex items-center gap-3 py-2.5 rounded-[6px] text-left relative before:absolute before:-inset-x-2 before:inset-y-0 before:rounded-[6px] before:transition-colors hover:before:bg-warm-surface/50 dark:hover:before:bg-dark-surface/50"
>
<span
className={`w-2 h-2 rounded-full flex-none ${isSyncing ? 'animate-pulse' : ''}`}
style={{ background: c.enabled ? c.color : '#888' }}
/>
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 leading-4">
<span className={`text-xs ${c.enabled ? 'text-warm-text dark:text-dark-text' : 'text-warm-muted dark:text-dark-muted'}`}>
{c.label}
</span>
Expand All @@ -591,6 +626,48 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
})}
</Section>

{!registryLoading && !registryError && discoverConnectors.length > 0 && (
<Section title="Available Connectors">
{discoverConnectors.map(rc => (
<div
key={rc.name}
className="flex items-center gap-3 py-2.5"
>
<span
className="w-2 h-2 rounded-full flex-none opacity-50"
style={{ background: rc.color }}
/>
<div className="flex-1 min-w-0 leading-4">
<span className="text-xs text-warm-muted dark:text-dark-muted">{rc.label}</span>
<span className="text-[11px] text-warm-faint dark:text-dark-faint ml-2">{rc.description}</span>
{installErrors[rc.name] && installingPackage !== rc.name && (
<div className="text-[10px] text-red-400 mt-0.5">{installErrors[rc.name]}</div>
)}
</div>
<button
onClick={() => handleInstall(rc.name)}
disabled={installingPackage === rc.name}
className="text-[11px] font-medium text-accent dark:text-accent-dark hover:underline disabled:opacity-50 flex-none"
>
{installingPackage === rc.name ? 'Installing\u2026' : 'Install'}
</button>
</div>
))}
</Section>
)}

{registryLoading && (
<div className="text-[11px] text-warm-faint dark:text-dark-faint px-2">
Loading connector directory\u2026
</div>
)}

{registryError && !registryLoading && (
<div className="text-[11px] text-warm-faint dark:text-dark-faint px-2">
Couldn't load connector directory
</div>
)}

{syncError && (
<div className="px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-[6px]">
<p className="text-xs text-red-500">{syncError}</p>
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/connectors/registry-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchRegistry } from './registry-fetch.js'
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'

const testCacheDir = join(tmpdir(), `registry-fetch-test-${process.pid}`)

const sampleRegistry = {
version: 1,
connectors: [
{
name: '@spool-lab/connector-twitter-bookmarks',
id: 'twitter-bookmarks',
platform: 'twitter',
label: 'X Bookmarks',
description: 'Your saved tweets on X',
color: '#1DA1F2',
author: 'spool-lab',
category: 'social',
firstParty: true,
bundled: true,
npm: 'https://www.npmjs.com/package/@spool-lab/connector-twitter-bookmarks',
},
],
}

beforeEach(() => {
rmSync(testCacheDir, { recursive: true, force: true })
mkdirSync(testCacheDir, { recursive: true })
})

describe('fetchRegistry', () => {
it('returns connectors on successful fetch', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response(JSON.stringify(sampleRegistry)))
const result = await fetchRegistry({ fetchFn, cacheDir: testCacheDir })
expect(result).toEqual(sampleRegistry.connectors)
})

it('caches result with fetchedAt timestamp', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response(JSON.stringify(sampleRegistry)))
await fetchRegistry({ fetchFn, cacheDir: testCacheDir })

const cached = JSON.parse(
require('node:fs').readFileSync(join(testCacheDir, 'registry-cache.json'), 'utf-8'),
)
expect(cached.connectors).toEqual(sampleRegistry.connectors)
expect(typeof cached.fetchedAt).toBe('number')
})

it('falls back to cache on fetch failure', async () => {
writeFileSync(
join(testCacheDir, 'registry-cache.json'),
JSON.stringify({ connectors: sampleRegistry.connectors, fetchedAt: Date.now() }),
)
const fetchFn = vi.fn().mockRejectedValue(new Error('network error'))
const result = await fetchRegistry({ fetchFn, cacheDir: testCacheDir })
expect(result).toEqual(sampleRegistry.connectors)
})

it('returns empty array when fetch fails and no cache', async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error('network error'))
const result = await fetchRegistry({ fetchFn, cacheDir: testCacheDir })
expect(result).toEqual([])
})

it('returns empty array when fetch returns non-ok response and no cache', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 }))
const result = await fetchRegistry({ fetchFn, cacheDir: testCacheDir })
expect(result).toEqual([])
})

it('falls back to cache on non-ok response', async () => {
writeFileSync(
join(testCacheDir, 'registry-cache.json'),
JSON.stringify({ connectors: sampleRegistry.connectors, fetchedAt: Date.now() }),
)
const fetchFn = vi.fn().mockResolvedValue(new Response('Server Error', { status: 500 }))
const result = await fetchRegistry({ fetchFn, cacheDir: testCacheDir })
expect(result).toEqual(sampleRegistry.connectors)
})

it('uses AbortSignal with 3s timeout', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response(JSON.stringify(sampleRegistry)))
await fetchRegistry({ fetchFn, cacheDir: testCacheDir })
const call = fetchFn.mock.calls[0]
expect(call[1]?.signal).toBeInstanceOf(AbortSignal)
})
})
55 changes: 55 additions & 0 deletions packages/core/src/connectors/registry-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'

const REGISTRY_URL =
'https://raw.githubusercontent.com/spool-lab/spool/main/packages/landing/public/registry.json'
const CACHE_FILE = 'registry-cache.json'
const TIMEOUT_MS = 3_000

export interface RegistryConnector {
name: string
id: string
platform: string
label: string
description: string
color: string
author: string
category: string
firstParty: boolean
bundled: boolean
npm: string
}

interface FetchRegistryOpts {
fetchFn?: typeof fetch
cacheDir: string
}

export async function fetchRegistry(opts: FetchRegistryOpts): Promise<RegistryConnector[]> {
const { fetchFn = globalThis.fetch, cacheDir } = opts
const cachePath = join(cacheDir, CACHE_FILE)

try {
const res = await fetchFn(REGISTRY_URL, { signal: AbortSignal.timeout(TIMEOUT_MS) })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { connectors?: RegistryConnector[] }
const connectors: RegistryConnector[] = data.connectors ?? []
try {
mkdirSync(cacheDir, { recursive: true })
writeFileSync(cachePath, JSON.stringify({ connectors, fetchedAt: Date.now() }))
} catch {}
return connectors
} catch {
return readCachedRegistry(cachePath)
}
}

function readCachedRegistry(cachePath: string): RegistryConnector[] {
try {
const raw = readFileSync(cachePath, 'utf-8')
const cached = JSON.parse(raw)
return cached.connectors ?? []
} catch {
return []
}
}
Loading