You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
399 lines
13 KiB
399 lines
13 KiB
/**
|
|
* @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<void> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Saves content directly to the device's Downloads folder (Electron platform).
|
|
* Uses Electron's IPC to save files directly to the Downloads directory.
|
|
*
|
|
* @param fileName - The filename of the file to save
|
|
* @param content - The content to write to the file
|
|
* @returns Promise that resolves when the file is saved
|
|
*/
|
|
async saveToDevice(fileName: string, content: string): Promise<void> {
|
|
try {
|
|
// Ensure content is valid JSON
|
|
try {
|
|
JSON.parse(content);
|
|
} catch {
|
|
throw new Error('Content must be valid JSON');
|
|
}
|
|
|
|
// Generate unique filename
|
|
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
|
|
|
|
logger.info(
|
|
`[ElectronPlatformService] Using native IPC for direct file save`,
|
|
uniqueFileName,
|
|
);
|
|
|
|
// 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(uniqueFileName, content);
|
|
|
|
if (result.success) {
|
|
logger.info(
|
|
`[ElectronPlatformService] File saved successfully to`,
|
|
result.path,
|
|
);
|
|
logger.info(
|
|
`[ElectronPlatformService] File saved to Downloads folder`,
|
|
uniqueFileName,
|
|
);
|
|
} else {
|
|
logger.error(
|
|
`[ElectronPlatformService] Native save failed`,
|
|
result.error,
|
|
);
|
|
throw new Error(`Native file save 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 = uniqueFileName;
|
|
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`,
|
|
uniqueFileName,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error("[ElectronPlatformService] File save failed", error);
|
|
throw new Error(`Failed to save file: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the system file picker to let the user choose where to save a file (Electron platform).
|
|
* Uses Electron's IPC to show the native save dialog.
|
|
*
|
|
* @param fileName - The suggested filename for the file
|
|
* @param content - The content to write to the file
|
|
* @returns Promise that resolves when the file is saved
|
|
*/
|
|
async saveAs(fileName: string, content: string): Promise<void> {
|
|
try {
|
|
// Ensure content is valid JSON
|
|
try {
|
|
JSON.parse(content);
|
|
} catch {
|
|
throw new Error('Content must be valid JSON');
|
|
}
|
|
|
|
// Generate unique filename
|
|
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
|
|
|
|
logger.info(
|
|
`[ElectronPlatformService] Using native IPC for save as dialog`,
|
|
uniqueFileName,
|
|
);
|
|
|
|
// 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 (same as saveToDevice for now)
|
|
// TODO: Implement native save dialog when available
|
|
const result = await window.electronAPI.exportData(uniqueFileName, content);
|
|
|
|
if (result.success) {
|
|
logger.info(
|
|
`[ElectronPlatformService] File saved successfully to`,
|
|
result.path,
|
|
);
|
|
logger.info(
|
|
`[ElectronPlatformService] File saved via save as`,
|
|
uniqueFileName,
|
|
);
|
|
} else {
|
|
logger.error(
|
|
`[ElectronPlatformService] Native save as failed`,
|
|
result.error,
|
|
);
|
|
throw new Error(`Native file save as 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 = uniqueFileName;
|
|
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`,
|
|
uniqueFileName,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error("[ElectronPlatformService] File save as failed", error);
|
|
throw new Error(`Failed to save file as: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates unique filename with timestamp, hashed device ID, and counter
|
|
*/
|
|
private generateUniqueFileNameElectron(baseName: string, counter = 0): string {
|
|
const now = new Date();
|
|
const timestamp = now.toISOString()
|
|
.replace(/[:.]/g, '-')
|
|
.replace('T', '_')
|
|
.replace('Z', '');
|
|
|
|
const deviceIdHash = this.getHashedDeviceIdentifierElectron();
|
|
const counterSuffix = counter > 0 ? `_${counter}` : '';
|
|
|
|
const maxBaseLength = 45;
|
|
const truncatedBase = baseName.length > maxBaseLength
|
|
? baseName.substring(0, maxBaseLength)
|
|
: baseName;
|
|
|
|
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
|
|
const extension = '.json';
|
|
const devicePart = `_${deviceIdHash}`;
|
|
const timestampPart = `_${timestamp}${counterSuffix}`;
|
|
|
|
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
|
|
|
|
if (totalLength > 200) {
|
|
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
|
|
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
|
|
return `${finalBase}${devicePart}${timestampPart}${extension}`;
|
|
}
|
|
|
|
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
|
|
}
|
|
|
|
/**
|
|
* Gets hashed device identifier
|
|
*/
|
|
private getHashedDeviceIdentifierElectron(): string {
|
|
try {
|
|
const deviceInfo = this.getDeviceInfoElectron();
|
|
return this.hashStringElectron(deviceInfo);
|
|
} catch (error) {
|
|
return 'electron';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets device info string
|
|
*/
|
|
private getDeviceInfoElectron(): string {
|
|
try {
|
|
// Use machine-specific information
|
|
const os = require('os');
|
|
const hostname = os.hostname() || 'unknown';
|
|
const platform = os.platform() || 'unknown';
|
|
const arch = os.arch() || 'unknown';
|
|
|
|
// Create device info string
|
|
return `${platform}_${hostname}_${arch}`;
|
|
} catch (error) {
|
|
return 'electron';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simple hash function for device ID
|
|
*/
|
|
private hashStringElectron(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash;
|
|
}
|
|
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// --- PWA/Web-only methods (no-op for Electron) ---
|
|
public registerServiceWorker(): void {}
|
|
}
|
|
|