/** * @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 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 { SharedImage } from "./plugins/SharedImagePlugin"; import "./utils/safeAreaInset"; // Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery) import "@timesafari/daily-notification-plugin"; 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; /** * 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 using SharedImage plugin * Reads from native layer (App Group UserDefaults on iOS, SharedPreferences on Android) * and stores 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" && Capacitor.getPlatform() !== "android") ) { isProcessingSharedImage = false; return { success: false }; } // Use SharedImage plugin to get shared image data directly from native layer // No file I/O or polling needed - direct native-to-JS communication let result; try { result = await SharedImage.getSharedImage(); } catch (error) { logger.error("[Main] Error calling SharedImage.getSharedImage():", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); isProcessingSharedImage = false; return { success: false }; } // Check if we have valid image data (base64 must be non-null and non-empty) if (result && result.base64 && result.base64.trim().length > 0) { const fileName = result.fileName || "shared-image.jpg"; // Store in temp database using extracted method logger.info( "[Main] Native shared image found (via plugin), storing in temp DB", ); await storeSharedImageInTempDB(result.base64, fileName); isProcessingSharedImage = false; return { success: true, fileName }; } // No shared image found logger.debug("[Main] No shared image found via plugin"); isProcessingSharedImage = false; return { success: false }; } 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" && Capacitor.getPlatform() !== "android") ) { 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 sheet) // On Android, share intents are processed in MainActivity.onCreate, so we need to check // after a delay to ensure the native code has finished processing if ( Capacitor.isNativePlatform() && (Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android") ) { // Use multiple checks with increasing delays to handle timing issues // Android share intent processing happens in onCreate, which may complete after JS loads const checkDelays = Capacitor.getPlatform() === "android" ? [500, 1500, 3000] // Android needs more time for share intent processing : [1000]; // iOS is faster checkDelays.forEach((delay) => { setTimeout(async () => { await checkForSharedImageAndNavigate(); }, delay); }); } // Listen for app state changes to detect when app becomes active if ( Capacitor.isNativePlatform() && (Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android") ) { 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);