/** * SigSocket Service - Clean Implementation with New WASM APIs * * This service provides a clean interface for SigSocket functionality using * the new WASM-based APIs that handle all WebSocket management, request storage, * and security validation internally. * * Architecture: * - WASM handles: WebSocket connection, message parsing, request storage, security * - Extension handles: UI notifications, badge updates, user interactions */ class SigSocketService { constructor() { // Connection state this.isConnected = false; this.currentWorkspace = null; this.connectedPublicKey = null; // Configuration this.defaultServerUrl = "ws://localhost:8080/ws"; // WASM module reference this.wasmModule = null; // UI communication this.popupPort = null; // Status monitoring this.statusMonitorInterval = null; this.lastKnownConnectionState = false; } /** * Initialize the service with WASM module * @param {Object} wasmModule - The loaded WASM module with SigSocketManager */ async initialize(wasmModule) { this.wasmModule = wasmModule; // Load server URL from storage try { const result = await chrome.storage.local.get(['sigSocketUrl']); if (result.sigSocketUrl) { this.defaultServerUrl = result.sigSocketUrl; } } catch (error) { console.warn('Failed to load SigSocket URL from storage:', error); } // Restore any persisted pending requests await this.restorePendingRequests(); console.log('๐Ÿ”Œ SigSocket service initialized with WASM APIs'); } /** * Restore pending requests from persistent storage * Only restore requests that match the current workspace */ async restorePendingRequests() { try { const result = await chrome.storage.local.get(['sigSocketPendingRequests']); if (result.sigSocketPendingRequests && Array.isArray(result.sigSocketPendingRequests)) { console.log(`๐Ÿ”„ Found ${result.sigSocketPendingRequests.length} stored requests`); // Filter requests for current workspace only const currentWorkspaceRequests = result.sigSocketPendingRequests.filter(request => request.target_public_key === this.connectedPublicKey ); console.log(`๐Ÿ”„ Restoring ${currentWorkspaceRequests.length} requests for current workspace`); // Add each workspace-specific request back to WASM storage for (const request of currentWorkspaceRequests) { try { await this.wasmModule.SigSocketManager.add_pending_request(JSON.stringify(request.request || request)); console.log(`โœ… Restored request: ${request.id || request.request?.id}`); } catch (error) { console.warn(`Failed to restore request ${request.id || request.request?.id}:`, error); } } // Update badge after restoration this.updateBadge(); } } catch (error) { console.warn('Failed to restore pending requests:', error); } } /** * Persist pending requests to storage with workspace isolation */ async persistPendingRequests() { try { const requests = await this.getFilteredRequests(); // Get existing storage to merge with other workspaces const result = await chrome.storage.local.get(['sigSocketPendingRequests']); const existingRequests = result.sigSocketPendingRequests || []; // Remove old requests for current workspace const otherWorkspaceRequests = existingRequests.filter(request => request.target_public_key !== this.connectedPublicKey ); // Combine with current workspace requests const allRequests = [...otherWorkspaceRequests, ...requests]; await chrome.storage.local.set({ sigSocketPendingRequests: allRequests }); console.log(`๐Ÿ’พ Persisted ${requests.length} requests for current workspace (${allRequests.length} total)`); } catch (error) { console.warn('Failed to persist pending requests:', error); } } /** * Connect to SigSocket server using WASM APIs * WASM handles all connection logic (reuse, switching, etc.) * @param {string} workspaceId - The workspace/keyspace identifier * @param {number} retryCount - Number of retry attempts (default: 3) * @returns {Promise} - True if connected successfully */ async connectToServer(workspaceId, retryCount = 3) { for (let attempt = 1; attempt <= retryCount; attempt++) { try { if (!this.wasmModule?.SigSocketManager) { throw new Error('WASM SigSocketManager not available'); } console.log(`๐Ÿ”— Requesting SigSocket connection for workspace: ${workspaceId} (attempt ${attempt}/${retryCount})`); // Clean workspace switching if (this.currentWorkspace && this.currentWorkspace !== workspaceId) { console.log(`๐Ÿ”„ Clean workspace switch: ${this.currentWorkspace} -> ${workspaceId}`); await this.cleanWorkspaceSwitch(workspaceId); // Small delay to ensure clean state transition await new Promise(resolve => setTimeout(resolve, 300)); } // Let WASM handle all connection logic (reuse, switching, etc.) const connectionInfo = await this.wasmModule.SigSocketManager.connect_workspace_with_events( workspaceId, this.defaultServerUrl, (event) => this.handleSigSocketEvent(event) ); // Parse connection info const info = JSON.parse(connectionInfo); this.currentWorkspace = workspaceId; // Use the parameter we passed, not WASM response this.connectedPublicKey = info.public_key; this.isConnected = info.is_connected; console.log(`โœ… SigSocket connection result:`, { workspace: this.currentWorkspace, publicKey: this.connectedPublicKey?.substring(0, 16) + '...', connected: this.isConnected, serverUrl: this.defaultServerUrl }); // Validate that we have a public key if connected if (this.isConnected && !this.connectedPublicKey) { console.warn('โš ๏ธ Connected but no public key received - this may cause request issues'); } // Update badge to show current state this.updateBadge(); if (this.isConnected) { // Clean flow: Connect -> Restore workspace requests -> Update UI console.log(`๐Ÿ”— Connected to workspace: ${workspaceId}, restoring pending requests...`); // 1. Restore requests for this specific workspace await this.restorePendingRequests(); // 2. Update badge with current count this.updateBadge(); console.log(`โœ… Workspace ${workspaceId} ready with restored requests`); return true; } // If not connected but no error, try again if (attempt < retryCount) { console.log(`โณ Connection not established, retrying in 1 second...`); await new Promise(resolve => setTimeout(resolve, 1000)); } } catch (error) { // Check if this is an expected "no workspace" error during startup const isExpectedStartupError = error.message && (error.message.includes('Workspace not found') || error.message.includes('no keypairs available')); if (isExpectedStartupError && attempt === 1) { console.log(`โณ SigSocket connection attempt ${attempt}: No active workspace (expected after extension reload)`); } // Check if this is a public key related error if (error.message && error.message.includes('public key')) { console.error(`๐Ÿ”‘ Public key error detected: ${error.message}`); // For public key errors, don't retry immediately - might need workspace change if (attempt === 1) { console.log(`๐Ÿ”„ Public key error on first attempt, trying to disconnect and reconnect...`); await this.disconnect(); await new Promise(resolve => setTimeout(resolve, 1000)); } } if (attempt < retryCount) { if (!isExpectedStartupError) { console.log(`โณ Retrying connection in 2 seconds...`); } await new Promise(resolve => setTimeout(resolve, 2000)); } else { // Final attempt failed this.isConnected = false; this.currentWorkspace = null; this.connectedPublicKey = null; if (isExpectedStartupError) { console.log(`โ„น๏ธ SigSocket connection failed: No active workspace. Will connect when user logs in.`); } } } } return false; } /** * Handle events from the WASM SigSocket client * This is called automatically when requests arrive * @param {Object} event - Event from WASM layer */ async handleSigSocketEvent(event) { console.log('๐Ÿ“จ Received SigSocket event:', event); if (event.type === 'sign_request') { console.log(`๐Ÿ” New sign request: ${event.request_id} for workspace: ${this.currentWorkspace}`); // Clean flow: Request arrives -> Store -> Persist -> Update UI try { // 1. Request is automatically stored in WASM (already done by WASM layer) // 2. Persist to storage with workspace isolation await this.persistPendingRequests(); // 3. Update badge count this.updateBadge(); // 4. Show notification this.showSignRequestNotification(); // 5. Notify popup if connected this.notifyPopupOfNewRequest(); console.log(`โœ… Request ${event.request_id} processed and stored for workspace: ${this.currentWorkspace}`); } catch (error) { console.error(`โŒ Failed to process request ${event.request_id}:`, error); } } } /** * Approve a sign request using WASM APIs * @param {string} requestId - Request to approve * @returns {Promise} - True if approved successfully */ async approveSignRequest(requestId) { try { if (!this.wasmModule?.SigSocketManager) { throw new Error('WASM SigSocketManager not available'); } // Check if we're connected before attempting approval if (!this.isConnected) { console.warn(`โš ๏ธ Not connected to SigSocket server, cannot approve request: ${requestId}`); throw new Error('Not connected to SigSocket server'); } // Verify we can approve this request const canApprove = await this.canApproveRequest(requestId); if (!canApprove) { console.warn(`โš ๏ธ Cannot approve request ${requestId} - keyspace may be locked or request not found`); throw new Error('Cannot approve request - keyspace may be locked or request not found'); } console.log(`โœ… Approving request: ${requestId}`); // WASM handles all validation, signing, and server communication await this.wasmModule.SigSocketManager.approve_request(requestId); console.log(`๐ŸŽ‰ Request approved successfully: ${requestId}`); // Clean flow: Approve -> Remove from storage -> Update UI // 1. Remove from persistent storage (WASM already removed it) await this.persistPendingRequests(); // 2. Update badge count this.updateBadge(); // 3. Notify popup of updated state this.notifyPopupOfRequestUpdate(); console.log(`โœ… Request ${requestId} approved and cleaned up`); return true; } catch (error) { console.error(`โŒ Failed to approve request ${requestId}:`, error); // Check if this is a connection-related error if (error.message && (error.message.includes('Connection not found') || error.message.includes('public key'))) { console.error(`๐Ÿ”‘ Connection/public key error during approval. Current state:`, { connected: this.isConnected, workspace: this.currentWorkspace, publicKey: this.connectedPublicKey?.substring(0, 16) + '...' }); // Try to reconnect for next time if (this.currentWorkspace) { console.log(`๐Ÿ”„ Attempting to reconnect to workspace: ${this.currentWorkspace}`); setTimeout(() => this.connectToServer(this.currentWorkspace), 1000); } } return false; } } /** * Reject a sign request using WASM APIs * @param {string} requestId - Request to reject * @param {string} reason - Reason for rejection * @returns {Promise} - True if rejected successfully */ async rejectSignRequest(requestId, reason = 'User rejected') { try { if (!this.wasmModule?.SigSocketManager) { throw new Error('WASM SigSocketManager not available'); } console.log(`โŒ Rejecting request: ${requestId}, reason: ${reason}`); // WASM handles rejection and server communication await this.wasmModule.SigSocketManager.reject_request(requestId, reason); console.log(`โœ… Request rejected successfully: ${requestId}`); // Clean flow: Reject -> Remove from storage -> Update UI // 1. Remove from persistent storage (WASM already removed it) await this.persistPendingRequests(); // 2. Update badge count this.updateBadge(); // 3. Notify popup of updated state this.notifyPopupOfRequestUpdate(); console.log(`โœ… Request ${requestId} rejected and cleaned up`); return true; } catch (error) { console.error(`โŒ Failed to reject request ${requestId}:`, error); return false; } } /** * Get pending requests from WASM (filtered by current workspace) * @returns {Promise} - Array of pending requests for current workspace */ async getPendingRequests() { return this.getFilteredRequests(); } /** * Get filtered requests from WASM (workspace-aware) * @returns {Promise} - Array of filtered requests */ async getFilteredRequests() { try { if (!this.wasmModule?.SigSocketManager) { return []; } const requestsJson = await this.wasmModule.SigSocketManager.get_filtered_requests(); const requests = JSON.parse(requestsJson); console.log(`๐Ÿ“‹ Retrieved ${requests.length} filtered requests for current workspace`); return requests; } catch (error) { console.error('Failed to get filtered requests:', error); return []; } } /** * Check if a request can be approved (keyspace validation) * @param {string} requestId - Request ID to check * @returns {Promise} - True if can be approved */ async canApproveRequest(requestId) { try { if (!this.wasmModule?.SigSocketManager) { return false; } return await this.wasmModule.SigSocketManager.can_approve_request(requestId); } catch (error) { console.error('Failed to check request approval status:', error); return false; } } /** * Show clickable notification for new sign request * Call this AFTER the request has been stored and persisted */ async showSignRequestNotification() { try { if (chrome.notifications && chrome.notifications.create) { // Small delay to ensure request is fully stored await new Promise(resolve => setTimeout(resolve, 100)); console.log(`๐Ÿ“ข Preparing notification for new signature request`); // Check if keyspace is currently unlocked to customize message let message = 'New signature request received. Click to review and approve.'; let title = 'SigSocket Sign Request'; // Try to determine if keyspace is locked try { const requests = await this.getPendingRequests(); const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false; if (!canApprove) { message = 'New signature request received. Click to unlock keyspace and approve.'; title = 'SigSocket Request'; } } catch (error) { // If we can't check, use generic message message = 'New signature request received. Click to open extension.'; } // Create clickable notification with unique ID const notificationId = `sigsocket-request-${Date.now()}`; const notificationOptions = { type: 'basic', iconUrl: 'icons/icon48.png', title: title, message: message, requireInteraction: true // Keep notification visible until user interacts }; console.log(`๐Ÿ“ข Creating notification: ${notificationId}`, notificationOptions); chrome.notifications.create(notificationId, notificationOptions, (createdId) => { if (chrome.runtime.lastError) { console.error('โŒ Failed to create notification:', chrome.runtime.lastError); } else { console.log(`โœ… Notification created successfully: ${createdId}`); } }); } else { console.log('๐Ÿ“ข Notifications not available, skipping notification'); } } catch (error) { console.warn('Failed to show notification:', error); } } /** * Update extension badge with pending request count */ async updateBadge() { try { const requests = await this.getPendingRequests(); const count = requests.length; const badgeText = count > 0 ? count.toString() : ''; console.log(`๐Ÿ”ข Updating badge: ${count} pending requests`); chrome.action.setBadgeText({ text: badgeText }); chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' }); } catch (error) { console.error('Failed to update badge:', error); } } /** * Notify popup about new request */ async notifyPopupOfNewRequest() { if (!this.popupPort) { console.log('No popup connected, skipping notification'); return; } try { const requests = await this.getPendingRequests(); const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false; this.popupPort.postMessage({ type: 'NEW_SIGN_REQUEST', canApprove, pendingRequests: requests }); console.log(`๐Ÿ“ค Notified popup: ${requests.length} requests, canApprove: ${canApprove}`); } catch (error) { console.error('Failed to notify popup:', error); } } /** * Notify popup about request updates */ async notifyPopupOfRequestUpdate() { if (!this.popupPort) return; try { const requests = await this.getPendingRequests(); this.popupPort.postMessage({ type: 'REQUESTS_UPDATED', pendingRequests: requests }); } catch (error) { console.error('Failed to notify popup of update:', error); } } /** * Disconnect from SigSocket server * WASM handles all disconnection logic */ async disconnect() { try { if (this.wasmModule?.SigSocketManager) { await this.wasmModule.SigSocketManager.disconnect(); } // Clear local state this.isConnected = false; this.currentWorkspace = null; this.connectedPublicKey = null; this.lastKnownConnectionState = false; // Stop status monitoring this.stopStatusMonitoring(); this.updateBadge(); console.log('๐Ÿ”Œ SigSocket disconnection requested'); } catch (error) { console.error('Failed to disconnect:', error); } } /** * Clear persisted pending requests from storage */ async clearPersistedRequests() { try { await chrome.storage.local.remove(['sigSocketPendingRequests']); console.log('๐Ÿ—‘๏ธ Cleared persisted pending requests from storage'); } catch (error) { console.warn('Failed to clear persisted requests:', error); } } /** * Clean workspace switch - clear current workspace requests only */ async cleanWorkspaceSwitch(newWorkspace) { try { console.log(`๐Ÿ”„ Clean workspace switch: ${this.currentWorkspace} -> ${newWorkspace}`); // 1. Persist current workspace requests before switching if (this.currentWorkspace && this.isConnected) { await this.persistPendingRequests(); console.log(`๐Ÿ’พ Saved requests for workspace: ${this.currentWorkspace}`); } // 2. Clear WASM state (will be restored for new workspace) if (this.wasmModule?.SigSocketManager) { await this.wasmModule.SigSocketManager.clear_pending_requests(); console.log('๐Ÿงน Cleared WASM request state'); } // 3. Reset local state this.currentWorkspace = null; this.connectedPublicKey = null; this.isConnected = false; console.log('โœ… Workspace switch cleanup completed'); } catch (error) { console.error('โŒ Failed to clean workspace switch:', error); } } /** * Get connection status with real connection verification * @returns {Promise} - Connection status information */ async getStatus() { try { if (!this.wasmModule?.SigSocketManager) { return { isConnected: false, workspace: null, publicKey: null, pendingRequestCount: 0, serverUrl: this.defaultServerUrl }; } // Get WASM status first const statusJson = await this.wasmModule.SigSocketManager.get_connection_status(); const status = JSON.parse(statusJson); // Verify connection by trying to get requests (this will fail if not connected) let actuallyConnected = false; let requests = []; try { requests = await this.getPendingRequests(); // If we can get requests and WASM says connected, we're probably connected actuallyConnected = status.is_connected && Array.isArray(requests); } catch (error) { // If getting requests fails, we're definitely not connected console.warn('Connection verification failed:', error); actuallyConnected = false; } // Update our internal state this.isConnected = actuallyConnected; if (status.connected_public_key && actuallyConnected) { this.connectedPublicKey = status.connected_public_key; } else { this.connectedPublicKey = null; } // If we're disconnected, clear our workspace if (!actuallyConnected) { this.currentWorkspace = null; } const statusResult = { isConnected: actuallyConnected, workspace: this.currentWorkspace, publicKey: status.connected_public_key, pendingRequestCount: requests.length, serverUrl: this.defaultServerUrl, // Clean flow status indicators cleanFlowReady: actuallyConnected && this.currentWorkspace && status.connected_public_key }; console.log('๐Ÿ“Š Clean flow status:', { connected: statusResult.isConnected, workspace: statusResult.workspace, requestCount: statusResult.pendingRequestCount, flowReady: statusResult.cleanFlowReady }); return statusResult; } catch (error) { console.error('Failed to get status:', error); // Clear state on error this.isConnected = false; this.currentWorkspace = null; this.connectedPublicKey = null; return { isConnected: false, workspace: null, publicKey: null, pendingRequestCount: 0, serverUrl: this.defaultServerUrl }; } } /** * Set the popup port for communication * @param {chrome.runtime.Port|null} port - The popup port or null to disconnect */ setPopupPort(port) { this.popupPort = port; if (port) { console.log('๐Ÿ“ฑ Popup connected to SigSocket service'); // Immediately check connection status when popup opens this.checkConnectionStatusNow(); // Start monitoring connection status when popup connects this.startStatusMonitoring(); } else { console.log('๐Ÿ“ฑ Popup disconnected from SigSocket service'); // Stop monitoring when popup disconnects this.stopStatusMonitoring(); } } /** * Immediately check and update connection status */ async checkConnectionStatusNow() { try { // Force a fresh connection check const currentStatus = await this.getStatusWithConnectionTest(); this.lastKnownConnectionState = currentStatus.isConnected; // Notify popup of current status this.notifyPopupOfStatusChange(currentStatus); console.log(`๐Ÿ” Immediate status check: ${currentStatus.isConnected ? 'Connected' : 'Disconnected'}`); } catch (error) { console.warn('Failed to check connection status immediately:', error); } } /** * Get status with additional connection testing */ async getStatusWithConnectionTest() { const status = await this.getStatus(); // If WASM claims we're connected, do an additional verification if (status.isConnected) { try { // Try to get connection status again - if this fails, we're not really connected const verifyJson = await this.wasmModule.SigSocketManager.get_connection_status(); const verifyStatus = JSON.parse(verifyJson); if (!verifyStatus.is_connected) { console.log('๐Ÿ” Connection verification failed - marking as disconnected'); status.isConnected = false; this.isConnected = false; this.currentWorkspace = null; } } catch (error) { console.log('๐Ÿ” Connection test failed - marking as disconnected:', error.message); status.isConnected = false; this.isConnected = false; this.currentWorkspace = null; } } return status; } /** * Start periodic status monitoring to detect connection changes */ startStatusMonitoring() { // Clear any existing monitoring if (this.statusMonitorInterval) { clearInterval(this.statusMonitorInterval); } // Check status every 2 seconds when popup is open (more responsive) this.statusMonitorInterval = setInterval(async () => { if (this.popupPort) { try { const currentStatus = await this.getStatusWithConnectionTest(); // Check if connection status changed if (currentStatus.isConnected !== this.lastKnownConnectionState) { console.log(`๐Ÿ”„ Connection state changed: ${this.lastKnownConnectionState} -> ${currentStatus.isConnected}`); this.lastKnownConnectionState = currentStatus.isConnected; // Notify popup of status change this.notifyPopupOfStatusChange(currentStatus); } } catch (error) { console.warn('Status monitoring error:', error); // On error, assume disconnected if (this.lastKnownConnectionState !== false) { console.log('๐Ÿ”„ Status monitoring error - marking as disconnected'); this.lastKnownConnectionState = false; this.notifyPopupOfStatusChange({ isConnected: false, workspace: null, publicKey: null, pendingRequestCount: 0, serverUrl: this.defaultServerUrl }); } } } else { // Stop monitoring when popup is closed this.stopStatusMonitoring(); } }, 2000); // 2 seconds for better responsiveness } /** * Stop status monitoring */ stopStatusMonitoring() { if (this.statusMonitorInterval) { clearInterval(this.statusMonitorInterval); this.statusMonitorInterval = null; } } /** * Notify popup of connection status change * @param {Object} status - Current connection status */ notifyPopupOfStatusChange(status) { if (this.popupPort) { this.popupPort.postMessage({ type: 'CONNECTION_STATUS_CHANGED', status: status }); console.log(`๐Ÿ“ก Notified popup of connection status change: ${status.isConnected ? 'Connected' : 'Disconnected'}`); } } /** * Called when keyspace is unlocked - clean approach to show pending requests */ async onKeypaceUnlocked() { try { console.log('๐Ÿ”“ Keyspace unlocked - preparing to show pending requests'); // 1. Restore any persisted requests for this workspace await this.restorePendingRequests(); // 2. Get current requests (includes restored + any new ones) const requests = await this.getPendingRequests(); // 3. Check if we can approve requests (keyspace should be unlocked now) const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : true; // 4. Update badge with current count this.updateBadge(); // 5. Notify popup if connected if (this.popupPort) { this.popupPort.postMessage({ type: 'KEYSPACE_UNLOCKED', canApprove, pendingRequests: requests }); } console.log(`๐Ÿ”“ Keyspace unlocked: ${requests.length} requests ready, canApprove: ${canApprove}`); return requests; } catch (error) { console.error('Failed to handle keyspace unlock:', error); return []; } } } // Export for use in background script export default SigSocketService;