/** * @file Capacitor Main Entry Point * @author Matthew Raymer * * This file initializes the deep linking system for the TimeSafari app. * It sets up the connection between Capacitor's URL handling and our deep link processor. * * Deep Linking Flow: * 1. Capacitor receives URL open event * 2. Event is passed to DeepLinkHandler * 3. URL is validated and processed * 4. Router navigates to appropriate view * * Integration Points: * - Capacitor App plugin for URL handling * - Vue Router for navigation * - Error handling system * - Logging system * * Type Safety: * - Uses DeepLinkHandler for type-safe parameter processing * - Ensures type safety between Capacitor events and app routing * - Maintains type checking through the entire deep link flow * * @example * // URL open event from OS * timesafari://claim/123?view=details * // Processed and routed to appropriate view with type-safe parameters */ import { initializeApp } from "./main.common"; import { App as CapacitorApp } from "@capacitor/app"; import { Capacitor } from "@capacitor/core"; import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import router from "./router"; import { handleApiError } from "./services/api"; import { AxiosError } from "axios"; import { DeepLinkHandler } from "./services/deepLinks"; import { logger, safeStringify } from "./utils/logger"; import { PlatformServiceFactory } from "./services/PlatformServiceFactory"; import { SHARED_PHOTO_BASE64_KEY } from "./libs/util"; import "./utils/safeAreaInset"; logger.log("[Capacitor] πŸš€ Starting initialization"); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); const app = initializeApp(); // Initialize API error handling for unhandled promise rejections window.addEventListener("unhandledrejection", (event) => { if (event.reason?.response) { handleApiError(event.reason, event.reason.config?.url || "unknown"); } }); const deepLinkHandler = new DeepLinkHandler(router); // Lock to prevent duplicate processing of shared images let isProcessingSharedImage = false; /** * Polls for file existence with exponential backoff * More reliable than hardcoded timeout - checks if file actually exists * * @param filePath - Path to the file to check * @param maxRetries - Maximum number of retry attempts (default: 5) * @param initialDelay - Initial delay in milliseconds (default: 100) * @returns Promise - true if file exists, false if max retries reached */ async function pollForFileExistence( filePath: string, maxRetries: number = 5, initialDelay: number = 100, ): Promise { for (let attempt = 0; attempt < maxRetries; attempt++) { try { await Filesystem.stat({ path: filePath, directory: Directory.Documents, }); // File exists return true; } catch (error) { // File doesn't exist yet, wait and retry if (attempt < maxRetries - 1) { // Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms const delay = initialDelay * Math.pow(2, attempt); logger.debug( `[Main] File not found (attempt ${attempt + 1}/${maxRetries}), waiting ${delay}ms...`, ); await new Promise((resolve) => setTimeout(resolve, delay)); } } } return false; } /** * Stores shared image data in temp database * Handles clearing old data, converting base64 to data URL, and storing * * @param base64 - Raw base64 string of the image * @param fileName - Optional filename for logging * @returns Promise */ async function storeSharedImageInTempDB( base64: string, fileName?: string, ): Promise { const platformService = PlatformServiceFactory.getInstance(); // Clear old image from temp DB first to ensure we get the new one try { await platformService.dbExec("DELETE FROM temp WHERE id = ?", [ SHARED_PHOTO_BASE64_KEY, ]); logger.debug("[Main] Cleared old shared image from temp DB"); } catch (clearError) { logger.debug( "[Main] No old image to clear (or error clearing):", clearError, ); } // Convert raw base64 to data URL format that base64ToBlob expects // base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..." // Try to detect image type from base64 or default to jpeg let mimeType = "image/jpeg"; // default if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) { // JPEG or PNG mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png"; } const dataUrl = `data:${mimeType};base64,${base64}`; // Use INSERT OR REPLACE to handle existing records await platformService.dbExec( "INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)", [SHARED_PHOTO_BASE64_KEY, dataUrl], ); logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`); } /** * Handles deep link routing for the application * Processes URLs in the format timesafari:/// * Maps incoming deep links to corresponding router paths with parameters * * @param {Object} data - Deep link data object * @param {string} data.url - The full deep link URL to process * @returns {Promise} * * @example * // Handles URLs like: * // timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H * // timesafari://project/abc123 * * @throws {Error} If URL format is invalid */ /** * Check for native shared image from iOS App Group UserDefaults * and store in temp database before routing to shared-photo view */ async function checkAndStoreNativeSharedImage(): Promise<{ success: boolean; fileName?: string; }> { // Prevent duplicate processing if (isProcessingSharedImage) { logger.debug( "[Main] ⏸️ Shared image processing already in progress, skipping", ); return { success: false }; } isProcessingSharedImage = true; try { if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") { isProcessingSharedImage = false; return { success: false }; } logger.debug("[Main] Checking for iOS shared image from App Group"); // Use Capacitor's native bridge to call the ShareImagePlugin // eslint-disable-next-line @typescript-eslint/no-explicit-any const capacitor = (window as any).Capacitor; if (!capacitor || !capacitor.Plugins) { logger.debug("[Main] Capacitor plugins not available"); isProcessingSharedImage = false; return { success: false }; } // WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge // AppDelegate writes the shared image data to a temp file, and we read it here const tempFilePath = "timesafari_shared_photo.json"; // Check if file exists first (more reliable than hardcoded timeout) const fileExists = await pollForFileExistence(tempFilePath); if (fileExists) { try { const fileContent = await Filesystem.readFile({ path: tempFilePath, directory: Directory.Documents, encoding: Encoding.UTF8, }); if (fileContent.data) { const sharedData = JSON.parse(fileContent.data as string); const base64 = sharedData.base64; const fileName = sharedData.fileName || "shared-image.jpg"; if (base64) { // Store in temp database using extracted method logger.info( "[Main] Native shared image found (via temp file), storing in temp DB", ); await storeSharedImageInTempDB(base64, fileName); // Delete the temp file immediately after reading to prevent re-reading try { await Filesystem.deleteFile({ path: tempFilePath, directory: Directory.Documents, }); logger.debug("[Main] Deleted temp file after reading"); } catch (deleteError) { logger.error("[Main] Failed to delete temp file:", deleteError); } isProcessingSharedImage = false; return { success: true, fileName }; } } } catch (fileError: unknown) { // File exists but can't be read - log and continue to plugin method logger.debug( "[Main] Temp file exists but couldn't be read, trying plugin method", ); } } else { logger.debug( "[Main] Temp file not found after polling (this is normal if plugin works)", ); } // NOTE: Plugin registration issue - ShareImage plugin is not being auto-discovered // This is a known issue that needs to be resolved. For now, we use the temp file workaround above. // Try multiple methods to call the plugin (fallback if temp file method fails) // eslint-disable-next-line @typescript-eslint/no-explicit-any const plugins = (capacitor as any).Plugins; // eslint-disable-next-line @typescript-eslint/no-explicit-any const shareImagePlugin = (plugins as any)?.ShareImage; // eslint-disable-next-line @typescript-eslint/no-explicit-any let result: any = null; if ( shareImagePlugin && typeof shareImagePlugin.getSharedImageData === "function" ) { logger.debug("[Main] Using direct plugin method"); try { result = await shareImagePlugin.getSharedImageData(); } catch (pluginError) { logger.error("[Main] Plugin call failed:", pluginError); isProcessingSharedImage = false; return { success: false }; } } else { // Method 2: Use Capacitor's execute method logger.debug( "[Main] Plugin not found directly, trying Capacitor.execute", ); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const bridge = (capacitor as any).getBridge?.(); if (bridge) { // eslint-disable-next-line @typescript-eslint/no-explicit-any result = await bridge.execute({ pluginId: "ShareImage", methodName: "getSharedImageData", options: {}, }); } else { // Method 3: Try execute on Plugins object // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((plugins as any)?.execute) { // eslint-disable-next-line @typescript-eslint/no-explicit-any result = await (plugins as any).execute( "ShareImage", "getSharedImageData", {}, ); } } } catch (executeError) { logger.error("[Main] Execute method failed:", executeError); isProcessingSharedImage = false; return { success: false }; } } if (!result || !result.success || !result.data) { logger.debug("[Main] No shared image data found in result"); isProcessingSharedImage = false; return { success: false }; } const { base64, fileName } = result.data; if (!base64) { logger.debug("[Main] Shared image data missing base64"); isProcessingSharedImage = false; return { success: false }; } logger.info("[Main] Native shared image found, storing in temp DB"); // Store in temp database using extracted method await storeSharedImageInTempDB(base64, fileName); isProcessingSharedImage = false; return { success: true, fileName: fileName || "shared-image.jpg" }; } catch (error) { logger.error("[Main] Error checking for native shared image:", error); isProcessingSharedImage = false; return { success: false }; } } const handleDeepLink = async (data: { url: string }) => { const { url } = data; logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`); try { // Handle empty path URLs from share extension (timesafari://) // These are used to open the app, and we should check for shared images const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///"; if ( isEmptyPathUrl && Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios" ) { logger.debug( "[Main] πŸ“Έ Empty path URL from share extension, checking for native shared image", ); // Try to get shared image from App Group and store in temp database // AppDelegate writes the file when the deep link is received, so we may need to retry // The checkAndStoreNativeSharedImage function now uses polling internally, so we just call it once try { const imageResult = await checkAndStoreNativeSharedImage(); if (imageResult.success) { logger.info( "[Main] βœ… Native shared image found, navigating to shared-photo", ); // Wait for router to be ready await router.isReady(); // Navigate directly to shared-photo route // Use replace if already on shared-photo to force refresh, otherwise push const fileName = imageResult.fileName || "shared-image.jpg"; const isAlreadyOnSharedPhoto = router.currentRoute.value.path === "/shared-photo"; if (isAlreadyOnSharedPhoto) { // Force refresh by replacing the route await router.replace({ path: "/shared-photo", query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update }); } else { await router.push({ path: "/shared-photo", query: { fileName }, }); } logger.info( `[Main] βœ… Navigated to /shared-photo?fileName=${fileName}`, ); return; // Exit early, don't process as deep link } else { logger.debug( "[Main] ℹ️ No native shared image found, ignoring empty path URL", ); return; // Exit early, don't process empty path as deep link } } catch (error) { logger.error("[Main] Error processing native shared image:", error); // If check fails, don't process as deep link (empty path would fail validation anyway) return; } } // Wait for router to be ready logger.debug(`[Main] ⏳ Waiting for router to be ready...`); await router.isReady(); logger.debug(`[Main] βœ… Router is ready, processing deeplink`); // Process the deeplink logger.debug(`[Main] πŸš€ Starting deeplink processing`); await deepLinkHandler.handleDeepLink(url); logger.debug(`[Main] βœ… Deeplink processed successfully`); } catch (error) { logger.error(`[Main] ❌ Deeplink processing failed:`, { url, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, timestamp: new Date().toISOString(), }); // Log additional context for debugging logger.error(`[Main] πŸ” Debug context:`, { routerReady: router.isReady(), currentRoute: router.currentRoute.value, appMounted: app._instance?.isMounted, timestamp: new Date().toISOString(), }); // Fallback to original error handling let message: string = error instanceof Error ? error.message : safeStringify(error); if (url) { message += `\nURL: ${url}`; } handleApiError({ message } as AxiosError, "deep-link"); } }; // Function to register the deeplink listener const registerDeepLinkListener = async () => { try { logger.info( `[Main] πŸ”— Attempting to register deeplink handler with Capacitor`, ); // Check if Capacitor App plugin is available logger.debug(`[Main] πŸ” Checking Capacitor App plugin availability...`); if (!CapacitorApp) { throw new Error("Capacitor App plugin not available"); } logger.info(`[Main] βœ… Capacitor App plugin is available`); // Check available methods on CapacitorApp logger.debug( `[Main] πŸ” Capacitor App plugin methods:`, Object.getOwnPropertyNames(CapacitorApp), ); logger.debug( `[Main] πŸ” Capacitor App plugin addListener method:`, typeof CapacitorApp.addListener, ); // Wait for router to be ready first await router.isReady(); logger.debug( `[Main] βœ… Router is ready, proceeding with listener registration`, ); // Try to register the listener logger.info(`[Main] πŸ§ͺ Attempting to register appUrlOpen listener...`); const listenerHandle = await CapacitorApp.addListener( "appUrlOpen", handleDeepLink, ); logger.info( `[Main] βœ… appUrlOpen listener registered successfully with handle:`, listenerHandle, ); return listenerHandle; } catch (error) { logger.error(`[Main] ❌ Failed to register deeplink listener:`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, timestamp: new Date().toISOString(), }); throw error; } }; /** * Check for shared image and navigate to shared-photo route if found * This is called when app becomes active (from share extension or app launch) */ async function checkForSharedImageAndNavigate() { if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") { return; } try { logger.debug("[Main] πŸ” Checking for shared image on app activation"); const imageResult = await checkAndStoreNativeSharedImage(); if (imageResult.success) { logger.info( "[Main] βœ… Shared image found, navigating to shared-photo route", ); // Wait for router to be ready await router.isReady(); // Navigate to shared-photo route with fileName if available // Use replace if already on shared-photo to force refresh, otherwise push const fileName = imageResult.fileName || "shared-image.jpg"; const isAlreadyOnSharedPhoto = router.currentRoute.value.path === "/shared-photo"; if (isAlreadyOnSharedPhoto) { // Force refresh by replacing the route await router.replace({ path: "/shared-photo", query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update }); } else { const route = imageResult.fileName ? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}` : "/shared-photo"; await router.push(route); } logger.info(`[Main] βœ… Navigated to /shared-photo?fileName=${fileName}`); } else { logger.debug("[Main] ℹ️ No shared image found"); } } catch (error) { logger.error("[Main] ❌ Error checking for shared image:", error); } } logger.log("[Capacitor] πŸš€ Mounting app"); app.mount("#app"); logger.info(`[Main] βœ… App mounted successfully`); // Check for shared image on initial load (in case app was launched from share extension) if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") { setTimeout(async () => { await checkForSharedImageAndNavigate(); }, 1000); // Small delay to ensure router is ready } // Listen for app state changes to detect when app becomes active if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") { CapacitorApp.addListener("appStateChange", async ({ isActive }) => { if (isActive) { logger.debug("[Main] πŸ“± App became active, checking for shared image"); await checkForSharedImageAndNavigate(); } }); } // Register deeplink listener after app is mounted setTimeout(async () => { try { logger.info( `[Main] ⏳ Delaying listener registration to ensure Capacitor is ready...`, ); await registerDeepLinkListener(); logger.info(`[Main] πŸŽ‰ Deep link system fully initialized!`); } catch (error) { logger.error(`[Main] ❌ Deep link system initialization failed:`, error); } }, 2000); // 2 second delay to ensure Capacitor is fully ready // Log app initialization status setTimeout(() => { logger.info(`[Main] πŸ“Š App initialization status:`, { routerReady: router.isReady(), currentRoute: router.currentRoute.value, appMounted: app._instance?.isMounted, timestamp: new Date().toISOString(), }); }, 1000);