forked from trent_larson/crowd-funder-for-time-pwa
- Add generateUniqueFileName() method to all platform services - Implement device ID hashing for privacy-friendly identification - Add JSON validation before file operations - Generate filenames with format: base_deviceHash_timestamp.json - Support multiple exports in same second with counter system - Ensure filenames fit within platform length limits (200 chars max) - Update saveToDevice() and saveAs() methods across all platforms Platforms updated: - CapacitorPlatformService: Mobile file operations with unique names - WebPlatformService: Browser download with device fingerprinting - ElectronPlatformService: Desktop IPC with machine identification Resolves file naming conflicts and improves debugging capabilities while maintaining user privacy through hashed device identifiers.
877 lines
27 KiB
TypeScript
877 lines
27 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
|
|
logger.info(
|
|
"[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"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
|
|
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
|
|
*
|
|
* @param fileName - The filename of the file to save
|
|
* @param content - The content to write to the file
|
|
* @returns Promise that resolves when the file is saved
|
|
*/
|
|
async saveToDevice(fileName: string, content: string): Promise<void> {
|
|
try {
|
|
// Ensure content is valid JSON
|
|
try {
|
|
JSON.parse(content);
|
|
} catch {
|
|
throw new Error('Content must be valid JSON');
|
|
}
|
|
|
|
// Generate unique filename
|
|
const uniqueFileName = this.generateUniqueFileName(fileName);
|
|
|
|
// Web platform: Use the same download mechanism as writeAndShareFile
|
|
await this.writeAndShareFile(uniqueFileName, content);
|
|
logger.log("[WebPlatformService] File saved to device", uniqueFileName);
|
|
} catch (error) {
|
|
logger.error("[WebPlatformService] Error saving file to device", error);
|
|
throw new Error(
|
|
`Failed to save file to device: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the system file picker to let the user choose where to save a file (web platform).
|
|
* Uses the browser's download mechanism with a suggested filename.
|
|
*
|
|
* @param fileName - The suggested filename for the file
|
|
* @param content - The content to write to the file
|
|
* @returns Promise that resolves when the file is saved
|
|
*/
|
|
async saveAs(fileName: string, content: string): Promise<void> {
|
|
try {
|
|
// Ensure content is valid JSON
|
|
try {
|
|
JSON.parse(content);
|
|
} catch {
|
|
throw new Error('Content must be valid JSON');
|
|
}
|
|
|
|
// Generate unique filename
|
|
const uniqueFileName = this.generateUniqueFileName(fileName);
|
|
|
|
// Web platform: Use the same download mechanism as writeAndShareFile
|
|
await this.writeAndShareFile(uniqueFileName, content);
|
|
logger.log("[WebPlatformService] File saved as", uniqueFileName);
|
|
} catch (error) {
|
|
logger.error("[WebPlatformService] Error saving file as", error);
|
|
throw new Error(
|
|
`Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates unique filename with timestamp, hashed device ID, and counter
|
|
*/
|
|
private generateUniqueFileName(baseName: string, counter = 0): string {
|
|
const now = new Date();
|
|
const timestamp = now.toISOString()
|
|
.replace(/[:.]/g, '-')
|
|
.replace('T', '_')
|
|
.replace('Z', '');
|
|
|
|
const deviceIdHash = this.getHashedDeviceIdentifier();
|
|
const counterSuffix = counter > 0 ? `_${counter}` : '';
|
|
|
|
const maxBaseLength = 45;
|
|
const truncatedBase = baseName.length > maxBaseLength
|
|
? baseName.substring(0, maxBaseLength)
|
|
: baseName;
|
|
|
|
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
|
|
const extension = '.json';
|
|
const devicePart = `_${deviceIdHash}`;
|
|
const timestampPart = `_${timestamp}${counterSuffix}`;
|
|
|
|
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
|
|
|
|
if (totalLength > 200) {
|
|
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
|
|
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
|
|
return `${finalBase}${devicePart}${timestampPart}${extension}`;
|
|
}
|
|
|
|
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
|
|
}
|
|
|
|
/**
|
|
* Gets hashed device identifier
|
|
*/
|
|
private getHashedDeviceIdentifier(): string {
|
|
try {
|
|
const deviceInfo = this.getDeviceInfo();
|
|
return this.hashString(deviceInfo);
|
|
} catch (error) {
|
|
return 'web';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets device info string
|
|
*/
|
|
private getDeviceInfo(): string {
|
|
try {
|
|
// Use browser fingerprint or fallback
|
|
const userAgent = navigator.userAgent;
|
|
const language = navigator.language || 'unknown';
|
|
const platform = navigator.platform || 'unknown';
|
|
|
|
let browser = 'unknown';
|
|
let os = 'unknown';
|
|
|
|
if (userAgent.includes('Chrome')) browser = 'chrome';
|
|
else if (userAgent.includes('Firefox')) browser = 'firefox';
|
|
else if (userAgent.includes('Safari')) browser = 'safari';
|
|
else if (userAgent.includes('Edge')) browser = 'edge';
|
|
|
|
if (userAgent.includes('Windows')) os = 'win';
|
|
else if (userAgent.includes('Mac')) os = 'mac';
|
|
else if (userAgent.includes('Linux')) os = 'linux';
|
|
else if (userAgent.includes('Android')) os = 'android';
|
|
else if (userAgent.includes('iOS')) os = 'ios';
|
|
|
|
return `${browser}_${os}_${platform}_${language}`;
|
|
} catch (error) {
|
|
return 'web';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simple hash function for device ID
|
|
*/
|
|
private hashString(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash;
|
|
}
|
|
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
|
|
}
|
|
|
|
/**
|
|
* @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");
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
// Database utility methods
|
|
generateInsertStatement(
|
|
model: Record<string, unknown>,
|
|
tableName: string,
|
|
): { sql: string; params: unknown[] } {
|
|
const keys = Object.keys(model);
|
|
const placeholders = keys.map(() => "?").join(", ");
|
|
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
const params = keys.map((key) => model[key]);
|
|
return { sql, params };
|
|
}
|
|
|
|
async updateDefaultSettings(
|
|
settings: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const keys = Object.keys(settings);
|
|
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
|
const sql = `UPDATE settings SET ${setClause} WHERE id = 1`;
|
|
const params = keys.map((key) => settings[key]);
|
|
await this.dbExec(sql, params);
|
|
}
|
|
|
|
async insertDidSpecificSettings(did: string): Promise<void> {
|
|
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
|
|
}
|
|
|
|
async updateDidSpecificSettings(
|
|
did: string,
|
|
settings: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const keys = Object.keys(settings);
|
|
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
|
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
|
|
const params = [...keys.map((key) => settings[key]), did];
|
|
// Log update operation for debugging
|
|
logger.debug(
|
|
"[WebPlatformService] updateDidSpecificSettings",
|
|
sql,
|
|
JSON.stringify(params, null, 2),
|
|
);
|
|
await this.dbExec(sql, params);
|
|
}
|
|
|
|
async retrieveSettingsForActiveAccount(): Promise<Record<
|
|
string,
|
|
unknown
|
|
> | null> {
|
|
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1");
|
|
if (result?.values?.[0]) {
|
|
// Convert the row to an object
|
|
const row = result.values[0];
|
|
const columns = result.columns || [];
|
|
const settings: Record<string, unknown> = {};
|
|
|
|
columns.forEach((column, index) => {
|
|
if (column !== "id") {
|
|
// Exclude the id column
|
|
settings[column] = row[index];
|
|
}
|
|
});
|
|
|
|
return settings;
|
|
}
|
|
return null;
|
|
}
|
|
}
|