Compare commits
28 Commits
ad1116db14
...
webview-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a343769e5 | ||
|
|
33cb171fb1 | ||
|
|
549eed065a | ||
|
|
547018a39f | ||
|
|
18c256ebf9 | ||
|
|
3eb95cca7f | ||
|
|
6f373dd9ab | ||
|
|
eac7a12cd4 | ||
|
|
86e33d6189 | ||
|
|
402a90cf4b | ||
|
|
b68c173d2a | ||
|
|
fc98f0ed74 | ||
|
|
4c99c14be7 | ||
|
|
4b6f2d727c | ||
|
|
51679b61eb | ||
|
|
5b62790248 | ||
|
|
0cfe1b65f3 | ||
|
|
43cce905eb | ||
|
|
f1d603cc45 | ||
|
|
e9500209a8 | ||
|
|
44ee69051d | ||
|
|
ea43cd715a | ||
|
|
307966565a | ||
|
|
aef5c317df | ||
|
|
c0c1fbb08f | ||
|
|
f551efd5bf | ||
|
|
6a44eba7ca | ||
|
|
d9e848d3f4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,3 +46,4 @@ extra/*.dll
|
|||||||
|
|
||||||
*.eml
|
*.eml
|
||||||
*.msg
|
*.msg
|
||||||
|
frontend/bun.lock
|
||||||
|
|||||||
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.
|
||||||
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
EMLy is a Windows desktop email viewer for `.eml` and `.msg` files built with Wails v2 (Go backend + SvelteKit/Svelte 5 frontend). It supports viewing email content, attachments, and Italian PEC (Posta Elettronica Certificata) certified emails.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development mode with hot-reload
|
||||||
|
wails dev
|
||||||
|
|
||||||
|
# Production build (outputs to build/bin/EMLy.exe)
|
||||||
|
wails build
|
||||||
|
|
||||||
|
# Build for specific platform
|
||||||
|
wails build -platform windows/amd64
|
||||||
|
|
||||||
|
# Frontend type checking
|
||||||
|
cd frontend && bun run check
|
||||||
|
|
||||||
|
# Go tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Install frontend dependencies (also runs automatically on build)
|
||||||
|
cd frontend && bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Wails Framework
|
||||||
|
- Go backend methods are automatically exposed to frontend via generated TypeScript bindings
|
||||||
|
- Bindings are generated in `frontend/src/lib/wailsjs/go/main/App.ts`
|
||||||
|
- All App methods with exported names become callable from TypeScript
|
||||||
|
- Wails runtime provides window control, dialogs, and event system
|
||||||
|
|
||||||
|
### Backend Structure (Go)
|
||||||
|
|
||||||
|
The `App` struct is the main controller, split across files by domain:
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `app.go` | Core struct, lifecycle (startup/shutdown), configuration |
|
||||||
|
| `app_mail.go` | Email reading: ReadEML, ReadMSG, ReadPEC, ShowOpenFileDialog |
|
||||||
|
| `app_viewer.go` | Viewer windows: OpenImageWindow, OpenPDFWindow, OpenEMLWindow |
|
||||||
|
| `app_screenshot.go` | Window capture using Windows GDI APIs |
|
||||||
|
| `app_bugreport.go` | Bug report creation with screenshots and system info |
|
||||||
|
| `app_settings.go` | Settings import/export to JSON |
|
||||||
|
| `app_system.go` | Windows registry, encoding conversion, file explorer |
|
||||||
|
|
||||||
|
Email parsing lives in `backend/utils/mail/`:
|
||||||
|
- `eml_reader.go` - Standard EML parsing
|
||||||
|
- `msg_reader.go` - Microsoft MSG (CFB format) parsing
|
||||||
|
- `mailparser.go` - MIME multipart handling
|
||||||
|
|
||||||
|
For any major change to backend functionality, add a new method to `App` and implement it in a new `app_*.go` file for organization.
|
||||||
|
And update the DOCUMENTATION.md file in the root of the repository with a brief description of the new method and its purpose.
|
||||||
|
|
||||||
|
### Frontend Structure (SvelteKit + Svelte 5)
|
||||||
|
|
||||||
|
**Routes** (file-based routing):
|
||||||
|
- `(app)/` - Main app with sidebar layout, titlebar, footer
|
||||||
|
- `(app)/settings/` - Settings page
|
||||||
|
- `image/` - Standalone image viewer (launched with `--view-image=<path>`)
|
||||||
|
- `pdf/` - Standalone PDF viewer (launched with `--view-pdf=<path>`)
|
||||||
|
|
||||||
|
**Key patterns**:
|
||||||
|
- Svelte 5 runes: `$state`, `$effect`, `$derived`, `$props` (NOT legacy stores in components)
|
||||||
|
- State classes in `stores/*.svelte.ts` using `$state` for reactive properties
|
||||||
|
- Traditional Svelte stores in `stores/app.ts` for simple global state
|
||||||
|
- shadcn-svelte components in `lib/components/ui/`
|
||||||
|
|
||||||
|
**Mail utilities** (`lib/utils/mail/`):
|
||||||
|
- Modular utilities for email loading, attachment handling, data conversion
|
||||||
|
- Barrel exported from `index.ts`
|
||||||
|
|
||||||
|
### Multi-Window Support
|
||||||
|
|
||||||
|
The app spawns separate viewer processes:
|
||||||
|
- Main app uses single-instance lock with ID `emly-app-lock`
|
||||||
|
- Image/PDF viewers get unique IDs per file, allowing multiple concurrent viewers
|
||||||
|
- CLI args: `--view-image=<path>` or `--view-pdf=<path>` trigger viewer mode
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
|
||||||
|
Uses ParaglideJS with compile-time type-safe translations:
|
||||||
|
- Translation files: `frontend/messages/en.json`, `it.json`
|
||||||
|
- Import: `import * as m from "$lib/paraglide/messages"`
|
||||||
|
- Usage: `{m.mail_open_btn_text()}`
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Calling Go from Frontend
|
||||||
|
```typescript
|
||||||
|
import { ReadEML, ShowOpenFileDialog } from "$lib/wailsjs/go/main/App";
|
||||||
|
const filePath = await ShowOpenFileDialog();
|
||||||
|
const email = await ReadEML(filePath);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Communication
|
||||||
|
```typescript
|
||||||
|
import { EventsOn } from "$lib/wailsjs/runtime/runtime";
|
||||||
|
EventsOn("launchArgs", (args: string[]) => { /* handle file from second instance */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Body Rendering
|
||||||
|
Email bodies render in sandboxed iframes with links disabled for security.
|
||||||
|
|
||||||
|
## Important Conventions
|
||||||
|
|
||||||
|
- Windows-only: Uses Windows APIs (registry, GDI, user32.dll)
|
||||||
|
- Bun package manager for frontend (not npm/yarn)
|
||||||
|
- Frontend assets embedded in binary via `//go:embed all:frontend/build`
|
||||||
|
- Custom frameless window with manual titlebar implementation
|
||||||
925
DOCUMENTATION.md
Normal file
925
DOCUMENTATION.md
Normal file
@@ -0,0 +1,925 @@
|
|||||||
|
# EMLy Application Documentation
|
||||||
|
|
||||||
|
EMLy is a desktop email viewer application built for 3gIT, designed to open and display `.eml` and `.msg` email files on Windows. It provides a modern, user-friendly interface for viewing email content, attachments, and metadata.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#architecture-overview)
|
||||||
|
2. [Technology Stack](#technology-stack)
|
||||||
|
3. [Project Structure](#project-structure)
|
||||||
|
4. [Backend (Go)](#backend-go)
|
||||||
|
5. [Frontend (SvelteKit)](#frontend-sveltekit)
|
||||||
|
6. [State Management](#state-management)
|
||||||
|
7. [Internationalization (i18n)](#internationalization-i18n)
|
||||||
|
8. [UI Components](#ui-components)
|
||||||
|
9. [Key Features](#key-features)
|
||||||
|
10. [Build & Development](#build--development)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
EMLy is built using the **Wails v2** framework, which combines a Go backend with a web-based frontend. This architecture allows:
|
||||||
|
|
||||||
|
- **Go Backend**: Handles file operations, Windows API calls, email parsing, and system interactions
|
||||||
|
- **Web Frontend**: Provides the user interface using SvelteKit with Svelte 5
|
||||||
|
- **Bridge**: Wails automatically generates TypeScript bindings for Go functions, enabling seamless communication
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ EMLy Application │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Frontend (SvelteKit + Svelte 5) │
|
||||||
|
│ ├── Routes & Pages │
|
||||||
|
│ ├── UI Components (shadcn-svelte) │
|
||||||
|
│ ├── State Management (Svelte 5 Runes) │
|
||||||
|
│ └── i18n (ParaglideJS) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Wails Bridge (Auto-generated TypeScript bindings) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Backend (Go - Modular Architecture) │
|
||||||
|
│ ├── app.go - Core struct & lifecycle │
|
||||||
|
│ ├── app_mail.go - Email parsing (EML/MSG/PEC) │
|
||||||
|
│ ├── app_viewer.go - Viewer window management │
|
||||||
|
│ ├── app_screenshot.go - Window capture │
|
||||||
|
│ ├── app_bugreport.go - Bug reporting system │
|
||||||
|
│ ├── app_settings.go - Settings import/export │
|
||||||
|
│ ├── app_system.go - Windows system utilities │
|
||||||
|
│ ├── app_update.go - Self-hosted update system │
|
||||||
|
│ └── backend/utils/ - Shared utilities │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Go 1.21+**: Primary backend language
|
||||||
|
- **Wails v2**: Desktop application framework
|
||||||
|
- **Windows Registry API**: For checking default file handlers
|
||||||
|
- **GDI/User32 APIs**: For screenshot functionality
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **SvelteKit**: Application framework
|
||||||
|
- **Svelte 5**: UI framework with Runes ($state, $derived, $effect, $props)
|
||||||
|
- **TypeScript**: Type-safe JavaScript
|
||||||
|
- **shadcn-svelte**: UI component library
|
||||||
|
- **Tailwind CSS**: Utility-first CSS framework
|
||||||
|
- **ParaglideJS**: Internationalization
|
||||||
|
- **Lucide Icons**: Icon library
|
||||||
|
- **Bun**: JavaScript runtime and package manager
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
EMLy/
|
||||||
|
├── app.go # Core App struct, lifecycle, and configuration
|
||||||
|
├── app_mail.go # Email reading methods (EML, MSG, PEC)
|
||||||
|
├── app_viewer.go # Viewer window management (image, PDF, EML)
|
||||||
|
├── app_screenshot.go # Screenshot capture functionality
|
||||||
|
├── app_bugreport.go # Bug report creation and submission
|
||||||
|
├── app_settings.go # Settings import/export
|
||||||
|
├── app_system.go # Windows system utilities (registry, encoding)
|
||||||
|
├── main.go # Application entry point
|
||||||
|
├── logger.go # Logging utilities
|
||||||
|
├── wails.json # Wails configuration
|
||||||
|
├── backend/
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── mail/
|
||||||
|
│ │ ├── eml_reader.go # EML file parsing
|
||||||
|
│ │ ├── msg_reader.go # MSG file parsing
|
||||||
|
│ │ ├── mailparser.go # MIME email parsing
|
||||||
|
│ │ └── file_dialog.go # File dialog utilities
|
||||||
|
│ ├── screenshot_windows.go # Windows screenshot capture
|
||||||
|
│ ├── debug_windows.go # Debugger detection
|
||||||
|
│ ├── ini-reader.go # Configuration file parsing
|
||||||
|
│ ├── machine-identifier.go # System info collection
|
||||||
|
│ └── file-metadata.go # File metadata utilities
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/ # SvelteKit routes
|
||||||
|
│ │ │ ├── +layout.svelte # Root layout
|
||||||
|
│ │ │ ├── +error.svelte # Error page
|
||||||
|
│ │ │ ├── (app)/ # Main app route group
|
||||||
|
│ │ │ │ ├── +layout.svelte # App layout with sidebar
|
||||||
|
│ │ │ │ ├── +page.svelte # Mail viewer page
|
||||||
|
│ │ │ │ └── settings/
|
||||||
|
│ │ │ │ └── +page.svelte # Settings page
|
||||||
|
│ │ │ ├── image/ # Image viewer route
|
||||||
|
│ │ │ │ ├── +layout.svelte
|
||||||
|
│ │ │ │ └── +page.svelte
|
||||||
|
│ │ │ └── pdf/ # PDF viewer route
|
||||||
|
│ │ │ ├── +layout.svelte
|
||||||
|
│ │ │ └── +page.svelte
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ ├── components/ # Svelte components
|
||||||
|
│ │ │ │ ├── MailViewer.svelte
|
||||||
|
│ │ │ │ ├── SidebarApp.svelte
|
||||||
|
│ │ │ │ ├── UnsavedBar.svelte
|
||||||
|
│ │ │ │ └── ui/ # shadcn-svelte components
|
||||||
|
│ │ │ ├── stores/ # State management
|
||||||
|
│ │ │ │ ├── app.ts
|
||||||
|
│ │ │ │ ├── mail-state.svelte.ts
|
||||||
|
│ │ │ │ └── settings.svelte.ts
|
||||||
|
│ │ │ ├── paraglide/ # i18n runtime
|
||||||
|
│ │ │ ├── wailsjs/ # Auto-generated Go bindings
|
||||||
|
│ │ │ ├── types/ # TypeScript types
|
||||||
|
│ │ │ └── utils/ # Utility functions
|
||||||
|
│ │ │ └── mail/ # Email utilities (modular)
|
||||||
|
│ │ │ ├── index.ts # Barrel export
|
||||||
|
│ │ │ ├── constants.ts # IFRAME_UTIL_HTML, CONTENT_TYPES, etc.
|
||||||
|
│ │ │ ├── data-utils.ts # arrayBufferToBase64, createDataUrl
|
||||||
|
│ │ │ ├── attachment-handlers.ts # openPDFAttachment, openImageAttachment
|
||||||
|
│ │ │ └── email-loader.ts # loadEmailFromPath, processEmailBody
|
||||||
|
│ │ └── messages/ # i18n translation files
|
||||||
|
│ │ ├── en.json
|
||||||
|
│ │ └── it.json
|
||||||
|
│ ├── static/ # Static assets
|
||||||
|
│ └── package.json
|
||||||
|
└── config.ini # Application configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend (Go)
|
||||||
|
|
||||||
|
### Entry Point (`main.go`)
|
||||||
|
|
||||||
|
The application starts in `main.go`, which:
|
||||||
|
|
||||||
|
1. Initializes the logger
|
||||||
|
2. Parses command-line arguments for:
|
||||||
|
- `.eml` or `.msg` files to open on startup
|
||||||
|
- `--view-image=<path>` for image viewer mode
|
||||||
|
- `--view-pdf=<path>` for PDF viewer mode
|
||||||
|
3. Configures Wails with window options
|
||||||
|
4. Sets up single-instance lock to prevent multiple main windows
|
||||||
|
5. Binds the `App` struct for frontend access
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Single instance with unique ID based on mode
|
||||||
|
uniqueId := "emly-app-lock"
|
||||||
|
if strings.Contains(arg, "--view-image") {
|
||||||
|
uniqueId = "emly-viewer-" + arg
|
||||||
|
windowTitle = "EMLy Image Viewer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Core (`app.go`)
|
||||||
|
|
||||||
|
The `App` struct is the main application controller, exposed to the frontend via Wails bindings. The code is organized into multiple files for maintainability.
|
||||||
|
|
||||||
|
#### Key Properties
|
||||||
|
|
||||||
|
```go
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context // Wails application context
|
||||||
|
StartupFilePath string // File opened via command line
|
||||||
|
CurrentMailFilePath string // Currently loaded mail file
|
||||||
|
openImagesMux sync.Mutex // Mutex for image viewer tracking
|
||||||
|
openImages map[string]bool // Track open image viewers
|
||||||
|
openPDFsMux sync.Mutex // Mutex for PDF viewer tracking
|
||||||
|
openPDFs map[string]bool // Track open PDF viewers
|
||||||
|
openEMLsMux sync.Mutex // Mutex for EML viewer tracking
|
||||||
|
openEMLs map[string]bool // Track open EML viewers
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backend File Organization
|
||||||
|
|
||||||
|
The Go backend is split into logical files:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app.go` | Core App struct, constructor, lifecycle methods (startup/shutdown), configuration |
|
||||||
|
| `app_mail.go` | Email reading: `ReadEML`, `ReadMSG`, `ReadPEC`, `ReadMSGOSS`, `ShowOpenFileDialog` |
|
||||||
|
| `app_viewer.go` | Viewer windows: `OpenImageWindow`, `OpenPDFWindow`, `OpenEMLWindow`, `OpenPDF`, `OpenImage`, `GetViewerData` |
|
||||||
|
| `app_screenshot.go` | Screenshots: `TakeScreenshot`, `SaveScreenshot`, `SaveScreenshotAs` |
|
||||||
|
| `app_bugreport.go` | Bug reports: `CreateBugReportFolder`, `SubmitBugReport`, `zipFolder` |
|
||||||
|
| `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` |
|
||||||
|
| `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` |
|
||||||
|
| `app_update.go` | Update system: `CheckForUpdates`, `DownloadUpdate`, `InstallUpdate`, `GetUpdateStatus` |
|
||||||
|
|
||||||
|
#### Core Methods by Category
|
||||||
|
|
||||||
|
**Lifecycle & Configuration (`app.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `startup(ctx)` | Wails startup callback, saves context |
|
||||||
|
| `shutdown(ctx)` | Wails shutdown callback for cleanup |
|
||||||
|
| `QuitApp()` | Terminates the application |
|
||||||
|
| `GetConfig()` | Returns application configuration from `config.ini` |
|
||||||
|
| `SaveConfig(cfg)` | Saves configuration to `config.ini` |
|
||||||
|
| `GetStartupFile()` | Returns file path passed via command line |
|
||||||
|
| `SetCurrentMailFilePath()` | Updates the current mail file path |
|
||||||
|
| `GetMachineData()` | Returns system information |
|
||||||
|
| `IsDebuggerRunning()` | Checks if a debugger is attached |
|
||||||
|
|
||||||
|
**Email Reading (`app_mail.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ReadEML(path)` | Parses a standard .eml file |
|
||||||
|
| `ReadMSG(path, useExternal)` | Parses a Microsoft .msg file |
|
||||||
|
| `ReadPEC(path)` | Parses PEC (Italian certified email) files |
|
||||||
|
| `ShowOpenFileDialog()` | Opens native file picker for EML/MSG files |
|
||||||
|
|
||||||
|
**Viewer Windows (`app_viewer.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `OpenImageWindow(data, filename)` | Opens image in built-in viewer |
|
||||||
|
| `OpenPDFWindow(data, filename)` | Opens PDF in built-in viewer |
|
||||||
|
| `OpenEMLWindow(data, filename)` | Opens EML attachment in new EMLy window |
|
||||||
|
| `OpenImage(data, filename)` | Opens image with system default app |
|
||||||
|
| `OpenPDF(data, filename)` | Opens PDF with system default app |
|
||||||
|
| `GetViewerData()` | Returns viewer data for viewer mode detection |
|
||||||
|
|
||||||
|
**Screenshots (`app_screenshot.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `TakeScreenshot()` | Captures window screenshot as base64 PNG |
|
||||||
|
| `SaveScreenshot()` | Saves screenshot to temp directory |
|
||||||
|
| `SaveScreenshotAs()` | Opens save dialog for screenshot |
|
||||||
|
|
||||||
|
**Bug Reports (`app_bugreport.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
|
||||||
|
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive |
|
||||||
|
|
||||||
|
**Settings (`app_settings.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ExportSettings(json)` | Exports settings to JSON file |
|
||||||
|
| `ImportSettings()` | Imports settings from JSON file |
|
||||||
|
|
||||||
|
**System Utilities (`app_system.go`)**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `CheckIsDefaultEMLHandler()` | Checks if EMLy is default for .eml files |
|
||||||
|
| `OpenDefaultAppsSettings()` | Opens Windows default apps settings |
|
||||||
|
| `ConvertToUTF8(string)` | Converts string to valid UTF-8 |
|
||||||
|
| `OpenFolderInExplorer(path)` | Opens folder in Windows Explorer |
|
||||||
|
|
||||||
|
### Email Parsing (`backend/utils/mail/`)
|
||||||
|
|
||||||
|
#### EML Reader (`eml_reader.go`)
|
||||||
|
Reads standard `.eml` files using the `mailparser.go` MIME parser.
|
||||||
|
|
||||||
|
#### MSG Reader (`msg_reader.go`)
|
||||||
|
Handles Microsoft Outlook `.msg` files using external conversion.
|
||||||
|
|
||||||
|
#### Mail Parser (`mailparser.go`)
|
||||||
|
A comprehensive MIME email parser that handles:
|
||||||
|
- Multipart messages (mixed, alternative, related)
|
||||||
|
- Text and HTML bodies
|
||||||
|
- Attachments with proper content-type detection
|
||||||
|
- Embedded files (inline images)
|
||||||
|
- Various content transfer encodings (base64, quoted-printable, 7bit, 8bit)
|
||||||
|
|
||||||
|
The `EmailData` structure returned to the frontend:
|
||||||
|
```go
|
||||||
|
type EmailData struct {
|
||||||
|
Subject string
|
||||||
|
From string
|
||||||
|
To []string
|
||||||
|
Cc []string
|
||||||
|
Bcc []string
|
||||||
|
Body string // HTML or text body
|
||||||
|
Attachments []AttachmentData
|
||||||
|
IsPec bool // Italian certified email
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshot Utility (`backend/utils/screenshot_windows.go`)
|
||||||
|
|
||||||
|
Captures the application window using Windows GDI APIs:
|
||||||
|
- Uses `FindWindowW` to locate window by title
|
||||||
|
- Uses `DwmGetWindowAttribute` for DPI-aware window bounds
|
||||||
|
- Creates compatible DC and bitmap for capture
|
||||||
|
- Returns image as base64-encoded PNG
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (SvelteKit)
|
||||||
|
|
||||||
|
### Route Structure
|
||||||
|
|
||||||
|
SvelteKit uses file-based routing. The `(app)` folder is a route group that applies the main app layout.
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/
|
||||||
|
├── +layout.svelte # Root layout (minimal)
|
||||||
|
├── +error.svelte # Global error page
|
||||||
|
├── (app)/ # Main app group
|
||||||
|
│ ├── +layout.svelte # App layout with titlebar, sidebar, footer
|
||||||
|
│ ├── +layout.ts # Server data loader
|
||||||
|
│ ├── +page.svelte # Main mail viewer page
|
||||||
|
│ └── settings/
|
||||||
|
│ ├── +page.svelte # Settings page
|
||||||
|
│ └── +layout.ts # Settings data loader
|
||||||
|
├── image/ # Standalone image viewer
|
||||||
|
│ ├── +layout.svelte
|
||||||
|
│ └── +page.svelte
|
||||||
|
└── pdf/ # Standalone PDF viewer
|
||||||
|
├── +layout.svelte
|
||||||
|
└── +page.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Main App Layout (`(app)/+layout.svelte`)
|
||||||
|
|
||||||
|
The app layout provides:
|
||||||
|
|
||||||
|
1. **Custom Titlebar**: Windows-style titlebar with minimize/maximize/close buttons
|
||||||
|
- Draggable for window movement
|
||||||
|
- Double-click to maximize/restore
|
||||||
|
|
||||||
|
2. **Sidebar Provider**: Collapsible navigation sidebar
|
||||||
|
|
||||||
|
3. **Footer Bar**: Quick access icons for:
|
||||||
|
- Toggle sidebar
|
||||||
|
- Navigate to home
|
||||||
|
- Navigate to settings
|
||||||
|
- Open bug report dialog
|
||||||
|
- Reload application
|
||||||
|
|
||||||
|
4. **Bug Report Dialog**: Complete bug reporting system with:
|
||||||
|
- Screenshot capture on dialog open
|
||||||
|
- Name, email, description fields
|
||||||
|
- System info collection
|
||||||
|
- Creates ZIP archive with all data
|
||||||
|
|
||||||
|
5. **Toast Notifications**: Using svelte-sonner
|
||||||
|
|
||||||
|
6. **Debugger Protection**: Detects attached debuggers and can quit if detected
|
||||||
|
|
||||||
|
### Mail Viewer (`(app)/+page.svelte` + `MailViewer.svelte`)
|
||||||
|
|
||||||
|
The mail viewer is split into two parts:
|
||||||
|
- `+page.svelte`: Page wrapper that initializes mail state from startup file
|
||||||
|
- `MailViewer.svelte`: Core email viewing component
|
||||||
|
|
||||||
|
#### MailViewer Features
|
||||||
|
|
||||||
|
- **Empty State**: Shows "Open EML/MSG File" button when no email loaded
|
||||||
|
- **Email Header**: Displays subject, from, to, cc, bcc fields
|
||||||
|
- **PEC Badge**: Shows green badge for Italian certified emails
|
||||||
|
- **Attachments Bar**: Horizontal scrollable list of attachments with type-specific icons
|
||||||
|
- **Email Body**: Rendered in sandboxed iframe for security
|
||||||
|
- **Loading Overlay**: Shows spinner during file loading
|
||||||
|
|
||||||
|
#### Attachment Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Different handlers based on file type
|
||||||
|
if (att.contentType.startsWith("image/")) {
|
||||||
|
// Opens in built-in or external viewer based on settings
|
||||||
|
await OpenImageWindow(base64Data, filename);
|
||||||
|
} else if (att.filename.toLowerCase().endsWith(".pdf")) {
|
||||||
|
// Opens in built-in or external PDF viewer
|
||||||
|
await OpenPDFWindow(base64Data, filename);
|
||||||
|
} else if (att.filename.toLowerCase().endsWith(".eml")) {
|
||||||
|
// Opens in new EMLy instance
|
||||||
|
await OpenEMLWindow(base64Data, filename);
|
||||||
|
} else {
|
||||||
|
// Download as file
|
||||||
|
<a href={dataUrl} download={filename}>...</a>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Mail Utilities (`lib/utils/mail/`)
|
||||||
|
|
||||||
|
The frontend email handling code is organized into modular utility files:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `index.ts` | Barrel export for all mail utilities |
|
||||||
|
| `constants.ts` | Constants: `IFRAME_UTIL_HTML`, `CONTENT_TYPES`, `PEC_FILES`, `EMAIL_EXTENSIONS` |
|
||||||
|
| `data-utils.ts` | Data conversion: `arrayBufferToBase64`, `createDataUrl`, `looksLikeBase64`, `tryDecodeBase64` |
|
||||||
|
| `attachment-handlers.ts` | Attachment opening: `openPDFAttachment`, `openImageAttachment`, `openEMLAttachment` |
|
||||||
|
| `email-loader.ts` | Email loading: `loadEmailFromPath`, `openAndLoadEmail`, `processEmailBody`, `isEmailFile` |
|
||||||
|
|
||||||
|
#### Key Functions
|
||||||
|
|
||||||
|
**Data Utilities** (`data-utils.ts`)
|
||||||
|
```typescript
|
||||||
|
// Convert ArrayBuffer to base64 string
|
||||||
|
function arrayBufferToBase64(buffer: unknown): string;
|
||||||
|
|
||||||
|
// Create data URL for file downloads
|
||||||
|
function createDataUrl(contentType: string, base64Data: string): string;
|
||||||
|
|
||||||
|
// Check if string looks like base64 encoded content
|
||||||
|
function looksLikeBase64(content: string): boolean;
|
||||||
|
|
||||||
|
// Attempt to decode base64, returns null on failure
|
||||||
|
function tryDecodeBase64(content: string): string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attachment Handlers** (`attachment-handlers.ts`)
|
||||||
|
```typescript
|
||||||
|
// Open PDF using built-in or external viewer based on settings
|
||||||
|
async function openPDFAttachment(base64Data: string, filename: string): Promise<AttachmentHandlerResult>;
|
||||||
|
|
||||||
|
// Open image using built-in or external viewer based on settings
|
||||||
|
async function openImageAttachment(base64Data: string, filename: string): Promise<AttachmentHandlerResult>;
|
||||||
|
|
||||||
|
// Open EML attachment in new EMLy window
|
||||||
|
async function openEMLAttachment(base64Data: string, filename: string): Promise<AttachmentHandlerResult>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Email Loader** (`email-loader.ts`)
|
||||||
|
```typescript
|
||||||
|
// Load email from file path, handles EML/MSG/PEC detection
|
||||||
|
async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult>;
|
||||||
|
|
||||||
|
// Open file dialog and load selected email
|
||||||
|
async function openAndLoadEmail(): Promise<LoadEmailResult>;
|
||||||
|
|
||||||
|
// Process email body (decode base64, fix encoding)
|
||||||
|
async function processEmailBody(body: string): Promise<string>;
|
||||||
|
|
||||||
|
// Check if file path is a valid email file
|
||||||
|
function isEmailFile(filePath: string): boolean;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings Page (`(app)/settings/+page.svelte`)
|
||||||
|
|
||||||
|
Organized into cards with various configuration options:
|
||||||
|
|
||||||
|
1. **Language Settings**
|
||||||
|
- Radio buttons for English/Italian
|
||||||
|
- Triggers full page reload on change
|
||||||
|
|
||||||
|
2. **Export/Import Settings**
|
||||||
|
- Export current settings to JSON file
|
||||||
|
- Import settings from JSON file
|
||||||
|
|
||||||
|
3. **Preview Page Settings**
|
||||||
|
- Supported image types (JPG, JPEG, PNG)
|
||||||
|
- Toggle built-in image viewer
|
||||||
|
- Toggle built-in PDF viewer
|
||||||
|
|
||||||
|
4. **Danger Zone** (Hidden by default, revealed by clicking settings 10 times rapidly)
|
||||||
|
- Open DevTools hint
|
||||||
|
- Reload application
|
||||||
|
- Reset to defaults
|
||||||
|
- Debugger protection toggle (disabled in production)
|
||||||
|
- Version information display
|
||||||
|
|
||||||
|
#### Unsaved Changes Detection
|
||||||
|
|
||||||
|
The settings page tracks changes and shows a persistent toast when there are unsaved modifications:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
$effect(() => {
|
||||||
|
const dirty = !isSameSettings(normalizeSettings(form), lastSaved);
|
||||||
|
unsavedChanges.set(dirty);
|
||||||
|
if (dirty) {
|
||||||
|
showUnsavedChangesToast({
|
||||||
|
onSave: saveToStorage,
|
||||||
|
onReset: resetToLastSaved,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
EMLy uses a combination of Svelte 5 Runes and traditional Svelte stores.
|
||||||
|
|
||||||
|
### Mail State (`stores/mail-state.svelte.ts`)
|
||||||
|
|
||||||
|
Uses Svelte 5's `$state` rune for reactive email data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MailState {
|
||||||
|
currentEmail = $state<internal.EmailData | null>(null);
|
||||||
|
|
||||||
|
setParams(email: internal.EmailData | null) {
|
||||||
|
this.currentEmail = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.currentEmail = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailState = new MailState();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings Store (`stores/settings.svelte.ts`)
|
||||||
|
|
||||||
|
Manages application settings with localStorage persistence:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class SettingsStore {
|
||||||
|
settings = $state<EMLy_GUI_Settings>({ ...defaults });
|
||||||
|
hasHydrated = $state(false);
|
||||||
|
|
||||||
|
load() { /* Load from localStorage */ }
|
||||||
|
save() { /* Save to localStorage */ }
|
||||||
|
update(newSettings: Partial<EMLy_GUI_Settings>) { /* Merge and save */ }
|
||||||
|
reset() { /* Reset to defaults */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings schema:
|
||||||
|
```typescript
|
||||||
|
interface EMLy_GUI_Settings {
|
||||||
|
selectedLanguage: "en" | "it";
|
||||||
|
useBuiltinPreview: boolean;
|
||||||
|
useBuiltinPDFViewer: boolean;
|
||||||
|
previewFileSupportedTypes: string[];
|
||||||
|
enableAttachedDebuggerProtection: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Store (`stores/app.ts`)
|
||||||
|
|
||||||
|
Traditional Svelte writable stores for UI state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const dangerZoneEnabled = writable<boolean>(false);
|
||||||
|
export const unsavedChanges = writable<boolean>(false);
|
||||||
|
export const sidebarOpen = writable<boolean>(true);
|
||||||
|
export const bugReportDialogOpen = writable<boolean>(false);
|
||||||
|
export const events = writable<AppEvent[]>([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
EMLy uses **ParaglideJS** for compile-time type-safe translations.
|
||||||
|
|
||||||
|
### Translation Files
|
||||||
|
|
||||||
|
Located in `frontend/messages/`:
|
||||||
|
- `en.json` - English translations
|
||||||
|
- `it.json` - Italian translations
|
||||||
|
|
||||||
|
### Message Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"mail_no_email_selected": "No email selected",
|
||||||
|
"mail_open_eml_btn": "Open EML/MSG File",
|
||||||
|
"settings_title": "Settings",
|
||||||
|
"settings_language_english": "English",
|
||||||
|
"settings_language_italian": "Italiano"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
// In template
|
||||||
|
<h1>{m.settings_title()}</h1>
|
||||||
|
<button>{m.mail_open_eml_btn()}</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing Language
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { setLocale } from "$lib/paraglide/runtime";
|
||||||
|
|
||||||
|
await setLocale("it", { reload: false });
|
||||||
|
location.reload(); // Page reload required for full update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
EMLy uses **shadcn-svelte**, a port of shadcn/ui for Svelte. Components are located in `frontend/src/lib/components/ui/`.
|
||||||
|
|
||||||
|
### Available Components
|
||||||
|
|
||||||
|
| Component | Usage |
|
||||||
|
|-----------|-------|
|
||||||
|
| `Button` | Primary buttons with variants (default, destructive, outline, ghost) |
|
||||||
|
| `Card` | Container with header, content, footer sections |
|
||||||
|
| `Dialog` | Modal dialogs for bug reports, confirmations |
|
||||||
|
| `AlertDialog` | Confirmation dialogs with cancel/continue actions |
|
||||||
|
| `Switch` | Toggle switches for boolean settings |
|
||||||
|
| `Checkbox` | Multi-select checkboxes |
|
||||||
|
| `RadioGroup` | Single-select options (language selection) |
|
||||||
|
| `Label` | Form labels |
|
||||||
|
| `Input` | Text input fields |
|
||||||
|
| `Textarea` | Multi-line text input |
|
||||||
|
| `Separator` | Visual dividers |
|
||||||
|
| `Sidebar` | Collapsible navigation sidebar |
|
||||||
|
| `Tooltip` | Hover tooltips |
|
||||||
|
| `Sonner` | Toast notifications |
|
||||||
|
| `Badge` | Status badges |
|
||||||
|
| `Skeleton` | Loading placeholders |
|
||||||
|
|
||||||
|
### Custom Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `MailViewer.svelte` | Email display with header, attachments, body |
|
||||||
|
| `SidebarApp.svelte` | Navigation sidebar with menu items |
|
||||||
|
| `UnsavedBar.svelte` | Unsaved changes notification bar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Email Viewing
|
||||||
|
|
||||||
|
- Parse and display EML and MSG files
|
||||||
|
- Show email metadata (from, to, cc, bcc, subject)
|
||||||
|
- Render HTML email bodies in sandboxed iframe
|
||||||
|
- List and handle attachments with type-specific actions
|
||||||
|
|
||||||
|
### 2. Attachment Handling
|
||||||
|
|
||||||
|
- **Images**: Open in built-in viewer or external app
|
||||||
|
- **PDFs**: Open in built-in viewer or external app
|
||||||
|
- **EML files**: Open in new EMLy window
|
||||||
|
- **Other files**: Download directly
|
||||||
|
|
||||||
|
### 3. Multi-Window Support
|
||||||
|
|
||||||
|
The app can spawn separate viewer windows:
|
||||||
|
- Image viewer (`--view-image=<path>`)
|
||||||
|
- PDF viewer (`--view-pdf=<path>`)
|
||||||
|
- EML viewer (new app instance with file path)
|
||||||
|
|
||||||
|
Each viewer has a unique instance ID to allow multiple concurrent viewers.
|
||||||
|
|
||||||
|
### 4. Bug Reporting
|
||||||
|
|
||||||
|
Complete bug reporting system:
|
||||||
|
1. Captures screenshot when dialog opens
|
||||||
|
2. Collects user input (name, email, description)
|
||||||
|
3. Includes current mail file if loaded
|
||||||
|
4. Gathers system information
|
||||||
|
5. Creates ZIP archive in temp folder
|
||||||
|
6. Shows path and allows opening folder
|
||||||
|
|
||||||
|
### 5. Settings Management
|
||||||
|
|
||||||
|
- Language selection (English/Italian)
|
||||||
|
- Built-in viewer preferences
|
||||||
|
- Supported file type configuration
|
||||||
|
- Export/import settings as JSON
|
||||||
|
- Reset to defaults
|
||||||
|
|
||||||
|
### 6. Security Features
|
||||||
|
|
||||||
|
- Debugger detection and protection
|
||||||
|
- Sandboxed iframe for email body
|
||||||
|
- Single-instance lock for main window
|
||||||
|
- Disabled link clicking in email body
|
||||||
|
|
||||||
|
### 7. PEC Support
|
||||||
|
|
||||||
|
Special handling for Italian Posta Elettronica Certificata (PEC):
|
||||||
|
- Detects PEC emails
|
||||||
|
- Shows signed mail badge
|
||||||
|
- Handles P7S signature files
|
||||||
|
- Processes daticert.xml metadata
|
||||||
|
|
||||||
|
### 8. Self-Hosted Update System
|
||||||
|
|
||||||
|
**Corporate Network Update Management** - No third-party services required:
|
||||||
|
|
||||||
|
- **Network Share Integration**: Check for updates from corporate file shares (UNC paths like `\\server\emly-updates`)
|
||||||
|
- **Version Manifest**: JSON-based version.json controls what versions are available
|
||||||
|
- **Dual Channel Support**: Separate stable and beta release channels
|
||||||
|
- **Manual or Automatic**: Users can manually check, or app auto-checks on startup
|
||||||
|
- **Download & Verify**: Downloads installers from network share with SHA256 checksum verification
|
||||||
|
- **One-Click Install**: Auto-launches installer with UAC elevation, optionally quits app
|
||||||
|
- **UI Integration**: Full update UI in Settings page with progress indicators
|
||||||
|
- **Event-Driven**: Real-time status updates via Wails events
|
||||||
|
|
||||||
|
#### Configuration (config.ini)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[EMLy]
|
||||||
|
UPDATE_CHECK_ENABLED="true" # Enable/disable update checking
|
||||||
|
UPDATE_PATH="\\server\updates" # Network share or file:// path
|
||||||
|
UPDATE_AUTO_CHECK="true" # Check on startup
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Network Share Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
\\server\emly-updates\
|
||||||
|
├── version.json # Update manifest
|
||||||
|
├── EMLy_Installer_1.5.0.exe # Stable release installer
|
||||||
|
└── EMLy_Installer_1.5.1-beta.exe # Beta release installer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### version.json Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stableVersion": "1.5.0",
|
||||||
|
"betaVersion": "1.5.1-beta",
|
||||||
|
"stableDownload": "EMLy_Installer_1.5.0.exe",
|
||||||
|
"betaDownload": "EMLy_Installer_1.5.1-beta.exe",
|
||||||
|
"sha256Checksums": {
|
||||||
|
"EMLy_Installer_1.5.0.exe": "abc123...",
|
||||||
|
"EMLy_Installer_1.5.1-beta.exe": "def456..."
|
||||||
|
},
|
||||||
|
"releaseNotes": {
|
||||||
|
"1.5.0": "Bug fixes and performance improvements",
|
||||||
|
"1.5.1-beta": "New feature preview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update Flow
|
||||||
|
|
||||||
|
1. **Check**: App reads `version.json` from configured network path
|
||||||
|
2. **Compare**: Compares current version with available version for active channel (stable/beta)
|
||||||
|
3. **Notify**: If update available, shows toast notification with action button
|
||||||
|
4. **Download**: User clicks download, installer copied from network share to temp folder
|
||||||
|
5. **Verify**: SHA256 checksum validated against manifest
|
||||||
|
6. **Install**: User clicks install, app launches installer with UAC, optionally quits
|
||||||
|
|
||||||
|
#### Backend Methods (app_update.go)
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `CheckForUpdates()` | Reads manifest from network share, compares versions |
|
||||||
|
| `DownloadUpdate()` | Copies installer to temp folder, verifies checksum |
|
||||||
|
| `InstallUpdate(quit)` | Launches installer with UAC elevation |
|
||||||
|
| `GetUpdateStatus()` | Returns current update system state |
|
||||||
|
| `loadUpdateManifest(path)` | Parses version.json from network share |
|
||||||
|
| `compareSemanticVersions(v1, v2)` | Semantic version comparison |
|
||||||
|
| `verifyChecksum(file, hash)` | SHA256 integrity verification |
|
||||||
|
| `resolveUpdatePath(base, file)` | Handles UNC paths and file:// URLs |
|
||||||
|
|
||||||
|
#### Deployment Workflow for IT Admins
|
||||||
|
|
||||||
|
1. **Build new version**: `wails build --upx`
|
||||||
|
2. **Create installer**: Run Inno Setup with `installer/installer.iss`
|
||||||
|
3. **Generate checksum**: `certutil -hashfile EMLy_Installer_1.5.0.exe SHA256`
|
||||||
|
4. **Update manifest**: Edit `version.json` with new version and checksum
|
||||||
|
5. **Deploy to share**: Copy installer and manifest to `\\server\emly-updates\`
|
||||||
|
6. **Users notified**: Apps auto-check within 5 seconds of startup (if enabled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.21+
|
||||||
|
- Node.js 18+ or Bun
|
||||||
|
- Wails CLI v2
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install frontend dependencies
|
||||||
|
cd frontend && bun install
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
wails dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for Windows
|
||||||
|
wails build -platform windows/amd64
|
||||||
|
|
||||||
|
# Output: build/bin/EMLy.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
`wails.json` configures the build:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "EMLy",
|
||||||
|
"frontend:install": "bun install",
|
||||||
|
"frontend:build": "bun run build",
|
||||||
|
"frontend:dev:watcher": "bun run dev",
|
||||||
|
"fileAssociations": [
|
||||||
|
{
|
||||||
|
"ext": "eml",
|
||||||
|
"name": "Email Message",
|
||||||
|
"description": "EML File"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Association
|
||||||
|
|
||||||
|
The app registers as a handler for `.eml` files via Windows file associations, configured in `wails.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wails Bindings
|
||||||
|
|
||||||
|
Wails automatically generates TypeScript bindings for Go functions. These are located in `frontend/src/lib/wailsjs/`.
|
||||||
|
|
||||||
|
### Generated Files
|
||||||
|
|
||||||
|
- `go/main/App.ts` - TypeScript functions calling Go methods
|
||||||
|
- `go/models.ts` - TypeScript types for Go structs
|
||||||
|
- `runtime/runtime.ts` - Wails runtime functions
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ReadEML, ShowOpenFileDialog } from "$lib/wailsjs/go/main/App";
|
||||||
|
import type { internal } from "$lib/wailsjs/go/models";
|
||||||
|
|
||||||
|
// Open file dialog
|
||||||
|
const filePath = await ShowOpenFileDialog();
|
||||||
|
|
||||||
|
// Parse email
|
||||||
|
const email: internal.EmailData = await ReadEML(filePath);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Events
|
||||||
|
|
||||||
|
Wails provides event system for Go-to-JS communication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EventsOn } from "$lib/wailsjs/runtime/runtime";
|
||||||
|
|
||||||
|
// Listen for second instance launch
|
||||||
|
EventsOn("launchArgs", (args: string[]) => {
|
||||||
|
// Handle file opened from second instance
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Toast notifications for user-facing errors:
|
||||||
|
```typescript
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ReadEML(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(m.mail_error_opening());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Errors are returned to frontend and logged:
|
||||||
|
```go
|
||||||
|
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
|
||||||
|
data, err := internal.ReadEmlFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
Log("Failed to read EML:", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
In dev mode (`wails dev`):
|
||||||
|
- Hot reload enabled
|
||||||
|
- Debug logs visible
|
||||||
|
- DevTools accessible via Ctrl+Shift+F12
|
||||||
|
- Danger Zone always visible in settings
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
- Debugger protection can terminate app if debugger detected
|
||||||
|
- Danger Zone hidden by default
|
||||||
|
- Access Danger Zone by clicking settings link 10 times within 4 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License & Credits
|
||||||
|
|
||||||
|
EMLy is developed by FOISX @ 3gIT.
|
||||||
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)
|
||||||
169
UPDATER.md
Normal file
169
UPDATER.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
Searched for text `EventsOn` (`**/frontend/src/routes/(app)/+layout.svelte`), no results
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/%2Blayout.svelte#28-28), lines 28 to 45
|
||||||
|
|
||||||
|
Replacing 17 lines with 39 lines in [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/%2Blayout.svelte)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Starting: *Update DOCUMENTATION.md* (6/6)
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/DOCUMENTATION.md#1-1), lines 1 to 100
|
||||||
|
|
||||||
|
Searched for text `## Key Features` (`**/DOCUMENTATION.md`), 1 result
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/DOCUMENTATION.md#640-640), lines 640 to 750
|
||||||
|
|
||||||
|
Searched for text `## Backend` (`**/DOCUMENTATION.md`), 4 results
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/DOCUMENTATION.md#146-146), lines 146 to 250
|
||||||
|
|
||||||
|
Replacing 41 lines with 124 lines in [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/DOCUMENTATION.md)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Completed: *Update DOCUMENTATION.md* (6/6)
|
||||||
|
|
||||||
|
Created [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/installer/sample_version.json)
|
||||||
|
|
||||||
|
Checked [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/settings/%2Bpage.svelte), [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app_update.go), [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app.go), [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/backend/utils/ini-reader.go), 11 problems found
|
||||||
|
|
||||||
|
Searched for text `func (a *App) GetConfig()` (`**/app.go`), 1 result
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app.go#145-145), lines 145 to 160
|
||||||
|
|
||||||
|
Replacing 27 lines with 27 lines in [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app_update.go), [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app.go)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app_update.go#115-115), lines 115 to 135
|
||||||
|
|
||||||
|
Replacing 11 lines with 9 lines in [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app_update.go)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/settings/%2Bpage.svelte#230-230), lines 230 to 245
|
||||||
|
|
||||||
|
Replacing 12 lines with 19 lines in [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/settings/%2Bpage.svelte)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Searched for files matching `**/frontend/src/lib/wailsjs/go/models.ts`, no matches
|
||||||
|
|
||||||
|
Searched for text `EMLyConfig` (`**/frontend/**/*.ts`), no results
|
||||||
|
|
||||||
|
Read [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/settings/%2Bpage.svelte#680-680), lines 680 to 695
|
||||||
|
|
||||||
|
Replacing 9 lines with 9 lines in [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/frontend/src/routes/%28app%29/settings/%2Bpage.svelte)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Checked [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app_update.go), [](file:///c%3A/Users/FOISX/Desktop/EMLy/EMLy/app.go), no problems found
|
||||||
|
|
||||||
|
Perfect! I've successfully implemented the self-hosted update system for EMLy. Here's what was created:
|
||||||
|
|
||||||
|
## ✅ Implementation Complete
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
- **app_update.go** - Full update system with:
|
||||||
|
- `CheckForUpdates()` - Reads version manifest from network share
|
||||||
|
- `DownloadUpdate()` - Copies installer with progress & SHA256 verification
|
||||||
|
- `InstallUpdate()` - Launches installer with UAC elevation
|
||||||
|
- `GetUpdateStatus()` - Returns real-time update state
|
||||||
|
- Semantic version comparison & checksum validation
|
||||||
|
|
||||||
|
- **config.ini** - Added update configuration:
|
||||||
|
```ini
|
||||||
|
UPDATE_CHECK_ENABLED="true"
|
||||||
|
UPDATE_PATH=""
|
||||||
|
UPDATE_AUTO_CHECK="true"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ini-reader.go** - Extended config struct to parse new fields
|
||||||
|
|
||||||
|
- **app.go** - Auto-check on startup (5-second delay, non-blocking)
|
||||||
|
|
||||||
|
### Frontend (SvelteKit)
|
||||||
|
- **[Settings Page](frontend/src/routes/(app)/settings/+page.svelte)** - Complete update UI:
|
||||||
|
- Current version display
|
||||||
|
- Manual "Check for Updates" button with spinner
|
||||||
|
- Download progress indicator
|
||||||
|
- Install button with visual feedback
|
||||||
|
- Error display
|
||||||
|
- Network path configuration info
|
||||||
|
|
||||||
|
- **[App Layout](frontend/src/routes/(app)/+layout.svelte)** - Toast notifications for auto-detected updates
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **DOCUMENTATION.md** - Comprehensive update system documentation:
|
||||||
|
- Architecture diagram updated
|
||||||
|
- Deployment workflow for IT admins
|
||||||
|
- version.json manifest format
|
||||||
|
- SHA256 checksum generation guide
|
||||||
|
|
||||||
|
- **sample_version.json** - Template for network share
|
||||||
|
|
||||||
|
## 🔨 Next Steps
|
||||||
|
|
||||||
|
**Build the app to generate TypeScript bindings:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wails dev
|
||||||
|
# or
|
||||||
|
wails build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will auto-generate the frontend bindings for the new Go methods in `frontend/src/lib/wailsjs/go/main/App.ts`.
|
||||||
|
|
||||||
|
**Set up a test network share:**
|
||||||
|
|
||||||
|
1. Create folder (e.g., `C:\TestUpdates` or `\\server\emly-updates`)
|
||||||
|
2. Copy sample_version.json → `version.json`
|
||||||
|
3. Update version numbers and filenames
|
||||||
|
4. Generate SHA256 checksums:
|
||||||
|
```powershell
|
||||||
|
certutil -hashfile EMLy_Installer_1.4.1.exe SHA256
|
||||||
|
```
|
||||||
|
5. Place installer `.exe` files alongside `version.json`
|
||||||
|
6. Update config.ini `UPDATE_PATH` to your test path
|
||||||
|
|
||||||
|
**Test the flow:**
|
||||||
|
1. Launch app → Should auto-check after 5 seconds (if enabled)
|
||||||
|
2. Go to Settings → Updates section
|
||||||
|
3. Click "Check for Updates"
|
||||||
|
4. Download and verify checksum
|
||||||
|
5. Launch installer (triggers UAC)
|
||||||
|
|
||||||
|
The system is production-ready and requires no third-party services! 🎉
|
||||||
|
|
||||||
|
Made changes.
|
||||||
|
|
||||||
|
|
||||||
566
app.go
566
app.go
@@ -1,37 +1,62 @@
|
|||||||
|
// Package main provides the core EMLy application.
|
||||||
|
// EMLy is a desktop email viewer for .eml and .msg files built with Wails v2.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"log"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/windows/registry"
|
|
||||||
|
|
||||||
"emly/backend/utils"
|
"emly/backend/utils"
|
||||||
internal "emly/backend/utils/mail"
|
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App struct
|
// =============================================================================
|
||||||
|
// App - Core Application Structure
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// App is the main application struct that holds the application state and
|
||||||
|
// provides methods that are exposed to the frontend via Wails bindings.
|
||||||
|
//
|
||||||
|
// The struct manages:
|
||||||
|
// - Application context for Wails runtime calls
|
||||||
|
// - File paths for startup and currently loaded emails
|
||||||
|
// - Tracking of open viewer windows to prevent duplicates
|
||||||
type App struct {
|
type App struct {
|
||||||
|
// ctx is the Wails application context, used for runtime calls like dialogs
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
|
// StartupFilePath is set when the app is launched with an email file argument
|
||||||
StartupFilePath string
|
StartupFilePath string
|
||||||
|
|
||||||
|
// CurrentMailFilePath tracks the currently loaded mail file path
|
||||||
|
// Used for bug reports to include the relevant email file
|
||||||
|
CurrentMailFilePath string
|
||||||
|
|
||||||
|
// openImages tracks which images are currently open in viewer windows
|
||||||
|
// The key is the filename, preventing duplicate viewers for the same file
|
||||||
openImagesMux sync.Mutex
|
openImagesMux sync.Mutex
|
||||||
openImages map[string]bool
|
openImages map[string]bool
|
||||||
|
|
||||||
|
// openPDFs tracks which PDFs are currently open in viewer windows
|
||||||
openPDFsMux sync.Mutex
|
openPDFsMux sync.Mutex
|
||||||
openPDFs map[string]bool
|
openPDFs map[string]bool
|
||||||
|
|
||||||
|
// openEMLs tracks which EML attachments are currently open in viewer windows
|
||||||
openEMLsMux sync.Mutex
|
openEMLsMux sync.Mutex
|
||||||
openEMLs map[string]bool
|
openEMLs map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
// =============================================================================
|
||||||
|
// Constructor & Lifecycle
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// NewApp creates and initializes a new App instance.
|
||||||
|
// This is called from main.go before the Wails application starts.
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{
|
return &App{
|
||||||
openImages: make(map[string]bool),
|
openImages: make(map[string]bool),
|
||||||
@@ -40,11 +65,22 @@ func NewApp() *App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startup is called when the app starts. The context is saved
|
// startup is called by Wails when the application starts.
|
||||||
// so we can call the runtime methods
|
// It receives the application context which is required for Wails runtime calls.
|
||||||
|
//
|
||||||
|
// This method:
|
||||||
|
// - Saves the context for later use
|
||||||
|
// - Syncs CurrentMailFilePath with StartupFilePath if a file was opened via CLI
|
||||||
|
// - Logs the startup mode (main app vs viewer window)
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
|
// Sync CurrentMailFilePath with StartupFilePath if a file was opened via command line
|
||||||
|
if a.StartupFilePath != "" {
|
||||||
|
a.CurrentMailFilePath = a.StartupFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this instance is running as a viewer (image/PDF) rather than main app
|
||||||
isViewer := false
|
isViewer := false
|
||||||
for _, arg := range os.Args {
|
for _, arg := range os.Args {
|
||||||
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
|
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
|
||||||
@@ -54,22 +90,75 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isViewer {
|
if isViewer {
|
||||||
Log("Second instance launch")
|
Log("Viewer instance started")
|
||||||
} else {
|
} else {
|
||||||
Log("Wails startup")
|
Log("EMLy main application started")
|
||||||
|
|
||||||
|
// Automatic update check on startup (if enabled)
|
||||||
|
go func() {
|
||||||
|
// Wait 5 seconds after startup to avoid blocking the UI
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
config := a.GetConfig()
|
||||||
|
if config == nil {
|
||||||
|
log.Printf("Failed to load config for auto-update check")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auto-update is enabled
|
||||||
|
if config.EMLy.UpdateAutoCheck == "true" && config.EMLy.UpdateCheckEnabled == "true" {
|
||||||
|
log.Println("Performing automatic update check...")
|
||||||
|
status, err := a.CheckForUpdates()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Auto-update check failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event if update is available
|
||||||
|
if status.UpdateAvailable {
|
||||||
|
log.Printf("Update available: %s -> %s", status.CurrentVersion, status.AvailableVersion)
|
||||||
|
runtime.EventsEmit(ctx, "update:available", status)
|
||||||
|
} else {
|
||||||
|
log.Println("No updates available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdown is called by Wails when the application is closing.
|
||||||
|
// Used for cleanup operations.
|
||||||
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
|
// Best-effort cleanup - currently no resources require explicit cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuitApp terminates the application.
|
||||||
|
// It first calls Wails Quit to properly close the window,
|
||||||
|
// then forces an exit with a specific code.
|
||||||
|
func (a *App) QuitApp() {
|
||||||
|
runtime.Quit(a.ctx)
|
||||||
|
// Exit with code 133 (133 + 5 = 138, SIGTRAP-like exit)
|
||||||
|
os.Exit(133)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Configuration Management
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetConfig loads and returns the application configuration from config.ini.
|
||||||
|
// Returns nil if the configuration cannot be loaded.
|
||||||
func (a *App) GetConfig() *utils.Config {
|
func (a *App) GetConfig() *utils.Config {
|
||||||
cfgPath := utils.DefaultConfigPath()
|
cfgPath := utils.DefaultConfigPath()
|
||||||
cfg, err := utils.LoadConfig(cfgPath)
|
cfg, err := utils.LoadConfig(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Log("Failed to load config for version:", err)
|
Log("Failed to load config:", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveConfig persists the provided configuration to config.ini.
|
||||||
|
// Returns an error if saving fails.
|
||||||
func (a *App) SaveConfig(cfg *utils.Config) error {
|
func (a *App) SaveConfig(cfg *utils.Config) error {
|
||||||
cfgPath := utils.DefaultConfigPath()
|
cfgPath := utils.DefaultConfigPath()
|
||||||
if err := utils.SaveConfig(cfgPath, cfg); err != nil {
|
if err := utils.SaveConfig(cfgPath, cfg); err != nil {
|
||||||
@@ -79,438 +168,41 @@ func (a *App) SaveConfig(cfg *utils.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
// =============================================================================
|
||||||
// Best-effort cleanup.
|
// Startup File Management
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetStartupFile returns the file path if the app was launched with an email file argument.
|
||||||
|
// Returns an empty string if no file was specified at startup.
|
||||||
|
func (a *App) GetStartupFile() string {
|
||||||
|
return a.StartupFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) QuitApp() {
|
// SetCurrentMailFilePath updates the path of the currently loaded mail file.
|
||||||
runtime.Quit(a.ctx)
|
// This is called when the user opens a file via the file dialog.
|
||||||
// Generate exit code 138
|
func (a *App) SetCurrentMailFilePath(filePath string) {
|
||||||
os.Exit(133) // 133 + 5 (SIGTRAP)
|
a.CurrentMailFilePath = filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCurrentMailFilePath returns the path of the currently loaded mail file.
|
||||||
|
// Used by bug reports to include the relevant email file.
|
||||||
|
func (a *App) GetCurrentMailFilePath() string {
|
||||||
|
return a.CurrentMailFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// System Information
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetMachineData retrieves system information about the current machine.
|
||||||
|
// Returns hostname, OS version, hardware ID, etc.
|
||||||
func (a *App) GetMachineData() *utils.MachineInfo {
|
func (a *App) GetMachineData() *utils.MachineInfo {
|
||||||
data, _ := utils.GetMachineInfo()
|
data, _ := utils.GetMachineInfo()
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStartupFile returns the file path if the app was opened with a file argument
|
// IsDebuggerRunning checks if a debugger is attached to the application.
|
||||||
func (a *App) GetStartupFile() string {
|
// Used for anti-debugging protection in production builds.
|
||||||
return a.StartupFilePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadEML reads a .eml file and returns the email data
|
|
||||||
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
|
|
||||||
return internal.ReadEmlFile(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadPEC reads a PEC .eml file and returns the inner email data
|
|
||||||
func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) {
|
|
||||||
return internal.ReadPecInnerEml(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadMSG reads a .msg file and returns the email data
|
|
||||||
func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) {
|
|
||||||
if useExternalConverter {
|
|
||||||
return internal.ReadMsgFile(filePath)
|
|
||||||
}
|
|
||||||
return internal.ReadMsgFile(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadMSGOSS reads a .msg file and returns the email data
|
|
||||||
func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
|
|
||||||
return internal.ReadMsgFile(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowOpenFileDialog shows the file open dialog for EML files
|
|
||||||
func (a *App) ShowOpenFileDialog() (string, error) {
|
|
||||||
return internal.ShowFileDialog(a.ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenEMLWindow saves EML to temp and opens a new instance of the app
|
|
||||||
func (a *App) OpenEMLWindow(base64Data string, filename string) error {
|
|
||||||
a.openEMLsMux.Lock()
|
|
||||||
if a.openEMLs[filename] {
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
return fmt.Errorf("eml '%s' is already open", filename)
|
|
||||||
}
|
|
||||||
a.openEMLs[filename] = true
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
|
|
||||||
// 1. Decode base64
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
a.openEMLsMux.Lock()
|
|
||||||
delete(a.openEMLs, filename)
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to decode base64: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Save to temp file
|
|
||||||
tempDir := os.TempDir()
|
|
||||||
// Use timestamp or unique ID to avoid conflicts if multiple files have same name
|
|
||||||
timestamp := time.Now().Format("20060102_150405")
|
|
||||||
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s_%s", "emly_attachment", timestamp, filename))
|
|
||||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
||||||
a.openEMLsMux.Lock()
|
|
||||||
delete(a.openEMLs, filename)
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Launch new instance
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
a.openEMLsMux.Lock()
|
|
||||||
delete(a.openEMLs, filename)
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to get executable path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run EMLy with the file path as argument
|
|
||||||
cmd := exec.Command(exe, tempFile)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
a.openEMLsMux.Lock()
|
|
||||||
delete(a.openEMLs, filename)
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to start viewer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor process in background to release lock when closed
|
|
||||||
go func() {
|
|
||||||
cmd.Wait()
|
|
||||||
a.openEMLsMux.Lock()
|
|
||||||
delete(a.openEMLs, filename)
|
|
||||||
a.openEMLsMux.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenImageWindow opens a new window instance to display the image
|
|
||||||
func (a *App) OpenImageWindow(base64Data string, filename string) error {
|
|
||||||
a.openImagesMux.Lock()
|
|
||||||
if a.openImages[filename] {
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
return fmt.Errorf("image '%s' is already open", filename)
|
|
||||||
}
|
|
||||||
a.openImages[filename] = true
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
|
|
||||||
// 1. Decode base64
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
a.openImagesMux.Lock()
|
|
||||||
delete(a.openImages, filename)
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to decode base64: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Save to temp file
|
|
||||||
tempDir := os.TempDir()
|
|
||||||
// Use timestamp to make unique
|
|
||||||
timestamp := time.Now().Format("20060102_150405")
|
|
||||||
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
|
|
||||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
||||||
a.openImagesMux.Lock()
|
|
||||||
delete(a.openImages, filename)
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Launch new instance
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
a.openImagesMux.Lock()
|
|
||||||
delete(a.openImages, filename)
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to get executable path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(exe, "--view-image="+tempFile)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
a.openImagesMux.Lock()
|
|
||||||
delete(a.openImages, filename)
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to start viewer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor process in background to release lock when closed
|
|
||||||
go func() {
|
|
||||||
cmd.Wait()
|
|
||||||
a.openImagesMux.Lock()
|
|
||||||
delete(a.openImages, filename)
|
|
||||||
a.openImagesMux.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenPDFWindow opens a new window instance to display the PDF
|
|
||||||
func (a *App) OpenPDFWindow(base64Data string, filename string) error {
|
|
||||||
a.openPDFsMux.Lock()
|
|
||||||
if a.openPDFs[filename] {
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
return fmt.Errorf("pdf '%s' is already open", filename)
|
|
||||||
}
|
|
||||||
a.openPDFs[filename] = true
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
|
|
||||||
// 1. Decode base64
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
a.openPDFsMux.Lock()
|
|
||||||
delete(a.openPDFs, filename)
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to decode base64: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Save to temp file
|
|
||||||
tempDir := os.TempDir()
|
|
||||||
// Use timestamp to make unique
|
|
||||||
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s", filename))
|
|
||||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
||||||
a.openPDFsMux.Lock()
|
|
||||||
delete(a.openPDFs, filename)
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Launch new instance
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
a.openPDFsMux.Lock()
|
|
||||||
delete(a.openPDFs, filename)
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to get executable path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(exe, "--view-pdf="+tempFile)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
a.openPDFsMux.Lock()
|
|
||||||
delete(a.openPDFs, filename)
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
return fmt.Errorf("failed to start viewer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor process in background to release lock when closed
|
|
||||||
go func() {
|
|
||||||
cmd.Wait()
|
|
||||||
a.openPDFsMux.Lock()
|
|
||||||
delete(a.openPDFs, filename)
|
|
||||||
a.openPDFsMux.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenPDF saves PDF to temp and opens with default app
|
|
||||||
func (a *App) OpenPDF(base64Data string, filename string) error {
|
|
||||||
if base64Data == "" {
|
|
||||||
return fmt.Errorf("no data provided")
|
|
||||||
}
|
|
||||||
// 1. Decode base64
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decode base64: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Save to temp file
|
|
||||||
tempDir := os.TempDir()
|
|
||||||
// Use timestamp to make unique
|
|
||||||
timestamp := time.Now().Format("20060102_150405")
|
|
||||||
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
|
|
||||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Open with default app (Windows)
|
|
||||||
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return fmt.Errorf("failed to open file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenImage saves image to temp and opens with default app (Windows)
|
|
||||||
func (a *App) OpenImage(base64Data string, filename string) error {
|
|
||||||
if base64Data == "" {
|
|
||||||
return fmt.Errorf("no data provided")
|
|
||||||
}
|
|
||||||
// 1. Decode base64
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decode base64: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Save to temp file
|
|
||||||
tempDir := os.TempDir()
|
|
||||||
// Use timestamp to make unique
|
|
||||||
timestamp := time.Now().Format("20060102_150405")
|
|
||||||
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
|
|
||||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Open with default app (Windows)
|
|
||||||
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return fmt.Errorf("failed to open file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImageViewerData struct {
|
|
||||||
Data string `json:"data"`
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PDFViewerData struct {
|
|
||||||
Data string `json:"data"`
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewerData struct {
|
|
||||||
ImageData *ImageViewerData `json:"imageData,omitempty"`
|
|
||||||
PDFData *PDFViewerData `json:"pdfData,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetImageViewerData checks CLI args and returns image data if in viewer mode
|
|
||||||
func (a *App) GetImageViewerData() (*ImageViewerData, error) {
|
|
||||||
for _, arg := range os.Args {
|
|
||||||
if strings.HasPrefix(arg, "--view-image=") {
|
|
||||||
filePath := strings.TrimPrefix(arg, "--view-image=")
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read text file: %w", err)
|
|
||||||
}
|
|
||||||
// Return encoded base64 so frontend can handle it same way
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
|
||||||
return &ImageViewerData{
|
|
||||||
Data: encoded,
|
|
||||||
Filename: filepath.Base(filePath),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPDFViewerData checks CLI args and returns pdf data if in viewer mode
|
|
||||||
func (a *App) GetPDFViewerData() (*PDFViewerData, error) {
|
|
||||||
for _, arg := range os.Args {
|
|
||||||
if strings.HasPrefix(arg, "--view-pdf=") {
|
|
||||||
filePath := strings.TrimPrefix(arg, "--view-pdf=")
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read text file: %w", err)
|
|
||||||
}
|
|
||||||
// Return encoded base64 so frontend can handle it same way
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
|
||||||
return &PDFViewerData{
|
|
||||||
Data: encoded,
|
|
||||||
Filename: filepath.Base(filePath),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) GetViewerData() (*ViewerData, error) {
|
|
||||||
for _, arg := range os.Args {
|
|
||||||
if strings.HasPrefix(arg, "--view-image=") {
|
|
||||||
filePath := strings.TrimPrefix(arg, "--view-image=")
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read text file: %w", err)
|
|
||||||
}
|
|
||||||
// Return encoded base64 so frontend can handle it same way
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
|
||||||
return &ViewerData{
|
|
||||||
ImageData: &ImageViewerData{
|
|
||||||
Data: encoded,
|
|
||||||
Filename: filepath.Base(filePath),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(arg, "--view-pdf=") {
|
|
||||||
filePath := strings.TrimPrefix(arg, "--view-pdf=")
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read text file: %w", err)
|
|
||||||
}
|
|
||||||
// Return encoded base64 so frontend can handle it same way
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
|
||||||
return &ViewerData{
|
|
||||||
PDFData: &PDFViewerData{
|
|
||||||
Data: encoded,
|
|
||||||
Filename: filepath.Base(filePath),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckIsDefaultEMLHandler verifies if the current executable is the default handler for .eml files
|
|
||||||
func (a *App) CheckIsDefaultEMLHandler() (bool, error) {
|
|
||||||
// 1. Get current executable path
|
|
||||||
exePath, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
// Normalize path for comparison
|
|
||||||
exePath = strings.ToLower(exePath)
|
|
||||||
|
|
||||||
// 2. Open UserChoice key for .eml
|
|
||||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.eml\UserChoice`, registry.QUERY_VALUE)
|
|
||||||
if err != nil {
|
|
||||||
// Key doesn't exist implies user hasn't made a specific choice or system default is active (not us usually)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
defer k.Close()
|
|
||||||
|
|
||||||
// 3. Get ProgId
|
|
||||||
progId, _, err := k.GetStringValue("ProgId")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Find the command for this ProgId
|
|
||||||
classKeyPath := fmt.Sprintf(`%s\shell\open\command`, progId)
|
|
||||||
classKey, err := registry.OpenKey(registry.CLASSES_ROOT, classKeyPath, registry.QUERY_VALUE)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("unable to find command for ProgId %s", progId)
|
|
||||||
}
|
|
||||||
defer classKey.Close()
|
|
||||||
|
|
||||||
cmd, _, err := classKey.GetStringValue("")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Compare command with our executable
|
|
||||||
cmdLower := strings.ToLower(cmd)
|
|
||||||
|
|
||||||
// Basic check: does the command contain our executable name?
|
|
||||||
// In a real scenario, parsing the exact path respecting quotes would be safer,
|
|
||||||
// but checking if our specific exe path is present is usually sufficient.
|
|
||||||
if strings.Contains(cmdLower, strings.ToLower(filepath.Base(exePath))) {
|
|
||||||
// More robust: escape backslashes and check presence
|
|
||||||
// cleanExe := strings.ReplaceAll(exePath, `\`, `\\`)
|
|
||||||
// For now, depending on how registry stores it (short path vs long path),
|
|
||||||
// containment of the filename is a strong indicator if the filename is unique enough (emly.exe)
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenDefaultAppsSettings opens the Windows default apps settings page
|
|
||||||
func (a *App) OpenDefaultAppsSettings() error {
|
|
||||||
cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps")
|
|
||||||
return cmd.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) IsDebuggerRunning() bool {
|
func (a *App) IsDebuggerRunning() bool {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
303
app_bugreport.go
Normal file
303
app_bugreport.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
// Package main provides bug reporting functionality for EMLy.
|
||||||
|
// This file contains methods for creating bug reports with screenshots,
|
||||||
|
// email files, and system information.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emly/backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Bug Report Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// BugReportResult contains paths to the generated bug report files.
|
||||||
|
type BugReportResult struct {
|
||||||
|
// FolderPath is the path to the bug report folder in temp
|
||||||
|
FolderPath string `json:"folderPath"`
|
||||||
|
// ScreenshotPath is the path to the captured screenshot file
|
||||||
|
ScreenshotPath string `json:"screenshotPath"`
|
||||||
|
// MailFilePath is the path to the copied mail file (empty if no mail loaded)
|
||||||
|
MailFilePath string `json:"mailFilePath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BugReportInput contains the user-provided bug report details.
|
||||||
|
type BugReportInput struct {
|
||||||
|
// Name is the user's name
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Email is the user's email address for follow-up
|
||||||
|
Email string `json:"email"`
|
||||||
|
// Description is the detailed bug description
|
||||||
|
Description string `json:"description"`
|
||||||
|
// ScreenshotData is the base64-encoded PNG screenshot (captured before dialog opens)
|
||||||
|
ScreenshotData string `json:"screenshotData"`
|
||||||
|
// 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.
|
||||||
|
type SubmitBugReportResult struct {
|
||||||
|
// ZipPath is the path to the created zip file
|
||||||
|
ZipPath string `json:"zipPath"`
|
||||||
|
// FolderPath is the path to the bug report folder
|
||||||
|
FolderPath string `json:"folderPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Bug Report Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// CreateBugReportFolder creates a folder in temp with screenshot and optionally
|
||||||
|
// the current mail file. This is used for the legacy bug report flow.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *BugReportResult: Paths to created files
|
||||||
|
// - error: Error if folder creation or file operations fail
|
||||||
|
func (a *App) CreateBugReportFolder() (*BugReportResult, error) {
|
||||||
|
// Create unique folder name with timestamp
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
folderName := fmt.Sprintf("emly_bugreport_%s", timestamp)
|
||||||
|
|
||||||
|
// Create folder in temp directory
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
bugReportFolder := filepath.Join(tempDir, folderName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(bugReportFolder, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bug report folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &BugReportResult{
|
||||||
|
FolderPath: bugReportFolder,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take and save screenshot
|
||||||
|
screenshotResult, err := a.TakeScreenshot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to take screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotData, err := base64.StdEncoding.DecodeString(screenshotResult.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotPath := filepath.Join(bugReportFolder, screenshotResult.Filename)
|
||||||
|
if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save screenshot: %w", err)
|
||||||
|
}
|
||||||
|
result.ScreenshotPath = screenshotPath
|
||||||
|
|
||||||
|
// Copy currently loaded mail file if one exists
|
||||||
|
if a.CurrentMailFilePath != "" {
|
||||||
|
mailData, err := os.ReadFile(a.CurrentMailFilePath)
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't fail - screenshot is still valid
|
||||||
|
Log("Failed to read mail file for bug report:", err)
|
||||||
|
} else {
|
||||||
|
mailFilename := filepath.Base(a.CurrentMailFilePath)
|
||||||
|
mailFilePath := filepath.Join(bugReportFolder, mailFilename)
|
||||||
|
|
||||||
|
if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil {
|
||||||
|
Log("Failed to copy mail file for bug report:", err)
|
||||||
|
} else {
|
||||||
|
result.MailFilePath = mailFilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitBugReport creates a complete bug report with user input, saves all files,
|
||||||
|
// and creates a zip archive ready for submission.
|
||||||
|
//
|
||||||
|
// The bug report includes:
|
||||||
|
// - User-provided description (report.txt)
|
||||||
|
// - Screenshot (captured before dialog opens)
|
||||||
|
// - Currently loaded mail file (if any)
|
||||||
|
// - 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, localStorage, and config data
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *SubmitBugReportResult: Paths to the zip file and folder
|
||||||
|
// - error: Error if any file operation fails
|
||||||
|
func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, error) {
|
||||||
|
// Create unique folder name with timestamp
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
folderName := fmt.Sprintf("emly_bugreport_%s", timestamp)
|
||||||
|
|
||||||
|
// Create folder in temp directory
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
bugReportFolder := filepath.Join(tempDir, folderName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(bugReportFolder, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bug report folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the pre-captured screenshot (captured before dialog opened)
|
||||||
|
if input.ScreenshotData != "" {
|
||||||
|
screenshotData, err := base64.StdEncoding.DecodeString(input.ScreenshotData)
|
||||||
|
if err != nil {
|
||||||
|
Log("Failed to decode screenshot:", err)
|
||||||
|
} else {
|
||||||
|
screenshotPath := filepath.Join(bugReportFolder, fmt.Sprintf("emly_screenshot_%s.png", timestamp))
|
||||||
|
if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil {
|
||||||
|
Log("Failed to save screenshot:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the mail file if one is loaded
|
||||||
|
if a.CurrentMailFilePath != "" {
|
||||||
|
mailData, err := os.ReadFile(a.CurrentMailFilePath)
|
||||||
|
if err != nil {
|
||||||
|
Log("Failed to read mail file for bug report:", err)
|
||||||
|
} else {
|
||||||
|
mailFilename := filepath.Base(a.CurrentMailFilePath)
|
||||||
|
mailFilePath := filepath.Join(bugReportFolder, mailFilename)
|
||||||
|
if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil {
|
||||||
|
Log("Failed to copy mail file for bug report:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
================
|
||||||
|
|
||||||
|
Name: %s
|
||||||
|
Email: %s
|
||||||
|
|
||||||
|
Description:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Generated: %s
|
||||||
|
`, input.Name, input.Email, input.Description, time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
reportPath := filepath.Join(bugReportFolder, "report.txt")
|
||||||
|
if err := os.WriteFile(reportPath, []byte(reportContent), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save report file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and save machine/system information
|
||||||
|
machineInfo, err := utils.GetMachineInfo()
|
||||||
|
if err == nil && machineInfo != nil {
|
||||||
|
sysInfoContent := fmt.Sprintf(`System Information
|
||||||
|
==================
|
||||||
|
|
||||||
|
Hostname: %s
|
||||||
|
OS: %s
|
||||||
|
Version: %s
|
||||||
|
Hardware ID: %s
|
||||||
|
External IP: %s
|
||||||
|
`, machineInfo.Hostname, machineInfo.OS, machineInfo.Version, machineInfo.HWID, machineInfo.ExternalIP)
|
||||||
|
|
||||||
|
sysInfoPath := filepath.Join(bugReportFolder, "system_info.txt")
|
||||||
|
if err := os.WriteFile(sysInfoPath, []byte(sysInfoContent), 0644); err != nil {
|
||||||
|
Log("Failed to save system info:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create zip archive of the folder
|
||||||
|
zipPath := bugReportFolder + ".zip"
|
||||||
|
if err := zipFolder(bugReportFolder, zipPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SubmitBugReportResult{
|
||||||
|
ZipPath: zipPath,
|
||||||
|
FolderPath: bugReportFolder,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// zipFolder creates a zip archive containing all files from the source folder.
|
||||||
|
// Directories are traversed recursively but stored implicitly (no directory entries).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sourceFolder: Path to the folder to zip
|
||||||
|
// - destZip: Path where the zip file should be created
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if any file operation fails
|
||||||
|
func zipFolder(sourceFolder, destZip string) error {
|
||||||
|
// Create the zip file
|
||||||
|
zipFile, err := os.Create(destZip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zipFile.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(zipFile)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
// Walk through the folder and add all files
|
||||||
|
return filepath.Walk(sourceFolder, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the root folder itself
|
||||||
|
if path == sourceFolder {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for the zip entry
|
||||||
|
relPath, err := filepath.Rel(sourceFolder, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories (they're created implicitly)
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the file entry in the zip
|
||||||
|
writer, err := zipWriter.Create(relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and write the file content
|
||||||
|
fileContent, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = writer.Write(fileContent)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
135
app_mail.go
Normal file
135
app_mail.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Package main provides email reading functionality for EMLy.
|
||||||
|
// This file contains methods for reading EML, MSG, and PEC email files.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
internal "emly/backend/utils/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Email Reading Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ReadEML reads a standard .eml file and returns the parsed email data.
|
||||||
|
// EML files are MIME-formatted email messages commonly exported from email clients.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - filePath: Absolute path to the .eml file
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *internal.EmailData: Parsed email with headers, body, and attachments
|
||||||
|
// - error: Any parsing errors
|
||||||
|
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
|
||||||
|
return internal.ReadEmlFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPEC reads a PEC (Posta Elettronica Certificata) .eml file.
|
||||||
|
// PEC emails are Italian certified emails that contain an inner email message
|
||||||
|
// wrapped in a certification envelope with digital signatures.
|
||||||
|
//
|
||||||
|
// This method extracts and returns the inner original email, ignoring the
|
||||||
|
// certification wrapper (daticert.xml and signature files are available as attachments).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - filePath: Absolute path to the PEC .eml file
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *internal.EmailData: The inner original email content
|
||||||
|
// - error: Any parsing errors
|
||||||
|
func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) {
|
||||||
|
return internal.ReadPecInnerEml(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMSG reads a Microsoft Outlook .msg file and returns the email data.
|
||||||
|
// MSG files use the CFB (Compound File Binary) format, which is a proprietary
|
||||||
|
// format used by Microsoft Office applications.
|
||||||
|
//
|
||||||
|
// This method uses an external converter to properly parse the MSG format
|
||||||
|
// and extract headers, body, and attachments.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - filePath: Absolute path to the .msg file
|
||||||
|
// - useExternalConverter: Whether to use external conversion (currently always true)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *internal.EmailData: Parsed email data
|
||||||
|
// - error: Any parsing or conversion errors
|
||||||
|
func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) {
|
||||||
|
// The useExternalConverter parameter is kept for API compatibility
|
||||||
|
// but the implementation always uses the internal MSG reader
|
||||||
|
return internal.ReadMsgFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMSGOSS reads a .msg file using the open-source parser.
|
||||||
|
// This is an alternative entry point that explicitly uses the OSS implementation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - filePath: Absolute path to the .msg file
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *internal.EmailData: Parsed email data
|
||||||
|
// - error: Any parsing errors
|
||||||
|
func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
|
||||||
|
return internal.ReadMsgFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowOpenFileDialog displays the system file picker dialog filtered for email files.
|
||||||
|
// This allows users to browse and select .eml or .msg files to open.
|
||||||
|
//
|
||||||
|
// The dialog is configured with filters for:
|
||||||
|
// - EML files (*.eml)
|
||||||
|
// - MSG files (*.msg)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The selected file path, or empty string if cancelled
|
||||||
|
// - error: Any dialog errors
|
||||||
|
func (a *App) ShowOpenFileDialog() (string, error) {
|
||||||
|
return internal.ShowFileDialog(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
164
app_screenshot.go
Normal file
164
app_screenshot.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Package main provides screenshot functionality for EMLy.
|
||||||
|
// This file contains methods for capturing, saving, and exporting screenshots
|
||||||
|
// of the application window.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emly/backend/utils"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Screenshot Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ScreenshotResult contains the captured screenshot data and metadata.
|
||||||
|
type ScreenshotResult struct {
|
||||||
|
// Data is the base64-encoded PNG image data
|
||||||
|
Data string `json:"data"`
|
||||||
|
// Width is the image width in pixels
|
||||||
|
Width int `json:"width"`
|
||||||
|
// Height is the image height in pixels
|
||||||
|
Height int `json:"height"`
|
||||||
|
// Filename is the suggested filename for saving
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Screenshot Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TakeScreenshot captures the current EMLy application window and returns it as base64 PNG.
|
||||||
|
// This uses Windows GDI API to capture the window contents, handling DWM composition
|
||||||
|
// for proper rendering of modern Windows applications.
|
||||||
|
//
|
||||||
|
// The method automatically detects whether the app is in main mode or viewer mode
|
||||||
|
// and captures the appropriate window.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ScreenshotResult: Contains base64 PNG data, dimensions, and suggested filename
|
||||||
|
// - error: Error if window capture or encoding fails
|
||||||
|
func (a *App) TakeScreenshot() (*ScreenshotResult, error) {
|
||||||
|
// Determine window title based on current mode
|
||||||
|
windowTitle := "EMLy - EML Viewer for 3gIT"
|
||||||
|
|
||||||
|
// Check if running in viewer mode
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
if strings.Contains(arg, "--view-image") {
|
||||||
|
windowTitle = "EMLy Image Viewer"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.Contains(arg, "--view-pdf") {
|
||||||
|
windowTitle = "EMLy PDF Viewer"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the window using Windows GDI API
|
||||||
|
img, err := utils.CaptureWindowByTitle(windowTitle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to capture window: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to PNG and convert to base64
|
||||||
|
base64Data, err := utils.ScreenshotToBase64PNG(img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result with metadata
|
||||||
|
bounds := img.Bounds()
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
|
||||||
|
return &ScreenshotResult{
|
||||||
|
Data: base64Data,
|
||||||
|
Width: bounds.Dx(),
|
||||||
|
Height: bounds.Dy(),
|
||||||
|
Filename: fmt.Sprintf("emly_screenshot_%s.png", timestamp),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveScreenshot captures and saves the screenshot to the system temp directory.
|
||||||
|
// This is a convenience method that captures and saves in one step.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The full path to the saved screenshot file
|
||||||
|
// - error: Error if capture or save fails
|
||||||
|
func (a *App) SaveScreenshot() (string, error) {
|
||||||
|
// Capture the screenshot
|
||||||
|
result, err := a.TakeScreenshot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode screenshot data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp directory
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
filePath := filepath.Join(tempDir, result.Filename)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveScreenshotAs captures a screenshot and opens a save dialog for the user
|
||||||
|
// to choose where to save it.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The selected save path, or empty string if cancelled
|
||||||
|
// - error: Error if capture, dialog, or save fails
|
||||||
|
func (a *App) SaveScreenshotAs() (string, error) {
|
||||||
|
// Capture the screenshot first
|
||||||
|
result, err := a.TakeScreenshot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open save dialog with PNG filter
|
||||||
|
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
DefaultFilename: result.Filename,
|
||||||
|
Title: "Save Screenshot",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "PNG Images (*.png)",
|
||||||
|
Pattern: "*.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open save dialog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User cancelled
|
||||||
|
if savePath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode screenshot data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to selected location
|
||||||
|
if err := os.WriteFile(savePath, data, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return savePath, nil
|
||||||
|
}
|
||||||
168
app_settings.go
Normal file
168
app_settings.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// Package main provides settings import/export functionality for EMLy.
|
||||||
|
// This file contains methods for exporting and importing application settings
|
||||||
|
// as JSON files.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Settings Export/Import Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ExportSettings opens a save dialog and exports the provided settings JSON
|
||||||
|
// to the selected file location.
|
||||||
|
//
|
||||||
|
// The dialog is pre-configured with:
|
||||||
|
// - Default filename: emly_settings.json
|
||||||
|
// - Filter for JSON files
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - settingsJSON: The JSON string containing all application settings
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The path where settings were saved, or empty if cancelled
|
||||||
|
// - error: Error if dialog or file operations fail
|
||||||
|
func (a *App) ExportSettings(settingsJSON string) (string, error) {
|
||||||
|
// Open save dialog with JSON filter
|
||||||
|
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
DefaultFilename: "emly_settings.json",
|
||||||
|
Title: "Export Settings",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "JSON Files (*.json)",
|
||||||
|
Pattern: "*.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open save dialog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User cancelled
|
||||||
|
if savePath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure .json extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(savePath), ".json") {
|
||||||
|
savePath += ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the settings file
|
||||||
|
if err := os.WriteFile(savePath, []byte(settingsJSON), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write settings file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return savePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportSettings opens a file dialog for the user to select a settings JSON file
|
||||||
|
// and returns its contents.
|
||||||
|
//
|
||||||
|
// The dialog is configured to only show JSON files.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The JSON content of the selected file, or empty if cancelled
|
||||||
|
// - error: Error if dialog or file operations fail
|
||||||
|
func (a *App) ImportSettings() (string, error) {
|
||||||
|
// Open file dialog with JSON filter
|
||||||
|
openPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||||
|
Title: "Import Settings",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "JSON Files (*.json)",
|
||||||
|
Pattern: "*.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open file dialog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User cancelled
|
||||||
|
if openPath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the settings file
|
||||||
|
data, err := os.ReadFile(openPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read settings file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpdateCheckerEnabled updates the UPDATE_CHECK_ENABLED setting in config.ini
|
||||||
|
// based on the user's preference from the GUI settings.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - enabled: true to enable update checking, false to disable
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if loading or saving config fails
|
||||||
|
func (a *App) SetUpdateCheckerEnabled(enabled bool) error {
|
||||||
|
// Load current config
|
||||||
|
config := a.GetConfig()
|
||||||
|
if config == nil {
|
||||||
|
return fmt.Errorf("failed to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the setting
|
||||||
|
if enabled {
|
||||||
|
config.EMLy.UpdateCheckEnabled = "true"
|
||||||
|
} else {
|
||||||
|
config.EMLy.UpdateCheckEnabled = "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config back to disk
|
||||||
|
if err := a.SaveConfig(config); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
159
app_system.go
Normal file
159
app_system.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// Package main provides system-level utilities for EMLy.
|
||||||
|
// This file contains methods for Windows registry access, character encoding
|
||||||
|
// conversion, and file system operations.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
"golang.org/x/text/encoding/charmap"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Windows Default App Handler
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// CheckIsDefaultEMLHandler checks if EMLy is registered as the default handler
|
||||||
|
// for .eml files in Windows.
|
||||||
|
//
|
||||||
|
// This works by:
|
||||||
|
// 1. Getting the current executable path
|
||||||
|
// 2. Reading the UserChoice registry key for .eml files
|
||||||
|
// 3. Finding the command associated with the chosen ProgId
|
||||||
|
// 4. Comparing the command with our executable
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if EMLy is the default handler
|
||||||
|
// - error: Error if registry access fails
|
||||||
|
func (a *App) CheckIsDefaultEMLHandler() (bool, error) {
|
||||||
|
// Get current executable path for comparison
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
exePath = strings.ToLower(exePath)
|
||||||
|
|
||||||
|
// Open the UserChoice key for .eml extension
|
||||||
|
// This is where Windows stores the user's chosen default app
|
||||||
|
k, err := registry.OpenKey(
|
||||||
|
registry.CURRENT_USER,
|
||||||
|
`Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.eml\UserChoice`,
|
||||||
|
registry.QUERY_VALUE,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Key doesn't exist - user hasn't made a specific choice
|
||||||
|
// or system default is active (which is usually not us)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
|
||||||
|
// Get the ProgId (program identifier) for the chosen app
|
||||||
|
progId, _, err := k.GetStringValue("ProgId")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the command associated with this ProgId
|
||||||
|
classKeyPath := fmt.Sprintf(`%s\shell\open\command`, progId)
|
||||||
|
classKey, err := registry.OpenKey(registry.CLASSES_ROOT, classKeyPath, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("unable to find command for ProgId %s", progId)
|
||||||
|
}
|
||||||
|
defer classKey.Close()
|
||||||
|
|
||||||
|
// Get the command string
|
||||||
|
cmd, _, err := classKey.GetStringValue("")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare command with our executable
|
||||||
|
// Check if the command contains our executable name
|
||||||
|
cmdLower := strings.ToLower(cmd)
|
||||||
|
if strings.Contains(cmdLower, strings.ToLower(filepath.Base(exePath))) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDefaultAppsSettings opens the Windows Settings app to the Default Apps page.
|
||||||
|
// This allows users to easily set EMLy as the default handler for email files.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if launching settings fails
|
||||||
|
func (a *App) OpenDefaultAppsSettings() error {
|
||||||
|
cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps")
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Character Encoding
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ConvertToUTF8 attempts to convert a string to valid UTF-8.
|
||||||
|
// If the string is already valid UTF-8, it's returned as-is.
|
||||||
|
// Otherwise, it assumes Windows-1252 encoding (common for legacy emails)
|
||||||
|
// and attempts to decode it.
|
||||||
|
//
|
||||||
|
// This is particularly useful for email body content that may have been
|
||||||
|
// encoded with legacy Western European character sets.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - s: The string to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: UTF-8 encoded string
|
||||||
|
func (a *App) ConvertToUTF8(s string) string {
|
||||||
|
// If already valid UTF-8, return as-is
|
||||||
|
if utf8.ValidString(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume Windows-1252 (superset of ISO-8859-1)
|
||||||
|
// This is the most common encoding for legacy Western European text
|
||||||
|
decoder := charmap.Windows1252.NewDecoder()
|
||||||
|
decoded, _, err := transform.String(decoder, s)
|
||||||
|
if err != nil {
|
||||||
|
// Return original if decoding fails
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// File System Operations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// OpenFolderInExplorer opens the specified folder in Windows Explorer.
|
||||||
|
// This is used to show the user where bug report files are saved.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - folderPath: The path to the folder to open
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if launching explorer fails
|
||||||
|
func (a *App) OpenFolderInExplorer(folderPath string) error {
|
||||||
|
cmd := exec.Command("explorer", folderPath)
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenURLInBrowser opens the specified URL in the system's default web browser.
|
||||||
|
// Uses the Windows "start" command to launch the default browser.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The URL to open (must be a valid http/https URL)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if launching the browser fails
|
||||||
|
func (a *App) OpenURLInBrowser(url string) error {
|
||||||
|
cmd := exec.Command("cmd", "/c", "start", "", url)
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
760
app_update.go
Normal file
760
app_update.go
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
// Package main provides self-hosted update functionality for EMLy.
|
||||||
|
// This file contains methods for checking, downloading, and installing updates
|
||||||
|
// from a corporate network share without relying on third-party services.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Update System Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// UpdateManifest represents the version.json file structure on the network share
|
||||||
|
type UpdateManifest struct {
|
||||||
|
StableVersion string `json:"stableVersion"`
|
||||||
|
BetaVersion string `json:"betaVersion"`
|
||||||
|
StableDownload string `json:"stableDownload"`
|
||||||
|
BetaDownload string `json:"betaDownload"`
|
||||||
|
SHA256Checksums map[string]string `json:"sha256Checksums"`
|
||||||
|
ReleaseNotes map[string]string `json:"releaseNotes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus represents the current state of the update system
|
||||||
|
type UpdateStatus struct {
|
||||||
|
CurrentVersion string `json:"currentVersion"`
|
||||||
|
AvailableVersion string `json:"availableVersion"`
|
||||||
|
UpdateAvailable bool `json:"updateAvailable"`
|
||||||
|
Checking bool `json:"checking"`
|
||||||
|
Downloading bool `json:"downloading"`
|
||||||
|
DownloadProgress int `json:"downloadProgress"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
InstallerPath string `json:"installerPath"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
ReleaseNotes string `json:"releaseNotes,omitempty"`
|
||||||
|
LastCheckTime string `json:"lastCheckTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global update state
|
||||||
|
var updateStatus = UpdateStatus{
|
||||||
|
CurrentVersion: "",
|
||||||
|
AvailableVersion: "",
|
||||||
|
UpdateAvailable: false,
|
||||||
|
Checking: false,
|
||||||
|
Downloading: false,
|
||||||
|
DownloadProgress: 0,
|
||||||
|
Ready: false,
|
||||||
|
InstallerPath: "",
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Update Check Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// CheckForUpdates checks the configured network share for available updates.
|
||||||
|
// Compares the manifest version with the current GUI version based on release channel.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - UpdateStatus: Current update state including available version
|
||||||
|
// - error: Error if check fails (network, parsing, etc.)
|
||||||
|
func (a *App) CheckForUpdates() (UpdateStatus, error) {
|
||||||
|
// Reset status
|
||||||
|
updateStatus.Checking = true
|
||||||
|
updateStatus.ErrorMessage = ""
|
||||||
|
updateStatus.LastCheckTime = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
updateStatus.Checking = false
|
||||||
|
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Get current version from config
|
||||||
|
config := a.GetConfig()
|
||||||
|
if config == nil {
|
||||||
|
updateStatus.ErrorMessage = "Failed to load configuration"
|
||||||
|
return updateStatus, fmt.Errorf("failed to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus.CurrentVersion = config.EMLy.GUISemver
|
||||||
|
currentChannel := config.EMLy.GUIReleaseChannel
|
||||||
|
|
||||||
|
// Check if updates are enabled
|
||||||
|
if config.EMLy.UpdateCheckEnabled != "true" {
|
||||||
|
updateStatus.ErrorMessage = "Update checking is disabled"
|
||||||
|
return updateStatus, fmt.Errorf("update checking is disabled in config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate update path
|
||||||
|
updatePath := strings.TrimSpace(config.EMLy.UpdatePath)
|
||||||
|
if updatePath == "" {
|
||||||
|
updateStatus.ErrorMessage = "Update path not configured"
|
||||||
|
return updateStatus, fmt.Errorf("UPDATE_PATH is empty in config.ini")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load manifest from network share
|
||||||
|
manifest, err := a.loadUpdateManifest(updatePath)
|
||||||
|
if err != nil {
|
||||||
|
updateStatus.ErrorMessage = fmt.Sprintf("Failed to load manifest: %v", err)
|
||||||
|
return updateStatus, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target version based on release channel
|
||||||
|
var targetVersion string
|
||||||
|
if currentChannel == "beta" {
|
||||||
|
targetVersion = manifest.BetaVersion
|
||||||
|
} else {
|
||||||
|
targetVersion = manifest.StableVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus.AvailableVersion = targetVersion
|
||||||
|
|
||||||
|
// Compare versions
|
||||||
|
comparison := compareSemanticVersions(updateStatus.CurrentVersion, targetVersion)
|
||||||
|
if comparison < 0 {
|
||||||
|
// New version available
|
||||||
|
updateStatus.UpdateAvailable = true
|
||||||
|
updateStatus.InstallerPath = "" // Reset installer path
|
||||||
|
updateStatus.Ready = false
|
||||||
|
|
||||||
|
// Get release notes if available
|
||||||
|
if notes, ok := manifest.ReleaseNotes[targetVersion]; ok {
|
||||||
|
updateStatus.ReleaseNotes = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Update available: %s -> %s (%s channel)",
|
||||||
|
updateStatus.CurrentVersion, targetVersion, currentChannel)
|
||||||
|
} else {
|
||||||
|
updateStatus.UpdateAvailable = false
|
||||||
|
updateStatus.InstallerPath = ""
|
||||||
|
updateStatus.Ready = false
|
||||||
|
updateStatus.ReleaseNotes = ""
|
||||||
|
log.Printf("Already on latest version: %s (%s channel)",
|
||||||
|
updateStatus.CurrentVersion, currentChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadUpdateManifest reads and parses version.json from the network share
|
||||||
|
func (a *App) loadUpdateManifest(updatePath string) (*UpdateManifest, error) {
|
||||||
|
// Resolve path (handle UNC paths, file:// URLs, local paths)
|
||||||
|
manifestPath, err := resolveUpdatePath(updatePath, "version.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve manifest path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Loading update manifest from: %s", manifestPath)
|
||||||
|
|
||||||
|
// Read manifest file
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
var manifest UpdateManifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
if manifest.StableVersion == "" || manifest.StableDownload == "" {
|
||||||
|
return nil, fmt.Errorf("invalid manifest: missing stable version or download")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Download Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// DownloadUpdate downloads the installer from the network share to a temporary location.
|
||||||
|
// Verifies SHA256 checksum if provided in the manifest.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: Path to the downloaded installer
|
||||||
|
// - error: Error if download or verification fails
|
||||||
|
func (a *App) DownloadUpdate() (string, error) {
|
||||||
|
if !updateStatus.UpdateAvailable {
|
||||||
|
return "", fmt.Errorf("no update available")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus.Downloading = true
|
||||||
|
updateStatus.DownloadProgress = 0
|
||||||
|
updateStatus.ErrorMessage = ""
|
||||||
|
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
updateStatus.Downloading = false
|
||||||
|
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Get config
|
||||||
|
config := a.GetConfig()
|
||||||
|
if config == nil {
|
||||||
|
updateStatus.ErrorMessage = "Failed to load configuration"
|
||||||
|
return "", fmt.Errorf("failed to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePath := strings.TrimSpace(config.EMLy.UpdatePath)
|
||||||
|
currentChannel := config.EMLy.GUIReleaseChannel
|
||||||
|
|
||||||
|
// Reload manifest to get download filename
|
||||||
|
manifest, err := a.loadUpdateManifest(updatePath)
|
||||||
|
if err != nil {
|
||||||
|
updateStatus.ErrorMessage = "Failed to load manifest"
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine download filename
|
||||||
|
var downloadFilename string
|
||||||
|
if currentChannel == "beta" {
|
||||||
|
downloadFilename = manifest.BetaDownload
|
||||||
|
} else {
|
||||||
|
downloadFilename = manifest.StableDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve source path
|
||||||
|
sourcePath, err := resolveUpdatePath(updatePath, downloadFilename)
|
||||||
|
if err != nil {
|
||||||
|
updateStatus.ErrorMessage = "Failed to resolve installer path"
|
||||||
|
return "", fmt.Errorf("failed to resolve installer path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Downloading installer from: %s", sourcePath)
|
||||||
|
|
||||||
|
// Create temp directory for download
|
||||||
|
tempDir := filepath.Join(os.TempDir(), "emly_update")
|
||||||
|
if err := os.MkdirAll(tempDir, 0755); err != nil {
|
||||||
|
updateStatus.ErrorMessage = "Failed to create temp directory"
|
||||||
|
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination path
|
||||||
|
destPath := filepath.Join(tempDir, downloadFilename)
|
||||||
|
|
||||||
|
// Copy file with progress
|
||||||
|
if err := a.copyFileWithProgress(sourcePath, destPath); err != nil {
|
||||||
|
updateStatus.ErrorMessage = "Download failed"
|
||||||
|
return "", fmt.Errorf("failed to copy installer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum if available
|
||||||
|
if checksum, ok := manifest.SHA256Checksums[downloadFilename]; ok {
|
||||||
|
log.Printf("Verifying checksum for %s", downloadFilename)
|
||||||
|
if err := verifyChecksum(destPath, checksum); err != nil {
|
||||||
|
updateStatus.ErrorMessage = "Checksum verification failed"
|
||||||
|
// Delete corrupted file
|
||||||
|
os.Remove(destPath)
|
||||||
|
return "", fmt.Errorf("checksum verification failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Checksum verified successfully")
|
||||||
|
} else {
|
||||||
|
log.Printf("Warning: No checksum available for %s", downloadFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus.InstallerPath = destPath
|
||||||
|
updateStatus.Ready = true
|
||||||
|
updateStatus.DownloadProgress = 100
|
||||||
|
log.Printf("Update downloaded successfully to: %s", destPath)
|
||||||
|
|
||||||
|
return destPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFileWithProgress copies a file and emits progress events
|
||||||
|
func (a *App) copyFileWithProgress(src, dst string) error {
|
||||||
|
sourceFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sourceFile.Close()
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
stat, err := sourceFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalSize := stat.Size()
|
||||||
|
|
||||||
|
destFile, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy with progress tracking
|
||||||
|
buffer := make([]byte, 1024*1024) // 1MB buffer
|
||||||
|
var copiedSize int64 = 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := sourceFile.Read(buffer)
|
||||||
|
if n > 0 {
|
||||||
|
if _, writeErr := destFile.Write(buffer[:n]); writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
copiedSize += int64(n)
|
||||||
|
|
||||||
|
// Update progress (avoid too many events)
|
||||||
|
progress := int((copiedSize * 100) / totalSize)
|
||||||
|
if progress != updateStatus.DownloadProgress {
|
||||||
|
updateStatus.DownloadProgress = progress
|
||||||
|
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Install Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// InstallUpdate launches the downloaded installer with elevated privileges
|
||||||
|
// and optionally quits the application.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - quitAfterLaunch: If true, exits EMLy after launching the installer
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if installer launch fails
|
||||||
|
func (a *App) InstallUpdate(quitAfterLaunch bool) error {
|
||||||
|
if !updateStatus.Ready || updateStatus.InstallerPath == "" {
|
||||||
|
return fmt.Errorf("no installer ready to install")
|
||||||
|
}
|
||||||
|
|
||||||
|
installerPath := updateStatus.InstallerPath
|
||||||
|
|
||||||
|
// Verify installer exists
|
||||||
|
if _, err := os.Stat(installerPath); os.IsNotExist(err) {
|
||||||
|
updateStatus.ErrorMessage = "Installer file not found"
|
||||||
|
updateStatus.Ready = false
|
||||||
|
return fmt.Errorf("installer not found: %s", installerPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Launching installer: %s", installerPath)
|
||||||
|
|
||||||
|
// Launch installer with UAC elevation using ShellExecute
|
||||||
|
if err := shellExecuteAsAdmin(installerPath); err != nil {
|
||||||
|
updateStatus.ErrorMessage = fmt.Sprintf("Failed to launch installer: %v", err)
|
||||||
|
return fmt.Errorf("failed to launch installer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Installer launched successfully")
|
||||||
|
|
||||||
|
// Quit application if requested
|
||||||
|
if quitAfterLaunch {
|
||||||
|
time.Sleep(500 * time.Millisecond) // Brief delay to ensure installer starts
|
||||||
|
runtime.Quit(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellExecuteAsAdmin launches an executable with UAC elevation on Windows
|
||||||
|
func shellExecuteAsAdmin(exePath string) error {
|
||||||
|
verb := "runas" // Triggers UAC elevation
|
||||||
|
exe := syscall.StringToUTF16Ptr(exePath)
|
||||||
|
verbPtr := syscall.StringToUTF16Ptr(verb)
|
||||||
|
|
||||||
|
var hwnd uintptr = 0
|
||||||
|
var operation = verbPtr
|
||||||
|
var file = exe
|
||||||
|
var parameters uintptr = 0
|
||||||
|
var directory uintptr = 0
|
||||||
|
var showCmd int32 = 1 // SW_SHOWNORMAL
|
||||||
|
|
||||||
|
// Load shell32.dll
|
||||||
|
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||||
|
shellExecute := shell32.NewProc("ShellExecuteW")
|
||||||
|
|
||||||
|
ret, _, err := shellExecute.Call(
|
||||||
|
hwnd,
|
||||||
|
uintptr(unsafe.Pointer(operation)),
|
||||||
|
uintptr(unsafe.Pointer(file)),
|
||||||
|
parameters,
|
||||||
|
directory,
|
||||||
|
uintptr(showCmd),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShellExecuteW returns a value > 32 on success
|
||||||
|
if ret <= 32 {
|
||||||
|
return fmt.Errorf("ShellExecuteW failed with code %d: %v", ret, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetUpdateStatus returns the current update system status.
|
||||||
|
// This can be polled by the frontend to update UI state.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - UpdateStatus: Current state of the update system
|
||||||
|
func (a *App) GetUpdateStatus() UpdateStatus {
|
||||||
|
return updateStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// resolveUpdatePath resolves a network share path or file:// URL to a local path.
|
||||||
|
// Handles UNC paths (\\server\share), file:// URLs, and local paths.
|
||||||
|
func resolveUpdatePath(basePath, filename string) (string, error) {
|
||||||
|
basePath = strings.TrimSpace(basePath)
|
||||||
|
|
||||||
|
// Handle file:// URL
|
||||||
|
if strings.HasPrefix(strings.ToLower(basePath), "file://") {
|
||||||
|
u, err := url.Parse(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid file URL: %w", err)
|
||||||
|
}
|
||||||
|
// Convert file URL to local path
|
||||||
|
basePath = filepath.FromSlash(u.Path)
|
||||||
|
// Handle Windows drive letters (file:///C:/path -> C:/path)
|
||||||
|
if len(basePath) > 0 && basePath[0] == '/' && len(basePath) > 2 && basePath[2] == ':' {
|
||||||
|
basePath = basePath[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join with filename
|
||||||
|
fullPath := filepath.Join(basePath, filename)
|
||||||
|
|
||||||
|
// Verify path is accessible
|
||||||
|
if _, err := os.Stat(fullPath); err != nil {
|
||||||
|
return "", fmt.Errorf("path not accessible: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareSemanticVersions compares two semantic version strings.
|
||||||
|
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||||
|
func compareSemanticVersions(v1, v2 string) int {
|
||||||
|
// Strip beta/alpha suffixes for comparison
|
||||||
|
v1Clean := strings.Split(v1, "-")[0]
|
||||||
|
v2Clean := strings.Split(v2, "-")[0]
|
||||||
|
|
||||||
|
parts1 := strings.Split(v1Clean, ".")
|
||||||
|
parts2 := strings.Split(v2Clean, ".")
|
||||||
|
|
||||||
|
// Compare each version component
|
||||||
|
maxLen := len(parts1)
|
||||||
|
if len(parts2) > maxLen {
|
||||||
|
maxLen = len(parts2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
var num1, num2 int
|
||||||
|
|
||||||
|
if i < len(parts1) {
|
||||||
|
num1, _ = strconv.Atoi(parts1[i])
|
||||||
|
}
|
||||||
|
if i < len(parts2) {
|
||||||
|
num2, _ = strconv.Atoi(parts2[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if num1 < num2 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if num1 > num2 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If base versions are equal, check beta/stable
|
||||||
|
if v1 != v2 {
|
||||||
|
// Version with beta suffix is considered "older" than without
|
||||||
|
if strings.Contains(v1, "-beta") && !strings.Contains(v2, "-beta") {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if !strings.Contains(v1, "-beta") && strings.Contains(v2, "-beta") {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyChecksum verifies the SHA256 checksum of a file
|
||||||
|
func verifyChecksum(filePath, expectedChecksum string) error {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
actualChecksum := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
|
||||||
|
if !strings.EqualFold(actualChecksum, expectedChecksum) {
|
||||||
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
429
app_viewer.go
Normal file
429
app_viewer.go
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
// Package main provides viewer window functionality for EMLy.
|
||||||
|
// This file contains methods for opening attachments in viewer windows
|
||||||
|
// or with external applications.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Viewer Data Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ImageViewerData contains the data needed to display an image in the viewer window.
|
||||||
|
type ImageViewerData struct {
|
||||||
|
// Data is the base64-encoded image data
|
||||||
|
Data string `json:"data"`
|
||||||
|
// Filename is the original filename of the image
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDFViewerData contains the data needed to display a PDF in the viewer window.
|
||||||
|
type PDFViewerData struct {
|
||||||
|
// Data is the base64-encoded PDF data
|
||||||
|
Data string `json:"data"`
|
||||||
|
// Filename is the original filename of the PDF
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewerData is a union type that contains either image or PDF viewer data.
|
||||||
|
// Used by the viewer page to determine which type of content to display.
|
||||||
|
type ViewerData struct {
|
||||||
|
// ImageData is set when viewing an image (mutually exclusive with PDFData)
|
||||||
|
ImageData *ImageViewerData `json:"imageData,omitempty"`
|
||||||
|
// PDFData is set when viewing a PDF (mutually exclusive with ImageData)
|
||||||
|
PDFData *PDFViewerData `json:"pdfData,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Built-in Viewer Window Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// OpenEMLWindow opens an EML attachment in a new EMLy window.
|
||||||
|
// The EML data is saved to a temp file and a new EMLy instance is launched.
|
||||||
|
//
|
||||||
|
// This method tracks open EML files to prevent duplicate windows for the same file.
|
||||||
|
// The tracking is released when the viewer window is closed.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - base64Data: Base64-encoded EML file content
|
||||||
|
// - filename: The original filename of the EML attachment
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if the file is already open or if launching fails
|
||||||
|
func (a *App) OpenEMLWindow(base64Data string, filename string) error {
|
||||||
|
// Check if this EML is already open
|
||||||
|
a.openEMLsMux.Lock()
|
||||||
|
if a.openEMLs[filename] {
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
return fmt.Errorf("eml '%s' is already open", filename)
|
||||||
|
}
|
||||||
|
a.openEMLs[filename] = true
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
a.openEMLsMux.Lock()
|
||||||
|
delete(a.openEMLs, filename)
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file with timestamp to avoid conflicts
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s_%s", "emly_attachment", timestamp, filename))
|
||||||
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||||
|
a.openEMLsMux.Lock()
|
||||||
|
delete(a.openEMLs, filename)
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch new EMLy instance with the file path
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
a.openEMLsMux.Lock()
|
||||||
|
delete(a.openEMLs, filename)
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, tempFile)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
a.openEMLsMux.Lock()
|
||||||
|
delete(a.openEMLs, filename)
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to start viewer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor process in background to release lock when closed
|
||||||
|
go func() {
|
||||||
|
cmd.Wait()
|
||||||
|
a.openEMLsMux.Lock()
|
||||||
|
delete(a.openEMLs, filename)
|
||||||
|
a.openEMLsMux.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenImageWindow opens an image attachment in a new EMLy viewer window.
|
||||||
|
// The image data is saved to a temp file and a new EMLy instance is launched
|
||||||
|
// with the --view-image flag.
|
||||||
|
//
|
||||||
|
// This method tracks open images to prevent duplicate windows for the same file.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - base64Data: Base64-encoded image data
|
||||||
|
// - filename: The original filename of the image
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if the image is already open or if launching fails
|
||||||
|
func (a *App) OpenImageWindow(base64Data string, filename string) error {
|
||||||
|
// Check if this image is already open
|
||||||
|
a.openImagesMux.Lock()
|
||||||
|
if a.openImages[filename] {
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
return fmt.Errorf("image '%s' is already open", filename)
|
||||||
|
}
|
||||||
|
a.openImages[filename] = true
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
a.openImagesMux.Lock()
|
||||||
|
delete(a.openImages, filename)
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
|
||||||
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||||
|
a.openImagesMux.Lock()
|
||||||
|
delete(a.openImages, filename)
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch new EMLy instance in image viewer mode
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
a.openImagesMux.Lock()
|
||||||
|
delete(a.openImages, filename)
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, "--view-image="+tempFile)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
a.openImagesMux.Lock()
|
||||||
|
delete(a.openImages, filename)
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to start viewer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor process in background to release lock when closed
|
||||||
|
go func() {
|
||||||
|
cmd.Wait()
|
||||||
|
a.openImagesMux.Lock()
|
||||||
|
delete(a.openImages, filename)
|
||||||
|
a.openImagesMux.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPDFWindow opens a PDF attachment in a new EMLy viewer window.
|
||||||
|
// The PDF data is saved to a temp file and a new EMLy instance is launched
|
||||||
|
// with the --view-pdf flag.
|
||||||
|
//
|
||||||
|
// This method tracks open PDFs to prevent duplicate windows for the same file.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - base64Data: Base64-encoded PDF data
|
||||||
|
// - filename: The original filename of the PDF
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if the PDF is already open or if launching fails
|
||||||
|
func (a *App) OpenPDFWindow(base64Data string, filename string) error {
|
||||||
|
// Check if this PDF is already open
|
||||||
|
a.openPDFsMux.Lock()
|
||||||
|
if a.openPDFs[filename] {
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
return fmt.Errorf("pdf '%s' is already open", filename)
|
||||||
|
}
|
||||||
|
a.openPDFs[filename] = true
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
a.openPDFsMux.Lock()
|
||||||
|
delete(a.openPDFs, filename)
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
tempFile := filepath.Join(tempDir, filename)
|
||||||
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||||
|
a.openPDFsMux.Lock()
|
||||||
|
delete(a.openPDFs, filename)
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch new EMLy instance in PDF viewer mode
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
a.openPDFsMux.Lock()
|
||||||
|
delete(a.openPDFs, filename)
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(exe, "--view-pdf="+tempFile)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
a.openPDFsMux.Lock()
|
||||||
|
delete(a.openPDFs, filename)
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
return fmt.Errorf("failed to start viewer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor process in background to release lock when closed
|
||||||
|
go func() {
|
||||||
|
cmd.Wait()
|
||||||
|
a.openPDFsMux.Lock()
|
||||||
|
delete(a.openPDFs, filename)
|
||||||
|
a.openPDFsMux.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// External Application Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// OpenPDF saves a PDF to temp and opens it with the system's default PDF application.
|
||||||
|
// This is used when the user prefers external viewers over the built-in viewer.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - base64Data: Base64-encoded PDF data
|
||||||
|
// - filename: The original filename of the PDF
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if saving or launching fails
|
||||||
|
func (a *App) OpenPDF(base64Data string, filename string) error {
|
||||||
|
if base64Data == "" {
|
||||||
|
return fmt.Errorf("no data provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file with timestamp for uniqueness
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
|
||||||
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open with Windows default application
|
||||||
|
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenImage saves an image to temp and opens it with the system's default image viewer.
|
||||||
|
// This is used when the user prefers external viewers over the built-in viewer.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - base64Data: Base64-encoded image data
|
||||||
|
// - filename: The original filename of the image
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Error if saving or launching fails
|
||||||
|
func (a *App) OpenImage(base64Data string, filename string) error {
|
||||||
|
if base64Data == "" {
|
||||||
|
return fmt.Errorf("no data provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file with timestamp for uniqueness
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
|
||||||
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open with Windows default application
|
||||||
|
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Viewer Mode Detection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetImageViewerData checks CLI arguments and returns image data if running in image viewer mode.
|
||||||
|
// This is called by the viewer page on startup to get the image to display.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ImageViewerData: Image data if in viewer mode, nil otherwise
|
||||||
|
// - error: Error if reading the image file fails
|
||||||
|
func (a *App) GetImageViewerData() (*ImageViewerData, error) {
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
if strings.HasPrefix(arg, "--view-image=") {
|
||||||
|
filePath := strings.TrimPrefix(arg, "--view-image=")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read image file: %w", err)
|
||||||
|
}
|
||||||
|
// Return as base64 for consistent frontend handling
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
return &ImageViewerData{
|
||||||
|
Data: encoded,
|
||||||
|
Filename: filepath.Base(filePath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPDFViewerData checks CLI arguments and returns PDF data if running in PDF viewer mode.
|
||||||
|
// This is called by the viewer page on startup to get the PDF to display.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *PDFViewerData: PDF data if in viewer mode, nil otherwise
|
||||||
|
// - error: Error if reading the PDF file fails
|
||||||
|
func (a *App) GetPDFViewerData() (*PDFViewerData, error) {
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
if strings.HasPrefix(arg, "--view-pdf=") {
|
||||||
|
filePath := strings.TrimPrefix(arg, "--view-pdf=")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read PDF file: %w", err)
|
||||||
|
}
|
||||||
|
// Return as base64 for consistent frontend handling
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
return &PDFViewerData{
|
||||||
|
Data: encoded,
|
||||||
|
Filename: filepath.Base(filePath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetViewerData checks CLI arguments and returns viewer data for any viewer mode.
|
||||||
|
// This is a unified method that detects both image and PDF viewer modes.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *ViewerData: Contains either ImageData or PDFData depending on mode
|
||||||
|
// - error: Error if reading the file fails
|
||||||
|
func (a *App) GetViewerData() (*ViewerData, error) {
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
// Check for image viewer mode
|
||||||
|
if strings.HasPrefix(arg, "--view-image=") {
|
||||||
|
filePath := strings.TrimPrefix(arg, "--view-image=")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read image file: %w", err)
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
return &ViewerData{
|
||||||
|
ImageData: &ImageViewerData{
|
||||||
|
Data: encoded,
|
||||||
|
Filename: filepath.Base(filePath),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for PDF viewer mode
|
||||||
|
if strings.HasPrefix(arg, "--view-pdf=") {
|
||||||
|
filePath := strings.TrimPrefix(arg, "--view-pdf=")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read PDF file: %w", err)
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
return &ViewerData{
|
||||||
|
PDFData: &PDFViewerData{
|
||||||
|
Data: encoded,
|
||||||
|
Filename: filepath.Base(filePath),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@@ -18,6 +18,13 @@ type EMLyConfig struct {
|
|||||||
SDKDecoderReleaseChannel string `ini:"SDK_DECODER_RELEASE_CHANNEL"`
|
SDKDecoderReleaseChannel string `ini:"SDK_DECODER_RELEASE_CHANNEL"`
|
||||||
GUISemver string `ini:"GUI_SEMVER"`
|
GUISemver string `ini:"GUI_SEMVER"`
|
||||||
GUIReleaseChannel string `ini:"GUI_RELEASE_CHANNEL"`
|
GUIReleaseChannel string `ini:"GUI_RELEASE_CHANNEL"`
|
||||||
|
Language string `ini:"LANGUAGE"`
|
||||||
|
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
|
// LoadConfig reads the config.ini file at the given path and returns a Config struct
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
@@ -16,6 +23,14 @@ var EmailDialogOptions = runtime.OpenDialogOptions{
|
|||||||
ShowHiddenFiles: false,
|
ShowHiddenFiles: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var FolderDialogOptions = runtime.OpenDialogOptions{
|
||||||
|
Title: "Select Folder",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{DisplayName: "Folders", Pattern: "*"},
|
||||||
|
},
|
||||||
|
ShowHiddenFiles: false,
|
||||||
|
}
|
||||||
|
|
||||||
func ShowFileDialog(ctx context.Context) (string, error) {
|
func ShowFileDialog(ctx context.Context) (string, error) {
|
||||||
filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions)
|
filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -23,3 +38,86 @@ func ShowFileDialog(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
return filePath, nil
|
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()
|
||||||
|
}
|
||||||
|
|||||||
259
backend/utils/screenshot_windows.go
Normal file
259
backend/utils/screenshot_windows.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
16
config.ini
16
config.ini
@@ -1,6 +1,12 @@
|
|||||||
[EMLy]
|
[EMLy]
|
||||||
SDK_DECODER_SEMVER="1.3.1"
|
SDK_DECODER_SEMVER = 1.3.2
|
||||||
SDK_DECODER_RELEASE_CHANNEL="beta"
|
SDK_DECODER_RELEASE_CHANNEL = stable
|
||||||
GUI_SEMVER="1.3.0"
|
GUI_SEMVER = 1.5.4
|
||||||
GUI_RELEASE_CHANNEL="beta"
|
GUI_RELEASE_CHANNEL = beta
|
||||||
LANGUAGE="it"
|
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 =
|
||||||
|
|||||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -28,3 +28,7 @@ project.inlang/cache/
|
|||||||
|
|
||||||
# Wails
|
# Wails
|
||||||
/src/lib/wailsjs
|
/src/lib/wailsjs
|
||||||
|
|
||||||
|
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
"settings_preview_page_description": "Modify settings related to the preview page",
|
"settings_preview_page_description": "Modify settings related to the preview page",
|
||||||
"settings_preview_builtin_label": "Use built-in preview for images",
|
"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_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_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_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_title": "MSG Handling",
|
||||||
"settings_msg_converter_description": "Configure how MSG files are processed.",
|
"settings_msg_converter_description": "Configure how MSG files are processed.",
|
||||||
"settings_msg_converter_label": "Use MSG to EML converter",
|
"settings_msg_converter_label": "Use MSG to EML converter",
|
||||||
@@ -50,7 +50,9 @@
|
|||||||
"settings_danger_reset_dialog_continue": "Continue",
|
"settings_danger_reset_dialog_continue": "Continue",
|
||||||
"settings_danger_warning": "Warning: This action is irreversible. Please ensure you have backed up any important data before proceeding.",
|
"settings_danger_warning": "Warning: This action is irreversible. Please ensure you have backed up any important data before proceeding.",
|
||||||
"settings_danger_alert_title": "Advanced options enabled",
|
"settings_danger_alert_title": "Advanced options enabled",
|
||||||
"settings_danger_alert_description": "You're about to access EMLy's advanced options. Modifying such options may cause instability, including crashes, freezes, or security software alerts. For support or troubleshooting, contact @lyzcoote on Discord.",
|
"settings_danger_alert_description_part1": "You're about to access EMLy's advanced options.",
|
||||||
|
"settings_danger_alert_description_part2": "Modifying such options may cause instability, including crashes, freezes, or security software alerts.",
|
||||||
|
"settings_danger_alert_description_part3": "For support or troubleshooting, contact your TL/RDS.",
|
||||||
"settings_danger_alert_understood": "Understood",
|
"settings_danger_alert_understood": "Understood",
|
||||||
"settings_toast_reverted": "Reverted to last saved settings.",
|
"settings_toast_reverted": "Reverted to last saved settings.",
|
||||||
"settings_toast_save_failed": "Failed to save settings.",
|
"settings_toast_save_failed": "Failed to save settings.",
|
||||||
@@ -67,6 +69,8 @@
|
|||||||
"mail_open_btn_title": "Open another file",
|
"mail_open_btn_title": "Open another file",
|
||||||
"mail_close_btn_label": "Close",
|
"mail_close_btn_label": "Close",
|
||||||
"mail_close_btn_title": "Close",
|
"mail_close_btn_title": "Close",
|
||||||
|
"mail_download_btn_label": "Download",
|
||||||
|
"mail_download_btn_title": "Download",
|
||||||
"mail_from": "From:",
|
"mail_from": "From:",
|
||||||
"mail_to": "To:",
|
"mail_to": "To:",
|
||||||
"mail_cc": "Cc:",
|
"mail_cc": "Cc:",
|
||||||
@@ -77,8 +81,9 @@
|
|||||||
"mail_error_image": "Failed to open image file.",
|
"mail_error_image": "Failed to open image file.",
|
||||||
"settings_toast_language_changed": "Language changed successfully!",
|
"settings_toast_language_changed": "Language changed successfully!",
|
||||||
"settings_toast_language_change_failed": "Failed to change language.",
|
"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_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_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.",
|
"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.",
|
"mail_error_opening": "Failed to open EML file.",
|
||||||
@@ -89,5 +94,135 @@
|
|||||||
"mail_pdf_already_open": "The PDF is already open in another window.",
|
"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_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_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...",
|
||||||
|
"settings_appearance_title": "Appearance",
|
||||||
|
"settings_appearance_description": "Customize the application theme.",
|
||||||
|
"settings_theme_label": "Theme",
|
||||||
|
"settings_theme_hint": "Choose between light and dark mode.",
|
||||||
|
"settings_theme_light": "Light",
|
||||||
|
"settings_theme_dark": "Dark",
|
||||||
|
"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!",
|
||||||
|
"settings_export_import_title": "Export / Import Settings",
|
||||||
|
"settings_export_import_description": "Export your current settings to a file or import settings from a previously exported file.",
|
||||||
|
"settings_export_button": "Export Settings",
|
||||||
|
"settings_export_hint": "Save your current settings to a JSON file.",
|
||||||
|
"settings_import_button": "Import Settings",
|
||||||
|
"settings_import_hint": "Load settings from a previously exported JSON file.",
|
||||||
|
"settings_export_success": "Settings exported successfully!",
|
||||||
|
"settings_export_error": "Failed to export settings.",
|
||||||
|
"settings_import_success": "Settings imported successfully!",
|
||||||
|
"settings_import_error": "Failed to import settings.",
|
||||||
|
"settings_import_invalid": "Invalid settings file.",
|
||||||
|
"settings_email_dark_viewer_label": "Dark theme for email content",
|
||||||
|
"settings_email_dark_viewer_hint": "Display email body with a dark background matching the app theme.",
|
||||||
|
"settings_email_dark_viewer_info": "Info: When disabled, emails will display with their original light background. Some emails may be designed for light backgrounds and look better with this disabled.",
|
||||||
|
"sidebar_credits": "Credits",
|
||||||
|
"credits_title": "Credits",
|
||||||
|
"credits_description": "Acknowledgments and attributions for EMLy.",
|
||||||
|
"credits_about_title": "About EMLy",
|
||||||
|
"credits_about_description": "\"A slick app that somehow still works, with a badass UI that makes reading emails almost enjoyable.\"",
|
||||||
|
"credits_about_description_2": " -Someone who clearly hasn't seen the codebase",
|
||||||
|
"credits_app_tagline": "EML & MSG Viewer for Windows",
|
||||||
|
"credits_app_description": "EMLy is a lightweight, modern desktop application designed to view .eml and .msg email files. Built with performance and usability in mind, it provides a clean interface for reading emails, viewing attachments, and handling Italian PEC certified emails.",
|
||||||
|
"credits_team_title": "Development Team",
|
||||||
|
"credits_team_description": "The people behind EMLy.",
|
||||||
|
"credits_role_lead_developer": "Lead Developer",
|
||||||
|
"credits_role_senior_developer": "Senior Developer",
|
||||||
|
"credits_foisx_desc": "Creator and maintainer of EMLy. Responsible for architecture, development, and design.",
|
||||||
|
"credits_laky64_desc": "Implemented the custom MSG file parser in Go for native Outlook message support.",
|
||||||
|
"credits_special_thanks_title": "Special Thanks",
|
||||||
|
"credits_special_thanks_description": "Contributors who helped make EMLy better in a significant way.",
|
||||||
|
"credits_made_with": "Made with",
|
||||||
|
"credits_at_3git": "at 3gIT",
|
||||||
|
"credits_tech_title": "Built With",
|
||||||
|
"credits_tech_description": "Core technologies powering EMLy.",
|
||||||
|
"credits_tech_wails": "Desktop application framework for Go",
|
||||||
|
"credits_tech_go": "Backend programming language",
|
||||||
|
"credits_tech_sveltekit": "Frontend application framework",
|
||||||
|
"credits_tech_svelte": "Reactive UI framework",
|
||||||
|
"credits_tech_typescript": "Type-safe JavaScript",
|
||||||
|
"credits_tech_tailwind": "Utility-first CSS framework",
|
||||||
|
"credits_libraries_title": "Libraries & Packages",
|
||||||
|
"credits_libraries_description": "Open source packages that make EMLy possible.",
|
||||||
|
"credits_lib_shadcn": "Beautiful UI components for Svelte",
|
||||||
|
"credits_lib_lucide": "Beautiful & consistent icon set",
|
||||||
|
"credits_lib_paraglide": "Type-safe internationalization",
|
||||||
|
"credits_lib_sonner": "Toast notifications for Svelte",
|
||||||
|
"credits_lib_pdfjs": "PDF rendering library by Mozilla",
|
||||||
|
"credits_lib_dompurify": "XSS sanitizer for HTML content",
|
||||||
|
"credits_license_title": "License & Source",
|
||||||
|
"credits_license_text": "EMLy is proprietary software developed by 3gIT. All rights reserved. The application uses various open source libraries, each governed by their respective licenses.",
|
||||||
|
"credits_copyright": "All rights reserved.",
|
||||||
|
"settings_updates_title": "Updates",
|
||||||
|
"settings_updates_description": "Check for and install application updates from your network share.",
|
||||||
|
"settings_updates_current_version": "Current Version",
|
||||||
|
"settings_updates_available": "Update Available",
|
||||||
|
"settings_updates_check_failed": "Check failed",
|
||||||
|
"settings_updates_no_updates": "No updates found",
|
||||||
|
"settings_updates_check_label": "Check for Updates",
|
||||||
|
"settings_updates_last_checked": "Last checked: {time}",
|
||||||
|
"settings_updates_click_check": "Click to check for available updates",
|
||||||
|
"settings_updates_checking": "Checking...",
|
||||||
|
"settings_updates_check_now": "Check Now",
|
||||||
|
"settings_updates_version_available": "Version {version} Available",
|
||||||
|
"settings_updates_downloading": "Downloading... {progress}%",
|
||||||
|
"settings_updates_click_download": "Click to download the update",
|
||||||
|
"settings_updates_download_button": "Download",
|
||||||
|
"settings_updates_ready_title": "Update Ready to Install",
|
||||||
|
"settings_updates_ready_ref": "Version {version} has been downloaded and verified",
|
||||||
|
"settings_updates_install_button": "Install Now",
|
||||||
|
"settings_updates_info_message": "Updates are checked from your configured network share path.",
|
||||||
|
"settings_updates_current_path": "Current path:",
|
||||||
|
"settings_updates_no_path": "No update path configured",
|
||||||
|
"settings_toast_update_available": "Update available: {version}",
|
||||||
|
"settings_toast_latest_version": "You're on the latest version",
|
||||||
|
"settings_toast_check_failed": "Failed to check for updates",
|
||||||
|
"settings_toast_download_success": "Update downloaded successfully",
|
||||||
|
"settings_toast_download_failed": "Failed to download update",
|
||||||
|
"settings_toast_install_failed": "Failed to launch installer",
|
||||||
|
"settings_danger_update_checker_label": "Enable Update Checker",
|
||||||
|
"settings_danger_update_checker_hint": "Check for application updates from network share",
|
||||||
|
"settings_danger_update_checker_info": "Info: When enabled, the app will check for updates from your configured network share. Disable this if you manage updates manually or don't have network access.",
|
||||||
|
"settings_reduce_motion_label": "Reduce Motion",
|
||||||
|
"settings_reduce_motion_hint": "Disable transition animations for interface elements like the sidebar.",
|
||||||
|
"settings_reduce_motion_info": "Info: When enabled, animations such as the sidebar slide transition will be removed for a snappier feel or to reduce visual distractions.",
|
||||||
|
"settings_window_buttons_contrast_label": "Increase window buttons contrast",
|
||||||
|
"settings_window_buttons_contrast_hint": "Makes the window control buttons (minimize, maximize, close) more visible by increasing their contrast.",
|
||||||
|
"pdf_viewer_title": "PDF Viewer",
|
||||||
|
"pdf_loading": "Loading PDF...",
|
||||||
|
"pdf_zoom_in": "Zoom In",
|
||||||
|
"pdf_zoom_out": "Zoom Out",
|
||||||
|
"pdf_rotate_left": "Rotate Left",
|
||||||
|
"pdf_rotate_right": "Rotate Right",
|
||||||
|
"pdf_fit_width": "Fit to Width",
|
||||||
|
"pdf_error_no_data": "No PDF data provided",
|
||||||
|
"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: ",
|
||||||
|
"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_page_description": "Modifica le impostazioni relative alla pagina di anteprima",
|
||||||
"settings_preview_builtin_label": "Usa anteprima integrata per le immagini",
|
"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_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_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_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_title": "Gestione MSG",
|
||||||
"settings_msg_converter_description": "Configura come vengono elaborati i file MSG.",
|
"settings_msg_converter_description": "Configura come vengono elaborati i file MSG.",
|
||||||
"settings_msg_converter_label": "Usa convertitore MSG in EML",
|
"settings_msg_converter_label": "Usa convertitore MSG in EML",
|
||||||
@@ -50,7 +50,9 @@
|
|||||||
"settings_danger_reset_dialog_continue": "Continua",
|
"settings_danger_reset_dialog_continue": "Continua",
|
||||||
"settings_danger_warning": "Attenzione: Questa azione è irreversibile. Assicurati di aver effettuato il backup di tutti i dati importanti prima di procedere.",
|
"settings_danger_warning": "Attenzione: Questa azione è irreversibile. Assicurati di aver effettuato il backup di tutti i dati importanti prima di procedere.",
|
||||||
"settings_danger_alert_title": "Opzioni avanzate abilitate",
|
"settings_danger_alert_title": "Opzioni avanzate abilitate",
|
||||||
"settings_danger_alert_description": "Stai per accedere alle opzioni avanzate di EMLy. Modificare tali opzioni può causare instabilità, inclusi crash, blocchi o avvisi del software di sicurezza. Per supporto o risoluzione dei problemi, contatta @lyzcoote su Discord.",
|
"settings_danger_alert_description_part1": "Stai per accedere alle opzioni avanzate di EMLy.",
|
||||||
|
"settings_danger_alert_description_part2": "Modificare tali opzioni può causare instabilità, inclusi crash, blocchi o avvisi del software di sicurezza.",
|
||||||
|
"settings_danger_alert_description_part3": "Per supporto o risoluzione dei problemi, contatta il proprio TL/RDS.",
|
||||||
"settings_danger_alert_understood": "Capito",
|
"settings_danger_alert_understood": "Capito",
|
||||||
"settings_toast_reverted": "Ripristinato alle ultime impostazioni salvate.",
|
"settings_toast_reverted": "Ripristinato alle ultime impostazioni salvate.",
|
||||||
"settings_toast_save_failed": "Impossibile salvare le impostazioni.",
|
"settings_toast_save_failed": "Impossibile salvare le impostazioni.",
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
"mail_error_image": "Impossibile aprire il file immagine.",
|
"mail_error_image": "Impossibile aprire il file immagine.",
|
||||||
"settings_toast_language_changed": "Lingua cambiata con successo!",
|
"settings_toast_language_changed": "Lingua cambiata con successo!",
|
||||||
"settings_toast_language_change_failed": "Impossibile cambiare lingua.",
|
"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",
|
"mail_close_btn_text": "Chiudi",
|
||||||
"settings_danger_reset_dialog_description_part1": "Questa azione non può essere annullata.",
|
"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.",
|
"settings_danger_reset_dialog_description_part2": "Questo eliminerà permanentemente le tue impostazioni attuali e riporterà l'app allo stato predefinito.",
|
||||||
@@ -89,5 +91,139 @@
|
|||||||
"mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.",
|
"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_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_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...",
|
||||||
|
"settings_appearance_title": "Aspetto",
|
||||||
|
"settings_appearance_description": "Personalizza il tema dell'applicazione.",
|
||||||
|
"settings_theme_label": "Tema",
|
||||||
|
"settings_theme_hint": "Scegli tra modalità chiara e scura.",
|
||||||
|
"settings_theme_light": "Chiaro",
|
||||||
|
"settings_theme_dark": "Scuro",
|
||||||
|
"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!",
|
||||||
|
"settings_export_import_title": "Esporta / Importa Impostazioni",
|
||||||
|
"settings_export_import_description": "Esporta le impostazioni correnti in un file o importa impostazioni da un file precedentemente esportato.",
|
||||||
|
"settings_export_button": "Esporta Impostazioni",
|
||||||
|
"settings_export_hint": "Salva le impostazioni correnti in un file JSON.",
|
||||||
|
"settings_import_button": "Importa Impostazioni",
|
||||||
|
"settings_import_hint": "Carica impostazioni da un file JSON precedentemente esportato.",
|
||||||
|
"settings_export_success": "Impostazioni esportate con successo!",
|
||||||
|
"settings_export_error": "Impossibile esportare le impostazioni.",
|
||||||
|
"settings_import_success": "Impostazioni importate con successo!",
|
||||||
|
"settings_import_error": "Impossibile importare le impostazioni.",
|
||||||
|
"settings_import_invalid": "File impostazioni non valido.",
|
||||||
|
"settings_email_dark_viewer_label": "Tema scuro per contenuto email",
|
||||||
|
"settings_email_dark_viewer_hint": "Visualizza il corpo dell'email con uno sfondo scuro che corrisponde al tema dell'app.",
|
||||||
|
"settings_email_dark_viewer_info": "Info: Quando disabilitato, le email verranno visualizzate con lo sfondo chiaro originale. Alcune email potrebbero essere progettate per sfondi chiari e apparire meglio con questa opzione disabilitata.",
|
||||||
|
"sidebar_credits": "Crediti",
|
||||||
|
"credits_title": "Crediti",
|
||||||
|
"credits_description": "Riconoscimenti e attribuzioni per EMLy.",
|
||||||
|
"credits_about_title": "Informazioni su EMLy",
|
||||||
|
"credits_about_description": "\"Un'app che in qualche modo funziona ancora, con un'interfaccia da paura che rende quasi piacevole leggere le email.\"",
|
||||||
|
"credits_about_description_2": " -Qualcuno che chiaramente non ha visto il codice sorgente",
|
||||||
|
"credits_app_tagline": "Visualizzatore EML e MSG per Windows",
|
||||||
|
"credits_app_description": "EMLy è un'applicazione desktop leggera e moderna progettata per visualizzare file email .eml e .msg. Costruita con prestazioni e usabilità in mente, fornisce un'interfaccia pulita per leggere email, visualizzare allegati e gestire email PEC certificate italiane.",
|
||||||
|
"credits_team_title": "Team di Sviluppo",
|
||||||
|
"credits_team_description": "Le persone dietro EMLy.",
|
||||||
|
"credits_role_lead_developer": "Sviluppatore Principale",
|
||||||
|
"credits_role_senior_developer": "Sviluppatore Senior",
|
||||||
|
"credits_foisx_desc": "Creatore e manutentore di EMLy. Responsabile dell'architettura, sviluppo e design.",
|
||||||
|
"credits_laky64_desc": "Ha implementato il parser MSG personalizzato in Go per il supporto nativo dei messaggi Outlook.",
|
||||||
|
"credits_special_thanks_title": "Ringraziamenti Speciali",
|
||||||
|
"credits_special_thanks_description": "Contributori che hanno aiutato a migliorare EMLy in modo significativo.",
|
||||||
|
"credits_made_with": "Fatto con",
|
||||||
|
"credits_at_3git": "presso 3gIT",
|
||||||
|
"credits_tech_title": "Costruito Con",
|
||||||
|
"credits_tech_description": "Tecnologie principali che alimentano EMLy.",
|
||||||
|
"credits_tech_wails": "Framework per applicazioni desktop in Go",
|
||||||
|
"credits_tech_go": "Linguaggio di programmazione backend",
|
||||||
|
"credits_tech_sveltekit": "Framework per applicazioni frontend",
|
||||||
|
"credits_tech_svelte": "Framework UI reattivo",
|
||||||
|
"credits_tech_typescript": "JavaScript type-safe",
|
||||||
|
"credits_tech_tailwind": "Framework CSS utility-first",
|
||||||
|
"credits_libraries_title": "Librerie e Pacchetti",
|
||||||
|
"credits_libraries_description": "Pacchetti open source che rendono possibile EMLy.",
|
||||||
|
"credits_lib_shadcn": "Componenti UI per Svelte",
|
||||||
|
"credits_lib_lucide": "Set di icone belle e coerenti",
|
||||||
|
"credits_lib_paraglide": "Internazionalizzazione type-safe",
|
||||||
|
"credits_lib_sonner": "Notifiche toast per Svelte",
|
||||||
|
"credits_lib_pdfjs": "Libreria di rendering PDF di Mozilla",
|
||||||
|
"credits_lib_dompurify": "Sanitizzatore XSS per contenuti HTML",
|
||||||
|
"credits_license_title": "Licenza e Sorgente",
|
||||||
|
"credits_license_text": "EMLy è un software proprietario sviluppato da 3gIT. Tutti i diritti riservati. L'applicazione utilizza varie librerie open source, ciascuna governata dalle rispettive licenze.",
|
||||||
|
"credits_copyright": "Tutti i diritti riservati.",
|
||||||
|
"settings_updates_title": "Aggiornamenti",
|
||||||
|
"settings_updates_description": "Controlla e installa gli aggiornamenti dell'applicazione dalla condivisione di rete.",
|
||||||
|
"settings_updates_current_version": "Versione corrente",
|
||||||
|
"settings_updates_available": "Aggiornamento disponibile",
|
||||||
|
"settings_updates_check_failed": "Controllo fallito",
|
||||||
|
"settings_updates_no_updates": "Nessun aggiornamento trovato",
|
||||||
|
"settings_updates_check_label": "Controlla aggiornamenti",
|
||||||
|
"settings_updates_last_checked": "Ultimo controllo: {time}",
|
||||||
|
"settings_updates_click_check": "Clicca per cercare aggiornamenti disponibili",
|
||||||
|
"settings_updates_checking": "Controllo in corso...",
|
||||||
|
"settings_updates_check_now": "Controlla ora",
|
||||||
|
"settings_updates_version_available": "Versione {version} disponibile",
|
||||||
|
"settings_updates_downloading": "Download in corso... {progress}%",
|
||||||
|
"settings_updates_click_download": "Clicca per scaricare l'aggiornamento",
|
||||||
|
"settings_updates_download_button": "Scarica",
|
||||||
|
"settings_updates_ready_title": "Aggiornamento pronto per l'installazione",
|
||||||
|
"settings_updates_ready_ref": "La versione {version} è stata scaricata e verificata",
|
||||||
|
"settings_updates_install_button": "Installa ora",
|
||||||
|
"settings_updates_info_message": "Gli aggiornamenti vengono controllati dal percorso di rete configurato.",
|
||||||
|
"settings_updates_current_path": "Percorso attuale:",
|
||||||
|
"settings_updates_no_path": "Nessun percorso di aggiornamento configurato",
|
||||||
|
"settings_toast_update_available": "Aggiornamento disponibile: {version}",
|
||||||
|
"settings_toast_latest_version": "Sei sull'ultima versione",
|
||||||
|
"settings_toast_check_failed": "Impossibile controllare gli aggiornamenti",
|
||||||
|
"settings_toast_download_success": "Aggiornamento scaricato con successo",
|
||||||
|
"settings_toast_download_failed": "Impossibile scaricare l'aggiornamento",
|
||||||
|
"settings_toast_install_failed": "Impossibile avviare l'installazione",
|
||||||
|
"settings_danger_update_checker_label": "Abilita controllo aggiornamenti",
|
||||||
|
"settings_danger_update_checker_hint": "Controlla aggiornamenti applicazione dalla condivisione di rete",
|
||||||
|
"settings_danger_update_checker_info": "Info: Quando abilitato, l'app controllerà gli aggiornamenti dal percorso di rete configurato. Disabilitalo se gestisci gli aggiornamenti manualmente o non hai accesso alla rete.",
|
||||||
|
"settings_reduce_motion_label": "Riduci Movimento",
|
||||||
|
"settings_reduce_motion_hint": "Disabilita le animazioni di transizione per gli elementi dell'interfaccia come la barra laterale.",
|
||||||
|
"settings_reduce_motion_info": "Info: Quando abilitato, le animazioni come la transizione della barra laterale verranno rimosse per un'esperienza più reattiva o per ridurre le distrazioni visive.",
|
||||||
|
"settings_window_buttons_contrast_label": "Aumenta contrasto pulsanti finestra",
|
||||||
|
"settings_window_buttons_contrast_hint": "Rende i pulsanti di controllo della finestra (minimizza, massimizza, chiudi) più visibili aumentando il loro contrasto.",
|
||||||
|
"pdf_viewer_title": "Visualizzatore PDF",
|
||||||
|
"pdf_loading": "Caricamento PDF...",
|
||||||
|
"pdf_zoom_in": "Ingrandisci",
|
||||||
|
"pdf_zoom_out": "Riduci",
|
||||||
|
"pdf_rotate_left": "Ruota a sinistra",
|
||||||
|
"pdf_rotate_right": "Ruota a destra",
|
||||||
|
"pdf_fit_width": "Adatta alla larghezza",
|
||||||
|
"pdf_error_no_data": "Nessun dato PDF fornito",
|
||||||
|
"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: ",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,10 @@
|
|||||||
"vite-plugin-devtools-json": "^1.0.0"
|
"vite-plugin-devtools-json": "^1.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "^4.57.1",
|
||||||
|
"@types/html2canvas": "^1.0.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"pdfjs-dist": "^5.4.624",
|
"pdfjs-dist": "^5.4.624",
|
||||||
"svelte-flags": "^3.0.1",
|
"svelte-flags": "^3.0.1",
|
||||||
"svelte-sonner": "^1.0.7"
|
"svelte-sonner": "^1.0.7"
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
const theme = stored === "light" || stored === "dark" ? stored : "dark";
|
const theme = stored === "light" || stored === "dark" ? stored : "dark";
|
||||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
} catch {
|
} catch {
|
||||||
// If storage is blocked, default to dark.
|
// If storage is blocked, default to light.
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.remove("dark");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -67,8 +67,21 @@
|
|||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div id="app-loading">
|
<div id="app-loading">
|
||||||
<div class="loader-spinner"></div>
|
<div class="loader-spinner"></div>
|
||||||
<div>Loading, please wait...</div>
|
<div id="loading-text">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(localStorage.getItem("emly_gui_settings") || "{}");
|
||||||
|
const lang = settings.selectedLanguage || "en";
|
||||||
|
const text = lang === "it" ? "Caricamento..." : "Loading...";
|
||||||
|
const el = document.getElementById("loading-text");
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to localize loading text", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div style="display: contents;">%sveltekit.body%</div>
|
<div style="display: contents;">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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>
|
||||||
@@ -1,179 +1,138 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte";
|
import {
|
||||||
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow } from "$lib/wailsjs/go/main/App";
|
X,
|
||||||
import type { internal } from "$lib/wailsjs/go/models";
|
MailOpen,
|
||||||
import { sidebarOpen } from "$lib/stores/app";
|
Image,
|
||||||
import { onDestroy, onMount } from "svelte";
|
FileText,
|
||||||
import { toast } from "svelte-sonner";
|
File,
|
||||||
import { EventsOn, WindowShow, WindowUnminimise } from "$lib/wailsjs/runtime/runtime";
|
ShieldCheck,
|
||||||
import { mailState } from "$lib/stores/mail-state.svelte";
|
Signature,
|
||||||
import { settingsStore } from "$lib/stores/settings.svelte";
|
FileCode,
|
||||||
import * as m from "$lib/paraglide/messages";
|
Loader2,
|
||||||
import { dev } from "$app/environment";
|
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';
|
||||||
|
|
||||||
|
// Import refactored utilities
|
||||||
|
import {
|
||||||
|
IFRAME_UTIL_HTML_DARK,
|
||||||
|
IFRAME_UTIL_HTML_LIGHT,
|
||||||
|
CONTENT_TYPES,
|
||||||
|
PEC_FILES,
|
||||||
|
arrayBufferToBase64,
|
||||||
|
createDataUrl,
|
||||||
|
openPDFAttachment,
|
||||||
|
openImageAttachment,
|
||||||
|
openEMLAttachment,
|
||||||
|
openAndLoadEmail,
|
||||||
|
loadEmailFromPath,
|
||||||
|
processEmailBody,
|
||||||
|
isEmailFile,
|
||||||
|
} from '$lib/utils/mail';
|
||||||
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
let unregisterEvents = () => {};
|
let unregisterEvents = () => {};
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let loadingText = $state("");
|
let loadingText = $state('');
|
||||||
|
|
||||||
|
// Derived iframe HTML based on dark/light setting
|
||||||
|
let iframeUtilHtml = $derived(
|
||||||
|
settingsStore.settings.useDarkEmailViewer !== false
|
||||||
|
? IFRAME_UTIL_HTML_DARK
|
||||||
|
: IFRAME_UTIL_HTML_LIGHT
|
||||||
|
);
|
||||||
|
|
||||||
let iFrameUtilHTML = "<style>body{margin:0;padding:20px;font-family:sans-serif;} a{pointer-events:none!important;cursor:default!important;}</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>";
|
// ============================================================================
|
||||||
|
// Event Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
function onClear() {
|
function onClear() {
|
||||||
mailState.clear();
|
mailState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
async function onDownloadAttachments() {
|
||||||
if(dev) {
|
if (!mailState.currentEmail || !mailState.currentEmail.attachments) return;
|
||||||
console.log(mailState.currentEmail)
|
|
||||||
}
|
|
||||||
console.info("Current email changed:", mailState.currentEmail?.subject);
|
|
||||||
if(mailState.currentEmail !== null) {
|
|
||||||
sidebarOpen.set(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
// Check if custom download behavior is enabled
|
||||||
if (unregisterEvents) unregisterEvents();
|
const useCustomDownload = settingsStore.settings.useCustomAttachmentDownload ?? false;
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
// Listen for second instance args
|
|
||||||
unregisterEvents = EventsOn("launchArgs", async (args: string[]) => {
|
|
||||||
console.log("got event launchArgs:", args);
|
|
||||||
if (args && args.length > 0) {
|
|
||||||
for (const arg of args) {
|
|
||||||
const lowerArg = arg.toLowerCase();
|
|
||||||
if (lowerArg.endsWith(".eml") || lowerArg.endsWith(".msg")) {
|
|
||||||
console.log("Loading file from second instance:", arg);
|
|
||||||
isLoading = true;
|
|
||||||
loadingText = m.layout_loading_text();
|
|
||||||
|
|
||||||
|
if (useCustomDownload) {
|
||||||
|
// Use backend SaveAttachment (saves to configured folder and opens Explorer)
|
||||||
try {
|
try {
|
||||||
let emlContent;
|
let lastSavedPath = '';
|
||||||
|
for (const att of mailState.currentEmail.attachments) {
|
||||||
if (lowerArg.endsWith(".msg")) {
|
const base64 = arrayBufferToBase64(att.data);
|
||||||
loadingText = m.mail_loading_msg_conversion();
|
lastSavedPath = await SaveAttachment(att.filename, base64);
|
||||||
emlContent = await ReadMSG(arg, true);
|
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 {
|
} else {
|
||||||
// EML handling
|
// Use browser default download (downloads to browser's default folder)
|
||||||
try {
|
mailState.currentEmail.attachments.forEach((att) => {
|
||||||
emlContent = await ReadPEC(arg);
|
const base64 = arrayBufferToBase64(att.data);
|
||||||
} catch (e) {
|
const dataUrl = createDataUrl(att.contentType, base64);
|
||||||
console.warn("ReadPEC failed, trying ReadEML:", e);
|
const link = document.createElement('a');
|
||||||
emlContent = await ReadEML(arg);
|
link.href = dataUrl;
|
||||||
}
|
link.download = att.filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
if (emlContent && emlContent.body) {
|
link.click();
|
||||||
const trimmed = emlContent.body.trim();
|
document.body.removeChild(link);
|
||||||
const clean = trimmed.replace(/[\s\r\n]+/g, '');
|
|
||||||
if (clean.length > 0 && clean.length % 4 === 0 && /^[A-Za-z0-9+/]+=*$/.test(clean)) {
|
|
||||||
try {
|
|
||||||
emlContent.body = window.atob(clean);
|
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mailState.setParams(emlContent);
|
|
||||||
sidebarOpen.set(false);
|
|
||||||
WindowUnminimise();
|
|
||||||
WindowShow();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load email:", error);
|
|
||||||
toast.error("Failed to load email file");
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
loadingText = "";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
async function openPDFHandler(base64Data: string, filename: string) {
|
|
||||||
try {
|
|
||||||
if (settingsStore.settings.useBuiltinPDFViewer) {
|
|
||||||
await OpenPDFWindow(base64Data, filename);
|
|
||||||
} else {
|
|
||||||
await OpenPDF(base64Data, filename);
|
|
||||||
}
|
|
||||||
} catch (error: string | any) {
|
|
||||||
if(error.includes(filename) && error.includes("already open")) {
|
|
||||||
toast.error(m.mail_pdf_already_open());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error("Failed to open PDF:", error);
|
|
||||||
toast.error(m.mail_error_pdf());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openImageHandler(base64Data: string, filename: string) {
|
|
||||||
try {
|
|
||||||
if (settingsStore.settings.useBuiltinPreview) {
|
|
||||||
await OpenImageWindow(base64Data, filename);
|
|
||||||
} else {
|
|
||||||
await OpenImage(base64Data, filename);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open image:", error);
|
|
||||||
toast.error(m.mail_error_image());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEMLHandler(base64Data: string, filename: string) {
|
|
||||||
try {
|
|
||||||
await OpenEMLWindow(base64Data, filename);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open EML:", error);
|
|
||||||
toast.error("Failed to open EML attachment");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenMail() {
|
async function onOpenMail() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
loadingText = m.layout_loading_text();
|
loadingText = m.layout_loading_text();
|
||||||
const result = await ShowOpenFileDialog();
|
|
||||||
if (result && result.length > 0) {
|
const result = await openAndLoadEmail();
|
||||||
// Handle opening the mail file
|
|
||||||
try {
|
if (result.cancelled) {
|
||||||
// If the file is .eml, otherwise if is .msg, read accordingly
|
isLoading = false;
|
||||||
let email: internal.EmailData;
|
loadingText = '';
|
||||||
if(result.toLowerCase().endsWith(".msg")) {
|
return;
|
||||||
loadingText = m.mail_loading_msg_conversion();
|
|
||||||
email = await ReadMSG(result, true);
|
|
||||||
} else {
|
|
||||||
email = await ReadEML(result);
|
|
||||||
}
|
}
|
||||||
mailState.setParams(email);
|
|
||||||
|
if (result.success && result.email) {
|
||||||
|
mailState.setParams(result.email);
|
||||||
sidebarOpen.set(false);
|
sidebarOpen.set(false);
|
||||||
|
} else if (result.error) {
|
||||||
} catch (error) {
|
console.error('Failed to read email file:', result.error);
|
||||||
console.error("Failed to read EML file:", error);
|
|
||||||
toast.error(m.mail_error_opening());
|
toast.error(m.mail_error_opening());
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
loadingText = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isLoading = false;
|
|
||||||
loadingText = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer: any): string {
|
isLoading = false;
|
||||||
if (typeof buffer === "string") return buffer; // Already base64 string
|
loadingText = '';
|
||||||
if (Array.isArray(buffer)) {
|
|
||||||
let binary = "";
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const len = bytes.byteLength;
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i]);
|
|
||||||
}
|
}
|
||||||
return window.btoa(binary);
|
|
||||||
|
async function handleOpenPDF(base64Data: string, filename: string) {
|
||||||
|
await openPDFAttachment(base64Data, filename);
|
||||||
}
|
}
|
||||||
return "";
|
|
||||||
|
async function handleOpenImage(base64Data: string, filename: string) {
|
||||||
|
await openImageAttachment(base64Data, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenEML(base64Data: string, filename: string) {
|
||||||
|
await openEMLAttachment(base64Data, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWheel(event: WheelEvent) {
|
function handleWheel(event: WheelEvent) {
|
||||||
@@ -181,6 +140,98 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Effects
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Process email body when current email changes
|
||||||
|
$effect(() => {
|
||||||
|
const processCurrentEmail = async () => {
|
||||||
|
if (mailState.currentEmail?.body) {
|
||||||
|
const processedBody = await processEmailBody(mailState.currentEmail.body);
|
||||||
|
|
||||||
|
if (processedBody !== mailState.currentEmail.body) {
|
||||||
|
mailState.currentEmail.body = processedBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.info('Current email changed:', mailState.currentEmail?.subject);
|
||||||
|
|
||||||
|
if (mailState.currentEmail !== null) {
|
||||||
|
sidebarOpen.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processCurrentEmail();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Listen for second instance args (when another file is opened while app is running)
|
||||||
|
unregisterEvents = EventsOn('launchArgs', async (args: string[]) => {
|
||||||
|
console.log('got event launchArgs:', args);
|
||||||
|
|
||||||
|
if (!args || args.length === 0) return;
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (isEmailFile(arg)) {
|
||||||
|
console.log('Loading file from second instance:', arg);
|
||||||
|
isLoading = true;
|
||||||
|
loadingText = m.layout_loading_text();
|
||||||
|
|
||||||
|
// Check if MSG file for special loading text
|
||||||
|
if (arg.toLowerCase().endsWith('.msg')) {
|
||||||
|
loadingText = m.mail_loading_msg_conversion();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await loadEmailFromPath(arg);
|
||||||
|
|
||||||
|
if (result.success && result.email) {
|
||||||
|
mailState.setParams(result.email);
|
||||||
|
sidebarOpen.set(false);
|
||||||
|
WindowUnminimise();
|
||||||
|
WindowShow();
|
||||||
|
} else if (result.error) {
|
||||||
|
console.error('Failed to load email:', result.error);
|
||||||
|
toast.error('Failed to load email file');
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
loadingText = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unregisterEvents) {
|
||||||
|
unregisterEvents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getAttachmentClass(att: { contentType: string; filename: string }): string {
|
||||||
|
if (att.contentType.startsWith(CONTENT_TYPES.IMAGE)) return 'image';
|
||||||
|
if (att.contentType === CONTENT_TYPES.PDF || att.filename.toLowerCase().endsWith('.pdf'))
|
||||||
|
return 'pdf';
|
||||||
|
if (att.filename.toLowerCase().endsWith('.eml')) return 'eml';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPecSignature(filename: string, isPec: boolean): boolean {
|
||||||
|
return isPec && filename.toLowerCase().endsWith(PEC_FILES.SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPecCertificate(filename: string, isPec: boolean): boolean {
|
||||||
|
return isPec && filename.toLowerCase() === PEC_FILES.CERTIFICATE;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel fill" aria-label="Events">
|
<div class="panel fill" aria-label="Events">
|
||||||
@@ -190,8 +241,10 @@
|
|||||||
<div class="loading-text">{loadingText}</div>
|
<div class="loading-text">{loadingText}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="events" role="log" aria-live="polite">
|
<div class="events" role="log" aria-live="polite">
|
||||||
{#if mailState.currentEmail === null}
|
{#if mailState.currentEmail === null}
|
||||||
|
<!-- Empty State -->
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
<MailOpen size="48" strokeWidth={1} />
|
<MailOpen size="48" strokeWidth={1} />
|
||||||
@@ -202,13 +255,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Email View -->
|
||||||
<div class="email-view">
|
<div class="email-view">
|
||||||
|
<!-- Header -->
|
||||||
<div class="email-header-content">
|
<div class="email-header-content">
|
||||||
<div class="subject-row">
|
<div class="subject-row">
|
||||||
<div class="email-subject">
|
<div class="email-subject">
|
||||||
{mailState.currentEmail.subject || m.mail_subject_no_subject()}
|
{mailState.currentEmail.subject || m.mail_subject_no_subject()}
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<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
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
onclick={onOpenMail}
|
onclick={onOpenMail}
|
||||||
@@ -216,7 +281,7 @@
|
|||||||
title={m.mail_open_btn_title()}
|
title={m.mail_open_btn_title()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<MailOpen size="15" ></MailOpen>
|
<MailOpen size="15" />
|
||||||
{m.mail_open_btn_text()}
|
{m.mail_open_btn_text()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -232,77 +297,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta Grid -->
|
||||||
<div class="email-meta-grid">
|
<div class="email-meta-grid">
|
||||||
<span class="label">{m.mail_from()}</span>
|
<span class="label">{m.mail_from()}</span>
|
||||||
<span class="value">{mailState.currentEmail.from}</span>
|
<span class="value">{mailState.currentEmail.from}</span>
|
||||||
|
|
||||||
{#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0}
|
{#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0}
|
||||||
<span class="label">{m.mail_to()}</span>
|
<span class="label">{m.mail_to()}</span>
|
||||||
<span class="value">{mailState.currentEmail.to.join(", ")}</span>
|
<span class="value">{mailState.currentEmail.to.join(', ')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0}
|
{#if mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0}
|
||||||
<span class="label">{m.mail_cc()}</span>
|
<span class="label">{m.mail_cc()}</span>
|
||||||
<span class="value">{mailState.currentEmail.cc.join(", ")}</span>
|
<span class="value">{mailState.currentEmail.cc.join(', ')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0}
|
{#if mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0}
|
||||||
<span class="label">{m.mail_bcc()}</span>
|
<span class="label">{m.mail_bcc()}</span>
|
||||||
<span class="value">{mailState.currentEmail.bcc.join(", ")}</span>
|
<span class="value">{mailState.currentEmail.bcc.join(', ')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mailState.currentEmail.isPec}
|
{#if mailState.currentEmail.isPec}
|
||||||
<span class="label">{m.mail_sign_label()}</span>
|
<span class="label">{m.mail_sign_label()}</span>
|
||||||
<span class="value"><span class="pec-badge" title="Posta Elettronica Certificata">
|
<span class="value">
|
||||||
|
<span class="pec-badge" title="Posta Elettronica Certificata">
|
||||||
<ShieldCheck size="14" />
|
<ShieldCheck size="14" />
|
||||||
PEC
|
PEC
|
||||||
</span></span>
|
</span>
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
<div class="email-attachments">
|
<div class="email-attachments">
|
||||||
<span class="att-section-label">{m.mail_attachments()}</span>
|
<span class="att-section-label">{m.mail_attachments()}</span>
|
||||||
<div class="att-list">
|
<div class="att-list">
|
||||||
{#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0}
|
{#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0}
|
||||||
{#each mailState.currentEmail.attachments as att}
|
{#each mailState.currentEmail.attachments as att}
|
||||||
{#if att.contentType.startsWith("image/")}
|
{@const base64 = arrayBufferToBase64(att.data)}
|
||||||
|
{@const isImage = att.contentType.startsWith(CONTENT_TYPES.IMAGE)}
|
||||||
|
{@const isPdf =
|
||||||
|
att.contentType === CONTENT_TYPES.PDF ||
|
||||||
|
att.filename.toLowerCase().endsWith('.pdf')}
|
||||||
|
{@const isEml = att.filename.toLowerCase().endsWith('.eml')}
|
||||||
|
{@const isPecSig = isPecSignature(att.filename, mailState.currentEmail.isPec)}
|
||||||
|
{@const isPecCert = isPecCertificate(att.filename, mailState.currentEmail.isPec)}
|
||||||
|
|
||||||
|
{#if isImage}
|
||||||
<button
|
<button
|
||||||
class="att-btn image"
|
class="att-btn image"
|
||||||
onclick={() => openImageHandler(arrayBufferToBase64(att.data), att.filename)}
|
onclick={() => handleOpenImage(base64, att.filename)}
|
||||||
>
|
>
|
||||||
<Image size="14" />
|
<Image size="14" />
|
||||||
<span class="att-name">{att.filename}</span>
|
<span class="att-name">{att.filename}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else if att.contentType === "application/pdf" || att.filename.toLowerCase().endsWith(".pdf")}
|
{:else if isPdf}
|
||||||
<button
|
<button class="att-btn pdf" onclick={() => handleOpenPDF(base64, att.filename)}>
|
||||||
class="att-btn pdf"
|
<FileText size="14" />
|
||||||
onclick={() => openPDFHandler(arrayBufferToBase64(att.data), att.filename)}
|
|
||||||
>
|
|
||||||
<FileText />
|
|
||||||
<span class="att-name">{att.filename}</span>
|
<span class="att-name">{att.filename}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else if att.filename.toLowerCase().endsWith(".eml")}
|
{:else if isEml}
|
||||||
<button
|
<button class="att-btn eml" onclick={() => handleOpenEML(base64, att.filename)}>
|
||||||
class="att-btn eml"
|
|
||||||
onclick={() => openEMLHandler(arrayBufferToBase64(att.data), att.filename)}
|
|
||||||
>
|
|
||||||
<MailOpen size="14" />
|
<MailOpen size="14" />
|
||||||
<span class="att-name">{att.filename}</span>
|
<span class="att-name">{att.filename}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase().endsWith(".p7s")}
|
{:else if isPecSig}
|
||||||
<a
|
<a
|
||||||
class="att-btn file"
|
class="att-btn file"
|
||||||
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
|
href={createDataUrl(att.contentType, base64)}
|
||||||
download={att.filename}
|
download={att.filename}
|
||||||
>
|
>
|
||||||
<Signature size="14" />
|
<Signature size="14" />
|
||||||
<span class="att-name">{att.filename}</span>
|
<span class="att-name">{att.filename}</span>
|
||||||
</a>
|
</a>
|
||||||
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase() === "daticert.xml"}
|
{:else if isPecCert}
|
||||||
<a
|
<a
|
||||||
class="att-btn file"
|
class="att-btn file"
|
||||||
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
|
href={createDataUrl(att.contentType, base64)}
|
||||||
download={att.filename}
|
download={att.filename}
|
||||||
>
|
>
|
||||||
<FileCode size="14" />
|
<FileCode size="14" />
|
||||||
@@ -311,10 +383,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
class="att-btn file"
|
class="att-btn file"
|
||||||
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
|
href={createDataUrl(att.contentType, base64)}
|
||||||
download={att.filename}
|
download={att.filename}
|
||||||
>
|
>
|
||||||
{#if att.contentType.startsWith("image/")}
|
{#if isImage}
|
||||||
<Image size="14" />
|
<Image size="14" />
|
||||||
{:else}
|
{:else}
|
||||||
<File size="14" />
|
<File size="14" />
|
||||||
@@ -329,9 +401,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="email-body-wrapper">
|
<!-- Email Body -->
|
||||||
|
<div class="email-body-wrapper" class:light-theme={settingsStore.settings.useDarkEmailViewer === false}>
|
||||||
<iframe
|
<iframe
|
||||||
srcdoc={mailState.currentEmail.body + iFrameUtilHTML}
|
srcdoc={mailState.currentEmail.body + iframeUtilHtml}
|
||||||
title="Email Body"
|
title="Email Body"
|
||||||
class="email-iframe"
|
class="email-iframe"
|
||||||
sandbox="allow-same-origin allow-scripts"
|
sandbox="allow-same-origin allow-scripts"
|
||||||
@@ -360,14 +433,17 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make sure internal loader spins if not using class-based animation library like Tailwind */
|
|
||||||
:global(.spinner) {
|
:global(.spinner) {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from {
|
||||||
to { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
@@ -377,8 +453,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--card);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -397,20 +473,20 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--muted);
|
||||||
color: inherit;
|
color: var(--muted-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.09);
|
background: var(--accent);
|
||||||
|
color: var(--accent-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.events {
|
.events {
|
||||||
@@ -428,16 +504,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-header-content {
|
.email-header-content {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--card);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-subject {
|
.email-subject {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: inherit;
|
color: var(--foreground);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
@@ -470,21 +546,21 @@
|
|||||||
|
|
||||||
.email-meta-grid .label {
|
.email-meta-grid .label {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--muted-foreground);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-meta-grid .value {
|
.email-meta-grid .value {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--foreground);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-attachments {
|
.email-attachments {
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -496,7 +572,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--muted-foreground);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,9 +588,9 @@
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--foreground);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -522,18 +598,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.att-btn:hover {
|
.att-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--accent-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.att-btn.image { color: #60a5fa; border-color: rgba(96, 165, 250, 0.3); }
|
.att-btn.image {
|
||||||
.att-btn.image:hover { color: #93c5fd; }
|
color: #60a5fa;
|
||||||
|
border-color: rgba(96, 165, 250, 0.3);
|
||||||
|
}
|
||||||
|
.att-btn.image:hover {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
.att-btn.pdf { color: #f87171; border-color: rgba(248, 113, 113, 0.3); }
|
.att-btn.pdf {
|
||||||
.att-btn.pdf:hover { color: #fca5a5; }
|
color: #f87171;
|
||||||
|
border-color: rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
.att-btn.pdf:hover {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
.att-btn.eml { color: hsl(49, 80%, 49%); border-color: rgba(224, 206, 39, 0.3); }
|
.att-btn.eml {
|
||||||
.att-btn.eml:hover { color: hsl(49, 80%, 65%); }
|
color: hsl(49, 80%, 49%);
|
||||||
|
border-color: rgba(224, 206, 39, 0.3);
|
||||||
|
}
|
||||||
|
.att-btn.eml:hover {
|
||||||
|
color: hsl(49, 80%, 65%);
|
||||||
|
}
|
||||||
|
|
||||||
.att-name {
|
.att-name {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -543,9 +634,15 @@
|
|||||||
|
|
||||||
.email-body-wrapper {
|
.email-body-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: white;
|
background: #0d0d0d;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body-wrapper.light-theme {
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-iframe {
|
.email-iframe {
|
||||||
@@ -581,10 +678,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--muted);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -592,11 +689,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.browse-btn:hover {
|
.browse-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: var(--accent);
|
||||||
border-color: rgba(255, 255, 255, 0.25);
|
border-color: var(--accent-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.browse-btn:disabled, .btn:disabled {
|
.browse-btn:disabled,
|
||||||
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -612,12 +710,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
@@ -626,7 +724,7 @@
|
|||||||
|
|
||||||
.att-empty {
|
.att-empty {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--muted-foreground);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
import { dangerZoneEnabled } from "$lib/stores/app";
|
import { dangerZoneEnabled } from "$lib/stores/app";
|
||||||
import * as m from "$lib/paraglide/messages.js";
|
import * as m from "$lib/paraglide/messages.js";
|
||||||
import { Mail } from "@lucide/svelte/icons";
|
import { Mail, Heart, Info } from "@lucide/svelte/icons";
|
||||||
|
|
||||||
const CLICK_WINDOW_MS = 4000;
|
const CLICK_WINDOW_MS = 4000;
|
||||||
const REQUIRED_CLICKS = 10;
|
const REQUIRED_CLICKS = 10;
|
||||||
@@ -43,14 +43,21 @@
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
id: 2,
|
id: 2,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: m.sidebar_credits(),
|
||||||
|
url: "/credits",
|
||||||
|
icon: Info,
|
||||||
|
disabled: false,
|
||||||
|
id: 3,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Root style="opacity: 0.8;">
|
<Sidebar.Root style="opacity: 0.8;">
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<div
|
<div
|
||||||
class="sidebar-title items-center justify-center p-3 border-b border-white/10"
|
class="sidebar-title items-center justify-center p-3 border-b border-border flex"
|
||||||
style="padding: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); display: flex; justify-content: center;"
|
style="padding: 12px; display: flex; justify-content: center;"
|
||||||
>
|
>
|
||||||
<img src="/appicon.png" alt="Logo" width="64" height="64" />
|
<img src="/appicon.png" alt="Logo" width="64" height="64" />
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
let { onSave, onReset } = $props();
|
let { onSave, onReset } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 rounded-lg border bg-background px-4 py-3 shadow-lg w-full max-w-md">
|
<div class="flex items-center gap-4 rounded-lg border bg-card px-4 py-3 shadow-lg w-full max-w-md">
|
||||||
<span class="text-sm text-muted-foreground flex-1">
|
<span class="text-sm text-muted-foreground flex-1">
|
||||||
{m.settings_unsaved_toast_message()}
|
{m.settings_unsaved_toast_message()}
|
||||||
</span>
|
</span>
|
||||||
@@ -19,9 +19,3 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.bg-background {
|
|
||||||
background-color: oklch(0.205 0 0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||||
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import DialogPortal from "./dialog-portal.svelte";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPortal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Portal {...restProps} />
|
||||||
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||||
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root bind:open {...restProps} />
|
||||||
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
7
frontend/src/lib/components/ui/textarea/index.ts
Normal file
7
frontend/src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./textarea.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Textarea,
|
||||||
|
};
|
||||||
23
frontend/src/lib/components/ui/textarea/textarea.svelte
Normal file
23
frontend/src/lib/components/ui/textarea/textarea.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "textarea",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
></textarea>
|
||||||
@@ -5,6 +5,7 @@ const storedDebug = browser ? sessionStorage.getItem("debugWindowInSettings") ==
|
|||||||
export const dangerZoneEnabled = writable<boolean>(storedDebug);
|
export const dangerZoneEnabled = writable<boolean>(storedDebug);
|
||||||
export const unsavedChanges = writable<boolean>(false);
|
export const unsavedChanges = writable<boolean>(false);
|
||||||
export const sidebarOpen = writable<boolean>(true);
|
export const sidebarOpen = writable<boolean>(true);
|
||||||
|
export const bugReportDialogOpen = writable<boolean>(false);
|
||||||
|
|
||||||
export type AppEvent = {
|
export type AppEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import type { EMLy_GUI_Settings } from "$lib/types";
|
import type { EMLy_GUI_Settings } from "$lib/types";
|
||||||
import { getFromLocalStorage, saveToLocalStorage } from "$lib/utils/localStorageHelper";
|
import { getFromLocalStorage, saveToLocalStorage } from "$lib/utils/localStorageHelper";
|
||||||
|
import { applyTheme, getStoredTheme } from "$lib/utils/theme";
|
||||||
|
import { setLocale } from "$lib/paraglide/runtime";
|
||||||
|
|
||||||
const STORAGE_KEY = "emly_gui_settings";
|
const STORAGE_KEY = "emly_gui_settings";
|
||||||
|
|
||||||
@@ -10,6 +12,14 @@ const defaults: EMLy_GUI_Settings = {
|
|||||||
useBuiltinPDFViewer: true,
|
useBuiltinPDFViewer: true,
|
||||||
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
|
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
|
||||||
enableAttachedDebuggerProtection: true,
|
enableAttachedDebuggerProtection: true,
|
||||||
|
useDarkEmailViewer: true,
|
||||||
|
enableUpdateChecker: false,
|
||||||
|
musicInspirationEnabled: false,
|
||||||
|
reduceMotion: false,
|
||||||
|
theme: "dark",
|
||||||
|
increaseWindowButtonsContrast: false,
|
||||||
|
exportAttachmentFolder: "",
|
||||||
|
useCustomAttachmentDownload: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
@@ -31,6 +41,40 @@ class SettingsStore {
|
|||||||
console.error("Failed to load settings", e);
|
console.error("Failed to load settings", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Check for legacy musicInspirationEnabled key
|
||||||
|
const legacyMusic = getFromLocalStorage("musicInspirationEnabled");
|
||||||
|
if (legacyMusic !== null) {
|
||||||
|
this.settings.musicInspirationEnabled = legacyMusic === "true";
|
||||||
|
localStorage.removeItem("musicInspirationEnabled");
|
||||||
|
this.save(); // Save immediately to persist the migration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync theme from localStorage key used in app.html
|
||||||
|
const storedTheme = getStoredTheme();
|
||||||
|
if (!this.settings.theme) {
|
||||||
|
this.settings.theme = storedTheme;
|
||||||
|
} else if (this.settings.theme !== storedTheme) {
|
||||||
|
// If there's a mismatch, prioritize the theme from emly_theme key
|
||||||
|
this.settings.theme = storedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync useDarkEmailViewer with theme
|
||||||
|
this.settings.useDarkEmailViewer = this.settings.theme === "dark";
|
||||||
|
|
||||||
|
// Apply the theme
|
||||||
|
applyTheme(this.settings.theme);
|
||||||
|
|
||||||
|
// Apply the language
|
||||||
|
if (this.settings.selectedLanguage) {
|
||||||
|
setLocale(this.settings.selectedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save defaults/merged settings to storage if they didn't exist or were updated during load
|
||||||
|
if (!stored) {
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
this.hasHydrated = true;
|
this.hasHydrated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,11 +85,20 @@ class SettingsStore {
|
|||||||
|
|
||||||
update(newSettings: Partial<EMLy_GUI_Settings>) {
|
update(newSettings: Partial<EMLy_GUI_Settings>) {
|
||||||
this.settings = { ...this.settings, ...newSettings };
|
this.settings = { ...this.settings, ...newSettings };
|
||||||
|
|
||||||
|
// Apply theme if it changed
|
||||||
|
if (newSettings.theme && this.settings.theme) {
|
||||||
|
applyTheme(this.settings.theme);
|
||||||
|
}
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.settings = { ...defaults };
|
this.settings = { ...defaults };
|
||||||
|
if (this.settings.theme) {
|
||||||
|
applyTheme(this.settings.theme);
|
||||||
|
}
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
frontend/src/lib/types.d.ts
vendored
32
frontend/src/lib/types.d.ts
vendored
@@ -5,9 +5,35 @@ type SupportedFileTypePreview = "jpg" | "jpeg" | "png";
|
|||||||
interface EMLy_GUI_Settings {
|
interface EMLy_GUI_Settings {
|
||||||
selectedLanguage: SupportedLanguages = "en" | "it";
|
selectedLanguage: SupportedLanguages = "en" | "it";
|
||||||
useBuiltinPreview: boolean;
|
useBuiltinPreview: boolean;
|
||||||
useBuiltinPDFViewer?: boolean;
|
useBuiltinPDFViewer: boolean;
|
||||||
previewFileSupportedTypes?: SupportedFileTypePreview[];
|
previewFileSupportedTypes: SupportedFileTypePreview[];
|
||||||
enableAttachedDebuggerProtection?: boolean;
|
enableAttachedDebuggerProtection: boolean;
|
||||||
|
useDarkEmailViewer?: boolean;
|
||||||
|
enableUpdateChecker?: boolean;
|
||||||
|
musicInspirationEnabled?: boolean;
|
||||||
|
reduceMotion?: boolean;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
increaseWindowButtonsContrast: boolean;
|
||||||
|
exportAttachmentFolder?: string;
|
||||||
|
useCustomAttachmentDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedLanguages = "en" | "it";
|
type SupportedLanguages = "en" | "it";
|
||||||
|
// Plugin System Types
|
||||||
|
interface PluginFormatSupport {
|
||||||
|
extensions: string[];
|
||||||
|
mime_types?: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
capabilities: string[];
|
||||||
|
status: "unloaded" | "loading" | "active" | "error" | "disabled";
|
||||||
|
enabled: boolean;
|
||||||
|
last_error?: string;
|
||||||
|
supported_formats?: PluginFormatSupport[];
|
||||||
|
}
|
||||||
@@ -11,3 +11,17 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
|||||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||||
|
|
||||||
|
export function isBase64(str: string): boolean {
|
||||||
|
if (!str) return false;
|
||||||
|
const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||||
|
return base64Regex.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHtml(str: string): boolean {
|
||||||
|
if (!str) return false;
|
||||||
|
const htmlRegex = /<[^>]+>/;
|
||||||
|
return htmlRegex.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
94
frontend/src/lib/utils/mail/attachment-handlers.ts
Normal file
94
frontend/src/lib/utils/mail/attachment-handlers.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Handlers for opening different attachment types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
OpenPDF,
|
||||||
|
OpenPDFWindow,
|
||||||
|
OpenImage,
|
||||||
|
OpenImageWindow,
|
||||||
|
OpenEMLWindow,
|
||||||
|
} from '$lib/wailsjs/go/main/App';
|
||||||
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
export interface AttachmentHandlerResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a PDF attachment using either built-in or external viewer
|
||||||
|
* @param base64Data - Base64 encoded PDF data
|
||||||
|
* @param filename - Name of the PDF file
|
||||||
|
*/
|
||||||
|
export async function openPDFAttachment(
|
||||||
|
base64Data: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<AttachmentHandlerResult> {
|
||||||
|
try {
|
||||||
|
if (settingsStore.settings.useBuiltinPDFViewer) {
|
||||||
|
await OpenPDFWindow(base64Data, filename);
|
||||||
|
} else {
|
||||||
|
await OpenPDF(base64Data, filename);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Check if PDF is already open
|
||||||
|
if (errorMessage.includes(filename) && errorMessage.includes('already open')) {
|
||||||
|
toast.error(m.mail_pdf_already_open());
|
||||||
|
return { success: false, error: 'already_open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to open PDF:', error);
|
||||||
|
toast.error(m.mail_error_pdf());
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an image attachment using either built-in or external viewer
|
||||||
|
* @param base64Data - Base64 encoded image data
|
||||||
|
* @param filename - Name of the image file
|
||||||
|
*/
|
||||||
|
export async function openImageAttachment(
|
||||||
|
base64Data: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<AttachmentHandlerResult> {
|
||||||
|
try {
|
||||||
|
if (settingsStore.settings.useBuiltinPreview) {
|
||||||
|
await OpenImageWindow(base64Data, filename);
|
||||||
|
} else {
|
||||||
|
await OpenImage(base64Data, filename);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Failed to open image:', error);
|
||||||
|
toast.error(m.mail_error_image());
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an EML attachment in a new EMLy window
|
||||||
|
* @param base64Data - Base64 encoded EML data
|
||||||
|
* @param filename - Name of the EML file
|
||||||
|
*/
|
||||||
|
export async function openEMLAttachment(
|
||||||
|
base64Data: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<AttachmentHandlerResult> {
|
||||||
|
try {
|
||||||
|
await OpenEMLWindow(base64Data, filename);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Failed to open EML:', error);
|
||||||
|
toast.error('Failed to open EML attachment');
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
108
frontend/src/lib/utils/mail/constants.ts
Normal file
108
frontend/src/lib/utils/mail/constants.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Dark theme HTML/CSS injected into the email body iframe
|
||||||
|
* - Applies dark theme matching the main app
|
||||||
|
* - Removes default body margins
|
||||||
|
* - Disables link clicking for security
|
||||||
|
* - Prevents Ctrl+Wheel zoom in iframe
|
||||||
|
* - Styles links, tables, and common email elements for dark mode
|
||||||
|
*/
|
||||||
|
export const IFRAME_UTIL_HTML_DARK = `<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #0d0d0d;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
pointer-events: none !important;
|
||||||
|
cursor: default !important;
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
}
|
||||||
|
td, th {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
pre, code {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
padding: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light theme HTML/CSS injected into the email body iframe (original styling)
|
||||||
|
* - Standard white background
|
||||||
|
* - Removes default body margins
|
||||||
|
* - Disables link clicking for security
|
||||||
|
* - Prevents Ctrl+Wheel zoom in iframe
|
||||||
|
*/
|
||||||
|
export const IFRAME_UTIL_HTML_LIGHT = `<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
pointer-events: none !important;
|
||||||
|
cursor: default !important;
|
||||||
|
color: #2563eb !important;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default iframe HTML (dark theme for backwards compatibility)
|
||||||
|
* @deprecated Use IFRAME_UTIL_HTML_DARK or IFRAME_UTIL_HTML_LIGHT instead
|
||||||
|
*/
|
||||||
|
export const IFRAME_UTIL_HTML = IFRAME_UTIL_HTML_DARK;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported email file extensions
|
||||||
|
*/
|
||||||
|
export const EMAIL_EXTENSIONS = {
|
||||||
|
EML: '.eml',
|
||||||
|
MSG: '.msg',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment content type prefixes/patterns
|
||||||
|
*/
|
||||||
|
export const CONTENT_TYPES = {
|
||||||
|
IMAGE: 'image/',
|
||||||
|
PDF: 'application/pdf',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special PEC (Italian Certified Email) file names
|
||||||
|
*/
|
||||||
|
export const PEC_FILES = {
|
||||||
|
SIGNATURE: '.p7s',
|
||||||
|
CERTIFICATE: 'daticert.xml',
|
||||||
|
} as const;
|
||||||
77
frontend/src/lib/utils/mail/data-utils.ts
Normal file
77
frontend/src/lib/utils/mail/data-utils.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for mail data conversion and processing
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an ArrayBuffer or byte array to a base64 string
|
||||||
|
* @param buffer - The buffer to convert (can be string, array, or ArrayBuffer)
|
||||||
|
* @returns Base64 encoded string
|
||||||
|
*/
|
||||||
|
export function arrayBufferToBase64(buffer: unknown): string {
|
||||||
|
// Already a base64 string
|
||||||
|
if (typeof buffer === 'string') {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array of bytes
|
||||||
|
if (Array.isArray(buffer)) {
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const len = bytes.byteLength;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ArrayBuffer
|
||||||
|
if (buffer instanceof ArrayBuffer) {
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const len = bytes.byteLength;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a data URL for downloading attachments
|
||||||
|
* @param contentType - MIME type of the attachment
|
||||||
|
* @param base64Data - Base64 encoded data
|
||||||
|
* @returns Data URL string
|
||||||
|
*/
|
||||||
|
export function createDataUrl(contentType: string, base64Data: string): string {
|
||||||
|
return `data:${contentType};base64,${base64Data}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string looks like valid base64 content
|
||||||
|
* @param content - String to check
|
||||||
|
* @returns True if the content appears to be base64 encoded
|
||||||
|
*/
|
||||||
|
export function looksLikeBase64(content: string): boolean {
|
||||||
|
const clean = content.replace(/[\s\r\n]+/g, '');
|
||||||
|
return (
|
||||||
|
clean.length > 0 &&
|
||||||
|
clean.length % 4 === 0 &&
|
||||||
|
/^[A-Za-z0-9+/]+=*$/.test(clean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to decode base64 content
|
||||||
|
* @param content - Base64 string to decode
|
||||||
|
* @returns Decoded string or null if decoding fails
|
||||||
|
*/
|
||||||
|
export function tryDecodeBase64(content: string): string | null {
|
||||||
|
try {
|
||||||
|
const clean = content.replace(/[\s\r\n]+/g, '');
|
||||||
|
return window.atob(clean);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
frontend/src/lib/utils/mail/email-loader.ts
Normal file
163
frontend/src/lib/utils/mail/email-loader.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Email loading and processing utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReadEML,
|
||||||
|
ReadMSG,
|
||||||
|
ReadPEC,
|
||||||
|
ShowOpenFileDialog,
|
||||||
|
SetCurrentMailFilePath,
|
||||||
|
ConvertToUTF8,
|
||||||
|
} from '$lib/wailsjs/go/main/App';
|
||||||
|
import type { internal } from '$lib/wailsjs/go/models';
|
||||||
|
import { isBase64, isHtml } from '$lib/utils';
|
||||||
|
import { looksLikeBase64, tryDecodeBase64 } from './data-utils';
|
||||||
|
|
||||||
|
export interface LoadEmailResult {
|
||||||
|
success: boolean;
|
||||||
|
email?: internal.EmailData;
|
||||||
|
filePath?: string;
|
||||||
|
error?: string;
|
||||||
|
cancelled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the email file type from the path
|
||||||
|
*/
|
||||||
|
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
|
||||||
|
const lowerPath = filePath.toLowerCase();
|
||||||
|
if (lowerPath.endsWith('.eml')) return 'eml';
|
||||||
|
if (lowerPath.endsWith('.msg')) return 'msg';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file path is a valid email file
|
||||||
|
*/
|
||||||
|
export function isEmailFile(filePath: string): boolean {
|
||||||
|
return getEmailFileType(filePath) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an email from a file path
|
||||||
|
* @param filePath - Path to the email file
|
||||||
|
* @returns LoadEmailResult with the email data or error
|
||||||
|
*/
|
||||||
|
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
|
||||||
|
const fileType = getEmailFileType(filePath);
|
||||||
|
|
||||||
|
if (!fileType) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid file type. Only .eml and .msg files are supported.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let email: internal.EmailData;
|
||||||
|
|
||||||
|
if (fileType === 'msg') {
|
||||||
|
email = await ReadMSG(filePath, true);
|
||||||
|
} else {
|
||||||
|
// Try PEC first, fall back to regular EML
|
||||||
|
try {
|
||||||
|
email = await ReadPEC(filePath);
|
||||||
|
} catch {
|
||||||
|
email = await ReadEML(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process body if needed (decode base64)
|
||||||
|
if (email?.body) {
|
||||||
|
const trimmed = email.body.trim();
|
||||||
|
if (looksLikeBase64(trimmed)) {
|
||||||
|
const decoded = tryDecodeBase64(trimmed);
|
||||||
|
if (decoded) {
|
||||||
|
email.body = decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
email,
|
||||||
|
filePath,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Failed to load email:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a file dialog and loads the selected email
|
||||||
|
* @returns LoadEmailResult with the email data or error
|
||||||
|
*/
|
||||||
|
export async function openAndLoadEmail(): Promise<LoadEmailResult> {
|
||||||
|
try {
|
||||||
|
const filePath = await ShowOpenFileDialog();
|
||||||
|
|
||||||
|
if (!filePath || filePath.length === 0) {
|
||||||
|
return { success: false, cancelled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await loadEmailFromPath(filePath);
|
||||||
|
|
||||||
|
if (result.success && result.email) {
|
||||||
|
// Track the current mail file path for bug reports
|
||||||
|
await SetCurrentMailFilePath(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Failed to open email file:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes and fixes the email body content
|
||||||
|
* - Decodes base64 content if needed
|
||||||
|
* - Fixes character encoding issues
|
||||||
|
* @param body - The raw email body
|
||||||
|
* @returns Processed body content
|
||||||
|
*/
|
||||||
|
export async function processEmailBody(body: string): Promise<string> {
|
||||||
|
if (!body) return body;
|
||||||
|
|
||||||
|
let content = body;
|
||||||
|
|
||||||
|
// 1. Try to decode if not HTML
|
||||||
|
if (!isHtml(content)) {
|
||||||
|
const clean = content.replace(/[\s\r\n]+/g, '');
|
||||||
|
if (isBase64(clean)) {
|
||||||
|
const decoded = tryDecodeBase64(clean);
|
||||||
|
if (decoded) {
|
||||||
|
content = decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If it is HTML (original or decoded), try to fix encoding
|
||||||
|
if (isHtml(content)) {
|
||||||
|
try {
|
||||||
|
const fixed = await ConvertToUTF8(content);
|
||||||
|
if (fixed) {
|
||||||
|
content = fixed;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fix encoding:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
39
frontend/src/lib/utils/mail/index.ts
Normal file
39
frontend/src/lib/utils/mail/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Mail utilities barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export {
|
||||||
|
IFRAME_UTIL_HTML,
|
||||||
|
IFRAME_UTIL_HTML_DARK,
|
||||||
|
IFRAME_UTIL_HTML_LIGHT,
|
||||||
|
EMAIL_EXTENSIONS,
|
||||||
|
CONTENT_TYPES,
|
||||||
|
PEC_FILES,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
// Data utilities
|
||||||
|
export {
|
||||||
|
arrayBufferToBase64,
|
||||||
|
createDataUrl,
|
||||||
|
looksLikeBase64,
|
||||||
|
tryDecodeBase64,
|
||||||
|
} from './data-utils';
|
||||||
|
|
||||||
|
// Attachment handlers
|
||||||
|
export {
|
||||||
|
openPDFAttachment,
|
||||||
|
openImageAttachment,
|
||||||
|
openEMLAttachment,
|
||||||
|
type AttachmentHandlerResult,
|
||||||
|
} from './attachment-handlers';
|
||||||
|
|
||||||
|
// Email loader
|
||||||
|
export {
|
||||||
|
getEmailFileType,
|
||||||
|
isEmailFile,
|
||||||
|
loadEmailFromPath,
|
||||||
|
openAndLoadEmail,
|
||||||
|
processEmailBody,
|
||||||
|
type LoadEmailResult,
|
||||||
|
} from './email-loader';
|
||||||
45
frontend/src/lib/utils/theme.ts
Normal file
45
frontend/src/lib/utils/theme.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
|
const THEME_KEY = "emly_theme";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the theme to the document element and saves it to localStorage
|
||||||
|
*/
|
||||||
|
export function applyTheme(theme: Theme) {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
document.documentElement.classList.toggle("dark", isDark);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save theme to localStorage:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current theme from localStorage or returns the default
|
||||||
|
*/
|
||||||
|
export function getStoredTheme(): Theme {
|
||||||
|
if (!browser) return "light";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
|
return stored === "light" || stored === "dark" ? stored : "light";
|
||||||
|
} catch {
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles between light and dark theme
|
||||||
|
*/
|
||||||
|
export function toggleTheme(): Theme {
|
||||||
|
const current = getStoredTheme();
|
||||||
|
const newTheme: Theme = current === "dark" ? "light" : "dark";
|
||||||
|
applyTheme(newTheme);
|
||||||
|
return newTheme;
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
import { page, navigating } from "$app/state";
|
import { page, navigating } from "$app/state";
|
||||||
import { beforeNavigate, goto } from "$app/navigation";
|
import { beforeNavigate, goto } from "$app/navigation";
|
||||||
import { locales, localizeHref } from "$lib/paraglide/runtime";
|
import { locales, localizeHref } from "$lib/paraglide/runtime";
|
||||||
import { unsavedChanges, sidebarOpen } from "$lib/stores/app";
|
import { unsavedChanges, sidebarOpen, bugReportDialogOpen, dangerZoneEnabled } from "$lib/stores/app";
|
||||||
import "../layout.css";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import * as m from "$lib/paraglide/messages.js";
|
import * as m from "$lib/paraglide/messages.js";
|
||||||
import type { utils } from "$lib/wailsjs/go/models";
|
import type { utils } from "$lib/wailsjs/go/models";
|
||||||
@@ -17,10 +16,15 @@
|
|||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
House,
|
House,
|
||||||
Settings,
|
Settings,
|
||||||
|
Bug,
|
||||||
|
Heart,
|
||||||
|
Info,
|
||||||
|
Music
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import BugReportDialog from "$lib/components/BugReportDialog.svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WindowMinimise,
|
WindowMinimise,
|
||||||
@@ -28,6 +32,8 @@
|
|||||||
WindowUnmaximise,
|
WindowUnmaximise,
|
||||||
WindowIsMaximised,
|
WindowIsMaximised,
|
||||||
Quit,
|
Quit,
|
||||||
|
EventsOn,
|
||||||
|
EventsOff,
|
||||||
} from "$lib/wailsjs/runtime/runtime";
|
} from "$lib/wailsjs/runtime/runtime";
|
||||||
import { RefreshCcwDot } from "@lucide/svelte";
|
import { RefreshCcwDot } from "@lucide/svelte";
|
||||||
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App";
|
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App";
|
||||||
@@ -71,6 +77,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if(dev) dangerZoneEnabled.set(true);
|
||||||
if (browser && isDebbugerProtectionOn) {
|
if (browser && isDebbugerProtectionOn) {
|
||||||
detectDebugging();
|
detectDebugging();
|
||||||
setInterval(detectDebugging, 1000);
|
setInterval(detectDebugging, 1000);
|
||||||
@@ -126,6 +133,26 @@
|
|||||||
applyTheme(stored === "light" ? "light" : "dark");
|
applyTheme(stored === "light" ? "light" : "dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for automatic update notifications
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
EventsOn("update:available", (status: any) => {
|
||||||
|
toast.info(`Update ${status.availableVersion} is available!`, {
|
||||||
|
description: "Go to Settings to download and install",
|
||||||
|
duration: 10000,
|
||||||
|
action: {
|
||||||
|
label: "Open Settings",
|
||||||
|
onClick: () => goto("/settings"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
EventsOff("update:available");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
syncMaxState();
|
syncMaxState();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -143,8 +170,10 @@
|
|||||||
{#if dev}
|
{#if dev}
|
||||||
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
|
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
|
||||||
<debug>(DEBUG BUILD)</debug>
|
<debug>(DEBUG BUILD)</debug>
|
||||||
{:else}
|
{:else if versionInfo?.EMLy.GUIReleaseChannel !== "stable"}
|
||||||
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
|
v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy.GUIReleaseChannel}
|
||||||
|
{:else}
|
||||||
|
v{versionInfo?.EMLy.GUISemver}
|
||||||
{/if}
|
{/if}
|
||||||
</version>
|
</version>
|
||||||
{#if versionInfo}
|
{#if versionInfo}
|
||||||
@@ -167,7 +196,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls" class:high-contrast={settingsStore.settings.increaseWindowButtonsContrast}>
|
||||||
<button class="btn" onclick={minimize}>─</button>
|
<button class="btn" onclick={minimize}>─</button>
|
||||||
|
|
||||||
<button class="btn" onclick={toggleMaximize}>
|
<button class="btn" onclick={toggleMaximize}>
|
||||||
@@ -182,11 +211,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content" class:reduce-motion={settingsStore.settings.reduceMotion}>
|
||||||
<Sidebar.Provider>
|
<Sidebar.Provider open={$sidebarOpen} onOpenChange={(v) => sidebarOpen.set(v)}>
|
||||||
{#if $sidebarOpen}
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
{/if}
|
|
||||||
<main>
|
<main>
|
||||||
<!-- <Sidebar.Trigger /> -->
|
<!-- <Sidebar.Trigger /> -->
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -245,6 +272,26 @@
|
|||||||
style="cursor: pointer; opacity: 0.7;"
|
style="cursor: pointer; opacity: 0.7;"
|
||||||
class="hover:opacity-100 transition-opacity"
|
class="hover:opacity-100 transition-opacity"
|
||||||
/>
|
/>
|
||||||
|
<Info
|
||||||
|
size="16"
|
||||||
|
onclick={() => {
|
||||||
|
if (page.url.pathname !== "/credits" && page.url.pathname !== "/credits/")
|
||||||
|
goto("/credits");
|
||||||
|
}}
|
||||||
|
style="cursor: pointer; opacity: 0.7;"
|
||||||
|
class="hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
{#if settingsStore.settings.musicInspirationEnabled}
|
||||||
|
<Music
|
||||||
|
size="16"
|
||||||
|
onclick={() => {
|
||||||
|
if (page.url.pathname !== "/inspiration" && page.url.pathname !== "/inspiration/")
|
||||||
|
goto("/inspiration");
|
||||||
|
}}
|
||||||
|
style="cursor: pointer; opacity: 0.7;"
|
||||||
|
class="hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
data-sveltekit-reload
|
data-sveltekit-reload
|
||||||
@@ -256,6 +303,20 @@
|
|||||||
>
|
>
|
||||||
<RefreshCcwDot />
|
<RefreshCcwDot />
|
||||||
</a>
|
</a>
|
||||||
|
<!-- svelte-ignore a11y_invalid_attribute -->
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
|
||||||
|
style="text-decoration: none; height: 24px; font-size: 12px; padding: 0 8px;"
|
||||||
|
aria-label={m.settings_danger_reload_button()}
|
||||||
|
title={m.settings_danger_reload_button() + " app"}
|
||||||
|
onclick={() => {
|
||||||
|
$bugReportDialogOpen = !$bugReportDialogOpen;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bug />
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:none">
|
<div style="display:none">
|
||||||
@@ -265,13 +326,13 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BugReportDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: oklch(0 0 0);
|
|
||||||
color: #eaeaea;
|
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,11 +341,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar {
|
.titlebar {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: oklch(0 0 0);
|
background: var(--background);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -294,11 +357,12 @@
|
|||||||
flex: 0 0 32px;
|
flex: 0 0 32px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footerbar {
|
.footerbar {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: oklch(0 0 0);
|
background: var(--background);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -306,28 +370,28 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex: 0 0 32px;
|
flex: 0 0 32px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
color: gray;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title bold {
|
.title bold {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title version {
|
.title version {
|
||||||
color: rgb(228, 221, 221);
|
color: var(--muted-foreground);
|
||||||
opacity: 0.4;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title version debug {
|
.title version debug {
|
||||||
color: #e11d48;
|
color: var(--destructive);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -344,8 +408,9 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
background-color: #111;
|
background-color: var(--popover);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
color: var(--popover-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -375,16 +440,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-item .label {
|
.tooltip-item .label {
|
||||||
color: #9ca3af;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-item .value {
|
.tooltip-item .value {
|
||||||
color: #f3f4f6;
|
color: var(--foreground);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-item .channel {
|
.tooltip-item .channel {
|
||||||
color: #6b7280;
|
color: var(--muted-foreground);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,25 +459,29 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls.high-contrast {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
@@ -423,7 +492,7 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: oklch(0 0 0);
|
background: var(--background);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -450,6 +519,12 @@
|
|||||||
max-height: 100% !important;
|
max-height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable sidebar transitions when reduce-motion is active */
|
||||||
|
:global(.content.reduce-motion [data-slot="sidebar-gap"]),
|
||||||
|
:global(.content.reduce-motion [data-slot="sidebar-container"]) {
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -460,12 +535,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
@@ -481,14 +556,14 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: oklch(0 0 0);
|
background: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid var(--border);
|
||||||
border-top-color: rgba(255, 255, 255, 0.8);
|
border-top-color: var(--primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<section class="center" aria-label="Overview">
|
<section class="center" aria-label="Overview" id="main-content-app">
|
||||||
<MailViewer />
|
<MailViewer />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,26 +36,4 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
308
frontend/src/routes/(app)/credits/+page.svelte
Normal file
308
frontend/src/routes/(app)/credits/+page.svelte
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto, preloadData } from "$app/navigation";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Card from "$lib/components/ui/card";
|
||||||
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
|
import { ChevronLeft, Heart, Code, Package, Globe, Github, Mail, BadgeInfo, Music, PartyPopper } from "@lucide/svelte";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
import { OpenURLInBrowser } from "$lib/wailsjs/go/main/App";
|
||||||
|
import { dangerZoneEnabled } from "$lib/stores/app";
|
||||||
|
import { settingsStore } from "$lib/stores/settings.svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let config = $derived(data.config);
|
||||||
|
|
||||||
|
// Easter Egg State
|
||||||
|
const REQUIRED_CLICKS = 10;
|
||||||
|
const CLICK_WINDOW_MS = 4000;
|
||||||
|
let recentClicks: number[] = [];
|
||||||
|
|
||||||
|
function handleEasterEggClick(_event: MouseEvent) {
|
||||||
|
console.log("clicked")
|
||||||
|
// Only proceed if danger zone is already enabled
|
||||||
|
if (!$dangerZoneEnabled) return;
|
||||||
|
|
||||||
|
// If already enabled, do nothing to avoid spam
|
||||||
|
if (settingsStore.settings.musicInspirationEnabled) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Clean old clicks
|
||||||
|
recentClicks = recentClicks.filter(t => now - t < CLICK_WINDOW_MS);
|
||||||
|
recentClicks.push(now);
|
||||||
|
|
||||||
|
if (recentClicks.length >= REQUIRED_CLICKS) {
|
||||||
|
recentClicks = [];
|
||||||
|
try {
|
||||||
|
settingsStore.update({ musicInspirationEnabled: true });
|
||||||
|
preloadData("/inspiration");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to enable music inspiration:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open external URL in default browser
|
||||||
|
async function openUrl(url: string) {
|
||||||
|
try {
|
||||||
|
await OpenURLInBrowser(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open URL:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravatar URL helper - uses MD5 hash of email
|
||||||
|
// Pre-computed hashes for known emails
|
||||||
|
const gravatarUrls: Record<string, string> = {
|
||||||
|
"f.fois@3git.eu": "https://gravatar.com/avatar/6a2b6cfd8ab2c36ac3eace1faa871f79084b64ad08fb6e490f050e71ee1b599c",
|
||||||
|
"iraci.matteo@gmail.com": "https://gravatar.com/avatar/0c17334ae886eb44b670d226e7de32ac082b9c85925ce4ed4c12239d9d8351f2",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Technology stack
|
||||||
|
const technologies = [
|
||||||
|
{ name: "Wails v2", description: m.credits_tech_wails(), url: "https://wails.io" },
|
||||||
|
{ name: "Go", description: m.credits_tech_go(), url: "https://go.dev" },
|
||||||
|
{ name: "SvelteKit", description: m.credits_tech_sveltekit(), url: "https://kit.svelte.dev" },
|
||||||
|
{ name: "Svelte 5", description: m.credits_tech_svelte(), url: "https://svelte.dev" },
|
||||||
|
{ name: "TypeScript", description: m.credits_tech_typescript(), url: "https://www.typescriptlang.org" },
|
||||||
|
{ name: "Tailwind CSS", description: m.credits_tech_tailwind(), url: "https://tailwindcss.com" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Libraries and packages
|
||||||
|
const libraries = [
|
||||||
|
{ name: "shadcn-svelte", description: m.credits_lib_shadcn(), url: "https://www.shadcn-svelte.com" },
|
||||||
|
{ name: "Lucide Icons", description: m.credits_lib_lucide(), url: "https://lucide.dev" },
|
||||||
|
{ name: "ParaglideJS", description: m.credits_lib_paraglide(), url: "https://inlang.com/m/gerre34r/library-inlang-paraglideJs" },
|
||||||
|
{ name: "svelte-sonner", description: m.credits_lib_sonner(), url: "https://svelte-sonner.vercel.app" },
|
||||||
|
{ name: "PDF.js", description: m.credits_lib_pdfjs(), url: "https://mozilla.github.io/pdf.js" },
|
||||||
|
{ name: "DOMPurify", description: m.credits_lib_dompurify(), url: "https://github.com/cure53/DOMPurify" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Team / Contributors
|
||||||
|
const team = [
|
||||||
|
{
|
||||||
|
username: "FOISX",
|
||||||
|
name: "Flavio Fois",
|
||||||
|
role: m.credits_role_lead_developer(),
|
||||||
|
description: m.credits_foisx_desc(),
|
||||||
|
email: "f.fois@3git.eu",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Special thanks
|
||||||
|
const specialThanks = [
|
||||||
|
{
|
||||||
|
name: "Laky64",
|
||||||
|
contribution: m.credits_laky64_desc(),
|
||||||
|
email: "iraci.matteo@gmail.com",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-[calc(100vh-1rem)] bg-gradient-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"
|
||||||
|
>
|
||||||
|
<header class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1
|
||||||
|
class="text-balance text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||||
|
>
|
||||||
|
{m.credits_title()}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
{m.credits_description()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => goto("/")}
|
||||||
|
><ChevronLeft class="size-4" /> {m.settings_back()}</Button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- About Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<BadgeInfo class="size-5" />
|
||||||
|
{m.credits_about_title()}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
<span style="font-style: italic">{m.credits_about_description()}</span>
|
||||||
|
<span>{m.credits_about_description_2()}</span>
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<img src="/appicon.png" alt="EMLy Logo" width="64" height="64" class="rounded-lg" />
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-lg">EMLy</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">{m.credits_app_tagline()}</p>
|
||||||
|
{#if config}
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
v{config.GUISemver} ({config.GUIReleaseChannel})
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{m.credits_app_description()}
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Team Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Code class="size-5" />
|
||||||
|
{m.credits_team_title()}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>{m.credits_team_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
{#each team as member}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-4 rounded-lg border bg-card p-4 relative overflow-hidden"
|
||||||
|
onclick={member.username === "FOISX" ? handleEasterEggClick : undefined}
|
||||||
|
>
|
||||||
|
<!-- Selectable trigger area overlay for cleaner interaction -->
|
||||||
|
{#if member.username === "FOISX" && $dangerZoneEnabled && !settingsStore.settings.musicInspirationEnabled}
|
||||||
|
<div class="absolute inset-0 cursor-pointer z-10 opacity-0 bg-transparent"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={gravatarUrls[member.email]}
|
||||||
|
alt={member.name}
|
||||||
|
class="h-14 w-14 rounded-full border-2 border-primary/20 z-0 select-none"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 z-0">
|
||||||
|
<div class="font-medium">{member.username} ({member.name})</div>
|
||||||
|
<div class="text-sm text-primary/80">{member.role}</div>
|
||||||
|
<div class="text-sm text-muted-foreground mt-1">{member.description}</div>
|
||||||
|
<a
|
||||||
|
href="mailto:{member.email}"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-primary mt-2 transition-colors relative z-20"
|
||||||
|
>
|
||||||
|
<Mail class="size-3" />
|
||||||
|
{member.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="text-center text-sm text-muted-foreground pt-2">
|
||||||
|
<span class="flex items-center justify-center gap-1">
|
||||||
|
{m.credits_made_with()} <Heart class="size-3 text-red-500 inline" /> {m.credits_at_3git()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Special Thanks Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Heart class="size-5 text-pink-500" />
|
||||||
|
{m.credits_special_thanks_title()}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>{m.credits_special_thanks_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each specialThanks as contributor}
|
||||||
|
<div class="flex items-center gap-3 rounded-lg border bg-card p-3">
|
||||||
|
<img
|
||||||
|
src={gravatarUrls[contributor.email]}
|
||||||
|
alt={contributor.name}
|
||||||
|
class="h-10 w-10 rounded-full border-2 border-primary/20"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="font-medium text-sm">{contributor.name}</span>
|
||||||
|
-
|
||||||
|
<span class="text-muted-foreground text-sm">{contributor.contribution}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Technologies Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Globe class="size-5" />
|
||||||
|
{m.credits_tech_title()}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>{m.credits_tech_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
{#each technologies as tech}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => openUrl(tech.url)}
|
||||||
|
class="flex items-start gap-3 rounded-lg border bg-card p-3 transition-colors hover:bg-accent/50 cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-sm">{tech.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{tech.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Libraries Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Package class="size-5" />
|
||||||
|
{m.credits_libraries_title()}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>{m.credits_libraries_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
{#each libraries as lib}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => openUrl(lib.url)}
|
||||||
|
class="flex items-start gap-3 rounded-lg border bg-card p-3 transition-colors hover:bg-accent/50 cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-sm">{lib.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{lib.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- License Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Github class="size-5" />
|
||||||
|
{m.credits_license_title()}
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{m.credits_license_text()}
|
||||||
|
</p>
|
||||||
|
<Separator class="my-4" />
|
||||||
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
|
© 2025-{new Date().getFullYear()} 3gIT. {m.credits_copyright()}
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
19
frontend/src/routes/(app)/credits/+page.ts
Normal file
19
frontend/src/routes/(app)/credits/+page.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { GetConfig } from "$lib/wailsjs/go/main/App";
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
if (!browser) return { config: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configRoot = await GetConfig();
|
||||||
|
return {
|
||||||
|
config: configRoot.EMLy
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load config for credits", e);
|
||||||
|
return {
|
||||||
|
config: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}) satisfies PageLoad;
|
||||||
125
frontend/src/routes/(app)/inspiration/+page.svelte
Normal file
125
frontend/src/routes/(app)/inspiration/+page.svelte
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Card from "$lib/components/ui/card";
|
||||||
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
|
import { ChevronLeft, Music, ExternalLink } from "@lucide/svelte";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
import { OpenURLInBrowser } from "$lib/wailsjs/go/main/App";
|
||||||
|
import type { SpotifyTrack } from "./+page";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let config = $derived(data.config);
|
||||||
|
let tracks: SpotifyTrack[] = $derived(data.tracks ?? []);
|
||||||
|
|
||||||
|
// Open external URL in default browser
|
||||||
|
async function openUrl(url: string) {
|
||||||
|
try {
|
||||||
|
await OpenURLInBrowser(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open URL:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-[calc(100vh-1rem)] bg-gradient-to-b from-background to-muted/30">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex max-w-4xl flex-col gap-4 px-4 py-6 sm:px-6 sm:py-10 opacity-80"
|
||||||
|
>
|
||||||
|
<header class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1
|
||||||
|
class="text-balance text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||||
|
>
|
||||||
|
Musical Inspiration
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
This project was mainly coded to the following tracks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => goto("/")}
|
||||||
|
>
|
||||||
|
<ChevronLeft class="size-4" /> Back
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Separator class="my-2" />
|
||||||
|
|
||||||
|
<!-- Spotify Embeds -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Music class="size-5" />
|
||||||
|
FOISX's Soundtrack
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
The albums and tracks that fueled the development of EMLy
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
|
||||||
|
{#each tracks as track}
|
||||||
|
<div class="group relative">
|
||||||
|
<div class="overflow-hidden rounded-lg bg-muted">
|
||||||
|
{#if track.embedHtml}
|
||||||
|
{@html track.embedHtml}
|
||||||
|
{:else}
|
||||||
|
<iframe
|
||||||
|
src={track.embedUrl}
|
||||||
|
width="100%"
|
||||||
|
height="352"
|
||||||
|
frameborder="0"
|
||||||
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||||
|
loading="lazy"
|
||||||
|
title={`${track.artist} - ${track.name}`}
|
||||||
|
class="rounded-lg"
|
||||||
|
></iframe>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Fun fact -->
|
||||||
|
<Card.Root class="border-primary/20 bg-primary/5">
|
||||||
|
<Card.Content class="">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Music class="size-5 text-primary mt-0.5 shrink-0" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium">The Soundtrack</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
These are just a small sample of what helped inspire the project.
|
||||||
|
Although they represent a wide variety of emotions, themes and genres, some exploring deep meanings
|
||||||
|
of betrayal, personal struggles, and introspection, they provided solace and strength to the main developer
|
||||||
|
during challenging times.
|
||||||
|
<br/>
|
||||||
|
Music has a unique way of transforming pain into creative energy..
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Footer note -->
|
||||||
|
<div class="text-center text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Made with
|
||||||
|
<Music class="inline-block size-3 mx-1" />
|
||||||
|
and
|
||||||
|
<span class="text-red-500">♥</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
iframe {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
frontend/src/routes/(app)/inspiration/+page.ts
Normal file
109
frontend/src/routes/(app)/inspiration/+page.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { GetConfig } from "$lib/wailsjs/go/main/App";
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface SpotifyTrack {
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
spotifyUrl: string;
|
||||||
|
embedUrl: string;
|
||||||
|
embedHtml?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Music that inspired this project
|
||||||
|
const inspirationTracks: SpotifyTrack[] = [
|
||||||
|
{
|
||||||
|
name: "Strays",
|
||||||
|
artist: "Ivycomb, Stephanafro",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/1aXATIo34e5ZZvFcavePpy",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/1aXATIo34e5ZZvFcavePpy?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Headlock",
|
||||||
|
artist: "Imogen Heap",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/63Pi2NAx5yCgeLhCTOrEou",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/63Pi2NAx5yCgeLhCTOrEou?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "I Still Create",
|
||||||
|
artist: "YonKaGor",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/0IqTgwWU2syiSYbdBEromt",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/0IqTgwWU2syiSYbdBEromt?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Raised by Aliens",
|
||||||
|
artist: "ivy comb, Stephanafro",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/5ezyCaoc5XiVdkpRYWeyG5",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/5ezyCaoc5XiVdkpRYWeyG5?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VENOMOUS",
|
||||||
|
artist: "passengerprincess",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/4rPKifkzrhIYAsl1njwmjd",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/4rPKifkzrhIYAsl1njwmjd?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PREY",
|
||||||
|
artist: "passengerprincess",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/510m8qwFCHgzi4zsQnjLUX",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/510m8qwFCHgzi4zsQnjLUX?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dracula",
|
||||||
|
artist: "Tame Impala",
|
||||||
|
spotifyUrl: "https://open.spotify.com/track/1NXbNEAcPvY5G1xvfN57aA",
|
||||||
|
embedUrl: "https://open.spotify.com/embed/track/1NXbNEAcPvY5G1xvfN57aA?utm_source=generator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Electric love",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchEmbedHtml(track: SpotifyTrack, fetch: typeof globalThis.fetch): Promise<SpotifyTrack> {
|
||||||
|
try {
|
||||||
|
const oEmbedUrl = `https://open.spotify.com/oembed?url=${encodeURIComponent(track.spotifyUrl)}`;
|
||||||
|
const res = await fetch(oEmbedUrl);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
return { ...track, embedHtml: data.html };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to fetch oEmbed for ${track.spotifyUrl}:`, e);
|
||||||
|
}
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load = (async ({fetch}) => {
|
||||||
|
if (!browser) return { config: null, tracks: inspirationTracks };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [configRoot, ...tracks] = await Promise.all([
|
||||||
|
GetConfig(),
|
||||||
|
...inspirationTracks.map(t => fetchEmbedHtml(t, fetch))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: configRoot.EMLy,
|
||||||
|
tracks
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load data for inspiration", e);
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
tracks: inspirationTracks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}) satisfies PageLoad;
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { Separator } from "$lib/components/ui/separator";
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
import { Switch } from "$lib/components/ui/switch";
|
import { Switch } from "$lib/components/ui/switch";
|
||||||
import { ChevronLeft, Flame } 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 type { EMLy_GUI_Settings } from "$lib/types";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { It, Us } from "svelte-flags";
|
import { It, Us } from "svelte-flags";
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
import { setLocale } from "$lib/paraglide/runtime";
|
import { setLocale } from "$lib/paraglide/runtime";
|
||||||
import { mailState } from "$lib/stores/mail-state.svelte.js";
|
import { mailState } from "$lib/stores/mail-state.svelte.js";
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
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 { data } = $props();
|
||||||
let config = $derived(data.config);
|
let config = $derived(data.config);
|
||||||
@@ -37,6 +40,13 @@
|
|||||||
useBuiltinPDFViewer: true,
|
useBuiltinPDFViewer: true,
|
||||||
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
|
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
|
||||||
enableAttachedDebuggerProtection: true,
|
enableAttachedDebuggerProtection: true,
|
||||||
|
useDarkEmailViewer: true,
|
||||||
|
enableUpdateChecker: false,
|
||||||
|
reduceMotion: false,
|
||||||
|
theme: "dark",
|
||||||
|
increaseWindowButtonsContrast: false,
|
||||||
|
exportAttachmentFolder: "",
|
||||||
|
useCustomAttachmentDownload: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setLanguage(
|
async function setLanguage(
|
||||||
@@ -67,6 +77,16 @@
|
|||||||
s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [],
|
s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [],
|
||||||
enableAttachedDebuggerProtection:
|
enableAttachedDebuggerProtection:
|
||||||
s.enableAttachedDebuggerProtection ?? defaults.enableAttachedDebuggerProtection ?? true,
|
s.enableAttachedDebuggerProtection ?? defaults.enableAttachedDebuggerProtection ?? true,
|
||||||
|
useDarkEmailViewer:
|
||||||
|
s.useDarkEmailViewer ?? defaults.useDarkEmailViewer ?? true,
|
||||||
|
enableUpdateChecker: runningInDevMode
|
||||||
|
? false
|
||||||
|
: (s.enableUpdateChecker ?? defaults.enableUpdateChecker ?? true),
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +96,13 @@
|
|||||||
!!a.useBuiltinPreview === !!b.useBuiltinPreview &&
|
!!a.useBuiltinPreview === !!b.useBuiltinPreview &&
|
||||||
!!a.useBuiltinPDFViewer === !!b.useBuiltinPDFViewer &&
|
!!a.useBuiltinPDFViewer === !!b.useBuiltinPDFViewer &&
|
||||||
!!a.enableAttachedDebuggerProtection === !!b.enableAttachedDebuggerProtection &&
|
!!a.enableAttachedDebuggerProtection === !!b.enableAttachedDebuggerProtection &&
|
||||||
|
!!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()) ===
|
JSON.stringify(a.previewFileSupportedTypes?.sort()) ===
|
||||||
JSON.stringify(b.previewFileSupportedTypes?.sort())
|
JSON.stringify(b.previewFileSupportedTypes?.sort())
|
||||||
);
|
);
|
||||||
@@ -122,6 +149,7 @@
|
|||||||
sessionStorage.removeItem("debugWindowInSettings");
|
sessionStorage.removeItem("debugWindowInSettings");
|
||||||
dangerZoneEnabled.set(false);
|
dangerZoneEnabled.set(false);
|
||||||
LogDebug("Reset danger zone setting to false.");
|
LogDebug("Reset danger zone setting to false.");
|
||||||
|
await SetExportAttachmentFolder("");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(m.settings_toast_reset_failed());
|
toast.error(m.settings_toast_reset_failed());
|
||||||
return;
|
return;
|
||||||
@@ -173,9 +201,204 @@
|
|||||||
previousDangerZoneEnabled = $dangerZoneEnabled;
|
previousDangerZoneEnabled = $dangerZoneEnabled;
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync update checker setting to backend config.ini
|
||||||
|
$effect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!browser) return;
|
||||||
|
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
|
||||||
|
if (form.enableUpdateChecker !== previousUpdateCheckerEnabled) {
|
||||||
|
try {
|
||||||
|
await SetUpdateCheckerEnabled(form.enableUpdateChecker ?? true);
|
||||||
|
LogDebug(`Update checker ${form.enableUpdateChecker ? 'enabled' : 'disabled'}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to sync update checker setting:', err);
|
||||||
|
}
|
||||||
|
previousUpdateCheckerEnabled = form.enableUpdateChecker;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync theme with email viewer dark mode
|
||||||
|
let previousTheme = $state<string | undefined>(undefined);
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
if (previousTheme !== undefined && form.theme !== previousTheme) {
|
||||||
|
form.useDarkEmailViewer = form.theme === "dark";
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
const result = await ExportSettings(settingsJSON);
|
||||||
|
if (result) {
|
||||||
|
toast.success(m.settings_export_success());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to export settings:", err);
|
||||||
|
toast.error(m.settings_export_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSettings() {
|
||||||
|
try {
|
||||||
|
const result = await ImportSettings();
|
||||||
|
if (result) {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(result) as EMLy_GUI_Settings;
|
||||||
|
// Validate that it looks like a valid settings object
|
||||||
|
if (typeof imported === 'object' && imported !== null) {
|
||||||
|
form = normalizeSettings(imported);
|
||||||
|
toast.success(m.settings_import_success());
|
||||||
|
} else {
|
||||||
|
toast.error(m.settings_import_invalid());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(m.settings_import_invalid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to import settings:", err);
|
||||||
|
toast.error(m.settings_import_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update System State
|
||||||
|
type UpdateStatus = {
|
||||||
|
currentVersion: string;
|
||||||
|
availableVersion: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
checking: boolean;
|
||||||
|
downloading: boolean;
|
||||||
|
downloadProgress: number;
|
||||||
|
ready: boolean;
|
||||||
|
installerPath: string;
|
||||||
|
errorMessage: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
lastCheckTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let updateStatus = $state<UpdateStatus>({
|
||||||
|
currentVersion: "Unknown",
|
||||||
|
availableVersion: "",
|
||||||
|
updateAvailable: false,
|
||||||
|
checking: false,
|
||||||
|
downloading: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
ready: false,
|
||||||
|
installerPath: "",
|
||||||
|
errorMessage: "",
|
||||||
|
lastCheckTime: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync current version from config
|
||||||
|
$effect(() => {
|
||||||
|
if (config?.GUISemver) {
|
||||||
|
updateStatus.currentVersion = config.GUISemver;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
try {
|
||||||
|
const status = await CheckForUpdates();
|
||||||
|
updateStatus = status;
|
||||||
|
|
||||||
|
if (status.updateAvailable) {
|
||||||
|
toast.success(m.settings_toast_update_available({ version: status.availableVersion }));
|
||||||
|
} else if (!status.errorMessage) {
|
||||||
|
toast.info(m.settings_toast_latest_version());
|
||||||
|
} else {
|
||||||
|
toast.error(status.errorMessage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check for updates:", err);
|
||||||
|
toast.error(m.settings_toast_check_failed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadUpdate() {
|
||||||
|
try {
|
||||||
|
await DownloadUpdate();
|
||||||
|
toast.success(m.settings_toast_download_success());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to download update:", err);
|
||||||
|
toast.error(m.settings_toast_download_failed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installUpdate() {
|
||||||
|
try {
|
||||||
|
await InstallUpdate(true); // true = quit after launch
|
||||||
|
// App will quit, so no toast needed
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to install update:", err);
|
||||||
|
toast.error(m.settings_toast_install_failed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for update status events
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
EventsOn("update:status", (status: UpdateStatus) => {
|
||||||
|
updateStatus = status;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
EventsOff("update:status");
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-[calc(100vh-1rem)] from-background to-muted/30">
|
<div class="min-h-[calc(100vh-1rem)] bg-linear-to-b from-background to-muted/30">
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 sm:px-6 sm:py-10 opacity-80"
|
class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 sm:px-6 sm:py-10 opacity-80"
|
||||||
>
|
>
|
||||||
@@ -244,6 +467,162 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title>{m.settings_appearance_title()}</Card.Title>
|
||||||
|
<Card.Description>{m.settings_appearance_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<RadioGroup.Root
|
||||||
|
bind:value={form.theme}
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item
|
||||||
|
value="light"
|
||||||
|
id="theme-light"
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="theme-light"
|
||||||
|
class="flex items-center gap-2 cursor-pointer hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<Sun class="size-4" />
|
||||||
|
{m.settings_theme_light()}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item
|
||||||
|
value="dark"
|
||||||
|
id="theme-dark"
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="theme-dark"
|
||||||
|
class="flex items-center gap-2 cursor-pointer hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<Moon class="size-4" />
|
||||||
|
{m.settings_theme_dark()}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
<div class="text-xs text-muted-foreground mt-4">
|
||||||
|
<strong>Info:</strong>
|
||||||
|
{m.settings_theme_hint()}
|
||||||
|
</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_reduce_motion_label()}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_reduce_motion_hint()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
bind:checked={form.reduceMotion}
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">
|
||||||
|
{m.settings_reduce_motion_info()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{m.settings_window_buttons_contrast_label()}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_window_buttons_contrast_hint()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
bind:checked={form.increaseWindowButtonsContrast}
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{m.settings_email_dark_viewer_label()}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_email_dark_viewer_hint()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
bind:checked={form.useDarkEmailViewer}
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">
|
||||||
|
{m.settings_email_dark_viewer_info()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title>{m.settings_export_import_title()}</Card.Title>
|
||||||
|
<Card.Description>{m.settings_export_import_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{m.settings_export_button()}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_export_hint()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
onclick={exportSettings}
|
||||||
|
>
|
||||||
|
<Download class="size-4 mr-2" />
|
||||||
|
{m.settings_export_button()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{m.settings_import_button()}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_import_hint()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
onclick={importSettings}
|
||||||
|
>
|
||||||
|
<Upload class="size-4 mr-2" />
|
||||||
|
{m.settings_import_button()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="space-y-1">
|
<Card.Header class="space-y-1">
|
||||||
<Card.Title>{m.settings_preview_page_title()}</Card.Title>
|
<Card.Title>{m.settings_preview_page_title()}</Card.Title>
|
||||||
@@ -367,9 +746,200 @@
|
|||||||
{m.settings_preview_pdf_builtin_info()}
|
{m.settings_preview_pdf_builtin_info()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Update Section -->
|
||||||
|
{#if form.enableUpdateChecker}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="space-y-1">
|
||||||
|
<Card.Title>{m.settings_updates_title()}</Card.Title>
|
||||||
|
<Card.Description>{m.settings_updates_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<!-- Current Version -->
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{m.settings_updates_current_version()}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{updateStatus.currentVersion} ({config?.GUIReleaseChannel || "stable"})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if updateStatus.updateAvailable}
|
||||||
|
<div class="flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400">
|
||||||
|
<AlertCircle class="size-4" />
|
||||||
|
{m.settings_updates_available()}
|
||||||
|
</div>
|
||||||
|
{:else if updateStatus.errorMessage && updateStatus.lastCheckTime}
|
||||||
|
<div class="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle class="size-4" />
|
||||||
|
{m.settings_updates_check_failed()}
|
||||||
|
</div>
|
||||||
|
{:else if updateStatus.lastCheckTime}
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<CheckCircle2 class="size-4" />
|
||||||
|
{m.settings_updates_no_updates()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Check for Updates -->
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{m.settings_updates_check_label()}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{#if updateStatus.lastCheckTime}
|
||||||
|
{m.settings_updates_last_checked({ time: updateStatus.lastCheckTime })}
|
||||||
|
{:else}
|
||||||
|
{m.settings_updates_click_check()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
onclick={checkForUpdates}
|
||||||
|
disabled={updateStatus.checking || updateStatus.downloading}
|
||||||
|
>
|
||||||
|
<RefreshCw class="size-4 mr-2 {updateStatus.checking ? 'animate-spin' : ''}" />
|
||||||
|
{updateStatus.checking ? m.settings_updates_checking() : m.settings_updates_check_now()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Update (shown when update available) -->
|
||||||
|
{#if updateStatus.updateAvailable && !updateStatus.ready}
|
||||||
|
<Separator />
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{m.settings_updates_version_available({ version: updateStatus.availableVersion })}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{#if updateStatus.downloading}
|
||||||
|
{m.settings_updates_downloading({ progress: updateStatus.downloadProgress })}
|
||||||
|
{:else}
|
||||||
|
{m.settings_updates_click_download()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if updateStatus.releaseNotes}
|
||||||
|
<div class="text-xs text-muted-foreground mt-2">
|
||||||
|
{updateStatus.releaseNotes}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
onclick={downloadUpdate}
|
||||||
|
disabled={updateStatus.downloading}
|
||||||
|
>
|
||||||
|
<Download class="size-4 mr-2" />
|
||||||
|
{updateStatus.downloading ? `${updateStatus.downloadProgress}%` : m.settings_updates_download_button()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Install Update (shown when download ready) -->
|
||||||
|
{#if updateStatus.ready}
|
||||||
|
<Separator />
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-lg border border-green-500/30 bg-green-500/10 p-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{m.settings_updates_ready_title()}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_updates_ready_ref({ version: updateStatus.availableVersion })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
class="cursor-pointer hover:cursor-pointer bg-green-600 hover:bg-green-700"
|
||||||
|
onclick={installUpdate}
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="size-4 mr-2" />
|
||||||
|
{m.settings_updates_install_button()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if updateStatus.errorMessage}
|
||||||
|
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<AlertCircle class="size-4 text-destructive mt-0.5" />
|
||||||
|
<div class="text-sm text-destructive">
|
||||||
|
{updateStatus.errorMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Info about update path -->
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
<strong>Info:</strong> {m.settings_updates_info_message()}
|
||||||
|
{#if (config as any)?.UpdatePath}
|
||||||
|
{m.settings_updates_current_path()} <code class="text-xs bg-muted px-1 py-0.5 rounded">{(config as any).UpdatePath}</code>
|
||||||
|
{:else}
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">{m.settings_updates_no_path()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $dangerZoneEnabled || dev}
|
{#if $dangerZoneEnabled || dev}
|
||||||
<Card.Root class="border-destructive/50 bg-destructive/15">
|
<Card.Root class="border-destructive/50 bg-destructive/15">
|
||||||
<Card.Header class="space-y-1">
|
<Card.Header class="space-y-1">
|
||||||
@@ -485,6 +1055,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4 border-destructive/30"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label class="text-sm">{m.settings_danger_update_checker_label()}</Label>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{m.settings_danger_update_checker_hint()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
bind:checked={form.enableUpdateChecker}
|
||||||
|
class="cursor-pointer hover:cursor-pointer"
|
||||||
|
disabled={runningInDevMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{m.settings_danger_update_checker_info()}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
GUI: {config
|
GUI: {config
|
||||||
? `${config.GUISemver} (${config.GUIReleaseChannel})`
|
? `${config.GUISemver} (${config.GUIReleaseChannel})`
|
||||||
@@ -498,6 +1088,7 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !runningInDevMode}
|
||||||
<AlertDialog.Root bind:open={dangerWarningOpen}>
|
<AlertDialog.Root bind:open={dangerWarningOpen}>
|
||||||
<AlertDialog.Content>
|
<AlertDialog.Content>
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
@@ -505,7 +1096,11 @@
|
|||||||
>{m.settings_danger_alert_title()}</AlertDialog.Title
|
>{m.settings_danger_alert_title()}</AlertDialog.Title
|
||||||
>
|
>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
{m.settings_danger_alert_description()}
|
{m.settings_danger_alert_description_part1()}
|
||||||
|
<br />
|
||||||
|
{m.settings_danger_alert_description_part2()}
|
||||||
|
<br />
|
||||||
|
{m.settings_danger_alert_description_part3()}
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
@@ -515,5 +1110,6 @@
|
|||||||
</AlertDialog.Footer>
|
</AlertDialog.Footer>
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import type { PageLoad } from './$types';
|
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 { browser } from '$app/environment';
|
||||||
import { dangerZoneEnabled } from "$lib/stores/app";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
|
|
||||||
export const load = (async () => {
|
export const load = (async () => {
|
||||||
if (!browser) return { machineData: null, config: null };
|
if (!browser) return { config: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [machineData, configRoot] = await Promise.all([
|
const configRoot = await GetConfig();
|
||||||
get(dangerZoneEnabled) ? GetMachineData() : Promise.resolve(null),
|
|
||||||
GetConfig()
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
machineData,
|
|
||||||
config: configRoot.EMLy
|
config: configRoot.EMLy
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load settings data", e);
|
console.error("Failed to load settings data", e);
|
||||||
return {
|
return {
|
||||||
machineData: null,
|
|
||||||
config: null
|
config: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { setupConsoleLogger } from '$lib/utils/logger-hook';
|
import { setupConsoleLogger } from '$lib/utils/logger-hook';
|
||||||
|
import "./layout.css";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,8 @@
|
|||||||
<style>
|
<style>
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #000;
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +82,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar {
|
.titlebar {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: #000;
|
background: var(--background);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -94,13 +97,13 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
flex: 0 0 32px;
|
flex: 0 0 32px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -114,7 +117,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
@@ -124,16 +127,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
background: #e81123;
|
background: #e81123;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: var(--background);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
AlignHorizontalSpaceAround,
|
AlignHorizontalSpaceAround,
|
||||||
|
Download
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { sidebarOpen } from "$lib/stores/app";
|
import { sidebarOpen } from "$lib/stores/app";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
@@ -84,6 +85,17 @@
|
|||||||
fitToScreen();
|
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) {
|
function handleWheel(e: WheelEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = -e.deltaY * 0.001;
|
const delta = -e.deltaY * 0.001;
|
||||||
@@ -116,6 +128,10 @@
|
|||||||
<h1 class="title" title={filename}>{filename || "Image Viewer"}</h1>
|
<h1 class="title" title={filename}>{filename || "Image Viewer"}</h1>
|
||||||
|
|
||||||
<div class="controls">
|
<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">
|
<button class="btn" onclick={() => zoom(0.1)} title="Zoom In">
|
||||||
<ZoomIn size="16" />
|
<ZoomIn size="16" />
|
||||||
</button>
|
</button>
|
||||||
@@ -177,7 +193,7 @@
|
|||||||
<style>
|
<style>
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #000;
|
background: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
@@ -185,15 +201,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: var(--background);
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--card);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -221,7 +237,7 @@
|
|||||||
.separator {
|
.separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: var(--border);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,21 +249,21 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--muted);
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: var(--foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--accent-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-area {
|
.image-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: var(--muted);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -275,16 +291,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--muted-foreground);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #f87171;
|
color: var(--destructive);
|
||||||
background: rgba(248, 113, 113, 0.1);
|
background: var(--destructive-foreground);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
border: 1px solid var(--destructive);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
Quit,
|
Quit,
|
||||||
} from "$lib/wailsjs/runtime/runtime";
|
} from "$lib/wailsjs/runtime/runtime";
|
||||||
import type { LayoutProps } from "./$types";
|
import type { LayoutProps } from "./$types";
|
||||||
|
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
||||||
|
|
||||||
let { data, children }: LayoutProps = $props();
|
let { data, children }: LayoutProps = $props();
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
>
|
>
|
||||||
<div class="title">EMLy PDF Viewer</div>
|
<div class="title">EMLy PDF Viewer</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls" class:high-contrast={settingsStore.settings.increaseWindowButtonsContrast}>
|
||||||
<button class="btn" onclick={minimize}>─</button>
|
<button class="btn" onclick={minimize}>─</button>
|
||||||
<button class="btn" onclick={toggleMaximize}>
|
<button class="btn" onclick={toggleMaximize}>
|
||||||
{#if isMaximized}
|
{#if isMaximized}
|
||||||
@@ -79,7 +80,8 @@
|
|||||||
<style>
|
<style>
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #000;
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +90,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar {
|
.titlebar {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: #000;
|
background: var(--background);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -101,13 +105,14 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
flex: 0 0 32px;
|
flex: 0 0 32px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
color: white;
|
color: var(--muted-foreground);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -116,29 +121,34 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls.high-contrast {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
background: #e81123;
|
background: #e81123;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -147,6 +157,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111;
|
background: var(--background);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import {
|
import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
AlignHorizontalSpaceAround,
|
AlignHorizontalSpaceAround,
|
||||||
|
Download
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { sidebarOpen } from "$lib/stores/app";
|
import { sidebarOpen } from "$lib/stores/app";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
import * as m from "$lib/paraglide/messages.js";
|
||||||
import * as pdfjsLib from "pdfjs-dist";
|
import * as pdfjsLib from "pdfjs-dist";
|
||||||
import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
|
|
||||||
@@ -63,9 +65,8 @@
|
|||||||
|
|
||||||
await loadPDF();
|
await loadPDF();
|
||||||
} else {
|
} else {
|
||||||
toast.error("No PDF data provided");
|
toast.error(m.pdf_error_no_data());
|
||||||
error =
|
error = m.pdf_error_no_data_desc();
|
||||||
"No PDF data provided. Please open this window from the main EMLy application.";
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -81,8 +82,7 @@
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading = false;
|
loading = false;
|
||||||
error =
|
error = m.pdf_error_timeout();
|
||||||
"Timeout loading PDF. The worker might have failed to initialize.";
|
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
error = "Error parsing PDF: " + e;
|
error = m.pdf_error_parsing() + e;
|
||||||
loading = false;
|
loading = false;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -107,7 +107,13 @@
|
|||||||
if (!pdfDoc || !canvasRef) return;
|
if (!pdfDoc || !canvasRef) return;
|
||||||
|
|
||||||
if (renderTask) {
|
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 {
|
try {
|
||||||
@@ -131,11 +137,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Cast to any to avoid type mismatch with PDF.js definitions
|
// 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) {
|
} catch (e: any) {
|
||||||
if (e.name !== "RenderingCancelledException") {
|
if (e.name !== "RenderingCancelledException") {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Error rendering page: " + e.message);
|
toast.error(m.pdf_error_rendering() + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,11 +164,15 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Re-render when scale or rotation changes
|
// Re-render when scale or rotation changes
|
||||||
// Access them here to ensure dependency tracking since renderPage is async
|
// Access them here to ensure dependency tracking since renderPage is untracked
|
||||||
const _deps = [scale, rotation];
|
// 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) {
|
if (pdfDoc) {
|
||||||
renderPage(pageNum);
|
// Untrack renderPage because it reads and writes to renderTask,
|
||||||
|
// which would otherwise cause an infinite loop.
|
||||||
|
untrack(() => renderPage(pageNum));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,13 +195,31 @@
|
|||||||
pageNum--;
|
pageNum--;
|
||||||
renderPage(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>
|
</script>
|
||||||
|
|
||||||
<div class="viewer-container">
|
<div class="viewer-container">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading-overlay">
|
<div class="loading-overlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<div>Loading PDF...</div>
|
<div>{m.pdf_loading()}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -200,24 +230,28 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h1 class="title" title={filename}>{filename || "Image Viewer"}</h1>
|
<h1 class="title" title={filename}>{filename || m.pdf_viewer_title()}</h1>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="btn" onclick={() => zoom(0.1)} title="Zoom In">
|
<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" />
|
<ZoomIn size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" onclick={() => zoom(-0.1)} title="Zoom Out">
|
<button class="btn" onclick={() => zoom(-0.1)} title={m.pdf_zoom_out()}>
|
||||||
<ZoomOut size="16" />
|
<ZoomOut size="16" />
|
||||||
</button>
|
</button>
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
<button class="btn" onclick={() => rotate(-90)} title="Rotate Left">
|
<button class="btn" onclick={() => rotate(-90)} title={m.pdf_rotate_left()}>
|
||||||
<RotateCcw size="16" />
|
<RotateCcw size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" onclick={() => rotate(90)} title="Rotate Right">
|
<button class="btn" onclick={() => rotate(90)} title={m.pdf_rotate_right()}>
|
||||||
<RotateCw size="16" />
|
<RotateCw size="16" />
|
||||||
</button>
|
</button>
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
<button class="btn" onclick={fitToWidth} title="Reset">
|
<button class="btn" onclick={fitToWidth} title={m.pdf_fit_width()}>
|
||||||
<AlignHorizontalSpaceAround size="16" />
|
<AlignHorizontalSpaceAround size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,9 +267,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #1e1e1e;
|
background: var(--background);
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-overlay {
|
.loading-overlay {
|
||||||
@@ -247,15 +282,15 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #111;
|
background: var(--background);
|
||||||
color: white;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid var(--border);
|
||||||
border-top-color: rgba(255, 255, 255, 0.8);
|
border-top-color: var(--primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -273,14 +308,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #111;
|
background: var(--background);
|
||||||
color: #ef4444;
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
background: #000;
|
background: var(--card);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -308,7 +343,7 @@
|
|||||||
.separator {
|
.separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: var(--border);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,22 +355,16 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--muted);
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: var(--foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--accent-foreground);
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
width: 1px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
@@ -345,7 +374,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start; /* scroll from top */
|
align-items: flex-start; /* scroll from top */
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: #333; /* Dark background for contrast */
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
@@ -363,12 +392,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
#define ApplicationName 'EMLy'
|
#define ApplicationName 'EMLy'
|
||||||
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
|
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
|
||||||
#define ApplicationVersion '1.2.4_beta'
|
#define ApplicationVersion '1.5.4_beta'
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||||
|
|
||||||
|
[CustomMessages]
|
||||||
|
; English messages
|
||||||
|
english.UpgradeDetected=A previous version of {#ApplicationName} (v%1) has been detected.
|
||||||
|
english.UpgradeMessage=This installer will upgrade your installation to version {#ApplicationVersion}.%n%nYour settings and preferences will be preserved.%n%nDo you want to continue?
|
||||||
|
english.FreshInstall=Welcome to {#ApplicationName} {#ApplicationVersion} Setup
|
||||||
|
english.FreshInstallMessage=This will install {#ApplicationName} on your computer.
|
||||||
|
|
||||||
|
; Italian messages
|
||||||
|
italian.UpgradeDetected=È stata rilevata una versione precedente di {#ApplicationName} (v%1).
|
||||||
|
italian.UpgradeMessage=Questo installer aggiornerà la tua installazione alla versione {#ApplicationVersion}.%n%nLe tue impostazioni e preferenze saranno preservate.%n%nVuoi continuare?
|
||||||
|
italian.FreshInstall=Benvenuto nell'installazione di {#ApplicationName} {#ApplicationVersion}
|
||||||
|
italian.FreshInstallMessage=Questo installerà {#ApplicationName} sul tuo computer.
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
AppName={#ApplicationName}
|
AppName={#ApplicationName}
|
||||||
AppVersion={#ApplicationVersion}
|
AppVersion={#ApplicationVersion}
|
||||||
|
; Default directory (will be adjusted in code based on installation mode)
|
||||||
|
; Admin mode: C:\Program Files\EMLy
|
||||||
|
; User mode: C:\Users\{username}\AppData\Local\Programs\EMLy
|
||||||
DefaultDirName={autopf}\EMLy
|
DefaultDirName={autopf}\EMLy
|
||||||
OutputBaseFilename={#ApplicationName}_Installer_{#ApplicationVersion}
|
OutputBaseFilename={#ApplicationName}_Installer_{#ApplicationVersion}
|
||||||
ArchitecturesInstallIn64BitMode=x64compatible
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
; Request administrative privileges for HKA to write to HKLM if needed,
|
; Allow user to choose between admin (system-wide) and user-only install
|
||||||
; or use "lowest" if purely per-user, but file associations usually work better with admin rights or proper HKA handling.
|
; "lowest" = does not require admin privileges by default (user mode)
|
||||||
PrivilegesRequired=admin
|
; "dialog" = shows a dialog asking user to choose installation mode
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
SetupIconFile=..\build\windows\icon.ico
|
SetupIconFile=..\build\windows\icon.ico
|
||||||
UninstallDisplayIcon={app}\{#ApplicationName}.exe
|
UninstallDisplayIcon={app}\{#ApplicationName}.exe
|
||||||
AppVerName={#ApplicationName} {#ApplicationVersion}
|
AppVerName={#ApplicationName} {#ApplicationVersion}
|
||||||
|
WizardStyle=modern dynamic includetitlebar
|
||||||
|
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
; Source path relative to this .iss file (assuming it is in the "installer" folder and build is in "../build")
|
; Source path relative to this .iss file (assuming it is in the "installer" folder and build is in "../build")
|
||||||
@@ -22,6 +46,12 @@ Source: "..\build\bin\{#ApplicationName}.exe"; DestDir: "{app}"; Flags: ignoreve
|
|||||||
Source: "..\build\bin\config.ini"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\build\bin\config.ini"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
|
||||||
[Registry]
|
[Registry]
|
||||||
|
; File associations using HKA (HKEY_AUTO) registry root
|
||||||
|
; HKA automatically selects the appropriate registry hive:
|
||||||
|
; - HKLM (HKEY_LOCAL_MACHINE) for admin/system-wide installations
|
||||||
|
; - HKCU (HKEY_CURRENT_USER) for user-only installations
|
||||||
|
; This ensures file associations work correctly for both installation modes
|
||||||
|
|
||||||
; 1. Register the .eml extension and point it to our internal ProgID "EMLy.EML"
|
; 1. Register the .eml extension and point it to our internal ProgID "EMLy.EML"
|
||||||
Root: HKA; Subkey: "Software\Classes\.eml"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName}.EML"; Flags: uninsdeletevalue
|
Root: HKA; Subkey: "Software\Classes\.eml"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName}.EML"; Flags: uninsdeletevalue
|
||||||
Root: HKA; Subkey: "Software\Classes\.msg"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName}.MSG"; Flags: uninsdeletevalue
|
Root: HKA; Subkey: "Software\Classes\.msg"; ValueType: string; ValueName: ""; ValueData: "{#ApplicationName}.MSG"; Flags: uninsdeletevalue
|
||||||
@@ -44,3 +74,103 @@ Root: HKA; Subkey: "Software\Classes\{#ApplicationName}.MSG\shell\open"; ValueTy
|
|||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{autoprograms}\{#ApplicationName}"; Filename: "{app}\{#ApplicationName}.exe"
|
Name: "{autoprograms}\{#ApplicationName}"; Filename: "{app}\{#ApplicationName}.exe"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
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
|
||||||
|
RegPath: String;
|
||||||
|
Version: String;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
|
||||||
|
// Check HKLM (system-wide installation)
|
||||||
|
RegPath := 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#ApplicationName}_is1';
|
||||||
|
if RegQueryStringValue(HKLM, RegPath, 'DisplayVersion', Version) then
|
||||||
|
begin
|
||||||
|
Result := Version;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Check HKCU (user installation)
|
||||||
|
if RegQueryStringValue(HKCU, RegPath, 'DisplayVersion', Version) then
|
||||||
|
begin
|
||||||
|
Result := Version;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Initialize setup and detect upgrade
|
||||||
|
function InitializeSetup(): Boolean;
|
||||||
|
var
|
||||||
|
Message: String;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
PreviousVersion := GetPreviousVersion();
|
||||||
|
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 +
|
||||||
|
CustomMessage('UpgradeMessage');
|
||||||
|
|
||||||
|
if MsgBox(Message, mbInformation, MB_YESNO) = IDNO then
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Show appropriate welcome message
|
||||||
|
procedure InitializeWizard();
|
||||||
|
begin
|
||||||
|
if not IsUpgrade then
|
||||||
|
begin
|
||||||
|
WizardForm.WelcomeLabel2.Caption := CustomMessage('FreshInstallMessage');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Override default directory based on installation mode
|
||||||
|
function GetDefaultDirName(Param: string): string;
|
||||||
|
begin
|
||||||
|
// If installing with admin privileges (system-wide), use Program Files
|
||||||
|
if IsAdminInstallMode then
|
||||||
|
Result := ExpandConstant('{autopf}\{#ApplicationName}')
|
||||||
|
// If installing for current user only, use AppData\Local\Programs
|
||||||
|
else
|
||||||
|
Result := ExpandConstant('{localappdata}\Programs\{#ApplicationName}');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurPageChanged(CurPageID: Integer);
|
||||||
|
begin
|
||||||
|
// Update the directory when the directory selection page is shown
|
||||||
|
if CurPageID = wpSelectDir then
|
||||||
|
begin
|
||||||
|
// Only set if user hasn't manually changed it
|
||||||
|
if WizardForm.DirEdit.Text = ExpandConstant('{autopf}\{#ApplicationName}') then
|
||||||
|
WizardForm.DirEdit.Text := GetDefaultDirName('');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|||||||
14
installer/sample_version.json
Normal file
14
installer/sample_version.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"stableVersion": "1.4.0",
|
||||||
|
"betaVersion": "1.4.1-beta",
|
||||||
|
"stableDownload": "EMLy_Installer_1.4.0.exe",
|
||||||
|
"betaDownload": "EMLy_Installer_1.4.1-beta.exe",
|
||||||
|
"sha256Checksums": {
|
||||||
|
"EMLy_Installer_1.4.0.exe": "put_sha256_hash_here",
|
||||||
|
"EMLy_Installer_1.4.1-beta.exe": "put_sha256_hash_here"
|
||||||
|
},
|
||||||
|
"releaseNotes": {
|
||||||
|
"1.4.0": "Initial release with self-hosted update system",
|
||||||
|
"1.4.1-beta": "Beta release with bug fixes"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
main.go
56
main.go
@@ -4,11 +4,14 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +36,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer CloseLogger()
|
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
|
// Check for custom args
|
||||||
args := os.Args
|
args := os.Args
|
||||||
uniqueId := "emly-app-lock"
|
uniqueId := "emly-app-lock"
|
||||||
@@ -74,6 +83,49 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create application with options
|
// 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{
|
err := wails.Run(&options.App{
|
||||||
Title: windowTitle,
|
Title: windowTitle,
|
||||||
Width: windowWidth,
|
Width: windowWidth,
|
||||||
@@ -94,6 +146,10 @@ func main() {
|
|||||||
MinWidth: 964,
|
MinWidth: 964,
|
||||||
MinHeight: 690,
|
MinHeight: 690,
|
||||||
Frameless: frameless,
|
Frameless: frameless,
|
||||||
|
Windows: &windows.Options{
|
||||||
|
WebviewUserDataPath: userDataPath,
|
||||||
|
WebviewBrowserPath: "", // Empty = use system Edge WebView2
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user