From 6a44eba7ca078a8d11d19d3736f048b6c209c0b7 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Thu, 5 Feb 2026 21:38:51 +0100 Subject: [PATCH] feat: Implement bug reporting feature with screenshot capture - Added a new utility for capturing screenshots on Windows. - Introduced a bug report dialog in the frontend with fields for name, email, and bug description. - Integrated screenshot capture functionality into the bug report dialog. - Added localization for bug report messages in English and Italian. - Updated package dependencies to include html2canvas for potential future use. - Created a new UI dialog component structure for better organization and reusability. - Enhanced the app layout to accommodate the new bug report feature. --- app.go | 368 +++++++++++++++++- backend/utils/screenshot_windows.go | 259 ++++++++++++ frontend/messages/en.json | 22 +- frontend/messages/it.json | 22 +- frontend/package.json | 2 + frontend/src/lib/components/MailViewer.svelte | 6 +- .../components/ui/dialog/dialog-close.svelte | 7 + .../ui/dialog/dialog-content.svelte | 45 +++ .../ui/dialog/dialog-description.svelte | 17 + .../components/ui/dialog/dialog-footer.svelte | 20 + .../components/ui/dialog/dialog-header.svelte | 20 + .../ui/dialog/dialog-overlay.svelte | 20 + .../components/ui/dialog/dialog-portal.svelte | 7 + .../components/ui/dialog/dialog-title.svelte | 17 + .../ui/dialog/dialog-trigger.svelte | 7 + .../lib/components/ui/dialog/dialog.svelte | 7 + .../src/lib/components/ui/dialog/index.ts | 34 ++ .../src/lib/components/ui/textarea/index.ts | 7 + .../components/ui/textarea/textarea.svelte | 23 ++ frontend/src/lib/stores/app.ts | 1 + frontend/src/routes/(app)/+layout.svelte | 278 ++++++++++++- frontend/src/routes/(app)/+page.svelte | 26 +- 22 files changed, 1176 insertions(+), 39 deletions(-) create mode 100644 backend/utils/screenshot_windows.go create mode 100644 frontend/src/lib/components/ui/dialog/dialog-close.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-content.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-description.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-footer.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-header.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-overlay.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-portal.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-title.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog-trigger.svelte create mode 100644 frontend/src/lib/components/ui/dialog/dialog.svelte create mode 100644 frontend/src/lib/components/ui/dialog/index.ts create mode 100644 frontend/src/lib/components/ui/textarea/index.ts create mode 100644 frontend/src/lib/components/ui/textarea/textarea.svelte diff --git a/app.go b/app.go index f5c82a7..98c13b9 100644 --- a/app.go +++ b/app.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "context" "encoding/base64" "fmt" @@ -24,14 +25,15 @@ import ( // App struct type App struct { - ctx context.Context - StartupFilePath string - openImagesMux sync.Mutex - openImages map[string]bool - openPDFsMux sync.Mutex - openPDFs map[string]bool - openEMLsMux sync.Mutex - openEMLs map[string]bool + 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 } // NewApp creates a new App application struct @@ -48,6 +50,11 @@ func NewApp() *App { func (a *App) startup(ctx context.Context) { a.ctx = ctx + // Set CurrentMailFilePath to StartupFilePath if a file was opened via command line + if a.StartupFilePath != "" { + a.CurrentMailFilePath = a.StartupFilePath + } + isViewer := false for _, arg := range os.Args { if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") { @@ -102,6 +109,16 @@ 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) @@ -534,3 +551,338 @@ func (a *App) ConvertToUTF8(s string) string { } 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() +} diff --git a/backend/utils/screenshot_windows.go b/backend/utils/screenshot_windows.go new file mode 100644 index 0000000..6ac8206 --- /dev/null +++ b/backend/utils/screenshot_windows.go @@ -0,0 +1,259 @@ +package utils + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/png" + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + dwmapi = syscall.NewLazyDLL("dwmapi.dll") + + // user32 functions + getForegroundWindow = user32.NewProc("GetForegroundWindow") + getWindowRect = user32.NewProc("GetWindowRect") + getClientRect = user32.NewProc("GetClientRect") + getDC = user32.NewProc("GetDC") + releaseDC = user32.NewProc("ReleaseDC") + findWindowW = user32.NewProc("FindWindowW") + getWindowDC = user32.NewProc("GetWindowDC") + printWindow = user32.NewProc("PrintWindow") + clientToScreen = user32.NewProc("ClientToScreen") + + // gdi32 functions + createCompatibleDC = gdi32.NewProc("CreateCompatibleDC") + createCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap") + selectObject = gdi32.NewProc("SelectObject") + bitBlt = gdi32.NewProc("BitBlt") + deleteDC = gdi32.NewProc("DeleteDC") + deleteObject = gdi32.NewProc("DeleteObject") + getDIBits = gdi32.NewProc("GetDIBits") + + // dwmapi functions + dwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute") +) + +// RECT structure for Windows API +type RECT struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +// POINT structure for Windows API +type POINT struct { + X int32 + Y int32 +} + +// BITMAPINFOHEADER structure +type BITMAPINFOHEADER struct { + BiSize uint32 + BiWidth int32 + BiHeight int32 + BiPlanes uint16 + BiBitCount uint16 + BiCompression uint32 + BiSizeImage uint32 + BiXPelsPerMeter int32 + BiYPelsPerMeter int32 + BiClrUsed uint32 + BiClrImportant uint32 +} + +// BITMAPINFO structure +type BITMAPINFO struct { + BmiHeader BITMAPINFOHEADER + BmiColors [1]uint32 +} + +const ( + SRCCOPY = 0x00CC0020 + DIB_RGB_COLORS = 0 + BI_RGB = 0 + PW_CLIENTONLY = 1 + PW_RENDERFULLCONTENT = 2 + DWMWA_EXTENDED_FRAME_BOUNDS = 9 +) + +// CaptureWindowByHandle captures a screenshot of a specific window by its handle +func CaptureWindowByHandle(hwnd uintptr) (*image.RGBA, error) { + if hwnd == 0 { + return nil, fmt.Errorf("invalid window handle") + } + + // Try to get the actual window bounds using DWM (handles DPI scaling better) + var rect RECT + ret, _, _ := dwmGetWindowAttribute.Call( + hwnd, + uintptr(DWMWA_EXTENDED_FRAME_BOUNDS), + uintptr(unsafe.Pointer(&rect)), + uintptr(unsafe.Sizeof(rect)), + ) + + // Fallback to GetWindowRect if DWM fails + if ret != 0 { + ret, _, err := getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect))) + if ret == 0 { + return nil, fmt.Errorf("GetWindowRect failed: %v", err) + } + } + + width := int(rect.Right - rect.Left) + height := int(rect.Bottom - rect.Top) + + if width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid window dimensions: %dx%d", width, height) + } + + // Get window DC + hdcWindow, _, err := getWindowDC.Call(hwnd) + if hdcWindow == 0 { + return nil, fmt.Errorf("GetWindowDC failed: %v", err) + } + defer releaseDC.Call(hwnd, hdcWindow) + + // Create compatible DC + hdcMem, _, err := createCompatibleDC.Call(hdcWindow) + if hdcMem == 0 { + return nil, fmt.Errorf("CreateCompatibleDC failed: %v", err) + } + defer deleteDC.Call(hdcMem) + + // Create compatible bitmap + hBitmap, _, err := createCompatibleBitmap.Call(hdcWindow, uintptr(width), uintptr(height)) + if hBitmap == 0 { + return nil, fmt.Errorf("CreateCompatibleBitmap failed: %v", err) + } + defer deleteObject.Call(hBitmap) + + // Select bitmap into DC + oldBitmap, _, _ := selectObject.Call(hdcMem, hBitmap) + defer selectObject.Call(hdcMem, oldBitmap) + + // Try PrintWindow first (works better with layered/composited windows) + ret, _, _ = printWindow.Call(hwnd, hdcMem, PW_RENDERFULLCONTENT) + if ret == 0 { + // Fallback to BitBlt + ret, _, err = bitBlt.Call( + hdcMem, 0, 0, uintptr(width), uintptr(height), + hdcWindow, 0, 0, + SRCCOPY, + ) + if ret == 0 { + return nil, fmt.Errorf("BitBlt failed: %v", err) + } + } + + // Prepare BITMAPINFO + bmi := BITMAPINFO{ + BmiHeader: BITMAPINFOHEADER{ + BiSize: uint32(unsafe.Sizeof(BITMAPINFOHEADER{})), + BiWidth: int32(width), + BiHeight: -int32(height), // Negative for top-down DIB + BiPlanes: 1, + BiBitCount: 32, + BiCompression: BI_RGB, + }, + } + + // Allocate buffer for pixel data + pixelDataSize := width * height * 4 + pixelData := make([]byte, pixelDataSize) + + // Get the bitmap bits + ret, _, err = getDIBits.Call( + hdcMem, + hBitmap, + 0, + uintptr(height), + uintptr(unsafe.Pointer(&pixelData[0])), + uintptr(unsafe.Pointer(&bmi)), + DIB_RGB_COLORS, + ) + if ret == 0 { + return nil, fmt.Errorf("GetDIBits failed: %v", err) + } + + // Convert BGRA to RGBA + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for i := 0; i < len(pixelData); i += 4 { + img.Pix[i+0] = pixelData[i+2] // R <- B + img.Pix[i+1] = pixelData[i+1] // G <- G + img.Pix[i+2] = pixelData[i+0] // B <- R + img.Pix[i+3] = pixelData[i+3] // A <- A + } + + return img, nil +} + +// CaptureForegroundWindow captures the currently focused window +func CaptureForegroundWindow() (*image.RGBA, error) { + hwnd, _, _ := getForegroundWindow.Call() + if hwnd == 0 { + return nil, fmt.Errorf("no foreground window found") + } + return CaptureWindowByHandle(hwnd) +} + +// CaptureWindowByTitle captures a window by its title +func CaptureWindowByTitle(title string) (*image.RGBA, error) { + titlePtr, err := syscall.UTF16PtrFromString(title) + if err != nil { + return nil, fmt.Errorf("failed to convert title: %v", err) + } + + hwnd, _, _ := findWindowW.Call(0, uintptr(unsafe.Pointer(titlePtr))) + if hwnd == 0 { + return nil, fmt.Errorf("window with title '%s' not found", title) + } + return CaptureWindowByHandle(hwnd) +} + +// ScreenshotToBase64PNG captures a window and returns it as a base64-encoded PNG string +func ScreenshotToBase64PNG(img *image.RGBA) (string, error) { + if img == nil { + return "", fmt.Errorf("nil image provided") + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return "", fmt.Errorf("failed to encode PNG: %v", err) + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// CaptureWindowToBase64 is a convenience function that captures a window and returns base64 PNG +func CaptureWindowToBase64(hwnd uintptr) (string, error) { + img, err := CaptureWindowByHandle(hwnd) + if err != nil { + return "", err + } + return ScreenshotToBase64PNG(img) +} + +// CaptureForegroundWindowToBase64 captures the foreground window and returns base64 PNG +func CaptureForegroundWindowToBase64() (string, error) { + img, err := CaptureForegroundWindow() + if err != nil { + return "", err + } + return ScreenshotToBase64PNG(img) +} + +// CaptureWindowByTitleToBase64 captures a window by title and returns base64 PNG +func CaptureWindowByTitleToBase64(title string) (string, error) { + img, err := CaptureWindowByTitle(title) + if err != nil { + return "", err + } + return ScreenshotToBase64PNG(img) +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 32c8323..bde7195 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -89,5 +89,25 @@ "mail_pdf_already_open": "The PDF is already open in another window.", "settings_danger_debugger_protection_label": "Enable attached debugger protection", "settings_danger_debugger_protection_hint": "This will prevent the app from being debugged by an attached debugger.", - "settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds." + "settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds.", + "bugreport_title": "Report a Bug", + "bugreport_description": "Describe what you were doing when the bug occurred and what you expected to happen instead.", + "bugreport_name_label": "Name", + "bugreport_name_placeholder": "Your name", + "bugreport_email_label": "Email", + "bugreport_email_placeholder": "your.email@example.com", + "bugreport_text_label": "Bug Description", + "bugreport_text_placeholder": "Describe the bug in detail...", + "bugreport_info": "Your message, email file (if loaded), screenshot, and system information will be included in the report.", + "bugreport_screenshot_label": "Attached Screenshot:", + "bugreport_cancel": "Cancel", + "bugreport_submit": "Submit Report", + "bugreport_submitting": "Creating report...", + "bugreport_success_title": "Bug Report Created", + "bugreport_success_message": "Your bug report has been saved to:", + "bugreport_copy_path": "Copy Path", + "bugreport_open_folder": "Open Folder", + "bugreport_close": "Close", + "bugreport_error": "Failed to create bug report.", + "bugreport_copied": "Path copied to clipboard!" } diff --git a/frontend/messages/it.json b/frontend/messages/it.json index 7013dde..dfbf8fe 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -89,5 +89,25 @@ "mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.", "settings_danger_debugger_protection_label": "Abilita protezione da debugger", "settings_danger_debugger_protection_hint": "Questo impedirà che il debug dell'app venga eseguito da un debugger collegato.", - "settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private." + "settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private.", + "bugreport_title": "Segnala un Bug", + "bugreport_description": "Descrivi cosa stavi facendo quando si è verificato il bug e cosa ti aspettavi che accadesse.", + "bugreport_name_label": "Nome", + "bugreport_name_placeholder": "Il tuo nome", + "bugreport_email_label": "Email", + "bugreport_email_placeholder": "tua.email@esempio.com", + "bugreport_text_label": "Descrizione del Bug", + "bugreport_text_placeholder": "Descrivi il bug in dettaglio...", + "bugreport_info": "Il tuo messaggio, il file email (se caricato), lo screenshot e le informazioni di sistema saranno inclusi nella segnalazione.", + "bugreport_screenshot_label": "Screenshot Allegato:", + "bugreport_cancel": "Annulla", + "bugreport_submit": "Invia Segnalazione", + "bugreport_submitting": "Creazione segnalazione...", + "bugreport_success_title": "Segnalazione Bug Creata", + "bugreport_success_message": "La tua segnalazione bug è stata salvata in:", + "bugreport_copy_path": "Copia Percorso", + "bugreport_open_folder": "Apri Cartella", + "bugreport_close": "Chiudi", + "bugreport_error": "Impossibile creare la segnalazione bug.", + "bugreport_copied": "Percorso copiato negli appunti!" } diff --git a/frontend/package.json b/frontend/package.json index 66a27fb..a4702d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,9 @@ "vite-plugin-devtools-json": "^1.0.0" }, "dependencies": { + "@types/html2canvas": "^1.0.0", "dompurify": "^3.3.1", + "html2canvas": "^1.4.1", "pdfjs-dist": "^5.4.624", "svelte-flags": "^3.0.1", "svelte-sonner": "^1.0.7" diff --git a/frontend/src/lib/components/MailViewer.svelte b/frontend/src/lib/components/MailViewer.svelte index 2bc34f6..bd07687 100644 --- a/frontend/src/lib/components/MailViewer.svelte +++ b/frontend/src/lib/components/MailViewer.svelte @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..5c6ee6d --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,45 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3845023 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..e7ff446 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/dialog/dialog-header.svelte b/frontend/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..4e5c447 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f81ad83 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-portal.svelte b/frontend/src/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..ccfa79c --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-title.svelte b/frontend/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..e4d4b34 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..9d1e801 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog.svelte b/frontend/src/lib/components/ui/dialog/dialog.svelte new file mode 100644 index 0000000..211672c --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..076cef5 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,34 @@ +import Root from "./dialog.svelte"; +import Portal from "./dialog-portal.svelte"; +import Title from "./dialog-title.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Trigger from "./dialog-trigger.svelte"; +import Close from "./dialog-close.svelte"; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/frontend/src/lib/components/ui/textarea/index.ts b/frontend/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/frontend/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/frontend/src/lib/components/ui/textarea/textarea.svelte b/frontend/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..124e9d0 --- /dev/null +++ b/frontend/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/stores/app.ts b/frontend/src/lib/stores/app.ts index d6bd03a..6e058ae 100644 --- a/frontend/src/lib/stores/app.ts +++ b/frontend/src/lib/stores/app.ts @@ -5,6 +5,7 @@ const storedDebug = browser ? sessionStorage.getItem("debugWindowInSettings") == export const dangerZoneEnabled = writable(storedDebug); export const unsavedChanges = writable(false); export const sidebarOpen = writable(true); +export const bugReportDialogOpen = writable(false); export type AppEvent = { id: string; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 84c8c11..ebfe475 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -3,7 +3,7 @@ import { page, navigating } from "$app/state"; import { beforeNavigate, goto } from "$app/navigation"; import { locales, localizeHref } from "$lib/paraglide/runtime"; - import { unsavedChanges, sidebarOpen } from "$lib/stores/app"; + import { unsavedChanges, sidebarOpen, bugReportDialogOpen } from "$lib/stores/app"; import "../layout.css"; import { onMount } from "svelte"; import * as m from "$lib/paraglide/messages.js"; @@ -17,10 +17,20 @@ PanelRightOpen, House, Settings, + Bug, + Loader2, + Copy, + FolderOpen, + CheckCircle, + Camera, } from "@lucide/svelte"; import { Separator } from "$lib/components/ui/separator/index.js"; import { toast } from "svelte-sonner"; - import { buttonVariants } from "$lib/components/ui/button/index.js"; + import { Button, buttonVariants } from "$lib/components/ui/button/index.js"; + import * as Dialog from "$lib/components/ui/dialog/index.js"; + import { Input } from "$lib/components/ui/input/index.js"; + import { Label } from "$lib/components/ui/label/index.js"; + import { Textarea } from "$lib/components/ui/textarea/index.js"; import { WindowMinimise, @@ -30,7 +40,7 @@ Quit, } from "$lib/wailsjs/runtime/runtime"; import { RefreshCcwDot } from "@lucide/svelte"; - import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App"; + import { IsDebuggerRunning, QuitApp, TakeScreenshot, SubmitBugReport, OpenFolderInExplorer } from "$lib/wailsjs/go/main/App"; import { settingsStore } from "$lib/stores/settings.svelte.js"; let versionInfo: utils.Config | null = $state(null); @@ -38,6 +48,20 @@ let isDebugerOn: boolean = $state(false); let isDebbugerProtectionOn: boolean = $state(true); + // Bug report form state + let userName = $state(""); + let userEmail = $state(""); + let bugDescription = $state(""); + + // Bug report screenshot state + let screenshotData = $state(""); + let isCapturing = $state(false); + + // Bug report UI state + let isSubmitting = $state(false); + let isSuccess = $state(false); + let resultZipPath = $state(""); + async function syncMaxState() { isMaximized = await WindowIsMaximised(); } @@ -126,6 +150,92 @@ applyTheme(stored === "light" ? "light" : "dark"); }); + // Bug report dialog effects + $effect(() => { + if ($bugReportDialogOpen) { + // Capture screenshot immediately when dialog opens + captureScreenshot(); + } else { + // Reset form when dialog closes + resetBugReportForm(); + } + }); + + async function captureScreenshot() { + isCapturing = true; + try { + const result = await TakeScreenshot(); + screenshotData = result.data; + console.log("Screenshot captured:", result.width, "x", result.height); + } catch (err) { + console.error("Failed to capture screenshot:", err); + } finally { + isCapturing = false; + } + } + + function resetBugReportForm() { + userName = ""; + userEmail = ""; + bugDescription = ""; + screenshotData = ""; + isCapturing = false; + isSubmitting = false; + isSuccess = false; + resultZipPath = ""; + } + + async function handleBugReportSubmit(event: Event) { + event.preventDefault(); + + if (!bugDescription.trim()) { + toast.error("Please provide a bug description."); + return; + } + + isSubmitting = true; + + try { + const result = await SubmitBugReport({ + name: userName, + email: userEmail, + description: bugDescription, + screenshotData: screenshotData + }); + + resultZipPath = result.zipPath; + isSuccess = true; + console.log("Bug report created:", result.zipPath); + } catch (err) { + console.error("Failed to create bug report:", err); + toast.error(m.bugreport_error()); + } finally { + isSubmitting = false; + } + } + + async function copyBugReportPath() { + try { + await navigator.clipboard.writeText(resultZipPath); + toast.success(m.bugreport_copied()); + } catch (err) { + console.error("Failed to copy path:", err); + } + } + + async function openBugReportFolder() { + try { + const folderPath = resultZipPath.replace(/\.zip$/, ""); + await OpenFolderInExplorer(folderPath); + } catch (err) { + console.error("Failed to open folder:", err); + } + } + + function closeBugReportDialog() { + $bugReportDialogOpen = false; + } + syncMaxState(); @@ -246,6 +356,16 @@ class="hover:opacity-100 transition-opacity" /> + + { + $bugReportDialogOpen = !$bugReportDialogOpen; + }} + style="cursor: pointer; opacity: 0.7;" + class="hover:opacity-100 transition-opacity" + /> + +
@@ -265,6 +386,135 @@ {/each}
+ + + + + {#if isSuccess} + + + + + {m.bugreport_success_title()} + + + {m.bugreport_success_message()} + + + +
+
+ {resultZipPath} +
+ +
+ + +
+
+ + + + + {:else} + +
+ + {m.bugreport_title()} + + {m.bugreport_description()} + + + +
+
+ + +
+ +
+ + +
+ +
+ +