forked from trent_larson/crowd-funder-for-time-pwa
- Deleted src/registerServiceWorker.ts and all related imports - Cleaned up WebPlatformService and main.web.ts to remove manual SW logic - Updated VitePWA config for correct dev/prod SW handling - Fixed missing FontAwesome download icon in PWA prompt - Updated docs to reflect new PWA registration approach PWA now works reliably in all web environments with zero manual SW code.
673 lines
21 KiB
TypeScript
673 lines
21 KiB
TypeScript
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
|
|
}
|
|
}
|