diff --git a/packages/app/src/main/index.ts b/packages/app/src/main/index.ts index e9c9b74..7245f1b 100644 --- a/packages/app/src/main/index.ts +++ b/packages/app/src/main/index.ts @@ -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' @@ -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 @@ -251,31 +278,17 @@ async function handleSpoolUrl(url: string): Promise { 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, }) } } @@ -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) +}) diff --git a/packages/app/src/preload/index.ts b/packages/app/src/preload/index.ts index 639012a..069b9b9 100644 --- a/packages/app/src/preload/index.ts +++ b/packages/app/src/preload/index.ts @@ -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' @@ -164,6 +164,12 @@ const api = { ipcRenderer.on('connector:event', handler) return () => ipcRenderer.removeListener('connector:event', handler) }, + + fetchRegistry: (): Promise => + ipcRenderer.invoke('connector:fetch-registry'), + + install: (packageName: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('connector:install', { packageName }), }, // Auto-update diff --git a/packages/app/src/renderer/components/SettingsPanel.tsx b/packages/app/src/renderer/components/SettingsPanel.tsx index 8b47d24..2090c4f 100644 --- a/packages/app/src/renderer/components/SettingsPanel.tsx +++ b/packages/app/src/renderer/components/SettingsPanel.tsx @@ -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' @@ -321,6 +321,11 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount: const [availableUpdates, setAvailableUpdates] = useState>({}) const [updatingConnector, setUpdatingConnector] = useState(null) const [updateErrors, setUpdateErrors] = useState>({}) + const [registryConnectors, setRegistryConnectors] = useState([]) + const [registryLoading, setRegistryLoading] = useState(true) + const [registryError, setRegistryError] = useState(false) + const [installingPackage, setInstallingPackage] = useState(null) + const [installErrors, setInstallErrors] = useState>({}) const loadConnectors = useCallback(async () => { if (!window.spool?.connectors) return @@ -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) @@ -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) ── @@ -549,7 +584,7 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
{connectors.length === 0 && ( -

No connectors available.

+

No connectors installed yet

)} {connectors.map(c => { const isSyncing = syncingConnector === c.id || c.syncing @@ -558,13 +593,13 @@ function ConnectorsTab({ claudeCount, codexCount, geminiCount }: { claudeCount:
+ {!registryLoading && !registryError && discoverConnectors.length > 0 && ( +
+ {discoverConnectors.map(rc => ( +
+ +
+ {rc.label} + {rc.description} + {installErrors[rc.name] && installingPackage !== rc.name && ( +
{installErrors[rc.name]}
+ )} +
+ +
+ ))} +
+ )} + + {registryLoading && ( +
+ Loading connector directory\u2026 +
+ )} + + {registryError && !registryLoading && ( +
+ Couldn't load connector directory +
+ )} + {syncError && (

{syncError}

diff --git a/packages/core/src/connectors/registry-fetch.test.ts b/packages/core/src/connectors/registry-fetch.test.ts new file mode 100644 index 0000000..610b703 --- /dev/null +++ b/packages/core/src/connectors/registry-fetch.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/connectors/registry-fetch.ts b/packages/core/src/connectors/registry-fetch.ts new file mode 100644 index 0000000..6fc3294 --- /dev/null +++ b/packages/core/src/connectors/registry-fetch.ts @@ -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 { + 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 [] + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e90005b..c454c30 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,8 @@ export type { export { downloadAndInstall, uninstallConnector, resolveNpmPackage, registryUrl, checkForUpdates } from './connectors/npm-install.js' export type { UpdateInfo } from './connectors/npm-install.js' +export { fetchRegistry } from './connectors/registry-fetch.js' +export type { RegistryConnector } from './connectors/registry-fetch.js' // ── Plugin loader ────────────────────────────────────────────────────────── export { loadConnectors } from './connectors/loader.js'