diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index d39cc8a..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Claude Code Review -on: - pull_request_target: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ secrets.GITHUB_TOKEN }} - # Allow workflow to run for all users, including those without write access - # ⚠️ Security Note: This bypasses the write permission requirement check. - # The workflow is still secured by pull_request_target which runs in the base repo context - # and only reviews code without executing it. See: - # https://github.com/anthropics/claude-code-action/blob/main/docs/security.md#access-control - allowed_non_write_users: "*" - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options \ No newline at end of file diff --git a/api1.js b/api1.js index 2736f59..b0d8f14 100644 --- a/api1.js +++ b/api1.js @@ -284,8 +284,8 @@ function registerTakerHandlers() { // ✅ START BACKGROUND SERVICES setTimeout(async () => { console.log('🔄 Starting background services...'); - await startOfferbookSync('auto'); - startPeriodicSync(); + // Offerbook sync is triggered explicitly on launch and manually by the user. + // The Rust backend keeps offerbook.json up to date internally. startPeriodicWalletSync(); console.log('✅ Background services started'); }, 2000); @@ -320,9 +320,6 @@ function registerTakerHandlers() { console.log('🛑 Shutting down taker...'); console.trace('Shutdown called from:'); // ← ADD THIS to see who called it - // Stop periodic syncs - stopPeriodicSync(); - // Stop wallet sync if (api1State.walletSyncInterval) { clearInterval(api1State.walletSyncInterval); @@ -345,138 +342,80 @@ function registerTakerHandlers() { } }); - async function startOfferbookSync(source = 'manual') { - try { - console.log(`🚀 startOfferbookSync called with source: ${source}`); - - if (!api1State.takerInstance) { - console.log('❌ No taker instance!'); - return { success: false, error: 'Taker not initialized' }; - } - - console.log('✅ Taker instance exists'); + /** + * Spawn a worker thread to run syncOfferbookAndWait(). + * + * The sync MUST run off the main thread — calling it on the main thread + * blocks Electron's entire event loop and triggers the OS "not responding" + * dialog. The worker creates its own Taker instance. At launch this matches + * the main Taker (both cold). Mid-session the worker's Tor circuits warm up + * quickly because the Tor daemon reuses circuits it already established for + * the main Taker. + * + * Returns { success, syncId } immediately. Caller polls getSyncStatus(syncId). + */ + function startSyncWorker(source = 'manual') { + if (!api1State.takerInstance || !api1State.storedTakerConfig) { + return { success: false, error: 'Taker not initialized' }; + } - // Check if sync already running - if (api1State.syncState.isRunning) { - console.log(`⏭️ Sync already running (${source}), skipping`); - return { - success: false, - duplicate: true, - existingSyncId: api1State.syncState.currentSyncId, - }; - } + if (api1State.syncState.isRunning) { + console.log(`⏭️ Sync already running (${source}), skipping`); + return { success: false, duplicate: true }; + } - const syncId = `${source}_${Date.now()}_${Math.random().toString(36).substring(7)}`; - console.log( - `🔄 [${syncId}] Triggering manual offerbook sync (${source})...` - ); + const syncId = `${source}_${Date.now()}_${Math.random().toString(36).substring(7)}`; + api1State.syncState.isRunning = true; + api1State.syncState.currentSyncId = syncId; + api1State.activeSyncs.set(syncId, { + status: 'syncing', + startedAt: Date.now(), + source, + }); - // Set flags - api1State.syncState.isRunning = true; - api1State.syncState.currentSyncId = syncId; + const workerConfig = { + dataDir: api1State.DATA_DIR, + walletName: api1State.currentWalletName || api1State.DEFAULT_WALLET_NAME, + rpcConfig: api1State.storedTakerConfig.rpcConfig, + zmqAddr: api1State.storedTakerConfig.zmqAddr, + controlPort: api1State.storedTakerConfig.controlPort || 9051, + torAuthPassword: api1State.storedTakerConfig.torAuthPassword, + password: api1State.storedTakerConfig.password || '', + protocol: api1State.protocolVersion || 'v1', + }; - // ✅ FIX: Delete old offerbook and create fresh empty one - const offerbookPath = path.join( - api1State.storedTakerConfig.dataDir, - 'offerbook.json' - ); + const worker = new Worker(path.join(__dirname, 'offerbook-worker.js'), { + workerData: { config: workerConfig }, + }); - if (fs.existsSync(offerbookPath)) { - try { - const content = fs.readFileSync(offerbookPath, 'utf8'); - JSON.parse(content); // Test if valid - console.log('✅ Existing offerbook is valid'); - } catch (parseError) { - console.log('⚠️ Corrupted offerbook detected, recreating...'); - fs.writeFileSync( - offerbookPath, - JSON.stringify({ makers: [] }), - 'utf8' - ); - } - } else { - console.log('📝 Creating initial offerbook.json...'); - fs.writeFileSync(offerbookPath, JSON.stringify({ makers: [] }), 'utf8'); - } + const finish = (status, extra = {}) => { api1State.activeSyncs.set(syncId, { - status: 'syncing', - startedAt: Date.now(), - source: source, + ...api1State.activeSyncs.get(syncId), + ...extra, + status, + completedAt: Date.now(), }); - - // ✅ Trigger manual sync - api1State.takerInstance.runOfferSyncNow(); - - // Monitor until complete - const checkInterval = setInterval(() => { - try { - const isSyncing = api1State.takerInstance.isOfferbookSyncing(); - - if (!isSyncing) { - clearInterval(checkInterval); - - console.log(`✅ Offerbook sync completed (${source})`); - api1State.activeSyncs.set(syncId, { - ...api1State.activeSyncs.get(syncId), - status: 'completed', - completedAt: Date.now(), - }); - - // Clear flags - api1State.syncState.isRunning = false; - api1State.syncState.currentSyncId = null; - api1State.syncState.lastSyncTime = Date.now(); - } - } catch (err) { - clearInterval(checkInterval); - console.error(`❌ Sync check failed:`, err); - - api1State.syncState.isRunning = false; - api1State.syncState.currentSyncId = null; - - api1State.activeSyncs.set(syncId, { - ...api1State.activeSyncs.get(syncId), - status: 'failed', - error: err.message, - }); - } - }, 1000); - - return { success: true, syncId, source }; - } catch (error) { - console.error('❌ Sync offerbook failed:', error); + if (status === 'completed') api1State.syncState.lastSyncTime = Date.now(); api1State.syncState.isRunning = false; api1State.syncState.currentSyncId = null; - return { success: false, error: error.message }; - } - } - - function startPeriodicSync() { - // Clear any existing interval - if (api1State.syncState.periodicInterval) { - clearInterval(api1State.syncState.periodicInterval); - } + }; - console.log('⏰ Starting periodic sync scheduler (every 15 minutes)'); + worker.on('message', (msg) => { + if (msg.type === 'completed') { + console.log(`✅ [${syncId}] Offerbook sync completed`); + finish('completed'); + } else if (msg.type === 'error') { + console.error(`❌ [${syncId}] Offerbook sync failed:`, msg.error); + finish('failed', { error: msg.error }); + } + }); - api1State.syncState.periodicInterval = setInterval( - async () => { - console.log('⏰ Periodic sync triggered'); - await startOfferbookSync('periodic'); - }, - 15 * 60 * 1000 - ); // 15 minutes - } + worker.on('error', (err) => { + console.error(`❌ [${syncId}] Offerbook worker error:`, err.message); + finish('failed', { error: err.message }); + }); - /** - * Stop periodic offerbook syncs - */ - function stopPeriodicSync() { - if (api1State.syncState.periodicInterval) { - clearInterval(api1State.syncState.periodicInterval); - api1State.syncState.periodicInterval = null; - console.log('⏰ Periodic sync scheduler stopped'); - } + return { success: true, syncId }; } // Get wallet info @@ -654,25 +593,6 @@ function registerTakerHandlers() { } }); - // Check if offerbook is syncing - ipcMain.handle('taker:isOfferbookSyncing', async () => { - try { - if (!api1State.takerInstance) { - return { success: false, error: 'Taker not initialized' }; - } - - const isSyncing = api1State.takerInstance.isOfferbookSyncing(); - - return { - success: true, - isSyncing: isSyncing, - }; - } catch (error) { - console.error('Failed to check offerbook sync status:', error); - return { success: false, error: error.message, isSyncing: false }; - } - }); - // Get UTXOs ipcMain.handle('taker:getUtxos', async () => { try { @@ -865,9 +785,8 @@ function registerTakerHandlers() { } ); - // Sync offerbook - ipcMain.handle('taker:syncOfferbook', async () => { - return await startOfferbookSync('manual'); + ipcMain.handle('taker:syncOfferbookAndWait', () => { + return startSyncWorker('manual'); }); // Get sync status @@ -1047,8 +966,8 @@ function registerCoinswapHandlers() { while (retries < maxRetries) { try { - // Check if sync is still running - const isSyncing = api1State.takerInstance.isOfferbookSyncing(); + // Check if app-level sync is still running. + const isSyncing = api1State.syncState.isRunning; if (!isSyncing) { // Sync complete - now check if we have enough makers diff --git a/offerbook-worker.js b/offerbook-worker.js new file mode 100644 index 0000000..3ff2bb0 --- /dev/null +++ b/offerbook-worker.js @@ -0,0 +1,39 @@ +const { parentPort, workerData } = require('worker_threads'); + +/** + * Worker thread for running offerbook sync operations. + * Creates its own Taker instance so the sync doesn't block the main process IPC. + */ + +(async () => { + try { + const coinswapNapi = require('coinswap-napi'); + const { config } = workerData; + + const protocol = config.protocol || 'v1'; + const TakerClass = + protocol === 'v2' ? coinswapNapi.TaprootTaker : coinswapNapi.Taker; + + if (!TakerClass) { + throw new Error( + `${protocol === 'v2' ? 'TaprootTaker' : 'Taker'} class not found. Please rebuild coinswap-napi.` + ); + } + + const taker = new TakerClass( + config.dataDir, + config.walletName || 'taker-wallet', + config.rpcConfig, + config.controlPort || 9051, + config.torAuthPassword || undefined, + config.zmqAddr, + config.password || '' + ); + + taker.syncOfferbookAndWait(); + + parentPort.postMessage({ type: 'completed' }); + } catch (err) { + parentPort.postMessage({ type: 'error', error: err.message }); + } +})(); diff --git a/package-lock.json b/package-lock.json index 82b229f..5b1a254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "taker-app", - "version": "1.0.0", + "name": "coinswap-taker", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "taker-app", - "version": "1.0.0", + "name": "coinswap-taker", + "version": "0.2.0", "license": "ISC", "dependencies": { "@tailwindcss/cli": "^4.1.14", diff --git a/preload.js b/preload.js index 3fb88ec..869c472 100644 --- a/preload.js +++ b/preload.js @@ -12,7 +12,8 @@ contextBridge.exposeInMainWorld('api', { getBalance: () => ipcRenderer.invoke('taker:getBalance'), getNextAddress: () => ipcRenderer.invoke('taker:getNextAddress'), sync: () => ipcRenderer.invoke('taker:sync'), - syncOfferbook: () => ipcRenderer.invoke('taker:syncOfferbook'), + syncOfferbookAndWait: () => + ipcRenderer.invoke('taker:syncOfferbookAndWait'), getSyncStatus: (syncId) => ipcRenderer.invoke('taker:getSyncStatus', syncId), getOffers: () => ipcRenderer.invoke('taker:getOffers'), @@ -30,7 +31,6 @@ contextBridge.exposeInMainWorld('api', { testTorConnection: (config) => ipcRenderer.invoke('tor:testConnection', config), getProtocol: () => ipcRenderer.invoke('taker:getProtocol'), - isOfferbookSyncing: () => ipcRenderer.invoke('taker:isOfferbookSyncing'), setupLogging: (dataDir, level) => ipcRenderer.invoke('taker:setupLogging', { dataDir, level }), diff --git a/src/components/market/Market.js b/src/components/market/Market.js index 85f6272..588b7d5 100644 --- a/src/components/market/Market.js +++ b/src/components/market/Market.js @@ -8,6 +8,7 @@ export function Market(container) { let syncProgress = null; let currentMakerStatus = 'good'; // 'good', 'bad', or 'unresponsive' let syncCheckInterval = null; + let periodicRefreshInterval = null; // Check sync state every second function startSyncStateMonitor() { @@ -152,7 +153,7 @@ export function Market(container) { } } - async function syncOfferbook() { + async function syncOfferbookAndWait() { try { // Check if sync is already running const activeSyncId = localStorage.getItem('active_sync_id'); @@ -169,7 +170,7 @@ export function Market(container) { console.log('🔄 Starting offerbook sync...'); - const result = await window.api.taker.syncOfferbook(); + const result = await window.api.taker.syncOfferbookAndWait(); if (!result.success) { throw new Error(result.error || 'Failed to start sync'); @@ -254,73 +255,40 @@ export function Market(container) { async function handleRefresh() { const refreshBtn = content.querySelector('#refresh-market-btn'); - // Check if sync is already running - const activeSyncId = localStorage.getItem('active_sync_id'); - if (activeSyncId) { - try { - const status = await window.api.taker.getSyncStatus(activeSyncId); - if ( - status.success && - (status.sync.status === 'syncing' || - status.sync.status === 'starting') - ) { - showError('Sync already in progress'); - return; - } - } catch (err) { - localStorage.removeItem('active_sync_id'); - } + // Guard against double-click if sync already running + const stateCheck = await window.api.taker.getCurrentSyncState(); + if (stateCheck.success && stateCheck.isRunning) { + showError('Sync already in progress'); + return; } const originalText = refreshBtn.innerHTML; - refreshBtn.disabled = true; refreshBtn.innerHTML = 'Syncing...'; - // ✅ Show sync progress bar ONLY - syncProgress = { - percent: 50, - status: 'syncing', - message: 'Syncing market data...', - }; + syncProgress = { percent: 50, status: 'syncing', message: 'Syncing market data...' }; updateUI(); try { - const result = await window.api.taker.syncOfferbook(); - + const result = await window.api.taker.syncOfferbookAndWait(); if (!result.success) { throw new Error(result.error || 'Failed to start sync'); } const syncId = result.syncId; - console.log('📡 Sync started:', syncId); - localStorage.setItem('active_sync_id', syncId); - - // Poll until sync completes - let isSyncing = true; - while (isSyncing) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const statusResult = await window.api.taker.isOfferbookSyncing(); - if (statusResult.success) { - isSyncing = statusResult.isSyncing; - if (isSyncing) { - console.log('⏳ Still syncing...'); - } - } - } - - // Sync is done - wait for file write - console.log('✅ Offerbook synced - waiting for file write...'); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise((resolve, reject) => { + const poll = setInterval(async () => { + try { + const status = await window.api.taker.getSyncStatus(syncId); + if (!status.success) { clearInterval(poll); reject(new Error('Failed to get sync status')); return; } + if (status.sync.status === 'completed') { clearInterval(poll); resolve(); } + else if (status.sync.status === 'failed') { clearInterval(poll); reject(new Error(status.sync.error || 'Sync failed')); } + } catch (err) { clearInterval(poll); reject(err); } + }, 1000); + }); - // Clear sync progress syncProgress = null; - - // NOW fetch fresh makers - console.log('✅ Now fetching fresh makers...'); - localStorage.removeItem('active_sync_id'); - await fetchMakers(); // This sets isLoading = false and updates UI + await fetchMakers(); refreshBtn.innerHTML = '✅ Synced!'; setTimeout(() => { @@ -328,7 +296,7 @@ export function Market(container) { refreshBtn.innerHTML = originalText; }, 2000); } catch (error) { - syncProgress = null; + syncProgress = null; updateUI(); refreshBtn.innerHTML = '❌ Failed'; showError(error.message); @@ -361,11 +329,11 @@ export function Market(container) { } banner.classList.remove('hidden'); - // ✅ CHECK: Is offerbook currently syncing? + // Check if app-level sync is currently running. try { - const syncingResult = await window.api.taker.isOfferbookSyncing(); + const syncingResult = await window.api.taker.getCurrentSyncState(); - if (syncingResult.success && syncingResult.isSyncing) { + if (syncingResult.success && syncingResult.isRunning) { console.log('⏳ Background sync in progress, waiting...'); isLoading = true; updateUI(); @@ -409,6 +377,25 @@ export function Market(container) { await fetchMakers(); isLoading = false; updateUI(); + + // Refresh offerbook data every 15 minutes. The Rust backend keeps + // offerbook.json up to date; this just re-reads the file for the UI. + periodicRefreshInterval = setInterval(async () => { + const syncState = await window.api.taker.getCurrentSyncState(); + if (!syncState.isRunning) { + await fetchMakers(); + } + }, 15 * 60 * 1000); + + // Clean up the interval when this component is removed from the DOM + const observer = new MutationObserver(() => { + if (!document.body.contains(content)) { + clearInterval(periodicRefreshInterval); + periodicRefreshInterval = null; + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); } async function monitorExistingSync() { @@ -418,15 +405,15 @@ export function Market(container) { isLoading = true; updateUI(); - // ✅ SIMPLE: Just poll until sync is done + // Poll app sync state until sync is done. let isSyncing = true; while (isSyncing) { await new Promise((resolve) => setTimeout(resolve, 1000)); try { - const result = await window.api.taker.isOfferbookSyncing(); + const result = await window.api.taker.getCurrentSyncState(); if (result.success) { - isSyncing = result.isSyncing; + isSyncing = result.isRunning; if (isSyncing) { console.log('⏳ Still syncing...'); } diff --git a/src/components/taker/TakerInitialization.js b/src/components/taker/TakerInitialization.js index 6cae7c9..07c1067 100644 --- a/src/components/taker/TakerInitialization.js +++ b/src/components/taker/TakerInitialization.js @@ -28,6 +28,12 @@ export function TakerInitializationComponent(container, config, onInitialized) { Initializing taker (creates wallet) +
Discovering available makers via Tor. This may take a minute...
+