import { ImageResult, PlatformService, PlatformCapabilities, } from "../PlatformService"; import { logger } from "../../utils/logger"; import { QueryExecResult } from "@/interfaces/database"; // Dynamic import of initBackend to prevent worker context errors import type { WorkerRequest, WorkerResponse, QueryRequest, ExecRequest, QueryResult, GetOneRowRequest, } from "@/interfaces/worker-messages"; /** * Platform service implementation for web browser platform. * Implements the PlatformService interface with web-specific functionality. * * @remarks * This service provides web-based implementations for: * - Image capture using the browser's file input * - Image selection from local filesystem * - Image processing and conversion * - Database operations via worker thread messaging * * Note: File system operations are not available in the web platform * due to browser security restrictions. These methods throw appropriate errors. */ export class WebPlatformService implements PlatformService { private static instanceCount = 0; // Debug counter private worker: Worker | null = null; private workerReady = false; private workerInitPromise: Promise | null = null; private pendingMessages = new Map< string, { resolve: (_value: unknown) => void; reject: (_reason: unknown) => void; timeout: NodeJS.Timeout; } >(); private messageIdCounter = 0; private readonly messageTimeout = 30000; // 30 seconds constructor() { WebPlatformService.instanceCount++; // Use debug level logging for development mode to reduce console noise const isDevelopment = process.env.VITE_PLATFORM === "development"; const log = isDevelopment ? logger.debug : logger.log; log("[WebPlatformService] Initializing web platform service"); // Only initialize SharedArrayBuffer setup for web platforms if (this.isWorker()) { log("[WebPlatformService] Skipping initBackend call in worker context"); return; } // Initialize shared array buffer for main thread this.initSharedArrayBuffer(); // Start worker initialization but don't await it in constructor this.workerInitPromise = this.initializeWorker(); } /** * Initialize the SQL worker for database operations */ private async initializeWorker(): Promise { try { this.worker = new Worker( new URL("../../registerSQLWorker.js", import.meta.url), { type: "module" }, ); // This is required for Safari compatibility with nested workers // It installs a handler that proxies web worker creation through the main thread // CRITICAL: Only call initBackend from main thread, not from worker context const isMainThread = typeof window !== "undefined"; if (isMainThread) { // We're in the main thread - safe to dynamically import and call initBackend try { const { initBackend } = await import( "absurd-sql/dist/indexeddb-main-thread" ); initBackend(this.worker); } catch (error) { logger.error( "[WebPlatformService] Failed to import/call initBackend:", error, ); throw error; } } else { // We're in a worker context - skip initBackend call // Use console for critical startup message to avoid circular dependency // eslint-disable-next-line no-console console.log( "[WebPlatformService] Skipping initBackend call in worker context", ); } this.worker.onmessage = (event) => { this.handleWorkerMessage(event.data); }; this.worker.onerror = (error) => { logger.error("[WebPlatformService] Worker error:", error); this.workerReady = false; }; // Send ping to verify worker is ready await this.sendWorkerMessage({ type: "ping" }); this.workerReady = true; } catch (error) { logger.error("[WebPlatformService] Failed to initialize worker:", error); this.workerReady = false; this.workerInitPromise = null; throw new Error("Failed to initialize database worker"); } } /** * Handle messages received from the worker */ private handleWorkerMessage(message: WorkerResponse): void { const { id, type } = message; // Handle absurd-sql internal messages (these are normal, don't log) if (!id && message.type?.startsWith("__absurd:")) { return; // Internal absurd-sql message, ignore silently } if (!id) { logger.warn("[WebPlatformService] Received message without ID:", message); return; } const pending = this.pendingMessages.get(id); if (!pending) { logger.warn( "[WebPlatformService] Received response for unknown message ID:", id, ); return; } // Clear timeout and remove from pending clearTimeout(pending.timeout); this.pendingMessages.delete(id); switch (type) { case "success": pending.resolve(message.data); break; case "error": { const error = new Error(message.error.message); if (message.error.stack) { error.stack = message.error.stack; } pending.reject(error); break; } case "init-complete": pending.resolve(true); break; case "pong": pending.resolve(true); break; default: logger.warn("[WebPlatformService] Unknown response type:", type); pending.resolve(message); break; } } /** * Send a message to the worker and wait for response */ private async sendWorkerMessage( request: Omit, ): Promise { if (!this.worker) { throw new Error("Worker not initialized"); } const id = `msg_${++this.messageIdCounter}_${Date.now()}`; const fullRequest: WorkerRequest = { id, ...request } as WorkerRequest; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingMessages.delete(id); reject(new Error(`Worker message timeout for ${request.type} (${id})`)); }, this.messageTimeout); this.pendingMessages.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout, }); this.worker!.postMessage(fullRequest); }); } /** * Wait for worker to be ready */ private async ensureWorkerReady(): Promise { // Wait for initial initialization to complete if (this.workerInitPromise) { await this.workerInitPromise; } if (this.workerReady) { return; } // Try to ping the worker if not ready try { await this.sendWorkerMessage({ type: "ping" }); this.workerReady = true; } catch (error) { logger.error("[WebPlatformService] Worker not ready:", error); throw new Error("Database worker not ready"); } } /** * Gets the capabilities of the web platform * @returns Platform capabilities object */ getCapabilities(): PlatformCapabilities { return { hasFileSystem: false, hasCamera: true, // Through file input with capture isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent), isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), hasFileDownload: true, needsFileHandlingInstructions: false, isNativeApp: false, // Web is not a native app }; } /** * Not supported in web platform. * @param _path - Unused path parameter * @throws Error indicating file system access is not available */ async readFile(_path: string): Promise { throw new Error("File system access not available in web platform"); } /** * Not supported in web platform. * @param _path - Unused path parameter * @param _content - Unused content parameter * @throws Error indicating file system access is not available */ async writeFile(_path: string, _content: string): Promise { throw new Error("File system access not available in web platform"); } /** * Not supported in web platform. * @param _path - Unused path parameter * @throws Error indicating file system access is not available */ async deleteFile(_path: string): Promise { throw new Error("File system access not available in web platform"); } /** * Not supported in web platform. * @param _directory - Unused directory parameter * @throws Error indicating file system access is not available */ async listFiles(_directory: string): Promise { throw new Error("File system access not available in web platform"); } /** * Opens the device camera for photo capture on desktop browsers using getUserMedia. * On mobile browsers, uses file input with capture attribute. * Falls back to file input if getUserMedia is not available or fails. * * @returns Promise resolving to the captured image data */ async takePicture(): Promise { const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const hasGetUserMedia = !!( navigator.mediaDevices && navigator.mediaDevices.getUserMedia ); // If on mobile, use file input with capture attribute (existing behavior) if (isMobile || !hasGetUserMedia) { return new Promise((resolve, reject) => { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.capture = "environment"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { try { const blob = await this.processImageFile(file); resolve({ blob, fileName: file.name || "photo.jpg", }); } catch (error) { logger.error("Error processing camera image:", error); reject(new Error("Failed to process camera image")); } } else { reject(new Error("No image captured")); } }; input.click(); }); } // Desktop: Use getUserMedia for webcam capture return new Promise((resolve, reject) => { let stream: MediaStream | null = null; let video: HTMLVideoElement | null = null; let captureButton: HTMLButtonElement | null = null; let overlay: HTMLDivElement | null = null; const cleanup = () => { if (stream) { stream.getTracks().forEach((track) => track.stop()); } if (video && video.parentNode) video.parentNode.removeChild(video); if (captureButton && captureButton.parentNode) captureButton.parentNode.removeChild(captureButton); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); }; // Move async operations inside Promise body navigator.mediaDevices .getUserMedia({ video: { facingMode: "user" }, }) .then((mediaStream) => { stream = mediaStream; // Create overlay for video and button overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.top = "0"; overlay.style.left = "0"; overlay.style.width = "100vw"; overlay.style.height = "100vh"; overlay.style.background = "rgba(0,0,0,0.8)"; overlay.style.display = "flex"; overlay.style.flexDirection = "column"; overlay.style.justifyContent = "center"; overlay.style.alignItems = "center"; overlay.style.zIndex = "9999"; video = document.createElement("video"); video.autoplay = true; video.playsInline = true; video.style.maxWidth = "90vw"; video.style.maxHeight = "70vh"; video.srcObject = stream; overlay.appendChild(video); captureButton = document.createElement("button"); captureButton.textContent = "Capture Photo"; captureButton.style.marginTop = "2rem"; captureButton.style.padding = "1rem 2rem"; captureButton.style.fontSize = "1.2rem"; captureButton.style.background = "#2563eb"; captureButton.style.color = "white"; captureButton.style.border = "none"; captureButton.style.borderRadius = "0.5rem"; captureButton.style.cursor = "pointer"; overlay.appendChild(captureButton); document.body.appendChild(overlay); captureButton.onclick = () => { try { // Create a canvas to capture the frame const canvas = document.createElement("canvas"); canvas.width = video!.videoWidth; canvas.height = video!.videoHeight; const ctx = canvas.getContext("2d"); ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height); canvas.toBlob( (blob) => { cleanup(); if (blob) { resolve({ blob, fileName: `photo_${Date.now()}.jpg`, }); } else { reject(new Error("Failed to capture image from webcam")); } }, "image/jpeg", 0.95, ); } catch (err) { cleanup(); reject(err); } }; }) .catch((error) => { cleanup(); logger.error("Error accessing webcam:", error); // Fallback to file input const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { this.processImageFile(file) .then((blob) => { resolve({ blob, fileName: file.name || "photo.jpg", }); }) .catch((error) => { logger.error("Error processing fallback image:", error); reject(new Error("Failed to process fallback image")); }); } else { reject(new Error("No image selected")); } }; input.click(); }); }); } /** * Opens a file input dialog for selecting an image file. * Creates a temporary file input element to access local files. * * @returns Promise resolving to the selected image data * @throws Error if image processing fails or no image is selected * * @remarks * Allows selection of any image file type. * Processes the selected image to ensure consistent format. */ async pickImage(): Promise { return new Promise((resolve, reject) => { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { try { const blob = await this.processImageFile(file); resolve({ blob, fileName: file.name || "photo.jpg", }); } catch (error) { logger.error("Error processing picked image:", error); reject(new Error("Failed to process picked image")); } } else { reject(new Error("No image selected")); } }; input.click(); }); } /** * Processes an image file to ensure consistent format. * Converts the file to a data URL and then to a Blob. * * @param file - The image File object to process * @returns Promise resolving to processed image Blob * @throws Error if file reading or conversion fails * * @remarks * This method ensures consistent image format across different * input sources by converting through data URL to Blob. */ private async processImageFile(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { const dataUrl = event.target?.result as string; // Convert to blob to ensure consistent format fetch(dataUrl) .then((res) => res.blob()) .then((blob) => resolve(blob)) .catch((error) => { logger.error("Error converting data URL to blob:", error); reject(error); }); }; reader.onerror = (error) => { logger.error("Error reading file:", error); reject(error); }; reader.readAsDataURL(file); }); } /** * Checks if running on Capacitor platform. * @returns false, as this is not Capacitor */ isCapacitor(): boolean { return false; } /** * Checks if running on Electron platform. * @returns false, as this is not Electron */ isElectron(): boolean { return false; } /** * Checks if running on web platform. * @returns true, as this is the web implementation */ isWeb(): boolean { return true; } /** * Handles deep link URLs in the web platform. * Deep links are handled through URL parameters in the web environment. * * @param _url - The deep link URL to handle (unused in web implementation) * @returns Promise that resolves immediately as web handles URLs naturally */ async handleDeepLink(_url: string): Promise { // Web platform can handle deep links through URL parameters return Promise.resolve(); } /** * Downloads a file in the web platform using blob URLs and download links. * Creates a temporary download link and triggers the browser's download mechanism. * @param fileName - The name of the file to download * @param content - The content to write to the file * @returns Promise that resolves when the download is initiated */ async writeAndShareFile(fileName: string, content: string): Promise { try { // Create a blob with the content const blob = new Blob([content], { type: "application/json" }); // Create a temporary download link const url = URL.createObjectURL(blob); const downloadLink = document.createElement("a"); downloadLink.href = url; downloadLink.download = fileName; downloadLink.style.display = "none"; // Add to DOM, click, and remove document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); // Clean up the object URL after a short delay setTimeout(() => URL.revokeObjectURL(url), 1000); logger.log("[WebPlatformService] File download initiated:", fileName); } catch (error) { logger.error("[WebPlatformService] Error downloading file:", error); throw new Error( `Failed to download file: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } /** * @see PlatformService.dbQuery */ async dbQuery( sql: string, params?: unknown[], ): Promise { await this.ensureWorkerReady(); return this.sendWorkerMessage({ type: "query", sql, params, } as QueryRequest).then((result) => result.result[0]); } /** * @see PlatformService.dbExec */ async dbExec( sql: string, params?: unknown[], ): Promise<{ changes: number; lastId?: number }> { await this.ensureWorkerReady(); return this.sendWorkerMessage<{ changes: number; lastId?: number }>({ type: "exec", sql, params, } as ExecRequest); } async dbGetOneRow( sql: string, params?: unknown[], ): Promise { await this.ensureWorkerReady(); return this.sendWorkerMessage({ type: "getOneRow", sql, params, } as GetOneRowRequest); } /** * Rotates the camera between front and back cameras. * @returns Promise that resolves when the camera is rotated * @throws Error indicating camera rotation is not implemented in web platform */ async rotateCamera(): Promise { throw new Error("Camera rotation not implemented in web platform"); } // --- PWA/Web-only methods --- public registerServiceWorker(): void { // PWA service worker is automatically registered by VitePWA plugin // No manual registration needed } public get isPWAEnabled(): boolean { // PWA is always enabled for web platform return true; } /** * Checks if running in a worker context */ private isWorker(): boolean { return typeof window === "undefined"; } /** * Initialize SharedArrayBuffer setup (handled by initBackend in initializeWorker) */ private initSharedArrayBuffer(): void { // SharedArrayBuffer initialization is handled by initBackend call in initializeWorker } }