From 5ee4a7e411c8060dac6d33d9b6f96c573047f061 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Tue, 8 Apr 2025 08:06:00 +0000 Subject: [PATCH] refactor: centralize platform-specific behavior in platform services - Add platform-specific capability methods to PlatformService interface: - getExportInstructions() - getExportSuccessMessage() - needsSecondaryDownloadLink() - needsDownloadCleanup() - Update platform service implementations: - WebPlatformService: Implement web-specific export behavior - CapacitorPlatformService: Implement mobile-specific export behavior - ElectronPlatformService: Add placeholder for export functionality - PyWebViewPlatformService: Add placeholder for export functionality - Refactor DataExportSection component: - Remove direct platform checks (isWeb, isCapacitor, etc.) - Use platform service capabilities for UI behavior - Improve error handling and logging - Add proper cleanup for web platform downloads - Update PlatformServiceFactory: - Make getInstance() async to support dynamic imports - Improve platform service initialization - Fix code style and documentation: - Update JSDoc comments - Fix string quotes consistency - Add proper error handling - Improve logging messages - Update Vite config: - Add all Capacitor dependencies to external list - Ensure consistent handling across platforms --- src/components/DataExportSection.vue | 106 +++++++++--------- src/components/PhotoDialog.vue | 15 ++- src/services/PlatformService.ts | 24 ++++ src/services/PlatformServiceFactory.ts | 41 ++++--- src/services/api.ts | 8 +- src/services/deepLinks.ts | 8 +- src/services/plan.ts | 12 +- .../platforms/CapacitorPlatformService.ts | 58 ++++++++-- .../platforms/ElectronPlatformService.ts | 14 ++- .../platforms/PyWebViewPlatformService.ts | 14 ++- src/services/platforms/WebPlatformService.ts | 70 +++++++++--- vite.config.common.mts | 16 ++- 12 files changed, 276 insertions(+), 110 deletions(-) diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index bd7cafdf..84e8b7fe 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -1,16 +1,9 @@ -/** - * Data Export Section Component - * - * Provides UI and functionality for exporting user data and backing up identifier seeds. - * Includes buttons for seed backup and database export, with platform-specific download instructions. - * - * @component - * @displayName DataExportSection - * @example - * ```vue - * - * ``` - */ +/** * Data Export Section Component * * Provides UI and functionality for +exporting user data and backing up identifier seeds. * Includes buttons for seed +backup and database export, with platform-specific download instructions. * * +@component * @displayName DataExportSection * @example * ```vue * + +* ``` */ @@ -100,60 +89,72 @@ export default class DataExportSection extends Vue { /** * Platform service instance for platform-specific operations */ - private platformService: PlatformService = PlatformServiceFactory.getInstance(); - - /** - * Whether the current platform is iOS - */ - private get isIOS(): boolean { - return /iPad|iPhone|iPod/.test(navigator.userAgent); - } - - /** - * Whether to show platform-specific instructions - */ - private get showPlatformInstructions(): boolean { - return this.platformService.isCapacitor(); - } + private platformService?: PlatformService; /** * Lifecycle hook to clean up resources * Revokes object URL when component is unmounted (web platform only) */ beforeUnmount() { - if (this.downloadUrl && this.platformService.isWeb()) { + if (this.downloadUrl && this.platformService?.needsDownloadCleanup()) { URL.revokeObjectURL(this.downloadUrl); } } + async mounted() { + this.platformService = await PlatformServiceFactory.getInstance(); + logger.log( + "DataExportSection mounted on platform:", + process.env.VITE_PLATFORM, + ); + } + /** * Exports the database to a JSON file * Uses platform-specific methods for saving the exported data * Shows success/error notifications to user - * + * * @throws {Error} If export fails * @emits {Notification} Success or error notification */ public async exportDatabase() { try { + if (!this.platformService) { + this.platformService = await PlatformServiceFactory.getInstance(); + } + logger.log( + "Starting database export on platform:", + process.env.VITE_PLATFORM, + ); + const blob = await db.export({ prettyJson: true }); const fileName = `${db.name}-backup.json`; + logger.log("Database export details:", { + fileName, + blobSize: `${blob.size} bytes`, + platform: process.env.VITE_PLATFORM, + }); await this.platformService.exportDatabase(blob, fileName); + logger.log("Database export completed successfully:", { + fileName, + platform: process.env.VITE_PLATFORM, + }); this.$notify( { group: "alert", type: "success", title: "Export Successful", - text: this.platformService.isWeb() - ? "See your downloads directory for the backup. It is in the Dexie format." - : "The backup has been saved to your device.", + text: this.platformService.getExportSuccessMessage(), }, -1, ); } catch (error) { - logger.error("Export Error:", error); + logger.error("Database export failed:", { + error, + platform: process.env.VITE_PLATFORM, + }); this.$notify( { group: "alert", @@ -172,7 +173,8 @@ export default class DataExportSection extends Vue { */ public computedStartDownloadLinkClassNames() { return { - hidden: this.downloadUrl && this.platformService.isWeb(), + hidden: + this.downloadUrl && this.platformService?.needsSecondaryDownloadLink(), }; } @@ -182,7 +184,9 @@ export default class DataExportSection extends Vue { */ public computedDownloadLinkClassNames() { return { - hidden: !this.downloadUrl || !this.platformService.isWeb(), + hidden: + !this.downloadUrl || + !this.platformService?.needsSecondaryDownloadLink(), }; } } diff --git a/src/components/PhotoDialog.vue b/src/components/PhotoDialog.vue index 561c96ee..15a93f43 100644 --- a/src/components/PhotoDialog.vue +++ b/src/components/PhotoDialog.vue @@ -108,6 +108,7 @@ import { retrieveSettingsForActiveAccount } from "../db/index"; import { accessToken } from "../libs/crypto"; import { logger } from "../utils/logger"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; +import { PlatformService } from "../services/PlatformService"; @Component({ components: { VuePictureCropper } }) export default class PhotoDialog extends Vue { @@ -123,13 +124,14 @@ export default class PhotoDialog extends Vue { uploading = false; visible = false; - private platformService = PlatformServiceFactory.getInstance(); + private platformService?: PlatformService; URL = window.URL || window.webkitURL; async mounted() { try { const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; + this.platformService = await PlatformServiceFactory.getInstance(); } catch (err: unknown) { logger.error("Error retrieving settings from database:", err); this.$notify( @@ -137,7 +139,10 @@ export default class PhotoDialog extends Vue { group: "alert", type: "danger", title: "Error", - text: err.message || "There was an error retrieving your settings.", + text: + err instanceof Error + ? err.message + : "There was an error retrieving your settings.", }, -1, ); @@ -181,6 +186,9 @@ export default class PhotoDialog extends Vue { async takePhoto() { try { + if (!this.platformService) { + this.platformService = await PlatformServiceFactory.getInstance(); + } const result = await this.platformService.takePicture(); this.blob = result.blob; this.fileName = result.fileName; @@ -200,6 +208,9 @@ export default class PhotoDialog extends Vue { async pickPhoto() { try { + if (!this.platformService) { + this.platformService = await PlatformServiceFactory.getInstance(); + } const result = await this.platformService.pickImage(); this.blob = result.blob; this.fileName = result.fileName; diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index c58f6d71..20dff449 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -100,4 +100,28 @@ export interface PlatformService { * @returns Promise that resolves when the deep link has been handled */ handleDeepLink(url: string): Promise; + + /** + * Gets platform-specific instructions for saving exported files + * @returns Array of instruction strings for the current platform + */ + getExportInstructions(): string[]; + + /** + * Gets the success message for database export + * @returns Success message appropriate for the current platform + */ + getExportSuccessMessage(): string; + + /** + * Checks if the platform requires a secondary download link + * @returns true if platform needs a secondary download link + */ + needsSecondaryDownloadLink(): boolean; + + /** + * Checks if the platform needs cleanup after download + * @returns true if platform needs cleanup after download + */ + needsDownloadCleanup(): boolean; } diff --git a/src/services/PlatformServiceFactory.ts b/src/services/PlatformServiceFactory.ts index 6dca11b8..42e15121 100644 --- a/src/services/PlatformServiceFactory.ts +++ b/src/services/PlatformServiceFactory.ts @@ -1,20 +1,17 @@ import { PlatformService } from "./PlatformService"; import { WebPlatformService } from "./platforms/WebPlatformService"; -import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; -import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; -import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService"; /** * Factory class for creating platform-specific service implementations. * Implements the Singleton pattern to ensure only one instance of PlatformService exists. - * + * * The factory determines which platform implementation to use based on the VITE_PLATFORM * environment variable. Supported platforms are: * - capacitor: Mobile platform using Capacitor * - electron: Desktop platform using Electron * - pywebview: Python WebView implementation * - web: Default web platform (fallback) - * + * * @example * ```typescript * const platformService = PlatformServiceFactory.getInstance(); @@ -27,32 +24,46 @@ export class PlatformServiceFactory { /** * Gets or creates the singleton instance of PlatformService. * Creates the appropriate platform-specific implementation based on environment. - * + * * @returns {PlatformService} The singleton instance of PlatformService */ - public static getInstance(): PlatformService { + public static async getInstance(): Promise { if (PlatformServiceFactory.instance) { return PlatformServiceFactory.instance; } const platform = process.env.VITE_PLATFORM || "web"; + let service: PlatformService; switch (platform) { - case "capacitor": - PlatformServiceFactory.instance = new CapacitorPlatformService(); + case "capacitor": { + const { CapacitorPlatformService } = await import( + "./platforms/CapacitorPlatformService" + ); + service = new CapacitorPlatformService(); break; - case "electron": - PlatformServiceFactory.instance = new ElectronPlatformService(); + } + case "electron": { + const { ElectronPlatformService } = await import( + "./platforms/ElectronPlatformService" + ); + service = new ElectronPlatformService(); break; - case "pywebview": - PlatformServiceFactory.instance = new PyWebViewPlatformService(); + } + case "pywebview": { + const { PyWebViewPlatformService } = await import( + "./platforms/PyWebViewPlatformService" + ); + service = new PyWebViewPlatformService(); break; + } case "web": default: - PlatformServiceFactory.instance = new WebPlatformService(); + service = new WebPlatformService(); break; } - return PlatformServiceFactory.instance; + PlatformServiceFactory.instance = service; + return service; } } diff --git a/src/services/api.ts b/src/services/api.ts index 5869abf8..3235100e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,7 +1,7 @@ /** * API error handling utilities for the application. * Provides centralized error handling for API requests with platform-specific logging. - * + * * @module api */ @@ -10,12 +10,12 @@ import { logger } from "../utils/logger"; /** * Handles API errors with platform-specific logging and error processing. - * + * * @param error - The Axios error object from the failed request * @param endpoint - The API endpoint that was called * @returns null for rate limit errors (400), throws the error otherwise * @throws The original error for non-rate-limit cases - * + * * @remarks * Special handling includes: * - Enhanced logging for Capacitor platform @@ -25,7 +25,7 @@ import { logger } from "../utils/logger"; * - HTTP status * - Response data * - Request configuration (URL, method, headers) - * + * * @example * ```typescript * try { diff --git a/src/services/deepLinks.ts b/src/services/deepLinks.ts index 4681019a..20e8cce0 100644 --- a/src/services/deepLinks.ts +++ b/src/services/deepLinks.ts @@ -25,7 +25,7 @@ * * Deep Link Format: * timesafari://[/][?queryParam1=value1&queryParam2=value2] - * + * * Supported Routes: * - user-profile: View user profile * - project-details: View project details @@ -73,7 +73,7 @@ export class DeepLinkHandler { /** * Parses deep link URL into path, params and query components. * Validates URL structure using Zod schemas. - * + * * @param url - The deep link URL to parse (format: scheme://path[?query]) * @throws {DeepLinkError} If URL format is invalid * @returns Parsed URL components (path, params, query) @@ -111,7 +111,7 @@ export class DeepLinkHandler { /** * Processes incoming deep links and routes them appropriately. * Handles validation, error handling, and routing to the correct view. - * + * * @param url - The deep link URL to process * @throws {DeepLinkError} If URL processing fails */ @@ -142,7 +142,7 @@ export class DeepLinkHandler { /** * Routes the deep link to appropriate view with validated parameters. * Validates route and parameters using Zod schemas before routing. - * + * * @param path - The route path from the deep link * @param params - URL parameters * @param query - Query string parameters diff --git a/src/services/plan.ts b/src/services/plan.ts index a730d63a..1419b22c 100644 --- a/src/services/plan.ts +++ b/src/services/plan.ts @@ -1,7 +1,7 @@ /** * Plan service module for handling plan and claim data loading. * Provides functionality to load plans with retry mechanism and error handling. - * + * * @module plan */ @@ -26,11 +26,11 @@ interface PlanResponse { /** * Loads a plan with automatic retry mechanism. * Attempts to load the plan multiple times in case of failure. - * + * * @param handle - The unique identifier for the plan or claim * @param retries - Number of retry attempts (default: 3) * @returns Promise resolving to PlanResponse - * + * * @remarks * - Implements exponential backoff with 1 second delay between retries * - Provides detailed logging of each attempt and any errors @@ -39,7 +39,7 @@ interface PlanResponse { * - HTTP status and headers * - Response data * - Request configuration - * + * * @example * ```typescript * const response = await loadPlanWithRetry('plan-123'); @@ -104,11 +104,11 @@ export const loadPlanWithRetry = async ( /** * Makes a single API request to load a plan or claim. * Determines the appropriate endpoint based on the handle. - * + * * @param handle - The unique identifier for the plan or claim * @returns Promise resolving to PlanResponse * @throws Will throw an error if the API request fails - * + * * @remarks * - Automatically detects claim vs plan endpoints based on handle * - Uses axios for HTTP requests diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index b3bcffa8..b2f62088 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -66,7 +66,9 @@ export class CapacitorPlatformService implements PlatformService { path: directory, directory: Directory.Data, }); - return result.files.map(file => typeof file === 'string' ? file : file.name); + return result.files.map((file) => + typeof file === "string" ? file : file.name, + ); } /** @@ -190,17 +192,50 @@ export class CapacitorPlatformService implements PlatformService { return Promise.resolve(); } + getExportInstructions(): string[] { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + if (isIOS) { + return [ + "On iOS: Choose 'More...' and select a place in iCloud, or go 'Back' and save to another location.", + ]; + } else { + return [ + "On Android: Choose 'Open' and then share to your preferred place.", + ]; + } + } + + getExportSuccessMessage(): string { + return "The backup has been saved to your device."; + } + + needsSecondaryDownloadLink(): boolean { + return false; + } + + needsDownloadCleanup(): boolean { + return false; + } + async exportDatabase(blob: Blob, fileName: string): Promise { + logger.log("Starting database export on Capacitor platform:", { + fileName, + blobSize: `${blob.size} bytes`, + }); + // Create a File object from the Blob - const file = new File([blob], fileName, { type: 'application/json' }); - + const file = new File([blob], fileName, { type: "application/json" }); + try { + logger.log("Attempting to use native share sheet"); // Use the native share sheet await navigator.share({ files: [file], title: fileName, }); + logger.log("Database export completed via native share sheet"); } catch (error) { + logger.log("Native share failed, falling back to Capacitor Share API"); // Fallback to Capacitor Share API if Web Share API fails // First save to temporary file const base64Data = await this.blobToBase64(blob); @@ -208,23 +243,26 @@ export class CapacitorPlatformService implements PlatformService { path: fileName, data: base64Data, directory: Directory.Cache, // Use Cache instead of Documents for temporary files - recursive: true + recursive: true, }); - + logger.log("Temporary file created for sharing:", result.uri); + // Then share using Capacitor Share API await Share.share({ title: fileName, - url: result.uri + url: result.uri, }); - + logger.log("Database export completed via Capacitor Share API"); + // Clean up the temporary file try { await Filesystem.deleteFile({ path: fileName, - directory: Directory.Cache + directory: Directory.Cache, }); + logger.log("Temporary file cleaned up successfully"); } catch (cleanupError) { - logger.warn('Failed to clean up temporary file:', cleanupError); + logger.warn("Failed to clean up temporary file:", cleanupError); } } } @@ -234,7 +272,7 @@ export class CapacitorPlatformService implements PlatformService { const reader = new FileReader(); reader.onloadend = () => { const base64data = reader.result as string; - resolve(base64data.split(',')[1]); + resolve(base64data.split(",")[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 0a99cd43..b7d4955b 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -5,7 +5,7 @@ import { logger } from "../../utils/logger"; * Platform service implementation for Electron (desktop) platform. * Note: This is a placeholder implementation with most methods currently unimplemented. * Implements the PlatformService interface but throws "Not implemented" errors for most operations. - * + * * @remarks * This service is intended for desktop application functionality through Electron. * Future implementations should provide: @@ -121,4 +121,16 @@ export class ElectronPlatformService implements PlatformService { logger.error("handleDeepLink not implemented in Electron platform"); throw new Error("Not implemented"); } + + /** + * Exports a database blob to a file. + * @param _blob - The database blob to export + * @param _fileName - The name of the file to save + * @throws Error with "Not implemented" message + * @todo Implement file export using Electron's file system API + */ + async exportDatabase(_blob: Blob, _fileName: string): Promise { + logger.error("exportDatabase not implemented in Electron platform"); + throw new Error("Not implemented"); + } } diff --git a/src/services/platforms/PyWebViewPlatformService.ts b/src/services/platforms/PyWebViewPlatformService.ts index 0e118297..a4832d48 100644 --- a/src/services/platforms/PyWebViewPlatformService.ts +++ b/src/services/platforms/PyWebViewPlatformService.ts @@ -5,7 +5,7 @@ import { logger } from "../../utils/logger"; * Platform service implementation for PyWebView platform. * Note: This is a placeholder implementation with most methods currently unimplemented. * Implements the PlatformService interface but throws "Not implemented" errors for most operations. - * + * * @remarks * This service is intended for Python-based desktop applications using pywebview. * Future implementations should provide: @@ -122,4 +122,16 @@ export class PyWebViewPlatformService implements PlatformService { logger.error("handleDeepLink not implemented in PyWebView platform"); throw new Error("Not implemented"); } + + /** + * Exports a database blob to a file using the Python backend. + * @param _blob - The database blob to export + * @param _fileName - The name of the file to save + * @throws Error with "Not implemented" message + * @todo Implement file export through pywebview's Python-JavaScript bridge + */ + async exportDatabase(_blob: Blob, _fileName: string): Promise { + logger.error("exportDatabase not implemented in PyWebView platform"); + throw new Error("Not implemented"); + } } diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index 7e94357f..883904fd 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -4,13 +4,13 @@ import { logger } from "../../utils/logger"; /** * 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 - * + * * Note: File system operations are not available in the web platform * due to browser security restrictions. These methods throw appropriate errors. */ @@ -55,10 +55,10 @@ export class WebPlatformService implements PlatformService { /** * Opens a file input dialog configured for camera capture. * Creates a temporary file input element to access the device camera. - * + * * @returns Promise resolving to the captured image data * @throws Error if image capture fails or no image is selected - * + * * @remarks * Uses the 'capture' attribute to prefer the device camera. * Falls back to file selection if camera is not available. @@ -96,10 +96,10 @@ export class WebPlatformService implements PlatformService { /** * 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. @@ -135,11 +135,11 @@ export class WebPlatformService implements PlatformService { /** * 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. @@ -201,7 +201,7 @@ export class WebPlatformService implements PlatformService { /** * 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 */ @@ -210,12 +210,52 @@ export class WebPlatformService implements PlatformService { return Promise.resolve(); } + getExportInstructions(): string[] { + return [ + "After the download, you can save the file in your preferred storage location.", + ]; + } + + getExportSuccessMessage(): string { + return "See your downloads directory for the backup. It is in the Dexie format."; + } + + needsSecondaryDownloadLink(): boolean { + return true; + } + + needsDownloadCleanup(): boolean { + return true; + } + async exportDatabase(blob: Blob, fileName: string): Promise { - const downloadUrl = URL.createObjectURL(blob); - const downloadAnchor = document.createElement('a'); - downloadAnchor.href = downloadUrl; - downloadAnchor.download = fileName; - downloadAnchor.click(); - setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000); + logger.log("Starting database export on web platform:", { + fileName, + blobSize: `${blob.size} bytes`, + }); + + try { + logger.log("Creating download link for database export"); + // Create a download link + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + + logger.log("Triggering download"); + // Trigger the download + document.body.appendChild(a); + a.click(); + + logger.log("Cleaning up download link"); + // Clean up + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + logger.log("Database export completed successfully"); + } catch (error) { + logger.error("Failed to export database:", error); + throw error; + } } } diff --git a/vite.config.common.mts b/vite.config.common.mts index 1e288f5f..9c459932 100644 --- a/vite.config.common.mts +++ b/vite.config.common.mts @@ -36,7 +36,21 @@ export async function createBuildConfig(mode: string) { assetsDir: 'assets', chunkSizeWarningLimit: 1000, rollupOptions: { - external: isCapacitor ? ['@capacitor/app'] : [] + external: isCapacitor + ? [ + '@capacitor/app', + '@capacitor/share', + '@capacitor/filesystem', + '@capacitor/camera', + '@capacitor/core' + ] + : [ + '@capacitor/app', + '@capacitor/share', + '@capacitor/filesystem', + '@capacitor/camera', + '@capacitor/core' + ] } }, define: {