diff --git a/packages/app/e2e/helpers/seed.ts b/packages/app/e2e/helpers/seed.ts deleted file mode 100644 index 9c6fb6e..0000000 --- a/packages/app/e2e/helpers/seed.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { ElectronApplication } from '@playwright/test' - -export interface SeedCapture { - platform: string - platformId: string - title: string - url: string - content?: string - connectorId: string - author?: string -} - -/** - * Insert a capture + its M:N attribution into the app's DB by delegating - * to a test-only hook installed on `globalThis` in the main process. The - * hook is registered in `main/index.ts` when `SPOOL_E2E_TEST=1` is set, - * which the launch helper always does. This avoids: - * - * - Loading `better-sqlite3` in the test process (the app rebuilds it for - * the electron ABI, which can't be `require`d from a plain Node process). - * - Shelling out to the `sqlite3` CLI, whose FTS5 support is missing on - * macOS GitHub runners (the captures_fts triggers would fail). - */ -export async function seedCapture( - app: ElectronApplication, - capture: SeedCapture, -): Promise { - const captureUuid = randomUUID() - await app.evaluate(({}, args) => { - const g = globalThis as unknown as { - __spoolSeedCapture?: (args: unknown) => void - } - if (!g.__spoolSeedCapture) { - throw new Error('SPOOL_E2E_TEST hook not installed; did launchApp set the env var?') - } - g.__spoolSeedCapture(args) - }, { ...capture, captureUuid }) -} diff --git a/packages/app/src/main/acp.ts b/packages/app/src/main/acp.ts index 4ca6ddf..2d66d87 100644 --- a/packages/app/src/main/acp.ts +++ b/packages/app/src/main/acp.ts @@ -870,24 +870,17 @@ export class AcpManager { */ private buildPrompt(userQuery: string): string { return [ - 'You have access to a local knowledge base called Spool that indexes:', - ' 1. The user\'s AI coding sessions (Claude Code, Codex CLI, Gemini CLI)', - ' 2. Platform data synced via connectors (X/Twitter bookmarks, GitHub stars, etc.)', + 'You have access to a local knowledge base called Spool that indexes the user\'s AI coding sessions (Claude Code, Codex CLI, Gemini CLI).', '', 'The database is at ~/.spool/spool.db (SQLite with FTS5). You can query it directly with the `sqlite3` CLI.', '', - '── Agent session schema ──', + '── Schema ──', ' sources(id, name TEXT, base_path TEXT) -- "claude", "codex", or "gemini"', ' projects(id, source_id, slug, display_path, display_name, last_synced)', ' sessions(id, project_id, source_id, session_uuid TEXT, title TEXT, started_at TEXT, ended_at TEXT, message_count INT, has_tool_use INT)', ' messages(id, session_id, source_id, role TEXT, content_text TEXT, timestamp TEXT, tool_names TEXT)', ' messages_fts(content_text) -- FTS5 virtual table, content synced from messages', '', - '── Connector captures schema ──', - ' connector_sync_state(connector_id TEXT PRIMARY KEY, head_cursor, tail_cursor, tail_complete INT, last_forward_sync_at, total_synced INT, enabled INT, config_json TEXT)', - ' captures(id, source_id, capture_uuid TEXT, url TEXT, title TEXT, content_text TEXT, author TEXT, platform TEXT, platform_id TEXT, content_type TEXT, metadata TEXT, captured_at TEXT, raw_json TEXT)', - ' captures_fts(title, content_text) -- FTS5 virtual table, content synced from captures', - '', 'Example queries:', ' # FTS search on agent sessions', ' sqlite3 ~/.spool/spool.db "SELECT m.content_text, s.title, s.started_at, p.display_name FROM messages_fts f JOIN messages m ON m.id = f.rowid JOIN sessions s ON s.id = m.session_id JOIN projects p ON p.id = s.project_id WHERE messages_fts MATCH \'search terms\' ORDER BY rank LIMIT 10"', @@ -895,25 +888,11 @@ export class AcpManager { ' # Recent sessions', ' sqlite3 ~/.spool/spool.db "SELECT session_uuid, title, started_at, message_count FROM sessions ORDER BY started_at DESC LIMIT 20"', '', - ' # FTS search on captures (bookmarks, saved web content)', - ' sqlite3 ~/.spool/spool.db "SELECT c.title, c.author, c.url, c.content_text, c.platform, c.captured_at FROM captures_fts f JOIN captures c ON c.id = f.rowid WHERE captures_fts MATCH \'search terms\' ORDER BY rank LIMIT 10"', - '', - ' # List captures for a specific connector', - ' sqlite3 ~/.spool/spool.db "SELECT c.title, c.author, c.url, c.content_text, c.captured_at, c.platform FROM captures c JOIN capture_connectors cc ON cc.capture_id = c.id WHERE cc.connector_id = \'twitter-bookmarks\' ORDER BY c.captured_at DESC LIMIT 20"', - '', - ' # What connectors are enabled', - ' sqlite3 ~/.spool/spool.db "SELECT connector_id, total_synced, last_forward_sync_at FROM connector_sync_state WHERE enabled = 1"', - '', 'Important:', '- Interpret the user\'s intent and decide what to search. Don\'t just match their exact words.', - '- For questions about bookmarks, saved content, or web platforms → query captures/captures_fts.', - '- For questions about coding sessions, projects, or what the user built → query messages/sessions.', - '- Treat source boundaries as part of the query semantics, not as an implementation detail.', - '- For connector captures, JOIN `capture_connectors cc ON cc.capture_id = c.id` and filter by `cc.connector_id` to distinguish different connectors for the same platform. One capture can belong to multiple connectors.', - '- If the user names a source, only return results from that source unless they explicitly ask for cross-source search.', + '- If the user names a specific source (claude/codex/gemini), only return results from that source unless they explicitly ask for cross-source search.', '- For cross-source questions, first identify the relevant sources, then query each source separately, confirm hits or no-hits per source, and only then merge them into one answer.', '- For temporal queries ("what did I do recently"), use explicit date filters and be conservative when comparing times across different sources.', - '- Different sources may store `captured_at` differently. For cross-source time questions, verify each source separately before merging results. Do not assume one source\'s time semantics apply to another.', '- You may run multiple queries to find relevant information.', '- Synthesize a concise answer. Reference specific items, URLs, or sessions when relevant.', '- Keep the result layer separate from the explanation layer: answer the user\'s question directly first, then add brief notes about what you checked if helpful.', diff --git a/packages/app/src/main/dev-connectors.test.ts b/packages/app/src/main/dev-connectors.test.ts deleted file mode 100644 index 3e1acd6..0000000 --- a/packages/app/src/main/dev-connectors.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it, afterEach } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync, readlinkSync, lstatSync, rmSync, symlinkSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { ensureSymlink, linkDevConnectors, pruneBrokenConnectorLinks } from './dev-connectors.js' - -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), 'dev-connectors-test-')) -} - -const tempDirs: string[] = [] -afterEach(() => { - for (const d of tempDirs) rmSync(d, { recursive: true, force: true }) - tempDirs.length = 0 -}) - -function tmp(): string { - const d = makeTempDir() - tempDirs.push(d) - return d -} - -// ── ensureSymlink ──────────────────────────────────────────────────────────── - -describe('ensureSymlink', () => { - it('creates a symlink', () => { - const dir = tmp() - const target = join(dir, 'target') - mkdirSync(target) - const link = join(dir, 'link') - - ensureSymlink(target, link) - - expect(lstatSync(link).isSymbolicLink()).toBe(true) - expect(readlinkSync(link)).toBe(target) - }) - - it('is idempotent when target matches', () => { - const dir = tmp() - const target = join(dir, 'target') - mkdirSync(target) - const link = join(dir, 'link') - - ensureSymlink(target, link) - ensureSymlink(target, link) - - expect(readlinkSync(link)).toBe(target) - }) - - it('replaces symlink when target differs', () => { - const dir = tmp() - const oldTarget = join(dir, 'old') - const newTarget = join(dir, 'new') - mkdirSync(oldTarget) - mkdirSync(newTarget) - const link = join(dir, 'link') - - symlinkSync(oldTarget, link) - ensureSymlink(newTarget, link) - - expect(readlinkSync(link)).toBe(newTarget) - }) - - it('replaces regular directory with symlink', () => { - const dir = tmp() - const target = join(dir, 'target') - mkdirSync(target) - const link = join(dir, 'link') - mkdirSync(link) - - ensureSymlink(target, link) - - expect(lstatSync(link).isSymbolicLink()).toBe(true) - expect(readlinkSync(link)).toBe(target) - }) -}) - -// ── linkDevConnectors ──────────────────────────────────────────────────────── - -describe('linkDevConnectors', () => { - function setupWorkspace(dir: string, connectors: Array<{ name: string; id: string }>) { - // packages/connector-sdk - mkdirSync(join(dir, 'packages', 'connector-sdk'), { recursive: true }) - writeFileSync(join(dir, 'packages', 'connector-sdk', 'package.json'), '{"name":"@spool-lab/connector-sdk"}') - - // packages/connectors/ - for (const c of connectors) { - const shortName = c.name.replace('@spool-lab/connector-', '') - const connDir = join(dir, 'packages', 'connectors', shortName) - mkdirSync(connDir, { recursive: true }) - writeFileSync(join(connDir, 'package.json'), JSON.stringify({ - name: c.name, - spool: { type: 'connector', id: c.id }, - })) - } - } - - it('links all workspace connectors', () => { - const workspace = tmp() - const spoolDir = tmp() - - setupWorkspace(workspace, [ - { name: '@spool-lab/connector-twitter-bookmarks', id: 'twitter-bookmarks' }, - { name: '@spool-lab/connector-hackernews-hot', id: 'hackernews-hot' }, - { name: '@spool-lab/connector-typeless', id: 'typeless' }, - ]) - - linkDevConnectors(spoolDir, workspace) - - const nm = join(spoolDir, 'connectors', 'node_modules', '@spool-lab') - expect(lstatSync(join(nm, 'connector-twitter-bookmarks')).isSymbolicLink()).toBe(true) - expect(lstatSync(join(nm, 'connector-hackernews-hot')).isSymbolicLink()).toBe(true) - expect(lstatSync(join(nm, 'connector-typeless')).isSymbolicLink()).toBe(true) - }) - - it('symlinks connector-sdk for peer dep resolution', () => { - const workspace = tmp() - const spoolDir = tmp() - - setupWorkspace(workspace, [ - { name: '@spool-lab/connector-twitter-bookmarks', id: 'twitter-bookmarks' }, - ]) - - linkDevConnectors(spoolDir, workspace) - - const sdkLink = join(spoolDir, 'connectors', 'node_modules', '@spool-lab', 'connector-sdk') - expect(lstatSync(sdkLink).isSymbolicLink()).toBe(true) - expect(readlinkSync(sdkLink)).toBe(join(workspace, 'packages', 'connector-sdk')) - }) - - it('no-ops when workspace has no connectors dir', () => { - const workspace = tmp() - const spoolDir = tmp() - - linkDevConnectors(spoolDir, workspace) - - expect(() => lstatSync(join(spoolDir, 'connectors'))).toThrow() - }) -}) - -// ── pruneBrokenConnectorLinks ──────────────────────────────────────────────── - -describe('pruneBrokenConnectorLinks', () => { - function connectorsNm(spoolDir: string): string { - const p = join(spoolDir, 'connectors', 'node_modules') - mkdirSync(p, { recursive: true }) - return p - } - - it('removes broken symlinks in scoped dirs', () => { - const spoolDir = tmp() - const nm = connectorsNm(spoolDir) - mkdirSync(join(nm, '@spool-lab')) - - const brokenLink = join(nm, '@spool-lab', 'connector-x') - symlinkSync('/nonexistent/path/does-not-exist', brokenLink) - - pruneBrokenConnectorLinks(spoolDir) - - expect(() => lstatSync(brokenLink)).toThrow() - }) - - it('preserves valid symlinks', () => { - const spoolDir = tmp() - const target = join(tmp(), 'real-target') - mkdirSync(target) - const nm = connectorsNm(spoolDir) - mkdirSync(join(nm, '@spool-lab')) - - const goodLink = join(nm, '@spool-lab', 'connector-x') - symlinkSync(target, goodLink) - - pruneBrokenConnectorLinks(spoolDir) - - expect(lstatSync(goodLink).isSymbolicLink()).toBe(true) - expect(readlinkSync(goodLink)).toBe(target) - }) - - it('preserves regular (npm-installed) directories', () => { - const spoolDir = tmp() - const nm = connectorsNm(spoolDir) - const installedDir = join(nm, '@graydawnc', 'connector-y') - mkdirSync(installedDir, { recursive: true }) - writeFileSync(join(installedDir, 'package.json'), '{"name":"@graydawnc/connector-y"}') - - pruneBrokenConnectorLinks(spoolDir) - - expect(lstatSync(installedDir).isDirectory()).toBe(true) - }) - - it('handles broken unscoped symlinks at top level', () => { - const spoolDir = tmp() - const nm = connectorsNm(spoolDir) - - const brokenLink = join(nm, 'connector-z') - symlinkSync('/nonexistent/target', brokenLink) - - pruneBrokenConnectorLinks(spoolDir) - - expect(() => lstatSync(brokenLink)).toThrow() - }) - - it('no-ops when node_modules does not exist', () => { - const spoolDir = tmp() - expect(() => pruneBrokenConnectorLinks(spoolDir)).not.toThrow() - }) -}) diff --git a/packages/app/src/main/dev-connectors.ts b/packages/app/src/main/dev-connectors.ts deleted file mode 100644 index 79dca9a..0000000 --- a/packages/app/src/main/dev-connectors.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, rmSync, symlinkSync } from 'node:fs' -import { join } from 'node:path' - -export function ensureSymlink(target: string, linkPath: string): void { - try { - const stat = lstatSync(linkPath) - if (stat.isSymbolicLink() && readlinkSync(linkPath) === target) return - rmSync(linkPath, { recursive: true, force: true }) - } catch { - // Doesn't exist, proceed - } - symlinkSync(target, linkPath) -} - -function removeIfBrokenSymlink(p: string): boolean { - let stat - try { stat = lstatSync(p) } catch { return false } - if (!stat.isSymbolicLink()) return false - if (existsSync(p)) return false - rmSync(p, { force: true }) - return true -} - -/** - * Remove broken symlinks under ~/.spool/connectors/node_modules. - * - * Dev symlinks (from linkDevConnectors) point into a workspace checkout; - * deleting the worktree or switching to a branch that no longer carries a - * connector leaves the symlink dangling. A dangling link later breaks npm - * installs (mkdirSync follows the link and ENOENTs on the missing target). - */ -export function pruneBrokenConnectorLinks(spoolDir: string): void { - const nodeModules = join(spoolDir, 'connectors', 'node_modules') - if (!existsSync(nodeModules)) return - - for (const entry of readdirSync(nodeModules)) { - const entryPath = join(nodeModules, entry) - if (entry.startsWith('@')) { - let children: string[] - try { children = readdirSync(entryPath) } catch { continue } - for (const child of children) { - const p = join(entryPath, child) - if (removeIfBrokenSymlink(p)) console.log(`[connectors] pruned broken symlink ${p}`) - } - } else { - if (removeIfBrokenSymlink(entryPath)) console.log(`[connectors] pruned broken symlink ${entryPath}`) - } - } -} - -/** - * Try to install a connector package from the workspace by symlinking. - * Returns the resolved name+version on success, or null if the package - * isn't in the workspace (caller should fall through to npm install). - */ -export function installFromWorkspace( - packageName: string, - spoolDir: string, - workspaceRoot: string, -): { name: string; version: string } | null { - const connectorsParent = join(workspaceRoot, 'packages', 'connectors') - if (!existsSync(connectorsParent)) return null - - for (const entry of readdirSync(connectorsParent)) { - const pkgDir = join(connectorsParent, entry) - const pkgJsonPath = join(pkgDir, 'package.json') - if (!existsSync(pkgJsonPath)) continue - let pkg: { name?: string; version?: string; spool?: { type?: string } } - try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) } catch { continue } - if (pkg.name !== packageName || pkg.spool?.type !== 'connector') continue - - const nodeModules = join(spoolDir, 'connectors', 'node_modules') - const segments = packageName.startsWith('@') ? packageName.split('/') : [packageName] - mkdirSync(join(nodeModules, ...segments.slice(0, -1)), { recursive: true }) - ensureSymlink(pkgDir, join(nodeModules, ...segments)) - console.log(`[dev] symlinked workspace connector ${packageName}`) - return { name: packageName, version: pkg.version ?? '0.0.0' } - } - return null -} - -export function linkDevConnectors(spoolDir: string, workspaceRoot: string): void { - const connectorsParent = join(workspaceRoot, 'packages', 'connectors') - if (!existsSync(connectorsParent)) return - - const nodeModules = join(spoolDir, 'connectors', 'node_modules') - - const sdkSource = join(workspaceRoot, 'packages', 'connector-sdk') - const sdkScopeDir = join(nodeModules, '@spool-lab') - mkdirSync(sdkScopeDir, { recursive: true }) - ensureSymlink(sdkSource, join(sdkScopeDir, 'connector-sdk')) - - for (const entry of readdirSync(connectorsParent)) { - const pkgDir = join(connectorsParent, entry) - const pkgJsonPath = join(pkgDir, 'package.json') - if (!existsSync(pkgJsonPath)) continue - - let pkg: any - try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) } catch { continue } - if (pkg?.spool?.type !== 'connector') continue - - const name: string = pkg.name - const segments = name.startsWith('@') ? name.split('/') : [name] - const linkPath = join(nodeModules, ...segments) - mkdirSync(join(nodeModules, ...segments.slice(0, -1)), { recursive: true }) - ensureSymlink(pkgDir, linkPath) - console.log(`[dev] linked workspace connector ${name}`) - } -} diff --git a/packages/app/src/main/index.ts b/packages/app/src/main/index.ts index 34507a9..45c68f7 100644 --- a/packages/app/src/main/index.ts +++ b/packages/app/src/main/index.ts @@ -1,28 +1,16 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, Notification, nativeTheme, nativeImage, net, powerMonitor, shell } from 'electron' -import { join, resolve } from 'node:path' -import { homedir } from 'node:os' -import { readdirSync, readFileSync, existsSync } from 'node:fs' -import { spawn, type ChildProcess } from 'node:child_process' +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, nativeImage } from 'electron' +import { join } from 'node:path' import { Worker } from 'node:worker_threads' import { getDB, Syncer, SpoolWatcher, - searchFragments, searchAll, searchSessionPreview, searchCaptures, listRecentSessions, getSessionWithMessages, getStatus, + searchFragments, searchSessionPreview, listRecentSessions, getSessionWithMessages, getStatus, starItem, unstarItem, listStarredItems, getStarredUuidsByType, - ConnectorRegistry, SyncScheduler, - loadSyncState, saveSyncState, - loadConnectors, makeFetchCapability, makeChromeCookiesCapability, makeLogCapabilityFor, makeSqliteCapability, makeExecCapability, - TrustStore, downloadAndInstall, uninstallConnector, resolveNpmPackage, checkForUpdates, - deleteConnectorItems, - fetchRegistry, - PrerequisiteChecker, } from '@spool-lab/core' -import type { UpdateInfo } from '@spool-lab/core' -import type { AuthStatus, ConnectorStatus, FragmentResult, SchedulerEvent, SearchResult, SessionSource, StarKind } from '@spool-lab/core' +import type { FragmentResult, SessionSource, StarKind } from '@spool-lab/core' import { setupTray } from './tray.js' import { AcpManager } from './acp.js' import { setupAutoUpdater, downloadUpdate, quitAndInstall } from './updater.js' import { openTerminal } from './terminal.js' -import { linkDevConnectors, installFromWorkspace, pruneBrokenConnectorLinks } from './dev-connectors.js' import { getSessionResumeCommand } from '../shared/resumeCommand.js' import { resolveResumeWorkingDirectory } from './sessionResume.js' import { loadUIPreferences, saveThemeEditor, saveThemeSource } from './uiPreferences.js' @@ -54,10 +42,8 @@ if (!gotSingleInstanceLock) { app.quit() } -app.on('second-instance', (_event, argv) => { +app.on('second-instance', () => { focusExistingWindow() - const url = argv.find(arg => arg.startsWith('spool://')) - if (url) handleSpoolUrl(url) }) let mainWindow: BrowserWindow | null = null @@ -65,35 +51,9 @@ let db: Database.Database let syncer: Syncer let watcher: SpoolWatcher let acpManager: AcpManager -let connectorRegistry: ConnectorRegistry -let syncScheduler: SyncScheduler -let trustStore: TrustStore | null = null let isSyncActive = false -let proxyFetch: typeof globalThis.fetch -let spoolDir: string -let updateCache = new Map() -let prerequisiteChecker: PrerequisiteChecker -let execCapabilityImpl: ReturnType -const runningInstalls = new Map() - -function makePrerequisitesFor(registry: ConnectorRegistry, checker: PrerequisiteChecker) { - return (packageId: string) => ({ - check: () => { - const pkg = registry.getPackage(packageId) - if (!pkg) throw new Error(`Package "${packageId}" not found in registry`) - return checker.check(pkg) - }, - }) -} -function killChildWithEscalation(child: ChildProcess): void { - child.kill('SIGTERM') - setTimeout(() => { - if (!child.killed) child.kill('SIGKILL') - }, 5000) -} - -type CachedSearchValue = SearchResult[] | FragmentResult[] +type CachedSearchValue = FragmentResult[] class SearchCache { private entries = new Map() @@ -192,169 +152,6 @@ function runSyncWorker(): Promise<{ added: number; updated: number; errors: numb return activeSyncPromise } -const VALID_NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/ - -// Serialize async connector operations (install/update) via a promise chain. -// Prevents races where reloadConnectors() from an install could re-register -// a connector that a concurrent uninstall just removed. -let connectorOpQueue: Promise = Promise.resolve() -function withConnectorLock(fn: () => Promise): Promise { - const next = connectorOpQueue.then(fn, fn) - // Swallow rejections on the queue so a failed op doesn't block subsequent ones - connectorOpQueue = next.then(() => {}, () => {}) - return next -} - -function installConnectorPackage( - packageName: string, -): Promise<{ ok: true; name: string; version: string } | { ok: false; error: string }> { - return withConnectorLock(async () => { - try { - const connectorsDir = join(spoolDir, 'connectors') - // Dev-mode: if the package lives in this workspace, symlink it instead - // of hitting npm. Lets us test connectors before they're published. - const workspaceResult = !app.isPackaged - ? installFromWorkspace(packageName, spoolDir, resolve(process.cwd(), '..', '..')) - : null - const result = workspaceResult ?? await downloadAndInstall(packageName, connectorsDir, fetch) - - const isFirstParty = packageName.startsWith('@spool-lab/') - if (!isFirstParty && trustStore) { - trustStore.add(packageName) - } - - // Clear stale sync state from a prior install (prevents inheriting - // old cursors/enabled flags if uninstall's DB cleanup failed) - const pkgJsonPath = join(connectorsDir, 'node_modules', ...result.name.split('/'), 'package.json') - try { - const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) - const ids: string[] = Array.isArray(pkgJson.spool?.connectors) - ? pkgJson.spool.connectors.map((c: any) => c.id).filter(Boolean) - : pkgJson.spool?.id ? [pkgJson.spool.id] : [] - for (const cid of ids) { - db.prepare('DELETE FROM connector_sync_state WHERE connector_id = ?').run(cid) - } - } catch (err) { - console.warn('[install] failed to clear stale sync state:', err) - } - - 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 - const packageName = decodeURIComponent(match[1]!) - if (!VALID_NPM_NAME.test(packageName)) return null - return { action: 'install', packageName } -} - -async function handleSpoolUrl(url: string): Promise { - const parsed = parseSpoolUrl(url) - if (!parsed) return - - const isFirstParty = parsed.packageName.startsWith('@spool-lab/') - - // Fetch metadata from npm first — get human-readable label + latest version - let info: Awaited> - try { - info = await resolveNpmPackage(parsed.packageName, fetch) - } catch (err) { - dialog.showMessageBox(mainWindow!, { - type: 'error', - message: 'Connector not found', - detail: `Could not find "${parsed.packageName}" on npm.`, - }) - return - } - - if (!info.isConnector) { - dialog.showMessageBox(mainWindow!, { - type: 'error', - message: 'Not a connector', - detail: `"${parsed.packageName}" is not a Spool connector.`, - }) - return - } - - const displayName = info.label ?? parsed.packageName - - // Check installed version - const { existsSync, readFileSync } = await import('node:fs') - const nameSegments = parsed.packageName.startsWith('@') ? parsed.packageName.split('/') : [parsed.packageName] - const installedPkgPath = join(spoolDir, 'connectors', 'node_modules', ...nameSegments, 'package.json') - let installedVersion: string | null = null - if (existsSync(installedPkgPath)) { - try { - const pkg = JSON.parse(readFileSync(installedPkgPath, 'utf8')) - installedVersion = typeof pkg.version === 'string' ? pkg.version : null - } catch {} - } - - // Build dialog content - let message: string - let detail: string - let actionLabel: string - - if (installedVersion && installedVersion === info.version) { - message = `${displayName} is already up to date` - detail = `Version ${installedVersion} is installed. Reinstall anyway?` - actionLabel = 'Reinstall' - } else if (installedVersion) { - message = `Update ${displayName}?` - detail = `${installedVersion} → ${info.version}` - actionLabel = 'Update' - } else { - message = `Install ${displayName}?` - detail = isFirstParty - ? `Official Spool connector · v${info.version}` - : `Community connector · v${info.version}\nThis will run third-party code on your machine.` - actionLabel = 'Install' - } - - const { response } = await dialog.showMessageBox(mainWindow!, { - type: !isFirstParty && !installedVersion ? 'warning' : 'question', - buttons: [actionLabel, 'Cancel'], - defaultId: 1, - title: `${actionLabel} Connector`, - message, - detail, - }) - - if (response !== 0) return - - mainWindow?.setProgressBar(0.5) - - const installResult = await installConnectorPackage(parsed.packageName) - - mainWindow?.setProgressBar(-1) - - if (!installResult.ok) { - dialog.showMessageBox(mainWindow!, { - type: 'error', - message: `Failed to install ${displayName}`, - detail: installResult.error, - }) - } -} - -app.on('open-url', (event, url) => { - event.preventDefault() - handleSpoolUrl(url) -}) - app.whenReady().then(async () => { // Set dock icon (dev mode doesn't pick up build config) const dockIconPath = join(__dirname, '../../resources/icon.icns') @@ -381,7 +178,6 @@ app.whenReady().then(async () => { Menu.setApplicationMenu(appMenu) db = getDB() - installE2ETestHooks(db) acpManager = new AcpManager() syncer = new Syncer(db) watcher = new SpoolWatcher(syncer) @@ -393,87 +189,6 @@ app.whenReady().then(async () => { console.error('[watcher]', data.error, data.root ? `(root=${data.root})` : '') }) - // ── Connector framework ────────────────────────────────────────────── - connectorRegistry = new ConnectorRegistry() - // Use Electron's net.request for proxy support with full header control. - // net.fetch drops Cookie (forbidden header) and injects Sec-Fetch-* headers; - // net.request gives us raw control over what goes on the wire. - proxyFetch = (input, init) => { - const url = input instanceof URL ? input.toString() : typeof input === 'string' ? input : input.url - const hdrs = (init?.headers ?? {}) as Record - return new Promise((resolve, reject) => { - const req = net.request({ url, method: init?.method ?? 'GET' }) - for (const [key, value] of Object.entries(hdrs)) { - req.setHeader(key, value) - } - - req.on('response', (resp) => { - const chunks: Buffer[] = [] - resp.on('data', (chunk: Buffer) => chunks.push(chunk)) - resp.on('end', () => { - const body = Buffer.concat(chunks) - resolve(new Response(body, { - status: resp.statusCode, - statusText: resp.statusMessage, - headers: resp.headers as Record, - })) - }) - }) - req.on('error', (err) => { - reject(err) - }) - req.end() - }) - } - - spoolDir = join(homedir(), '.spool') - trustStore = new TrustStore(spoolDir) - - execCapabilityImpl = makeExecCapability() - prerequisiteChecker = new PrerequisiteChecker(execCapabilityImpl) - - pruneBrokenConnectorLinks(spoolDir) - - if (!app.isPackaged) { - linkDevConnectors(spoolDir, resolve(process.cwd(), '../..')) - } - - await loadConnectors({ - connectorsDir: join(spoolDir, 'connectors'), - capabilityImpls: { - fetch: makeFetchCapability(proxyFetch), - cookies: makeChromeCookiesCapability(), - sqlite: makeSqliteCapability(), - exec: execCapabilityImpl, - logFor: (connectorId: string) => makeLogCapabilityFor(connectorId), - prerequisitesFor: makePrerequisitesFor(connectorRegistry, prerequisiteChecker), - }, - 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, - }) - - syncScheduler = new SyncScheduler(db, connectorRegistry) - syncScheduler.on((event: SchedulerEvent) => { - mainWindow?.webContents.send('connector:event', event) - }) - syncScheduler.start() - - // Wake from sleep: reschedule a forward pass immediately instead of waiting - // up to 30s for the next periodic tick. - powerMonitor.on('resume', () => { - 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() @@ -483,23 +198,6 @@ app.whenReady().then(async () => { mainWindow = createWindow() - mainWindow.on('focus', () => { - // Always re-check on focus, including for packages that are currently - // all-ok — extensions can be removed and CLIs uninstalled without our - // knowledge, and skipping the recheck would leave stale green status - // until the user manually clicks Re-check. - for (const pkg of connectorRegistry.listPackages()) { - const before = prerequisiteChecker.getCached(pkg.id) - prerequisiteChecker.check(pkg).then((after) => { - const changed = !before || before.length !== after.length || - before.some((s, i) => s.status !== after[i]?.status) - if (changed) { - mainWindow?.webContents.send('connector:status-changed', { packageId: pkg.id }) - } - }).catch(() => undefined) - } - }) - // Auto-updater (only runs in packaged builds) setupAutoUpdater(() => mainWindow) @@ -541,113 +239,6 @@ app.on('window-all-closed', () => { app.dock?.hide() }) -// Graceful shutdown: cancel in-flight syncs cooperatively so the engine can -// record stopReason='cancelled' and partial progress before the runtime tears -// down. Without this the tick fiber and runJob fibers are abandoned at process -// death and state updates for the current cycle are lost. -app.on('before-quit', () => { - syncScheduler?.stop() -}) - -// ── Connector helpers ───────────────────────────────────────────────────────── - -function tryRun(fn: () => void, label: string, fallback?: () => void): void { - try { fn() } catch (err) { - if (fallback) try { fallback() } catch (err2) { console.warn(`[uninstall] fallback also failed for ${label}:`, err2) } - console.error(`[uninstall] failed to delete ${label}:`, err) - } -} - -interface InstalledConnectorInfo { packageName: string; currentVersion: string; connectorId: string; platform: string } - -function getInstalledConnectorPackages(): InstalledConnectorInfo[] { - const connectorsDir = join(spoolDir, 'connectors') - const nodeModules = join(connectorsDir, 'node_modules') - if (!existsSync(nodeModules)) return [] - - const results: InstalledConnectorInfo[] = [] - 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') continue - if (Array.isArray(pkg.spool.connectors)) { - for (const c of pkg.spool.connectors) { - if (c.id) { - results.push({ packageName: pkg.name, currentVersion: pkg.version ?? '0.0.0', connectorId: c.id, platform: c.platform ?? '' }) - } - } - } else if (pkg.spool.id) { - results.push({ packageName: pkg.name, currentVersion: pkg.version ?? '0.0.0', connectorId: pkg.spool.id, platform: pkg.spool.platform ?? '' }) - } - } catch {} - } - } - return results -} - -async function reloadConnectors(): Promise { - const connectorsDir = join(spoolDir, 'connectors') - await loadConnectors({ - connectorsDir, - capabilityImpls: { - fetch: makeFetchCapability(proxyFetch), - cookies: makeChromeCookiesCapability(), - sqlite: makeSqliteCapability(), - exec: execCapabilityImpl, - logFor: (id: string) => makeLogCapabilityFor(id), - prerequisitesFor: makePrerequisitesFor(connectorRegistry, prerequisiteChecker), - }, - 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; 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 } -} - -function pkgIdForConnector(connectorId: string): string | undefined { - for (const pkg of connectorRegistry.listPackages()) { - if (pkg.connectors.some(c => c.id === connectorId)) return pkg.id - } - return undefined -} - -type ResolvedCli = - | { ok: true; pkg: ReturnType & {}; command: string } - | { ok: false; reason: 'package-not-found' | 'not-cli-prereq' | 'no-command-for-platform' | 'requires-manual' } - -function stepsDiffer(a: import('@spool-lab/core').SetupStep[] | undefined, b: import('@spool-lab/core').SetupStep[]): boolean { - if (!a || a.length !== b.length) return true - return a.some((s, i) => s.status !== b[i]?.status) -} - -function resolveCliPrereq(packageId: string, prereqId: string): ResolvedCli { - const pkg = connectorRegistry.getPackage(packageId) - if (!pkg) return { ok: false, reason: 'package-not-found' } - const p = (pkg.prerequisites ?? []).find(x => x.id === prereqId) - if (!p || p.install.kind !== 'cli') return { ok: false, reason: 'not-cli-prereq' } - const command = p.install.command[process.platform as 'darwin' | 'linux' | 'win32'] - if (!command) return { ok: false, reason: 'no-command-for-platform' } - if (p.install.requiresManual || /\bsudo\b/.test(command)) return { ok: false, reason: 'requires-manual' } - return { ok: true, pkg, command } -} - // ── IPC Handlers ────────────────────────────────────────────────────────────── ipcMain.handle('spool:search', (_e, { query, limit = 10, source, onlyStarred }: { query: string; limit?: number; source?: string; onlyStarred?: boolean }) => { @@ -657,9 +248,14 @@ ipcMain.handle('spool:search', (_e, { query, limit = 10, source, onlyStarred }: if (cached) return cached } - const results = source === 'claude' || source === 'codex' || source === 'gemini' - ? searchFragments(db, query, { limit, source, ...(onlyStarred ? { onlyStarred: true } : {}) }) - : searchAll(db, query, { limit, ...(onlyStarred ? { onlyStarred: true } : {}) }) + const sessionSource = source === 'claude' || source === 'codex' || source === 'gemini' + ? source + : undefined + const results = searchFragments(db, query, { + limit, + ...(sessionSource ? { source: sessionSource } : {}), + ...(onlyStarred ? { onlyStarred: true } : {}), + }).map(f => ({ ...f, kind: 'fragment' as const })) if (!isSyncActive) { searchCache.set(cacheKey, results) @@ -673,27 +269,15 @@ ipcMain.handle('spool:search-preview', (_e, { query, limit = 5, source }: { quer const cached = searchCache.get(cacheKey) if (cached) return cached - // Session-scoped preview stays sessions-only. - if (source === 'claude' || source === 'codex' || source === 'gemini') { - const fragments = searchSessionPreview(db, query, { limit, source }) - .map(f => ({ ...f, kind: 'fragment' as const })) - searchCache.set(cacheKey, fragments) - return fragments - } - - // Unfiltered preview: fragments first (historical behavior), captures fill - // any remaining slots. Captures now appear when a query matches only - // connector content (e.g. a Reddit post). - const fragments = searchSessionPreview(db, query, { limit }) - .map(f => ({ ...f, kind: 'fragment' as const })) - const capLimit = Math.max(0, limit - fragments.length) - const captures = capLimit > 0 - ? searchCaptures(db, query, { limit: capLimit }).map(c => ({ ...c, kind: 'capture' as const })) - : [] - const results = [...fragments, ...captures] - - searchCache.set(cacheKey, results) - return results + const sessionSource = source === 'claude' || source === 'codex' || source === 'gemini' + ? source + : undefined + const fragments = searchSessionPreview(db, query, { + limit, + ...(sessionSource ? { source: sessionSource } : {}), + }).map(f => ({ ...f, kind: 'fragment' as const })) + searchCache.set(cacheKey, fragments) + return fragments }) ipcMain.handle('spool:list-sessions', (_e, { limit = 50 }: { limit?: number } = {}) => { @@ -843,304 +427,3 @@ ipcMain.handle('spool:install-update', () => { quitAndInstall() }) -// ── Connector Handlers ────────────────────────────────────────────────── - -ipcMain.handle('connector:list', (): ConnectorStatus[] => { - const installed = getInstalledConnectorPackages() - const versionMap = new Map(installed.map(p => [p.connectorId, p.currentVersion])) - const pkgNameMap = new Map(installed.map(p => [p.connectorId, p.packageName])) - const connIdToPackageId = new Map() - for (const pkg of connectorRegistry.listPackages()) { - for (const c of pkg.connectors) { - connIdToPackageId.set(c.id, pkg.id) - } - } - const result = syncScheduler.getStatus().connectors.map(c => { - const pkgId = connIdToPackageId.get(c.id) - const pkg = pkgId ? connectorRegistry.getPackage(pkgId) : undefined - const cached = pkgId ? prerequisiteChecker?.getCached(pkgId) : undefined - const status: ConnectorStatus = { - ...c, - version: versionMap.get(c.id) ?? '0.0.0', - packageName: pkgNameMap.get(c.id) ?? '', - } - if (pkgId !== undefined) status.packageId = pkgId - // Always send a setup array when the package declares prerequisites, so - // the UI can render the prereq card immediately. If we haven't checked - // yet, fill with pending steps from the manifest so the user sees the - // structure right away rather than nothing-then-flash. - if (cached !== undefined) { - status.setup = cached - } else if (pkg?.prerequisites && pkg.prerequisites.length > 0) { - status.setup = pkg.prerequisites.map(p => ({ - id: p.id, - label: p.name, - kind: p.kind, - status: 'pending' as const, - ...(p.minVersion !== undefined ? { minVersion: p.minVersion } : {}), - ...(p.install ? { install: p.install } : {}), - ...(p.docsUrl ? { docsUrl: p.docsUrl } : {}), - })) - } - return status - }) - // Kick off background prereq checks for packages not yet cached so the - // Setup card appears on first load without needing a focus event. - for (const pkg of connectorRegistry.listPackages()) { - if (pkg.prerequisites && pkg.prerequisites.length > 0 && !prerequisiteChecker.getCached(pkg.id)) { - prerequisiteChecker.check(pkg).then(() => { - mainWindow?.webContents.send('connector:status-changed', { packageId: pkg.id }) - }).catch(() => undefined) - } - } - return result -}) - -ipcMain.handle('connector:check-auth', async (_e, { id }: { id: string }): Promise => { - const connector = connectorRegistry.get(id) - return connector.checkAuth() -}) - -ipcMain.handle('connector:sync-now', (_e, { id }: { id: string }) => { - syncScheduler.triggerNow(id, 'both') - return { ok: true } -}) - -ipcMain.handle('connector:get-status', () => { - return syncScheduler.getStatus() -}) - -ipcMain.handle('connector:set-enabled', (_e, { id, enabled }: { id: string; enabled: boolean }) => { - const state = loadSyncState(db, id) - saveSyncState(db, { ...state, enabled }) - if (enabled) { - syncScheduler.triggerNow(id, 'both') - } - return { ok: true } -}) - -ipcMain.handle('connector:uninstall', (_e, { id }: { id: string }) => { - const connectorsDir = join(spoolDir, 'connectors') - - const allInstalled = getInstalledConnectorPackages() - const pkg = allInstalled.find(p => p.connectorId === id) - if (!pkg) { - return { ok: false, error: `No installed package found for connector "${id}"` } - } - const packageName = pkg.packageName - const siblings = allInstalled.filter(p => p.packageName === packageName) - - // Resolve registry package id before removing from registry - const registryPkgId = pkgIdForConnector(id) - - // Registry + scheduler first: prevents the scheduler tick from re-queuing - // syncs, and lets in-flight syncs wind down via the cancel signal. - for (const sib of siblings) { - connectorRegistry.remove(sib.connectorId) - syncScheduler.cancelIfRunning(sib.connectorId) - } - - uninstallConnector(packageName, connectorsDir) - - // Best-effort DB cleanup — captures_fts_delete trigger can fail on corrupted FTS rows. - // deleteConnectorItems handles capture_connectors + stars + captures in the right order. - for (const sib of siblings) { - tryRun(() => db.prepare('DELETE FROM connector_sync_state WHERE connector_id = ?').run(sib.connectorId), `sync state for ${sib.connectorId}`) - tryRun(() => deleteConnectorItems(db, sib.connectorId), `captures for ${sib.connectorId}`) - } - - if (registryPkgId) prerequisiteChecker.invalidate(registryPkgId) - - const removedIds = siblings.map(s => s.connectorId) - mainWindow?.webContents.send('connector:event', { - type: 'uninstalled', - connectorId: id, - packageName, - removedIds, - }) - - return { ok: true } -}) - -ipcMain.handle('connector:check-updates', async () => { - const { updates, installed } = await runConnectorUpdateCheck() - const byConnectorId: Record = {} - 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 }) => { - return withConnectorLock(async () => { - 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 }) => { - connectorRegistry.get(connectorId) - const row = db.prepare( - 'SELECT COUNT(*) as cnt FROM capture_connectors WHERE connector_id = ?', - ).get(connectorId) as { cnt: number } - return row.cnt -}) - -ipcMain.handle('connector:fetch-registry', async () => { - // Dev-mode override: read registry.json from the workspace so local edits - // show up without pushing to GitHub main. Set SPOOL_REGISTRY_URL to a file:// - // URL, absolute path, or HTTP URL to override explicitly. - const override = process.env.SPOOL_REGISTRY_URL - ?? (!app.isPackaged ? join(process.cwd(), '../landing/public/registry.json') : undefined) - return fetchRegistry({ - fetchFn: (input, init) => net.fetch(input as any, init), - cacheDir: spoolDir, - ...(override !== undefined && { url: override }), - }) -}) - -ipcMain.handle('connector:install', async (_e, { packageName }: { packageName: string }) => { - return installConnectorPackage(packageName) -}) - -ipcMain.handle('connector:recheck-prerequisites', async (_e, { packageId }: { packageId: string }) => { - const pkg = connectorRegistry.getPackage(packageId) - if (!pkg) return { ok: false, error: 'PACKAGE_NOT_FOUND' } - const before = prerequisiteChecker.getCached(packageId) - prerequisiteChecker.invalidate(packageId) - const setup = await prerequisiteChecker.check(pkg) - if (stepsDiffer(before, setup)) { - mainWindow?.webContents.send('connector:status-changed', { packageId }) - } - return { ok: true, setup } -}) - -type InstallResult = - | { ok: true; installId: string; exitCode: number } - | { ok: false; reason: 'requires-manual' } - | { ok: false; reason: 'package-not-found' } - | { ok: false; reason: 'not-cli-prereq' } - | { ok: false; reason: 'no-command-for-platform' } - | { ok: false; reason: 'install-failed'; exitCode: number; errorMessage: string } - -ipcMain.handle('connector:install-cli', async (_e, { packageId, prereqId, installId: providedInstallId }: { packageId: string; prereqId: string; installId?: string }) => { - const resolved = resolveCliPrereq(packageId, prereqId) - if (!resolved.ok) return { ok: false, reason: resolved.reason } satisfies InstallResult - - const { pkg, command } = resolved - const installId = providedInstallId ?? `${packageId}::${prereqId}::${Date.now()}` - const isWin = process.platform === 'win32' - const shellBin = isWin ? (process.env['ComSpec'] || 'cmd.exe') : (process.env['SHELL'] || '/bin/bash') - const args = isWin ? ['/c', command] : ['-lc', command] - - // SECURITY: runs with user's shell/env; trust anchor is registry.json allowlist. - return new Promise((resolvePromise) => { - const child = spawn(shellBin, args, { env: process.env }) - runningInstalls.set(installId, child) - const timer = setTimeout(() => killChildWithEscalation(child), 120_000) - let stderrTail = '' - child.stdout?.on('data', () => { /* discard */ }) - child.stderr?.on('data', (d: Buffer) => { stderrTail = (stderrTail + d.toString()).slice(-4096) }) - child.on('exit', async (code) => { - clearTimeout(timer) - runningInstalls.delete(installId) - const ok = code === 0 - if (ok) { - const before = prerequisiteChecker.getCached(packageId) - prerequisiteChecker.invalidate(packageId) - const after = await prerequisiteChecker.check(pkg).catch(() => undefined) - if (after && stepsDiffer(before, after)) { - mainWindow?.webContents.send('connector:status-changed', { packageId }) - } - resolvePromise({ ok: true, installId, exitCode: code ?? 0 }) - } else { - const errorMessage = stderrTail.trim().split('\n').slice(-3).join('\n') - resolvePromise({ ok: false, reason: 'install-failed', exitCode: code ?? -1, errorMessage }) - } - }) - }) -}) - -ipcMain.handle('connector:install-cli-cancel', async (_e, { installId }: { installId: string }) => { - const child = runningInstalls.get(installId) - if (child) { - killChildWithEscalation(child) - return { ok: true } - } - return { ok: false, error: 'NOT_FOUND' } -}) - -ipcMain.handle('connector:copy-install-command', async (_e, { packageId, prereqId }: { packageId: string; prereqId: string }) => { - const resolved = resolveCliPrereq(packageId, prereqId) - if (!resolved.ok) return { ok: false, reason: resolved.reason } - clipboard.writeText(resolved.command) - return { ok: true, command: resolved.command } -}) - -ipcMain.handle('connector:open-external', async (_e, { url }: { url: string }) => { - await shell.openExternal(url) - return { ok: true } -}) - -// ── E2E test hooks ────────────────────────────────────────────────────────── -// Only active when SPOOL_E2E_TEST=1. Exposes a small seeding surface on -// globalThis so Playwright's app.evaluate() can insert fixture rows using -// the app's already-loaded, electron-ABI better-sqlite3 (the test process -// itself can't import better-sqlite3 without ABI mismatches, and the -// system `sqlite3` CLI on macOS runners lacks FTS5, which breaks the -// captures_fts triggers). -function installE2ETestHooks(sharedDb: Database.Database): void { - if (process.env['SPOOL_E2E_TEST'] !== '1') return - const g = globalThis as unknown as Record - g['__spoolSeedCapture'] = (args: { - platform: string - platformId: string - title: string - url: string - content?: string - connectorId: string - author?: string - captureUuid: string - }): void => { - const source = sharedDb.prepare("SELECT id FROM sources WHERE name = 'connector'").get() as - | { id: number } - | undefined - if (!source) throw new Error("'connector' source row missing") - - const info = sharedDb.prepare(` - INSERT INTO captures - (source_id, capture_uuid, url, title, content_text, author, - platform, platform_id, content_type, thumbnail_url, metadata, - captured_at, raw_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'post', NULL, '{}', - datetime('now'), NULL) - `).run( - source.id, args.captureUuid, args.url, args.title, - args.content ?? args.title, args.author ?? null, - args.platform, args.platformId, - ) - sharedDb.prepare( - 'INSERT OR IGNORE INTO capture_connectors (capture_id, connector_id) VALUES (?, ?)', - ).run(info.lastInsertRowid, args.connectorId) - } -} diff --git a/packages/app/src/preload/index.ts b/packages/app/src/preload/index.ts index 0075073..d141620 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, StarKind, StarredItem, ConnectorStatus, AuthStatus, SchedulerStatus, RegistryConnector } from '@spool-lab/core' +import type { FragmentResult, Session, Message, StatusInfo, SyncResult, SearchResult, StarKind, StarredItem } from '@spool-lab/core' import type { SearchSortOrder } from '../shared/searchSort.js' import type { ThemeEditorStateV1 } from '../renderer/theme/editorTypes.js' @@ -65,7 +65,7 @@ const api = { listStarredItems: (limit?: number): Promise => ipcRenderer.invoke('spool:list-starred-items', { limit }), - getStarredUuids: (): Promise<{ session: string[]; capture: string[] }> => + getStarredUuids: (): Promise<{ session: string[] }> => ipcRenderer.invoke('spool:get-starred-uuids'), getRuntimeInfo: (): Promise<{ isDev: boolean; appPath: string; appName: string }> => @@ -141,70 +141,6 @@ const api = { setThemeEditorState: (state: ThemeEditorStateV1): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:set-theme-editor-state', { state }), - // ── Connectors ── - - connectors: { - list: (): Promise => - ipcRenderer.invoke('connector:list'), - - checkAuth: (id: string): Promise => - ipcRenderer.invoke('connector:check-auth', { id }), - - syncNow: (id: string): Promise<{ ok: boolean }> => - ipcRenderer.invoke('connector:sync-now', { id }), - - setEnabled: (id: string, enabled: boolean): Promise<{ ok: boolean }> => - ipcRenderer.invoke('connector:set-enabled', { id, enabled }), - - getStatus: (): Promise => - ipcRenderer.invoke('connector:get-status'), - - getCaptureCount: (connectorId: string): Promise => - ipcRenderer.invoke('connector:get-capture-count', { connectorId }), - - uninstall: (id: string): Promise<{ ok: boolean }> => - ipcRenderer.invoke('connector:uninstall', { id }), - - checkUpdates: (): Promise> => - 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) - 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 }), - - recheckPrerequisites: (packageId: string) => - ipcRenderer.invoke('connector:recheck-prerequisites', { packageId }), - - installCli: (packageId: string, prereqId: string, installId?: string) => - ipcRenderer.invoke('connector:install-cli', { packageId, prereqId, installId }), - - cancelInstallCli: (installId: string) => - ipcRenderer.invoke('connector:install-cli-cancel', { installId }), - - copyInstallCommand: (packageId: string, prereqId: string) => - ipcRenderer.invoke('connector:copy-install-command', { packageId, prereqId }), - - openExternal: (url: string) => - ipcRenderer.invoke('connector:open-external', { url }), - - onStatusChanged: (cb: (e: { packageId: string }) => void) => { - const handler = (_e: unknown, payload: { packageId: string }) => cb(payload) - ipcRenderer.on('connector:status-changed', handler) - return () => ipcRenderer.removeListener('connector:status-changed', handler) - }, - }, - // Auto-update onUpdateStatus: (cb: (data: { status: 'available' | 'downloading' | 'ready' | 'error'; version?: string; percent?: number }) => void) => { const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { status: 'available' | 'downloading' | 'ready' | 'error'; version?: string; percent?: number })