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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Connector Update Mechanism

## Problem

Installed connectors stay at whatever version they were installed at. There is no version check, no upgrade prompt, and no way for users to know an update is available.

## Solution

Check npm registry for newer versions on app launch (async, non-blocking) and expose update availability in the connector detail view. Users can one-click update from the detail page.

## Data Flow

1. After `loadConnectors` completes at startup, call `checkForUpdates` for each non-bundled connector
2. Store results in an in-memory `Map<packageName, { current: string, latest: string }>` in main process
3. Expose via IPC to renderer
4. Connector detail view shows update prompt when `latest > current`
5. User clicks Update → main process downloads, installs, reloads → renderer refreshes

## Changes by Layer

### Core (`packages/core/src/connectors/npm-install.ts`)

New function:

```typescript
checkForUpdates(
connectors: Array<{ packageName: string; currentVersion: string }>,
fetchFn: typeof fetch
): Promise<Map<string, { current: string; latest: string }>>
```

- Calls `resolveNpmPackage` for each connector in parallel
- Compares installed version (from package.json) against npm latest
- Returns only entries where `semver.gt(latest, current)`
- Swallows individual fetch failures (network down, package delisted) — no update shown is fine

### Main Process (`packages/app/src/main/index.ts`)

- After startup load completes, fire `checkForUpdates` asynchronously (do not block app startup)
- Cache results in a module-level Map
- New IPC handlers:
- `connector:check-updates` — re-run `checkForUpdates`, refresh cache, return results
- `connector:update` — given a package name: stop connector sync → `downloadAndInstall` → reload connectors → clear update cache entry → emit `connector:event { type: 'updated' }`

### Preload (`packages/app/src/preload/index.ts`)

New methods on `connectors` API:

```typescript
checkUpdates(): Promise<Record<string, { current: string; latest: string }>>
update(connectorId: string): Promise<{ ok: boolean; error?: string }>
```

### Renderer (`packages/app/src/renderer/components/SettingsPanel.tsx`)

In connector detail view:

- On mount / after manual check: call `checkUpdates()`, store state
- If update available for current connector: show "v1.2.0 → v1.3.0" label + "Update" button
- Button states: idle → updating (spinner) → success (refresh) / error (show message, button re-enabled)
- Failed update: show inline error text, old version continues working

## Update Execution Flow

1. User clicks Update
2. Main process: pause scheduler for this connector
3. `downloadAndInstall(packageName, connectorsDir, fetchFn)` — overwrites existing files
4. `loadConnectors(deps)` — rediscovers and reloads all connectors
5. Resume scheduler
6. Success: emit `{ type: 'updated', name, version }` event → renderer refreshes detail view
7. Failure: return `{ ok: false, error }` → renderer shows error, old version unaffected

## What We Don't Do

- No DB schema changes (version lives in package.json already)
- No periodic background polling (launch + manual check is sufficient)
- No auto-update (user-initiated only)
- No update check for bundled connectors (managed by app updates)
- No confirmation dialog (low-risk operation on already-trusted connector)

## Check Timing

- **On launch**: async after `loadConnectors`, non-blocking
- **Manual**: user triggers from Settings panel (re-checks all connectors)
153 changes: 106 additions & 47 deletions packages/app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
ConnectorRegistry, SyncScheduler,
loadSyncState, saveSyncState,
loadConnectors, makeFetchCapability, makeChromeCookiesCapability, makeLogCapabilityFor, makeSqliteCapability,
TrustStore, downloadAndInstall, uninstallConnector, resolveNpmPackage,
TrustStore, downloadAndInstall, uninstallConnector, resolveNpmPackage, checkForUpdates,
} from '@spool/core'
import type { UpdateInfo } from '@spool/core'
import type { AuthStatus, ConnectorStatus, FragmentResult, SchedulerEvent, SearchResult, SessionSource } from '@spool/core'
import { setupTray } from './tray.js'
import { AcpManager } from './acp.js'
Expand Down Expand Up @@ -65,6 +66,7 @@ let isSyncActive = false
let proxyFetch: typeof globalThis.fetch
let spoolDir: string
const bundledConnectorIds = new Set<string>()
let updateCache = new Map<string, UpdateInfo>()

type CachedSearchValue = SearchResult[] | FragmentResult[]

Expand Down Expand Up @@ -260,26 +262,7 @@ async function handleSpoolUrl(url: string): Promise<void> {
trustStore.add(parsed.packageName)
}

// Reload connectors into registry
await loadConnectors({
bundledConnectorsDir: !app.isPackaged
? join(process.cwd(), 'dist/bundled-connectors')
: join(process.resourcesPath, 'bundled-connectors'),
connectorsDir,
capabilityImpls: {
fetch: makeFetchCapability(proxyFetch),
cookies: makeChromeCookiesCapability(),
sqlite: makeSqliteCapability(),
logFor: (id: string) => makeLogCapabilityFor(id),
},
registry: connectorRegistry,
log: {
info: (msg, fields) => console.log(`[loader] ${msg}`, fields ?? ''),
warn: (msg, fields) => console.warn(`[loader] ${msg}`, fields ?? ''),
error: (msg, fields) => console.error(`[loader] ${msg}`, fields ?? ''),
},
trustStore: trustStore!,
})
await reloadConnectors()

mainWindow?.setProgressBar(-1) // clear progress
mainWindow?.webContents.send('connector:event', {
Expand Down Expand Up @@ -421,6 +404,11 @@ app.whenReady().then(async () => {
syncScheduler?.onWake()
})

// Check for connector updates (async, non-blocking)
runConnectorUpdateCheck().catch((err) => {
console.error('[connector-updates] check failed:', err)
})

// Initial sync in worker thread (non-blocking)
runSyncWorker().then(() => {
watcher.start()
Expand Down Expand Up @@ -470,6 +458,63 @@ app.on('before-quit', () => {
syncScheduler?.stop()
})

// ── Connector helpers ─────────────────────────────────────────────────────────

function getInstalledConnectorPackages(): Array<{ packageName: string; currentVersion: string; connectorId: string }> {
const connectorsDir = join(spoolDir, 'connectors')
const nodeModules = join(connectorsDir, 'node_modules')
if (!existsSync(nodeModules)) return []

const results: Array<{ packageName: string; currentVersion: string; connectorId: string }> = []
for (const entry of readdirSync(nodeModules)) {
if (entry.startsWith('.')) continue
const dirs = entry.startsWith('@')
? readdirSync(join(nodeModules, entry)).map(s => join(entry, s))
: [entry]
for (const dir of dirs) {
const pkgPath = join(nodeModules, dir, 'package.json')
if (!existsSync(pkgPath)) continue
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (pkg.spool?.type === 'connector' && pkg.spool?.id && !bundledConnectorIds.has(pkg.spool.id)) {
results.push({ packageName: pkg.name, currentVersion: pkg.version ?? '0.0.0', connectorId: pkg.spool.id })
}
} catch {}
}
}
return results
}

async function reloadConnectors(): Promise<void> {
const connectorsDir = join(spoolDir, 'connectors')
await loadConnectors({
bundledConnectorsDir: !app.isPackaged
? join(process.cwd(), 'dist/bundled-connectors')
: join(process.resourcesPath, 'bundled-connectors'),
connectorsDir,
capabilityImpls: {
fetch: makeFetchCapability(proxyFetch),
cookies: makeChromeCookiesCapability(),
sqlite: makeSqliteCapability(),
logFor: (id: string) => makeLogCapabilityFor(id),
},
registry: connectorRegistry,
log: {
info: (msg, fields) => console.log(`[loader] ${msg}`, fields ?? ''),
warn: (msg, fields) => console.warn(`[loader] ${msg}`, fields ?? ''),
error: (msg, fields) => console.error(`[loader] ${msg}`, fields ?? ''),
},
trustStore: trustStore!,
})
}

async function runConnectorUpdateCheck(): Promise<{ updates: Map<string, UpdateInfo>; installed: Array<{ packageName: string; currentVersion: string; connectorId: string }> }> {
const installed = getInstalledConnectorPackages()
if (installed.length === 0) return { updates: new Map(), installed }
updateCache = await checkForUpdates(installed, fetch)
return { updates: updateCache, installed }
}

// ── IPC Handlers ──────────────────────────────────────────────────────────────

ipcMain.handle('spool:search', (_e, { query, limit = 10, source }: { query: string; limit?: number; source?: string }) => {
Expand Down Expand Up @@ -633,9 +678,12 @@ ipcMain.handle('spool:install-update', () => {
// ── Connector Handlers ──────────────────────────────────────────────────

ipcMain.handle('connector:list', (): ConnectorStatus[] => {
const installed = getInstalledConnectorPackages()
const versionMap = new Map(installed.map(p => [p.connectorId, p.currentVersion]))
return syncScheduler.getStatus().connectors.map(c => ({
...c,
bundled: bundledConnectorIds.has(c.id),
version: versionMap.get(c.id) ?? '0.0.0',
}))
})

Expand Down Expand Up @@ -665,34 +713,11 @@ ipcMain.handle('connector:set-enabled', (_e, { id, enabled }: { id: string; enab
ipcMain.handle('connector:uninstall', (_e, { id }: { id: string }) => {
const connectorsDir = join(spoolDir, 'connectors')

// Find the package name by scanning installed packages
const nodeModules = join(connectorsDir, 'node_modules')
let packageName: string | null = null

if (existsSync(nodeModules)) {
for (const entry of readdirSync(nodeModules)) {
if (entry.startsWith('.')) continue
const dirs = entry.startsWith('@')
? readdirSync(join(nodeModules, entry)).map(s => join(entry, s))
: [entry]
for (const dir of dirs) {
const pkgPath = join(nodeModules, dir, 'package.json')
if (!existsSync(pkgPath)) continue
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (pkg.spool?.id === id) {
packageName = pkg.name
break
}
} catch {}
}
if (packageName) break
}
}

if (!packageName) {
const pkg = getInstalledConnectorPackages().find(p => p.connectorId === id)
if (!pkg) {
return { ok: false, error: `No installed package found for connector "${id}"` }
}
const packageName = pkg.packageName

// Get platform before removing from registry
if (!connectorRegistry.has(id)) {
Expand Down Expand Up @@ -720,6 +745,40 @@ ipcMain.handle('connector:uninstall', (_e, { id }: { id: string }) => {
return { ok: true }
})

ipcMain.handle('connector:check-updates', async () => {
const { updates, installed } = await runConnectorUpdateCheck()
const byConnectorId: Record<string, { current: string; latest: string }> = {}
for (const pkg of installed) {
const update = updates.get(pkg.packageName)
if (update) byConnectorId[pkg.connectorId] = update
}
return byConnectorId
})

ipcMain.handle('connector:update', async (_e, { id }: { id: string }) => {
const installed = getInstalledConnectorPackages()
const pkg = installed.find(p => p.connectorId === id)
if (!pkg) return { ok: false, error: `No installed package found for connector "${id}"` }

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

await reloadConnectors()
updateCache.delete(pkg.packageName)

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

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

ipcMain.handle('connector:get-capture-count', (_e, { connectorId }: { connectorId: string }) => {
const connector = connectorRegistry.get(connectorId)
const row = db.prepare(
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ const api = {
uninstall: (id: string): Promise<{ ok: boolean }> =>
ipcRenderer.invoke('connector:uninstall', { id }),

checkUpdates: (): Promise<Record<string, { current: string; latest: string }>> =>
ipcRenderer.invoke('connector:check-updates'),

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

onEvent: (cb: (event: { type: string; connectorId?: string; progress?: unknown; result?: unknown; code?: string; message?: string; name?: string; version?: string }) => void) => {
const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as any)
ipcRenderer.on('connector:event', handler)
Expand Down
Loading