You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
672 lines
21 KiB
672 lines
21 KiB
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<void> | 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<void> {
|
|
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<T>(
|
|
request: Omit<WorkerRequest, "id">,
|
|
): Promise<T> {
|
|
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<T>((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<void> {
|
|
// 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<boolean>({ 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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<ImageResult> {
|
|
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<ImageResult> {
|
|
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<Blob> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<QueryExecResult | undefined> {
|
|
await this.ensureWorkerReady();
|
|
return this.sendWorkerMessage<QueryResult>({
|
|
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<unknown[] | undefined> {
|
|
await this.ensureWorkerReady();
|
|
return this.sendWorkerMessage<unknown[] | undefined>({
|
|
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<void> {
|
|
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
|
|
}
|
|
}
|
|
|