diff --git a/electron/src/index.ts b/electron/src/index.ts
index edc8d3b9..a49e3395 100644
--- a/electron/src/index.ts
+++ b/electron/src/index.ts
@@ -1,10 +1,12 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import { getCapacitorElectronConfig, setupElectronDeepLinking } from '@capacitor-community/electron';
import type { MenuItemConstructorOptions } from 'electron';
-import { app, MenuItem } from 'electron';
+import { app, MenuItem, ipcMain } from 'electron';
import electronIsDev from 'electron-is-dev';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
+import { promises as fs } from 'fs';
+import { join } from 'path';
import { ElectronCapacitorApp, setupContentSecurityPolicy, setupReloadWatcher } from './setup';
@@ -106,3 +108,38 @@ app.on('activate', async function () {
});
// Place all ipc or other electron api calls and custom functionality under this line
+
+/**
+ * IPC Handler for exporting data to the user's Downloads folder.
+ *
+ * This provides a secure, native way to save files directly to the Downloads
+ * directory using the main process's file system access.
+ *
+ * @param fileName - The name of the file to save (including extension)
+ * @param data - The data to write to the file (string or buffer)
+ * @returns Promise<{success: boolean, path?: string, error?: string}>
+ */
+ipcMain.handle('export-data-to-downloads', async (_event, fileName: string, data: string) => {
+ try {
+ // Get the user's Downloads directory path
+ const downloadsDir = app.getPath('downloads');
+ const filePath = join(downloadsDir, fileName);
+
+ // Write the file to the Downloads directory
+ await fs.writeFile(filePath, data, 'utf-8');
+
+ console.log(`[Electron Main] File exported successfully: ${filePath}`);
+
+ return {
+ success: true,
+ path: filePath
+ };
+ } catch (error) {
+ console.error(`[Electron Main] File export failed:`, error);
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error occurred'
+ };
+ }
+});
diff --git a/electron/src/preload.ts b/electron/src/preload.ts
index c817d3b7..6a9e39fe 100644
--- a/electron/src/preload.ts
+++ b/electron/src/preload.ts
@@ -1,4 +1,34 @@
+import { contextBridge, ipcRenderer } from 'electron';
+
require('./rt/electron-rt');
//////////////////////////////
// User Defined Preload scripts below
console.log('User Preload!');
+
+/**
+ * Expose secure IPC APIs to the renderer process.
+ *
+ * This creates a bridge between the sandboxed renderer and the main process,
+ * allowing secure file operations while maintaining Electron's security model.
+ */
+contextBridge.exposeInMainWorld('electronAPI', {
+ /**
+ * Export data to the user's Downloads folder.
+ *
+ * @param fileName - The name of the file to save (e.g., 'backup-2025-07-06.json')
+ * @param data - The content to write to the file (string)
+ * @returns Promise<{success: boolean, path?: string, error?: string}>
+ *
+ * @example
+ * ```typescript
+ * const result = await window.electronAPI.exportData('my-backup.json', JSON.stringify(data));
+ * if (result.success) {
+ * console.log('File saved to:', result.path);
+ * } else {
+ * console.error('Export failed:', result.error);
+ * }
+ * ```
+ */
+ exportData: (fileName: string, data: string) =>
+ ipcRenderer.invoke('export-data-to-downloads', fileName, data)
+});
diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue
index a5c31ace..1a9dc167 100644
--- a/src/components/DataExportSection.vue
+++ b/src/components/DataExportSection.vue
@@ -23,27 +23,16 @@ messages * - Conditional UI based on platform capabilities * * @component *
-
-
- If no download happened yet, click again here to download now.
-
- After the download, you can save the file in your preferred storage
+ After the export, you can save the file in your preferred storage
location.
@@ -76,6 +65,11 @@ import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
* @vue-component
* Data Export Section Component
* Handles database export and seed backup functionality with platform-specific behavior
+ *
+ * Features:
+ * - Automatic date stamping of backup files (YYYY-MM-DD format)
+ * - Platform-specific export handling with proper abstraction
+ * - Robust error handling and user notifications
*/
@Component({
mixins: [PlatformServiceMixin],
@@ -95,11 +89,10 @@ export default class DataExportSection extends Vue {
@Prop({ required: true }) readonly activeDid!: string;
/**
- * URL for the database export download
- * Created and revoked dynamically during export process
- * Only used in web platform
+ * Flag indicating if export is currently in progress
+ * Used to show loading state and prevent multiple simultaneous exports
*/
- downloadUrl = "";
+ isExporting = false;
/**
* Notification helper for consistent notification patterns
@@ -116,116 +109,52 @@ export default class DataExportSection extends Vue {
*/
declare readonly platformService: import("@/services/PlatformService").PlatformService;
- /**
- * Computed property to check if we're on web platform
- */
- private get isWebPlatform(): boolean {
- return this.capabilities.hasFileDownload;
- }
-
- /**
- * Computed property to check if download is in progress
- */
- private get isDownloadInProgress(): boolean {
- return Boolean(this.downloadUrl && this.isWebPlatform);
- }
-
/**
* Computed property for the export file name
+ * Includes today's date for easy identification of backup files
*/
private get fileName(): string {
- return `${AppString.APP_NAME_NO_SPACES}-backup-contacts.json`;
- }
-
- /**
- * Lifecycle hook to clean up resources
- * Revokes object URL when component is unmounted (web platform only)
- */
- beforeUnmount() {
- if (this.downloadUrl && this.isWebPlatform) {
- URL.revokeObjectURL(this.downloadUrl);
- this.downloadUrl = "";
- }
+ const today = new Date();
+ const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format
+ return `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${dateString}.json`;
}
/**
* Exports the database to a JSON file
- * Uses platform-specific methods for saving the exported data
+ * Uses the platform service to handle platform-specific export logic
* Shows success/error notifications to user
*
* @throws {Error} If export fails
*/
public async exportDatabase(): Promise {
+ if (this.isExporting) {
+ return; // Prevent multiple simultaneous exports
+ }
+
try {
+ this.isExporting = true;
+
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
- const blob = new Blob([jsonStr], { type: "application/json" });
- // Handle export based on platform capabilities
- if (this.isWebPlatform) {
- await this.handleWebExport(blob);
- } else if (this.capabilities.hasFileSystem) {
- await this.handleNativeExport(jsonStr);
- } else {
- throw new Error("This platform does not support file downloads.");
- }
+ // Use platform service to handle export (no platform-specific logic here!)
+ await this.platformService.writeAndShareFile(this.fileName, jsonStr);
this.notify.success(
- this.isWebPlatform
- ? "See your downloads directory for the backup."
- : "The backup file has been saved.",
+ "Contact export completed successfully. Check your downloads or share dialog.",
);
} catch (error) {
logger.error("Export Error:", error);
this.notify.error(
`There was an error exporting the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
+ } finally {
+ this.isExporting = false;
}
}
-
- /**
- * Handles export for web platform using download link
- * @param blob The blob to download
- */
- private async handleWebExport(blob: Blob): Promise {
- this.downloadUrl = URL.createObjectURL(blob);
-
- try {
- // Wait for next tick to ensure DOM is updated
- await this.$nextTick();
-
- const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
- if (!downloadAnchor) {
- throw new Error("Download link element not found. Please try again.");
- }
-
- downloadAnchor.click();
-
- // Clean up the URL after a delay
- setTimeout(() => {
- URL.revokeObjectURL(this.downloadUrl);
- this.downloadUrl = "";
- }, 1000);
- } catch (error) {
- // Clean up the URL on error
- if (this.downloadUrl) {
- URL.revokeObjectURL(this.downloadUrl);
- this.downloadUrl = "";
- }
- throw error;
- }
- }
-
- /**
- * Handles export for native platforms using file system
- * @param jsonStr The JSON string to save
- */
- private async handleNativeExport(jsonStr: string): Promise {
- await this.platformService.writeAndShareFile(this.fileName, jsonStr);
- }
}
diff --git a/src/services/PlatformServiceFactory.ts b/src/services/PlatformServiceFactory.ts
index 88517e84..5ee68f0e 100644
--- a/src/services/PlatformServiceFactory.ts
+++ b/src/services/PlatformServiceFactory.ts
@@ -1,6 +1,7 @@
import { PlatformService } from "./PlatformService";
import { WebPlatformService } from "./platforms/WebPlatformService";
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService";
+import { ElectronPlatformService } from "./platforms/ElectronPlatformService";
/**
* Factory class for creating platform-specific service implementations.
@@ -76,56 +77,3 @@ export class PlatformServiceFactory {
};
}
}
-
-/**
- * Electron-specific platform service implementation.
- * Extends CapacitorPlatformService with electron-specific overrides.
- *
- * This service handles the unique requirements of the Electron platform:
- * - Desktop-specific capabilities
- * - Electron-specific file system access
- * - Desktop UI patterns
- * - Native desktop integration
- */
-class ElectronPlatformService extends CapacitorPlatformService {
- /**
- * Gets the capabilities of the Electron platform
- * Overrides the mobile-focused capabilities from CapacitorPlatformService
- * @returns Platform capabilities object specific to Electron
- */
- getCapabilities() {
- return {
- hasFileSystem: true,
- hasCamera: false, // Desktop typically doesn't have integrated cameras for our use case
- isMobile: false, // Electron is desktop, not mobile
- isIOS: false,
- hasFileDownload: true, // Desktop supports direct file downloads
- needsFileHandlingInstructions: false, // Desktop users are familiar with file handling
- isNativeApp: true, // Electron is a native app
- };
- }
-
- /**
- * Checks if running on Electron platform.
- * @returns true, as this is the Electron implementation
- */
- isElectron(): boolean {
- return true;
- }
-
- /**
- * Checks if running on Capacitor platform.
- * @returns false, as this is Electron, not pure Capacitor
- */
- isCapacitor(): boolean {
- return false;
- }
-
- /**
- * Checks if running on web platform.
- * @returns false, as this is not web
- */
- isWeb(): boolean {
- return false;
- }
-}
diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts
new file mode 100644
index 00000000..78a6edb8
--- /dev/null
+++ b/src/services/platforms/ElectronPlatformService.ts
@@ -0,0 +1,166 @@
+/**
+ * @fileoverview Electron Platform Service Implementation
+ * @author Matthew Raymer
+ *
+ * Provides platform-specific functionality for Electron desktop applications.
+ * This service extends CapacitorPlatformService to leverage Capacitor's APIs
+ * while overriding desktop-specific behaviors.
+ *
+ * Key Features:
+ * - Desktop-specific capabilities configuration
+ * - Native IPC-based file operations with proper security
+ * - Direct saving to user's Downloads folder via main process
+ * - Native desktop integration support
+ *
+ * Architecture:
+ * - Extends CapacitorPlatformService for API compatibility
+ * - Overrides methods for desktop-specific implementations
+ * - Maintains cross-platform service interface
+ *
+ * @since 1.0.0
+ */
+
+import { CapacitorPlatformService } from "./CapacitorPlatformService";
+import { logger } from "../../utils/logger";
+
+/**
+ * Electron-specific platform service implementation.
+ *
+ * This service handles the unique requirements of the Electron platform:
+ * - Desktop-specific capabilities and UI patterns
+ * - File system operations using Capacitor's Filesystem API
+ * - Native desktop integration features
+ * - Proper error handling with web fallbacks
+ *
+ * @extends CapacitorPlatformService
+ * @example
+ * ```typescript
+ * const electronService = new ElectronPlatformService();
+ * await electronService.writeAndShareFile('backup.json', jsonData);
+ * ```
+ */
+export class ElectronPlatformService extends CapacitorPlatformService {
+ /**
+ * Gets the capabilities of the Electron platform.
+ * Overrides the mobile-focused capabilities from CapacitorPlatformService
+ * to provide desktop-specific feature flags.
+ *
+ * @returns Platform capabilities object specific to Electron
+ */
+ getCapabilities() {
+ return {
+ hasFileSystem: true,
+ hasCamera: false, // Desktop typically doesn't have integrated cameras for our use case
+ isMobile: false, // Electron is desktop, not mobile
+ isIOS: false,
+ hasFileDownload: true, // Desktop supports direct file downloads
+ needsFileHandlingInstructions: false, // Desktop users are familiar with file handling
+ isNativeApp: true, // Electron is a native app
+ };
+ }
+
+ /**
+ * Handles file export for Electron platform using native IPC.
+ *
+ * This method provides a secure, native file export mechanism that:
+ * 1. Uses Electron's IPC (Inter-Process Communication) for secure file operations
+ * 2. Writes files directly to the user's Downloads folder via the main process
+ * 3. Provides exact file path feedback and proper error handling
+ * 4. Falls back to web-style downloads if IPC is unavailable
+ *
+ * @param fileName - The name of the file to save (with date stamp)
+ * @param content - The content to write to the file
+ * @returns Promise that resolves when the file is successfully saved
+ * @throws {Error} If both native IPC and fallback mechanisms fail
+ *
+ * @example
+ * ```typescript
+ * await electronService.writeAndShareFile('TimeSafari-backup-contacts-2025-07-06.json', jsonData);
+ * ```
+ *
+ * @note This implementation follows Electron's security best practices by:
+ * - Using contextBridge to expose safe IPC methods
+ * - Handling file operations in the main process with full filesystem access
+ * - Providing exact file paths for better user experience
+ * - Maintaining secure separation between renderer and main processes
+ */
+ async writeAndShareFile(fileName: string, content: string): Promise {
+ logger.info(
+ `[ElectronPlatformService] Using native IPC for reliable file export: ${fileName}`,
+ );
+
+ try {
+ // Check if we're running in Electron with the API available
+ if (typeof window !== "undefined" && window.electronAPI) {
+ // Use the native Electron IPC API for file exports
+ const result = await window.electronAPI.exportData(fileName, content);
+
+ if (result.success) {
+ logger.info(
+ `[ElectronPlatformService] File exported successfully to: ${result.path}`,
+ );
+ logger.info(
+ `[ElectronPlatformService] File saved to Downloads folder: ${fileName}`,
+ );
+ } else {
+ logger.error(
+ `[ElectronPlatformService] Native export failed: ${result.error}`,
+ );
+ throw new Error(`Native file export failed: ${result.error}`);
+ }
+ } else {
+ // Fallback to web-style download if Electron API is not available
+ logger.warn(
+ "[ElectronPlatformService] Electron API not available, falling back to web download",
+ );
+
+ const blob = new Blob([content], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const downloadLink = document.createElement("a");
+ downloadLink.href = url;
+ downloadLink.download = fileName;
+ downloadLink.style.display = "none";
+
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ document.body.removeChild(downloadLink);
+
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+
+ logger.info(
+ `[ElectronPlatformService] Fallback download initiated: ${fileName}`,
+ );
+ }
+ } catch (error) {
+ logger.error("[ElectronPlatformService] File export failed:", error);
+ throw new Error(`Failed to export file: ${error}`);
+ }
+ }
+
+ /**
+ * Checks if running on Electron platform.
+ *
+ * @returns true, as this is the Electron implementation
+ */
+ isElectron(): boolean {
+ return true;
+ }
+
+ /**
+ * Checks if running on Capacitor platform.
+ *
+ * @returns false, as this is Electron, not pure Capacitor
+ */
+ isCapacitor(): boolean {
+ return false;
+ }
+
+ /**
+ * Checks if running on web platform.
+ *
+ * @returns false, as this is not web
+ */
+ isWeb(): boolean {
+ return false;
+ }
+}
diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts
index 7463ad30..790000ad 100644
--- a/src/services/platforms/WebPlatformService.ts
+++ b/src/services/platforms/WebPlatformService.ts
@@ -559,13 +559,39 @@ export class WebPlatformService implements PlatformService {
}
/**
- * Not supported in web platform.
- * @param _fileName - Unused fileName parameter
- * @param _content - Unused content parameter
- * @throws Error indicating file system access is not available
+ * 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 {
- throw new Error("File system access not available in web platform");
+ async writeAndShareFile(fileName: string, content: string): Promise {
+ 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"}`,
+ );
+ }
}
/**
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
index 9cf4b740..2cf5bc81 100644
--- a/src/types/global.d.ts
+++ b/src/types/global.d.ts
@@ -33,4 +33,37 @@ declare module '@jlongster/sql.js' {
}) => Promise;
export default initSqlJs;
+}
+
+/**
+ * Electron API types for the main world context bridge.
+ *
+ * These types define the secure IPC APIs exposed by the preload script
+ * to the renderer process for native Electron functionality.
+ */
+interface ElectronAPI {
+ /**
+ * Export data to the user's Downloads folder.
+ *
+ * @param fileName - The name of the file to save (e.g., 'backup-2025-07-06.json')
+ * @param data - The content to write to the file (string)
+ * @returns Promise with success status, file path, or error message
+ */
+ exportData: (fileName: string, data: string) => Promise<{
+ success: boolean;
+ path?: string;
+ error?: string;
+ }>;
+}
+
+/**
+ * Global window interface extension for Electron APIs.
+ *
+ * This makes the electronAPI available on the window object
+ * in TypeScript without type errors.
+ */
+declare global {
+ interface Window {
+ electronAPI: ElectronAPI;
+ }
}
\ No newline at end of file