Compare commits
11 Commits
fc98f0ed74
...
webview-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a343769e5 | ||
|
|
33cb171fb1 | ||
|
|
549eed065a | ||
|
|
547018a39f | ||
|
|
18c256ebf9 | ||
|
|
3eb95cca7f | ||
|
|
6f373dd9ab | ||
|
|
eac7a12cd4 | ||
|
|
86e33d6189 | ||
|
|
402a90cf4b | ||
|
|
b68c173d2a |
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Changelog EMLy
|
||||
|
||||
## 1.5.4 (2026-02-10)
|
||||
1) Aggiunti i pulsanti "Download" al MailViewer, PDF e Image viewer, per scaricare il file invece di aprirlo direttamente.
|
||||
2) Refactor del sistema di bug report.
|
||||
3) Rimosso temporaneamente il fetching dei dati macchina all'apertura della pagine delle impostazioni, per evitare problemi di performance.
|
||||
4) Fixato un bug dove, nel Bug Reporting, non si disattivaa il pulsante di invio, se tutti i campi erano compilati.
|
||||
5) Aggiunto il supprto all'allegare i file di localStorage e config.ini al Bug Report, per investigare meglio i problemi legati all'ambiente dell'utente.
|
||||
|
||||
|
||||
|
||||
## 1.5.3 (2026-02-10)
|
||||
1) Sistemato un bug dove, al primo avvio, il tema chiaro era applicato insieme all'opzioni del tema scuro sul contenuto mail, causando un contrasto eccessivo.
|
||||
|
||||
|
||||
|
||||
## 1.5.2 (2026-02-10)
|
||||
1) Supporto tema chiaro/scuro.
|
||||
2) Internazionalizzazione completa (Italiano/Inglese).
|
||||
3) Opzioni di accessibilità (riduzione animazioni, contrasto).
|
||||
|
||||
|
||||
## 1.5.1 (2026-02-09)
|
||||
1) Sistemato un bug del primo avvio, con mismatch della lingua.
|
||||
2) Aggiunto il supporto all'installazione sotto AppData/Local
|
||||
|
||||
|
||||
## 1.5.0 (2026-02-08)
|
||||
1) Sistema di aggiornamento automatico self-hosted (ancora non attivo di default).
|
||||
2) Sistema di bug report integrato.
|
||||
|
||||
|
||||
## 1.4.1 (2026-02-06)
|
||||
1) Export/Import impostazioni.
|
||||
2) Aggiornamento configurazione installer.
|
||||
12
TODO.md
Normal file
12
TODO.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# New Features
|
||||
- [ ] Add an option to select the folder to save Attachments to, instead of always saving to the Downloads folder.
|
||||
- [ ] Create a sorta of "Bug Reporter" loader, to load the .ZIP file with the Bug Report data, and replicate the same enviroment as the user, to investigate the issue. (EML file, settings)
|
||||
- [x] Add a "Download" button to the MailViewer, PDF and Image viewer, to download the file instead of just opening it.
|
||||
|
||||
# Existing Features
|
||||
- [ ] Add seperated "Updater" binary, that will start on User login (via Scheduled Task), with a silent install mode.
|
||||
- [x] Attach localStorage, config file to the "Bug Reporter" ZIP file, to investigate the issue with the user enviroment.
|
||||
- [ ] Auto-send the "Bug Reporter" ZIP file to the support team, to investigate the issue with the user enviroment.
|
||||
|
||||
# Bugs
|
||||
- [ ] Missing i18n for Toast notifications (to investigate)
|
||||
@@ -38,6 +38,10 @@ type BugReportInput struct {
|
||||
Description string `json:"description"`
|
||||
// ScreenshotData is the base64-encoded PNG screenshot (captured before dialog opens)
|
||||
ScreenshotData string `json:"screenshotData"`
|
||||
// LocalStorageData is the JSON-encoded localStorage data
|
||||
LocalStorageData string `json:"localStorageData"`
|
||||
// ConfigData is the JSON-encoded config.ini data
|
||||
ConfigData string `json:"configData"`
|
||||
}
|
||||
|
||||
// SubmitBugReportResult contains the result of submitting a bug report.
|
||||
@@ -120,10 +124,12 @@ func (a *App) CreateBugReportFolder() (*BugReportResult, error) {
|
||||
// - User-provided description (report.txt)
|
||||
// - Screenshot (captured before dialog opens)
|
||||
// - Currently loaded mail file (if any)
|
||||
// - localStorage data (localStorage.json)
|
||||
// - Config.ini data (config.json)
|
||||
// - System information (hostname, OS version, hardware ID)
|
||||
//
|
||||
// Parameters:
|
||||
// - input: User-provided bug report details including pre-captured screenshot
|
||||
// - input: User-provided bug report details including pre-captured screenshot, localStorage, and config data
|
||||
//
|
||||
// Returns:
|
||||
// - *SubmitBugReportResult: Paths to the zip file and folder
|
||||
@@ -168,6 +174,22 @@ func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, err
|
||||
}
|
||||
}
|
||||
|
||||
// Save localStorage data if provided
|
||||
if input.LocalStorageData != "" {
|
||||
localStoragePath := filepath.Join(bugReportFolder, "localStorage.json")
|
||||
if err := os.WriteFile(localStoragePath, []byte(input.LocalStorageData), 0644); err != nil {
|
||||
Log("Failed to save localStorage data:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save config data if provided
|
||||
if input.ConfigData != "" {
|
||||
configPath := filepath.Join(bugReportFolder, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(input.ConfigData), 0644); err != nil {
|
||||
Log("Failed to save config data:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the report.txt file with user's description
|
||||
reportContent := fmt.Sprintf(`EMLy Bug Report
|
||||
================
|
||||
|
||||
49
app_mail.go
49
app_mail.go
@@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"emly/backend/utils/mail"
|
||||
internal "emly/backend/utils/mail"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@@ -86,3 +86,50 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
|
||||
func (a *App) ShowOpenFileDialog() (string, error) {
|
||||
return internal.ShowFileDialog(a.ctx)
|
||||
}
|
||||
|
||||
func (a *App) ShowOpenFolderDialog() (string, error) {
|
||||
return internal.ShowFolderDialog(a.ctx)
|
||||
}
|
||||
|
||||
// SaveAttachment saves an attachment to the configured download folder.
|
||||
// Uses EXPORT_ATTACHMENT_FOLDER from config.ini if set,
|
||||
// otherwise falls back to WEBVIEW2_DOWNLOAD_PATH, then to default Downloads folder.
|
||||
// After saving, opens Windows Explorer to show the saved file.
|
||||
//
|
||||
// Parameters:
|
||||
// - filename: The name to save the file as
|
||||
// - base64Data: The base64-encoded attachment data
|
||||
//
|
||||
// Returns:
|
||||
// - string: The full path where the file was saved
|
||||
// - error: Any file system errors
|
||||
func (a *App) SaveAttachment(filename string, base64Data string) (string, error) {
|
||||
// Try to get configured export folder first
|
||||
folderPath := a.GetExportAttachmentFolder()
|
||||
|
||||
// If not set, try to get WEBVIEW2_DOWNLOAD_PATH from config
|
||||
if folderPath == "" {
|
||||
config := a.GetConfig()
|
||||
if config != nil && config.EMLy.WebView2DownloadPath != "" {
|
||||
folderPath = config.EMLy.WebView2DownloadPath
|
||||
}
|
||||
}
|
||||
|
||||
savedPath, err := internal.SaveAttachmentToFolder(filename, base64Data, folderPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return savedPath, nil
|
||||
}
|
||||
|
||||
// OpenExplorerForPath opens Windows Explorer to show the specified file or folder.
|
||||
//
|
||||
// Parameters:
|
||||
// - path: The full path to open in Explorer
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any execution errors
|
||||
func (a *App) OpenExplorerForPath(path string) error {
|
||||
return internal.OpenFileExplorer(path)
|
||||
}
|
||||
|
||||
@@ -128,3 +128,41 @@ func (a *App) SetUpdateCheckerEnabled(enabled bool) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetExportAttachmentFolder updates the EXPORT_ATTACHMENT_FOLDER setting in config.ini
|
||||
// based on the user's preference from the GUI settings.
|
||||
//
|
||||
// Parameters:
|
||||
// - folderPath: The path to the folder where attachments should be exported
|
||||
//
|
||||
// Returns:
|
||||
// - error: Error if loading or saving config fails
|
||||
func (a *App) SetExportAttachmentFolder(folderPath string) error {
|
||||
// Load current config
|
||||
config := a.GetConfig()
|
||||
if config == nil {
|
||||
return fmt.Errorf("failed to load config")
|
||||
}
|
||||
|
||||
// Update the setting
|
||||
config.EMLy.ExportAttachmentFolder = folderPath
|
||||
|
||||
// Save config back to disk
|
||||
if err := a.SaveConfig(config); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExportAttachmentFolder returns the EXPORT_ATTACHMENT_FOLDER setting from config.ini
|
||||
//
|
||||
// Returns:
|
||||
// - string: The path to the export folder, or empty string if not set
|
||||
func (a *App) GetExportAttachmentFolder() string {
|
||||
config := a.GetConfig()
|
||||
if config == nil {
|
||||
return ""
|
||||
}
|
||||
return config.EMLy.ExportAttachmentFolder
|
||||
}
|
||||
|
||||
232
app_update.go
232
app_update.go
@@ -410,6 +410,238 @@ func shellExecuteAsAdmin(exePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// launchDetachedInstaller launches the installer as a completely detached process
|
||||
// using CreateProcess with DETACHED_PROCESS and CREATE_NEW_PROCESS_GROUP flags.
|
||||
// This allows the installer to continue running and close EMLy without errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - exePath: Full path to the installer executable
|
||||
// - args: Array of command-line arguments to pass to the installer
|
||||
//
|
||||
// Returns:
|
||||
// - error: Error if process creation fails
|
||||
func launchDetachedInstaller(exePath string, args []string) error {
|
||||
// Build command line: executable path + arguments
|
||||
cmdLine := fmt.Sprintf(`"%s"`, exePath)
|
||||
if len(args) > 0 {
|
||||
cmdLine += " " + strings.Join(args, " ")
|
||||
}
|
||||
|
||||
log.Printf("Launching detached installer: %s", cmdLine)
|
||||
|
||||
// Convert to UTF16 for Windows API
|
||||
cmdLinePtr := syscall.StringToUTF16Ptr(cmdLine)
|
||||
|
||||
// Setup process startup info
|
||||
var si syscall.StartupInfo
|
||||
var pi syscall.ProcessInformation
|
||||
|
||||
si.Cb = uint32(unsafe.Sizeof(si))
|
||||
si.Flags = syscall.STARTF_USESHOWWINDOW
|
||||
si.ShowWindow = syscall.SW_HIDE // Hide installer window (silent mode)
|
||||
|
||||
// Process creation flags:
|
||||
// CREATE_NEW_PROCESS_GROUP: Creates process in new process group
|
||||
// DETACHED_PROCESS: Process has no console, completely detached from parent
|
||||
const (
|
||||
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
)
|
||||
flags := uint32(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
|
||||
// Create the detached process
|
||||
err := syscall.CreateProcess(
|
||||
nil, // Application name (nil = use command line)
|
||||
cmdLinePtr, // Command line
|
||||
nil, // Process security attributes
|
||||
nil, // Thread security attributes
|
||||
false, // Inherit handles
|
||||
flags, // Creation flags
|
||||
nil, // Environment (nil = inherit)
|
||||
nil, // Current directory (nil = inherit)
|
||||
&si, // Startup info
|
||||
&pi, // Process information (output)
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("CreateProcess failed: %v", err)
|
||||
return fmt.Errorf("failed to create detached process: %w", err)
|
||||
}
|
||||
|
||||
// Close process and thread handles immediately
|
||||
// We don't need to wait for the process - it's fully detached
|
||||
syscall.CloseHandle(pi.Process)
|
||||
syscall.CloseHandle(pi.Thread)
|
||||
|
||||
log.Printf("Detached installer process launched successfully (PID: %d)", pi.ProcessId)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallUpdateSilent downloads the update (if needed) and launches the installer
|
||||
// in completely silent mode with a detached process. The installer will run with
|
||||
// these arguments: /VERYSILENT /ALLUSERS /SUPPRESSMSGBOXES /NORESTART /FORCEUPGRADE
|
||||
//
|
||||
// This method automatically quits EMLy after launching the installer, allowing the
|
||||
// installer to close the application and complete the upgrade without user interaction.
|
||||
//
|
||||
// Returns:
|
||||
// - error: Error if download or launch fails
|
||||
func (a *App) InstallUpdateSilent() error {
|
||||
log.Println("Starting silent update installation...")
|
||||
|
||||
// If installer not ready, attempt to download first
|
||||
if !updateStatus.Ready || updateStatus.InstallerPath == "" {
|
||||
log.Println("Installer not ready, downloading update first...")
|
||||
|
||||
_, err := a.DownloadUpdate()
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to download update: %v", err)
|
||||
log.Println(errMsg)
|
||||
updateStatus.ErrorMessage = errMsg
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait briefly for download to complete
|
||||
log.Println("Download initiated, waiting for completion...")
|
||||
for i := 0; i < 60; i++ { // Wait up to 60 seconds
|
||||
time.Sleep(1 * time.Second)
|
||||
if updateStatus.Ready {
|
||||
break
|
||||
}
|
||||
if updateStatus.ErrorMessage != "" {
|
||||
return fmt.Errorf("download error: %s", updateStatus.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if !updateStatus.Ready {
|
||||
return fmt.Errorf("download timeout - update not ready after 60 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
installerPath := updateStatus.InstallerPath
|
||||
|
||||
// Verify installer exists
|
||||
if _, err := os.Stat(installerPath); os.IsNotExist(err) {
|
||||
updateStatus.ErrorMessage = "Installer file not found"
|
||||
updateStatus.Ready = false
|
||||
log.Printf("Installer not found: %s", installerPath)
|
||||
return fmt.Errorf("installer not found: %s", installerPath)
|
||||
}
|
||||
|
||||
log.Printf("Installer ready at: %s", installerPath)
|
||||
|
||||
// Prepare silent installation arguments
|
||||
args := []string{
|
||||
"/VERYSILENT", // No UI, completely silent
|
||||
"/ALLUSERS", // Install for all users (requires admin)
|
||||
"/SUPPRESSMSGBOXES", // Suppress all message boxes
|
||||
"/NORESTART", // Don't restart system
|
||||
"/FORCEUPGRADE", // Skip upgrade confirmation dialog
|
||||
`/LOG="C:\install.log"`, // Create installation log
|
||||
}
|
||||
|
||||
log.Printf("Launching installer with args: %v", args)
|
||||
|
||||
// Launch detached installer
|
||||
if err := launchDetachedInstaller(installerPath, args); err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to launch installer: %v", err)
|
||||
log.Println(errMsg)
|
||||
updateStatus.ErrorMessage = errMsg
|
||||
return fmt.Errorf("failed to launch installer: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Detached installer launched successfully, quitting EMLy...")
|
||||
|
||||
// Quit application to allow installer to replace files
|
||||
time.Sleep(500 * time.Millisecond) // Brief delay to ensure installer starts
|
||||
runtime.Quit(a.ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallUpdateSilentFromPath downloads an installer from a custom SMB/network path
|
||||
// and launches it in silent mode with a detached process. Use this when you know the
|
||||
// exact installer path (e.g., \\server\updates\EMLy_Installer.exe) without needing
|
||||
// to check the version.json manifest.
|
||||
//
|
||||
// Parameters:
|
||||
// - smbPath: Full UNC path or local path to the installer (e.g., \\server\share\EMLy.exe)
|
||||
//
|
||||
// Returns:
|
||||
// - error: Error if download or launch fails
|
||||
func (a *App) InstallUpdateSilentFromPath(smbPath string) error {
|
||||
log.Printf("Starting silent installation from custom path: %s", smbPath)
|
||||
|
||||
// Verify source installer exists and is accessible
|
||||
if _, err := os.Stat(smbPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("Installer not found at: %s", smbPath)
|
||||
log.Println(errMsg)
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Create temporary directory for installer
|
||||
tempDir := os.TempDir()
|
||||
installerFilename := filepath.Base(smbPath)
|
||||
tempInstallerPath := filepath.Join(tempDir, installerFilename)
|
||||
|
||||
log.Printf("Copying installer to temp location: %s", tempInstallerPath)
|
||||
|
||||
// Copy installer from SMB path to local temp
|
||||
sourceFile, err := os.Open(smbPath)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to open source installer: %v", err)
|
||||
log.Println(errMsg)
|
||||
return fmt.Errorf("failed to open installer: %w", err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(tempInstallerPath)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to create temp installer: %v", err)
|
||||
log.Println(errMsg)
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
// Copy file
|
||||
bytesWritten, err := io.Copy(destFile, sourceFile)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to copy installer: %v", err)
|
||||
log.Println(errMsg)
|
||||
return fmt.Errorf("failed to copy installer: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Installer copied successfully (%d bytes)", bytesWritten)
|
||||
|
||||
// Prepare silent installation arguments
|
||||
args := []string{
|
||||
"/VERYSILENT", // No UI, completely silent
|
||||
"/ALLUSERS", // Install for all users (requires admin)
|
||||
"/SUPPRESSMSGBOXES", // Suppress all message boxes
|
||||
"/NORESTART", // Don't restart system
|
||||
"/FORCEUPGRADE", // Skip upgrade confirmation dialog
|
||||
`/LOG="C:\install.log"`, // Create installation log
|
||||
}
|
||||
|
||||
log.Printf("Launching installer with args: %v", args)
|
||||
|
||||
// Launch detached installer
|
||||
if err := launchDetachedInstaller(tempInstallerPath, args); err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to launch installer: %v", err)
|
||||
log.Println(errMsg)
|
||||
return fmt.Errorf("failed to launch installer: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Detached installer launched successfully, quitting EMLy...")
|
||||
|
||||
// Quit application to allow installer to replace files
|
||||
time.Sleep(500 * time.Millisecond) // Brief delay to ensure installer starts
|
||||
runtime.Quit(a.ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Methods
|
||||
// =============================================================================
|
||||
|
||||
@@ -22,6 +22,9 @@ type EMLyConfig struct {
|
||||
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
|
||||
UpdatePath string `ini:"UPDATE_PATH"`
|
||||
UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"`
|
||||
WebView2UserDataPath string `ini:"WEBVIEW2_USERDATA_PATH"`
|
||||
WebView2DownloadPath string `ini:"WEBVIEW2_DOWNLOAD_PATH"`
|
||||
ExportAttachmentFolder string `ini:"EXPORT_ATTACHMENT_FOLDER"`
|
||||
}
|
||||
|
||||
// LoadConfig reads the config.ini file at the given path and returns a Config struct
|
||||
|
||||
@@ -2,6 +2,13 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -16,6 +23,14 @@ var EmailDialogOptions = runtime.OpenDialogOptions{
|
||||
ShowHiddenFiles: false,
|
||||
}
|
||||
|
||||
var FolderDialogOptions = runtime.OpenDialogOptions{
|
||||
Title: "Select Folder",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Folders", Pattern: "*"},
|
||||
},
|
||||
ShowHiddenFiles: false,
|
||||
}
|
||||
|
||||
func ShowFileDialog(ctx context.Context) (string, error) {
|
||||
filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions)
|
||||
if err != nil {
|
||||
@@ -23,3 +38,86 @@ func ShowFileDialog(ctx context.Context) (string, error) {
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func ShowFolderDialog(ctx context.Context) (string, error) {
|
||||
folderPath, err := runtime.OpenDirectoryDialog(ctx, FolderDialogOptions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return folderPath, nil
|
||||
}
|
||||
|
||||
// SaveAttachmentToFolder saves a base64-encoded attachment to the specified folder.
|
||||
// If folderPath is empty, uses the user's Downloads folder as default.
|
||||
// Expands environment variables in the format %%VAR%% or %VAR%.
|
||||
//
|
||||
// Parameters:
|
||||
// - filename: The name to save the file as
|
||||
// - base64Data: The base64-encoded file content
|
||||
// - folderPath: Optional custom folder path (uses Downloads if empty)
|
||||
//
|
||||
// Returns:
|
||||
// - string: The full path where the file was saved
|
||||
// - error: Any file system or decoding errors
|
||||
func SaveAttachmentToFolder(filename string, base64Data string, folderPath string) (string, error) {
|
||||
// Decode base64 data
|
||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode attachment data: %w", err)
|
||||
}
|
||||
|
||||
// Use configured folder or default to Downloads
|
||||
targetFolder := folderPath
|
||||
if targetFolder == "" {
|
||||
targetFolder = filepath.Join(os.Getenv("USERPROFILE"), "Downloads")
|
||||
} else {
|
||||
// Expand environment variables (%%VAR%% or %VAR% format)
|
||||
re := regexp.MustCompile(`%%([^%]+)%%|%([^%]+)%`)
|
||||
targetFolder = re.ReplaceAllStringFunc(targetFolder, func(match string) string {
|
||||
varName := strings.Trim(match, "%")
|
||||
return os.Getenv(varName)
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure the target folder exists
|
||||
if err := os.MkdirAll(targetFolder, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create target folder: %w", err)
|
||||
}
|
||||
|
||||
// Create full path
|
||||
fullPath := filepath.Join(targetFolder, filename)
|
||||
|
||||
// Save the file
|
||||
if err := os.WriteFile(fullPath, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to save attachment: %w", err)
|
||||
}
|
||||
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
// OpenFileExplorer opens Windows Explorer and selects the specified file.
|
||||
// Uses the /select parameter to highlight the file in Explorer.
|
||||
// If the path is a directory, opens the directory without selecting anything.
|
||||
//
|
||||
// Parameters:
|
||||
// - filePath: The full path to the file or directory to open in Explorer
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any execution errors
|
||||
func OpenFileExplorer(filePath string) error {
|
||||
// Check if path is a directory or file
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat path: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Open directory
|
||||
cmd := exec.Command("explorer.exe", filePath)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// Open and select file
|
||||
cmd := exec.Command("explorer.exe", "/select,", filePath)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
[EMLy]
|
||||
SDK_DECODER_SEMVER = 1.3.2
|
||||
SDK_DECODER_RELEASE_CHANNEL = stable
|
||||
GUI_SEMVER = 1.5.3
|
||||
GUI_SEMVER = 1.5.4
|
||||
GUI_RELEASE_CHANNEL = beta
|
||||
LANGUAGE = it
|
||||
UPDATE_CHECK_ENABLED = false
|
||||
UPDATE_PATH =
|
||||
UPDATE_AUTO_CHECK = true
|
||||
WEBVIEW2_USERDATA_PATH =
|
||||
WEBVIEW2_DOWNLOAD_PATH = %%USERPROFILE%%\Documents\EMLy_Attachments
|
||||
EXPORT_ATTACHMENT_FOLDER =
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"settings_preview_page_description": "Modify settings related to the preview page",
|
||||
"settings_preview_builtin_label": "Use built-in preview for images",
|
||||
"settings_preview_builtin_hint": "Uses EMLy's built-in image previewer for supported image file types.",
|
||||
"settings_preview_builtin_info": "Info: If disabled, image files will be treated as downloads instead of being previewed within the app.",
|
||||
"settings_preview_builtin_info": "Info: If disabled, image files will be opened by the computer's default app instead of being previewed within the app.",
|
||||
"settings_preview_pdf_builtin_label": "Use built-in viewer for PDFs",
|
||||
"settings_preview_pdf_builtin_hint": "Uses EMLy's built-in viewer for PDF files.",
|
||||
"settings_preview_pdf_builtin_info": "Info: If disabled, PDF files will be treated as downloads instead of being previewed within the app.",
|
||||
"settings_preview_pdf_builtin_info": "Info: If disabled, PDF files will be opened by the computer's default app instead of being previewed within the app.",
|
||||
"settings_msg_converter_title": "MSG Handling",
|
||||
"settings_msg_converter_description": "Configure how MSG files are processed.",
|
||||
"settings_msg_converter_label": "Use MSG to EML converter",
|
||||
@@ -69,6 +69,8 @@
|
||||
"mail_open_btn_title": "Open another file",
|
||||
"mail_close_btn_label": "Close",
|
||||
"mail_close_btn_title": "Close",
|
||||
"mail_download_btn_label": "Download",
|
||||
"mail_download_btn_title": "Download",
|
||||
"mail_from": "From:",
|
||||
"mail_to": "To:",
|
||||
"mail_cc": "Cc:",
|
||||
@@ -79,8 +81,9 @@
|
||||
"mail_error_image": "Failed to open image file.",
|
||||
"settings_toast_language_changed": "Language changed successfully!",
|
||||
"settings_toast_language_change_failed": "Failed to change language.",
|
||||
"mail_open_btn_text": "Open EML/MSG File",
|
||||
"mail_open_btn_text": "Open File",
|
||||
"mail_close_btn_text": "Close",
|
||||
"mail_download_btn_text": "Download",
|
||||
"settings_danger_reset_dialog_description_part1": "This action cannot be undone.",
|
||||
"settings_danger_reset_dialog_description_part2": "This will permanently delete your current settings and return the app to its default state.",
|
||||
"mail_error_opening": "Failed to open EML file.",
|
||||
@@ -215,5 +218,11 @@
|
||||
"pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.",
|
||||
"pdf_error_timeout": "Timeout loading PDF. The worker might have failed to initialize.",
|
||||
"pdf_error_parsing": "Error parsing PDF: ",
|
||||
"pdf_error_rendering": "Error rendering page: "
|
||||
"pdf_error_rendering": "Error rendering page: ",
|
||||
"settings_custom_download_label": "Custom Attachment Download",
|
||||
"settings_custom_download_hint": "Save attachments to a custom folder and open Explorer automatically",
|
||||
"settings_custom_download_info": "Info: When enabled, attachments will be saved to the folder configured below (or WEBVIEW2_DOWNLOAD_PATH if not set) and Windows Explorer will open to show the file. When disabled, uses browser's default download behavior.",
|
||||
"settings_export_folder_label": "Select a folder to save exported attachments",
|
||||
"settings_export_folder_hint": "Choose a default location for saving attachments that you export from emails (instead of the Downloads folder)",
|
||||
"settings_select_folder_button": "Select folder"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"settings_preview_page_description": "Modifica le impostazioni relative alla pagina di anteprima",
|
||||
"settings_preview_builtin_label": "Usa anteprima integrata per le immagini",
|
||||
"settings_preview_builtin_hint": "Usa il visualizzatore di immagini integrato di EMLy per i tipi di file immagini supportati.",
|
||||
"settings_preview_builtin_info": "Info: Se disabilitato, i file immagine verranno trattati come download anziché essere visualizzati all'interno dell'app.",
|
||||
"settings_preview_builtin_info": "Info: Se disabilitato, i file immagine verranno aperti tramite l'app di default attuale anziché essere visualizzati all'interno dell'app.",
|
||||
"settings_preview_pdf_builtin_label": "Usa visualizzatore integrato per PDF",
|
||||
"settings_preview_pdf_builtin_hint": "Usa il visualizzatore integrato di EMLy per i file PDF.",
|
||||
"settings_preview_pdf_builtin_info": "Info: Se disabilitato, i file PDF verranno trattati come download invece di essere visualizzati nell'app.",
|
||||
"settings_preview_pdf_builtin_info": "Info: Se disabilitato, i file PDF verranno aperti tramite l'app di default attuale invece di essere visualizzati nell'app.",
|
||||
"settings_msg_converter_title": "Gestione MSG",
|
||||
"settings_msg_converter_description": "Configura come vengono elaborati i file MSG.",
|
||||
"settings_msg_converter_label": "Usa convertitore MSG in EML",
|
||||
@@ -79,7 +79,7 @@
|
||||
"mail_error_image": "Impossibile aprire il file immagine.",
|
||||
"settings_toast_language_changed": "Lingua cambiata con successo!",
|
||||
"settings_toast_language_change_failed": "Impossibile cambiare lingua.",
|
||||
"mail_open_btn_text": "Apri file EML/MSG",
|
||||
"mail_open_btn_text": "Apri file",
|
||||
"mail_close_btn_text": "Chiudi",
|
||||
"settings_danger_reset_dialog_description_part1": "Questa azione non può essere annullata.",
|
||||
"settings_danger_reset_dialog_description_part2": "Questo eliminerà permanentemente le tue impostazioni attuali e riporterà l'app allo stato predefinito.",
|
||||
@@ -215,5 +215,15 @@
|
||||
"pdf_error_no_data_desc": "Nessun dato PDF fornito. Apri questa finestra dall'applicazione principale EMLy.",
|
||||
"pdf_error_timeout": "Timeout caricamento PDF. Il worker potrebbe non essersi inizializzato correttamente.",
|
||||
"pdf_error_parsing": "Errore nel parsing del PDF: ",
|
||||
"pdf_error_rendering": "Errore nel rendering della pagina: "
|
||||
"pdf_error_rendering": "Errore nel rendering della pagina: ",
|
||||
"mail_download_btn_label": "Scarica",
|
||||
"mail_download_btn_title": "Scarica",
|
||||
"mail_download_btn_text": "Scarica",
|
||||
"settings_custom_download_label": "Download Personalizzato Allegati",
|
||||
"settings_custom_download_hint": "Salva gli allegati in una cartella personalizzata e apri automaticamente Esplora Risorse",
|
||||
"settings_custom_download_info": "Info: Quando abilitato, gli allegati verranno salvati nella cartella configurata di seguito (o WEBVIEW2_DOWNLOAD_PATH se non impostata) e Windows Explorer si aprirà per mostrare il file. Quando disabilitato, usa il comportamento di download predefinito del browser.",
|
||||
"settings_export_folder_label": "Seleziona una cartella per salvare gli allegati esportati",
|
||||
"settings_export_folder_hint": "Scegli una posizione predefinita per salvare gli allegati che esporti dalle email (invece della cartella Download)",
|
||||
"settings_select_folder_button": "Seleziona cartella"
|
||||
}
|
||||
|
||||
|
||||
309
frontend/src/lib/components/BugReportDialog.svelte
Normal file
309
frontend/src/lib/components/BugReportDialog.svelte
Normal file
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { bugReportDialogOpen } from "$lib/stores/app";
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button/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 { CheckCircle, Copy, FolderOpen, Camera, Loader2 } from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
// 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 system data
|
||||
let localStorageData = $state("");
|
||||
let configData = $state("");
|
||||
|
||||
// Bug report UI state
|
||||
let isSubmitting = $state(false);
|
||||
let isSuccess = $state(false);
|
||||
let resultZipPath = $state("");
|
||||
let canSubmit: boolean = $derived(
|
||||
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
|
||||
);
|
||||
|
||||
// Bug report dialog effects
|
||||
$effect(() => {
|
||||
if ($bugReportDialogOpen) {
|
||||
// Capture screenshot immediately when dialog opens
|
||||
captureScreenshot();
|
||||
// Capture localStorage data
|
||||
captureLocalStorage();
|
||||
// Capture config.ini data
|
||||
captureConfig();
|
||||
} 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 captureLocalStorage() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const data: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key) {
|
||||
data[key] = localStorage.getItem(key) || "";
|
||||
}
|
||||
}
|
||||
localStorageData = JSON.stringify(data, null, 2);
|
||||
console.log("localStorage data captured");
|
||||
} catch (err) {
|
||||
console.error("Failed to capture localStorage:", err);
|
||||
localStorageData = "Error capturing localStorage";
|
||||
}
|
||||
}
|
||||
|
||||
async function captureConfig() {
|
||||
try {
|
||||
const config = await GetConfig();
|
||||
configData = JSON.stringify(config, null, 2);
|
||||
console.log("Config data captured");
|
||||
} catch (err) {
|
||||
console.error("Failed to capture config:", err);
|
||||
configData = "Error capturing config";
|
||||
}
|
||||
}
|
||||
|
||||
function resetBugReportForm() {
|
||||
userName = "";
|
||||
userEmail = "";
|
||||
bugDescription = "";
|
||||
screenshotData = "";
|
||||
localStorageData = "";
|
||||
configData = "";
|
||||
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,
|
||||
localStorageData: localStorageData,
|
||||
configData: configData
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$bugReportDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-125 w-full max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||
{#if isSuccess}
|
||||
<!-- Success State -->
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||
{m.bugreport_success_title()}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_success_message()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="bg-muted rounded-md p-3">
|
||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" onclick={copyBugReportPath}>
|
||||
<Copy class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_copy_path()}
|
||||
</Button>
|
||||
<Button variant="outline" class="flex-1" onclick={openBugReportFolder}>
|
||||
<FolderOpen class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_open_folder()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button onclick={closeBugReportDialog}>
|
||||
{m.bugreport_close()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<!-- Form State -->
|
||||
<form onsubmit={handleBugReportSubmit}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.bugreport_title()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_description()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-name">{m.bugreport_name_label()}</Label>
|
||||
<Input
|
||||
id="bug-name"
|
||||
placeholder={m.bugreport_name_placeholder()}
|
||||
bind:value={userName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-email">{m.bugreport_email_label()}</Label>
|
||||
<Input
|
||||
id="bug-email"
|
||||
type="email"
|
||||
placeholder={m.bugreport_email_placeholder()}
|
||||
bind:value={userEmail}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-description">{m.bugreport_text_label()}</Label>
|
||||
<Textarea
|
||||
id="bug-description"
|
||||
placeholder={m.bugreport_text_placeholder()}
|
||||
bind:value={bugDescription}
|
||||
disabled={isSubmitting}
|
||||
class="min-h-30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Preview -->
|
||||
<div class="grid gap-2">
|
||||
<Label class="flex items-center gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
{m.bugreport_screenshot_label()}
|
||||
</Label>
|
||||
{#if isCapturing}
|
||||
<div class="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Capturing...
|
||||
</div>
|
||||
{:else if screenshotData}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<img
|
||||
src="data:image/png;base64,{screenshotData}"
|
||||
alt="Screenshot preview"
|
||||
class="w-full h-32 object-cover object-top opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
No screenshot available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{m.bugreport_info()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<button type="button" class={buttonVariants({ variant: "outline" })} disabled={isSubmitting} onclick={closeBugReportDialog}>
|
||||
{m.bugreport_cancel()}
|
||||
</button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="h-4 w-4 mr-2 animate-spin" />
|
||||
{m.bugreport_submitting()}
|
||||
{:else}
|
||||
{m.bugreport_submit()}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
:global(.custom-scrollbar::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb) {
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-corner) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -9,11 +9,13 @@
|
||||
Signature,
|
||||
FileCode,
|
||||
Loader2,
|
||||
Download,
|
||||
} from '@lucide/svelte';
|
||||
import { sidebarOpen } from '$lib/stores/app';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { EventsOn, WindowShow, WindowUnminimise } from '$lib/wailsjs/runtime/runtime';
|
||||
import { SaveAttachment, OpenExplorerForPath } from '$lib/wailsjs/go/main/App';
|
||||
import { mailState } from '$lib/stores/mail-state.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { dev } from '$app/environment';
|
||||
@@ -35,6 +37,7 @@
|
||||
isEmailFile,
|
||||
} from '$lib/utils/mail';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
|
||||
// ============================================================================
|
||||
// State
|
||||
@@ -59,6 +62,43 @@
|
||||
mailState.clear();
|
||||
}
|
||||
|
||||
async function onDownloadAttachments() {
|
||||
if (!mailState.currentEmail || !mailState.currentEmail.attachments) return;
|
||||
|
||||
// Check if custom download behavior is enabled
|
||||
const useCustomDownload = settingsStore.settings.useCustomAttachmentDownload ?? false;
|
||||
|
||||
if (useCustomDownload) {
|
||||
// Use backend SaveAttachment (saves to configured folder and opens Explorer)
|
||||
try {
|
||||
let lastSavedPath = '';
|
||||
for (const att of mailState.currentEmail.attachments) {
|
||||
const base64 = arrayBufferToBase64(att.data);
|
||||
lastSavedPath = await SaveAttachment(att.filename, base64);
|
||||
toast.success(`Saved: ${att.filename}`);
|
||||
}
|
||||
// Open Explorer to show the folder where files were saved
|
||||
if (lastSavedPath) {
|
||||
await OpenExplorerForPath(lastSavedPath);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Failed to save attachments: ${err}`);
|
||||
}
|
||||
} else {
|
||||
// Use browser default download (downloads to browser's default folder)
|
||||
mailState.currentEmail.attachments.forEach((att) => {
|
||||
const base64 = arrayBufferToBase64(att.data);
|
||||
const dataUrl = createDataUrl(att.contentType, base64);
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = att.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onOpenMail() {
|
||||
isLoading = true;
|
||||
loadingText = m.layout_loading_text();
|
||||
@@ -224,6 +264,16 @@
|
||||
{mailState.currentEmail.subject || m.mail_subject_no_subject()}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn"
|
||||
onclick={onDownloadAttachments}
|
||||
aria-label={m.mail_download_btn_label()}
|
||||
title={m.mail_download_btn_title()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Download size="15" />
|
||||
{m.mail_download_btn_text()}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={onOpenMail}
|
||||
|
||||
@@ -18,6 +18,8 @@ const defaults: EMLy_GUI_Settings = {
|
||||
reduceMotion: false,
|
||||
theme: "dark",
|
||||
increaseWindowButtonsContrast: false,
|
||||
exportAttachmentFolder: "",
|
||||
useCustomAttachmentDownload: false,
|
||||
};
|
||||
|
||||
class SettingsStore {
|
||||
|
||||
12
frontend/src/lib/types.d.ts
vendored
12
frontend/src/lib/types.d.ts
vendored
@@ -5,15 +5,17 @@ type SupportedFileTypePreview = "jpg" | "jpeg" | "png";
|
||||
interface EMLy_GUI_Settings {
|
||||
selectedLanguage: SupportedLanguages = "en" | "it";
|
||||
useBuiltinPreview: boolean;
|
||||
useBuiltinPDFViewer?: boolean;
|
||||
previewFileSupportedTypes?: SupportedFileTypePreview[];
|
||||
enableAttachedDebuggerProtection?: boolean;
|
||||
useBuiltinPDFViewer: boolean;
|
||||
previewFileSupportedTypes: SupportedFileTypePreview[];
|
||||
enableAttachedDebuggerProtection: boolean;
|
||||
useDarkEmailViewer?: boolean;
|
||||
enableUpdateChecker?: boolean;
|
||||
musicInspirationEnabled?: boolean;
|
||||
reduceMotion?: boolean;
|
||||
theme?: "light" | "dark";
|
||||
increaseWindowButtonsContrast?: boolean;
|
||||
theme: "light" | "dark";
|
||||
increaseWindowButtonsContrast: boolean;
|
||||
exportAttachmentFolder?: string;
|
||||
useCustomAttachmentDownload?: boolean;
|
||||
}
|
||||
|
||||
type SupportedLanguages = "en" | "it";
|
||||
|
||||
@@ -17,22 +17,14 @@
|
||||
House,
|
||||
Settings,
|
||||
Bug,
|
||||
Loader2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
CheckCircle,
|
||||
Camera,
|
||||
Heart,
|
||||
Info,
|
||||
Music
|
||||
} from "@lucide/svelte";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { toast } from "svelte-sonner";
|
||||
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 { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import BugReportDialog from "$lib/components/BugReportDialog.svelte";
|
||||
|
||||
import {
|
||||
WindowMinimise,
|
||||
@@ -44,7 +36,7 @@
|
||||
EventsOff,
|
||||
} from "$lib/wailsjs/runtime/runtime";
|
||||
import { RefreshCcwDot } from "@lucide/svelte";
|
||||
import { IsDebuggerRunning, QuitApp, TakeScreenshot, SubmitBugReport, OpenFolderInExplorer } from "$lib/wailsjs/go/main/App";
|
||||
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App";
|
||||
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
||||
|
||||
let versionInfo: utils.Config | null = $state(null);
|
||||
@@ -52,20 +44,6 @@
|
||||
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();
|
||||
}
|
||||
@@ -155,17 +133,6 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for automatic update notifications
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
@@ -186,81 +153,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
</script>
|
||||
|
||||
@@ -435,134 +327,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bug Report Dialog -->
|
||||
<Dialog.Root bind:open={$bugReportDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[500px] w-full max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||
{#if isSuccess}
|
||||
<!-- Success State -->
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||
{m.bugreport_success_title()}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_success_message()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="bg-muted rounded-md p-3">
|
||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" onclick={copyBugReportPath}>
|
||||
<Copy class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_copy_path()}
|
||||
</Button>
|
||||
<Button variant="outline" class="flex-1" onclick={openBugReportFolder}>
|
||||
<FolderOpen class="h-4 w-4 mr-2" />
|
||||
{m.bugreport_open_folder()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button onclick={closeBugReportDialog}>
|
||||
{m.bugreport_close()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<!-- Form State -->
|
||||
<form onsubmit={handleBugReportSubmit}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.bugreport_title()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_description()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-name">{m.bugreport_name_label()}</Label>
|
||||
<Input
|
||||
id="bug-name"
|
||||
placeholder={m.bugreport_name_placeholder()}
|
||||
bind:value={userName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-email">{m.bugreport_email_label()}</Label>
|
||||
<Input
|
||||
id="bug-email"
|
||||
type="email"
|
||||
placeholder={m.bugreport_email_placeholder()}
|
||||
bind:value={userEmail}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="bug-description">{m.bugreport_text_label()}</Label>
|
||||
<Textarea
|
||||
id="bug-description"
|
||||
placeholder={m.bugreport_text_placeholder()}
|
||||
bind:value={bugDescription}
|
||||
disabled={isSubmitting}
|
||||
class="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Preview -->
|
||||
<div class="grid gap-2">
|
||||
<Label class="flex items-center gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
{m.bugreport_screenshot_label()}
|
||||
</Label>
|
||||
{#if isCapturing}
|
||||
<div class="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Capturing...
|
||||
</div>
|
||||
{:else if screenshotData}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<img
|
||||
src="data:image/png;base64,{screenshotData}"
|
||||
alt="Screenshot preview"
|
||||
class="w-full h-32 object-cover object-top opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
No screenshot available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{m.bugreport_info()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<button type="button" class={buttonVariants({ variant: "outline" })} disabled={isSubmitting} onclick={closeBugReportDialog}>
|
||||
{m.bugreport_cancel()}
|
||||
</button>
|
||||
<Button type="submit" disabled={isSubmitting || isCapturing}>
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="h-4 w-4 mr-2 animate-spin" />
|
||||
{m.bugreport_submitting()}
|
||||
{:else}
|
||||
{m.bugreport_submit()}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<BugReportDialog />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -808,26 +573,4 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb) {
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
:global(.custom-scrollbar::-webkit-scrollbar-corner) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,6 +59,16 @@ const inspirationTracks: SpotifyTrack[] = [
|
||||
artist: "When Snakes Sing",
|
||||
spotifyUrl: "https://open.spotify.com/track/1nDkT2Cn13qDnFegF93UHi",
|
||||
embedUrl: "https://open.spotify.com/embed/track/1nDkT2Cn13qDnFegF93UHi?utm_source=generator"
|
||||
}, {
|
||||
name: "Keep It Tucked",
|
||||
artist: "ThxSoMch",
|
||||
spotifyUrl: "https://open.spotify.com/track/1EdQCb51lC8usq47IMhADP",
|
||||
embedUrl: "https://open.spotify.com/embed/track/1EdQCb51lC8usq47IMhADP?utm_source=generator"
|
||||
}, {
|
||||
name: "Deadly Valentine",
|
||||
artist: "Charlotte Gainsbourg",
|
||||
spotifyUrl: "https://open.spotify.com/track/0pfTlQJBOV4LUmF8qqrVy5",
|
||||
embedUrl: "https://open.spotify.com/embed/track/0pfTlQJBOV4LUmF8qqrVy5?utm_source=generator"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -71,7 +81,7 @@ async function fetchEmbedHtml(track: SpotifyTrack, fetch: typeof globalThis.fetc
|
||||
return { ...track, embedHtml: data.html };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch oEmbed for ${track.name}:`, e);
|
||||
console.error(`Failed to fetch oEmbed for ${track.spotifyUrl}:`, e);
|
||||
}
|
||||
return track;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon } from "@lucide/svelte";
|
||||
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon, FolderArchive } from "@lucide/svelte";
|
||||
import type { EMLy_GUI_Settings } from "$lib/types";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { It, Us } from "svelte-flags";
|
||||
@@ -25,8 +25,9 @@
|
||||
import { setLocale } from "$lib/paraglide/runtime";
|
||||
import { mailState } from "$lib/stores/mail-state.svelte.js";
|
||||
import { dev } from '$app/environment';
|
||||
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus, SetUpdateCheckerEnabled } from "$lib/wailsjs/go/main/App";
|
||||
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, SetUpdateCheckerEnabled, ShowOpenFolderDialog, GetExportAttachmentFolder, SetExportAttachmentFolder } from "$lib/wailsjs/go/main/App";
|
||||
import { EventsOn, EventsOff } from "$lib/wailsjs/runtime/runtime";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
let config = $derived(data.config);
|
||||
@@ -44,6 +45,8 @@
|
||||
reduceMotion: false,
|
||||
theme: "dark",
|
||||
increaseWindowButtonsContrast: false,
|
||||
exportAttachmentFolder: "",
|
||||
useCustomAttachmentDownload: false,
|
||||
};
|
||||
|
||||
async function setLanguage(
|
||||
@@ -82,6 +85,8 @@
|
||||
reduceMotion: s.reduceMotion ?? defaults.reduceMotion ?? false,
|
||||
theme: s.theme || defaults.theme || "light",
|
||||
increaseWindowButtonsContrast: s.increaseWindowButtonsContrast ?? defaults.increaseWindowButtonsContrast ?? false,
|
||||
exportAttachmentFolder: s.exportAttachmentFolder || defaults.exportAttachmentFolder || "",
|
||||
useCustomAttachmentDownload: s.useCustomAttachmentDownload ?? defaults.useCustomAttachmentDownload ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +99,8 @@
|
||||
!!a.useDarkEmailViewer === !!b.useDarkEmailViewer &&
|
||||
!!a.enableUpdateChecker === !!b.enableUpdateChecker &&
|
||||
!!a.reduceMotion === !!b.reduceMotion &&
|
||||
!!a.exportAttachmentFolder === !!b.exportAttachmentFolder &&
|
||||
!!a.useCustomAttachmentDownload === !!b.useCustomAttachmentDownload &&
|
||||
(a.theme ?? "light") === (b.theme ?? "light") &&
|
||||
!!a.increaseWindowButtonsContrast === !!b.increaseWindowButtonsContrast &&
|
||||
JSON.stringify(a.previewFileSupportedTypes?.sort()) ===
|
||||
@@ -142,6 +149,7 @@
|
||||
sessionStorage.removeItem("debugWindowInSettings");
|
||||
dangerZoneEnabled.set(false);
|
||||
LogDebug("Reset danger zone setting to false.");
|
||||
await SetExportAttachmentFolder("");
|
||||
} catch {
|
||||
toast.error(m.settings_toast_reset_failed());
|
||||
return;
|
||||
@@ -195,10 +203,10 @@
|
||||
});
|
||||
|
||||
// Sync update checker setting to backend config.ini
|
||||
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
|
||||
$effect(() => {
|
||||
(async () => {
|
||||
if (!browser) return;
|
||||
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
|
||||
if (form.enableUpdateChecker !== previousUpdateCheckerEnabled) {
|
||||
try {
|
||||
await SetUpdateCheckerEnabled(form.enableUpdateChecker ?? true);
|
||||
@@ -221,6 +229,52 @@
|
||||
previousTheme = form.theme;
|
||||
});
|
||||
|
||||
// Load export attachment folder from config.ini on startup
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
(async () => {
|
||||
try {
|
||||
const configFolder = await GetExportAttachmentFolder();
|
||||
if (configFolder && configFolder.trim() !== "") {
|
||||
form.exportAttachmentFolder = configFolder;
|
||||
// Also update lastSaved to avoid triggering unsaved changes
|
||||
lastSaved = { ...lastSaved, exportAttachmentFolder: configFolder };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load export folder from config:", err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
async function openFolderDialog(): Promise<string | null> {
|
||||
try {
|
||||
const result = await ShowOpenFolderDialog();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to open folder dialog:", err);
|
||||
toast.error("Failed to open folder dialog.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function selectExportFolder() {
|
||||
const folder = await openFolderDialog();
|
||||
if (folder) {
|
||||
// Save to form state
|
||||
form.exportAttachmentFolder = folder;
|
||||
// Save to config.ini
|
||||
try {
|
||||
await SetExportAttachmentFolder(folder);
|
||||
toast.success("Export folder updated!");
|
||||
} catch (err) {
|
||||
console.error("Failed to save export folder:", err);
|
||||
toast.error("Failed to save export folder to config.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSettings() {
|
||||
try {
|
||||
const settingsJSON = JSON.stringify(form, null, 2);
|
||||
@@ -344,7 +398,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-[calc(100vh-1rem)] bg-gradient-to-b from-background to-muted/30">
|
||||
<div class="min-h-[calc(100vh-1rem)] bg-linear-to-b from-background to-muted/30">
|
||||
<div
|
||||
class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 sm:px-6 sm:py-10 opacity-80"
|
||||
>
|
||||
@@ -692,6 +746,61 @@
|
||||
{m.settings_preview_pdf_builtin_info()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4">
|
||||
<div>
|
||||
<div class="font-medium">{m.settings_custom_download_label()}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{m.settings_custom_download_hint()}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="use-custom-attachment-download"
|
||||
bind:checked={form.useCustomAttachmentDownload}
|
||||
class="cursor-pointer hover:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
{m.settings_custom_download_info()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if form.useCustomAttachmentDownload}
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{m.settings_export_folder_label()}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{m.settings_export_folder_hint()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="%USERPROFILE%\Documents\EMLy_Attachments"
|
||||
class="flex-1"
|
||||
readonly
|
||||
bind:value={form.exportAttachmentFolder}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="cursor-pointer hover:cursor-pointer"
|
||||
onclick={selectExportFolder}
|
||||
>
|
||||
<FolderArchive class="size-4 mr-2" />
|
||||
{m.settings_select_folder_button()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { GetMachineData, GetConfig } from "$lib/wailsjs/go/main/App";
|
||||
import { GetConfig } from "$lib/wailsjs/go/main/App";
|
||||
import { browser } from '$app/environment';
|
||||
import { dangerZoneEnabled } from "$lib/stores/app";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export const load = (async () => {
|
||||
if (!browser) return { machineData: null, config: null };
|
||||
if (!browser) return { config: null };
|
||||
|
||||
try {
|
||||
const [machineData, configRoot] = await Promise.all([
|
||||
get(dangerZoneEnabled) ? GetMachineData() : Promise.resolve(null),
|
||||
GetConfig()
|
||||
]);
|
||||
const configRoot = await GetConfig();
|
||||
return {
|
||||
machineData,
|
||||
config: configRoot.EMLy
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings data", e);
|
||||
return {
|
||||
machineData: null,
|
||||
config: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
AlignHorizontalSpaceAround,
|
||||
Download
|
||||
} from "@lucide/svelte";
|
||||
import { sidebarOpen } from "$lib/stores/app";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -84,6 +85,17 @@
|
||||
fitToScreen();
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
if (!imageData || !filename) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = `data:image/png;base64,${imageData}`;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
@@ -116,6 +128,10 @@
|
||||
<h1 class="title" title={filename}>{filename || "Image Viewer"}</h1>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick={() => downloadImage()} title="Download">
|
||||
<Download size="16" />
|
||||
</button>
|
||||
<div class="separator"></div>
|
||||
<button class="btn" onclick={() => zoom(0.1)} title="Zoom In">
|
||||
<ZoomIn size="16" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import type { PageData } from "./$types";
|
||||
import {
|
||||
RotateCcw,
|
||||
@@ -7,6 +7,7 @@
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
AlignHorizontalSpaceAround,
|
||||
Download
|
||||
} from "@lucide/svelte";
|
||||
import { sidebarOpen } from "$lib/stores/app";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -106,7 +107,13 @@
|
||||
if (!pdfDoc || !canvasRef) return;
|
||||
|
||||
if (renderTask) {
|
||||
await renderTask.promise.catch(() => {}); // Cancel previous render if any (though we wait usually)
|
||||
// Cancel previous render if any and await its cleanup
|
||||
renderTask.cancel();
|
||||
try {
|
||||
await renderTask.promise;
|
||||
} catch (e) {
|
||||
// Expected cancellation error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -130,7 +137,9 @@
|
||||
};
|
||||
|
||||
// Cast to any to avoid type mismatch with PDF.js definitions
|
||||
await page.render(renderContext as any).promise;
|
||||
const task = page.render(renderContext as any);
|
||||
renderTask = task;
|
||||
await task.promise;
|
||||
} catch (e: any) {
|
||||
if (e.name !== "RenderingCancelledException") {
|
||||
console.error(e);
|
||||
@@ -155,11 +164,15 @@
|
||||
|
||||
$effect(() => {
|
||||
// Re-render when scale or rotation changes
|
||||
// Access them here to ensure dependency tracking since renderPage is async
|
||||
const _deps = [scale, rotation];
|
||||
// Access them here to ensure dependency tracking since renderPage is untracked
|
||||
// We also track pageNum to ensure we re-render if it changes via other means,
|
||||
// although navigation functions usually call renderPage manually.
|
||||
const _deps = [scale, rotation, pageNum];
|
||||
|
||||
if (pdfDoc) {
|
||||
renderPage(pageNum);
|
||||
// Untrack renderPage because it reads and writes to renderTask,
|
||||
// which would otherwise cause an infinite loop.
|
||||
untrack(() => renderPage(pageNum));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -182,6 +195,24 @@
|
||||
pageNum--;
|
||||
renderPage(pageNum);
|
||||
}
|
||||
|
||||
function downloadPDF() {
|
||||
if (!pdfData) return;
|
||||
try {
|
||||
// @ts-ignore
|
||||
const blob = new Blob([pdfData], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename || "document.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error("Failed to download PDF: " + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="viewer-container">
|
||||
@@ -202,6 +233,10 @@
|
||||
<h1 class="title" title={filename}>{filename || m.pdf_viewer_title()}</h1>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick={() => downloadPDF()} title={m.mail_download_btn_title()}>
|
||||
<Download size="16" />
|
||||
</button>
|
||||
<div class="separator"></div>
|
||||
<button class="btn" onclick={() => zoom(0.1)} title={m.pdf_zoom_in()}>
|
||||
<ZoomIn size="16" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#define ApplicationName 'EMLy'
|
||||
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
|
||||
#define ApplicationVersion '1.5.3_beta'
|
||||
#define ApplicationVersion '1.5.4_beta'
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
@@ -80,6 +80,20 @@ var
|
||||
PreviousVersion: String;
|
||||
IsUpgrade: Boolean;
|
||||
|
||||
// Check if a command line parameter exists
|
||||
function CmdLineParamExists(const Param: string): Boolean;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
for I := 1 to ParamCount do
|
||||
if CompareText(ParamStr(I), Param) = 0 then
|
||||
begin
|
||||
Result := True;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
// Check if a previous version is installed
|
||||
function GetPreviousVersion(): String;
|
||||
var
|
||||
@@ -114,6 +128,9 @@ begin
|
||||
IsUpgrade := (PreviousVersion <> '');
|
||||
|
||||
if IsUpgrade then
|
||||
begin
|
||||
// Check for /FORCEUPGRADE parameter to skip confirmation
|
||||
if not CmdLineParamExists('/FORCEUPGRADE') then
|
||||
begin
|
||||
// Show upgrade message
|
||||
Message := FmtMessage(CustomMessage('UpgradeDetected'), [PreviousVersion]) + #13#10#13#10 +
|
||||
@@ -125,6 +142,7 @@ begin
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
// Show appropriate welcome message
|
||||
procedure InitializeWizard();
|
||||
|
||||
56
main.go
56
main.go
@@ -4,11 +4,14 @@ import (
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
@@ -33,6 +36,12 @@ func main() {
|
||||
}
|
||||
defer CloseLogger()
|
||||
|
||||
// Load config.ini to get WebView2 paths
|
||||
configPath := filepath.Join(filepath.Dir(os.Args[0]), "config.ini")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
configPath = "config.ini" // fallback to current directory
|
||||
}
|
||||
|
||||
// Check for custom args
|
||||
args := os.Args
|
||||
uniqueId := "emly-app-lock"
|
||||
@@ -74,6 +83,49 @@ func main() {
|
||||
}
|
||||
|
||||
// Create application with options
|
||||
// Configure WebView2 DataPath (user data folder)
|
||||
userDataPath := filepath.Join(os.Getenv("APPDATA"), "EMLy") // default
|
||||
downloadPath := filepath.Join(os.Getenv("USERPROFILE"), "Downloads") // default
|
||||
|
||||
// Helper function to expand Windows-style environment variables
|
||||
expandEnvVars := func(path string) string {
|
||||
// Match %%VAR%% or %VAR% patterns and replace with actual values
|
||||
re := regexp.MustCompile(`%%([^%]+)%%|%([^%]+)%`)
|
||||
return re.ReplaceAllStringFunc(path, func(match string) string {
|
||||
varName := strings.Trim(match, "%")
|
||||
return os.Getenv(varName)
|
||||
})
|
||||
}
|
||||
|
||||
// Load paths from config.ini if available
|
||||
if cfg, err := os.ReadFile(configPath); err == nil {
|
||||
// Simple INI parsing for these specific values
|
||||
lines := strings.Split(string(cfg), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "WEBVIEW2_USERDATA_PATH") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
path := strings.TrimSpace(parts[1])
|
||||
if path != "" {
|
||||
userDataPath = expandEnvVars(path)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "WEBVIEW2_DOWNLOAD_PATH") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
path := strings.TrimSpace(parts[1])
|
||||
if path != "" {
|
||||
downloadPath = expandEnvVars(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("WebView2 UserDataPath: %s", userDataPath)
|
||||
log.Printf("WebView2 DownloadPath: %s", downloadPath)
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: windowTitle,
|
||||
Width: windowWidth,
|
||||
@@ -94,6 +146,10 @@ func main() {
|
||||
MinWidth: 964,
|
||||
MinHeight: 690,
|
||||
Frameless: frameless,
|
||||
Windows: &windows.Options{
|
||||
WebviewUserDataPath: userDataPath,
|
||||
WebviewBrowserPath: "", // Empty = use system Edge WebView2
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user