forked from trent_larson/crowd-funder-for-time-pwa
Replace hardcoded timeout with reliable file existence polling and extract duplicate image storage code into reusable function. File Polling Improvements: - Replace hardcoded 300ms timeout with pollForFileExistence() function - Use Filesystem.stat() to check file existence instead of guessing timing - Implement exponential backoff (100ms, 200ms, 400ms, 800ms, 1600ms) - Maximum 5 retries with configurable parameters - More reliable: actually checks if file exists rather than fixed wait time Code Deduplication: - Extract storeSharedImageInTempDB() function to eliminate code duplication - Consolidates clearing old data, base64-to-dataURL conversion, and DB storage - Used in both temp file and plugin fallback code paths - Improves maintainability and reduces risk of inconsistencies This makes the code more robust and maintainable while ensuring the file is actually available before attempting to read it.
582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
/**
|
||
* @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<boolean> - true if file exists, false if max retries reached
|
||
*/
|
||
async function pollForFileExistence(
|
||
filePath: string,
|
||
maxRetries: number = 5,
|
||
initialDelay: number = 100,
|
||
): Promise<boolean> {
|
||
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<void>
|
||
*/
|
||
async function storeSharedImageInTempDB(
|
||
base64: string,
|
||
fileName?: string,
|
||
): Promise<void> {
|
||
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://<route>/<param>
|
||
* 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<void>}
|
||
*
|
||
* @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);
|