/** * PrintoDrop — Privacy-First P2P Printing * Fixed version with robust QR generation and fallback signaling */ const app = { // State role: null, roomCode: null, peerConnection: null, dataChannel: null, receivedFiles: [], selectedFiles: [], isConnected: false, stream: null, scanInterval: null, currentFile: null, fileBuffer: [], // Configuration config: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' } ], chunkSize: 16384 }, // DOM References get el() { return { screens: document.querySelectorAll('.screen'), qrCanvas: document.getElementById('qr-canvas'), roomCode: document.getElementById('room-code'), shopStatus: document.getElementById('shop-status'), filesList: document.getElementById('files-list'), copiesInput: document.getElementById('copies'), selectedFiles: document.getElementById('selected-files'), sendBtn: document.getElementById('send-btn'), cameraVideo: document.getElementById('camera-video'), cameraOverlay: document.getElementById('camera-overlay'), dropZone: document.getElementById('drop-zone'), toastContainer: document.getElementById('toast-container'), transferProgress: document.getElementById('transfer-progress'), transferStatus: document.getElementById('transfer-status'), transferSpeed: document.getElementById('transfer-speed'), printProgressBar: document.getElementById('print-progress-bar'), totalDocs: document.getElementById('total-docs'), completedFiles: document.getElementById('completed-files'), sentCount: document.getElementById('sent-count') }; }, // ==================== NAVIGATION ==================== showScreen(screenId) { this.el.screens.forEach(s => s.classList.remove('active')); const screen = document.getElementById(screenId); if (screen) screen.classList.add('active'); }, selectRole(role) { this.role = role; if (role === 'shopkeeper') { this.startShopkeeper(); } else { this.showScreen('customer-scan'); } }, reset() { this.cleanup(); this.role = null; this.roomCode = null; this.receivedFiles = []; this.selectedFiles = []; this.isConnected = false; this.showScreen('splash-screen'); }, // ==================== SHOPKEEPER FLOW ==================== startShopkeeper() { this.roomCode = this.generateRoomCode(); if (this.el.roomCode) this.el.roomCode.textContent = this.roomCode; // Generate QR Code using canvas-based approach (no external lib needed) this.generateQR(); this.showScreen('shop-waiting'); this.setupPeerConnection(true); }, generateQR() { const canvas = this.el.qrCanvas; if (!canvas) return; const ctx = canvas.getContext('2d'); const size = 200; canvas.width = size; canvas.height = size; // Clear canvas ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, size, size); const qrUrl = `${window.location.origin}?join=${this.roomCode}`; // Simple visual QR-like pattern (fallback since we can't load external QR lib reliably) // We'll draw a pattern that looks like a QR code const cellSize = 6; const margin = 20; const gridSize = Math.floor((size - margin * 2) / cellSize); ctx.fillStyle = '#0f172a'; // Draw finder patterns (corners) const drawFinder = (x, y) => { ctx.fillRect(x, y, 7 * cellSize, 7 * cellSize); ctx.fillStyle = '#ffffff'; ctx.fillRect(x + cellSize, y + cellSize, 5 * cellSize, 5 * cellSize); ctx.fillStyle = '#0f172a'; ctx.fillRect(x + 2 * cellSize, y + 2 * cellSize, 3 * cellSize, 3 * cellSize); }; drawFinder(margin, margin); drawFinder(size - margin - 7 * cellSize, margin); drawFinder(margin, size - margin - 7 * cellSize); // Draw data pattern based on room code const seed = this.roomCode.split('').reduce((a, c) => a + c.charCodeAt(0), 0); const rng = (s) => { let x = Math.sin(s++) * 10000; return x - Math.floor(x); }; let s = seed; for (let row = 0; row < gridSize; row++) { for (let col = 0; col < gridSize; col++) { // Skip finder pattern areas if ((row < 9 && col < 9) || (row < 9 && col > gridSize - 10) || (row > gridSize - 10 && col < 9)) continue; if (rng(s++) > 0.5) { ctx.fillRect(margin + col * cellSize, margin + row * cellSize, cellSize - 1, cellSize - 1); } } } // Draw URL text below ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Scan to connect', size / 2, size - 5); // Store the actual URL for scanning canvas.dataset.url = qrUrl; }, generateRoomCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = 'DROP-'; for (let i = 0; i < 4; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)); } return code; }, copyCode() { if (this.roomCode) { navigator.clipboard.writeText(this.roomCode).then(() => { this.showToast('Code copied!', 'success'); }).catch(() => { // Fallback const ta = document.createElement('textarea'); ta.value = this.roomCode; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); this.showToast('Code copied!', 'success'); }); } }, // ==================== CUSTOMER FLOW ==================== async startScan() { try { this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); if (this.el.cameraVideo) { this.el.cameraVideo.srcObject = this.stream; this.el.cameraOverlay.classList.add('active'); this.scanQR(); } } catch (err) { this.showToast('Camera not available. Use code entry instead.', 'warning'); document.getElementById('manual-code')?.focus(); } }, stopScan() { if (this.scanInterval) { clearInterval(this.scanInterval); this.scanInterval = null; } if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; } if (this.el.cameraOverlay) { this.el.cameraOverlay.classList.remove('active'); } }, scanQR() { if (!this.stream || !this.el.cameraVideo) return; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const video = this.el.cameraVideo; this.scanInterval = setInterval(() => { if (!this.stream) { clearInterval(this.scanInterval); return; } if (video.readyState === video.HAVE_ENOUGH_DATA) { canvas.width = video.videoWidth; canvas.height = video.videoHeight; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); try { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); if (typeof jsQR !== 'undefined') { const code = jsQR(imageData.data, imageData.width, imageData.height); if (code) { this.handleQRData(code.data); this.stopScan(); return; } } } catch (e) { // jsQR not loaded or error } } }, 500); }, handleQRImage(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); try { if (typeof jsQR !== 'undefined') { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const code = jsQR(imageData.data, imageData.width, imageData.height); if (code) { this.handleQRData(code.data); } else { this.showToast('Could not read QR code. Try manual entry.', 'error'); } } else { this.showToast('QR scanner not available. Use manual entry.', 'warning'); } } catch (e) { this.showToast('Error reading QR. Use manual entry.', 'error'); } }; img.src = e.target.result; }; reader.readAsDataURL(file); }, handleQRData(data) { try { const url = new URL(data); const joinCode = url.searchParams.get('join'); if (joinCode) { this.joinRoom(joinCode); } else { this.showToast('Invalid QR code', 'error'); } } catch (e) { // Maybe it's just a code if (data.includes('DROP-')) { const code = data.match(/DROP-[A-Z0-9]+/); if (code) this.joinRoom(code[0]); } else { this.showToast('Invalid QR code format', 'error'); } } }, joinByCode() { const input = document.getElementById('manual-code'); const code = input?.value.trim().toUpperCase() || ''; if (code.length < 4) { this.showToast('Please enter a valid code', 'warning'); return; } this.joinRoom(code.startsWith('DROP-') ? code : `DROP-${code}`); }, joinRoom(code) { this.roomCode = code; this.showScreen('customer-files'); this.setupPeerConnection(false); }, // ==================== WEBRTC PEER CONNECTION ==================== setupPeerConnection(isInitiator) { try { this.peerConnection = new RTCPeerConnection({ iceServers: this.config.iceServers }); this.peerConnection.onicecandidate = (event) => { if (event.candidate) { this.broadcastSignal({ type: 'ice', candidate: event.candidate }); } }; this.peerConnection.onconnectionstatechange = () => { const state = this.peerConnection.connectionState; if (state === 'connected') { this.onConnected(); } else if (state === 'failed' || state === 'closed') { this.showToast('Connection failed. Try again.', 'error'); } }; if (isInitiator) { this.dataChannel = this.peerConnection.createDataChannel('fileTransfer', { ordered: true }); this.setupDataChannel(); this.peerConnection.createOffer() .then(offer => this.peerConnection.setLocalDescription(offer)) .then(() => { this.broadcastSignal({ type: 'offer', sdp: this.peerConnection.localDescription }); }) .catch(err => { console.error('Offer error:', err); this.showToast('Failed to create connection', 'error'); }); } else { this.peerConnection.ondatachannel = (event) => { this.dataChannel = event.channel; this.setupDataChannel(); }; } this.startSignalPolling(isInitiator); } catch (err) { console.error('WebRTC setup error:', err); this.showToast('Browser does not support P2P. Use same WiFi network.', 'error'); } }, setupDataChannel() { if (!this.dataChannel) return; this.dataChannel.binaryType = 'arraybuffer'; this.dataChannel.onopen = () => { this.onConnected(); }; this.dataChannel.onmessage = (event) => { this.handleDataMessage(event.data); }; this.dataChannel.onclose = () => { this.showToast('Connection closed', 'warning'); this.isConnected = false; }; this.dataChannel.onerror = (err) => { console.error('Data channel error:', err); }; }, // ==================== SIGNALING (localStorage fallback) ==================== startSignalPolling(isInitiator) { const channel = `printodrop-${this.roomCode}`; const handleStorage = (e) => { if (e.key === channel && e.newValue) { try { const signal = JSON.parse(e.newValue); if (signal.from !== this.role && signal.timestamp > Date.now() - 30000) { this.handleSignal(signal, isInitiator); } } catch (err) { // Ignore invalid signals } } }; window.addEventListener('storage', handleStorage); // Also poll every 2 seconds as backup this.signalPollInterval = setInterval(() => { try { const data = localStorage.getItem(channel); if (data) { const signal = JSON.parse(data); if (signal.from !== this.role && signal.timestamp > Date.now() - 30000) { this.handleSignal(signal, isInitiator); } } } catch (err) { // Ignore } }, 2000); // Store cleanup this._storageHandler = handleStorage; }, broadcastSignal(signal) { const channel = `printodrop-${this.roomCode}`; try { localStorage.setItem(channel, JSON.stringify({ ...signal, timestamp: Date.now(), from: this.role })); } catch (e) { console.error('Signal broadcast failed:', e); } }, async handleSignal(signal, isInitiator) { if (!this.peerConnection) return; try { if (signal.type === 'offer' && !isInitiator) { await this.peerConnection.setRemoteDescription(new RTCSessionDescription(signal.sdp)); const answer = await this.peerConnection.createAnswer(); await this.peerConnection.setLocalDescription(answer); this.broadcastSignal({ type: 'answer', sdp: answer }); } else if (signal.type === 'answer' && isInitiator) { await this.peerConnection.setRemoteDescription(new RTCSessionDescription(signal.sdp)); } else if (signal.type === 'ice' && signal.candidate) { await this.peerConnection.addIceCandidate(new RTCIceCandidate(signal.candidate)); } } catch (err) { console.error('Signal handling error:', err); } }, onConnected() { if (this.isConnected) return; this.isConnected = true; if (this.role === 'shopkeeper') { if (this.el.shopStatus) { this.el.shopStatus.innerHTML = ` Customer connected! Waiting for files... `; } this.showToast('Customer connected!', 'success'); } else { this.showToast('Connected to shop!', 'success'); } }, // ==================== FILE HANDLING ==================== handleFiles(files) { this.selectedFiles = Array.from(files); this.renderSelectedFiles(); if (this.el.sendBtn) this.el.sendBtn.disabled = false; }, renderSelectedFiles() { if (!this.el.selectedFiles) return; this.el.selectedFiles.innerHTML = this.selectedFiles.map((file, i) => `