feat: implement secure IPC-based file export for Electron

Replace sandboxed Capacitor filesystem with native IPC for reliable file exports:
- Add IPC handler in main process for direct Downloads folder access
- Expose secure electronAPI via contextBridge in preload script
- Update ElectronPlatformService to use native IPC with web fallback
- Add TypeScript definitions for electron APIs
- Fix file export issues where files were trapped in virtual filesystem
- Enable proper date-stamped backup filenames in Downloads folder
- Follow Electron security best practices with process isolation

Files now export directly to ~/Downloads with exact path feedback.
This commit is contained in:
Matthew Raymer
2025-07-06 03:46:28 +00:00
parent 400748b9a1
commit b1e9eff568
7 changed files with 328 additions and 159 deletions

View File

@@ -23,27 +23,16 @@ messages * - Conditional UI based on platform capabilities * * @component *
</router-link>
<button
:class="{ hidden: isDownloadInProgress }"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
:disabled="isExporting"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
@click="exportDatabase()"
>
Download Contacts
{{ isExporting ? "Exporting..." : "Download Contacts" }}
</button>
<!-- Hidden download link for web platform - always rendered for ref access -->
<a
v-if="isWebPlatform"
ref="downloadLink"
:href="downloadUrl"
:download="fileName"
:class="{ hidden: !downloadUrl }"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
<div v-if="capabilities.needsFileHandlingInstructions" class="mt-4">
<p>
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.
</p>
<ul>
@@ -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<void> {
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<void> {
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<void> {
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
}
}
</script>