feat: Implement SigSocket request queuing and approval system, Enhance Settings UI

This commit is contained in:
zaelgohary
2025-06-17 03:18:25 +03:00
parent c641d0ae2e
commit 4f3f98a954
7 changed files with 1567 additions and 220 deletions

View File

@@ -25,6 +25,10 @@ class SigSocketService {
// UI communication
this.popupPort = null;
// Status monitoring
this.statusMonitorInterval = null;
this.lastKnownConnectionState = false;
}
/**
@@ -44,72 +48,221 @@ class SigSocketService {
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<boolean>} - True if connected successfully
*/
async connectToServer(workspaceId) {
try {
if (!this.wasmModule?.SigSocketManager) {
throw new Error('WASM SigSocketManager not available');
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.`);
}
}
}
console.log(`🔗 Requesting SigSocket connection for workspace: ${workspaceId}`);
// 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 = info.workspace;
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
});
// Update badge to show current state
this.updateBadge();
return this.isConnected;
} catch (error) {
console.error('❌ SigSocket connection failed:', error);
this.isConnected = false;
this.currentWorkspace = null;
this.connectedPublicKey = null;
return false;
}
return false;
}
/**
* Handle events from the WASM SigSocket client
* This is called automatically when requests arrive
* @param {Object} event - Event from WASM layer
*/
handleSigSocketEvent(event) {
async handleSigSocketEvent(event) {
console.log('📨 Received SigSocket event:', event);
if (event.type === 'sign_request') {
console.log(`🔐 New sign request: ${event.request_id}`);
console.log(`🔐 New sign request: ${event.request_id} for workspace: ${this.currentWorkspace}`);
// The request is automatically stored by WASM
// We just handle UI updates
this.showSignRequestNotification();
this.updateBadge();
this.notifyPopupOfNewRequest();
// 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);
}
}
}
@@ -124,6 +277,19 @@ class SigSocketService {
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
@@ -131,14 +297,37 @@ class SigSocketService {
console.log(`🎉 Request approved successfully: ${requestId}`);
// Update UI
// 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;
}
}
@@ -162,10 +351,17 @@ class SigSocketService {
console.log(`✅ Request rejected successfully: ${requestId}`);
// Update UI
// 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) {
@@ -224,18 +420,54 @@ class SigSocketService {
}
/**
* Show notification for new sign request
* Show clickable notification for new sign request
* Call this AFTER the request has been stored and persisted
*/
showSignRequestNotification() {
async showSignRequestNotification() {
try {
if (chrome.notifications && chrome.notifications.create) {
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: 'SigSocket Sign Request',
message: 'New signature request received. Click to review.'
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}`);
}
});
console.log('📢 Notification shown for sign request');
} else {
console.log('📢 Notifications not available, skipping notification');
}
@@ -322,6 +554,10 @@ class SigSocketService {
this.isConnected = false;
this.currentWorkspace = null;
this.connectedPublicKey = null;
this.lastKnownConnectionState = false;
// Stop status monitoring
this.stopStatusMonitoring();
this.updateBadge();
@@ -333,7 +569,50 @@ class SigSocketService {
}
/**
* Get connection status from WASM
* 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<Object>} - Connection status information
*/
async getStatus() {
@@ -348,21 +627,63 @@ class SigSocketService {
};
}
// Let WASM provide the authoritative status
// Get WASM status first
const statusJson = await this.wasmModule.SigSocketManager.get_connection_status();
const status = JSON.parse(statusJson);
const requests = await this.getPendingRequests();
return {
isConnected: status.is_connected,
workspace: status.workspace,
publicKey: status.public_key,
// 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
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,
@@ -375,33 +696,178 @@ class SigSocketService {
/**
* Set the popup port for communication
* @param {chrome.runtime.Port} port - The popup port
* @param {chrome.runtime.Port|null} port - The popup port or null to disconnect
*/
setPopupPort(port) {
this.popupPort = port;
console.log('📱 Popup connected to SigSocket service');
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();
}
}
/**
* Called when keyspace is unlocked - notify popup of current state
* 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() {
if (!this.popupPort) return;
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();
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false;
this.popupPort.postMessage({
type: 'KEYSPACE_UNLOCKED',
canApprove,
pendingRequests: requests
});
// 3. Check if we can approve requests (keyspace should be unlocked now)
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : true;
console.log(`🔓 Keyspace unlocked notification sent: ${requests.length} requests, canApprove: ${canApprove}`);
// 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 [];
}
}
}