From 307966565abdd2361914acbf81cff8ffd42169c4 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Thu, 5 Feb 2026 22:41:02 +0100 Subject: [PATCH] feat: Refactor of Go backend code --- DOCUMENTATION.md | 169 +++++++- app.go | 972 +++++----------------------------------------- app_bugreport.go | 281 ++++++++++++++ app_mail.go | 88 +++++ app_screenshot.go | 164 ++++++++ app_settings.go | 100 +++++ app_system.go | 146 +++++++ app_viewer.go | 429 ++++++++++++++++++++ 8 files changed, 1462 insertions(+), 887 deletions(-) create mode 100644 app_bugreport.go create mode 100644 app_mail.go create mode 100644 app_screenshot.go create mode 100644 app_settings.go create mode 100644 app_system.go create mode 100644 app_viewer.go diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 244ce07..db0e4df 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -37,11 +37,15 @@ EMLy is built using the **Wails v2** framework, which combines a Go backend with ├─────────────────────────────────────────────────────────┤ │ Wails Bridge (Auto-generated TypeScript bindings) │ ├─────────────────────────────────────────────────────────┤ -│ Backend (Go) │ -│ ├── App Logic (app.go) │ -│ ├── Email Parsing (backend/utils/mail/) │ -│ ├── Windows APIs (screenshot, debugger detection) │ -│ └── File Operations │ +│ Backend (Go - Modular Architecture) │ +│ ├── app.go - Core struct & lifecycle │ +│ ├── app_mail.go - Email parsing (EML/MSG/PEC) │ +│ ├── app_viewer.go - Viewer window management │ +│ ├── app_screenshot.go - Window capture │ +│ ├── app_bugreport.go - Bug reporting system │ +│ ├── app_settings.go - Settings import/export │ +│ ├── app_system.go - Windows system utilities │ +│ └── backend/utils/ - Shared utilities │ └─────────────────────────────────────────────────────────┘ ``` @@ -71,7 +75,13 @@ EMLy is built using the **Wails v2** framework, which combines a Go backend with ``` EMLy/ -├── app.go # Main application logic +├── app.go # Core App struct, lifecycle, and configuration +├── app_mail.go # Email reading methods (EML, MSG, PEC) +├── app_viewer.go # Viewer window management (image, PDF, EML) +├── app_screenshot.go # Screenshot capture functionality +├── app_bugreport.go # Bug report creation and submission +├── app_settings.go # Settings import/export +├── app_system.go # Windows system utilities (registry, encoding) ├── main.go # Application entry point ├── logger.go # Logging utilities ├── wails.json # Wails configuration @@ -117,6 +127,12 @@ EMLy/ │ │ │ ├── wailsjs/ # Auto-generated Go bindings │ │ │ ├── types/ # TypeScript types │ │ │ └── utils/ # Utility functions +│ │ │ └── mail/ # Email utilities (modular) +│ │ │ ├── index.ts # Barrel export +│ │ │ ├── constants.ts # IFRAME_UTIL_HTML, CONTENT_TYPES, etc. +│ │ │ ├── data-utils.ts # arrayBufferToBase64, createDataUrl +│ │ │ ├── attachment-handlers.ts # openPDFAttachment, openImageAttachment +│ │ │ └── email-loader.ts # loadEmailFromPath, processEmailBody │ │ └── messages/ # i18n translation files │ │ ├── en.json │ │ └── it.json @@ -153,41 +169,104 @@ if strings.Contains(arg, "--view-image") { ### Application Core (`app.go`) -The `App` struct is the main application controller, exposed to the frontend via Wails bindings. +The `App` struct is the main application controller, exposed to the frontend via Wails bindings. The code is organized into multiple files for maintainability. #### Key Properties ```go type App struct { - ctx context.Context + ctx context.Context // Wails application context StartupFilePath string // File opened via command line CurrentMailFilePath string // Currently loaded mail file + openImagesMux sync.Mutex // Mutex for image viewer tracking openImages map[string]bool // Track open image viewers + openPDFsMux sync.Mutex // Mutex for PDF viewer tracking openPDFs map[string]bool // Track open PDF viewers + openEMLsMux sync.Mutex // Mutex for EML viewer tracking openEMLs map[string]bool // Track open EML viewers } ``` -#### Core Methods +#### Backend File Organization + +The Go backend is split into logical files: + +| File | Purpose | +|------|---------| +| `app.go` | Core App struct, constructor, lifecycle methods (startup/shutdown), configuration | +| `app_mail.go` | Email reading: `ReadEML`, `ReadMSG`, `ReadPEC`, `ReadMSGOSS`, `ShowOpenFileDialog` | +| `app_viewer.go` | Viewer windows: `OpenImageWindow`, `OpenPDFWindow`, `OpenEMLWindow`, `OpenPDF`, `OpenImage`, `GetViewerData` | +| `app_screenshot.go` | Screenshots: `TakeScreenshot`, `SaveScreenshot`, `SaveScreenshotAs` | +| `app_bugreport.go` | Bug reports: `CreateBugReportFolder`, `SubmitBugReport`, `zipFolder` | +| `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` | +| `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` | + +#### Core Methods by Category + +**Lifecycle & Configuration (`app.go`)** | Method | Description | |--------|-------------| +| `startup(ctx)` | Wails startup callback, saves context | +| `shutdown(ctx)` | Wails shutdown callback for cleanup | +| `QuitApp()` | Terminates the application | | `GetConfig()` | Returns application configuration from `config.ini` | +| `SaveConfig(cfg)` | Saves configuration to `config.ini` | | `GetStartupFile()` | Returns file path passed via command line | | `SetCurrentMailFilePath()` | Updates the current mail file path | -| `ReadEML(path)` | Parses an EML file and returns email data | -| `ReadMSG(path)` | Parses an MSG file and returns email data | +| `GetMachineData()` | Returns system information | +| `IsDebuggerRunning()` | Checks if a debugger is attached | + +**Email Reading (`app_mail.go`)** + +| Method | Description | +|--------|-------------| +| `ReadEML(path)` | Parses a standard .eml file | +| `ReadMSG(path, useExternal)` | Parses a Microsoft .msg file | | `ReadPEC(path)` | Parses PEC (Italian certified email) files | | `ShowOpenFileDialog()` | Opens native file picker for EML/MSG files | -| `OpenImageWindow(data, filename)` | Opens image in new viewer window | -| `OpenPDFWindow(data, filename)` | Opens PDF in new viewer window | -| `OpenEMLWindow(data, filename)` | Opens EML attachment in new window | + +**Viewer Windows (`app_viewer.go`)** + +| Method | Description | +|--------|-------------| +| `OpenImageWindow(data, filename)` | Opens image in built-in viewer | +| `OpenPDFWindow(data, filename)` | Opens PDF in built-in viewer | +| `OpenEMLWindow(data, filename)` | Opens EML attachment in new EMLy window | +| `OpenImage(data, filename)` | Opens image with system default app | +| `OpenPDF(data, filename)` | Opens PDF with system default app | +| `GetViewerData()` | Returns viewer data for viewer mode detection | + +**Screenshots (`app_screenshot.go`)** + +| Method | Description | +|--------|-------------| | `TakeScreenshot()` | Captures window screenshot as base64 PNG | -| `SubmitBugReport(input)` | Creates bug report with screenshot and system info | +| `SaveScreenshot()` | Saves screenshot to temp directory | +| `SaveScreenshotAs()` | Opens save dialog for screenshot | + +**Bug Reports (`app_bugreport.go`)** + +| Method | Description | +|--------|-------------| +| `CreateBugReportFolder()` | Creates folder with screenshot and mail file | +| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive | + +**Settings (`app_settings.go`)** + +| Method | Description | +|--------|-------------| | `ExportSettings(json)` | Exports settings to JSON file | | `ImportSettings()` | Imports settings from JSON file | -| `IsDebuggerRunning()` | Checks if a debugger is attached | -| `QuitApp()` | Terminates the application | + +**System Utilities (`app_system.go`)** + +| Method | Description | +|--------|-------------| +| `CheckIsDefaultEMLHandler()` | Checks if EMLy is default for .eml files | +| `OpenDefaultAppsSettings()` | Opens Windows default apps settings | +| `ConvertToUTF8(string)` | Converts string to valid UTF-8 | +| `OpenFolderInExplorer(path)` | Opens folder in Windows Explorer | ### Email Parsing (`backend/utils/mail/`) @@ -315,6 +394,62 @@ if (att.contentType.startsWith("image/")) { } ``` +### Frontend Mail Utilities (`lib/utils/mail/`) + +The frontend email handling code is organized into modular utility files: + +| File | Purpose | +|------|---------| +| `index.ts` | Barrel export for all mail utilities | +| `constants.ts` | Constants: `IFRAME_UTIL_HTML`, `CONTENT_TYPES`, `PEC_FILES`, `EMAIL_EXTENSIONS` | +| `data-utils.ts` | Data conversion: `arrayBufferToBase64`, `createDataUrl`, `looksLikeBase64`, `tryDecodeBase64` | +| `attachment-handlers.ts` | Attachment opening: `openPDFAttachment`, `openImageAttachment`, `openEMLAttachment` | +| `email-loader.ts` | Email loading: `loadEmailFromPath`, `openAndLoadEmail`, `processEmailBody`, `isEmailFile` | + +#### Key Functions + +**Data Utilities** (`data-utils.ts`) +```typescript +// Convert ArrayBuffer to base64 string +function arrayBufferToBase64(buffer: unknown): string; + +// Create data URL for file downloads +function createDataUrl(contentType: string, base64Data: string): string; + +// Check if string looks like base64 encoded content +function looksLikeBase64(content: string): boolean; + +// Attempt to decode base64, returns null on failure +function tryDecodeBase64(content: string): string | null; +``` + +**Attachment Handlers** (`attachment-handlers.ts`) +```typescript +// Open PDF using built-in or external viewer based on settings +async function openPDFAttachment(base64Data: string, filename: string): Promise; + +// Open image using built-in or external viewer based on settings +async function openImageAttachment(base64Data: string, filename: string): Promise; + +// Open EML attachment in new EMLy window +async function openEMLAttachment(base64Data: string, filename: string): Promise; +``` + +**Email Loader** (`email-loader.ts`) +```typescript +// Load email from file path, handles EML/MSG/PEC detection +async function loadEmailFromPath(filePath: string): Promise; + +// Open file dialog and load selected email +async function openAndLoadEmail(): Promise; + +// Process email body (decode base64, fix encoding) +async function processEmailBody(body: string): Promise; + +// Check if file path is a valid email file +function isEmailFile(filePath: string): boolean; +``` + ### Settings Page (`(app)/settings/+page.svelte`) Organized into cards with various configuration options: diff --git a/app.go b/app.go index 45397d0..3e450b4 100644 --- a/app.go +++ b/app.go @@ -1,42 +1,60 @@ +// Package main provides the core EMLy application. +// EMLy is a desktop email viewer for .eml and .msg files built with Wails v2. package main import ( - "archive/zip" "context" - "encoding/base64" - "fmt" "os" - "os/exec" - "path/filepath" "strings" "sync" - "time" - "unicode/utf8" - - "golang.org/x/sys/windows/registry" - "golang.org/x/text/encoding/charmap" - "golang.org/x/text/transform" "emly/backend/utils" - internal "emly/backend/utils/mail" "github.com/wailsapp/wails/v2/pkg/runtime" ) -// App struct +// ============================================================================= +// App - Core Application Structure +// ============================================================================= + +// App is the main application struct that holds the application state and +// provides methods that are exposed to the frontend via Wails bindings. +// +// The struct manages: +// - Application context for Wails runtime calls +// - File paths for startup and currently loaded emails +// - Tracking of open viewer windows to prevent duplicates type App struct { - ctx context.Context - StartupFilePath string - CurrentMailFilePath string // Tracks the currently loaded mail file (from startup or file dialog) - openImagesMux sync.Mutex - openImages map[string]bool - openPDFsMux sync.Mutex - openPDFs map[string]bool - openEMLsMux sync.Mutex - openEMLs map[string]bool + // ctx is the Wails application context, used for runtime calls like dialogs + ctx context.Context + + // StartupFilePath is set when the app is launched with an email file argument + StartupFilePath string + + // CurrentMailFilePath tracks the currently loaded mail file path + // Used for bug reports to include the relevant email file + CurrentMailFilePath string + + // openImages tracks which images are currently open in viewer windows + // The key is the filename, preventing duplicate viewers for the same file + openImagesMux sync.Mutex + openImages map[string]bool + + // openPDFs tracks which PDFs are currently open in viewer windows + openPDFsMux sync.Mutex + openPDFs map[string]bool + + // openEMLs tracks which EML attachments are currently open in viewer windows + openEMLsMux sync.Mutex + openEMLs map[string]bool } -// NewApp creates a new App application struct +// ============================================================================= +// Constructor & Lifecycle +// ============================================================================= + +// NewApp creates and initializes a new App instance. +// This is called from main.go before the Wails application starts. func NewApp() *App { return &App{ openImages: make(map[string]bool), @@ -45,16 +63,22 @@ func NewApp() *App { } } -// startup is called when the app starts. The context is saved -// so we can call the runtime methods +// startup is called by Wails when the application starts. +// It receives the application context which is required for Wails runtime calls. +// +// This method: +// - Saves the context for later use +// - Syncs CurrentMailFilePath with StartupFilePath if a file was opened via CLI +// - Logs the startup mode (main app vs viewer window) func (a *App) startup(ctx context.Context) { a.ctx = ctx - // Set CurrentMailFilePath to StartupFilePath if a file was opened via command line + // Sync CurrentMailFilePath with StartupFilePath if a file was opened via command line if a.StartupFilePath != "" { a.CurrentMailFilePath = a.StartupFilePath } + // Check if this instance is running as a viewer (image/PDF) rather than main app isViewer := false for _, arg := range os.Args { if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") { @@ -64,22 +88,45 @@ func (a *App) startup(ctx context.Context) { } if isViewer { - Log("Second instance launch") + Log("Viewer instance started") } else { - Log("Wails startup") + Log("EMLy main application started") } } +// shutdown is called by Wails when the application is closing. +// Used for cleanup operations. +func (a *App) shutdown(ctx context.Context) { + // Best-effort cleanup - currently no resources require explicit cleanup +} + +// QuitApp terminates the application. +// It first calls Wails Quit to properly close the window, +// then forces an exit with a specific code. +func (a *App) QuitApp() { + runtime.Quit(a.ctx) + // Exit with code 133 (133 + 5 = 138, SIGTRAP-like exit) + os.Exit(133) +} + +// ============================================================================= +// Configuration Management +// ============================================================================= + +// GetConfig loads and returns the application configuration from config.ini. +// Returns nil if the configuration cannot be loaded. func (a *App) GetConfig() *utils.Config { cfgPath := utils.DefaultConfigPath() cfg, err := utils.LoadConfig(cfgPath) if err != nil { - Log("Failed to load config for version:", err) + Log("Failed to load config:", err) return nil } return cfg } +// SaveConfig persists the provided configuration to config.ini. +// Returns an error if saving fails. func (a *App) SaveConfig(cfg *utils.Config) error { cfgPath := utils.DefaultConfigPath() if err := utils.SaveConfig(cfgPath, cfg); err != nil { @@ -89,859 +136,44 @@ func (a *App) SaveConfig(cfg *utils.Config) error { return nil } -func (a *App) shutdown(ctx context.Context) { - // Best-effort cleanup. +// ============================================================================= +// Startup File Management +// ============================================================================= + +// GetStartupFile returns the file path if the app was launched with an email file argument. +// Returns an empty string if no file was specified at startup. +func (a *App) GetStartupFile() string { + return a.StartupFilePath } -func (a *App) QuitApp() { - runtime.Quit(a.ctx) - // Generate exit code 138 - os.Exit(133) // 133 + 5 (SIGTRAP) +// SetCurrentMailFilePath updates the path of the currently loaded mail file. +// This is called when the user opens a file via the file dialog. +func (a *App) SetCurrentMailFilePath(filePath string) { + a.CurrentMailFilePath = filePath } +// GetCurrentMailFilePath returns the path of the currently loaded mail file. +// Used by bug reports to include the relevant email file. +func (a *App) GetCurrentMailFilePath() string { + return a.CurrentMailFilePath +} + +// ============================================================================= +// System Information +// ============================================================================= + +// GetMachineData retrieves system information about the current machine. +// Returns hostname, OS version, hardware ID, etc. func (a *App) GetMachineData() *utils.MachineInfo { data, _ := utils.GetMachineInfo() return data } -// GetStartupFile returns the file path if the app was opened with a file argument -func (a *App) GetStartupFile() string { - return a.StartupFilePath -} - -// SetCurrentMailFilePath sets the path of the currently loaded mail file -func (a *App) SetCurrentMailFilePath(filePath string) { - a.CurrentMailFilePath = filePath -} - -// GetCurrentMailFilePath returns the path of the currently loaded mail file -func (a *App) GetCurrentMailFilePath() string { - return a.CurrentMailFilePath -} - -// ReadEML reads a .eml file and returns the email data -func (a *App) ReadEML(filePath string) (*internal.EmailData, error) { - return internal.ReadEmlFile(filePath) -} - -// ReadPEC reads a PEC .eml file and returns the inner email data -func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) { - return internal.ReadPecInnerEml(filePath) -} - -// ReadMSG reads a .msg file and returns the email data -func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) { - if useExternalConverter { - return internal.ReadMsgFile(filePath) - } - return internal.ReadMsgFile(filePath) -} - -// ReadMSGOSS reads a .msg file and returns the email data -func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { - return internal.ReadMsgFile(filePath) -} - -// ShowOpenFileDialog shows the file open dialog for EML files -func (a *App) ShowOpenFileDialog() (string, error) { - return internal.ShowFileDialog(a.ctx) -} - -// OpenEMLWindow saves EML to temp and opens a new instance of the app -func (a *App) OpenEMLWindow(base64Data string, filename string) error { - a.openEMLsMux.Lock() - if a.openEMLs[filename] { - a.openEMLsMux.Unlock() - return fmt.Errorf("eml '%s' is already open", filename) - } - a.openEMLs[filename] = true - a.openEMLsMux.Unlock() - - // 1. Decode base64 - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - a.openEMLsMux.Lock() - delete(a.openEMLs, filename) - a.openEMLsMux.Unlock() - return fmt.Errorf("failed to decode base64: %w", err) - } - - // 2. Save to temp file - tempDir := os.TempDir() - // Use timestamp or unique ID to avoid conflicts if multiple files have same name - timestamp := time.Now().Format("20060102_150405") - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s_%s", "emly_attachment", timestamp, filename)) - if err := os.WriteFile(tempFile, data, 0644); err != nil { - a.openEMLsMux.Lock() - delete(a.openEMLs, filename) - a.openEMLsMux.Unlock() - return fmt.Errorf("failed to write temp file: %w", err) - } - - // 3. Launch new instance - exe, err := os.Executable() - if err != nil { - a.openEMLsMux.Lock() - delete(a.openEMLs, filename) - a.openEMLsMux.Unlock() - return fmt.Errorf("failed to get executable path: %w", err) - } - - // Run EMLy with the file path as argument - cmd := exec.Command(exe, tempFile) - if err := cmd.Start(); err != nil { - a.openEMLsMux.Lock() - delete(a.openEMLs, filename) - a.openEMLsMux.Unlock() - return fmt.Errorf("failed to start viewer: %w", err) - } - - // Monitor process in background to release lock when closed - go func() { - cmd.Wait() - a.openEMLsMux.Lock() - delete(a.openEMLs, filename) - a.openEMLsMux.Unlock() - }() - - return nil -} - -// OpenImageWindow opens a new window instance to display the image -func (a *App) OpenImageWindow(base64Data string, filename string) error { - a.openImagesMux.Lock() - if a.openImages[filename] { - a.openImagesMux.Unlock() - return fmt.Errorf("image '%s' is already open", filename) - } - a.openImages[filename] = true - a.openImagesMux.Unlock() - - // 1. Decode base64 - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - a.openImagesMux.Lock() - delete(a.openImages, filename) - a.openImagesMux.Unlock() - return fmt.Errorf("failed to decode base64: %w", err) - } - - // 2. Save to temp file - tempDir := os.TempDir() - // Use timestamp to make unique - timestamp := time.Now().Format("20060102_150405") - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) - if err := os.WriteFile(tempFile, data, 0644); err != nil { - a.openImagesMux.Lock() - delete(a.openImages, filename) - a.openImagesMux.Unlock() - return fmt.Errorf("failed to write temp file: %w", err) - } - - // 3. Launch new instance - exe, err := os.Executable() - if err != nil { - a.openImagesMux.Lock() - delete(a.openImages, filename) - a.openImagesMux.Unlock() - return fmt.Errorf("failed to get executable path: %w", err) - } - - cmd := exec.Command(exe, "--view-image="+tempFile) - if err := cmd.Start(); err != nil { - a.openImagesMux.Lock() - delete(a.openImages, filename) - a.openImagesMux.Unlock() - return fmt.Errorf("failed to start viewer: %w", err) - } - - // Monitor process in background to release lock when closed - go func() { - cmd.Wait() - a.openImagesMux.Lock() - delete(a.openImages, filename) - a.openImagesMux.Unlock() - }() - - return nil -} - -// OpenPDFWindow opens a new window instance to display the PDF -func (a *App) OpenPDFWindow(base64Data string, filename string) error { - a.openPDFsMux.Lock() - if a.openPDFs[filename] { - a.openPDFsMux.Unlock() - return fmt.Errorf("pdf '%s' is already open", filename) - } - a.openPDFs[filename] = true - a.openPDFsMux.Unlock() - - // 1. Decode base64 - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - a.openPDFsMux.Lock() - delete(a.openPDFs, filename) - a.openPDFsMux.Unlock() - return fmt.Errorf("failed to decode base64: %w", err) - } - - // 2. Save to temp file - tempDir := os.TempDir() - // Use timestamp to make unique - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s", filename)) - if err := os.WriteFile(tempFile, data, 0644); err != nil { - a.openPDFsMux.Lock() - delete(a.openPDFs, filename) - a.openPDFsMux.Unlock() - return fmt.Errorf("failed to write temp file: %w", err) - } - - // 3. Launch new instance - exe, err := os.Executable() - if err != nil { - a.openPDFsMux.Lock() - delete(a.openPDFs, filename) - a.openPDFsMux.Unlock() - return fmt.Errorf("failed to get executable path: %w", err) - } - - cmd := exec.Command(exe, "--view-pdf="+tempFile) - if err := cmd.Start(); err != nil { - a.openPDFsMux.Lock() - delete(a.openPDFs, filename) - a.openPDFsMux.Unlock() - return fmt.Errorf("failed to start viewer: %w", err) - } - - // Monitor process in background to release lock when closed - go func() { - cmd.Wait() - a.openPDFsMux.Lock() - delete(a.openPDFs, filename) - a.openPDFsMux.Unlock() - }() - - return nil -} - -// OpenPDF saves PDF to temp and opens with default app -func (a *App) OpenPDF(base64Data string, filename string) error { - if base64Data == "" { - return fmt.Errorf("no data provided") - } - // 1. Decode base64 - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return fmt.Errorf("failed to decode base64: %w", err) - } - - // 2. Save to temp file - tempDir := os.TempDir() - // Use timestamp to make unique - timestamp := time.Now().Format("20060102_150405") - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) - if err := os.WriteFile(tempFile, data, 0644); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - // 3. Open with default app (Windows) - cmd := exec.Command("cmd", "/c", "start", "", tempFile) - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - return nil -} - -// OpenImage saves image to temp and opens with default app (Windows) -func (a *App) OpenImage(base64Data string, filename string) error { - if base64Data == "" { - return fmt.Errorf("no data provided") - } - // 1. Decode base64 - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return fmt.Errorf("failed to decode base64: %w", err) - } - - // 2. Save to temp file - tempDir := os.TempDir() - // Use timestamp to make unique - timestamp := time.Now().Format("20060102_150405") - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) - if err := os.WriteFile(tempFile, data, 0644); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - // 3. Open with default app (Windows) - cmd := exec.Command("cmd", "/c", "start", "", tempFile) - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - return nil -} - -type ImageViewerData struct { - Data string `json:"data"` - Filename string `json:"filename"` -} - -type PDFViewerData struct { - Data string `json:"data"` - Filename string `json:"filename"` -} - -type ViewerData struct { - ImageData *ImageViewerData `json:"imageData,omitempty"` - PDFData *PDFViewerData `json:"pdfData,omitempty"` -} - -// GetImageViewerData checks CLI args and returns image data if in viewer mode -func (a *App) GetImageViewerData() (*ImageViewerData, error) { - for _, arg := range os.Args { - if strings.HasPrefix(arg, "--view-image=") { - filePath := strings.TrimPrefix(arg, "--view-image=") - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read text file: %w", err) - } - // Return encoded base64 so frontend can handle it same way - encoded := base64.StdEncoding.EncodeToString(data) - return &ImageViewerData{ - Data: encoded, - Filename: filepath.Base(filePath), - }, nil - } - } - return nil, nil -} - -// GetPDFViewerData checks CLI args and returns pdf data if in viewer mode -func (a *App) GetPDFViewerData() (*PDFViewerData, error) { - for _, arg := range os.Args { - if strings.HasPrefix(arg, "--view-pdf=") { - filePath := strings.TrimPrefix(arg, "--view-pdf=") - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read text file: %w", err) - } - // Return encoded base64 so frontend can handle it same way - encoded := base64.StdEncoding.EncodeToString(data) - return &PDFViewerData{ - Data: encoded, - Filename: filepath.Base(filePath), - }, nil - } - } - return nil, nil -} - -func (a *App) GetViewerData() (*ViewerData, error) { - for _, arg := range os.Args { - if strings.HasPrefix(arg, "--view-image=") { - filePath := strings.TrimPrefix(arg, "--view-image=") - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read text file: %w", err) - } - // Return encoded base64 so frontend can handle it same way - encoded := base64.StdEncoding.EncodeToString(data) - return &ViewerData{ - ImageData: &ImageViewerData{ - Data: encoded, - Filename: filepath.Base(filePath), - }, - }, nil - } - if strings.HasPrefix(arg, "--view-pdf=") { - filePath := strings.TrimPrefix(arg, "--view-pdf=") - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read text file: %w", err) - } - // Return encoded base64 so frontend can handle it same way - encoded := base64.StdEncoding.EncodeToString(data) - return &ViewerData{ - PDFData: &PDFViewerData{ - Data: encoded, - Filename: filepath.Base(filePath), - }, - }, nil - } - } - return nil, nil -} - -// CheckIsDefaultEMLHandler verifies if the current executable is the default handler for .eml files -func (a *App) CheckIsDefaultEMLHandler() (bool, error) { - // 1. Get current executable path - exePath, err := os.Executable() - if err != nil { - return false, err - } - // Normalize path for comparison - exePath = strings.ToLower(exePath) - - // 2. Open UserChoice key for .eml - k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.eml\UserChoice`, registry.QUERY_VALUE) - if err != nil { - // Key doesn't exist implies user hasn't made a specific choice or system default is active (not us usually) - return false, nil - } - defer k.Close() - - // 3. Get ProgId - progId, _, err := k.GetStringValue("ProgId") - if err != nil { - return false, err - } - - // 4. Find the command for this ProgId - classKeyPath := fmt.Sprintf(`%s\shell\open\command`, progId) - classKey, err := registry.OpenKey(registry.CLASSES_ROOT, classKeyPath, registry.QUERY_VALUE) - if err != nil { - return false, fmt.Errorf("unable to find command for ProgId %s", progId) - } - defer classKey.Close() - - cmd, _, err := classKey.GetStringValue("") - if err != nil { - return false, err - } - - // 5. Compare command with our executable - cmdLower := strings.ToLower(cmd) - - // Basic check: does the command contain our executable name? - // In a real scenario, parsing the exact path respecting quotes would be safer, - // but checking if our specific exe path is present is usually sufficient. - if strings.Contains(cmdLower, strings.ToLower(filepath.Base(exePath))) { - // More robust: escape backslashes and check presence - // cleanExe := strings.ReplaceAll(exePath, `\`, `\\`) - // For now, depending on how registry stores it (short path vs long path), - // containment of the filename is a strong indicator if the filename is unique enough (emly.exe) - return true, nil - } - - return false, nil -} - -// OpenDefaultAppsSettings opens the Windows default apps settings page -func (a *App) OpenDefaultAppsSettings() error { - cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps") - return cmd.Start() -} - +// IsDebuggerRunning checks if a debugger is attached to the application. +// Used for anti-debugging protection in production builds. func (a *App) IsDebuggerRunning() bool { if a == nil { return false } return utils.IsDebugged() } - -func (a *App) ConvertToUTF8(s string) string { - if utf8.ValidString(s) { - return s - } - - // If invalid UTF-8, assume Windows-1252 (superset of ISO-8859-1) - decoder := charmap.Windows1252.NewDecoder() - decoded, _, err := transform.String(decoder, s) - if err != nil { - return s // Return as-is if decoding fails - } - return decoded -} - -// ScreenshotResult contains the screenshot data and metadata -type ScreenshotResult struct { - Data string `json:"data"` // Base64-encoded PNG data - Width int `json:"width"` // Image width in pixels - Height int `json:"height"` // Image height in pixels - Filename string `json:"filename"` // Suggested filename -} - -// TakeScreenshot captures the current Wails application window and returns it as base64 PNG -func (a *App) TakeScreenshot() (*ScreenshotResult, error) { - // Get the window title to find our window - windowTitle := "EMLy - EML Viewer for 3gIT" - - // Check if we're in viewer mode - for _, arg := range os.Args { - if strings.Contains(arg, "--view-image") { - windowTitle = "EMLy Image Viewer" - break - } - if strings.Contains(arg, "--view-pdf") { - windowTitle = "EMLy PDF Viewer" - break - } - } - - img, err := utils.CaptureWindowByTitle(windowTitle) - if err != nil { - return nil, fmt.Errorf("failed to capture window: %w", err) - } - - base64Data, err := utils.ScreenshotToBase64PNG(img) - if err != nil { - return nil, fmt.Errorf("failed to encode screenshot: %w", err) - } - - bounds := img.Bounds() - timestamp := time.Now().Format("20060102_150405") - - return &ScreenshotResult{ - Data: base64Data, - Width: bounds.Dx(), - Height: bounds.Dy(), - Filename: fmt.Sprintf("emly_screenshot_%s.png", timestamp), - }, nil -} - -// SaveScreenshot captures and saves the screenshot to a file, returning the file path -func (a *App) SaveScreenshot() (string, error) { - result, err := a.TakeScreenshot() - if err != nil { - return "", err - } - - // Decode base64 data - data, err := base64.StdEncoding.DecodeString(result.Data) - if err != nil { - return "", fmt.Errorf("failed to decode screenshot data: %w", err) - } - - // Save to temp directory - tempDir := os.TempDir() - filePath := filepath.Join(tempDir, result.Filename) - - if err := os.WriteFile(filePath, data, 0644); err != nil { - return "", fmt.Errorf("failed to save screenshot: %w", err) - } - - return filePath, nil -} - -// SaveScreenshotAs opens a save dialog and saves the screenshot to the selected location -func (a *App) SaveScreenshotAs() (string, error) { - result, err := a.TakeScreenshot() - if err != nil { - return "", err - } - - // Open save dialog - savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ - DefaultFilename: result.Filename, - Title: "Save Screenshot", - Filters: []runtime.FileFilter{ - { - DisplayName: "PNG Images (*.png)", - Pattern: "*.png", - }, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to open save dialog: %w", err) - } - - if savePath == "" { - return "", nil // User cancelled - } - - // Decode base64 data - data, err := base64.StdEncoding.DecodeString(result.Data) - if err != nil { - return "", fmt.Errorf("failed to decode screenshot data: %w", err) - } - - if err := os.WriteFile(savePath, data, 0644); err != nil { - return "", fmt.Errorf("failed to save screenshot: %w", err) - } - - return savePath, nil -} - -// BugReportResult contains paths to the bug report files -type BugReportResult struct { - FolderPath string `json:"folderPath"` // Path to the bug report folder - ScreenshotPath string `json:"screenshotPath"` // Path to the screenshot file - MailFilePath string `json:"mailFilePath"` // Path to the copied mail file (empty if no mail) -} - -// CreateBugReportFolder creates a folder in temp with screenshot and optionally the current mail file -func (a *App) CreateBugReportFolder() (*BugReportResult, error) { - // Create timestamp for unique folder name - timestamp := time.Now().Format("20060102_150405") - folderName := fmt.Sprintf("emly_bugreport_%s", timestamp) - - // Create folder in temp directory - tempDir := os.TempDir() - bugReportFolder := filepath.Join(tempDir, folderName) - - if err := os.MkdirAll(bugReportFolder, 0755); err != nil { - return nil, fmt.Errorf("failed to create bug report folder: %w", err) - } - - result := &BugReportResult{ - FolderPath: bugReportFolder, - } - - // Take and save screenshot - screenshotResult, err := a.TakeScreenshot() - if err != nil { - return nil, fmt.Errorf("failed to take screenshot: %w", err) - } - - screenshotData, err := base64.StdEncoding.DecodeString(screenshotResult.Data) - if err != nil { - return nil, fmt.Errorf("failed to decode screenshot: %w", err) - } - - screenshotPath := filepath.Join(bugReportFolder, screenshotResult.Filename) - if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil { - return nil, fmt.Errorf("failed to save screenshot: %w", err) - } - result.ScreenshotPath = screenshotPath - - // Check if there's a mail file loaded and copy it - if a.CurrentMailFilePath != "" { - // Read the original mail file - mailData, err := os.ReadFile(a.CurrentMailFilePath) - if err != nil { - // Log but don't fail - screenshot is still valid - Log("Failed to read mail file for bug report:", err) - } else { - // Get the original filename - mailFilename := filepath.Base(a.CurrentMailFilePath) - mailFilePath := filepath.Join(bugReportFolder, mailFilename) - - if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil { - Log("Failed to copy mail file for bug report:", err) - } else { - result.MailFilePath = mailFilePath - } - } - } - - return result, nil -} - -// BugReportInput contains the user-provided bug report details -type BugReportInput struct { - Name string `json:"name"` - Email string `json:"email"` - Description string `json:"description"` - ScreenshotData string `json:"screenshotData"` // Base64-encoded PNG screenshot -} - -// SubmitBugReportResult contains the result of submitting a bug report -type SubmitBugReportResult struct { - ZipPath string `json:"zipPath"` // Path to the created zip file - FolderPath string `json:"folderPath"` // Path to the bug report folder -} - -// SubmitBugReport creates a complete bug report with user input, saves it, and zips the folder -func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, error) { - // Create timestamp for unique folder name - timestamp := time.Now().Format("20060102_150405") - folderName := fmt.Sprintf("emly_bugreport_%s", timestamp) - - // Create folder in temp directory - tempDir := os.TempDir() - bugReportFolder := filepath.Join(tempDir, folderName) - - if err := os.MkdirAll(bugReportFolder, 0755); err != nil { - return nil, fmt.Errorf("failed to create bug report folder: %w", err) - } - - // Save the pre-captured screenshot - if input.ScreenshotData != "" { - screenshotData, err := base64.StdEncoding.DecodeString(input.ScreenshotData) - if err != nil { - Log("Failed to decode screenshot:", err) - } else { - screenshotPath := filepath.Join(bugReportFolder, fmt.Sprintf("emly_screenshot_%s.png", timestamp)) - if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil { - Log("Failed to save screenshot:", err) - } - } - } - - // Copy the mail file if one is loaded - if a.CurrentMailFilePath != "" { - mailData, err := os.ReadFile(a.CurrentMailFilePath) - if err != nil { - Log("Failed to read mail file for bug report:", err) - } else { - mailFilename := filepath.Base(a.CurrentMailFilePath) - mailFilePath := filepath.Join(bugReportFolder, mailFilename) - if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil { - Log("Failed to copy mail file for bug report:", err) - } - } - } - - // Create the report.txt file with user's description - reportContent := fmt.Sprintf(`EMLy Bug Report -================ - -Name: %s -Email: %s - -Description: -%s - -Generated: %s -`, input.Name, input.Email, input.Description, time.Now().Format("2006-01-02 15:04:05")) - - reportPath := filepath.Join(bugReportFolder, "report.txt") - if err := os.WriteFile(reportPath, []byte(reportContent), 0644); err != nil { - return nil, fmt.Errorf("failed to save report file: %w", err) - } - - // Get machine info and save it - machineInfo, err := utils.GetMachineInfo() - if err == nil && machineInfo != nil { - sysInfoContent := fmt.Sprintf(`System Information -================== - -Hostname: %s -OS: %s -Version: %s -Hardware ID: %s -External IP: %s -`, machineInfo.Hostname, machineInfo.OS, machineInfo.Version, machineInfo.HWID, machineInfo.ExternalIP) - - sysInfoPath := filepath.Join(bugReportFolder, "system_info.txt") - if err := os.WriteFile(sysInfoPath, []byte(sysInfoContent), 0644); err != nil { - Log("Failed to save system info:", err) - } - } - - // Zip the folder - zipPath := bugReportFolder + ".zip" - if err := zipFolder(bugReportFolder, zipPath); err != nil { - return nil, fmt.Errorf("failed to create zip file: %w", err) - } - - return &SubmitBugReportResult{ - ZipPath: zipPath, - FolderPath: bugReportFolder, - }, nil -} - -// zipFolder creates a zip archive of the given folder -func zipFolder(sourceFolder, destZip string) error { - zipFile, err := os.Create(destZip) - if err != nil { - return err - } - defer zipFile.Close() - - zipWriter := zip.NewWriter(zipFile) - defer zipWriter.Close() - - // Walk through the folder and add all files to the zip - return filepath.Walk(sourceFolder, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Skip the root folder itself - if path == sourceFolder { - return nil - } - - // Get relative path for the zip entry - relPath, err := filepath.Rel(sourceFolder, path) - if err != nil { - return err - } - - // Skip directories (they'll be created implicitly) - if info.IsDir() { - return nil - } - - // Create the file in the zip - writer, err := zipWriter.Create(relPath) - if err != nil { - return err - } - - // Read the file content - fileContent, err := os.ReadFile(path) - if err != nil { - return err - } - - // Write to zip - _, err = writer.Write(fileContent) - return err - }) -} - -// OpenFolderInExplorer opens the specified folder in Windows Explorer -func (a *App) OpenFolderInExplorer(folderPath string) error { - cmd := exec.Command("explorer", folderPath) - return cmd.Start() -} - -// ExportSettings opens a save dialog and exports settings JSON to the selected file -func (a *App) ExportSettings(settingsJSON string) (string, error) { - savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ - DefaultFilename: "emly_settings.json", - Title: "Export Settings", - Filters: []runtime.FileFilter{ - { - DisplayName: "JSON Files (*.json)", - Pattern: "*.json", - }, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to open save dialog: %w", err) - } - - if savePath == "" { - return "", nil // User cancelled - } - - // Ensure .json extension - if !strings.HasSuffix(strings.ToLower(savePath), ".json") { - savePath += ".json" - } - - if err := os.WriteFile(savePath, []byte(settingsJSON), 0644); err != nil { - return "", fmt.Errorf("failed to write settings file: %w", err) - } - - return savePath, nil -} - -// ImportSettings opens an open dialog and returns the contents of the selected JSON file -func (a *App) ImportSettings() (string, error) { - openPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ - Title: "Import Settings", - Filters: []runtime.FileFilter{ - { - DisplayName: "JSON Files (*.json)", - Pattern: "*.json", - }, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to open file dialog: %w", err) - } - - if openPath == "" { - return "", nil // User cancelled - } - - data, err := os.ReadFile(openPath) - if err != nil { - return "", fmt.Errorf("failed to read settings file: %w", err) - } - - return string(data), nil -} diff --git a/app_bugreport.go b/app_bugreport.go new file mode 100644 index 0000000..e30f410 --- /dev/null +++ b/app_bugreport.go @@ -0,0 +1,281 @@ +// Package main provides bug reporting functionality for EMLy. +// This file contains methods for creating bug reports with screenshots, +// email files, and system information. +package main + +import ( + "archive/zip" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "time" + + "emly/backend/utils" +) + +// ============================================================================= +// Bug Report Types +// ============================================================================= + +// BugReportResult contains paths to the generated bug report files. +type BugReportResult struct { + // FolderPath is the path to the bug report folder in temp + FolderPath string `json:"folderPath"` + // ScreenshotPath is the path to the captured screenshot file + ScreenshotPath string `json:"screenshotPath"` + // MailFilePath is the path to the copied mail file (empty if no mail loaded) + MailFilePath string `json:"mailFilePath"` +} + +// BugReportInput contains the user-provided bug report details. +type BugReportInput struct { + // Name is the user's name + Name string `json:"name"` + // Email is the user's email address for follow-up + Email string `json:"email"` + // Description is the detailed bug description + Description string `json:"description"` + // ScreenshotData is the base64-encoded PNG screenshot (captured before dialog opens) + ScreenshotData string `json:"screenshotData"` +} + +// SubmitBugReportResult contains the result of submitting a bug report. +type SubmitBugReportResult struct { + // ZipPath is the path to the created zip file + ZipPath string `json:"zipPath"` + // FolderPath is the path to the bug report folder + FolderPath string `json:"folderPath"` +} + +// ============================================================================= +// Bug Report Methods +// ============================================================================= + +// CreateBugReportFolder creates a folder in temp with screenshot and optionally +// the current mail file. This is used for the legacy bug report flow. +// +// Returns: +// - *BugReportResult: Paths to created files +// - error: Error if folder creation or file operations fail +func (a *App) CreateBugReportFolder() (*BugReportResult, error) { + // Create unique folder name with timestamp + timestamp := time.Now().Format("20060102_150405") + folderName := fmt.Sprintf("emly_bugreport_%s", timestamp) + + // Create folder in temp directory + tempDir := os.TempDir() + bugReportFolder := filepath.Join(tempDir, folderName) + + if err := os.MkdirAll(bugReportFolder, 0755); err != nil { + return nil, fmt.Errorf("failed to create bug report folder: %w", err) + } + + result := &BugReportResult{ + FolderPath: bugReportFolder, + } + + // Take and save screenshot + screenshotResult, err := a.TakeScreenshot() + if err != nil { + return nil, fmt.Errorf("failed to take screenshot: %w", err) + } + + screenshotData, err := base64.StdEncoding.DecodeString(screenshotResult.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode screenshot: %w", err) + } + + screenshotPath := filepath.Join(bugReportFolder, screenshotResult.Filename) + if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil { + return nil, fmt.Errorf("failed to save screenshot: %w", err) + } + result.ScreenshotPath = screenshotPath + + // Copy currently loaded mail file if one exists + if a.CurrentMailFilePath != "" { + mailData, err := os.ReadFile(a.CurrentMailFilePath) + if err != nil { + // Log but don't fail - screenshot is still valid + Log("Failed to read mail file for bug report:", err) + } else { + mailFilename := filepath.Base(a.CurrentMailFilePath) + mailFilePath := filepath.Join(bugReportFolder, mailFilename) + + if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil { + Log("Failed to copy mail file for bug report:", err) + } else { + result.MailFilePath = mailFilePath + } + } + } + + return result, nil +} + +// SubmitBugReport creates a complete bug report with user input, saves all files, +// and creates a zip archive ready for submission. +// +// The bug report includes: +// - User-provided description (report.txt) +// - Screenshot (captured before dialog opens) +// - Currently loaded mail file (if any) +// - System information (hostname, OS version, hardware ID) +// +// Parameters: +// - input: User-provided bug report details including pre-captured screenshot +// +// Returns: +// - *SubmitBugReportResult: Paths to the zip file and folder +// - error: Error if any file operation fails +func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, error) { + // Create unique folder name with timestamp + timestamp := time.Now().Format("20060102_150405") + folderName := fmt.Sprintf("emly_bugreport_%s", timestamp) + + // Create folder in temp directory + tempDir := os.TempDir() + bugReportFolder := filepath.Join(tempDir, folderName) + + if err := os.MkdirAll(bugReportFolder, 0755); err != nil { + return nil, fmt.Errorf("failed to create bug report folder: %w", err) + } + + // Save the pre-captured screenshot (captured before dialog opened) + if input.ScreenshotData != "" { + screenshotData, err := base64.StdEncoding.DecodeString(input.ScreenshotData) + if err != nil { + Log("Failed to decode screenshot:", err) + } else { + screenshotPath := filepath.Join(bugReportFolder, fmt.Sprintf("emly_screenshot_%s.png", timestamp)) + if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil { + Log("Failed to save screenshot:", err) + } + } + } + + // Copy the mail file if one is loaded + if a.CurrentMailFilePath != "" { + mailData, err := os.ReadFile(a.CurrentMailFilePath) + if err != nil { + Log("Failed to read mail file for bug report:", err) + } else { + mailFilename := filepath.Base(a.CurrentMailFilePath) + mailFilePath := filepath.Join(bugReportFolder, mailFilename) + if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil { + Log("Failed to copy mail file for bug report:", err) + } + } + } + + // Create the report.txt file with user's description + reportContent := fmt.Sprintf(`EMLy Bug Report +================ + +Name: %s +Email: %s + +Description: +%s + +Generated: %s +`, input.Name, input.Email, input.Description, time.Now().Format("2006-01-02 15:04:05")) + + reportPath := filepath.Join(bugReportFolder, "report.txt") + if err := os.WriteFile(reportPath, []byte(reportContent), 0644); err != nil { + return nil, fmt.Errorf("failed to save report file: %w", err) + } + + // Get and save machine/system information + machineInfo, err := utils.GetMachineInfo() + if err == nil && machineInfo != nil { + sysInfoContent := fmt.Sprintf(`System Information +================== + +Hostname: %s +OS: %s +Version: %s +Hardware ID: %s +External IP: %s +`, machineInfo.Hostname, machineInfo.OS, machineInfo.Version, machineInfo.HWID, machineInfo.ExternalIP) + + sysInfoPath := filepath.Join(bugReportFolder, "system_info.txt") + if err := os.WriteFile(sysInfoPath, []byte(sysInfoContent), 0644); err != nil { + Log("Failed to save system info:", err) + } + } + + // Create zip archive of the folder + zipPath := bugReportFolder + ".zip" + if err := zipFolder(bugReportFolder, zipPath); err != nil { + return nil, fmt.Errorf("failed to create zip file: %w", err) + } + + return &SubmitBugReportResult{ + ZipPath: zipPath, + FolderPath: bugReportFolder, + }, nil +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +// zipFolder creates a zip archive containing all files from the source folder. +// Directories are traversed recursively but stored implicitly (no directory entries). +// +// Parameters: +// - sourceFolder: Path to the folder to zip +// - destZip: Path where the zip file should be created +// +// Returns: +// - error: Error if any file operation fails +func zipFolder(sourceFolder, destZip string) error { + // Create the zip file + zipFile, err := os.Create(destZip) + if err != nil { + return err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Walk through the folder and add all files + return filepath.Walk(sourceFolder, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip the root folder itself + if path == sourceFolder { + return nil + } + + // Get relative path for the zip entry + relPath, err := filepath.Rel(sourceFolder, path) + if err != nil { + return err + } + + // Skip directories (they're created implicitly) + if info.IsDir() { + return nil + } + + // Create the file entry in the zip + writer, err := zipWriter.Create(relPath) + if err != nil { + return err + } + + // Read and write the file content + fileContent, err := os.ReadFile(path) + if err != nil { + return err + } + + _, err = writer.Write(fileContent) + return err + }) +} diff --git a/app_mail.go b/app_mail.go new file mode 100644 index 0000000..003e2ff --- /dev/null +++ b/app_mail.go @@ -0,0 +1,88 @@ +// Package main provides email reading functionality for EMLy. +// This file contains methods for reading EML, MSG, and PEC email files. +package main + +import ( + "emly/backend/utils/mail" +) + +// ============================================================================= +// Email Reading Methods +// ============================================================================= + +// ReadEML reads a standard .eml file and returns the parsed email data. +// EML files are MIME-formatted email messages commonly exported from email clients. +// +// Parameters: +// - filePath: Absolute path to the .eml file +// +// Returns: +// - *internal.EmailData: Parsed email with headers, body, and attachments +// - error: Any parsing errors +func (a *App) ReadEML(filePath string) (*internal.EmailData, error) { + return internal.ReadEmlFile(filePath) +} + +// ReadPEC reads a PEC (Posta Elettronica Certificata) .eml file. +// PEC emails are Italian certified emails that contain an inner email message +// wrapped in a certification envelope with digital signatures. +// +// This method extracts and returns the inner original email, ignoring the +// certification wrapper (daticert.xml and signature files are available as attachments). +// +// Parameters: +// - filePath: Absolute path to the PEC .eml file +// +// Returns: +// - *internal.EmailData: The inner original email content +// - error: Any parsing errors +func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) { + return internal.ReadPecInnerEml(filePath) +} + +// ReadMSG reads a Microsoft Outlook .msg file and returns the email data. +// MSG files use the CFB (Compound File Binary) format, which is a proprietary +// format used by Microsoft Office applications. +// +// This method uses an external converter to properly parse the MSG format +// and extract headers, body, and attachments. +// +// Parameters: +// - filePath: Absolute path to the .msg file +// - useExternalConverter: Whether to use external conversion (currently always true) +// +// Returns: +// - *internal.EmailData: Parsed email data +// - error: Any parsing or conversion errors +func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) { + // The useExternalConverter parameter is kept for API compatibility + // but the implementation always uses the internal MSG reader + return internal.ReadMsgFile(filePath) +} + +// ReadMSGOSS reads a .msg file using the open-source parser. +// This is an alternative entry point that explicitly uses the OSS implementation. +// +// Parameters: +// - filePath: Absolute path to the .msg file +// +// Returns: +// - *internal.EmailData: Parsed email data +// - error: Any parsing errors +func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { + return internal.ReadMsgFile(filePath) +} + +// ShowOpenFileDialog displays the system file picker dialog filtered for email files. +// This allows users to browse and select .eml or .msg files to open. +// +// The dialog is configured with filters for: +// - EML files (*.eml) +// - MSG files (*.msg) +// +// Returns: +// - string: The selected file path, or empty string if cancelled +// - error: Any dialog errors +func (a *App) ShowOpenFileDialog() (string, error) { + return internal.ShowFileDialog(a.ctx) +} diff --git a/app_screenshot.go b/app_screenshot.go new file mode 100644 index 0000000..2e61ea0 --- /dev/null +++ b/app_screenshot.go @@ -0,0 +1,164 @@ +// Package main provides screenshot functionality for EMLy. +// This file contains methods for capturing, saving, and exporting screenshots +// of the application window. +package main + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "emly/backend/utils" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ============================================================================= +// Screenshot Types +// ============================================================================= + +// ScreenshotResult contains the captured screenshot data and metadata. +type ScreenshotResult struct { + // Data is the base64-encoded PNG image data + Data string `json:"data"` + // Width is the image width in pixels + Width int `json:"width"` + // Height is the image height in pixels + Height int `json:"height"` + // Filename is the suggested filename for saving + Filename string `json:"filename"` +} + +// ============================================================================= +// Screenshot Methods +// ============================================================================= + +// TakeScreenshot captures the current EMLy application window and returns it as base64 PNG. +// This uses Windows GDI API to capture the window contents, handling DWM composition +// for proper rendering of modern Windows applications. +// +// The method automatically detects whether the app is in main mode or viewer mode +// and captures the appropriate window. +// +// Returns: +// - *ScreenshotResult: Contains base64 PNG data, dimensions, and suggested filename +// - error: Error if window capture or encoding fails +func (a *App) TakeScreenshot() (*ScreenshotResult, error) { + // Determine window title based on current mode + windowTitle := "EMLy - EML Viewer for 3gIT" + + // Check if running in viewer mode + for _, arg := range os.Args { + if strings.Contains(arg, "--view-image") { + windowTitle = "EMLy Image Viewer" + break + } + if strings.Contains(arg, "--view-pdf") { + windowTitle = "EMLy PDF Viewer" + break + } + } + + // Capture the window using Windows GDI API + img, err := utils.CaptureWindowByTitle(windowTitle) + if err != nil { + return nil, fmt.Errorf("failed to capture window: %w", err) + } + + // Encode to PNG and convert to base64 + base64Data, err := utils.ScreenshotToBase64PNG(img) + if err != nil { + return nil, fmt.Errorf("failed to encode screenshot: %w", err) + } + + // Build result with metadata + bounds := img.Bounds() + timestamp := time.Now().Format("20060102_150405") + + return &ScreenshotResult{ + Data: base64Data, + Width: bounds.Dx(), + Height: bounds.Dy(), + Filename: fmt.Sprintf("emly_screenshot_%s.png", timestamp), + }, nil +} + +// SaveScreenshot captures and saves the screenshot to the system temp directory. +// This is a convenience method that captures and saves in one step. +// +// Returns: +// - string: The full path to the saved screenshot file +// - error: Error if capture or save fails +func (a *App) SaveScreenshot() (string, error) { + // Capture the screenshot + result, err := a.TakeScreenshot() + if err != nil { + return "", err + } + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(result.Data) + if err != nil { + return "", fmt.Errorf("failed to decode screenshot data: %w", err) + } + + // Save to temp directory + tempDir := os.TempDir() + filePath := filepath.Join(tempDir, result.Filename) + + if err := os.WriteFile(filePath, data, 0644); err != nil { + return "", fmt.Errorf("failed to save screenshot: %w", err) + } + + return filePath, nil +} + +// SaveScreenshotAs captures a screenshot and opens a save dialog for the user +// to choose where to save it. +// +// Returns: +// - string: The selected save path, or empty string if cancelled +// - error: Error if capture, dialog, or save fails +func (a *App) SaveScreenshotAs() (string, error) { + // Capture the screenshot first + result, err := a.TakeScreenshot() + if err != nil { + return "", err + } + + // Open save dialog with PNG filter + savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + DefaultFilename: result.Filename, + Title: "Save Screenshot", + Filters: []runtime.FileFilter{ + { + DisplayName: "PNG Images (*.png)", + Pattern: "*.png", + }, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to open save dialog: %w", err) + } + + // User cancelled + if savePath == "" { + return "", nil + } + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(result.Data) + if err != nil { + return "", fmt.Errorf("failed to decode screenshot data: %w", err) + } + + // Save to selected location + if err := os.WriteFile(savePath, data, 0644); err != nil { + return "", fmt.Errorf("failed to save screenshot: %w", err) + } + + return savePath, nil +} diff --git a/app_settings.go b/app_settings.go new file mode 100644 index 0000000..e52e700 --- /dev/null +++ b/app_settings.go @@ -0,0 +1,100 @@ +// Package main provides settings import/export functionality for EMLy. +// This file contains methods for exporting and importing application settings +// as JSON files. +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ============================================================================= +// Settings Export/Import Methods +// ============================================================================= + +// ExportSettings opens a save dialog and exports the provided settings JSON +// to the selected file location. +// +// The dialog is pre-configured with: +// - Default filename: emly_settings.json +// - Filter for JSON files +// +// Parameters: +// - settingsJSON: The JSON string containing all application settings +// +// Returns: +// - string: The path where settings were saved, or empty if cancelled +// - error: Error if dialog or file operations fail +func (a *App) ExportSettings(settingsJSON string) (string, error) { + // Open save dialog with JSON filter + savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + DefaultFilename: "emly_settings.json", + Title: "Export Settings", + Filters: []runtime.FileFilter{ + { + DisplayName: "JSON Files (*.json)", + Pattern: "*.json", + }, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to open save dialog: %w", err) + } + + // User cancelled + if savePath == "" { + return "", nil + } + + // Ensure .json extension + if !strings.HasSuffix(strings.ToLower(savePath), ".json") { + savePath += ".json" + } + + // Write the settings file + if err := os.WriteFile(savePath, []byte(settingsJSON), 0644); err != nil { + return "", fmt.Errorf("failed to write settings file: %w", err) + } + + return savePath, nil +} + +// ImportSettings opens a file dialog for the user to select a settings JSON file +// and returns its contents. +// +// The dialog is configured to only show JSON files. +// +// Returns: +// - string: The JSON content of the selected file, or empty if cancelled +// - error: Error if dialog or file operations fail +func (a *App) ImportSettings() (string, error) { + // Open file dialog with JSON filter + openPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Import Settings", + Filters: []runtime.FileFilter{ + { + DisplayName: "JSON Files (*.json)", + Pattern: "*.json", + }, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to open file dialog: %w", err) + } + + // User cancelled + if openPath == "" { + return "", nil + } + + // Read the settings file + data, err := os.ReadFile(openPath) + if err != nil { + return "", fmt.Errorf("failed to read settings file: %w", err) + } + + return string(data), nil +} diff --git a/app_system.go b/app_system.go new file mode 100644 index 0000000..fe24693 --- /dev/null +++ b/app_system.go @@ -0,0 +1,146 @@ +// Package main provides system-level utilities for EMLy. +// This file contains methods for Windows registry access, character encoding +// conversion, and file system operations. +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode/utf8" + + "golang.org/x/sys/windows/registry" + "golang.org/x/text/encoding/charmap" + "golang.org/x/text/transform" +) + +// ============================================================================= +// Windows Default App Handler +// ============================================================================= + +// CheckIsDefaultEMLHandler checks if EMLy is registered as the default handler +// for .eml files in Windows. +// +// This works by: +// 1. Getting the current executable path +// 2. Reading the UserChoice registry key for .eml files +// 3. Finding the command associated with the chosen ProgId +// 4. Comparing the command with our executable +// +// Returns: +// - bool: True if EMLy is the default handler +// - error: Error if registry access fails +func (a *App) CheckIsDefaultEMLHandler() (bool, error) { + // Get current executable path for comparison + exePath, err := os.Executable() + if err != nil { + return false, err + } + exePath = strings.ToLower(exePath) + + // Open the UserChoice key for .eml extension + // This is where Windows stores the user's chosen default app + k, err := registry.OpenKey( + registry.CURRENT_USER, + `Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.eml\UserChoice`, + registry.QUERY_VALUE, + ) + if err != nil { + // Key doesn't exist - user hasn't made a specific choice + // or system default is active (which is usually not us) + return false, nil + } + defer k.Close() + + // Get the ProgId (program identifier) for the chosen app + progId, _, err := k.GetStringValue("ProgId") + if err != nil { + return false, err + } + + // Find the command associated with this ProgId + classKeyPath := fmt.Sprintf(`%s\shell\open\command`, progId) + classKey, err := registry.OpenKey(registry.CLASSES_ROOT, classKeyPath, registry.QUERY_VALUE) + if err != nil { + return false, fmt.Errorf("unable to find command for ProgId %s", progId) + } + defer classKey.Close() + + // Get the command string + cmd, _, err := classKey.GetStringValue("") + if err != nil { + return false, err + } + + // Compare command with our executable + // Check if the command contains our executable name + cmdLower := strings.ToLower(cmd) + if strings.Contains(cmdLower, strings.ToLower(filepath.Base(exePath))) { + return true, nil + } + + return false, nil +} + +// OpenDefaultAppsSettings opens the Windows Settings app to the Default Apps page. +// This allows users to easily set EMLy as the default handler for email files. +// +// Returns: +// - error: Error if launching settings fails +func (a *App) OpenDefaultAppsSettings() error { + cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps") + return cmd.Start() +} + +// ============================================================================= +// Character Encoding +// ============================================================================= + +// ConvertToUTF8 attempts to convert a string to valid UTF-8. +// If the string is already valid UTF-8, it's returned as-is. +// Otherwise, it assumes Windows-1252 encoding (common for legacy emails) +// and attempts to decode it. +// +// This is particularly useful for email body content that may have been +// encoded with legacy Western European character sets. +// +// Parameters: +// - s: The string to convert +// +// Returns: +// - string: UTF-8 encoded string +func (a *App) ConvertToUTF8(s string) string { + // If already valid UTF-8, return as-is + if utf8.ValidString(s) { + return s + } + + // Assume Windows-1252 (superset of ISO-8859-1) + // This is the most common encoding for legacy Western European text + decoder := charmap.Windows1252.NewDecoder() + decoded, _, err := transform.String(decoder, s) + if err != nil { + // Return original if decoding fails + return s + } + return decoded +} + +// ============================================================================= +// File System Operations +// ============================================================================= + +// OpenFolderInExplorer opens the specified folder in Windows Explorer. +// This is used to show the user where bug report files are saved. +// +// Parameters: +// - folderPath: The path to the folder to open +// +// Returns: +// - error: Error if launching explorer fails +func (a *App) OpenFolderInExplorer(folderPath string) error { + cmd := exec.Command("explorer", folderPath) + return cmd.Start() +} diff --git a/app_viewer.go b/app_viewer.go new file mode 100644 index 0000000..013ac1f --- /dev/null +++ b/app_viewer.go @@ -0,0 +1,429 @@ +// Package main provides viewer window functionality for EMLy. +// This file contains methods for opening attachments in viewer windows +// or with external applications. +package main + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// ============================================================================= +// Viewer Data Types +// ============================================================================= + +// ImageViewerData contains the data needed to display an image in the viewer window. +type ImageViewerData struct { + // Data is the base64-encoded image data + Data string `json:"data"` + // Filename is the original filename of the image + Filename string `json:"filename"` +} + +// PDFViewerData contains the data needed to display a PDF in the viewer window. +type PDFViewerData struct { + // Data is the base64-encoded PDF data + Data string `json:"data"` + // Filename is the original filename of the PDF + Filename string `json:"filename"` +} + +// ViewerData is a union type that contains either image or PDF viewer data. +// Used by the viewer page to determine which type of content to display. +type ViewerData struct { + // ImageData is set when viewing an image (mutually exclusive with PDFData) + ImageData *ImageViewerData `json:"imageData,omitempty"` + // PDFData is set when viewing a PDF (mutually exclusive with ImageData) + PDFData *PDFViewerData `json:"pdfData,omitempty"` +} + +// ============================================================================= +// Built-in Viewer Window Methods +// ============================================================================= + +// OpenEMLWindow opens an EML attachment in a new EMLy window. +// The EML data is saved to a temp file and a new EMLy instance is launched. +// +// This method tracks open EML files to prevent duplicate windows for the same file. +// The tracking is released when the viewer window is closed. +// +// Parameters: +// - base64Data: Base64-encoded EML file content +// - filename: The original filename of the EML attachment +// +// Returns: +// - error: Error if the file is already open or if launching fails +func (a *App) OpenEMLWindow(base64Data string, filename string) error { + // Check if this EML is already open + a.openEMLsMux.Lock() + if a.openEMLs[filename] { + a.openEMLsMux.Unlock() + return fmt.Errorf("eml '%s' is already open", filename) + } + a.openEMLs[filename] = true + a.openEMLsMux.Unlock() + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to decode base64: %w", err) + } + + // Save to temp file with timestamp to avoid conflicts + tempDir := os.TempDir() + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s_%s", "emly_attachment", timestamp, filename)) + if err := os.WriteFile(tempFile, data, 0644); err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Launch new EMLy instance with the file path + exe, err := os.Executable() + if err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to get executable path: %w", err) + } + + cmd := exec.Command(exe, tempFile) + if err := cmd.Start(); err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to start viewer: %w", err) + } + + // Monitor process in background to release lock when closed + go func() { + cmd.Wait() + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + }() + + return nil +} + +// OpenImageWindow opens an image attachment in a new EMLy viewer window. +// The image data is saved to a temp file and a new EMLy instance is launched +// with the --view-image flag. +// +// This method tracks open images to prevent duplicate windows for the same file. +// +// Parameters: +// - base64Data: Base64-encoded image data +// - filename: The original filename of the image +// +// Returns: +// - error: Error if the image is already open or if launching fails +func (a *App) OpenImageWindow(base64Data string, filename string) error { + // Check if this image is already open + a.openImagesMux.Lock() + if a.openImages[filename] { + a.openImagesMux.Unlock() + return fmt.Errorf("image '%s' is already open", filename) + } + a.openImages[filename] = true + a.openImagesMux.Unlock() + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + a.openImagesMux.Lock() + delete(a.openImages, filename) + a.openImagesMux.Unlock() + return fmt.Errorf("failed to decode base64: %w", err) + } + + // Save to temp file + tempDir := os.TempDir() + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) + if err := os.WriteFile(tempFile, data, 0644); err != nil { + a.openImagesMux.Lock() + delete(a.openImages, filename) + a.openImagesMux.Unlock() + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Launch new EMLy instance in image viewer mode + exe, err := os.Executable() + if err != nil { + a.openImagesMux.Lock() + delete(a.openImages, filename) + a.openImagesMux.Unlock() + return fmt.Errorf("failed to get executable path: %w", err) + } + + cmd := exec.Command(exe, "--view-image="+tempFile) + if err := cmd.Start(); err != nil { + a.openImagesMux.Lock() + delete(a.openImages, filename) + a.openImagesMux.Unlock() + return fmt.Errorf("failed to start viewer: %w", err) + } + + // Monitor process in background to release lock when closed + go func() { + cmd.Wait() + a.openImagesMux.Lock() + delete(a.openImages, filename) + a.openImagesMux.Unlock() + }() + + return nil +} + +// OpenPDFWindow opens a PDF attachment in a new EMLy viewer window. +// The PDF data is saved to a temp file and a new EMLy instance is launched +// with the --view-pdf flag. +// +// This method tracks open PDFs to prevent duplicate windows for the same file. +// +// Parameters: +// - base64Data: Base64-encoded PDF data +// - filename: The original filename of the PDF +// +// Returns: +// - error: Error if the PDF is already open or if launching fails +func (a *App) OpenPDFWindow(base64Data string, filename string) error { + // Check if this PDF is already open + a.openPDFsMux.Lock() + if a.openPDFs[filename] { + a.openPDFsMux.Unlock() + return fmt.Errorf("pdf '%s' is already open", filename) + } + a.openPDFs[filename] = true + a.openPDFsMux.Unlock() + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + a.openPDFsMux.Lock() + delete(a.openPDFs, filename) + a.openPDFsMux.Unlock() + return fmt.Errorf("failed to decode base64: %w", err) + } + + // Save to temp file + tempDir := os.TempDir() + tempFile := filepath.Join(tempDir, filename) + if err := os.WriteFile(tempFile, data, 0644); err != nil { + a.openPDFsMux.Lock() + delete(a.openPDFs, filename) + a.openPDFsMux.Unlock() + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Launch new EMLy instance in PDF viewer mode + exe, err := os.Executable() + if err != nil { + a.openPDFsMux.Lock() + delete(a.openPDFs, filename) + a.openPDFsMux.Unlock() + return fmt.Errorf("failed to get executable path: %w", err) + } + + cmd := exec.Command(exe, "--view-pdf="+tempFile) + if err := cmd.Start(); err != nil { + a.openPDFsMux.Lock() + delete(a.openPDFs, filename) + a.openPDFsMux.Unlock() + return fmt.Errorf("failed to start viewer: %w", err) + } + + // Monitor process in background to release lock when closed + go func() { + cmd.Wait() + a.openPDFsMux.Lock() + delete(a.openPDFs, filename) + a.openPDFsMux.Unlock() + }() + + return nil +} + +// ============================================================================= +// External Application Methods +// ============================================================================= + +// OpenPDF saves a PDF to temp and opens it with the system's default PDF application. +// This is used when the user prefers external viewers over the built-in viewer. +// +// Parameters: +// - base64Data: Base64-encoded PDF data +// - filename: The original filename of the PDF +// +// Returns: +// - error: Error if saving or launching fails +func (a *App) OpenPDF(base64Data string, filename string) error { + if base64Data == "" { + return fmt.Errorf("no data provided") + } + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return fmt.Errorf("failed to decode base64: %w", err) + } + + // Save to temp file with timestamp for uniqueness + tempDir := os.TempDir() + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) + if err := os.WriteFile(tempFile, data, 0644); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Open with Windows default application + cmd := exec.Command("cmd", "/c", "start", "", tempFile) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + return nil +} + +// OpenImage saves an image to temp and opens it with the system's default image viewer. +// This is used when the user prefers external viewers over the built-in viewer. +// +// Parameters: +// - base64Data: Base64-encoded image data +// - filename: The original filename of the image +// +// Returns: +// - error: Error if saving or launching fails +func (a *App) OpenImage(base64Data string, filename string) error { + if base64Data == "" { + return fmt.Errorf("no data provided") + } + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return fmt.Errorf("failed to decode base64: %w", err) + } + + // Save to temp file with timestamp for uniqueness + tempDir := os.TempDir() + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) + if err := os.WriteFile(tempFile, data, 0644); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Open with Windows default application + cmd := exec.Command("cmd", "/c", "start", "", tempFile) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + return nil +} + +// ============================================================================= +// Viewer Mode Detection +// ============================================================================= + +// GetImageViewerData checks CLI arguments and returns image data if running in image viewer mode. +// This is called by the viewer page on startup to get the image to display. +// +// Returns: +// - *ImageViewerData: Image data if in viewer mode, nil otherwise +// - error: Error if reading the image file fails +func (a *App) GetImageViewerData() (*ImageViewerData, error) { + for _, arg := range os.Args { + if strings.HasPrefix(arg, "--view-image=") { + filePath := strings.TrimPrefix(arg, "--view-image=") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read image file: %w", err) + } + // Return as base64 for consistent frontend handling + encoded := base64.StdEncoding.EncodeToString(data) + return &ImageViewerData{ + Data: encoded, + Filename: filepath.Base(filePath), + }, nil + } + } + return nil, nil +} + +// GetPDFViewerData checks CLI arguments and returns PDF data if running in PDF viewer mode. +// This is called by the viewer page on startup to get the PDF to display. +// +// Returns: +// - *PDFViewerData: PDF data if in viewer mode, nil otherwise +// - error: Error if reading the PDF file fails +func (a *App) GetPDFViewerData() (*PDFViewerData, error) { + for _, arg := range os.Args { + if strings.HasPrefix(arg, "--view-pdf=") { + filePath := strings.TrimPrefix(arg, "--view-pdf=") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read PDF file: %w", err) + } + // Return as base64 for consistent frontend handling + encoded := base64.StdEncoding.EncodeToString(data) + return &PDFViewerData{ + Data: encoded, + Filename: filepath.Base(filePath), + }, nil + } + } + return nil, nil +} + +// GetViewerData checks CLI arguments and returns viewer data for any viewer mode. +// This is a unified method that detects both image and PDF viewer modes. +// +// Returns: +// - *ViewerData: Contains either ImageData or PDFData depending on mode +// - error: Error if reading the file fails +func (a *App) GetViewerData() (*ViewerData, error) { + for _, arg := range os.Args { + // Check for image viewer mode + if strings.HasPrefix(arg, "--view-image=") { + filePath := strings.TrimPrefix(arg, "--view-image=") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read image file: %w", err) + } + encoded := base64.StdEncoding.EncodeToString(data) + return &ViewerData{ + ImageData: &ImageViewerData{ + Data: encoded, + Filename: filepath.Base(filePath), + }, + }, nil + } + + // Check for PDF viewer mode + if strings.HasPrefix(arg, "--view-pdf=") { + filePath := strings.TrimPrefix(arg, "--view-pdf=") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read PDF file: %w", err) + } + encoded := base64.StdEncoding.EncodeToString(data) + return &ViewerData{ + PDFData: &PDFViewerData{ + Data: encoded, + Filename: filepath.Base(filePath), + }, + }, nil + } + } + return nil, nil +}